[
  {
    "path": ".claude/CLAUDE.md",
    "content": "@../AGENTS.md\n"
  },
  {
    "path": ".dockerignore",
    "content": "# See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files.\n\n# Ignore git directory.\n/.git/\n\n# Ignore bundler config.\n/.bundle\n\n# Ignore documentation\n/docs/\n/README.md\n/CLAUDE.md\n/AGENTS.md\n/STYLE.md\n/CONTRIBUTING.md\n\n# Ignore all environment files (except templates).\n/.env*\n!/.env*.erb\n\n# Ignore all default key files.\n/config/master.key\n/config/credentials/*.key\n\n# Ignore all logfiles and tempfiles.\n/log/*\n/tmp/*\n!/log/.keep\n!/tmp/.keep\n\n# Ignore pidfiles, but keep the directory.\n/tmp/pids/*\n!/tmp/pids/.keep\n\n# Ignore storage (uploaded files in development and any SQLite databases).\n/storage/*\n!/storage/.keep\n/tmp/storage/*\n!/tmp/storage/.keep\n\n# Ignore assets.\n/node_modules/\n/app/assets/builds/*\n!/app/assets/builds/.keep\n/public/assets\n"
  },
  {
    "path": ".gitattributes",
    "content": "# See https://git-scm.com/docs/gitattributes for more about git attribute files.\n\n# Mark the database schema as having been generated.\ndb/schema.rb linguist-generated\n\n# Mark any vendored files as having been vendored.\nvendor/* linguist-vendored\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\ncontact_links:\n  - name: Features, Bug Reports, Questions\n    url: https://github.com/basecamp/fizzy/discussions/new/choose\n    about: Please use the discussions area to report issues or ask quest\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/preapproved.md",
    "content": "---\nname: Pre-Discussed and Approved Topics\nabout: |-\n  For topics already discussed and approved in the GitHub Discussions section.\n---\n\n** PLEASE START A DISCUSSION INSTEAD OF OPENING AN ISSUE **\n** For more details see CONTRIBUTING.md **\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\n\nregistries:\n  github-basecamp:\n    type: git\n    url: https://github.com\n    username: x-access-token\n    password: ${{secrets.FIZZY_GH_TOKEN}}\n\nupdates:\n  - package-ecosystem: bundler\n    registries:\n      - github-basecamp\n    directory: \"/\"\n    insecure-external-code-execution: allow # zizmor: ignore[dependabot-execution] -- required for Bundler to resolve gems from the private github-basecamp registry\n    open-pull-requests-limit: 10\n    vendor: false\n    groups:\n      development-dependencies:\n        dependency-type: \"development\"\n    schedule:\n      interval: weekly\n    cooldown:\n      default-days: 7\n      semver-major-days: 14\n  - package-ecosystem: github-actions\n    directory: \"/\"\n    groups:\n      github-actions:\n        patterns:\n          - \"*\"\n    schedule:\n      interval: weekly\n    open-pull-requests-limit: 10\n    cooldown:\n      default-days: 7\n"
  },
  {
    "path": ".github/workflows/ci-checks.yml",
    "content": "name: Checks\n\non:\n  pull_request:\n\npermissions:\n  contents: read\n\njobs:\n  gemfile-drift:\n    name: Gemfile drift\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n      - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0\n        with:\n          ruby-version: .ruby-version\n          bundler-cache: true\n\n      - name: Check for lockfile drift\n        run: bin/bundle-drift check\n\n  security:\n    name: Security\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n      - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0\n        with:\n          ruby-version: .ruby-version\n          bundler-cache: true\n\n      - name: Gem audit\n        run: bin/bundler-audit check --update\n\n      - name: Importmap audit\n        run: bin/importmap audit\n\n      - name: Brakeman audit\n        run: bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error\n\n  lint:\n    name: Lint\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n      - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0\n        with:\n          ruby-version: .ruby-version\n          bundler-cache: true\n\n      - name: Lint code for consistent style\n        run: bin/rubocop\n\n  zizmor:\n    name: GitHub Actions audit\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n\n      - name: Run zizmor\n        uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2\n        with:\n          advanced-security: false\n"
  },
  {
    "path": ".github/workflows/ci-oss.yml",
    "content": "name: CI (OSS)\n\non:\n  pull_request:\n    types: [opened, synchronize]\n\npermissions:\n  contents: read\n\njobs:\n  test:\n    if: github.event.pull_request.head.repo.full_name != github.repository\n    uses: ./.github/workflows/test.yml\n    with:\n      saas: false\n"
  },
  {
    "path": ".github/workflows/ci-saas.yml",
    "content": "name: CI (SaaS)\n\non:\n  push:\n\npermissions:\n  contents: read\n\njobs:\n  test_oss:\n    name: Test (OSS)\n    uses: ./.github/workflows/test.yml\n    with:\n      saas: false\n  test_saas:\n    name: Test (SaaS)\n    uses: ./.github/workflows/test.yml\n    with:\n      saas: true\n    secrets:\n      FIZZY_GH_TOKEN: ${{ secrets.FIZZY_GH_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/dependabot-sync-saas-lockfile.yml",
    "content": "name: Sync Gemfile.saas.lock\n\non:\n  push:\n    branches:\n      - \"dependabot/bundler/**\"\n    paths:\n      - Gemfile.lock\n\npermissions:\n  contents: write\n\njobs:\n  sync:\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 # zizmor: ignore[artipacked] -- credentials needed for git push\n\n      - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0\n        with:\n          ruby-version: .ruby-version\n\n      - name: Forward Gemfile.lock changes to Gemfile.saas.lock\n        run: bin/bundle-drift forward\n\n      - name: Commit updated lockfile\n        run: |\n          git add Gemfile.saas.lock\n          if ! git diff --cached --quiet; then\n            git config user.name \"github-actions[bot]\"\n            git config user.email \"github-actions[bot]@users.noreply.github.com\"\n            git commit -m \"Sync Gemfile.saas.lock\"\n            git push\n          fi\n"
  },
  {
    "path": ".github/workflows/publish-image.yml",
    "content": "name: Build and publish container image to GHCR\n\non:\n  push:\n    branches:\n      - main\n    tags:\n      - 'v*'\n  workflow_dispatch:\n\nconcurrency:\n  group: publish-${{ github.workflow }}-${{ github.ref }}\n  cancel-in-progress: true\n\nenv:\n  IMAGE_DESCRIPTION: Fizzy is Kanban as it should be. Not as it has been.\n  SOURCE_URL: https://github.com/${{ github.repository }}\n\njobs:\n  build:\n    name: Build and push image (${{ matrix.arch }})\n    runs-on: ${{ matrix.runner }}\n    timeout-minutes: 45\n    permissions:\n      contents: read\n      packages: write\n      id-token: write\n      attestations: write\n    strategy:\n      fail-fast: false\n      matrix:\n        include:\n          - runner: ubuntu-latest\n            platform: linux/amd64\n            arch: amd64\n          - runner: ubuntu-24.04-arm\n            platform: linux/arm64\n            arch: arm64\n    env:\n      REGISTRY: ghcr.io\n      IMAGE_NAME: ${{ github.repository }}\n    steps:\n      - name: Checkout\n        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0\n\n      - name: Log in to GHCR\n        uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0\n        with:\n          registry: ${{ env.REGISTRY }}\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Compute canonical image name (lowercase)\n        id: vars\n        shell: bash\n        run: |\n          set -eu\n          IMAGE_REF=\"${IMAGE_NAME:-$GITHUB_REPOSITORY}\"\n          CANONICAL_IMAGE=\"${REGISTRY}/${IMAGE_REF,,}\"\n          echo \"canonical=${CANONICAL_IMAGE}\" >> \"$GITHUB_OUTPUT\"\n\n      - name: Extract Docker metadata (tags, labels) with arch suffix\n        id: meta\n        uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0\n        with:\n          images: ${{ steps.vars.outputs.canonical }}\n          tags: |\n            type=ref,event=branch\n            type=ref,event=tag\n            type=sha,format=short,prefix=sha-\n            type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}\n            type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}\n            type=semver,pattern={{major}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}\n          flavor: |\n            latest=false\n            suffix=-${{ matrix.arch }}\n          labels: |\n            org.opencontainers.image.source=${{ env.SOURCE_URL }}\n\n      - name: Build and push (${{ matrix.platform }})\n        id: build\n        uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0\n        with:\n          context: .\n          file: Dockerfile\n          build-args: |\n            OCI_SOURCE=${{ env.SOURCE_URL }}\n            OCI_DESCRIPTION=${{ env.IMAGE_DESCRIPTION }}\n          platforms: ${{ matrix.platform }}\n          push: ${{ github.event_name != 'pull_request' }}\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          cache-from: type=gha,scope=${{ matrix.platform }}\n          cache-to: type=gha,scope=${{ matrix.platform }},mode=max\n          sbom: false\n          provenance: false\n\n      - name: Attest image provenance (per-arch)\n        if: github.event_name != 'pull_request'\n        uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0\n        with:\n          subject-name: ${{ steps.vars.outputs.canonical }}\n          subject-digest: ${{ steps.build.outputs.digest }}\n          push-to-registry: false\n\n  manifest:\n    name: Create multi-arch manifest and sign\n    needs: build\n    if: github.event_name != 'pull_request'\n    runs-on: ubuntu-latest\n    timeout-minutes: 20\n    permissions:\n      packages: write\n      id-token: write\n    env:\n      REGISTRY: ghcr.io\n      IMAGE_NAME: ${{ github.repository }}\n    steps:\n      - name: Set up Docker Buildx (for imagetools)\n        uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0\n\n      - name: Log in to GHCR\n        uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0\n        with:\n          registry: ${{ env.REGISTRY }}\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Compute canonical image name (lowercase)\n        id: vars\n        shell: bash\n        run: |\n          set -eu\n          IMAGE_REF=\"${IMAGE_NAME:-$GITHUB_REPOSITORY}\"\n          CANONICAL_IMAGE=\"${REGISTRY}/${IMAGE_REF,,}\"\n          echo \"canonical=${CANONICAL_IMAGE}\" >> \"$GITHUB_OUTPUT\"\n\n      - name: Compute base tags (no suffix)\n        id: meta\n        uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0\n        with:\n          images: ${{ steps.vars.outputs.canonical }}\n          tags: |\n            type=ref,event=branch\n            type=ref,event=tag\n            type=sha,format=short,prefix=sha-\n            type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}\n            type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}\n            type=semver,pattern={{major}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}\n            type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}\n          flavor: |\n            latest=false\n          labels: |\n            org.opencontainers.image.source=${{ env.SOURCE_URL }}\n\n      - name: Create multi-arch manifests\n        shell: bash\n        env:\n          TAGS: ${{ steps.meta.outputs.tags }}\n        run: |\n          set -eu\n          tags=\"$TAGS\"\n          echo \"Creating manifests for tags:\"\n          printf '%s\\n' \"$tags\"\n          while IFS= read -r tag; do\n            [ -z \"$tag\" ] && continue\n            echo \"Creating manifest for $tag\"\n            src_tag=\"$tag\"\n            if [[ \"$tag\" == *:latest && \"${GITHUB_REF}\" == refs/tags/* ]]; then\n              ref=\"${GITHUB_REF#refs/tags/}\"\n              src_tag=\"${tag%:latest}:$ref\"\n            fi\n            if [ -n \"${IMAGE_DESCRIPTION:-}\" ]; then\n              docker buildx imagetools create \\\n                --tag \"$tag\" \\\n                --annotation \"index:org.opencontainers.image.description=${IMAGE_DESCRIPTION}\" \\\n                \"${src_tag}-amd64\" \\\n                \"${src_tag}-arm64\"\n            else\n              docker buildx imagetools create \\\n                --tag \"$tag\" \\\n                \"${src_tag}-amd64\" \\\n                \"${src_tag}-arm64\"\n            fi\n          done <<< \"$tags\"\n\n      - name: Install Cosign\n        uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0\n\n      - name: Cosign sign all tags (keyless OIDC)\n        shell: bash\n        env:\n          TAGS: ${{ steps.meta.outputs.tags }}\n        run: |\n          set -eu\n          tags=\"$TAGS\"\n          printf '%s\\n' \"$tags\"\n          while IFS= read -r tag; do\n            [ -z \"$tag\" ] && continue\n            echo \"Signing $tag\"\n            cosign sign --yes \"$tag\"\n          done <<< \"$tags\"\n\n\n"
  },
  {
    "path": ".github/workflows/test.yml",
    "content": "name: Test\n\non:\n  workflow_call:\n    inputs:\n      saas:\n        type: boolean\n        required: true\n    secrets:\n      FIZZY_GH_TOKEN:\n        required: false\n\npermissions:\n  contents: read\n\njobs:\n  test:\n    name: Tests (${{ matrix.mode }})\n    runs-on: ubuntu-latest\n\n    strategy:\n      matrix:\n        include:\n          - mode: SQLite\n            db_adapter: sqlite\n          - mode: MySQL\n            db_adapter: mysql\n\n    services:\n      mysql:\n        image: mysql:8.0\n        env:\n          MYSQL_ALLOW_EMPTY_PASSWORD: yes\n          MYSQL_DATABASE: fizzy_test\n        ports:\n          - 3306:3306\n        options: >-\n          --health-cmd=\"mysqladmin ping\"\n          --health-interval=10s\n          --health-timeout=5s\n          --health-retries=3\n\n    env:\n      RAILS_ENV: test\n      DATABASE_ADAPTER: ${{ matrix.db_adapter }}\n      ${{ inputs.saas && 'SAAS' || 'SAAS_DISABLED' }}: ${{ inputs.saas && '1' || '' }}\n      BUNDLE_GEMFILE: ${{ inputs.saas && 'Gemfile.saas' || 'Gemfile' }}\n      MYSQL_HOST: 127.0.0.1\n      MYSQL_PORT: 3306\n      MYSQL_USER: root\n      FIZZY_DB_HOST: 127.0.0.1\n      FIZZY_DB_PORT: 3306\n      BUNDLE_GITHUB__COM: ${{ inputs.saas && format('x-access-token:{0}', secrets.FIZZY_GH_TOKEN) || '' }} # zizmor: ignore[secrets-outside-env]\n\n    steps:\n      - name: Install system packages\n        run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 libvips curl ffmpeg\n\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n\n      - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0\n        with:\n          ruby-version: .ruby-version\n          bundler-cache: true\n\n      - name: Run tests\n        run: bin/rails db:setup test\n\n      - name: Run system tests\n        run: bin/rails test:system\n"
  },
  {
    "path": ".gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files for more about ignoring files.\n#\n# If you find yourself ignoring temporary files generated by your text editor\n# or operating system, you probably want to add a global ignore instead:\n#   git config --global core.excludesfile '~/.gitignore_global'\n\n# Ignore bundler config.\n/.bundle\n\n# Ignore all environment files (except templates).\n/.env*\n!/.env*.erb\n\n# Ignore all logfiles and tempfiles.\n/log/*\n/tmp/*\n!/log/.keep\n!/tmp/.keep\n*.log\n\n# Ignore pidfiles, but keep the directory.\n/tmp/pids/*\n!/tmp/pids/\n!/tmp/pids/.keep\n\n# Ignore storage (uploaded files in development and any SQLite databases).\n/storage/*\n!/storage/.keep\n/tmp/storage/*\n!/tmp/storage/\n!/tmp/storage/.keep\n/data\n\n*.sqlite3\n*.sqlite3_*\n\n/public/assets\n\n# Ignore master key for decrypting credentials and more.\n/config/master.key\n\n/config/credentials/*.key\n.DS_Store\n"
  },
  {
    "path": ".gitleaks.toml",
    "content": "[extend]\nuseDefault = true\n\n[allowlist]\npaths = [\n  '''log''',\n  '''tmp''',\n  '''.*\\.yml\\.enc''',\n  '''docs/''',\n  '''test/''',\n]\n\n[[rules]]\nid = \"basecamp-integration-url\"\ndescription = \"Basecamp Integration URL\"\nregex = '''https://[^\\s]*?([0-9a-fA-F]{16,})'''\n[rules.allowlist]\nregexTarget = \"match\"\nregexes = ['''github\\.com''']\n"
  },
  {
    "path": ".gitleaksignore",
    "content": "d8463077:gems/fizzy-saas/bin/setup:generic-api-key:54\nc4073c1c:app/models/integration/basecamp.rb:generic-api-key:3\nc4073c1c:app/models/integration/basecamp.rb:generic-api-key:4\n"
  },
  {
    "path": ".mise.toml",
    "content": "[settings]\nidiomatic_version_file_enable_tools = [\"ruby\"]\n\n[env]\nPROMETHEUS_EXPORTER_URL = \"http://127.0.0.1:9306/metrics\"\n"
  },
  {
    "path": ".rubocop.yml",
    "content": "# Omakase Ruby styling for Rails\ninherit_gem: { rubocop-rails-omakase: rubocop.yml }\n\n# Overwrite or add rules to create your own house style\n#\n# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]`\n# Layout/SpaceInsideArrayLiteralBrackets:\n#   Enabled: false\n\nAllCops:\n  Exclude:\n    - 'db/migrate/**/*'\n    - 'db/schema*.rb'\n    - 'saas/db/migrate/**/*'\n    - 'saas/db/saas_schema.rb'\n"
  },
  {
    "path": ".ruby-version",
    "content": "3.4.7\n"
  },
  {
    "path": "AGENTS.md",
    "content": "# Fizzy\n\nThis file provides guidance to AI coding agents working with this repository.\n\n## What is Fizzy?\n\nFizzy is a collaborative project management and issue tracking application built by 37signals/Basecamp. It's a kanban-style tool for teams to create and manage cards (tasks/issues) across boards, organize work into columns representing workflow stages, and collaborate via comments, mentions, and assignments.\n\n## Development Commands\n\n### Setup and Server\n```bash\nbin/setup              # Initial setup (installs gems, creates DB, loads schema)\nbin/dev                # Start development server (runs on port 3006)\n```\n\nDevelopment URL: http://fizzy.localhost:3006\nLogin with: david@example.com (development fixtures), password will appear in the browser console\n\n### Testing\n```bash\nbin/rails test                    # Run unit tests (fast)\nbin/rails test test/path/file_test.rb  # Run single test file\nbin/rails test:system             # Run system tests (Capybara + Selenium)\nbin/ci                            # Run full CI suite (style, security, tests)\n\n# For parallel test execution issues, use:\nPARALLEL_WORKERS=1 bin/rails test\n```\n\nCI pipeline (`bin/ci`) runs:\n1. Rubocop (style)\n2. Bundler audit (gem security)\n3. Importmap audit\n4. Brakeman (security scan)\n5. Application tests\n6. System tests\n\n### Database\n```bash\nbin/rails db:fixtures:load   # Load fixture data\nbin/rails db:migrate          # Run migrations\nbin/rails db:reset            # Drop, create, and load schema\n```\n\n### Other Utilities\n```bash\nbin/rails dev:email          # Toggle letter_opener for email preview\nbin/jobs                     # Manage Solid Queue jobs\nbin/kamal deploy             # Deploy (requires 1Password CLI for secrets)\n```\n\n## Deploy\n\nDefault branch: `main`\nPre-deploy: `bin/rails saas:enable`\nDeploy: `bin/kamal deploy -d <destination>`\nDestinations: production, staging, beta, beta1, beta2, beta3, beta4\nNote: `beta` is a template requiring `BETA_NUMBER` env var; typical targets are `beta1`-`beta4`.\n\n## Architecture Overview\n\n### Multi-Tenancy (URL-Based)\n\nFizzy uses **URL path-based multi-tenancy**:\n- Each Account (tenant) has a unique `external_account_id` (7+ digits)\n- URLs are prefixed: `/{account_id}/boards/...`\n- Middleware (`AccountSlug::Extractor`) extracts the account ID from the URL and sets `Current.account`\n- The slug is moved from `PATH_INFO` to `SCRIPT_NAME`, making Rails think it's \"mounted\" at that path\n- All models include `account_id` for data isolation\n- Background jobs automatically serialize and restore account context\n\n**Key insight**: This architecture allows multi-tenancy without subdomains or separate databases, making local development and testing simpler.\n\n### Authentication & Authorization\n\n**Passwordless magic link authentication**:\n- Global `Identity` (email-based) can have `Users` in multiple Accounts\n- Users belong to an Account and have roles: owner, admin, member, system\n- Sessions managed via signed cookies\n- Board-level access control via `Access` records\n\n### Core Domain Models\n\n**Account** → The tenant/organization\n- Has users, boards, cards, tags, webhooks\n- Has entropy configuration for auto-postponement\n\n**Identity** → Global user (email)\n- Can have Users in multiple Accounts\n- Session management tied to Identity\n\n**User** → Account membership\n- Belongs to Account and Identity\n- Has role (owner/admin/member/system)\n- Board access via explicit `Access` records\n\n**Board** → Primary organizational unit\n- Has columns for workflow stages\n- Can be \"all access\" or selective\n- Can be published publicly with shareable key\n\n**Card** → Main work item (task/issue)\n- Sequential number within each Account\n- Rich text description and attachments\n- Lifecycle: triage → columns → closed/not_now\n- Automatically postpones after inactivity (\"entropy\")\n\n**Event** → Records all significant actions\n- Polymorphic association to changed object\n- Drives activity timeline, notifications, webhooks\n- Has JSON `particulars` for action-specific data\n\n### Entropy System\n\nCards automatically \"postpone\" (move to \"not now\") after inactivity:\n- Account-level default entropy period\n- Board-level entropy override\n- Prevents endless todo lists from accumulating\n- Configurable via Account/Board settings\n\n### UUID Primary Keys\n\nAll tables use UUIDs (UUIDv7 format, base36-encoded as 25-char strings):\n- Custom fixture UUID generation maintains deterministic ordering for tests\n- Fixtures are always \"older\" than runtime records\n- `.first`/`.last` work correctly in tests\n\n### Background Jobs (Solid Queue)\n\nDatabase-backed job queue (no Redis):\n- Custom `FizzyActiveJobExtensions` prepended to ActiveJob\n- Jobs automatically capture/restore `Current.account`\n- Mission Control::Jobs for monitoring\n\nKey recurring tasks (via `config/recurring.yml`):\n- Deliver bundled notifications (every 30 min)\n- Auto-postpone stale cards (hourly)\n- Cleanup jobs for expired links, deliveries\n\n### Sharded Full-Text Search\n\n16-shard MySQL full-text search instead of Elasticsearch:\n- Shards determined by account ID hash (CRC32)\n- Search records denormalized for performance\n- Models in `app/models/search/`\n\n### Imports and exports\n\nAllow people to move between OSS and SAAS Fizzy instances:\n- Exports/Imports can be written to/read from local or S3 storage depending on the config of the instance (both must be supported)\n- Must be able to handle very large ZIP files (500+GB)\n- Models in `app/models/account/data_transfer/`, `app/models/zip_file`\n\n## Tools\n\n### Chrome MCP (Local Dev)\n\nURL: `http://fizzy.localhost:3006`\nLogin: david@example.com (passwordless magic link auth - check rails console for link)\n\nUse Chrome MCP tools to interact with the running dev app for UI testing and debugging.\n\n## Coding style\n\n@STYLE.md\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# How to contribute to Fizzy\n\nFizzy uses GitHub\n[discussions](https://github.com/basecamp/fizzy/discussions) to track\nfeature requests and questions, rather than [the issue\ntracker](https://github.com/basecamp/fizzy/issues). If you're considering\nopening an issue or pull request, please open a discussion instead.\n\nWhenever a discussion leads to an actionable and well-understood task, we'll\nmove it to the issue tracker where it can be worked on.\n\nThis is a little different than how some other projects work, but it makes it\neasier for us to triage and prioritise the work. It also means that the open\nissues all represent agreed-upon tasks that are either being worked on, or are\nready to be worked on.\n\nThis should also make it easier to see what's in progress, and to find\nsomething to work on if you'd like to do so.\n\n## What this means in practice\n\n### If you'd like to contribute to the code...\n\n1. If you're interested in working on one of the open issues, please do! We are\n   grateful for the help!\n2. You'll want to make sure someone else isn't already working on the same\n   issue. If they are, it will be tagged \"in progress\" and/or it should be clear\n   from the comments. When in doubt, you can always comment on the issue to ask.\n3. Similarly, if you need any help or guidance on the issue, please comment on\n   the issue as you go, and we'll do our best to help.\n4. When you have something ready for review or collaboration, open a PR.\n\n### If you've found a bug...\n\n1. If you don't have steps to reproduce the problem, or you're not certain it's a\n   bug, open a discussion.\n2. If you have steps to reproduce, open an issue.\n\n### If you have an idea for a feature...\n\n1. Open a discussion.\n\n### If you have a question, or are having trouble with configuration...\n\n1. Open a discussion.\n\nHopefully this process makes it easier for everyone to be involved. Thanks for\nhelping! ❤️\n\n"
  },
  {
    "path": "Dockerfile",
    "content": "# syntax=docker/dockerfile:1\n# check=error=true\n\n# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand:\n# docker build -t fizzy .\n# docker run -d -p 80:80 -e RAILS_MASTER_KEY=<value from config/master.key> --name fizzy fizzy\n\n# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html\n\n# Make sure RUBY_VERSION matches the Ruby version in .ruby-version\nARG RUBY_VERSION=3.4.7\nFROM docker.io/library/ruby:$RUBY_VERSION-slim AS base\n\n# Rails app lives here\nWORKDIR /rails\n\n# Install base packages\nRUN apt-get update -qq && \\\n    apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 libssl-dev && \\\n    ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \\\n    rm -rf /var/lib/apt/lists /var/cache/apt/archives\n\n# Set production environment variables and enable jemalloc for reduced memory usage and latency.\nENV RAILS_ENV=\"production\" \\\n    BUNDLE_DEPLOYMENT=\"1\" \\\n    BUNDLE_PATH=\"/usr/local/bundle\" \\\n    BUNDLE_WITHOUT=\"development:test\" \\\n    LD_PRELOAD=\"/usr/local/lib/libjemalloc.so\"\n\n# Throw-away build stage to reduce size of final image\nFROM base AS build\n\n# Install packages needed to build gems\nRUN apt-get update -qq && \\\n    apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config && \\\n    rm -rf /var/lib/apt/lists /var/cache/apt/archives\n\n# Install application gems\nCOPY Gemfile Gemfile.lock vendor ./\n\nRUN bundle install && \\\n    rm -rf ~/.bundle/ \"${BUNDLE_PATH}\"/ruby/*/cache \"${BUNDLE_PATH}\"/ruby/*/bundler/gems/*/.git && \\\n    # -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495\n    bundle exec bootsnap precompile -j 1 --gemfile\n\n# Copy application code\nCOPY . .\n\n# Precompile bootsnap code for faster boot times.\n# -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495\nRUN bundle exec bootsnap precompile -j 1 app/ lib/\n\n# Precompiling assets for production without requiring secret RAILS_MASTER_KEY\nRUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile\n\n\n\n\n# Final stage for app image\nFROM base\n\n# Image metadata\nARG OCI_DESCRIPTION\nLABEL org.opencontainers.image.description=\"${OCI_DESCRIPTION}\"\nARG OCI_SOURCE\nLABEL org.opencontainers.image.source=\"${OCI_SOURCE}\"\nLABEL org.opencontainers.image.licenses=\"O'Saasy\"\n\n# Run and own only the runtime files as a non-root user for security\nRUN groupadd --system --gid 1000 rails && \\\n    useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash\nUSER 1000:1000\n\n# Copy built artifacts: gems, application\nCOPY --chown=rails:rails --from=build \"${BUNDLE_PATH}\" \"${BUNDLE_PATH}\"\nCOPY --chown=rails:rails --from=build /rails /rails\n\n# Entrypoint prepares the database.\nENTRYPOINT [\"/rails/bin/docker-entrypoint\"]\n\n# Start server via Thruster by default, this can be overwritten at runtime\nEXPOSE 80\nCMD [\"./bin/thrust\", \"./bin/rails\", \"server\"]\n"
  },
  {
    "path": "Gemfile",
    "content": "source \"https://rubygems.org\"\n\ngit_source(:bc) { |repo| \"https://github.com/basecamp/#{repo}\" }\n\ngem \"rails\", github: \"rails/rails\", branch: \"main\"\n\n# Assets & front end\ngem \"importmap-rails\"\ngem \"propshaft\"\ngem \"stimulus-rails\"\ngem \"turbo-rails\", github: \"hotwired/turbo-rails\", branch: \"offline-cache\"\n\n# Deployment and drivers\ngem \"bootsnap\", require: false\ngem \"kamal\", require: false\ngem \"puma\", \">= 5.0\"\ngem \"solid_cable\", \">= 3.0\"\ngem \"solid_cache\", \"~> 1.0\"\ngem \"solid_queue\", \"~> 1.3\"\ngem \"sqlite3\", \">= 2.0\"\ngem \"thruster\", require: false\ngem \"trilogy\", \"~> 2.10\"\n\n# Features\ngem \"bcrypt\", \"~> 3.1.7\"\ngem \"geared_pagination\", \"~> 1.2\"\ngem \"rqrcode\"\ngem \"rouge\"\ngem \"jbuilder\"\ngem \"lexxy\", \"0.9.0.beta\"\ngem \"image_processing\", \"~> 1.14\"\ngem \"platform_agent\"\ngem \"aws-sdk-s3\", require: false\ngem \"web-push\"\ngem \"net-http-persistent\"\ngem \"zip_kit\"\ngem \"mittens\"\ngem \"useragent\", bc: \"useragent\"\n\n# Operations\ngem \"autotuner\"\ngem \"mission_control-jobs\"\ngem \"stackprof\"\ngem \"benchmark\" # indirect dependency, being removed from Ruby 3.5 stdlib so here to quash warnings\n\ngroup :development, :test do\n  gem \"brakeman\", require: false\n  gem \"bundler-audit\", require: false\n  gem \"debug\"\n  gem \"faker\"\n  gem \"letter_opener\"\n  gem \"rack-mini-profiler\"\n  gem \"rubocop-rails-omakase\", require: false\nend\n\ngroup :development do\n  gem \"web-console\", github: \"rails/web-console\"\nend\n\ngroup :test do\n  gem \"capybara\"\n  gem \"selenium-webdriver\"\n  gem \"webmock\"\n  gem \"vcr\"\n  gem \"mocha\"\nend\n"
  },
  {
    "path": "Gemfile.saas",
    "content": "# This Gemfile extends the base Gemfile with SaaS-specific dependencies\neval_gemfile \"Gemfile\"\n\ngit_source(:bc) { |repo| \"https://github.com/basecamp/#{repo}\" }\n\ngem \"activeresource\", require: \"active_resource\"\ngem \"actionpack-xml_parser\" # needed by queenbee for XML request body parsing\ngem \"queenbee\", bc: \"queenbee-plugin\"\ngem \"fizzy-saas\", path: \"saas\"\ngem \"console1984\", bc: \"console1984\"\ngem \"audits1984\", bc: \"audits1984\", branch: \"flavorjones/coworker-api\"\n\n# Native push notifications (iOS/Android)\ngem \"action_push_native\"\n\n# Telemetry\ngem \"rails_structured_logging\", bc: \"rails-structured-logging\"\ngem \"sentry-ruby\"\ngem \"sentry-rails\"\ngem \"yabeda\"\ngem \"yabeda-actioncable\"\ngem \"yabeda-activejob\", github: \"basecamp/yabeda-activejob\", branch: \"bulk-and-scheduled-jobs\"\ngem \"yabeda-gc\"\ngem \"yabeda-http_requests\"\ngem \"yabeda-prometheus-mmap\"\ngem \"yabeda-puma-plugin\"\ngem \"yabeda-rails\"\ngem \"webrick\" # required for yabeda-prometheus metrics server\ngem \"prometheus-client-mmap\", \"~> 1.3\"\ngem \"gvltools\"\n"
  },
  {
    "path": "LICENSE.md",
    "content": "# O'Saasy License Agreement\n\nCopyright © 2025, 37signals LLC.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\n1. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n2. No licensee or downstream recipient may use the Software (including any modified or derivative versions) to directly compete with the original Licensor by offering it to third parties as a hosted, managed, or Software-as-a-Service (SaaS) product or cloud service where the primary value of the service is the functionality of the Software itself.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE."
  },
  {
    "path": "README.md",
    "content": "# Fizzy\n\nThis is the source code of [Fizzy](https://fizzy.do/), the Kanban tracking tool for issues and ideas by [37signals](https://37signals.com).\n\n\n## Running your own Fizzy instance\n\nIf you want to run your own Fizzy instance, but don't need to change its code, you can use our pre-built Docker image.\nYou'll need access to a server on which you can run Docker, and you'll need to configure some options to customize your installation.\n\nYou can find the details of how to do a Docker-based deployment in our [Docker deployment guide](docs/docker-deployment.md).\n\nIf you want more flexibility to customize your Fizzy installation by changing its code, and deploy those changes to your server, then we recommend you deploy Fizzy with Kamal. You can find a complete walkthrough of doing that in our [Kamal deployment guide](docs/kamal-deployment.md).\n\n\n## Development\n\nYou are welcome -- and encouraged -- to modify Fizzy to your liking.\nPlease see our [Development guide](docs/development.md) for how to get Fizzy set up for local development.\n\n\n## Contributing\n\nWe welcome contributions! Please read our [style guide](STYLE.md) before submitting code.\n\n\n## License\n\nFizzy is released under the [O'Saasy License](LICENSE.md).\n"
  },
  {
    "path": "Rakefile",
    "content": "# Add your own tasks in files placed in lib/tasks ending in .rake,\n# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.\n\nrequire_relative 'config/application'\n\nRails.application.load_tasks\n"
  },
  {
    "path": "STYLE.md",
    "content": "\n# Style\n\nWe aim to write code that is a pleasure to read, and we have a lot of opinions about how to do it well. Writing great code is an essential part of our programming culture, and we deliberately set a high bar for every code change anyone contributes. We care about how code reads, how code looks, and how code makes you feel when you read it.\n\nWe love discussing code. If you have questions about how to write something, or if you detect some smell you are not quite sure how to solve, please ask away to other programmers. A Pull Request is a great way to do this.\n\nWhen writing new code, unless you are very familiar with our approach, try to find similar code elsewhere to look for inspiration.\n\n## Conditional returns\n\nIn general, we prefer to use expanded conditionals over guard clauses.\n\n```ruby\n# Bad\ndef todos_for_new_group\n  ids = params.require(:todolist)[:todo_ids]\n  return [] unless ids\n  @bucket.recordings.todos.find(ids.split(\",\"))\nend\n\n# Good\ndef todos_for_new_group\n  if ids = params.require(:todolist)[:todo_ids]\n    @bucket.recordings.todos.find(ids.split(\",\"))\n  else\n    []\n  end\nend\n```\n\nThis is because guard clauses can be hard to read, especially when they are nested.\n\nAs an exception, we sometimes use guard clauses to return early from a method:\n\n* When the return is right at the beginning of the method.\n* When the main method body is not trivial and involves several lines of code.\n\n```ruby\ndef after_recorded_as_commit(recording)\n  return if recording.parent.was_created?\n\n  if recording.was_created?\n    broadcast_new_column(recording)\n  else\n    broadcast_column_change(recording)\n  end\nend\n```\n\n## Methods ordering\n\nWe order methods in classes in the following order:\n\n1. `class` methods\n2. `public` methods with `initialize` at the top.\n3. `private` methods\n\n## Invocation order\n\nWe order methods vertically based on their invocation order. This helps us to understand the flow of the code.\n\n```ruby\nclass SomeClass\n  def some_method\n    method_1\n    method_2\n  end\n\n  private\n    def method_1\n      method_1_1\n      method_1_2\n    end\n  \n    def method_1_1\n      # ...\n    end\n  \n    def method_1_2\n      # ...\n    end\n  \n    def method_2\n      method_2_1\n      method_2_2\n    end\n  \n    def method_2_1\n      # ...\n    end\n  \n    def method_2_2\n      # ...\n    end\nend\n```\n\n## To bang or not to bang\n\nShould I call a method `do_something` or `do_something!`?\n\nAs a general rule, we only use `!` for methods that have a correspondent counterpart without `!`. In particular, we don’t use `!` to flag destructive actions. There are plenty of destructive methods in Ruby and Rails that do not end with `!`.\n\n## Visibility modifiers\n\nWe don't add a newline under visibility modifiers, and we indent the content under them.\n\n```ruby\nclass SomeClass\n  def some_method\n    # ...\n  end\n\n  private\n    def some_private_method_1\n      # ...\n    end\n\n    def some_private_method_2\n      # ...\n    end\nend\n```\n\nIf a module only has private methods, we mark it `private` at the top and add an extra new line after but don't indent.\n\n```ruby\nmodule SomeModule\n  private\n  \n  def some_private_method\n    # ...\n  end\nend\n```\n\n## CRUD controllers\n\nWe model web endpoints as CRUD operations on resources (REST). When an action doesn't map cleanly to a standard CRUD verb, we introduce a new resource rather than adding custom actions.\n\n```ruby\n# Bad\nresources :cards do\n  post :close\n  post :reopen\nend\n\n# Good\nresources :cards do\n  resource :closure\nend\n```\n\n## Controller and model interactions\n\nIn general, we favor a [vanilla Rails](https://dev.37signals.com/vanilla-rails-is-plenty/) approach with thin controllers directly invoking a rich domain model. We don't use services or other artifacts to connect the two.\n\nInvoking plain Active Record operations is totally fine:\n\n```ruby\nclass Cards::CommentsController < ApplicationController\n  def create\n    @comment = @card.comments.create!(comment_params)\n  end\nend\n```\n\nFor more complex behavior, we prefer clear, intention-revealing model APIs that controllers call directly:\n\n```ruby\nclass Cards::GoldnessesController < ApplicationController\n  def create\n    @card.gild\n  end\nend\n```\n\nWhen justified, it is fine to use services or form objects, but don't treat those as special artifacts:\n\n```ruby\nSignup.new(email_address: email_address).create_identity\n```\n\n## Run async operations in jobs\n\nAs a general rule, we write shallow job classes that delegate the logic itself to domain models:\n\n* We typically use the suffix `_later` to flag methods that enqueue a job.\n* A common scenario is having a model class that enqueues a job that, when executed, invokes some method in that same class. In this case, we use the suffix `_now` for the regular synchronous method.\n\n```ruby\nmodule Event::Relaying\n  extend ActiveSupport::Concern\n\n  included do\n    after_create_commit :relay_later\n  end\n\n  def relay_later\n    Event::RelayJob.perform_later(self)\n  end\n\n  def relay_now\n    # ...\n  end\nend\n\nclass Event::RelayJob < ApplicationJob\n  def perform(event)\n    event.relay_now\n  end\nend\n```\n"
  },
  {
    "path": "app/assets/images/.keep",
    "content": ""
  },
  {
    "path": "app/assets/stylesheets/_global.css",
    "content": "@layer reset, base, components, modules, utilities, native, platform;\n\n:root {\n  /* Insets - The mobile apps may inject their own custom insets based on native elements on screen, like a floating navigation */\n  --custom-safe-inset-top: var(--injected-safe-inset-top, env(safe-area-inset-top, 0px));\n  --custom-safe-inset-right: var(--injected-safe-inset-right, env(safe-area-inset-right, 0px));\n  --custom-safe-inset-bottom: var(--injected-safe-inset-bottom, env(safe-area-inset-bottom, 0px));\n  --custom-safe-inset-left: var(--injected-safe-inset-left, env(safe-area-inset-left, 0px));\n\n  /* Spacing */\n  --inline-space: 1ch;\n  --inline-space-half: calc(var(--inline-space) / 2);\n  --inline-space-double: calc(var(--inline-space) * 2);\n  --block-space: 1rem;\n  --block-space-half: calc(var(--block-space) / 2);\n  --block-space-double: calc(var(--block-space) * 2);\n\n  /* Text */\n  --font-sans: \"Adwaita Sans\", -apple-system, BlinkMacSystemFont, \"Segoe UI Variable Fizzy\", \"Segoe UI\", \"Noto Sans\", Helvetica, Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\";\n  --font-serif: ui-serif, serif;\n  --font-mono: ui-monospace, monospace;\n\n  --text-xx-small: 0.55rem;\n  --text-x-small: 0.75rem;\n  --text-small: 0.85rem;\n  --text-normal: 1rem;\n  --text-medium: 1.1rem;\n  --text-large: 1.5rem;\n  --text-x-large: 1.8rem;\n  --text-xx-large: 2.5rem;\n\n  @media (max-width: 639px) {\n    --text-xx-small: 0.65rem;\n    --text-x-small: 0.85rem;\n    --text-small: 0.95rem;\n    --text-normal: 1.1rem;\n    --text-medium: 1.2rem;\n    --text-large: 1.5rem;\n    --text-x-large: 1.8rem;\n    --text-xx-large: 2.5rem;\n  }\n\n  /* Borders */\n  --border: 1px solid var(--color-ink-lighter);\n\n  /* Shadows */\n  --shadow: 0 0 0 1px oklch(var(--lch-black) / 5%),\n            0 0.2em 0.2em oklch(var(--lch-black) / 5%),\n            0 0.4em 0.4em oklch(var(--lch-black) / 5%),\n            0 0.8em 0.8em oklch(var(--lch-black) / 5%);\n\n  /* Components */\n  --btn-size: 2.65em;\n  --footer-height: 2.65rem;\n  --tray-size: clamp(12rem, 25dvw, 24rem);\n\n  /* Focus rings for keyboard navigation */\n  --focus-ring-color: var(--color-link);\n  --focus-ring-offset: 1px;\n  --focus-ring-size: 2px;\n  --focus-ring: var(--focus-ring-size) solid var(--focus-ring-color);\n\n  /* Dialogs */\n  --dialog-duration: 150ms;\n\n  /* Easing functions from https://easingwizard.com/ */\n  --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);\n  --ease-out-overshoot: cubic-bezier(0.25, 1.75, 0.5, 1);\n  --ease-out-overshoot-subtle: cubic-bezier(0.25, 1.25, 0.5, 1);\n\n  @media (max-width: 799px) {\n    --tray-size: var(--footer-height);\n  }\n\n  /* Layout */\n  --main-padding: clamp(var(--inline-space), 3vw, calc(var(--inline-space) * 3));\n  --main-width: 1400px;\n\n  /* Z-index */\n  --z-events-column-header: 1;\n  --z-events-day-header: 3;\n  --z-popup: 10;\n  --z-nav: 20;\n  --z-flash: 30;\n  --z-tooltip: 40;\n  --z-bar: 50;\n  --z-tray: 51;\n  --z-welcome: 52;\n  --z-nav-open: 100;\n\n  /* OKLCH colors: Fixed */\n  --lch-black: 0% 0 0;\n  --lch-white: 100% 0 0;\n\n  /* OKLCH colors: Light mode */\n  --lch-canvas: var(--lch-white);\n  --lch-ink-inverted: var(--lch-white);\n\n  --lch-ink-darkest: 26% 0.05 264;\n  --lch-ink-darker: 40% 0.026 262;\n  --lch-ink-dark: 56% 0.014 260;\n  --lch-ink-medium: 66% 0.008 258;\n  --lch-ink-light: 84% 0.005 256;\n  --lch-ink-lighter: 92% 0.003 254;\n  --lch-ink-lightest: 96% 0.002 252;\n\n  --lch-uncolor-darkest: 26% 0.018 40;\n  --lch-uncolor-darker: 40.04% 0.0376 50.06;\n  --lch-uncolor-dark: 57.09% 0.0676 60.5;\n  --lch-uncolor-medium: 66% 0.0944 71.46;\n  --lch-uncolor-light: 83.97% 0.0457 80.84;\n  --lch-uncolor-lighter: 92% 0.014 90;\n  --lch-uncolor-lightest: 96% 0.012 100;\n\n  --lch-red-darkest: 26% 0.105 34;\n  --lch-red-darker: 40% 0.154 36;\n  --lch-red-dark: 59% 0.19 38;\n  --lch-red-medium: 66% 0.204 40;\n  --lch-red-light: 84.08% 0.0837 41.96;\n  --lch-red-lighter: 92% 0.03 44;\n  --lch-red-lightest: 96% 0.013 46;\n\n  --lch-yellow-darkest: 26% 0.0729 40;\n  --lch-yellow-darker: 40% 0.12 50;\n  --lch-yellow-dark: 58% 0.156 60;\n  --lch-yellow-medium: 74% 0.184 70;\n  --lch-yellow-light: 84% 0.12 80;\n  --lch-yellow-lighter: 92% 0.076 90;\n  --lch-yellow-lightest: 96% 0.034 100;\n\n  --lch-lime-darkest: 26% 0.064 109;\n  --lch-lime-darker: 40% 0.101 110;\n  --lch-lime-dark: 56.5% 0.142 111;\n  --lch-lime-medium: 68% 0.176 113.11;\n  --lch-lime-light: 83.92% 0.0927 113.6;\n  --lch-lime-lighter: 92% 0.046 114;\n  --lch-lime-lightest: 96% 0.034 115;\n\n  --lch-green-darkest: 26% 0.071 149;\n  --lch-green-darker: 40% 0.12 148;\n  --lch-green-dark: 55% 0.162 147;\n  --lch-green-medium: 66% 0.208 146;\n  --lch-green-light: 83.92% 0.0772 145.06;\n  --lch-green-lighter: 92% 0.044 144;\n  --lch-green-lightest: 96% 0.022 143;\n\n  --lch-aqua-darkest: 26% 0.059 214;\n  --lch-aqua-darker: 40% 0.093 212;\n  --lch-aqua-dark: 55.5% 0.122 210;\n  --lch-aqua-medium: 66% 0.152 208;\n  --lch-aqua-light: 83.88% 0.0555 206.02;\n  --lch-aqua-lighter: 92% 0.02 204;\n  --lch-aqua-lightest: 96% 0.012 202;\n\n  --lch-blue-darkest: 26% 0.126 264;\n  --lch-blue-darker: 40% 0.166 262;\n  --lch-blue-dark: 57.02% 0.1895 260.46;\n  --lch-blue-medium: 66% 0.196 257.82;\n  --lch-blue-light: 84.04% 0.0719 255.29;\n  --lch-blue-lighter: 92% 0.026 254;\n  --lch-blue-lightest: 96% 0.016 252;\n\n  --lch-violet-darkest: 26% 0.148 292;\n  --lch-violet-darker: 40% 0.2 290;\n  --lch-violet-dark: 58% 0.216 287.6;\n  --lch-violet-medium: 66% 0.206 285.52;\n  --lch-violet-light: 84.08% 0.0791 283.47;\n  --lch-violet-lighter: 92% 0.03 282;\n  --lch-violet-lightest: 96% 0.015 280;\n\n  --lch-purple-darkest: 26% 0.131 314;\n  --lch-purple-darker: 40% 0.178 312;\n  --lch-purple-dark: 58% 0.21 310;\n  --lch-purple-medium: 66% 0.258 308;\n  --lch-purple-light: 84.09% 0.0778 305.77;\n  --lch-purple-lighter: 92% 0.03 304;\n  --lch-purple-lightest: 96% 0.019 302;\n\n  --lch-pink-darkest: 26% 0.12 348;\n  --lch-pink-darker: 40% 0.16 346;\n  --lch-pink-dark: 59% 0.188 344;\n  --lch-pink-medium: 71.8% 0.2008 342;\n  --lch-pink-light: 84.04% 0.0737 340;\n  --lch-pink-lighter: 92% 0.03 338;\n  --lch-pink-lightest: 96% 0.02 336;\n\n  /* Colors: Named */\n  --color-black: oklch(var(--lch-black));\n  --color-white: oklch(var(--lch-white));\n\n  --color-ink: oklch(var(--lch-ink-darkest));\n  --color-ink-darkest: oklch(var(--lch-ink-darkest));\n  --color-ink-darker: oklch(var(--lch-ink-darker));\n  --color-ink-dark: oklch(var(--lch-ink-dark));\n  --color-ink-medium: oklch(var(--lch-ink-medium));\n  --color-ink-light: oklch(var(--lch-ink-light));\n  --color-ink-lighter: oklch(var(--lch-ink-lighter));\n  --color-ink-lightest: oklch(var(--lch-ink-lightest));\n\n  --color-ink-inverted: oklch(var(--lch-ink-inverted));\n\n  /* Colors: Abstractions */\n  --color-canvas: oklch(var(--lch-canvas));\n  --color-negative: oklch(var(--lch-red-dark));\n  --color-positive: oklch(var(--lch-green-dark));\n  --color-link: oklch(var(--lch-blue-dark));\n  --color-selected-light: oklch(var(--lch-blue-lightest));\n  --color-selected: oklch(var(--lch-blue-lighter));\n  --color-selected-dark: oklch(var(--lch-blue-light));\n  --color-highlight: oklch(var(--lch-yellow-lighter));\n  --color-marker: oklch(var(--lch-red-medium));\n  --color-terminal-bg: oklch(98% 0.002 252);\n  --color-terminal-text: var(--color-ink);\n  --color-terminal-text-light: var(--color-ink-lighter);\n  --color-golden: oklch(89.1% 0.178 95.7);\n  --color-maybe: oklch(var(--lch-blue-medium));\n\n  /* Colors: Cards */\n  --color-card-default: oklch(var(--lch-blue-dark));\n  --color-card-complete: var(--color-ink-darker);\n  --color-card-1: oklch(var(--lch-ink-medium));\n  --color-card-2: oklch(var(--lch-uncolor-medium));\n  --color-card-3: oklch(var(--lch-yellow-medium));\n  --color-card-4: oklch(var(--lch-lime-medium));\n  --color-card-5: oklch(var(--lch-aqua-medium));\n  --color-card-6: oklch(var(--lch-violet-medium));\n  --color-card-7: oklch(var(--lch-purple-medium));\n  --color-card-8: oklch(var(--lch-pink-medium));\n\n  /* Colors: Highlighter */\n  --highlight-1: rgb(136, 118, 38);\n  --highlight-2: rgb(185, 94, 6);\n  --highlight-3: rgb(207, 0, 0);\n  --highlight-4: rgb(216, 28, 170);\n  --highlight-5: rgb(144, 19, 254);\n  --highlight-6: rgb(5, 98, 185);\n  --highlight-7: rgb(17, 138, 15);\n  --highlight-8: rgb(148, 82, 22);\n  --highlight-9: rgb(102, 102, 102);\n\n  --highlight-bg-1: rgba(229, 223, 6, 0.3);\n  --highlight-bg-2: rgba(255, 185, 87, 0.3);\n  --highlight-bg-3: rgba(255, 118, 118, 0.3);\n  --highlight-bg-4: rgba(248, 137, 216, 0.3);\n  --highlight-bg-5: rgba(190, 165, 255, 0.3);\n  --highlight-bg-6: rgba(124, 192, 252, 0.3);\n  --highlight-bg-7: rgba(140, 255, 129, 0.3);\n  --highlight-bg-8: rgba(221, 170, 123, 0.3);\n  --highlight-bg-9: rgba(200, 200, 200, 0.3);\n\n  /* Colors: Syntax highlighting */\n  --color-code-token__att: oklch(var(--lch-blue-dark));\n  --color-code-token__comment: oklch(var(--lch-ink-medium));\n  --color-code-token__function: oklch(var(--lch-purple-dark));\n  --color-code-token__operator: oklch(var(--lch-red-dark));\n  --color-code-token__property: oklch(var(--lch-purple-dark));\n  --color-code-token__punctuation: oklch(var(--lch-ink-dark));\n  --color-code-token__selector: oklch(var(--lch-green-dark));\n  --color-code-token__variable: oklch(var(--lch-red-dark));\n\n  /* Colors: Generating gradient */\n  --color-gradient-1: oklch(var(--lch-violet-lighter));\n  --color-gradient-2: oklch(var(--lch-pink-lighter));\n  --color-gradient-3: oklch(var(--lch-purple-lighter));\n  --color-gradient-4: var(--color-canvas);\n}\n\n/* Dark mode - explicit theme choice overrides system preference */\nhtml[data-theme=\"dark\"] {\n  --lch-canvas: 20% 0.0195 232.58;\n  --lch-ink-inverted: var(--lch-black);\n\n  --lch-ink-darkest: 96.02% 0.0034 260;\n  --lch-ink-darker: 86% 0.0061 260;\n  --lch-ink-dark: 73.97% 0.009 260;\n  --lch-ink-medium: 62% 0.0122 260;\n  --lch-ink-light: 40% 0.0148 260;\n  --lch-ink-lighter: 30% 0.0178 260;\n  --lch-ink-lightest: 25% 0.0204 260;\n\n  --lch-uncolor-darkest: 96.09% 0.0076 100;\n  --lch-uncolor-darker: 86% 0.021 90;\n  --lch-uncolor-dark: 73.93% 0.041 80;\n  --lch-uncolor-medium: 62% 0.0552 70;\n  --lch-uncolor-light: 40% 0.0387 60;\n  --lch-uncolor-lighter: 30% 0.012 50;\n  --lch-uncolor-lightest: 25% 0.0017 40;\n\n  --lch-red-darkest: 95.85% 0.0218 46;\n  --lch-red-darker: 86% 0.086 44;\n  --lch-red-dark: 73.95% 0.139 42;\n  --lch-red-medium: 62% 0.154 40;\n  --lch-red-light: 40% 0.088 38;\n  --lch-red-lighter: 30% 0.032 36;\n  --lch-red-lightest: 25% 0.011 34;\n\n  --lch-yellow-darkest: 96% 0.056 100;\n  --lch-yellow-darker: 86% 0.103 90;\n  --lch-yellow-dark: 74.06% 0.136 80;\n  --lch-yellow-medium: 62.1% 0.146 70;\n  --lch-yellow-light: 40% 0.0736 60;\n  --lch-yellow-lighter: 30% 0.026 50;\n  --lch-yellow-lightest: 25% 0.01 40;\n\n  --lch-lime-darkest: 96.04% 0.066 115;\n  --lch-lime-darker: 86% 0.098 114;\n  --lch-lime-dark: 73.97% 0.121 113;\n  --lch-lime-medium: 62% 0.128 112;\n  --lch-lime-light: 40% 0.0637 111;\n  --lch-lime-lighter: 30% 0.024 110;\n  --lch-lime-lightest: 25% 0.012 109;\n\n  --lch-green-darkest: 96.12% 0.035 143;\n  --lch-green-darker: 86% 0.082 144;\n  --lch-green-dark: 73.99% 0.117 145;\n  --lch-green-medium: 62% 0.1261 146;\n  --lch-green-light: 40% 0.065 147;\n  --lch-green-lighter: 30% 0.03 148;\n  --lch-green-lightest: 25% 0.018 149;\n\n  --lch-aqua-darkest: 96.15% 0.0244 202;\n  --lch-aqua-darker: 86% 0.06 204;\n  --lch-aqua-dark: 73.92% 0.095 206;\n  --lch-aqua-medium: 62% 0.106 208;\n  --lch-aqua-light: 40% 0.0594 210;\n  --lch-aqua-lighter: 30% 0.028 212;\n  --lch-aqua-lightest: 25% 0.017 214;\n\n  --lch-blue-darkest: 95.93% 0.0217 252;\n  --lch-blue-darker: 86% 0.068 254;\n  --lch-blue-dark: 74% 0.1293 256;\n  --lch-blue-medium: 62% 0.159 258;\n  --lch-blue-light: 40% 0.094 260;\n  --lch-blue-lighter: 30% 0.0452 262;\n  --lch-blue-lightest: 25% 0.0318 264;\n\n  --lch-violet-darkest: 95.97% 0.019 280;\n  --lch-violet-darker: 86% 0.068 282;\n  --lch-violet-dark: 74.08% 0.142 284;\n  --lch-violet-medium: 62% 0.184 286;\n  --lch-violet-light: 40% 0.108 288;\n  --lch-violet-lighter: 30% 0.048 290;\n  --lch-violet-lightest: 25% 0.025 292;\n\n  --lch-purple-darkest: 95.99% 0.0217 302;\n  --lch-purple-darker: 86% 0.068 304;\n  --lch-purple-dark: 73.98% 0.141 306;\n  --lch-purple-medium: 62% 0.177 308;\n  --lch-purple-light: 40% 0.099 310;\n  --lch-purple-lighter: 30% 0.04 312;\n  --lch-purple-lightest: 25% 0.017 314;\n\n  --lch-pink-darkest: 95.84% 0.0308 336;\n  --lch-pink-darker: 86% 0.074 338;\n  --lch-pink-dark: 74.04% 0.1294 340;\n  --lch-pink-medium: 62% 0.166 342;\n  --lch-pink-light: 40% 0.085 344;\n  --lch-pink-lighter: 30% 0.03 346;\n  --lch-pink-lightest: 25% 0.011 348;\n\n  --color-terminal-bg: var(--color-canvas);\n  --color-terminal-text-light: oklch(var(--lch-green-dark));\n  --color-golden: oklch(var(--lch-blue-medium));\n  --color-highlight: oklch(var(--lch-blue-lighter));\n\n  --shadow: 0 0 0 1px oklch(var(--lch-black) / 0.42),\n    0 0.2em 1.6em -0.8em oklch(var(--lch-black) / 0.6),\n    0 0.4em 2.4em -1em oklch(var(--lch-black) / 0.7),\n    0 0.4em 0.8em -1.2em oklch(var(--lch-black) / 0.8),\n    0 0.8em 1.2em -1.6em oklch(var(--lch-black) / 0.9),\n    0 1.2em 1.6em -2em oklch(var(--lch-black) / 1);\n}\n\n/* Fallback to system preference when no explicit theme is set */\n@media (prefers-color-scheme: dark) {\n  html:not([data-theme]) {\n    --lch-canvas: 20% 0.0195 232.58;\n    --lch-ink-inverted: var(--lch-black);\n\n    --lch-ink-darkest: 96.02% 0.0034 260;\n    --lch-ink-darker: 86% 0.0061 260;\n    --lch-ink-dark: 73.97% 0.009 260;\n    --lch-ink-medium: 62% 0.0122 260;\n    --lch-ink-light: 40% 0.0148 260;\n    --lch-ink-lighter: 30% 0.0178 260;\n    --lch-ink-lightest: 25% 0.0204 260;\n\n    --lch-uncolor-darkest: 96.09% 0.0076 100;\n    --lch-uncolor-darker: 86% 0.021 90;\n    --lch-uncolor-dark: 73.93% 0.041 80;\n    --lch-uncolor-medium: 62% 0.0552 70;\n    --lch-uncolor-light: 40% 0.0387 60;\n    --lch-uncolor-lighter: 30% 0.012 50;\n    --lch-uncolor-lightest: 25% 0.0017 40;\n\n    --lch-red-darkest: 95.85% 0.0218 46;\n    --lch-red-darker: 86% 0.086 44;\n    --lch-red-dark: 73.95% 0.139 42;\n    --lch-red-medium: 62% 0.154 40;\n    --lch-red-light: 40% 0.088 38;\n    --lch-red-lighter: 30% 0.032 36;\n    --lch-red-lightest: 25% 0.011 34;\n\n    --lch-yellow-darkest: 96% 0.056 100;\n    --lch-yellow-darker: 86% 0.103 90;\n    --lch-yellow-dark: 74.06% 0.136 80;\n    --lch-yellow-medium: 62.1% 0.146 70;\n    --lch-yellow-light: 40% 0.0736 60;\n    --lch-yellow-lighter: 30% 0.026 50;\n    --lch-yellow-lightest: 25% 0.01 40;\n\n    --lch-lime-darkest: 96.04% 0.066 115;\n    --lch-lime-darker: 86% 0.098 114;\n    --lch-lime-dark: 73.97% 0.121 113;\n    --lch-lime-medium: 62% 0.128 112;\n    --lch-lime-light: 40% 0.0637 111;\n    --lch-lime-lighter: 30% 0.024 110;\n    --lch-lime-lightest: 25% 0.012 109;\n\n    --lch-green-darkest: 96.12% 0.035 143;\n    --lch-green-darker: 86% 0.082 144;\n    --lch-green-dark: 73.99% 0.117 145;\n    --lch-green-medium: 62% 0.1261 146;\n    --lch-green-light: 40% 0.065 147;\n    --lch-green-lighter: 30% 0.03 148;\n    --lch-green-lightest: 25% 0.018 149;\n\n    --lch-aqua-darkest: 96.15% 0.0244 202;\n    --lch-aqua-darker: 86% 0.06 204;\n    --lch-aqua-dark: 73.92% 0.095 206;\n    --lch-aqua-medium: 62% 0.106 208;\n    --lch-aqua-light: 40% 0.0594 210;\n    --lch-aqua-lighter: 30% 0.028 212;\n    --lch-aqua-lightest: 25% 0.017 214;\n\n    --lch-blue-darkest: 95.93% 0.0217 252;\n    --lch-blue-darker: 86% 0.068 254;\n    --lch-blue-dark: 74% 0.1293 256;\n    --lch-blue-medium: 62% 0.159 258;\n    --lch-blue-light: 40% 0.094 260;\n    --lch-blue-lighter: 30% 0.0452 262;\n    --lch-blue-lightest: 25% 0.0318 264;\n\n    --lch-violet-darkest: 95.97% 0.019 280;\n    --lch-violet-darker: 86% 0.068 282;\n    --lch-violet-dark: 74.08% 0.142 284;\n    --lch-violet-medium: 62% 0.184 286;\n    --lch-violet-light: 40% 0.108 288;\n    --lch-violet-lighter: 30% 0.048 290;\n    --lch-violet-lightest: 25% 0.025 292;\n\n    --lch-purple-darkest: 95.99% 0.0217 302;\n    --lch-purple-darker: 86% 0.068 304;\n    --lch-purple-dark: 73.98% 0.141 306;\n    --lch-purple-medium: 62% 0.177 308;\n    --lch-purple-light: 40% 0.099 310;\n    --lch-purple-lighter: 30% 0.04 312;\n    --lch-purple-lightest: 25% 0.017 314;\n\n    --lch-pink-darkest: 95.84% 0.0308 336;\n    --lch-pink-darker: 86% 0.074 338;\n    --lch-pink-dark: 74.04% 0.1294 340;\n    --lch-pink-medium: 62% 0.166 342;\n    --lch-pink-light: 40% 0.085 344;\n    --lch-pink-lighter: 30% 0.03 346;\n    --lch-pink-lightest: 25% 0.011 348;\n\n    --color-terminal-bg: var(--color-canvas);\n    --color-terminal-text-light: oklch(var(--lch-green-dark));\n    --color-golden: oklch(var(--lch-blue-medium));\n    --color-highlight: oklch(var(--lch-blue-lighter));\n\n    --shadow: 0 0 0 1px oklch(var(--lch-black) / 0.42),\n              0 .2em 1.6em -0.8em oklch(var(--lch-black) / 0.6),\n              0 .4em 2.4em -1em oklch(var(--lch-black) / 0.7),\n              0 .4em .8em -1.2em oklch(var(--lch-black) / 0.8),\n              0 .8em 1.2em -1.6em oklch(var(--lch-black) / 0.9),\n              0 1.2em 1.6em -2em oklch(var(--lch-black) / 1);\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/access-tokens.css",
    "content": ".access-tokens {\n  border-collapse: collapse;\n  font-size: var(--text-small);\n  inline-size: 100%;\n  margin-block-end: 2lh;\n\n  td, th {\n    border-block-end: 1px solid var(--color-ink-lighter);\n    text-align: start;\n\n    &:first-child {\n      inline-size: 100%;\n    }\n\n    &:not(:first-child) {\n      padding-inline-start: var(--inline-space);\n    }\n\n    &:not(:last-child) {\n      padding-inline-end: var(--inline-space);\n    }\n  }\n\n  th {\n    color: var(--color-ink-dark);\n    font-size: var(--text-x-small);\n    text-transform: uppercase;\n  }\n\n  td {\n    padding-block: 8px;\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/android.css",
    "content": "@layer platform {\n  [data-platform~=android] {\n    .hide-on-android {\n      display: none;\n    }\n\n    /* Filters\n    /* ------------------------------------------------------------------------ */\n\n    .filters {\n      --text-x-small: 1rem;\n    }\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/animation.css",
    "content": "@layer utilities {\n  .shake {\n    animation: shake 400ms both;\n  }\n\n  @keyframes appear-then-fade {\n    0%,100% { opacity: 0; }\n    5%,60%  { opacity: 1; }\n  }\n\n  @keyframes gradient {\n    0%   { background-position: 0% 50%; }\n    50%  { background-position: 100% 50%; }\n    100% { background-position: 0% 50%; }\n  }\n\n  @keyframes pulse {\n    0%   { opacity: 1; }\n    50%  { opacity: 0.4; }\n    100% { opacity: 1; }\n  }\n\n  /* Keyframes */\n  @keyframes react {\n    0%   { transform: scale(0.85);  opacity: 0; }\n    50%  { transform: scale(1.15); opacity: 1; }\n    100% { transform: scale(1); }\n  }\n\n  @keyframes scale-fade-out {\n    0%   { transform: scale(1); opacity: 1; }\n    100% { transform: scale(0); opacity: 0; }\n  }\n\n  @keyframes shake {\n    0%  { transform: translateX(-2rem); }\n    25% { transform: translateX(2rem); }\n    50% { transform: translateX(-1rem); }\n    75% { transform: translateX(1rem); }\n  }\n\n  @keyframes slide-up {\n    from { transform: translateY(2rem); }\n    to   { transform: translateY(0); }\n  }\n\n  @keyframes slide-up-fade-in {\n    from { transform: translateY(2rem); opacity: 0; }\n    to   { transform: translateY(0); opacity: 1; }\n  }\n\n  @keyframes slide-down {\n    from { transform: translateY(0); }\n    to   { transform: translateY(2rem); }\n  }\n\n  @keyframes submitting {\n    0%    { -webkit-mask-position: 0% 0%,   50% 0%,   100% 0% }\n    12.5% { -webkit-mask-position: 0% 50%,  50% 0%,   100% 0% }\n    25%   { -webkit-mask-position: 0% 100%, 50% 50%,  100% 0% }\n    37.5% { -webkit-mask-position: 0% 100%, 50% 100%, 100% 50% }\n    50%   { -webkit-mask-position: 0% 100%, 50% 100%, 100% 100% }\n    62.5% { -webkit-mask-position: 0% 50%,  50% 100%, 100% 100% }\n    75%   { -webkit-mask-position: 0% 0%,   50% 50%,  100% 100% }\n    87.5% { -webkit-mask-position: 0% 0%,   50% 0%,   100% 50% }\n    100%  { -webkit-mask-position: 0% 0%,   50% 0%,   100% 0% }\n  }\n\n  @keyframes success {\n    0%  { background-color: var(--color-border-darker); scale: 0.8; }\n    33% { background-color: var(--color-border-darker); scale: 1; }\n  }\n\n  @keyframes wiggle {\n    0% { transform: rotate(0deg); }\n    20% { transform: rotate(3deg); }\n    40% { transform: rotate(-3deg); }\n    60% { transform: rotate(3deg); }\n    80% { transform: rotate(-3deg); }\n    100% { transform: rotate(0deg); }\n  }\n\n  @keyframes wobble {\n    0%  { transform: rotate(calc(var(--bubble-rotate) + 30deg)); }\n    15% { border-radius: 66% 34% 72% 28% / 39% 63% 37% 61%; }\n    25% { border-radius: 55% 47% 62% 40% / 58% 50% 52% 44%; }\n    33% { border-radius: 46% 54% 61% 39% / 50% 51% 49% 50%; }\n    50% { border-radius: 54% 46% 61% 39% / 57% 49% 51% 43%; }\n    75% { border-radius: 53% 45% 60% 38% / 56% 48% 50% 42%; }\n  }\n\n  @keyframes zoom-fade {\n    100% { transform: translateY(-1.5em); scale: 2; opacity: 0; }\n  }\n\n  @keyframes blink {\n    50% {\n      border-color: transparent;\n    }\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/attachments.css",
    "content": "@layer components {\n  .attachment {\n    block-size: auto;\n    display: block;\n    inline-size: fit-content;\n    max-inline-size: 100%;\n    position: relative;\n\n    progress {\n      inline-size: 100%;\n      margin: auto;\n    }\n  }\n\n  .attachment__caption {\n    color: color-mix(in oklch, var(--color-ink) 66%, transparent);\n    font-size: var(--text-small);\n\n    textarea {\n      --input-border-radius: 0.3em;\n      --input-border-size: 0;\n      --input-padding: 0;\n\n      background-color: var(--input-background, transparent);\n      border: none;\n      color: inherit;\n      inline-size: 100%;\n      max-inline-size: 100%;\n      resize: none;\n      text-align: center;\n\n      &:focus {\n        --focus-ring-size: 0;\n      }\n\n      @supports (field-sizing: content) {\n        field-sizing: content;\n        inline-size: 100%;\n      }\n    }\n  }\n\n  .attachment__icon {\n    aspect-ratio: 4/5;\n    background-color: color-mix(var(--attachment-icon-color), transparent 90%);\n    block-size: 2.5lh;\n    border: 2px solid var(--attachment-icon-color);\n    border-block-start-width: 1ch;\n    border-radius: 0.5ch;\n    box-sizing: border-box;\n    color: var(--attachment-icon-color);\n    display: grid;\n    font-size: var(--text-small);\n    font-weight: bold;\n    inline-size: auto;\n    padding-inline: 0.5ch;\n    place-content: center;\n    text-transform: uppercase;\n    white-space: nowrap;\n  }\n\n  .attachment--preview {\n    margin-inline: auto;\n    text-align: center;\n\n    img, video {\n      block-size: auto;\n      display: block;\n      margin-inline: auto;\n      max-inline-size: 100%;\n      user-select: none;\n    }\n\n    > a {\n      display: block;\n    }\n\n    .attachment__caption {\n      column-gap: 0.5ch;\n      display: flex;\n      flex-wrap: wrap;\n      justify-content: center;\n      margin-block-start: 0.5ch;\n    }\n  }\n\n  .attachment--file {\n    --attachment-icon-color: var(--color-ink-medium);\n\n    align-items: center;\n    display: flex;\n    flex-wrap: wrap;\n    gap: 1ch;\n    inline-size: 100%;\n    margin-inline: 0;\n\n    .attachment__caption {\n      display: grid;\n      flex: 1;\n      text-align: start;\n    }\n\n    .attachment__name {\n      color: var(--color-ink);\n      font-weight: bold;\n    }\n\n    /* Video attachments don't have an identifiable class, but we need to\n     * make sure the caption is always below the video */\n    &:has(video) {\n      .attachment__caption {\n        flex: none;\n        inline-size: 100%;\n      }\n    }\n  }\n\n  .attachment--psd,\n  .attachment--key,\n  .attachment--sketch,\n  .attachment--ai,\n  .attachment--eps,\n  .attachment--indd,\n  .attachment--svg,\n  .attachment--ppt,\n  .attachment--pptx {\n    --attachment-icon-color: oklch(var(--lch-red-medium));\n  }\n\n  .attachment--css,\n  .attachment--crash,\n  .attachment--php,\n  .attachment--json,\n  .attachment--htm,\n  .attachment--html,\n  .attachment--rb,\n  .attachment--erb,\n  .attachment--ts,\n  .attachment--js {\n    --attachment-icon-color: oklch(var(--lch-purple-medium));\n  }\n\n  .attachment--txt,\n  .attachment--pages,\n  .attachment--rtf,\n  .attachment--md,\n  .attachment--doc,\n  .attachment--docx {\n    --attachment-icon-color: oklch(var(--lch-blue-medium));\n  }\n\n  .attachment--csv &,\n  .attachment--numbers &,\n  .attachment--xls &,\n  .attachment--xlsx & {\n    --attachment-icon-color: oklch(var(--lch-green-medium));\n  }\n\n  .attachment__link {\n    color: var(--color-link);\n    text-decoration: underline;\n  }\n\n  /* Custom attachments such as mentions, etc. */\n  action-text-attachment[content-type^='application/vnd.actiontext'] {\n    --attachment-bg-color: transparent;\n    --attachment-image-size: 1em;\n    --attachment-text-color: currentColor;\n\n    align-items: center;\n    background: var(--attachment-bg-color);\n    border-radius: 99rem;\n    box-shadow:\n      -0.25ch 0 0 var(--attachment-bg-color),\n       0.5ch 0 0 var(--attachment-bg-color);\n    color: var(--attachment-text-color);\n    display: inline-flex;\n    gap: 0.25ch;\n    margin: 0;\n    padding: 0;\n    position: relative;\n    vertical-align: bottom;\n    white-space: normal;\n\n    lexxy-editor & {\n      cursor: pointer;\n    }\n\n    img {\n      block-size: var(--attachment-image-size);\n      border-radius: 50%;\n      inline-size: var(--attachment-image-size);\n    }\n\n    &.node--selected {\n      --attachment-bg-color: oklch(var(--lch-blue-dark));\n      --attachment-text-color: var(--color-ink-inverted);\n    }\n  }\n\n  action-text-attachment[content-type^='application/vnd.actiontext.mention'] {\n    img {\n      object-fit: cover;\n    }\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/autoresize.css",
    "content": "@layer components {\n  @supports not (field-sizing: content) {\n    .autoresize__wrapper {\n      display: grid !important;\n      position: relative;\n\n      > *, &::after {\n        grid-area: 1 / 1 / 2 / 2;\n      }\n\n      &::after {\n        content: attr(data-autoresize-clone-value) \" \";\n        font: inherit;\n        line-height: inherit;\n        padding-block: var(--autosize-block-padding, 0);\n        visibility: hidden;\n        white-space: pre-wrap;\n      }\n    }\n\n    .autoresize__textarea {\n      inset: 0;\n      overflow: hidden;\n      position: absolute;\n      resize: none;\n    }\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/avatars.css",
    "content": "@layer components {\n  .avatar {\n    --avatar-border-radius: 50%;\n    --avatar-size-default: 5ch;\n    --btn-border-size: 0;\n\n    aspect-ratio: 1;\n    block-size: var(--avatar-size, var(--avatar-size-default));\n    border-radius: var(--avatar-border-radius);\n    display: grid;\n    flex-shrink: 0;\n    inline-size: var(--avatar-size, var(--avatar-size-default));\n    margin: 0;\n    place-items: center;\n\n    :is(img, .icon) {\n      aspect-ratio: 1;\n      block-size: 100%;\n      border-radius: var(--avatar-border-radius);\n      grid-area: 1/1;\n      inline-size: 100%;\n      max-inline-size: 100%;\n      object-fit: cover;\n    }\n  }\n\n  .avatar__form {\n    display: grid;\n    grid-template-columns: 1fr auto 1fr;\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/bar.css",
    "content": "@layer components {\n  .bar {\n    --row-gap: 0.2lh;\n\n    background-color: var(--color-terminal-bg);\n    block-size: calc(var(--footer-height) + env(safe-area-inset-bottom));\n    color: var(--color-terminal-text);\n    display: flex;\n    flex-direction: column;\n    font-size: 0.9em;\n    inset: auto 0 0 0;\n    max-block-size: 100%;\n    padding-block: var(--block-space) calc(var(--block-space) + env(safe-area-inset-bottom));\n    padding-inline:\n      calc(var(--tray-size) + calc(var(--inline-space) * 3) + env(safe-area-inset-left))\n      calc(var(--tray-size) + calc(var(--inline-space) * 3) + env(safe-area-inset-right));\n    place-content: center;\n    position: fixed;\n    view-transition-name: bar;\n    z-index: var(--z-bar);\n\n    html[data-theme=\"dark\"] & {\n      border-block: 1px solid var(--color-ink-lighter);\n    }\n\n    @media (prefers-color-scheme: dark) {\n      html:not([data-theme]) & {\n        border-block: 1px solid var(--color-ink-lighter);\n      }\n    }\n\n    &:has(.bar__placeholder[hidden]) {\n      padding-inline: 1ch;\n    }\n  }\n\n  ::view-transition-group(bar) {\n    z-index: 99;\n  }\n\n  .bar__input {\n    transform: translateY(50%);\n    transition: transform 350ms cubic-bezier(0.25, 1.25, 0.5, 1);\n\n    .bar:has(.bar__placeholder[hidden]) & {\n      transform: translateY(0);\n    }\n  }\n\n  .bar__modal {\n    background-color: var(--color-terminal-bg);\n    block-size: 75dvh;\n    border-block: 1px solid var(--color-ink-lighter);\n    inline-size: 100vw;\n    inset: auto 0 0 0;\n    max-inline-size: 100vw;\n    margin-block-end: calc(var(--footer-height) - 0.3rem + env(safe-area-inset-bottom));\n    position: fixed;\n    z-index: -1;\n\n    &:has(#bar-content[busy]), &:has(#bar-content:not([complete])), &:has([data-search-redirect]) {\n      display: none;\n    }\n  }\n\n  .bar__placeholder {\n    .btn--plain {\n      color: inherit;\n      font-size: var(--text-x-small);\n      font-weight: 600;\n      opacity: 0.66;\n      padding-inline: 1ch;\n      text-transform: uppercase;\n      white-space: nowrap;\n\n      &:hover {\n        color: oklch(var(--lch-blue-dark));\n        opacity: 1;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/base.css",
    "content": "@layer base {\n  html {\n    font-size: 100%;\n\n    @media (min-width: 100ch) {\n      font-size: 1.1875rem;\n    }\n  }\n\n  body {\n    -moz-osx-font-smoothing: grayscale;\n    -webkit-font-smoothing: antialiased;\n    -webkit-text-size-adjust: none;\n    background: var(--color-canvas);\n    color: var(--color-ink);\n    font-family: var(--font-sans);\n    interpolate-size: allow-keywords;\n    line-height: 1.375;\n    max-inline-size: 100vw;\n    scroll-behavior: auto;\n    text-rendering: optimizeLegibility;\n    text-size-adjust: none;\n  }\n\n  a {\n    text-decoration: none;\n\n    &:not([class]) {\n      color: var(--color-link);\n      text-decoration: underline;\n      text-decoration-skip-ink: auto;\n    }\n  }\n\n  :is(a, button, input, textarea, .switch, .btn) {\n    transition: 100ms ease-out;\n    transition-property: background-color, border-color, box-shadow, outline;\n    touch-action: manipulation;\n\n    /* Keyboard navigation */\n    &:where(:focus-visible) {\n      border-radius: 0.25ch;\n      outline: var(--focus-ring-size) solid var(--focus-ring-color);\n      outline-offset: var(--focus-ring-offset);\n    }\n\n    /* Default disabled styles */\n    &:where([disabled]) {\n      cursor: not-allowed;\n      opacity: 0.5;\n      pointer-events: none;\n    }\n  }\n\n  ::selection {\n    background: var(--color-selected);\n\n    html[data-theme=\"dark\"] & {\n      background-color: var(--color-selected-dark);\n    }\n\n    @media (prefers-color-scheme: dark) {\n      html:not([data-theme]) & {\n        background-color: var(--color-selected-dark);\n      }\n    }\n  }\n\n  :where(ul, ol):where([role=\"list\"]) {\n    margin: 0;\n    padding: 0;\n    list-style: none;\n  }\n\n  kbd {\n    border: 1px solid;\n    border-radius: 0.3em;\n    box-shadow: 0 0.1em 0 currentColor;\n    font-family: var(--font-mono);\n    font-size: 0.8em;\n    font-weight: 600;\n    opacity: 0.7;\n    padding: 0 0.4em;\n    text-transform: uppercase;\n    vertical-align: middle;\n    white-space: nowrap;\n  }\n\n  video {\n    max-inline-size: 100%;\n  }\n\n  /* Printing */\n  @page {\n    margin: 1in;\n  }\n\n  @media print {\n    .no-print {\n      display: none;\n    }\n  }\n\n  /* Turbo */\n  turbo-frame,\n  turbo-cable-stream-source {\n    display: contents;\n  }\n\n  .turbo-progress-bar {\n    visibility: hidden;\n  }\n\n  /* Nicer scrollbars on Chrome 29+. This is intended for Windows machines, but */\n  /* there's not a way to target Windows using CSS, so Chrome on Mac will have */\n  /* slightly thinner scrollbars than normal. #C1C1C1 is the default color on Macs. */\n  @media screen and (-webkit-min-device-pixel-ratio:0) and (min-resolution:.001dpcm) {\n    * {\n      scrollbar-color: #C1C1C1 transparent;\n      scrollbar-width: thin;\n    }\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/blank-slates.css",
    "content": "/* Styles for the blank slate. To manage when they are shown/hidden, do so in context */\n\n@layer components {\n  .blank-slate {\n    border-radius: 0.5ch;\n    border: 2px dashed var(--color-ink-lighter);\n    color: var(--color-ink-dark);\n    font-weight: 500;\n    margin-block-start: 2dvh;\n    padding: 1.5ch 2ch;\n    rotate: -3deg;\n  }\n\n  .blank-slate--drag {\n    background-color: color-mix(in srgb, transparent, var(--card-color) 5%);\n    border-color: color-mix(in srgb, transparent, var(--card-color) 10%);\n    color: color-mix(in srgb, transparent, var(--card-color) 75%);\n    margin-block-start: 0;\n    rotate: 0deg;\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/bubble.css",
    "content": "@layer components {\n  .bubble {\n    --bubble-color: var(--card-color, oklch(var(--lch-blue-medium)));\n    --bubble-number-max: 26px;\n    --bubble-shape: 54% 46% 61% 39% / 57% 49% 51% 43%;\n    --bubble-rotate: 0deg;\n    --bubble-size-default: 4rem;\n\n    block-size: var(--bubble-size, var(--bubble-size-default));\n    color: var(--card-content-color);\n    container-type: inline-size;\n    font-size: 1.4rem;\n    font-weight: bold;\n    inline-size: var(--bubble-size, var(--bubble-size-default));\n    inset-block-start: 20%;\n    padding: 0.5cqi;\n    position: absolute;\n    z-index: 1;\n\n    &:before {\n      background: radial-gradient(\n        color-mix(in srgb, var(--bubble-color) 8%, var(--color-canvas)) 50%,\n        color-mix(in srgb, var(--bubble-color) 48%, var(--color-canvas)) 100%\n      );\n      border-radius: var(--bubble-shape);\n      content: \"\";\n      inset: 0;\n      position: absolute;\n      transform: rotate(var(--bubble-rotate));\n      z-index: -1;\n    }\n\n    @media (any-hover: hover) {\n      &:hover:before {\n        animation: wobble 1200ms;\n      }\n    }\n\n    svg {\n      display: block;\n      letter-spacing: 0.2ch;\n      text-transform: uppercase;\n    }\n  }\n\n  .bubble__number {\n    display: grid;\n    font-size: clamp(10px, 50cqi, var(--bubble-number-max)); /* FF bug: https://app.fizzy.do/5986089/boards/2/cards/1373 */\n    font-weight: 900;\n    inset: 0;\n    place-content: center;\n    position: absolute;\n    text-align: center;\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/buttons.css",
    "content": "@layer components {\n  .btn {\n    --icon-size: var(--btn-icon-size, 1.3em);\n    --btn-border-radius: 99rem;\n    --btn-hover-brightness: 0.9;\n\n    align-items: center;\n    background-color: var(--btn-background, var(--color-canvas));\n    border-radius: var(--btn-border-radius);\n    border: var(--btn-border-size, 1px) solid var(--btn-border-color, var(--color-ink-light));\n    color: var(--btn-color, var(--color-ink));\n    cursor: pointer;\n    display: inline-flex;\n    font-size: 1em;\n    font-weight: var(--btn-font-weight, 600);\n    gap: var(--btn-gap, 0.5em);\n    justify-content: center;\n    padding: var(--btn-padding, 0.5em 1.1em);\n    pointer-events: auto;\n    position: relative;\n    text-decoration: none;\n    transition: 100ms ease-out;\n    transition-property: background-color, border, box-shadow, color, opacity, scale;\n\n    @media (any-hover: hover) {\n      &:hover {\n        filter: brightness(var(--btn-hover-brightness));\n      }\n    }\n\n    html[data-theme=\"dark\"] & {\n      --btn-hover-brightness: 1.25;\n    }\n\n    @media (prefers-color-scheme: dark) {\n      html:not([data-theme]) & {\n        --btn-hover-brightness: 1.25;\n      }\n    }\n\n    &[disabled],\n    &:has([disabled]),\n    [disabled] &[type=submit],\n    &[type=submit]:disabled {\n      cursor: not-allowed;\n      opacity: 0.3;\n      pointer-events: none;\n    }\n\n    form[aria-busy] &:disabled {\n      position: relative;\n\n      > * {\n        visibility: hidden;\n      }\n\n      &::after {\n        --mask: no-repeat radial-gradient(#000 68%,#0000 71%);\n        --size: 1.25em;\n\n        -webkit-mask: var(--mask), var(--mask), var(--mask);\n        -webkit-mask-size: 28% 45%;\n        animation: submitting 1s infinite linear;\n        aspect-ratio: 8/5;\n        background: currentColor;\n        content: \"\";\n        inline-size: var(--size);\n        inset: 50%;\n        margin-block: calc((var(--size) / 3) * -1);\n        margin-inline: calc((var(--size) / 2) * -1);\n        position: absolute;\n      }\n    }\n  }\n\n  /* Variants\n  /* ------------------------------------------------------------------------ */\n\n  .btn--plain {\n    --btn-background: transparent;\n    --btn-border-radius: 0;\n    --btn-border-size: 0;\n    --btn-color: inherit;\n    --btn-icon-size: 100%;\n    --btn-padding: 0;\n  }\n\n  .btn--link {\n    --btn-background: var(--color-link);\n    --btn-border-color: var(--color-canvas);\n    --btn-color: var(--color-ink-inverted);\n    --focus-ring-color: var(--color-link);\n  }\n\n  .btn--circle,\n  .btn[aria-label]:where(:has(.icon)),\n  .btn:where(:has(.for-screen-reader):has(.icon)) {\n    --btn-padding: 0;\n    --icon-size: 75%;\n\n    aspect-ratio: 1;\n    block-size: var(--btn-size);\n    display: grid;\n    inline-size: var(--btn-size);\n    justify-content: normal; /* FF fix */\n    place-items: center;\n\n    > * {\n      grid-area: 1/1;\n    }\n  }\n\n  /* Make a normal button circular on mobile */\n  @media (max-width: 639px) {\n    .btn--circle-mobile {\n      --btn-size: 3em;\n      --btn-padding: 0;\n      --icon-size: 75%;\n\n      aspect-ratio: 1;\n      inline-size: var(--btn-size);\n\n      kbd,\n      span:last-of-type:not(.icon) {\n        display: none;\n      }\n    }\n  }\n\n  @media (min-width: 640px) {\n    .btn .icon--mobile-only {\n      display: none !important;\n    }\n  }\n\n  .btn--negative {\n    --btn-background: var(--color-negative);\n    --btn-border-color: var(--color-negative);\n    --btn-color: var(--color-ink-inverted);\n    --focus-ring-color: var(--color-negative);\n  }\n\n  .btn--positive {\n    --btn-background: var(--color-positive);\n    --btn-border-color: var(--color-canvas);\n    --btn-color: var(--color-ink-inverted);\n    --focus-ring-color: var(--color-positive);\n  }\n\n  .btn--success {\n    --success-timing-function: cubic-bezier(0.25, 1.25, 0.5, 1);\n    animation: success 1s var(--success-timing-function);\n\n    .icon {\n      animation: zoom-fade 500ms var(--success-timing-function);\n    }\n  }\n\n  /* Fake button used to help space things out */\n  .btn--placeholder {\n    pointer-events: none;\n    visibility: hidden;\n  }\n\n  .btn--remove {\n    --btn-icon-size: 0.7em;\n  }\n\n  .btn--reversed {\n    --btn-background: var(--color-ink);\n    --btn-border-color: var(--color-canvas);\n    --btn-color: var(--color-canvas);\n    --focus-ring-color: var(--color-ink);\n  }\n\n  /* Toggleable buttons\n  /* ------------------------------------------------------------------------ */\n\n  .btn {\n    &:has(input[type=radio], input[type=checkbox]) {\n      position: relative;\n\n      :is(input[type=radio], input[type=checkbox]) {\n        appearance: none;\n        border-radius: var(--btn-border-radius);\n        cursor: pointer;\n        display: flex;\n        inset: 0;\n        margin: 0;\n        padding: 0;\n        position: absolute;\n\n        &:focus-visible {\n          outline: none;\n        }\n      }\n\n      .checked {\n        display: none;\n      }\n    }\n\n    &:has(input:checked)  {\n      --btn-background: var(--color-ink);\n      --btn-border-color: var(--color-ink);\n      --btn-color: var(--color-ink-inverted);\n      --focus-ring-color: var(--color-ink);\n\n      .checked {\n        display: block;\n      }\n    }\n\n    &:has(input:focus-visible)  {\n      outline: var(--focus-ring-size) solid var(--focus-ring-color);\n      outline-offset: var(--focus-ring-offset);\n    }\n  }\n\n  .btn--back {\n    --btn-border-size: 0;\n\n    @media (max-width: 639px) {\n      strong, kbd {\n        display: none;\n      }\n    }\n\n    @media (min-width: 640px) {\n      font-size: var(--text-medium);\n\n      .icon--arrow-left {\n        display: none;\n      }\n    }\n  }\n\n\n  /* Button groups\n  /* ------------------------------------------------------------------------ */\n\n  .btn__group {\n    .btn {\n      --btn-border-radius: 0;\n      --radius: 0.3em;\n\n      flex: 1 0 33%;\n      inline-size: 100%;\n      justify-content: center;\n      white-space: nowrap;\n    }\n\n    form {\n      flex: 1 1 0%;\n    }\n\n    :first-of-type .btn {\n      border-end-start-radius: var(--radius);\n      border-inline-end: 0;\n      border-start-start-radius: var(--radius);\n      padding-inline-end: 0.8em;\n    }\n\n    :last-of-type .btn {\n      border-end-end-radius: var(--radius);\n      border-inline-start: 0;\n      border-start-end-radius: var(--radius);\n      padding-inline-start: 0.8em;\n    }\n\n    span {\n      inline-size: 100%;\n    }\n  }\n\n  /* Button utilities\n  /* ------------------------------------------------------------------------ */\n\n  :is([data-platform~=mobile], [data-platform~=native]) {\n    .btn--ensure-tap-target-size {\n      --tap-target-z-index: 1;\n      --tap-target-min-size: 44px;\n\n      z-index: var(--tap-target-z-index);\n\n      &::before {\n        content: \"\";\n        display: block;\n        block-size: var(--tap-target-min-size);\n        inline-size: var(--tap-target-min-size);\n        inset: calc(50% - var(--tap-target-min-size) / 2) auto auto calc(50% - var(--tap-target-min-size) / 2);\n        opacity: 0;\n        position: absolute;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/card-columns.css",
    "content": "@layer components {\n  /* Layout adjustments for contained scrolling\n  /* ------------------------------------------------------------------------ */\n\n  /* Scroll columns individually on mobile */\n  @media (max-width: 639px) {\n    body.contained-scrolling {\n      block-size: 100dvh;\n      grid-template-rows: 1fr var(--footer-height);\n\n      #global-container {\n        display: grid;\n        grid-template-rows: auto 1fr;\n        overflow: hidden;\n      }\n\n      #main {\n        display: grid;\n        grid-template-rows: auto auto 1fr;\n        overflow: auto;\n        padding: 0;\n      }\n\n      /* Adapt the grid to public views (no filters or watchers sections) */\n      &.public #main {\n        grid-template-rows: 1fr;\n      }\n    }\n  }\n\n  /* Column container\n  /* ------------------------------------------------------------------------ */\n\n  #main:has(.card-columns) {\n    --main-padding: 0;\n  }\n\n  .card-columns {\n    --bubble-size: 3.5rem;\n    --cards-gap: min(1.2cqi, 1.7rem);\n    --column-gap: 8px;\n    --column-padding: calc(var(--column-gap) * 2);\n    --column-transition-duration: 300ms;\n    --column-width-collapsed: 40px;\n    --column-width-expanded: 450px;\n    --progress-increment: var(--progress-max-height) / var(--progress-max-cards);\n    --progress-max-cards: 15; /* should match first geared pagination page size */\n    --progress-max-height: 50dvh;\n\n    container-type: inline-size;\n    display: grid;\n    gap: var(--column-gap);\n    grid-template-columns: 1fr auto 1fr;\n    inline-size: 100%;\n    margin-inline: auto;\n    max-inline-size: var(--main-width);\n    outline: none;\n    overflow-x: auto;\n    overflow-y: hidden;\n    position: relative;\n\n    /* When it has something expanded */\n    &:has(.card-columns__left .is-expanded, .card-columns__right .is-expanded) {\n      grid-template-columns: auto auto auto;\n\n      @media (min-width: 640px) {\n        grid-template-columns: auto var(--column-width-expanded) auto;\n      }\n    }\n\n    &:has(.cards) {\n      block-size: 100%;\n      min-block-size: 20lh;\n    }\n\n    @media (max-width: 639px) {\n      --column-width-expanded: calc(100vw - var(--column-gap) * 4);\n\n      scroll-snap-type: inline mandatory;\n\n      &:not(:has(.is-expanded)) {\n        grid-template-columns: auto var(--column-width-collapsed) auto;\n      }\n    }\n\n    @media (min-width: 640px) {\n      padding-block-end: var(--column-width-collapsed);\n    }\n  }\n\n  .card-columns__left,\n  .card-columns__right {\n    align-items: stretch;\n    display: flex;\n    gap: var(--column-gap);\n    position: relative;\n\n    @media (max-width: 639px) {\n      min-block-size: 0;\n    }\n  }\n\n  .card-columns__left {\n    justify-content: end;\n    margin-inline-start: auto;\n    padding-inline-start: var(--column-gap);\n\n    @media (max-width: 639px) {\n      padding-inline-start: calc(var(--column-gap) * 2);\n    }\n  }\n\n  .card-columns__right {\n    justify-content: start;\n    padding-inline-end: var(--column-gap);\n    margin-inline-end: auto;\n  }\n\n  /* Column\n  /* ------------------------------------------------------------------------ */\n\n  .cards {\n    --column-color: color-mix(in srgb, var(--card-color) 15%, var(--color-canvas));\n\n    inline-size: var(--column-width-expanded);\n    outline: none;\n    position: relative;\n    scroll-snap-align: center;\n\n    &.is-expanded {\n      @media (max-width: 639px) {\n        overflow: hidden;\n      }\n    }\n\n    &.is-collapsed {\n      inline-size: var(--column-width-collapsed);\n\n      .pagination-link.pagination-link--active-when-observed,\n      .card {\n        display: none;\n      }\n    }\n\n    &.drag-and-drop__hover-container {\n      --dnd-bg-color: transparent;\n      --dnd-border-color: transparent;\n\n      &.is-off-screen {\n        &:after {\n          content: attr(data-column-name);\n          font-size: var(--text-x-small);\n          font-weight: 500;\n          line-height: var(--column-width-collapsed);\n          padding-inline: 1ch;\n          position: fixed;\n          text-transform: uppercase;\n          top: 0;\n          translate: -50%;\n        }\n\n        &.is-collapsed {\n          &:after {\n            writing-mode: vertical-rl;\n          }\n        }\n\n        &:not(.is-collapsed) {\n          &:after {\n            background-color: var(--column-color);\n            inline-size: calc(var(--column-width-expanded) - 4px); /* make room for the dnd border */\n          }\n        }\n      }\n    }\n\n    @media (any-hover: hover) {\n      .card:has(.card__background img:not([src=\"\"])):hover .card__background img:not([src=\"\"]) {\n        filter: blur(3px) brightness(1.2);\n        opacity: 0.2;\n      }\n    }\n  }\n\n  .cards__transition-container {\n    block-size: 100%;\n    border-radius: calc(var(--column-width-collapsed) / 2);\n    margin-block-start: 0.5ch; /* Allow a little room for the mini bubble */\n    transition: translate var(--column-transition-duration) var(--ease-out-overshoot-subtle);\n\n    @media (min-width: 640px) {\n      .is-expanded & {\n        translate: 0; /* Animate back from collapsed state */\n      }\n\n      .is-collapsed & {\n        margin-block-start: 0;\n        translate: 0 var(--column-width-collapsed);\n      }\n    }\n\n    .drag-and-drop__hover-container & {\n      --dnd-bg-color: var(--column-color);\n      --dnd-border-color: var(--card-color);\n\n      background-color: var(--dnd-bg-color);\n      outline: 2px dashed var(--dnd-border-color);\n      outline-offset: -2px;\n      transition: background-color 200ms;\n      z-index: 1;\n    }\n\n    .no-transitions & {\n      transition: none;\n    }\n\n    /* Use flex so the __list container can take up the remaining space for scrolling */\n    @media (max-width: 639px) {\n      .is-expanded & {\n        display: flex;\n        flex-direction: column;\n      }\n    }\n  }\n\n  /* The wrapper around the cards used to clip overflow while transitioning.\n   * Also, don't resize cards while transitioning to avoid reflow. */\n  .cards__list {\n    display: flex;\n    flex-direction: column;\n    gap: var(--cards-gap);\n    overflow-x: hidden;\n    overflow-y: auto;\n\n    .is-expanded & {\n      padding: var(--column-padding) var(--column-padding) calc(var(--column-padding) + var(--custom-safe-inset-bottom));\n\n      /* Use the rest of the column height for scrolling */\n      @media (max-width: 639px) {\n        flex: 1;\n        padding-inline: calc(var(--column-padding) / 4);\n      }\n    }\n\n    [aria-selected] & .card[aria-selected] {\n      outline: var(--focus-ring-size) solid var(--color-selected-dark);\n      outline-offset: var(--focus-ring-offset);\n\n      html[data-theme=\"dark\"] & {\n        outline-color: oklch(var(--lch-blue-medium));\n      }\n\n      @media (prefers-color-scheme: dark) {\n        html:not([data-theme]) & {\n          outline-color: oklch(var(--lch-blue-medium));\n        }\n      }\n    }\n\n    &:has(.card) {\n      .blank-slate {\n        display: none;\n      }\n    }\n\n    /* Use the default blank-slate on small viewports since drag-and-drop isn't available */\n    [data-controller~=\"drag-and-drop\"] & {\n      @media (max-width: 639px) {\n        .blank-slate--drag {\n          display: none;\n        }\n      }\n\n      @media (min-width: 640px) {\n        .blank-slate--default {\n          display: none;\n        }\n      }\n    }\n  }\n\n  .cards__new-column {\n    position: relative;\n\n    @media (max-width: 639px) {\n      inset-inline-end: 0;\n      position: absolute;\n      translate: 100%;\n      z-index: 2;\n    }\n\n    @media (min-width: 640px) {\n      margin-block-start: var(--column-width-collapsed);\n    }\n  }\n\n  /* Cards grid; used when filtering\n  /* -------------------------------------------------------------------------- */\n\n  .cards--grid {\n    --cards-gap: 1rem;\n    --card-grid-columns: 1;\n\n    container-type: inline-size;\n    inline-size: 100%;\n    margin-inline: auto;\n    max-inline-size: var(--main-width);\n\n    @media (min-width: 640px) {\n      --card-grid-columns: 2;\n    }\n\n    @media (min-width: 960px) {\n      --card-grid-columns: 3;\n    }\n\n    .cards__list {\n      display: flex;\n      flex-direction: row;\n      flex-wrap: wrap;\n      gap: var(--cards-gap);\n      justify-content: center;\n      padding: 1ch;\n    }\n\n    .card {\n      inline-size: calc((100% - var(--cards-gap) * (var(--card-grid-columns) - 1) ) / var(--card-grid-columns));\n    }\n\n    .card__header .card__column-name--current {\n      --btn-padding: 0.1em 0.5em;\n\n      background: none;\n      border: 1px solid currentColor;\n      color: var(--card-color);\n      display: inline-flex;\n      flex: 0 1 auto;\n      inline-size: fit-content;\n      margin: 0 0 0 auto;\n    }\n\n    .blank-slate--drag {\n      display: none;\n    }\n  }\n\n  /* Column Elements\n  /* ------------------------------------------------------------------------ */\n\n  .cards__header {\n    .cards.is-collapsed & {\n      block-size: 100%;\n    }\n\n    .cards.is-expanded & {\n      display: grid;\n      grid-template-areas: \"menu expander maximize\";\n      grid-template-columns: var(--column-width-collapsed) 1fr var(--column-width-collapsed);\n      padding-inline: var(--column-padding);\n    }\n  }\n\n  .cards__menu .btn--circle,\n  .cards__maximize-button {\n    --btn-background: transparent;\n\n    block-size: var(--column-width-collapsed);\n    inline-size: var(--column-width-collapsed);\n    opacity: 0;\n    outline-offset: -2px;\n\n    .cards:hover &,\n    &:focus-visible {\n      opacity: 1;\n    }\n\n    .cards.is-collapsed & {\n      display: none;\n    }\n  }\n\n  .cards__menu {\n    position: relative;\n    z-index: var(--z-popup);\n  }\n\n  .cards__maximize-button {\n    grid-area: maximize;\n  }\n\n  .cards__expander {\n    --gradient-direction: to bottom;\n\n    align-items: center;\n    border-radius: 99rem;\n    cursor: pointer;\n    display: flex;\n    flex-direction: row-reverse;\n    font-size: var(--text-x-small);\n    font-weight: 600;\n    gap: 0.5ch;\n    grid-area: expander;\n    justify-content: center;\n    outline: none;\n    outline-offset: -2px;\n    position: relative;\n    text-transform: uppercase;\n\n    &[disabled] {\n      opacity: 1;\n    }\n\n    @media (any-hover: hover) {\n      .is-collapsed:hover {\n        filter: brightness(0.9);\n      }\n    }\n\n    /* Progress */\n    &:after {\n      background: linear-gradient(var(--gradient-direction), var(--card-color), var(--column-color) 80%);\n      block-size: var(--column-width-collapsed);\n      border-radius: 99rem;\n      content: \"\";\n      inset: 0 0 auto;\n      margin-inline: auto;\n      max-block-size: var(--progress-max-height);\n      min-block-size: var(--column-width-collapsed);\n      opacity: 0;\n      position: absolute;\n      transition:\n        block-size 500ms var(--ease-out-overshoot),\n        inline-size var(--column-transition-duration) ease-out,\n        opacity var(--column-transition-duration) ease-out;\n      z-index: -1;\n    }\n\n    .no-transitions &:after {\n      transition: none;\n    }\n\n    .cards.is-collapsed & {\n      block-size: 100%;\n      flex-direction: column;\n      inline-size: var(--column-width-collapsed);\n      justify-content: start;\n      letter-spacing: 0.05em;\n\n      /* Guitar string */\n      &:before {\n        background-color: var(--column-color);\n        block-size: 100%;\n        content: \"\";\n        inline-size: 1px;\n        inset-block: calc(var(--column-width-collapsed) + var(--card-count) * var(--progress-increment)) 0;\n        position: absolute;\n        z-index: -2;\n      }\n\n      &:after {\n        block-size: calc(var(--column-width-collapsed) + var(--card-count) * var(--progress-increment));\n        max-block-size: none;\n        opacity: 1;\n        inline-size: var(--column-width-collapsed);\n      }\n    }\n\n    .cards.is-expanded & {\n      inline-size: 100%;\n      justify-content: center;\n    }\n  }\n\n  .cards__expander-count {\n    line-height: var(--column-width-collapsed);\n    inline-size: var(--column-width-collapsed);\n\n    .cards.is-expanded & {\n      display: none;\n    }\n  }\n\n  .cards__expander-title {\n    font-weight: inherit;\n    font-size: inherit;\n    line-height: var(--column-width-collapsed);\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n\n    .cards.is-collapsed & {\n      max-inline-size: 50vh;\n      writing-mode: vertical-rl;\n    }\n\n    .cards.is-expanded & {\n      align-items: center;\n      display: flex;\n      gap: 0.25ch;\n      max-inline-size: calc(100% - var(--column-width-collapsed) * 2);\n    }\n\n    .icon--collapse {\n    --icon-size: 1.15em;\n\n      opacity: 0.66;\n      transition: 150ms ease-out;\n      transition-property: opacity, scale;\n\n      @media (min-width: 640px) {\n        opacity: 0;\n        scale: 1.5;\n      }\n\n      .cards.is-collapsed & {\n        display: none;\n      }\n\n      .cards.is-expanded .cards__expander:hover & {\n        opacity: 0.66;\n        scale: 1;\n      }\n    }\n  }\n\n  /* Override card styles within columns\n  /* Adding .board-tools here since it sits outside the cards container on mobile */\n  /* ------------------------------------------------------------------------ */\n\n  .cards .card,\n  .board-tools {\n    --block-space: 1em;\n    --block-space-half: 0.5em;\n    --card-padding-inline: 1em;\n    --text-xx-large: 1.6em;\n    --text-x-small: 1em;\n\n    /* Set lower limit for font size */\n    font-size: clamp(0.6rem, 0.85cqi, 100px);\n\n    .card__counts {\n      --gap: 0.5ch;\n\n      align-items: flex-end;\n      display: flex;\n      flex-shrink: 0;\n      gap: calc(2 * var(--gap));\n      margin-inline: auto calc(var(--card-padding-inline) * -0.5);\n      padding-inline-start: var(--gap);\n    }\n\n    .card__boosts,\n    .card__comments {\n      --icon-size: 1.6em;\n\n      align-items: center;\n      display: flex;\n      flex-shrink: 0;\n      font-weight: 600;\n      gap: var(--gap);\n\n      img {\n        block-size: var(--icon-size);\n        inline-size: var(--icon-size);\n      }\n\n      .icon--comment {\n        color: var(--card-color);\n      }\n    }\n\n    .card__steps {\n      --column-gap: 0.8ch;\n\n      display: flex;\n    }\n\n    .card__tags {\n      gap: calc(var(--card-header-space) / 2);\n    }\n\n    .card__title {\n      pointer-events: none;\n    }\n\n    .card__link {\n      z-index: 1;\n    }\n\n    .card__stages,\n    .card__hide-on-index {\n      display: none;\n    }\n\n    .card__body {\n      padding-block: calc(var(--card-padding-block) * 0.75) var(--card-padding-block);\n    }\n\n    .card__meta {\n      font-weight: 600;\n\n      strong,\n      .local-time-value {\n        font-weight: inherit;\n      }\n\n      @media (max-width: 639px) {\n        inline-size: auto;\n      }\n    }\n\n    &:has(.card__background img:not([src=\"\"])) {\n      .card__content,\n      .card__meta,\n      .card__boosts,\n      .card__comments,\n      .card__column-name:not(.card__column-name--current) {\n        opacity: 0;\n        transition: opacity 0.2s ease-in-out;\n      }\n\n      @media (any-hover: hover) {\n        &:hover {\n          .card__content,\n          .card__footer,\n          .card__boosts,\n          .card__comments,\n          .card__column-name:not(.card__column-name--current) {\n            opacity: 1;\n          }\n\n          .card__background img {\n            filter: blur(3px) brightness(1.2);\n            opacity: 0.2;\n          }\n        }\n      }\n    }\n\n    .bubble {\n      inset-inline-start: 100%;\n      translate: -90% -40%;\n    }\n  }\n\n  /* Considering\n  /* ------------------------------------------------------------------------ */\n\n  .cards--maybe {\n    --card-color: oklch(var(--lch-blue-medium));\n\n    position: relative;\n\n    .card {\n      --avatar-size: 2.75em;\n      --text-small: 1.1em;\n\n      background-color: var(--color-canvas);\n      line-height: 1.2;\n      z-index: 2;\n\n      @media (min-width: 640px) {\n        --text-xx-large: 1.6em;\n      }\n    }\n\n    .card__board {\n      background-color: transparent;\n      color: var(--card-content-color);\n    }\n\n    .card__header {\n      color: var(--color-ink);\n      padding-block-start: calc(var(--card-padding-block) / 2);\n    }\n\n    .card__tags {\n      color: inherit;\n    }\n\n    .card__body {\n      padding-block: 0 var(--card-padding-block);\n    }\n\n    .card__people-label {\n      display: none;\n    }\n\n    .card__title {\n      min-block-size: 0;\n    }\n  }\n\n  /* Board tools\n  /* -------------------------------------------------------------------------- */\n\n  .board-tools.card {\n    --border-color: var(--color-selected-dark);\n    --border-size: 1px;\n    --card-padding-block: var(--block-space);\n\n    border: 1px solid var(--border-color);\n    inline-size: auto;\n    text-align: center;\n\n    @media (max-width: 639px) {\n      /* On mobile, hide the tool card inside the Maybe column */\n      .cards & {\n        display: none;\n      }\n\n      #cards_container > & {\n        margin: 0 3ch 1ch;\n      }\n    }\n\n    @media (min-width: 640px) {\n      /* On desktop, hide the tool card above the columns */\n      #cards_container > & {\n        display: none;\n      }\n    }\n\n    @media (min-width: 800px) {\n      margin: var(--column-padding) var(--column-padding) 0;\n    }\n\n    &:has(dialog[open]) {\n      z-index: 5;\n    }\n\n    .divider {\n      --divider-color: oklch(var(--lch-blue-light));\n    }\n\n    .btn--link {\n      font-size: 1.2em;\n    }\n\n    .btn:not(.btn--link, .btn--circle) {\n      border: 0;\n      color: var(--color-link);\n    }\n\n    footer {\n      font-size: var(--text-x-small);\n      margin-block: 1ch calc(var(--block-space-half) * -1);\n    }\n\n    .overflow-count {\n      font-size: 1.2em;\n      font-weight: 500;\n      padding: 0.5em 0.3em;\n    }\n  }\n\n  .board-tools__watching {\n    --btn-size: 32px;\n    --gap: 0.5ch;\n\n    display: flex;\n    gap: var(--gap);\n    inline-size: 100%;\n    margin-block: var(--block-space-half);\n    place-content: center;\n    position: relative;\n  }\n\n  .board-tools__watching-dialog {\n    --panel-padding: 2ch;\n    --panel-size: 100%;\n\n    flex-wrap: wrap;\n    gap: var(--gap);\n    inset-block-start: 0;\n    justify-content: center;\n    position: absolute;\n    z-index: 1;\n\n    &[open] {\n      display: flex;\n    }\n  }\n\n  /* On Deck (Not Now)\n  /* ------------------------------------------------------------------------ */\n\n  .cards--closed,\n  .cards--on-deck {\n    --card-color: var(--color-ink-light) !important;\n\n    .card,\n    .blank-slate {\n      --card-color: var(--color-card-complete) !important;\n    }\n\n    .bubble {\n      display: none !important;\n    }\n  }\n\n  /* Doing\n  /* -------------------------------------------------------------------------- */\n\n  /* Surface a mini bubble if there are cards with bubbles inside */\n  .cards--maybe:has(.bubble:not([hidden])) .cards__expander-title,\n  .cards--maybe.is-collapsed:has(.bubble:not([hidden])) .cards__transition-container,\n  .cards--doing.is-collapsed:has(.bubble:not([hidden])) .cards__transition-container {\n    --bubble-color: var(--card-color, oklch(var(--lch-blue-medium)));\n    --bubble-opacity: 75%;\n    --bubble-shape: 54% 46% 61% 39% / 57% 49% 51% 43%;\n\n    &:before {\n      background: radial-gradient(\n        color-mix(in srgb, var(--bubble-color) calc(var(--bubble-opacity) / 5), var(--color-canvas)) 50%,\n        color-mix(in srgb, var(--bubble-color) var(--bubble-opacity), var(--color-canvas)) 100%\n      );\n      block-size: 1em;\n      border-radius: var(--bubble-shape);\n      content: \"\";\n      inline-size: 1em;\n      inset: 0 0 auto auto;\n      position: absolute;\n      translate: 20% -20%;\n      z-index: 1;\n\n      @media (max-width: 639px) {\n        translate: 20% 0%;\n      }\n    }\n\n    /* Maybe column: position bubble relative to the title, not the container */\n    .cards--maybe.is-expanded & {\n      overflow: visible;\n      position: relative;\n\n      &:before {\n        inset-block-start: 50%;\n        inset-inline-start: 0;\n        translate: -125% -75%;\n        z-index: -1;\n      }\n    }\n\n    @media (max-width: 639px) {\n      &.cards__expander-title:before {\n        display: none;\n      }\n    }\n\n    html[data-theme=\"dark\"] & {\n      --bubble-opacity: 100%;\n    }\n\n    @media (prefers-color-scheme: dark) {\n      html:not([data-theme]) & {\n        --bubble-opacity: 100%;\n      }\n    }\n  }\n\n  /* Card column indicators\n  /* -------------------------------------------------------------------------- */\n\n  .card__column-name {\n    --btn-background: transparent;\n    --btn-padding: 0.2em 0.5em;\n    --btn-border-size: 0;\n    --btn-border-radius: 0.2em;\n\n    color: inherit;\n    inline-size: 100%;\n    justify-content: flex-start;\n    text-transform: uppercase;\n\n    @media (hover: hover) {\n      &:not(.card__column-name--current):hover {\n        --btn-background: color(from var(--column-color) srgb r g b / 0.15);\n\n        color: var(--column-color);\n      }\n    }\n  }\n\n  .card__column-name--current {\n    --btn-background: var(--card-color);\n\n    color: var(--color-ink-inverted);\n    opacity: 1 !important;\n\n    @media (hover: hover) {\n      &:hover {\n        --btn-background: var(--card-color);\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/card-perma.css",
    "content": "/* Card container for the perma. Tools and actions and whatnot */\n\n@layer components {\n  .card-perma {\n    --actions-block-inset: 1.5rem;\n    --actions-inline-inset: 4rem;\n    --color-container: color-mix(in srgb, var(--card-color) 33%, var(--color-canvas));\n    --half-btn-height: 1.25rem;\n    --padding-inline: calc(var(--block-space-double) + var(--block-space));\n    --padding-block: calc(var(--block-space-double) + var(--block-space-half));\n\n    align-items: start;\n    column-gap: var(--inline-space);\n    display: grid;\n    grid-template-areas:\n      \"notch-top notch-top notch-top\"\n      \"actions-left card actions-right\"\n      \"notch-bottom notch-bottom notch-bottom\"\n      \"closure-message closure-message closure-message\";\n    grid-template-columns: 48px minmax(0, 1120px) 48px;\n    inline-size: fit-content;\n    margin-block-start: var(--block-space);\n    max-inline-size: 100%;\n    margin-inline: auto;\n    position: relative;\n\n    &:has(dialog[open]) {\n      z-index: 3;\n    }\n\n    &:has(.card-perma__star-input:checked) {\n      .card {\n        outline: 4px solid var(--color-negative);\n      }\n    }\n\n    @media (max-width: 799px) {\n      --half-btn-height: 1.25rem;\n      --padding-inline: 1.5ch;\n\n      column-gap: 0;\n      grid-template-areas:\n        \"notch-top notch-top notch-top\"\n        \"card card card\"\n        \"actions-left notch-bottom actions-right\"\n        \"closure-message closure-message closure-message\";\n      grid-template-columns: 1fr auto 1fr;\n      inline-size: calc(100% + 2 * var(--padding-inline));\n      margin-inline: calc(-1 * var(--padding-inline));\n      max-inline-size: none;\n      position: relative;\n    }\n\n    .card {\n      --card-aspect-ratio: 2 / 0.95;\n      --lexxy-bg-color: var(--card-bg-color);\n\n      border: none;\n    }\n\n    .card__background {\n      filter: brightness(1.2) contrast(0.8);\n      opacity: 0.2;\n    }\n\n    .card__header {\n      @media (max-width: 639px) {\n        flex-wrap: wrap;\n        gap: var(--card-header-space) unset;\n      }\n    }\n\n    .card__tags {\n      @media (max-width: 639px) {\n        padding: 0.25lh;\n      }\n    }\n\n    .card__tags-list {\n      @media (min-width: 640px) {\n        text-overflow: ellipsis;\n        white-space: nowrap;\n        overflow: hidden;\n      }\n    }\n\n    .card__body {\n      position: relative;\n\n      @media (max-width: 639px) {\n        flex-direction: column;\n        padding-block: var(--card-padding-block) calc(var(--card-padding-block) * 1.5);\n        position: static;\n      }\n    }\n\n    .card__content {\n      display: flex;\n      flex-direction: column;\n      gap: 1ch;\n    }\n\n    .card__title {\n      font-size: clamp(var(--text-medium), 6vw, var(--text-x-large));\n      margin-block-end: 0.5ch;\n\n      /* With tight line spacing, Windows will cover over adjacent lines of text\n      * when input text is selected. Here, we're setting the selection color to a\n      * transparent value so the overlapping text lines are at least visible. */\n      ::selection {\n        background: oklch(var(--lch-blue-light) / 0.5);\n      }\n\n      &:has(textarea) {\n        @media (min-width: 640px) {\n          margin-block-end: 0;\n        }\n\n        @supports not (field-sizing: content) {\n          text-wrap: unset; /* Safari is annoying if you have text-wrap: balance in textareas */\n        }\n      }\n\n      @media (max-width: 639px) {\n        margin-block-end: 0.75ch;\n      }\n    }\n\n    .card__description {\n      @media (max-width: 639px) {\n        margin-block-end: 1ch;\n      }\n    }\n\n    .card__meta,\n    .card__stages {\n      @media (min-width: 640px) {\n        font-size: var(--text-small);\n      }\n    }\n\n    .card__meta {\n      grid-area: meta;\n      margin-inline-end: auto;\n\n      @media (max-width: 639px) {\n        --meta-spacer-block: 0.75ch;\n\n        min-inline-size: 0;\n        gap: calc(var(--meta-spacer-block) / 2);\n        display: flex;\n        flex-wrap: wrap;\n\n        .card__meta-text {\n          border: 0;\n          padding: 0;\n        }\n\n        .card__meta-avatars--author {\n          --btn-size: 1.5em;\n\n          display: initial;\n          margin-inline-end: unset;\n          order: 3;\n        }\n\n        .card__meta-text--added {\n          inline-size: 100%;\n          order: 1;\n        }\n\n        .card__meta-text--author {\n          order: 2;\n        }\n\n        .card__meta-text--updated {\n          border-block-start: var(--card-border);\n          inline-size: 100%;\n          margin-block-start: calc(var(--meta-spacer-block) * 0.5);\n          order: 4;\n          padding-block-start: var(--meta-spacer-block);\n        }\n\n        .card__meta-text--assignees {\n          margin-block-start: calc(var(--meta-spacer-block) * 3);\n          order: 6;\n          white-space: unset !important;\n        }\n\n        .card__meta-avatars--assignees {\n          margin-inline: 0 var(--meta-spacer-inline);\n          margin-block-start: calc(var(--meta-spacer-block) * 3);\n          order: 5;\n\n          .avatar {\n            display: grid;\n          }\n        }\n\n        &:has(.card__meta-avatars--assignees .avatar) {\n          .card__meta-text--assignees {\n            order: 5;\n          }\n\n          .card__meta-avatars--assignees {\n            margin-block-start: var(--meta-spacer-block);\n            order: 6;\n          }\n        }\n      }\n    }\n\n    &:has(.card__closed) .card__meta {\n      @media (max-width: 639px) {\n        .card__meta-avatars--assignees {\n          display: none;\n        }\n      }\n    }\n\n    .card__stages {\n      max-inline-size: 32ch;\n\n      @media (max-width: 639px) {\n        border: 1px solid var(--card-color);\n        border-radius: calc(0.2em + 3px);\n        flex-direction: row;\n        gap: 0;\n        overflow: auto;\n        max-inline-size: 100%;\n        padding: 3px;\n        position: relative;\n        white-space: nowrap;\n\n        & > form {\n          flex-grow: 1;\n          max-inline-size: 25ch;\n          min-block-size: 2.5em;\n        }\n\n        & > form:not(:has(.card__column-name--current)) + form:not(:has(.card__column-name--current)) {\n          box-shadow: -1px 0 0 0 var(--color-container);\n        }\n      }\n    }\n\n    .card__column-name {\n      @media (max-width: 639px) {\n        justify-content: center;\n      }\n    }\n\n    .card__closed {\n      @media (max-width: 639px) {\n        inset: auto 0 3rem auto;\n        scale: 75%;\n      }\n    }\n\n    .card__footer {\n      --btn-size: 2.5rem;\n\n      display: flex;\n      gap: 0.5ch;\n      inline-size: 100%;\n      text-align: start;\n\n      /* Switch to grid layout so that the bg zoom button can stay next to the\n       * meta element, and the reactions can sit below */\n      &:has(.reaction) {\n        display: grid;\n        grid-template-columns: 1fr auto;\n        grid-template-areas:\n          \"meta bg-zoom\"\n          \"reactions reactions\";\n      }\n\n      @media (max-width: 639px) {\n        display: grid;\n        font-size: var(--text-x-small);\n        gap: 1ch 0;\n        grid-template-columns: 1fr auto;\n        grid-template-areas:\n          \"meta bg-zoom\"\n          \"meta reactions\";\n\n        &:not(:has(.reaction)),\n        &:has(.card__background) {\n          column-gap: 2ch;\n        }\n      }\n    }\n\n    .reactions {\n      --reaction-size: var(--btn-size);\n\n      align-self: flex-end;\n      display: flex;\n      gap: 0.5ch;\n      grid-area: reactions;\n      margin-inline-start: auto;\n\n      &:has(.reaction) {\n        --padding: calc(var(--card-padding-block) / 2);\n        --reaction-size: 1.6875rem;\n\n        margin-block: var(--padding) calc(-1 * var(--padding));\n        padding-block-start: var(--padding);\n        position: relative;\n\n        &:before {\n          border-block-start: 1px dashed color-mix(in srgb, transparent, var(--card-color) 33%);\n          content: \"\";\n          inset: 0 calc(-1 * var(--card-padding-inline)) auto;\n          position: absolute;\n        }\n\n        @media (any-hover: none) {\n          --reaction-size: 2.25rem;\n        }\n      }\n\n      &:not(:has(.reaction)) {\n        margin: 0;\n\n        .reactions__trigger {\n          --btn-border-color: var(--color-ink-light);\n        }\n      }\n    }\n\n    .reaction__popup.popup {\n      inline-size: max-content;\n    }\n\n    .card__zoom-bg-btn {\n      grid-area: bg-zoom;\n    }\n\n    .bubble {\n      --bubble-number-max: 42px;\n      --bubble-size: 6rem;\n\n      inset: calc(var(--bubble-size) / -4) calc(var(--bubble-size) / 1.5) auto auto;\n      translate: 0 0;\n\n      @media (max-width: 799px) {\n        --bubble-size: 4.5rem;\n\n        inset: calc(var(--bubble-size) / 1.5) 0 auto auto;\n      }\n    }\n  }\n\n  /* Child items\n  /* ------------------------------------------------------------------------ */\n\n  .card-perma__bg {\n    background-color: var(--color-container);\n    border-radius: 0.2em;\n    grid-area: card;\n    padding: clamp(2rem, 4vw, var(--padding-block));\n\n    @media (max-width: 639px) {\n      padding: clamp(0.25rem, 2vw, var(--padding-block));\n      padding-block-end: clamp(2.5rem, 4vw, var(--padding-block));\n    }\n\n    @media (min-width: 640px) and (max-width: 799px) {\n      padding-inline: var(--padding-inline);\n    }\n  }\n\n  .card-perma__actions {\n    display: grid;\n    gap: var(--block-space-half);\n\n    &:has([open]) {\n      position: relative;\n      z-index: 1;\n    }\n\n    &:has([data-controller~=\"tooltip\"]:hover) {\n      z-index: var(--z-tooltip);\n    }\n  }\n\n  .card-perma__actions--left { grid-area: actions-left; }\n  .card-perma__actions--right { grid-area: actions-right; }\n\n  @media (max-width: 799px) {\n    .card-perma__actions {\n      display: flex;\n      padding-inline: var(--padding-inline);\n      translate: 0 -50%;\n    }\n\n    .card-perma__actions--right {\n      inset-inline-end: 0;\n      justify-content: flex-end;\n    }\n  }\n\n  .card-perma__image-btn {\n    &:has(input[type=\"file\"]:focus),\n    &:has(input[type=\"file\"]:focus-visible) {\n      outline: var(--focus-ring) !important;\n      outline-offset: var(--focus-ring-offset);\n    }\n\n    input[type=\"file\"] {\n      outline: none;\n    }\n  }\n\n  .card__banner {\n    align-items: center;\n    background-color: var(--color-highlight);\n    border-radius: 2em;\n    color: color-mix(in srgb, var(--card-color) 40%, var(--color-ink));\n    display: inline-flex;\n    inline-size: auto;\n    gap: var(--inline-space-half);\n    grid-area: notch-top;\n    justify-content: center;\n    margin-block-start: -4ch;\n    margin-inline: auto;\n    max-inline-size: 36ch;\n    padding: var(--block-space-half) var(--block-space);\n    position: relative;\n    text-align: center;\n    translate: 0 50%;\n    z-index: 0;\n\n    .btn {\n      --btn-background: var(--card-color);\n      --btn-border-color: var(--card-color);\n      --btn-color: var(--color-ink-inverted);\n    }\n  }\n\n  /* Notches\n  /* -------------------------------------------------------------------------- */\n\n  .card-perma__notch {\n    align-items: center;\n    color: color-mix(in srgb, var(--card-color) 40%, var(--color-ink));\n    display: inline-flex;\n    inline-size: auto;\n    gap: var(--inline-space);\n    justify-content: center;\n    margin-inline: auto;\n    position: relative;\n    text-align: center;\n    z-index: 0;\n  }\n\n  .card-perma__notch--top {\n    grid-area: notch-top;\n    inline-size: 100%;\n    margin-block-start: -4ch;\n    max-inline-size: 36ch;\n    padding-inline: 1ch;\n    translate: 0 50%;\n\n    .btn {\n      --btn-border-color: var(--card-color);\n      --btn-color: var(--card-color);\n\n      text-align: center;\n\n      &:has(input:checked)  {\n        --btn-background: var(--card-color);\n        --btn-border-color: var(--card-color);\n        --btn-color: var(--color-ink-inverted);\n      }\n    }\n  }\n\n  .card-perma__notch--bottom {\n    grid-area: notch-bottom;\n\n    /* Overlap the card BG by half the button height */\n    &:has(.btn) {\n      translate: 0 calc(-1 * var(--half-btn-height));\n    }\n\n    form {\n      background-color: var(--color-canvas);\n      border-radius: 99rem;\n    }\n\n    .btn:not(.popup__btn, .btn--plain, .btn--reversed, .settings-subscription__button) {\n      --btn-background: var(--card-color);\n      --btn-color: var(--color-ink-inverted);\n    }\n\n    .btn--reversed {\n      --btn-background: var(--color-canvas);\n      --btn-color: var(--card-color);\n      --btn-border-color: var(--color-container);\n    }\n\n    @media (max-width: 639px) {\n      flex-direction: column;\n    }\n  }\n\n  .card-perma__notch-new-card-buttons {\n    display: flex;\n    gap: var(--inline-space-half);\n\n    @media (max-width: 479px) {\n      flex-direction: column;\n\n      .btn {\n        inline-size: 100%;\n      }\n    }\n  }\n\n  .card-perma__closure-message {\n    color: var(--card-color);\n    grid-area: closure-message;\n    margin-block: var(--block-space) var(--block-space-double);\n    padding-inline: 1ch;\n\n    .btn--plain {\n      --btn-color: var(--card-color);\n\n      text-decoration: underline;\n    }\n\n    @media (max-width: 799px) {\n      margin-block: var(--block-space-half);\n      translate: 0 calc(-0.5 * var(--half-btn-height));\n    }\n\n    @media (min-width: 800px) {\n      .card-perma__notch--bottom:has(.btn) ~ & {\n        margin-block: var(--block-space-half) var(--block-space);\n        translate: 0 calc(-0.5 * var(--half-btn-height));\n      }\n    }\n  }\n\n  .card-perma__account-limit-message {\n    background-color: var(--color-canvas);\n    border: 2px solid var(--color-container);\n    border-radius: 4px;\n    margin-block-start: calc(var(--padding-block) / -2);\n    padding: 1ch 2ch;\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/cards.css",
    "content": "@layer components {\n  /* Base\n  /* ------------------------------------------------------------------------ */\n\n  .card {\n    --avatar-size: 2.75em;\n    --card-bg-color: color-mix(in srgb, var(--card-color) 4%, var(--color-canvas));\n    --card-content-color: color-mix(in srgb, var(--card-color) 30%, var(--color-ink));\n    --card-text-color: color-mix(in srgb, var(--card-color) 75%, var(--color-ink));\n    --card-border: 1px solid color-mix(in srgb, var(--card-color) 33%, var(--color-ink-inverted));\n    --card-header-space: 1ch;\n    --card-padding-inline: var(--inline-space-double);\n    --card-padding-block: var(--block-space);\n    --border-color: transparent;\n    --border-radius: 0.2em;\n    --border-size: 0;\n\n    aspect-ratio: var(--card-aspect-ratio, auto);\n    background-color: var(--card-bg-color);\n    border-radius: var(--border-radius);\n    box-shadow: var(--shadow);\n    display: flex;\n    flex-direction: column;\n    inline-size: 100%;\n    padding: var(--card-padding-block) var(--card-padding-inline);\n    position: relative;\n    text-align: start;\n    z-index: 1;\n\n    html[data-theme=\"dark\"] & {\n      box-shadow: 0 0 0 1px var(--color-ink-lighter);\n    }\n\n    @media (prefers-color-scheme: dark) {\n      html:not([data-theme]) & {\n        box-shadow: 0 0 0 1px var(--color-ink-lighter);\n      }\n    }\n\n    .popup {\n      inline-size: 260px;\n    }\n  }\n\n  /* Header\n  /* ------------------------------------------------------------------------ */\n\n  .card__header {\n    align-items: center;\n    border-radius: var(--border-radius) 0 0 0;\n    display: flex;\n    flex-wrap: nowrap;\n    gap: var(--card-header-space);\n    margin-block-start: calc(-1 * var(--card-padding-block));\n    margin-inline: calc(-1 * var(--card-padding-inline)) calc(-0.5 * var(--card-padding-inline));\n    max-inline-size: unset;\n    min-inline-size: 0;\n\n    .card__column-name {\n      display: none;\n    }\n  }\n\n  .card__board {\n    align-items: center;\n    align-self: start;\n    background-color: var(--card-color);\n    border-radius: var(--border-radius) 0 var(--border-radius) 0;\n    color: var(--color-ink-inverted);\n    display: inline-flex;\n    font-weight: 600;\n    max-inline-size: 100%;\n    min-inline-size: 0;\n    padding-block: 0.25lh;\n    padding-inline: var(--card-padding-inline) 1ch;\n    position: relative;\n    transition: background-color 100ms ease-out;\n\n    &:has(.btn) {\n      @media (any-hover: hover) {\n        &:hover {\n          background-color: color-mix(in srgb, var(--card-color) 90%, var(--color-ink));\n        }\n      }\n    }\n\n    dialog {\n      inset-block-start: 100%;\n    }\n  }\n\n  .card__id {\n    flex-shrink: 0;\n\n    .card-perma &:before {\n      content: \"No.\";\n      opacity: 0.5;\n    }\n  }\n\n  .card__board-name {\n    align-items: center;\n    border-inline-start: 1px solid color-mix(in hsl, transparent 75%, currentColor);\n    color: currentColor;\n    display: flex;\n    gap: 0.25ch;\n    margin-inline-start: var(--card-header-space);\n    max-inline-size: 100%;\n    min-inline-size: 0;\n    padding-inline-start: var(--card-header-space);\n    text-transform: uppercase;\n  }\n\n  .card__board-picker-button {\n    inset: 0;\n    position: absolute;\n  }\n\n  .card__tags {\n    --btn-color: var(--card-color);\n\n    align-items: center;\n    align-self: stretch;\n    color: var(--card-text-color);\n    display: flex;\n    gap: 0.5ch;\n    min-inline-size: 0;\n\n    [data-controller=\"dialog\"] {\n      align-items: center;\n      align-self: stretch;\n      display: flex;\n      position: relative;\n    }\n\n    .popup {\n      --panel-size: 18ch;\n\n      inset-block-start: 100%;\n    }\n  }\n\n  .card__tag-picker {\n    --panel-border-radius: 2em;\n    --panel-padding: 0.5em 0.7em;\n    --panel-size: max-content;\n\n    inline-size: auto !important;\n    inset: 0 auto auto 0;\n    max-inline-size: var(--panel-size) !important;\n    position: absolute;\n    z-index: 2;\n\n    &[open] {\n      display: flex;\n    }\n\n    .input {\n      --input-padding: 0.2em 0.5em;\n\n      inline-size: 18ch;\n    }\n  }\n\n  .card__tag-picker-button {\n    font-size: 0.6em;\n  }\n\n  .card__tag {\n    color: inherit;\n    font-weight: 600;\n    min-width: 0;\n    text-transform: uppercase;\n  }\n\n  /* Body\n  /* ------------------------------------------------------------------------ */\n\n  .card__body {\n    display: flex;\n    flex-grow: 1;\n    gap: 1ch;\n    inline-size: 100%;\n    padding-block: calc(var(--card-padding-block) / 2);\n\n    @media (min-width: 640px) {\n      gap: var(--card-padding-inline);\n    }\n  }\n\n  .card__content {\n    color: var(--card-content-color);\n    contain: inline-size;\n    flex: 2 1 auto;\n    max-inline-size: 100%;\n  }\n\n  .card__title {\n    --autosize-block-padding: 0 0.5ch;\n    --input-border-radius: 0;\n    --input-color: var(--card-content-color);\n    --lines: 3;\n\n    color: var(--card-content-color);\n    font-size: var(--text-xx-large);\n    font-weight: 900;\n    line-height: 1.15;\n    text-wrap: balance;\n\n    &.overflow-line-clamp {\n      text-wrap: unset; /* text-wrap: balance breaks -webkit-line-clamp in Safari */\n    }\n\n    .card-field__title {\n      overflow: hidden; /* prevent scrolling on windows */\n      padding-block: var(--autosize-block-padding);\n\n      &:is(textarea)::placeholder {\n        color: inherit;\n        opacity: 0.66;\n      }\n    }\n\n    .card__title-link {\n      color: inherit;\n    }\n\n    code {\n      background-color: var(--color-canvas);\n      border: 1px solid var(--color-ink-lighter);\n      border-radius: 0.25ch;\n      font-family: var(--font-mono);\n      font-size: smaller;\n      padding: 0.1ch 0.25ch;\n    }\n  }\n\n  .card__description {\n    /* Hide the empty element that Lexical saves when nothing is added to the description <p><br /></p> */\n    action-text-content p:only-child:has(br:only-child) {\n      display: none;\n    }\n\n    lexxy-toolbar {\n      border-block-start: 1px solid var(--lexxy-border-color);\n    }\n\n    & ~ .btn.btn--reversed {\n      --btn-background: var(--card-color);\n      --btn-color: var(--color-ink-inverted);\n    }\n\n    & ~ .btn {\n      --btn-border-color: var(--card-color);\n      --btn-color: var(--card-color);\n    }\n  }\n\n  .card__stages {\n    color: var(--card-text-color);\n    display: flex;\n    flex: 0 1 auto;\n    flex-direction: column;\n    gap: 2px;\n    justify-self: end;\n    max-inline-size: 20ch;\n    padding-block: var(--block-space-half);\n  }\n\n  /* Footer\n  /* ------------------------------------------------------------------------ */\n\n  /* Card metadata */\n  .card__meta {\n     --meta-spacer-block: 0.5ch;\n     --meta-spacer-inline: 0.75ch;\n\n    align-items: center;\n    color: var(--card-text-color);\n    display: grid;\n    font-size: var(--text-x-small);\n    font-weight: 500;\n    grid-template-areas:\n      \"avatars-author text-added text-updated avatars-assignees\"\n      \"avatars-author text-author text-assignees avatars-assignees\";\n    grid-template-columns: auto auto 1fr auto;\n    inline-size: fit-content;\n    text-transform: uppercase;\n\n    strong,\n    .local-time-value {\n      font-weight: 900;\n    }\n  }\n\n  /* Assign grid areas */\n  .card__meta-avatars--author { grid-area: avatars-author; }\n  .card__meta-avatars--assignees { grid-area: avatars-assignees; }\n  .card__meta-text--added { grid-area: text-added; }\n  .card__meta-text--author { grid-area: text-author; }\n  .card__meta-text--updated { grid-area: text-updated; }\n  .card__meta-text--assignees { grid-area: text-assignees; }\n\n  .card__meta-avatars {\n    align-self: center;\n  }\n\n  .card__meta-avatars--author {\n    margin-inline-end: var(--meta-spacer-inline);\n  }\n\n  .card__meta-avatars--assignees {\n    display: flex;\n    margin-inline-start: var(--meta-spacer-inline);\n\n    .avatar {\n      margin-inline-end: calc(-1 * var(--meta-spacer-inline));\n    }\n  }\n\n  .card__assignees-trigger {\n    background: transparent;\n    border: none;\n    padding: 0;\n    display: flex;\n\n    &:focus-visible {\n      outline: none;\n\n      .btn {\n        box-shadow: 0 0 0 var(--focus-ring-size) var(--focus-ring-color);\n      }\n    }\n  }\n\n  .card__meta-text {\n    line-height: 1;\n    white-space: nowrap;\n\n    .icon {\n      --icon-size: 0.9em;\n\n      margin-inline-end: 0.5ch;\n      vertical-align: top;\n    }\n  }\n\n  /* Top */\n  .card__meta-text:nth-of-type(odd) {\n    border-block-end: var(--card-border);\n    padding-block-end: var(--meta-spacer-block);\n  }\n\n  /* Bottom */\n  .card__meta-text:nth-of-type(even) {\n    padding-block-start: var(--meta-spacer-block);\n  }\n\n  /* Left */\n  .card__meta-text:nth-of-type(-n+2) {\n    border-inline-end: var(--card-border);\n    padding-inline-end: var(--meta-spacer-inline);\n  }\n\n  /* Right */\n  .card__meta-text:nth-of-type(n+3) {\n    padding-inline-start: var(--meta-spacer-inline);\n  }\n\n  @media (max-width: 639px) {\n    .card__meta {\n      inline-size: 100%;\n    }\n\n    .card__meta-avatars--author,\n    .card__meta-avatars--assignees .avatar {\n      display: none;\n    }\n  }\n\n  /* Closed stamp\n  /* ------------------------------------------------------------------------ */\n\n  .card__closed {\n    --stamp-color: oklch(var(--lch-green-medium) / 0.65);\n\n    align-items: center;\n    backdrop-filter: blur(2px);\n    background-color: color-mix(in srgb, var(--card-bg-color) 90%, transparent);\n    border-radius: 0.2em;\n    border: 0.5ch solid var(--stamp-color);\n    color: var(--color-ink-dark);\n    display: flex;\n    flex-direction: column;\n    font-weight: bold;\n    inset: auto 0 -1lh auto;\n    justify-content: center;\n    max-inline-size: 25ch;\n    min-inline-size: 16ch;\n    padding: 1ch;\n    pointer-events: none;\n    position: absolute;\n    rotate: 5deg;\n    transform-origin: top right;\n    z-index: 2;\n\n    .cards & {\n      display: none;\n\n      .cards--grid &,\n      .cards--on-deck & {\n        &.card__closed--system {\n          display: flex;\n        }\n      }\n    }\n  }\n\n  .card:has(.card__closed),\n  .card:is(.card--postponed),\n  .card-perma:has(.card__closed),\n  .card-perma:has(.card--postponed) {\n    --card-color: var(--color-card-complete) !important;\n\n    .bubble {\n      display: none;\n    }\n  }\n\n  .card__closed-title {\n    color: var(--stamp-color);\n    font-size: 1.3em;\n    font-weight: 900;\n    position: relative;\n    text-align: center;\n    text-transform: uppercase;\n  }\n\n  .card__closed-date {\n    font-family: var(--font-mono);\n    text-transform: uppercase;\n  }\n\n  .card__closed-by {\n    border-block-end: 1px dashed currentcolor;\n  }\n\n  /* Misc bits\n  /* ------------------------------------------------------------------------ */\n\n  .card__background {\n    inset: 0;\n    position: absolute;\n    z-index: -1;\n\n    img {\n      block-size: 100%;\n      border-radius: var(--border-radius);\n      inline-size: 100%;\n      object-fit: cover;\n      object-position: center;\n      opacity: 1;\n      transition: filter 0.2s ease-in-out, opacity 0.2s ease-in-out;\n    }\n  }\n\n  .card__link {\n    content: \"\";\n    inset: 0;\n    position: absolute;\n    z-index: -1;\n  }\n\n  .card:nth-child(2n+1) .bubble { --bubble-rotate: -90deg; }\n  .card:nth-child(3n+1) .bubble { --bubble-rotate: 45deg; }\n\n  /* Variants\n  /* ------------------------------------------------------------------------ */\n\n  .card--notification {\n    --card-color: var(--color-card-default);\n    --card-padding-inline: 1ch;\n    --card-padding-block: 1ch;\n\n    background-color: var(--color-canvas);\n    color: var(--color-ink);\n\n    &.card--closed {\n      --card-color: var(--color-card-complete) !important;\n    }\n\n    .card__body {\n      padding-block-end: 0;\n    }\n\n    .card__board {\n      font-size: var(--text-xx-small);\n      padding-block: 0.5ch;\n      padding-inline-start: var(--inline-space-double);\n    }\n\n    .card__header {\n      margin-block-start: calc(-1.1 * var(--card-padding-block));\n      margin-inline: calc(-1 * var(--card-padding-inline));\n      max-inline-size: unset;\n    }\n\n    .card__timestamp {\n      opacity: 0.66;\n    }\n\n    .card__notification-body {\n      font-size: var(--text-x-small);\n    }\n\n    .card__notification-meta {\n      font-size: var(--text-xx-small);\n      font-weight: 600;\n      text-transform: uppercase;\n    }\n\n    .card__notification-mentioner {\n      background-color: var(--color-highlight);\n      border-radius: 0.7em 0.2em 0.7em 0.2em;\n      color: inherit;\n      display: inline-flex;\n      padding: 0.1em 0.3em;\n    }\n\n    .card__title {\n      font-size: var(--text-small);\n      font-weight: bold;\n      min-block-size: 0;\n    }\n  }\n\n  .card__notification-unread-indicator {\n    --btn-background: var(--color-marker);\n    --btn-border-color: var(--color-canvas);\n    --btn-color: var(--color-ink-inverted);\n    --btn-icon-size: 0.5em;\n    --btn-padding: 0;\n    --btn-size: 1.6em;\n\n    font-size: var(--text-xx-small);\n    font-weight: 600;\n    margin: 2px;\n    position: relative;\n    z-index: 1;\n\n    .icon {\n      opacity: 0;\n      transition: opacity 150ms ease;\n    }\n\n    @media (hover: hover) {\n      .card:hover & {\n        --btn-background: var(--color-ink-lightest);\n        --btn-color: var(--color-ink);\n\n        .badge-count { opacity: 0; }\n               .icon { opacity: 1; }\n      }\n    }\n  }\n\n  .card__board-public-description {\n    max-inline-size: 66ch;\n\n    > *:first-child { margin-block-start: 0; }\n    > *:last-child { margin-block-end: 0; }\n\n    ul, ol {\n      inline-size: fit-content;\n      margin-inline: auto;\n      text-align: start;\n    }\n\n    code {\n      text-align: left;\n    }\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/circled-text.css",
    "content": "@layer components {\n  .circled-text {\n    --circled-color: oklch(var(--lch-blue-dark));\n    --circled-padding: -0.5ch;\n\n    background: none;\n    color: var(--circled-color);\n    position: relative;\n    white-space: nowrap;\n\n    span {\n      opacity: 0.5;\n      mix-blend-mode: multiply;\n\n      html[data-theme=\"dark\"] & {\n        mix-blend-mode: screen;\n      }\n\n      @media (prefers-color-scheme: dark) {\n        html:not([data-theme]) & {\n          mix-blend-mode: screen;\n        }\n      }\n    }\n\n    span::before,\n    span::after {\n      border: 2px solid var(--circled-color);\n      content: \"\";\n      inset: var(--circled-padding);\n      position: absolute;\n    }\n\n    span::before {\n      border-inline-end: none;\n      border-radius: 100% 0 0 75% / 50% 0 0 50%;\n      inset-block-start: calc(var(--circled-padding) / 2);\n      inset-inline-end: 50%;\n    }\n\n    span::after {\n      border-inline-start: none;\n      border-radius: 0 100% 75% 0 / 0 50% 50% 0;\n      inset-inline-start: 30%;\n    }\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/color-picker.css",
    "content": "@layer components {\n  .color-picker__colors {\n    display: grid;\n    grid-template-columns: repeat(3, 1fr);\n    gap: var(--inline-space-half);\n\n    .btn {\n      --btn-border-radius: 0.1em;\n      --btn-size: 2em;\n      --icon-size: 1.3em;\n\n      inline-size: 100%;\n    }\n  }\n}\n\n"
  },
  {
    "path": "app/assets/stylesheets/comments.css",
    "content": "@layer components {\n  .comments {\n    --avatar-size: 2.33em;\n    --comment-padding-block: var(--block-space-half);\n    --comment-padding-inline: var(--inline-space-double);\n    --comment-max: 70ch;\n    --reaction-size: 2.25rem;\n\n    display: flex;\n    flex-direction: column;\n    padding-inline: var(--inline-space);\n    place-items: center;\n    text-align: center;\n\n    @media (min-width: 160ch) {\n      padding-inline: var(--tray-size);\n    }\n\n    @media (min-width: 640px) {\n      --reaction-size: 1.6875rem;\n    }\n  }\n\n  .comments__subscribers {\n    max-inline-size: var(--comment-max);\n    padding-inline: calc(var(--comment-padding-block) + var(--inline-space-double));\n  }\n\n  .comment {\n    /* Distinguish from the .comment class used for code formatting without extra specificity */\n    &:where(.comments &) {\n      display: flex;\n      margin-inline: auto;\n      max-inline-size: var(--comment-max);\n      position: relative;\n    }\n\n    .comment-by-system & {\n      --comment-padding-block: var(--block-space-half);\n\n      text-align: center;\n\n      &::before {\n        /* Make up space for lack of avatar */\n        content: \"\";\n        display: flex;\n        inline-size: calc(var(--comment-padding-inline) * 0.9);\n      }\n\n      .comment__avatar {\n        display: none;\n      }\n\n      .comment__author {\n        a { margin: 0 auto; }\n        h3 { margin-inline: auto; }\n        strong { display: none; }\n      }\n\n      .comment__body {\n        padding: 0;\n        text-align: center;\n      }\n\n      .comment__content {\n        --stripe-color: var(--color-ink-lightest);\n\n        background-image: repeating-linear-gradient(\n          45deg in srgb,\n          var(--color-canvas) 0 1px,\n          var(--stripe-color) 1px 10px);\n        padding-inline: var(--comment-padding-inline);\n\n        .comments--system-expanded .comment-by-system & {\n          --stripe-color: color-mix(in srgb, var(--card-color) 10%, var(--color-canvas));\n        }\n      }\n\n      .reactions {\n        display: none !important;\n      }\n    }\n\n    .reactions {\n      margin-block-start: var(--block-space-half);\n      margin-inline: calc(var(--column-gap) / -1);\n\n      &:not(:has(.reaction)) {\n        inset-block-end: var(--comment-padding-block);\n        inset-inline-end: calc(var(--comment-padding-inline) / 2);\n        margin: 0;\n        position: absolute;\n\n        @media (max-width: 640px) {\n          inset-inline-end: calc(var(--comment-padding-inline) / 3);\n        }\n      }\n    }\n  }\n\n  .comment__author {\n    .btn {\n      font-weight: inherit;\n    }\n\n    @media (max-width: 639px) {\n      margin-block-end: calc(var(--block-space-half) / 2);\n\n      h3 {\n        display: flex;\n        flex-wrap: wrap;\n        align-items: baseline;\n        column-gap: 0.4em;\n      }\n    }\n  }\n\n  .comment__avatar {\n    margin: calc(var(--comment-padding-block) * 0.75) calc(var(--comment-padding-inline) * -0.75);\n    z-index: 0;\n  }\n\n  .comment__body {\n    text-align: start;\n\n    .action-text-content {\n      > action-text-attachment:first-child figure {\n        margin-block-start: 0.5ch;\n      }\n\n      > :last-child {\n        margin-block-end: 0;\n      }\n    }\n\n    &:not:has(lexxy-editor) {\n      padding-inline-end: var(--reaction-size);\n    }\n\n    /* Add an empty space so the last line of text doesn't overlap with the reaction button */\n    .action-text-content > p:last-child::after {\n      content: \"\";\n      display: inline-block;\n      inline-size: var(--reaction-size);\n    }\n  }\n\n  .comment__content {\n    --btn-icon-size: 1.2rem;\n    --btn-size: var(--reaction-size);\n    --comment-bg-color: var(--color-ink-lightest);\n    --lexxy-bg-color: var(--comment-bg-color);\n\n    background-color: var(--comment-bg-color);\n    border-radius: 0.2em;\n    max-inline-size: calc(100% - calc(var(--comment-padding-inline) * 0.75));\n    padding:\n      var(--comment-padding-block)\n      calc(var(--comment-padding-inline) / 2)\n      calc(var(--comment-padding-block) * 1.5)\n      var(--comment-padding-inline);\n    word-wrap: break-word;\n  }\n\n  .comment__edit {\n    background-color: var(--color-ink-lightest);\n\n    &:hover {\n      z-index: 1;\n    }\n  }\n\n  .comment__permalink-title {\n    color: currentColor;\n    opacity: 0.66;\n    text-decoration: none;\n    text-transform: capitalize;\n\n    @media (max-width: 639px) {\n      font-size: var(--text-small);\n    }\n  }\n\n  .comment__history {\n    background-color: transparent;\n    display: none;\n    inset: var(--comment-padding-block) var(--comment-padding-block) auto auto;\n    translate: 2px -2px; /* Align baseline with time stamp */\n    position: absolute;\n\n    @media (any-hover: hover) {\n      &:hover {\n        background-color: var(--stripe-color);\n      }\n    }\n  }\n\n  .comment-by-system {\n    display: none;\n    transition: var(--dialog-duration) allow-discrete;\n    transition-property: display;\n\n    .comments--system-expanded & {\n      display: contents;\n    }\n  }\n\n  /* Show the last system comment */\n  :nth-last-child(1 of .comment-by-system) {\n    display: contents;\n\n    .comment__history {\n      display: inline-flex;\n    }\n  }\n\n  /* Hide the \"Show history\" button if there's only one system comment */\n  :nth-child(1 of .comment-by-system) {\n    .comment__history {\n      display: none;\n    }\n  }\n\n  .comment-by-system--account-limit {\n    --stripe-color: oklch(var(--lch-blue-lightest));\n\n    .comment__content {\n      padding: 3ch;\n    }\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/credentials.css",
    "content": "@layer components {\n  .credential {\n    border-block-start: var(--border);\n    list-style: none;\n\n    &:last-child {\n      border-block-end: var(--border);\n    }\n  }\n\n  .credential__link {\n    align-items: center;\n    block-size: 1.75lh;\n    color: currentcolor;\n    display: flex;\n    gap: 1ch;\n    padding-inline: 1ch;\n\n    @media (any-hover: hover) {\n      &:hover {\n        background: var(--color-ink-lightest);\n\n        .credential__arrow {\n          opacity: 0.66;\n        }\n      }\n    }\n  }\n\n  .credential__arrow {\n    margin-inline-start: auto;\n    opacity: 0;\n  }\n\n  [data-passkey-errors] [data-passkey-error] {\n    display: none;\n  }\n\n  [data-passkey-errors][data-passkey-error-state=\"error\"] [data-passkey-error=\"error\"],\n  [data-passkey-errors][data-passkey-error-state=\"cancelled\"] [data-passkey-error=\"cancelled\"] {\n    display: block;\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/dialog.css",
    "content": "@layer components {\n  /* Prevent page scrolling when modal dialog is open */\n  html:has(dialog:modal) {\n    overflow: hidden;\n  }\n\n  :is(.dialog) {\n    border: 0;\n    opacity: 0;\n    transform: scale(0.85);\n    transform-origin: center;\n    transition-behavior: allow-discrete;\n    transition-duration: calc(var(--dialog-duration) / 2); /* Faster closing */\n    transition-property: display, opacity, overlay, transform;\n    transition-timing-function: ease-out;\n\n    &::backdrop {\n      background-color: var(--color-black);\n      opacity: 0;\n      transition-behavior: allow-discrete;\n      transition-duration: calc(var(--dialog-duration) / 2);\n      transition-property: display, opacity, overlay;\n      transition-timing-function: ease-out;\n    }\n\n    &[open] {\n      opacity: 1;\n      transform: scale(1);\n      transition-duration: var(--dialog-duration); /* Normal opening speed */\n\n      &::backdrop {\n        opacity: 0.5;\n        transition-duration: var(--dialog-duration);\n      }\n    }\n\n    @starting-style {\n      &[open] {\n        opacity: 0;\n        transform: scale(0.85);\n      }\n\n      &[open]::backdrop {\n        opacity: 0;\n      }\n    }\n  }\n\n  /* Ensure padding from viewport edges */\n  .dialog.panel {\n    max-inline-size: calc(100vw - var(--inline-space-double) * 2);\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/dividers.css",
    "content": "@layer components {\n  .divider {\n    --divider-color: var(--color-ink-light);\n\n    align-items: center;\n    display: flex;\n    gap: var(--inline-space);\n\n    &:before,\n    &:after {\n      background: var(--divider-color);\n      block-size: var(--divider-size, 1px);\n      content: \"\";\n      flex: 1;\n    }\n  }\n\n  .divider--fade {\n    &:before { background: linear-gradient(to right, transparent, var(--divider-color) 50%); }\n    &:after  { background: linear-gradient(to left, transparent, var(--divider-color) 50%); }\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/drag_and_drop.css",
    "content": "@layer components {\n  .drag-and-drop__dragged-item {\n    box-shadow: none;\n    filter: grayscale(1) brightness(0.97);\n    opacity: 0.6;\n    outline: 2px dashed var(--color-selected-dark);\n  }\n\n  .drag-and-drop__hover-container {\n    --dnd-bg-color: var(--color-selected-light);\n    --dnd-border-color: var(--color-selected-dark);\n\n    background-color: var(--dnd-bg-color);\n    outline: 2px dashed var(--dnd-border-color);\n    outline-offset: -2px;\n    transition: background-color 200ms;\n    z-index: 1;\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/events.css",
    "content": "@layer components {\n  /* Events header\n  /* ------------------------------------------------------------------------ */\n\n  .header--events {\n    --header-button-count: 1;\n\n    @media (min-width: 640px) {\n      --header-actions-width: 7.25rem !important;\n    }\n  }\n\n  /* Event column layout\n  /* ------------------------------------------------------------------------ */\n\n  .events {\n    --events-gap: 1ch;\n    --events-border: 1px solid var(--color-ink-lighter);\n    --events-day-header-height: 1.75rem;\n  }\n\n  .events--grid {\n    --events-grid-gap: 1rem;\n    --events-grid-columns: 1;\n\n    container-type: inline-size;\n    display: flex;\n    flex-direction: row;\n    flex-wrap: wrap;\n    gap: var(--events-grid-gap);\n    justify-content: center;\n    margin: var(--block-space) auto;\n    max-inline-size: var(--main-width);\n\n    @media (min-width: 640px) {\n      --events-grid-columns: 2;\n    }\n\n    @media (min-width: 960px) {\n      --events-grid-columns: 3;\n    }\n\n    .event {\n      inline-size: calc((100% - var(--events-grid-gap) * (var(--events-grid-columns) - 1) ) / var(--events-grid-columns)) !important;\n      margin: 0 !important;\n    }\n  }\n\n  .events__activity-summary {\n    border: solid var(--color-ink-lighter);\n    border-width: 1px 1px 0 1px;\n    color: var(--color-ink-darker);\n    inline-size: auto;\n    margin-inline: auto;\n    padding: 1.1lh 1lh 1lh;\n    position: relative;\n    text-align: start;\n    z-index: 2;\n\n    .events section:first-of-type & {\n      border-radius: 0.5em 0.5em 0 0;\n    }\n\n    &:has(.events__activity--generating) {\n      --border-color: var(--color-selected-dark);\n\n      animation: gradient 4s ease infinite;\n      background: linear-gradient(-45deg, var(--color-gradient-1), var(--color-gradient-2), var(--color-gradient-3), var(--color-gradient-4));\n      background-size: 300%;\n      text-align: center;\n    }\n\n    > * {\n      column-count: 2;\n      column-gap: var(--inline-space-double);\n      margin-inline: auto;\n      max-inline-size: 80ch;\n    }\n\n    a {\n      color: inherit;\n    }\n\n    h3 {\n      column-span: all;\n      font-size: var(--text-large);\n      line-height: 1.3;\n      margin-block: 0.5em 0.25em;\n      text-align: center;\n      text-wrap: balance;\n\n      + p {\n        column-span: all;\n        font-size: var(--text-medium);\n        margin-block: 0 1.5em;\n        text-align: center;\n        text-wrap: balance;\n      }\n    }\n\n    h4 {\n      break-after: avoid-column;\n      font-size: var(--text-medium);\n      line-height: 1.3;\n      margin-block: 0.5em 0.25em;\n      text-wrap: balance;\n\n      + p {\n        break-inside: avoid-column;\n        margin-block: 0 1.5em;\n      }\n    }\n\n    hr { display: none; }\n  }\n\n  .events__activity-generating-msg {\n    display: block;\n    font-weight: 500;\n    margin-block: 2lh;\n    opacity: 0.5;\n  }\n\n  .events__activity-prompt-edit {\n    inset: auto 1em 1em auto;\n    position: absolute;\n  }\n\n  .events__filter-select {\n    font-weight: inherit;\n    text-decoration: underline;\n\n    @media (any-hover: hover) {\n      &:hover {\n        --btn-color: var(--color-link);\n      }\n    }\n  }\n\n  .events__day {\n    position: relative;\n\n    @media (max-width: 639px) {\n      margin-block-end: calc(var(--events-gap) * 2);\n    }\n  }\n\n  .events__day-header,\n  .events__column-header {\n    font-size: var(--text-small);\n    text-align: center;\n    text-transform: uppercase;\n  }\n\n  .events__day-header {\n    block-size: 0;\n    margin-block-start: calc(var(--events-day-header-height) / 2);\n    position: relative;\n    z-index: var(--z-events-day-header);\n  }\n\n  .events__day-time {\n    align-items: center;\n    background-color: var(--color-ink);\n    block-size: var(--events-day-header-height);\n    border-radius: 0.2em;\n    color: var(--color-ink-inverted);\n    display: flex;\n    gap: 0.4ch;\n    inline-size: fit-content;\n    inset: 0 auto auto 50%;\n    margin-inline: auto;\n    padding-inline: 1.5ch;\n    position: absolute;\n    translate: -50% -50%;\n  }\n\n  .events__columns {\n    border-block-start: var(--events-border);\n    position: relative;\n    z-index: 1;\n\n    @media (min-width: 640px) {\n      align-items: end;\n      border-inline: var(--events-border);\n      display: grid;\n      grid-template-columns: repeat(3, 1fr);\n\n      /* Pseudo column borders since .events__column is display: contents */\n      &:before,\n      &:after {\n        border-inline-start: var(--events-border);\n        content: \"\";\n        inset-block: 0;\n        position: absolute;\n        z-index: var(--z-events-day-header);\n      }\n\n      &:before { inset-inline-start: calc(100% / 3); }\n      &:after { inset-inline-end: calc(100% / 3); }\n    }\n  }\n\n  .events__column {\n    @media (max-width: 639px) {\n      &:not(:has(.event)) {\n        display: none;\n      }\n    }\n\n    @media (min-width: 640px) {\n      display: contents;\n    }\n\n    &:nth-of-type(1) > * { grid-column-start: 1; }\n    &:nth-of-type(2) > * { grid-column-start: 2; }\n    &:nth-of-type(3) > * { grid-column-start: 3; }\n  }\n\n  .events__column-header {\n    background-color: var(--color-canvas);\n    grid-row-start: 1;\n    inset-block-start: var(--custom-safe-inset-top);\n    margin-block: calc(var(--events-gap) * 2) var(--events-gap);\n    padding-block: var(--events-gap);\n    position: sticky;\n    z-index: var(--z-events-column-header);\n\n    @media (max-width: 639px) {\n      margin-inline: calc(var(--main-padding) * -0.5);\n      padding-inline: var(--main-padding);\n    }\n  }\n\n  .events__column-footer {\n    grid-row-start: 26;\n    margin: var(--events-gap);\n    padding: var(--block-space-half) var(--inline-space);\n  }\n\n  .events__maximize-button {\n    inset: 50% var(--events-gap) auto auto;\n    outline-offset: -2px;\n    position: absolute;\n    transform: translateY(-50%);\n    z-index: 1;\n\n    @media (max-width: 639px) {\n      inset-inline-end: 0;\n    }\n\n    @media (any-hover: hover ) {\n      opacity: 0;\n\n      .events__column-header:hover &,\n      &:focus-visible {\n        opacity: 1;\n      }\n    }\n  }\n\n  .events__time-block {\n    align-content: end;\n    display: grid;\n    gap: var(--events-gap);\n    justify-items: center;\n    margin: 0;\n    padding: 0;\n\n    @media (min-width: 640px) {\n      padding-block-end: calc(var(--events-gap) * 3);\n      padding-inline: calc(var(--events-gap) * 2);\n    }\n\n    .event {\n      grid-column-start: unset !important;\n      grid-row-start: unset !important;\n    }\n  }\n\n  .events__none {\n    background-color: var(--color-canvas);\n    border-block-start: var(--events-border);\n    grid-column: 1 / -1;\n    padding-block: 3em;\n    text-align: center;\n  }\n\n  /* Event\n  /* ------------------------------------------------------------------------ */\n\n  .event {\n    --column-gap: 0.7ch;\n    --event-padding: 0.6em;\n\n    background-color: color-mix(in srgb, var(--card-color) 10%, var(--color-canvas));\n    border-radius: 0.2em;\n    box-shadow: 0 0 0 1px color-mix(in srgb, var(--card-color) 20%, var(--color-canvas));\n    color: color-mix(in srgb, var(--card-color) 40%, var(--color-ink));\n    margin: var(--inline-space);\n    max-inline-size: 100%;\n    min-inline-size: 0;\n    overflow: clip;\n    padding: var(--event-padding);\n    position: relative;\n    z-index: 0;\n\n    @media (max-width: 639px) {\n      inline-size: 100%;\n    }\n\n    &:has(.card__background img:not([src=\"\"])) {\n      background-color: var(--color-canvas) !important;\n    }\n\n    .event_attachments {\n      .attachment--image {\n        block-size: auto;\n        max-inline-size: 30%;\n      }\n    }\n\n    .card__background {\n      filter: brightness(1.2) contrast(0.8);\n      opacity: 0.2;\n      z-index: 0;\n    }\n\n    .card__header {\n      inline-size: 100%;\n      margin-block-start: calc(-1 * var(--event-padding));\n    }\n\n    .card__board {\n      background-color: transparent;\n      color: color-mix(in srgb, var(--card-color) 40%, var(--color-ink));\n      font-size: 0.7em;\n    }\n\n    .card__board-name {\n      border-inline-start: 1px solid color-mix(in srgb, var(--color-ink) 33%, var(--color-canvas));\n      color: color-mix(in srgb, var(--card-color) 40%, var(--color-ink));\n      margin: 0 0 0 calc(var(--inline-space) * 0.75);\n      padding-inline-start: calc(var(--inline-space) * 0.75);\n    }\n\n    .card__header {\n      overflow: visible;\n    }\n\n    .card__id {\n      margin: 0;\n    }\n  }\n\n  .event--related {\n    outline: 0.15rem solid var(--card-color);\n  }\n\n  .event__content {\n    max-inline-size: 100%;\n    position: relative;\n    z-index: 1;\n  }\n\n  .event__grid-item {\n    background-color: var(--color-canvas);\n    block-size: 100%;\n    border-radius: 0;\n    display: flex;\n    inline-size: 100%;\n  }\n\n  .event__grid-column-title {\n    --z: 3;\n\n    background-color: var(--color-canvas);\n    font-size: 0.9em;\n    padding: 1.5em 0 1em;\n    text-transform: uppercase;\n  }\n\n  .event__icon {\n    color: var(--card-color);\n    display: grid;\n    margin-inline-start: auto;\n    place-content: center;\n    translate: calc(var(--event-padding) / 2);\n  }\n\n  .event__timestamp {\n    align-self: start;\n    display: grid;\n    font-weight: 600;\n    margin-block-end: var(--block-space-half);\n  }\n\n  .event__title {\n    --lines: 4;\n\n    font-size: 1.1em;\n    line-height: 1.2;\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/expandable.css",
    "content": "@layer components {\n  .expandable-on-native {\n    body:not([data-platform~=native]) & {\n      &::details-content {\n        display: contents;\n      }\n\n      summary {\n        display: none;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/filters.css",
    "content": "@layer components {\n  #header:has(.filters) {\n    position: relative;\n  }\n\n  .filters {\n    align-items: center;\n    display: flex;\n    flex-wrap: wrap;\n    gap: var(--inline-space-half);\n    justify-content: center;\n    padding-block-start: 2px; /* prevents input focus-ring clipping on mobile */\n    position: relative;\n    view-transition-name: filters;\n    z-index: 1;\n\n    .btn {\n      --btn-border-color: var(--color-ink-medium);\n      --input-background: var(--color-canvas);\n    }\n\n    &:has(dialog[open]),\n    &:has([data-controller~=\"tooltip\"]:hover) {\n      z-index: calc(var(--z-nav) + 1);\n    }\n  }\n\n  .filter {\n    &[aria-selected] {\n      display: flex;\n    }\n  }\n\n  .filter__button {\n    --btn-border-size: 0;\n    --btn-font-weight: 400;\n    --btn-icon-size: 0.7em;\n    --btn-padding: 0.3em 0.7em;\n\n    inline-size: 100%;\n    justify-content: space-between;\n    text-align: start;\n\n    span {\n      overflow: hidden;\n      text-decoration: none;\n      text-overflow: ellipsis;\n      white-space: nowrap;\n    }\n\n    img {\n      display: none;\n    }\n\n    &:has(input[type=checkbox]:checked) img {\n      display: block;\n    }\n  }\n\n  .filter__columns {\n    display: grid;\n    grid-template-columns: repeat(5, 1fr);\n    max-block-size: 50dvh;\n  }\n\n  .filter__label {\n    display: flex;\n    inline-size: 100%;\n    padding: 0.3em 0.7em;\n\n    strong {\n      overflow: hidden;\n      text-decoration: none;\n      text-overflow: ellipsis;\n      white-space: nowrap;\n    }\n  }\n\n  .filter__menu {\n    display: flex;\n    flex-direction: column;\n    inline-size: 100%;\n    list-style: none;\n    margin: 0;\n    min-inline-size: 0;\n    overflow-x: auto;\n    padding: 0 var(--inline-space);\n    position: relative;\n    row-gap: 0.2em;\n\n    &::before {\n      block-size: 100%;\n      border-block: 0;\n      border-inline-end: 0;\n      border-inline-start: 1px solid var(--color-ink-lighter);\n      content: \"\";\n      display: inline-flex;\n      inline-size: 0;\n      position: absolute;\n      inset: 0 auto 0 0;\n    }\n\n    &:first-child::before {\n      display: none;\n    }\n\n    li {\n      text-align: start;\n    }\n  }\n\n  .filter__terms:is(.input) {\n    --input-background: var(--color-canvas);\n    --input-border-radius: 5em;\n    --input-padding: 0.5em 1.3em;\n    --input-width: 16em;\n    --collapsed-filter-space: calc(var(--btn-size) + var(--inline-space-half) + 0.25em);\n\n    inline-size: var(--input-width);\n    min-inline-size: var(--input-width);\n\n    .filters:not(.filters--expanded, .filters--has-filters-set) & {\n      --input-padding: 0.5em 2.7em 0.5em 1.3em;\n\n      inline-size: calc(var(--input-width) + (0.25 * var(--collapsed-filter-space)));\n      margin-inline-end: calc((var(--btn-size) + var(--inline-space-half) + 0.25em) * -1);\n      min-inline-size: calc(var(--input-width) + (0.25 * var(--collapsed-filter-space)));\n    }\n  }\n\n  .filter-toggle {\n    .filters:not(.filters--expanded, .filters--has-filters-set) & {\n      --btn-background: transparent;\n      --btn-border-size: 0;\n\n      transform: translateX(calc(var(--inline-space-half) * -1));\n      position: relative;\n    }\n  }\n\n  .quick-filter {\n    position: relative;\n\n    &:has([aria-checked=\"true\"]):not(.quick-filter--with-default) {\n      .input--select {\n        --input-background: var(--color-selected);\n      }\n    }\n\n    /* Hide a quick filter if there's nothing in it to filter by */\n    &:not(:has(.popup__item)) {\n      display: none !important;\n    }\n\n  }\n\n  .filters:not(.filters--expanded) {\n    .quick-filter:not([data-filter-show=true]) {\n      display: none;\n    }\n  }\n\n  .filters.filters--expanded {\n    .quick-filter {\n      display: block;\n    }\n  }\n\n  .filters__manage {\n    display: none;\n  }\n\n  .filters--has-filters-set .filters__manage {\n    display: flex;\n  }\n\n  .filters__show-when-expanded {\n    .filters:not(.filters--expanded) & {\n      display: none;\n    }\n  }\n\n  .filters__show-when-collapsed {\n    .filters--expanded & {\n      display: none;\n    }\n  }\n}\n\n"
  },
  {
    "path": "app/assets/stylesheets/flash.css",
    "content": "@layer components {\n  .flash {\n    display: flex;\n    inset-block-start: calc(var(--block-space) + var(--custom-safe-inset-top));\n    inset-inline-start: 50%;\n    justify-content: center;\n    position: fixed;\n    transform: translate(-50%);\n    z-index: var(--z-flash);\n  }\n\n  .flash__inner {\n    animation: appear-then-fade 3s 300ms both;\n    background-color: var(--flash-background, var(--color-ink));\n    border-radius: 4em;\n    color: var(--flash-color, var(--color-ink-inverted));\n    display: inline-flex;\n    font-size: var(--font-size-medium);\n    inline-size: max-content;\n    margin: 0 auto;\n    max-inline-size: 90vw;\n    padding: 0.7em 1.4em;\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/font-face.css",
    "content": "@layer base {\n  /*\n    Segoe UI Variable Fizzy font face configuration.\n\n    1. Segoe UI Variable (Weights 100-700):\n       Leverages variable font features to:\n       - Automatically adjust Weight (wght) dynamically within the 100-700 range.\n       - Automatically manage Optical Size (opsz) based on font-size.\n\n    2. Segoe UI Black (Weights 800-900):\n       Used as a fallback because Segoe UI Variable does not natively support 900 weight.\n       This ensures a consistent bold experience across all weights.\n  */\n  @font-face {\n    font-family: \"Segoe UI Variable Fizzy\";\n    src: local(\"Segoe UI Variable\");\n    font-weight: 100 700;\n    font-style: normal;\n  }\n\n  @font-face {\n    font-family: \"Segoe UI Variable Fizzy\";\n    src: local(\"Segoe UI Variable\");\n    font-weight: 100 700;\n    font-style: italic;\n  }\n\n  @font-face {\n    font-family: \"Segoe UI Variable Fizzy\";\n    src: local(\"Segoe UI Black\");\n    font-weight: 800 900;\n    font-style: normal;\n  }\n\n  @font-face {\n    font-family: \"Segoe UI Variable Fizzy\";\n    src: local(\"Segoe UI Black Italic\");\n    font-weight: 800 900;\n    font-style: italic;\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/golden-effect.css",
    "content": "@layer components {\n  .golden-effect {\n    /* Uncomment below to use the card color for golden effect */\n    /* --color-golden: color-mix(in srgb, var(--card-color) 35%, transparent); */\n\n    background-color: color-mix(in srgb, var(--color-golden) 4%, var(--color-canvas));\n    background-image: linear-gradient(60deg,\n      color-mix(in srgb, var(--color-golden) 45%, transparent) 0%,\n      color-mix(in srgb, var(--color-golden) 10%, transparent) 33%,\n      color-mix(in srgb, var(--color-golden) 5%, transparent) 66%,\n      color-mix(in srgb, var(--color-golden) 45%, transparent) 100%\n    );\n    box-shadow:\n      0 0 0 1px color-mix(in oklch, var(--color-golden) 100%, transparent),\n      0 0 0.2em 0.2em color-mix(in oklch, var(--color-golden) 25%, transparent),\n      0 0 1em 0.5em color-mix(in oklch, var(--color-golden) 25%, transparent);\n\n    lexxy-toolbar {\n      --lexxy-bg-color: transparent;\n    }\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/header.css",
    "content": "@layer components {\n  /* Centered title with space for two buttons on either side */\n  .header {\n    --header-gap: 0.5ch;\n    --btn-icon-size: 1rem;\n    --header-btn-size: 2rem;\n    --header-button-count: 0;\n    --header-actions-width: calc((var(--header-btn-size) + var(--header-gap)) * var(--header-button-count));\n\n    display: grid;\n    grid-template-columns: var(--header-actions-width) 1fr var(--header-actions-width);\n    grid-template-areas:\n      \"menu menu menu\"\n      \"actions-start title actions-end\";\n    max-inline-size: 100dvw;\n    padding-block: calc(var(--block-space-half) + var(--custom-safe-inset-top)) var(--block-space-half);\n    padding-inline: var(--main-padding);\n    position: relative;\n    z-index: var(--z-nav);\n\n    /* Change the grid size depending on how many buttons are present */\n    &:has(.header__actions > *:nth-child(1)) { --header-button-count: 1; }\n    &:has(.header__actions > *:nth-child(2)) { --header-button-count: 2; }\n    &:has(.header__actions > *:nth-child(3)) { --header-button-count: 3; }\n\n    &:has(nav) {\n      row-gap: 0;\n    }\n\n    &:has(dialog[open]) {\n      z-index: var(--z-nav-open);\n    }\n\n    &:has(~ #main .card-columns) {\n      inline-size: 100dvw;\n      margin-inline: auto;\n      max-inline-size: var(--main-width);\n    }\n\n    nav {\n      grid-area: menu;\n      margin-inline: auto;\n    }\n  }\n\n  .header__actions {\n    display: flex;\n    font-size: var(--text-x-small);\n    gap: var(--header-gap);\n    inline-size: var(--header-actions-width);\n  }\n\n  .header__actions--start {\n    grid-area: actions-start;\n    margin-inline-end: auto;\n  }\n\n  .header__actions--end {\n    grid-area: actions-end;\n    justify-content: flex-end;\n    margin-inline-start: auto;\n  }\n\n  .header__title {\n    color: inherit;\n    font-size: var(--text-large);\n    font-weight: 900;\n    grid-area: title;\n    margin: 0 auto;\n    min-inline-size: 0;\n    text-align: center;\n  }\n\n  .header__skip-navigation {\n    --left-offset: -999em;\n\n    inset-block-start: 4rem;\n    inset-inline-start: var(--left-offset);\n    position: absolute;\n    white-space: nowrap;\n    z-index: 11;\n\n    &:focus {\n      --left-offset: var(--inline-space);\n    }\n  }\n\n  .header__logo {\n    color: var(--color-ink);\n    font-size: 1.2rem;\n    inline-size: auto;\n    margin-block-start: 0.1em;\n\n    span {\n      background: var(--color-ink-lightest);\n      block-size: auto;\n      border-radius: 0.3125em;\n      box-shadow:\n        0 0 0 1px oklch(var(--lch-ink-darkest) / 0.1),\n        0 0.1em 0.2em -0.1em oklch(var(--lch-ink-darkest) / 0.05),\n        0 0.2em 0.4em -0.2em oklch(var(--lch-ink-darkest) / 0.05),\n        0 0.3em 0.6em -0.3em oklch(var(--lch-ink-darkest) / 0.05)\n      ;\n      display: grid;\n      height: 1.5em;\n      inline-size: 1.5em;\n      padding: 0.325em 0.275em 0.225em 0.275em;\n      place-content: center;\n      width: 1.5em;\n    }\n\n    svg {\n      height: 100%;\n      margin-inline-start: 0.4125em;\n      margin-inline-end: 0.5375em;\n      max-height: 0.8625em;\n      overflow: visible;\n      width: auto;\n    }\n  }\n\n  /* Optional class to stack header actions on small screens\n  /* ------------------------------------------------------------------------ */\n\n  .header--mobile-actions-stack {\n    @media (max-width: 639px) {\n      grid-template-areas:\n        \"actions-start menu actions-end\"\n        \"title title title\";\n\n      .header__title {\n        margin-block-start: 0.25rem;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/icons.css",
    "content": "@layer components {\n  .icon {\n    -webkit-touch-callout: none;\n    background-color: currentColor;\n    block-size: var(--icon-size, 1em);\n    display: inline-block;\n    flex-shrink: 0;\n    inline-size: var(--icon-size, 1em);\n    mask-image: var(--svg);\n    mask-position: center;\n    mask-repeat: no-repeat;\n    mask-size: var(--icon-size, 1em);\n    pointer-events: none;\n    user-select: none;\n  }\n\n  img.icon {\n    background: none;\n  }\n\n  .icon--37signals { --svg: url(\"37signals.svg\"); }\n  .icon--add { --svg: url(\"add.svg \"); }\n  .icon--add--meta { --svg: url(\"add--meta.svg \"); }\n  .icon--arrow-left { --svg: url(\"arrow-left.svg \"); }\n  .icon--arrow-right { --svg: url(\"arrow-right.svg \"); }\n  .icon--arrow-up { --svg: url(\"arrow-up.svg \"); }\n  .icon--art { --svg: url(\"art.svg \"); }\n  .icon--assigned { --svg: url(\"assigned.svg \"); }\n  .icon--attachment { --svg: url(\"attachment.svg \"); }\n  .icon--authentication { --svg: url(\"authentication.svg \"); }\n  .icon--bell-alert { --svg: url(\"bell-alert.svg \"); }\n  .icon--bell-off { --svg: url(\"bell-off.svg \"); }\n  .icon--bell { --svg: url(\"bell.svg \"); }\n  .icon--bolt { --svg: url(\"bolt.svg \"); }\n  .icon--bookmark-outline { --svg: url(\"bookmark-outline.svg \"); }\n  .icon--bookmark { --svg: url(\"bookmark.svg \"); }\n  .icon--boost { --svg: url(\"boost.svg \"); }\n  .icon--camera { --svg: url(\"camera.svg \"); }\n  .icon--caret-down { --svg: url(\"caret-down.svg \"); }\n  .icon--check { --svg: url(\"check.svg \"); }\n  .icon--check-circle { --svg: url(\"check-circle.svg \"); }\n  .icon--check-all { --svg: url(\"check-all.svg \"); }\n  .icon--clipboard { --svg: url(\"clipboard.svg \"); }\n  .icon--close { --svg: url(\"close.svg \"); }\n  .icon--close-circle { --svg: url(\"close-circle.svg \"); }\n  .icon--collapse { --svg: url(\"collapse.svg \"); }\n  .icon--board { --svg: url(\"board.svg \"); }\n  .icon--board-add { --svg: url(\"board-add.svg \"); }\n  .icon--column-left { --svg: url(\"column-left.svg \"); }\n  .icon--column-right { --svg: url(\"column-right.svg \"); }\n  .icon--comment { --svg: url(\"comment.svg \"); }\n  .icon--copy-paste { --svg: url(\"copy-paste.svg \"); }\n  .icon--crown { --svg: url(\"crown.svg \"); }\n  .icon--email { --svg: url(\"email.svg \"); }\n  .icon--everyone { --svg: url(\"everyone.svg \"); }\n  .icon--expand { --svg: url(\"expand.svg \"); }\n  .icon--gear { --svg: url(\"gear.svg \"); }\n  .icon--grid { --svg: url(\"grid.svg \"); }\n  .icon--filter { --svg: url(\"filter.svg \"); }\n  .icon--fizzy { --svg: url(\"fizzy.svg\"); }\n  .icon--globe { --svg: url(\"globe.svg \"); }\n  .icon--golden-ticket { --svg: url(\"golden-ticket.svg \"); }\n  .icon--history { --svg: url(\"history.svg \"); }\n  .icon--home { --svg: url(\"home.svg \"); }\n  .icon--install-edge { --svg: url(\"install-edge.svg \"); }\n  .icon--lifebuoy { --svg: url(\"lifebuoy.svg \"); }\n  .icon--lock { --svg: url(\"lock.svg \"); }\n  .icon--logout { --svg: url(\"logout.svg \"); }\n  .icon--marker { --svg: url(\"marker.svg \"); }\n  .icon--maximize { --svg: url(\"maximize.svg \"); }\n  .icon--menu { --svg: url(\"menu.svg \"); }\n  .icon--menu-dots-horizontal { --svg: url(\"menu-dots-horizontal.svg \"); }\n  .icon--menu-dots-vertical { --svg: url(\"menu-dots-vertical.svg \"); }\n  .icon--minus { --svg: url(\"minus.svg \"); }\n  .icon--monitor { --svg: url(\"monitor.svg \"); }\n  .icon--moon { --svg: url(\"moon.svg \"); }\n  .icon--move { --svg: url(\"move.svg \"); }\n  .icon--notification-bell-access-only { --svg: url(\"bell.svg \"); }\n  .icon--notification-bell-watching { --svg: url(\"bell-off.svg \"); }\n  .icon--notification-bell-reverse-access-only { --svg: url(\"bell-off.svg \"); }\n  .icon--notification-bell-reverse-watching { --svg: url(\"bell.svg \"); }\n  .icon--password { --svg: url(\"password.svg \"); }\n  .icon--pencil { --svg: url(\"pencil.svg \"); }\n  .icon--person { --svg: url(\"person.svg \"); }\n  .icon--person-add { --svg: url(\"person-add.svg \"); }\n  .icon--picture-add { --svg: url(\"picture-add.svg \"); }\n  .icon--picture-double { --svg: url(\"picture-double.svg \"); }\n  .icon--picture-remove { --svg: url(\"picture-remove.svg \"); }\n  .icon--picture-zoom { --svg: url(\"picture-zoom.svg \"); }\n  .icon--pinned { --svg: url(\"pinned.svg \"); }\n  .icon--qr-code { --svg: url(\"qr-code.svg \"); }\n  .icon--reaction { --svg: url(\"reaction.svg \"); }\n  .icon--refresh { --svg: url(\"refresh.svg \"); }\n  .icon--refresh--meta { --svg: url(\"refresh--meta.svg \"); }\n  .icon--remove { --svg: url(\"remove.svg \"); }\n  .icon--rename { --svg: url(\"rename.svg \"); }\n  .icon--search { --svg: url(\"search.svg \"); }\n  .icon--settings { --svg: url(\"settings.svg \"); }\n  .icon--share { --svg: url(\"share.svg \"); }\n  .icon--sliders { --svg: url(\"sliders.svg \"); }\n  .icon--sun { --svg: url(\"sun.svg \"); }\n  .icon--switch { --svg: url(\"switch.svg \"); }\n  .icon--tag { --svg: url(\"tag.svg \"); }\n  .icon--tag-outline { --svg: url(\"tag-outline.svg \"); }\n  .icon--thumb-up { --svg: url(\"thumb-up.svg \"); }\n  .icon--trash { --svg: url(\"trash.svg \"); }\n  .icon--unpinned { --svg: url(\"unpinned.svg\"); }\n  .icon--unseen { --svg: url(\"unseen.svg\"); }\n  .icon--world { --svg: url(\"world.svg\"); }\n  .icon--youtube { --svg: url(\"youtube.svg\"); }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/import.css",
    "content": "@layer components {\n  .import-status {\n    --import-status-border-color: var(--color-ink-light);\n    --import-status-color: var(--color-ink);\n\n    border: 1px dashed var(--import-status-border-color);\n    border-radius: 1ch;\n    color: var(--import-status-color);\n    font-size: var(--text-medium);\n    padding: 1.5ch;\n\n    .btn {\n      font-size: var(--text-small);\n      margin-block-start: 1.5ch;\n    }\n  }\n\n  .import-status--success {\n    --import-status-border-color: var(--color-positive);\n    --import-status-color: var(--color-positive);\n  }\n\n  .import-status--error {\n    --import-status-border-color: var(--color-negative);\n    --import-status-color: var(--color-negative);\n  }\n\n  @keyframes dash {\n    to {\n      background-position: 100% 0%, 0% 100%, 0% 0%, 100% 100%;\n    }\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/inputs.css",
    "content": "@layer components {\n  /* Text inputs */\n  .input {\n    accent-color: var(--input-accent-color, var(--color-ink));\n    background-color: var(--input-background, transparent);\n    border-radius: var(--input-border-radius, 0.5em);\n    border: var(--input-border-size, 1px) solid var(--input-border-color, var(--color-ink-medium));\n    color: var(--input-color, var(--color-ink));\n    font-size: max(16px, 1em);\n    inline-size: 100%;\n    line-height: inherit;\n    max-inline-size: 100%;\n    padding: var(--input-padding, 0.5em 0.8em);\n    resize: none;\n\n    &:autofill,\n    &:-webkit-autofill,\n    &:-webkit-autofill:hover,\n    &:-webkit-autofill:focus {\n      -webkit-text-fill-color: var(--color-ink);\n      -webkit-box-shadow: 0 0 0px 1000px var(--color-selected) inset;\n    }\n\n    &:where(:focus) {\n      --focus-ring-offset: -1px;\n    }\n\n    &[readonly] {\n      --focus-ring-size: 0;\n    }\n\n    &[autocomplete='one-time-code'] {\n      --input-spacing: 0.5em;\n\n      font-family: var(--font-mono);\n      font-size: var(--text-large);\n      font-weight: 900;\n      inline-size: 18ch;\n      letter-spacing: 1ch;\n      min-inline-size: 18ch;\n      text-align: center;\n    }\n\n    &[type='number'] {\n      &::-webkit-outer-spin-button,\n      &::-webkit-inner-spin-button {\n        -webkit-appearance: none;\n        margin: 0;\n      }\n    }\n\n    /* Target mobile Safari only */\n    @supports (hanging-punctuation: first) and (font: -apple-system-body) and (-webkit-appearance: none) {\n      @media (hover: none) {\n        font-size: max(16px, 1em) !important;\n      }\n    }\n  }\n\n  .input--file,\n  .input--upload {\n    cursor: pointer;\n\n    &:has(input[type=\"file\"]:focus-visible) {\n      outline: 0.15rem solid var(--color-selected-dark);\n    }\n\n    input[type=\"file\"] {\n      --hover-size: 0;\n      --input-border-color: transparent;\n      --input-border-radius: 8px;\n\n      block-size: 100%;\n      cursor: pointer;\n      font-size: 0;\n      inline-size: 100%;\n      overflow: clip;\n\n      &::file-selector-button {\n        appearance: none;\n        cursor: pointer;\n        opacity: 0;\n      }\n    }\n  }\n\n  .input--file {\n    display: grid;\n    inline-size: auto;\n    place-items: center;\n\n    > * {\n      grid-area: 1 / 1;\n    }\n\n    img {\n      border-radius: 0.4em;\n    }\n\n    &:is(.avatar) {\n      input[type=\"file\"] {\n        border-radius: 50%;\n      }\n    }\n  }\n\n  .input--upload {\n    --btn-border-color: var(--color-ink);\n    --btn-border-radius: 1ch;\n\n    border-style: dashed;\n    position: relative;\n\n    input[type=\"file\"] {\n      inset: 0;\n      outline: none;\n      position: absolute;\n    }\n\n    &:has([data-upload-preview-target=\"fileName\"]:not([hidden])) {\n      --btn-border-color: var(--color-positive);\n      --btn-color: var(--color-positive);\n    }\n  }\n\n  .input--select {\n    --input-border-radius: 2em;\n    --input-padding: 0.5em 1.8em 0.5em 1.2em;\n    --caret-icon: url(\"data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23000'/%3E%3C/svg%3E\");\n    --caret-icon-dark: url(\"data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E\");\n\n    -webkit-appearance: none;\n    appearance: none;\n    background-image: var(--caret-icon);\n    background-size: 0.5em;\n    background-position: center right 0.9em;\n    background-repeat: no-repeat;\n    text-align: start;\n\n    html[data-theme=\"dark\"] & {\n      --caret-icon: var(--caret-icon-dark);\n    }\n\n    @media (prefers-color-scheme: dark) {\n      html:not([data-theme]) & {\n        --caret-icon: var(--caret-icon-dark);\n      }\n    }\n\n    option {\n      background-color: var(--color-canvas);\n      color: var(--color-ink);\n    }\n  }\n\n  .input--textarea {\n    --input-padding: 0;\n\n    line-height: inherit;\n    min-block-size: calc(3lh + (2 * var(--input-padding)));\n    min-inline-size: 100%;\n    padding-block: var(--input-padding);\n    padding-inline: calc(var(--input-padding) + calc((1lh - 1ex) / 2));\n\n    @supports (field-sizing: content) {\n      field-sizing: content;\n      max-block-size: calc(3lh + (2 * var(--input-padding)));\n      min-block-size: calc(1lh + (2 * var(--input-padding)));\n    }\n  }\n\n  .input--invisible {\n    background-color: transparent;\n    block-size: 5px;\n    border: none;\n    inline-size: 5px;\n    opacity: 0.1;\n\n    &:focus {\n      outline: none;\n    }\n  }\n\n  /* Switches */\n  .switch {\n    --switch-color: var(--color-ink-medium);\n    --switch-hover-brightness: 0.9;\n\n    block-size: 1.75em;\n    border-radius: 2em;\n    display: inline-flex;\n    inline-size: 3em;\n    position: relative;\n\n    &:has(:focus-visible) {\n      .switch__btn {\n        outline: var(--focus-ring-size) solid var(--focus-ring-color);\n      }\n    }\n  }\n\n  .switch__input {\n    block-size: 0;\n    inline-size: 0;\n    opacity: 0.1;\n  }\n\n  .switch__btn {\n    background-color: var(--switch-color);\n    border-radius: 2em;\n    cursor: pointer;\n    inset: 0;\n    outline-offset: var(--focus-ring-offset);\n    position: absolute;\n    transition: 150ms ease;\n\n    &::before {\n      background-color: var(--color-ink-inverted);\n      block-size: 1.35em;\n      border-radius: 50%;\n      content: \"\";\n      inline-size: 1.35em;\n      inset-block-end: 0.2em;\n      inset-inline-start: 0.2em;\n      position: absolute;\n      transition: 150ms ease;\n    }\n\n    @media (any-hover: hover) {\n      &:hover {\n        background-color: color-mix(in srgb, var(--switch-color) 80%, var(--color-ink));\n      }\n    }\n\n    .switch__input:checked + & {\n      --switch-color: var(--color-link);\n\n      &::before {\n        transform: translateX(1.2em);\n      }\n    }\n\n    .switch__input:disabled + & {\n      --switch-color: var(--color-ink-medium);\n\n      cursor: not-allowed;\n      opacity: 0.5;\n    }\n  }\n\n  /* Containers that act like (and contain) inputs */\n  .input--actor {\n    outline-offset: -1px;\n    transition: box-shadow 150ms ease, outline-offset 150ms ease;\n\n    &:focus-within {\n      --input-border-color: var(--color-selected-dark);\n\n      outline: var(--focus-ring-size) solid var(--focus-ring-color);\n    }\n\n    .input {\n      --input-padding: 0;\n      --input-border-radius: 0;\n      --input-background: transparent;\n      --input-border-size: 0;\n\n      inline-size: 100%;\n      outline: 0;\n    }\n\n    &:has(.input:is(\n      :autofill,\n      :-webkit-autofill,\n      :-webkit-autofill:hover,\n      :-webkit-autofill:focus)) {\n        -webkit-text-fill-color: var(--color-text);\n        -webkit-box-shadow: 0 0 0px 1000px var(--color-selected) inset;\n    }\n  }\n\n  .input--hidden {\n    block-size: 0;\n    inline-size: 0;\n    opacity: 0;\n    padding: 0;\n  }\n\n  .input.boost__input {\n    --input-border-radius: 0;\n    --input-border-size: 0;\n    --input-padding: 0;\n\n    color: inherit;\n    font-size: inherit;\n    font-weight: inherit;\n    inline-size: min-content;\n    max-inline-size: 3ch;\n    min-inline-size: 1ch;\n    outline: none;\n\n    @supports (field-sizing: content) {\n      field-sizing: content;\n      max-inline-size: unset;\n    }\n\n    &:focus {\n      background-color: var(--color-highlight);\n    }\n\n    &::-webkit-outer-spin-button,\n    &::-webkit-inner-spin-button {\n      -webkit-appearance: none;\n      margin: 0;\n    }\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/ios.css",
    "content": "@layer platform {\n  :root:has([data-platform~=ios]) {\n    &[data-text-size=xsmall] { font-size: 14px; }\n    &[data-text-size=small] { font-size: 15px; }\n    &[data-text-size=medium] { font-size: 16px; }\n    &[data-text-size=large] { font-size: 17px; }\n    &[data-text-size=xlarge] { font-size: 19px; }\n    &[data-text-size=xxlarge] { font-size: 21px; }\n    &[data-text-size=xxxlarge] { font-size: 23px; }\n  }\n\n  [data-platform~=ios] {\n    .hide-on-ios {\n      display: none;\n    }\n\n    /* Events\n    /* ------------------------------------------------------------------------ */\n\n    .events__column-header {\n      background-color: unset;\n\n      & > span {\n        display: inline-block;\n        position: relative;\n\n        &::before {\n          content: \"\";\n          display: block;\n          background-color: oklch(from var(--color-canvas) l c h / 0.8);\n          -webkit-backdrop-filter: blur(16px);\n          backdrop-filter: blur(16px);\n          border-radius: 10em;\n          position: absolute;\n          inset-inline: -1.5ch;\n          inset-block: calc(var(--events-gap) * -0.8);\n          z-index: -1;\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/knobs.css",
    "content": "@layer components {\n  .knob {\n    --knob-angle-reserve: 120deg;\n    --knob-option-angle: calc((360deg - var(--knob-angle-reserve)) / (var(--knob-options) - 1));\n    --knob-option-size: 3ch;\n    --knob-chamfer-size: 1ch;\n    --knob-color: oklch(var(--lch-ink-light));\n    --knob-color-accent: oklch(var(--lch-blue-medium));\n    --knob-tick-size: 1ch;\n    --knob-radius: calc(var(--knob-size) / 2);\n    --knob-size: 96px;\n\n    border: none;\n    display: block;\n    font-weight: 500;\n    padding: var(--knob-option-size) 0 0;\n    position: relative;\n    text-align: center;\n  }\n\n  .knob__slider {\n    appearance: none;\n    background-color: transparent;\n    block-size: var(--knob-size);\n    inline-size: var(--knob-size);\n    inset: 50% auto auto 50%;\n    opacity: 0;\n    position: absolute;\n    translate: -50% -50%;\n    z-index: 1;\n\n    &::-moz-range-track {\n      block-size: var(--knob-size);\n      cursor: grab;\n    }\n\n    &::-webkit-slider-runnable-track {\n      block-size: var(--knob-size);\n      cursor: grab;\n    }\n\n    &::-moz-range-thumb {\n      background-color: transparent;\n      border: none;\n      border-radius: 0;\n    }\n\n    &::-webkit-slider-thumb {\n      appearance: none;\n      background-color: transparent;\n      height: 1px;\n      width: 1px;\n    }\n  }\n\n  .knob__option {\n    block-size: var(--knob-option-size);\n    border-radius: 50%;\n    cursor: pointer;\n    display: grid;\n    inline-size: var(--knob-option-size);\n    inset: 50% auto auto 50%;\n    place-content: center;\n    position: absolute;\n    transform:\n      translate(-50%, -50%)\n      rotate(calc(-1 * ((360deg - var(--knob-angle-reserve)) / 2) + (var(--knob-option-angle) * var(--i))))\n      translateY(calc(-1 * var(--knob-radius) - 50% - var(--knob-tick-size)));\n    z-index: 1;\n\n    &:hover,\n    &:has(input:checked) {\n      color: var(--knob-color-accent);\n    }\n\n    &:has(:focus-visible) {\n      outline: var(--focus-ring-size) solid var(--focus-ring-color);\n      outline-offset: 0;\n    }\n\n    /* Tick marks */\n    &:before {\n      background-color: var(--knob-color);\n      block-size: var(--knob-tick-size);\n      content: \"\";\n      inline-size: 2px;\n      inset: 100% auto auto 50%;\n      position: absolute;\n      translate: -50% 0;\n    }\n\n    /* The value text */\n    span {\n      rotate: calc(((360deg - var(--knob-angle-reserve)) / 2) - (var(--knob-option-angle) * var(--i)));\n    }\n\n    input {\n      opacity: 0;\n      position: absolute;\n    }\n  }\n\n  .knob__knob {\n    background: linear-gradient(to top, var(--knob-color), color-mix(in oklch, var(--knob-color) 50%, var(--color-canvas) 50%));\n    block-size: var(--knob-size);\n    border-radius: 50%;\n    box-shadow:\n      0 0 2px 1px rgba(0,0,0,0.10),\n      0 2px 4px rgba(0,0,0,0.15),\n      0 2px 8px rgba(0,0,0,0.20);\n    inline-size: var(--knob-size);\n    margin-inline: auto;\n    position: relative;\n\n    &:before,\n    &:after {\n      content: \"\";\n      position: absolute;\n    }\n\n    /* Indent */\n    &:before {\n      background: linear-gradient(to bottom, var(--knob-color), color-mix(in oklch, var(--knob-color) 50%, var(--color-canvas) 50%));\n      block-size: calc(var(--knob-size) - var(--knob-chamfer-size));\n      border-radius: 50%;\n      box-shadow:\n        0 -1px 0 rgba(255, 255, 255, 0.25),\n        inset 0 -1px 0 rgba(255, 255, 255, 0.25);\n      inline-size: calc(var(--knob-size) - var(--knob-chamfer-size));\n      inset: 50% auto auto 50%;\n      translate: -50% -50%;\n    }\n\n    /* Indicator */\n    &:after {\n      background-color: var(--color-ink);\n      block-size: calc(var(--knob-radius) - var(--knob-chamfer-size) / 2);\n      border-radius: 50% 50% 2px 2px;\n      inline-size: 4px;\n      inset: auto auto 50% 50%;\n      rotate: calc(-1 * ((360deg - var(--knob-angle-reserve)) / 2) + (var(--knob-option-angle) * var(--knob-index)));\n      transform-origin: center bottom;\n      transition: rotate 100ms;\n      translate: -50% 0;\n    }\n  }\n\n  .knob__label {\n    font-weight: bold;\n    margin-block-start: 1ch;\n    text-transform: uppercase;\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/layout.css",
    "content": "@layer base {\n  body {\n    display: grid;\n    grid-template-rows: auto 1fr auto 9em;\n\n    &.public {\n      grid-template-rows: auto 1fr auto;\n    }\n\n    &.compact-on-touch {\n      @media (any-hover: none) {\n        grid-template-rows: auto 1fr auto;\n        min-height: unset;\n      }\n    }\n  }\n\n  /* Required for the card column page on mobile, but not needed otherwise */\n  :where(#global-container) {\n    display: contents;\n  }\n\n  :where(#header) {\n    position: relative;\n    z-index: var(--z-nav);\n  }\n\n  :where(#main) {\n    inline-size: 100dvw;\n    margin-inline: auto;\n    max-inline-size: 100dvw;\n    padding-inline:\n      calc(var(--main-padding) + var(--custom-safe-inset-left))\n      calc(var(--main-padding) + var(--custom-safe-inset-right));\n    text-align: center;\n  }\n\n  :where(#footer) {\n    max-inline-size: 100dvw;\n  }\n\n  :is(#header, #footer) {\n    @media print {\n      display: none;\n    }\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/lexxy.css",
    "content": "@import url(\"lexxy-variables.css\") layer(base);\n@import url(\"lexxy-content.css\") layer(base);\n@import url(\"lexxy-editor.css\") layer(base);\n\n:root {\n  --lexxy-color-ink: var(--color-ink);\n  --lexxy-color-ink-medium: var(--color-ink-dark);\n  --lexxy-color-ink-light: var(--color-ink-medium);\n  --lexxy-color-ink-lighter: var(--color-ink-light);\n  --lexxy-color-ink-lightest: var(--color-ink-lighter);\n  --lexxy-color-ink-inverted: var(--color-ink-inverted);\n\n  --lexxy-color-canvas: var(--color-canvas);\n\n  --lexxy-color-accent-dark: var(--color-ink-dark);\n  --lexxy-color-accent-medium: var(--color-ink-medium);\n  --lexxy-color-accent-light: var(--color-ink-light);\n  --lexxy-color-accent-lightest: var(--color-ink-lighter);\n\n  --lexxy-color-red: oklch(var(--lch-red-medium));\n  --lexxy-color-green: oklch(var(--lch-green-medium));\n  --lexxy-color-blue: oklch(var(--lch-blue-medium));\n  --lexxy-color-purple: oklch(var(--lch-purple-medium));\n\n  --lexxy-color-code-token-att: var(--color-code-token__att);\n  --lexxy-color-code-token-comment: var(--color-code-token__comment);\n  --lexxy-color-code-token-function: var(--color-code-token__function);\n  --lexxy-color-code-token-operator: var(--color-code-token__operator);\n  --lexxy-color-code-token-property: var(--color-code-token__property);\n  --lexxy-color-code-token-punctuation: var(--color-code-token__punctuation);\n  --lexxy-color-code-token-selector: var(--color-code-token__selector);\n  --lexxy-color-code-token-variable: var(--color-code-token__variable);\n\n  --lexxy-color-selected: oklch(var(--lch-blue-light));\n  --lexxy-color-selected-dark: oklch(var(--lch-blue-medium));\n\n  --lexxy-color-table-cell-border: var(--color-ink-ligher);\n  --lexxy-color-table-cell-selected-bg: var(--lexxy-color-selected);\n  --lexxy-color-table-cell-toggle: var(--lexxy-color-selected);\n  --lexxy-color-table-cell-remove: oklch(var(--lch-red-medium) / 20%);\n\n  --lexxy-focus-ring-offset: 2px;\n}\n\n@layer components {\n  /* Editor\n  /* ------------------------------------------------------------------------ */\n\n  lexxy-editor {\n    --lexxy-border-color: oklch(var(--lch-ink-darkest) / 20%);\n    --lexxy-editor-padding: 0;\n    --lexxy-toolbar-button-size: 2rem;\n\n    background-color: transparent;\n    border: none;\n    border-radius: 0;\n  }\n\n  lexxy-toolbar {\n    border-color: var(--lexxy-border-color);\n    gap: 0;\n  }\n\n  .lexxy-editor__toolbar-button {\n    background: transparent;\n\n    &[aria-pressed=\"true\"],\n    [open] > & {\n      background-color: oklch(var(--lch-blue-medium) / 20%);\n    }\n\n    @media(any-hover: hover) {\n      &:hover:not([aria-pressed=\"true\"]) {\n        background-color: oklch(var(--lch-ink-dark) / 20%);\n      }\n    }\n  }\n\n  lexxy-link-dropdown {\n    --lexxy-toolbar-spacing: 1.5ch;\n\n    .lexxy-editor__toolbar-button {\n      border-radius: 99rem;\n\n      @media(any-hover: hover) {\n        &:hover {\n          filter: brightness(0.9);\n          opacity: 1;\n        }\n      }\n\n      &[type=\"submit\"],\n      &[type=\"submit\"]:hover {\n        background-color: var(--color-link);\n      }\n\n      &[type=\"button\"] {\n        border: 1px solid var(--color-ink-light);\n      }\n    }\n  }\n\n  .lexxy-editor__content {\n    margin-block-start: 0.5lh;\n  }\n\n  .lexxy-code-language-picker {\n    border-radius: 99rem;\n  }\n\n  lexxy-table-tools {\n    font-size: var(--text-x-small);\n  }\n\n  [data-lexical-cursor] {\n    animation: blink 1s step-end infinite;\n    block-size: 1lh;\n    border-inline-start: 1px solid currentColor;\n    line-height: inherit;\n    margin-block: 1em;\n  }\n\n  .lexxy-prompt-menu {\n    max-inline-size: min(35ch, calc(100% - var(--lexxy-prompt-offset-x)));\n  }\n\n  /* Content\n  /* ------------------------------------------------------------------------ */\n\n  .lexxy-content {\n    --lexxy-content-margin: 0.5lh;\n\n    color: currentColor;\n\n    h1, h2, h3, h4, h5, h6 {\n      font-weight: 800;\n      letter-spacing: -0.02ch;\n      line-height: 1.1;\n      overflow-wrap: break-word;\n      text-wrap: balance;\n    }\n\n    p:has(+ p) {\n      margin: 0;\n    }\n\n    ol, ul {\n      &:not(.lexxy-prompt-menu) {\n        padding-inline-start: 1ch;\n      }\n    }\n\n    blockquote {\n      border-inline-start: 0.25em solid var(--color-ink-lighter);\n      padding-block: 0;\n    }\n\n    code {\n      background: var(--color-canvas);\n      border: 1px solid var(--color-ink-lighter);\n    }\n\n    .horizontal-divider {\n      padding-block: var(--lexxy-content-margin);\n      hr { margin: 0; }\n    }\n\n    hr {\n      border: 0;\n      border-block-end: 2px solid currentColor;\n      color: currentColor;\n      inline-size: 20%;\n      margin: calc(var(--lexxy-content-margin) * 2) 0;\n    }\n\n    table {\n      th, td {\n        font-size: var(--text-small);\n        padding-block: 0.75ch;\n      }\n      tr:not([data-action=\"delete\"]) {\n        th:not([class*=\"selected\"], [data-action=\"delete\"], [data-action=\"toggle\"]) { background-color: var(--color-ink-lightest); }\n        td:not([class*=\"selected\"], [data-action=\"delete\"], [data-action=\"toggle\"]) { background-color: var(--color-canvas); }\n      }\n    }\n\n    .attachment {\n      margin-inline: auto;\n    }\n  }\n\n  .attachment {\n    margin-inline: 0;\n  }\n\n  .attachment-gallery {\n    .attachment {\n      display: inline-block;\n      inline-size: calc(33.333% - 0.8ch);\n    }\n\n    &.attachment-gallery--2,\n    &.attachment-gallery--4 {\n      .attachment {\n        inline-size: calc(50% - 0.8ch);\n      }\n    }\n  }\n\n  action-text-attachment[content-type^='application/vnd.actiontext'] {\n    lexxy-node-delete-button {\n      inset-inline-start: -0.25ch;\n\n      .lexxy-floating-controls__group {\n        background-color: oklch(var(--lch-blue-dark));\n        border-radius: 50%;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/lightbox.css",
    "content": "@layer components {\n  .lightbox {\n    --dialog-duration: 350ms;\n    --lightbox-padding: 3vmin;\n\n    background-color: transparent;\n    block-size: 100dvh;\n    border: 0;\n    inline-size: 100dvw;\n    inset: 0;\n    margin: auto;\n    max-height: unset;\n    max-width: unset;\n    overflow: hidden;\n    padding:\n      calc(var(--lightbox-padding) + var(--custom-safe-inset-top))\n      calc(var(--lightbox-padding) + var(--custom-safe-inset-right))\n      calc(var(--lightbox-padding) + var(--custom-safe-inset-bottom))\n      calc(var(--lightbox-padding) + var(--custom-safe-inset-left));\n    text-align: center;\n\n    &::backdrop {\n      -webkit-backdrop-filter: blur(16px);\n      backdrop-filter: blur(16px);\n      background-color: oklch(var(--lch-black) / 50%);\n    }\n\n    /* Closed state */\n    &,\n    &::backdrop {\n      opacity: 0;\n      transition: var(--dialog-duration) allow-discrete;\n      transition-property: display, opacity, overlay;\n    }\n\n    /* Open state */\n    &[open],\n    &[open]::backdrop {\n      align-items: center;\n      display: flex;\n      justify-content: center;\n      opacity: 1;\n\n      @starting-style {\n        opacity: 0;\n      }\n\n      .lightbox__figure {\n        animation: slide-up var(--dialog-duration);\n      }\n    }\n  }\n\n  .lightbox__actions {\n    display: flex;\n    gap: 1ch;\n    inset:\n      calc(var(--lightbox-padding) + var(--custom-safe-inset-top))\n      calc(var(--lightbox-padding) + var(--custom-safe-inset-right))\n      auto\n      auto;\n    position: absolute;\n  }\n\n  .lightbox__figure {\n    animation-fill-mode: forwards;\n    animation: slide-down var(--dialog-duration);\n    display: flex;\n    flex-direction: column;\n    gap: var(--lightbox-padding);\n    margin: 0 auto;\n    max-block-size: 100%;\n\n    img {\n      object-fit: contain;\n    }\n  }\n\n  .lightbox__caption {\n    color: var(--color-white);\n\n    &:empty {\n      display: none;\n    }\n\n    &[tabindex=\"-1\"]:focus-visible {\n      outline: unset;\n    }\n  }\n\n  .lightbox__image {\n    flex: 1;\n    min-block-size: 0;\n  }\n\n  /* Prevent body from scrolling when lightbox is open */\n  html:has(.lightbox[open]) {\n    overflow: clip;\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/markdown.css",
    "content": "@layer components {\n  .heading__link {\n    --opacity: 0.5;\n    --size: 0.8em;\n\n    background: url(link.svg) no-repeat center bottom 0.2em;\n    background-size: var(--size);\n    block-size: 1em;\n    color: var(--color-link);\n    display: inline-flex;\n    font-size: var(--size);\n    inline-size: var(--size);\n    padding: 1em 0 0;\n    opacity: var(--opacity);\n    overflow: hidden;\n    transition: opacity 300ms ease;\n    vertical-align: middle;\n\n    @media (hover: hover) {\n      --opacity: 0;\n\n      :is(h1, h2, h3, h4, h5, h6):hover & {\n        --opacity: 0.5;\n      }\n    }\n\n    html[data-theme=\"dark\"] & {\n      filter: invert(1);\n    }\n\n    @media (prefers-color-scheme: dark) {\n      html:not([data-theme]) & {\n        filter: invert(1);\n      }\n    }\n\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/native.css",
    "content": "@layer native {\n  [data-platform~=native] {\n    --footer-height: 0;\n\n    -webkit-tap-highlight-color: transparent;\n\n    .hide-on-native {\n      display: none;\n    }\n\n    /* Layout\n    /* ------------------------------------------------------------------------ */\n\n    &:not(.contained-scrolling) {\n      #main {\n        padding-block-end: var(--custom-safe-inset-bottom);\n      }\n    }\n\n    /* Header\n    /* ------------------------------------------------------------------------ */\n\n    .header {\n      padding-block-start: var(--custom-safe-inset-top);\n    }\n\n    .header--mobile-actions-stack {\n      .header__title {\n        margin-block-start: 0;\n      }\n    }\n\n    .header:is(\n      :not(:has(.header__title, .header__actions)),\n      :not(:has(.header__title, .header__actions--end)):has(.header__actions--start .btn--back:only-child)\n    ) {\n      block-size: var(--custom-safe-inset-top);\n      padding: unset;\n\n      * {\n        display: none;\n      }\n    }\n\n    .header__actions {\n      .btn--back {\n        display: none;\n      }\n    }\n\n    /* Card columns\n    /* ------------------------------------------------------------------------ */\n\n    .board-tools.card {\n      padding-block-start: 0;\n    }\n\n    /* Card perma\n    /* ------------------------------------------------------------------------ */\n\n    .card-perma {\n      margin-block-start: var(--block-space-half);\n\n      &:not(:has(.card-perma__notch-new-card-buttons)) .card-perma__bg {\n        padding-block-end: clamp(0.25rem, 2vw, var(--padding-block));\n      }\n\n      .card {\n        background: linear-gradient(to bottom, var(--color-canvas), var(--card-bg-color));\n        box-shadow: unset;\n      }\n\n      .card__board {\n        border-radius: 0 var(--border-radius) var(--border-radius) 0;\n      }\n    }\n\n    .card-perma__bg {\n      padding-inline: 0;\n      padding-block-start: 0;\n      background-color: unset;\n    }\n\n    .card-perma__closure-message {\n      margin-block: var(--block-space);\n      translate: unset;\n    }\n\n    .card-perma--draft {\n      .card {\n        box-shadow: 0 101vh 0 100vh var(--card-bg-color);\n      }\n\n      .card-perma__notch--bottom {\n        z-index: 1;\n      }\n    }\n\n    /* Search\n    /* ------------------------------------------------------------------------ */\n\n    .search {\n      overscroll-behavior: auto;\n    }\n\n    .search__title {\n      text-decoration: none;\n    }\n  }\n}\n\n[data-bridge-components~=form] {\n  [data-controller~=bridge--form] {\n    [data-bridge--form-target~=submit] {\n      display: none;\n    }\n  }\n}\n\n[data-bridge-components~=overflow-menu] {\n  [data-controller~=bridge--overflow-menu] {\n    [data-bridge--overflow-menu-target~=item] {\n      display: none;\n    }\n  }\n}\n\n[data-bridge-components~=buttons] {\n  [data-bridge--buttons-target~=button] {\n    display: none;\n  }\n}\n\n[data-bridge-components~=stamp] {\n  [data-controller~=bridge--stamp] {\n    display: none;\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/nav.css",
    "content": "@layer components {\n  /* Trigger\n  /* ------------------------------------------------------------------------ */\n\n  .nav__trigger {\n    --input-background: transparent;\n    --input-border-color: transparent;\n    --input-padding: 0.3em 2em 0.3em 0.75em;\n\n    font-weight: 600;\n    inline-size: auto;\n    margin-block-start: 0.1em;\n\n    ::-webkit-search-cancel-button {\n      -webkit-appearance: none;\n    }\n\n    @media (any-hover: hover) {\n      &:hover {\n        --input-background: var(--color-ink-lighter);\n\n        span {\n          background: var(--color-ink-lighter);\n        }\n      }\n    }\n\n    span {\n      background: var(--color-ink-lightest);\n      block-size: auto;\n      border-radius: 0.3125em;\n      box-shadow:\n        0 0 0 1px oklch(var(--lch-ink-darkest) / 0.1),\n        0 0.1em 0.2em -0.1em oklch(var(--lch-ink-darkest) / 0.05),\n        0 0.2em 0.4em -0.2em oklch(var(--lch-ink-darkest) / 0.05),\n        0 0.3em 0.6em -0.3em oklch(var(--lch-ink-darkest) / 0.05)\n      ;\n      display: grid;\n      height: 1.5em;\n      inline-size: 1.5em;\n      padding: 0.325em 0.275em 0.225em 0.275em;\n      place-content: center;\n      width: 1.5em;\n    }\n\n    svg {\n      height: 100%;\n      margin-inline-start: 0.4125em;\n      margin-inline-end: 0.5375em;\n      max-height: 0.8625em;\n      overflow: visible;\n      width: auto;\n    }\n  }\n\n  /* Dialog\n  /* ------------------------------------------------------------------------ */\n\n  .nav__menu.popup {\n    --panel-border-color: var(--color-selected-dark);\n    --panel-border-radius: 1.4em;\n    --panel-padding: var(--block-space);\n    --panel-size: 45ch;\n    --popup-display: grid;\n    --nav-section-gap: 2px;\n\n    block-size: auto;\n    box-shadow: 0 0 0 1px oklch(var(--lch-blue-medium) / 5%),\n                0 0.2em 0.2em oklch(var(--lch-blue-medium) / 5%),\n                0 0.4em 0.4em oklch(var(--lch-blue-medium) / 5%),\n                0 0.8em 0.8em oklch(var(--lch-blue-medium) / 5%);\n    gap: var(--nav-section-gap);\n    grid-template-rows: auto 1fr auto;\n    inset: 0 0 auto 0;\n    margin-inline: auto;\n    max-block-size: calc(100dvh - var(--block-space) - var(--footer-height));\n    overflow: hidden;\n    padding-block-end: 0;\n    scrollbar-gutter: stable both-edges;\n    transform-origin: top center;\n    translate: 0;\n    z-index: var(--z-nav);\n\n    @media (max-height: 668px) {\n      max-block-size: calc(100dvh - var(--block-space));\n    }\n  }\n\n  .nav__scroll-container {\n    display: flex;\n    flex-direction: column;\n    gap: var(--nav-section-gap);\n    margin-inline: calc(-1 * var(--block-space)); /* space for scrollbar */\n    overflow: auto;\n    padding-block-end: var(--nav-section-gap);\n    padding-inline: var(--block-space);\n  }\n\n  .nav__close {\n    @media (any-hover: hover) {\n      display: none !important;\n    }\n  }\n\n  .nav__section {\n    border-block-start: 1px solid var(--color-ink-lighter);\n    font-size: var(--text-small);\n\n    &[open] {\n      padding-block-end: 0.4rem;\n    }\n  }\n\n  .nav__section--secret:not([data-is-filtering]) {\n    display: none;\n  }\n\n  .nav__header {\n    --actions-width: 2rem;\n\n    display: grid;\n    grid-template-columns: var(--actions-width) 1fr var(--actions-width);\n    grid-template-areas:\n      \"actions-start title actions-end\";\n    justify-items: center;\n  }\n\n  .nav__header-actions {\n    display: flex;\n    font-size: var(--text-x-small);\n    gap: var(--inline-space);\n    inline-size: var(--actions-width);\n    min-inline-size: fit-content;\n  }\n\n  .nav__header-actions--end {\n    grid-area: actions-end;\n    justify-content: flex-end;\n    margin-inline-start: auto;\n  }\n\n  .nav__header-actions--start {\n    grid-area: actions-start;\n    margin-inline-end: auto;\n  }\n\n  .nav__header-title {\n    color: inherit;\n    grid-area: title;\n    justify-content: center;\n    margin: auto;\n    min-inline-size: 0;\n    text-align: center;\n  }\n\n  .nav__hotkeys {\n    --gap: 8px;\n\n    align-items: center;\n    display: flex;\n    flex-wrap: wrap;\n    gap: var(--gap);\n    inline-size: 100%;\n    list-style: none;\n    justify-content: center;\n    margin: var(--block-space) auto calc(var(--block-space-half) / 2);\n    max-inline-size: 100%;\n\n    /* When all its children are hidden, hide this as well so it doesn't take up space */\n    &:has(.popup__item[hidden]):not(:has(.popup__item:not([hidden]))) {\n      display: none;\n    }\n\n    .btn {\n      --btn-border-radius: 0.4em;\n\n      align-content: end;\n      aspect-ratio: 5/3;\n      background-color: var(--color-ink-lightest);\n      border-radius: 0.5em;\n      container-type: inline-size;\n      flex-basis: calc((100% - var(--gap) * 2) / 3);\n      flex-direction: column;\n      font-size: var(--text-small);\n      line-height: 1;\n      justify-content: center;\n      overflow: hidden;\n      position: relative;\n      row-gap: 0.3lh;\n      text-align: center;\n\n      kbd {\n        inset: 0.66em 0.33em auto auto;\n        line-height: 1.4;\n        opacity: 0.5;\n        position: absolute;\n\n        @media (any-hover: none) {\n          /* This is a reasonable way to assert touch devices. any-pointer would seem */\n          /* to be a better fit but it is incorrectly reported on many devices */\n          display: none;\n        }\n      }\n\n      .icon {\n        --icon-size: 2em !important;\n      }\n\n      span {\n        display: flex;\n        text-wrap: nowrap;\n      }\n\n      @media (max-width: 639px) {\n        font-size: var(--text-x-small);\n        font-size: clamp(var(--text-xx-small), 3.15cqi, var(--text-small));\n        font-weight: 500;\n      }\n    }\n  }\n\n  .nav__blank-slate {\n    font-size: var(--text-small);\n    margin: 2rem auto;\n\n    .nav:has(.popup__item:not([hidden])) & {\n      display: none;\n    }\n  }\n\n  .nav__footer {\n    background-color: var(--color-canvas);\n    border-block-start: 1px solid var(--color-ink-lighter);\n    font-size: var(--text-small);\n    line-height: 1.6;\n    margin-block-start: calc(-1 * var(--nav-section-gap));\n    padding: 1.5ch;\n    text-align: center;\n    z-index: 1;\n\n    @media (max-height: 668px) {\n      font-size: var(--text-x-small);\n    }\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/notifications.css",
    "content": "@layer components {\n  /* Notifications list\n  /* ------------------------------------------------------------------------ */\n\n  .notifications-list {\n    --panel-size: 45ch;\n\n    .tray__item {\n      position: relative;\n\n      &[aria-selected] {\n        outline: 0;\n\n        .card {\n          border-radius: 0.25ch;\n          outline: var(--focus-ring-size) solid var(--focus-ring-color);\n          outline-offset: var(--focus-ring-offset);\n        }\n      }\n    }\n\n    .card {\n      @media (prefers-color-scheme: dark) {\n        box-shadow: 0 0 0 1px var(--color-ink-lighter);\n      }\n    }\n\n    .card__header {\n      column-gap: var(--inline-space-half);\n    }\n\n    &:has(.card--notification) {\n      .notifications-list__blank-slate {\n        display: none;\n      }\n    }\n  }\n\n  /* Read items\n  /* ------------------------------------------------------------------------ */\n\n  .notifications-list--read {\n    &:not(:has(.card--notification)) {\n      display: none;\n    }\n\n    .card {\n      box-shadow: 0 0 0 1px var(--color-ink-lighter);\n    }\n\n    .card__notification-unread-indicator {\n      --btn-background: transparent;\n      --btn-size: 1.8em;\n\n      margin: 2px;\n\n      .icon {\n        block-size: 1.7em;\n        color: var(--color-ink);\n        inline-size: 1.7em;\n        opacity: 1;\n      }\n    }\n  }\n\n\n /* Help\n /* ------------------------------------------------------------------------ */\n  .notifications-help {\n    h3 {\n      font-size: var(--text-medium);\n      margin: 0;\n    }\n\n    .icon {\n      --icon-size: 1.2em;\n\n      vertical-align: text-top;\n    }\n\n    ol {\n      margin-block: var(--block-space-half) var(--block-space);\n\n      &:last-of-type {\n        margin-block-end: var(--block-space-half);\n      }\n    }\n  }\n\n  .notifications-help__explainer {\n    padding: var(--block-space);\n  }\n\n  .notifications__on-message {\n    display: none;\n\n    .notifications--on & {\n      display: revert;\n    }\n  }\n\n  .notifications__off-message {\n    display: revert;\n\n    .notifications--on & {\n      display: none;\n    }\n  }\n\n  .notifications__status {\n    --panel-border-radius: 0.5em;\n    --panel-padding: var(--block-space);\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/pagination.css",
    "content": "@layer components {\n  .pagination-link {\n    display: block;\n    flex: 1;\n    min-block-size: 1.5rem;\n    pointer-events: none;\n    text-decoration: none;\n\n    &.pagination-link--active-when-observed {\n      block-size: 0;\n      inline-size: 0;\n      overflow: hidden;\n      visibility: hidden;\n\n      turbo-frame:has(&):has(~ turbo-frame) & {\n        display: none;\n      }\n    }\n\n    &[aria-busy=\"true\"] {\n      .spinner {\n        display: block;\n      }\n    }\n  }\n\n  .day-timeline-pagination-link {\n    block-size: 1px;\n    display: block;\n    inline-size: 1px;\n    overflow: clip;\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/panels.css",
    "content": "@layer components {\n  .panel {\n    background-color: var(--panel-bg, var(--color-canvas));\n    border: var(--panel-border-size, 1px) solid var(--panel-border-color, var(--color-ink-lighter));\n    border-radius: var(--panel-border-radius, 1em);\n    color: var(--color-ink);\n    inline-size: var(--panel-size, 60ch);\n    max-inline-size: 100%;\n    padding: var(--panel-padding, var(--block-space));\n\n    @media (min-width: 640px) {\n      --panel-size: 100%;\n\n      padding: var(--panel-padding, var(--block-space-double));\n    }\n  }\n\n  .panel--wide {\n    --panel-size: 60ch;\n  }\n\n  .panel--centered {\n    --panel-border-size: 0;\n    --panel-size: 100%;\n\n    @media (min-width: 640px) {\n      --panel-size: 42ch;\n    }\n\n    #main:has(&) {\n      display: grid;\n      justify-content: center;\n      margin: auto;\n    }\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/performance-notice.css",
    "content": ".performance-notice {\n  background: oklch(var(--lch-yellow-lightest));\n  border-radius: 1ch;\n  border: 1px solid oklch(var(--lch-yellow-light));\n  font-size: var(--text-small);\n  margin-block-end: 2ch;\n  margin-inline: auto;\n  max-inline-size: 60ch;\n  padding-inline: 2ch;\n  padding: 1ch;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/pins.css",
    "content": "@layer components {\n  .pins-list {\n    --panel-size: 45ch;\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/popup.css",
    "content": "@layer components {\n  .popup {\n    --btn-background: transparent;\n    --panel-border-radius: 0.5em;\n    --panel-padding: var(--block-space);\n    --panel-size: auto;\n    --popup-icon-size: 24px;\n    --popup-item-padding-inline: 0.4rem;\n    --popup-display: flex;\n\n    inset: 0 auto auto 50%;\n    max-block-size: 70dvh;\n    max-inline-size: min(55ch, calc(100vw - (var(--panel-padding))));\n    min-inline-size: min(25ch, calc(100vw - (var(--panel-padding))));\n    overflow: auto;\n    position: absolute;\n    translate: -50%;\n    z-index: var(--z-popup);\n\n    &[open] {\n      display: var(--popup-display);\n    }\n\n    /* The .pop-up--align-<drection> classes are used for initial alignment.\n     * The orient JS helper layers on the .orient-<direction> to avoid running\n     * off the edge of the screen and will override .popup--align */\n    &:where(.popup--align-left),\n    &.orient-left {\n      inset-inline: auto 0;\n      translate: var(--orient-offset, 0px);\n    }\n\n    &:where(.popup--align-right),\n    &.orient-right {\n      inset-inline: 0 auto;\n      translate: var(--orient-offset, 0px);\n    }\n\n    form {\n      display: contents;\n    }\n  }\n\n  .popup__footer {\n    border-block-start: 1px solid var(--color-ink-lightest);\n    color: var(--card-color);\n    margin-block-start: var(--popup-item-padding-inline);\n    padding: var(--popup-item-padding-inline) var(--popup-item-padding-inline) 0;\n    text-align: center;\n    text-transform: initial;\n  }\n\n  .popup__title {\n    font-weight: 800;\n    white-space: nowrap;\n\n    &[tabindex=\"-1\"]:focus-visible {\n      outline: unset;\n    }\n  }\n\n  /* Hide lists when all the items within are hidden */\n  .popup__section {\n    &:not(:has(.popup__list)),\n    &:not(:has(.popup__list > *)),\n    &:has(.popup__item[hidden]):not(:has(.popup__item:not([hidden]))) {\n      display: none;\n    }\n  }\n\n  .popup__section-title {\n    background: var(--color-canvas);\n    font-size: var(--text-small);\n    font-weight: 600;\n    inset-block-start: 0;\n    list-style: none;\n    padding: 0.75ch var(--inline-space-half);\n    position: sticky;\n    text-transform: uppercase;\n    z-index: 1;\n\n    &:is(summary) {\n      align-items: center;\n      cursor: pointer;\n      display: flex;\n      gap: 0.5ch;\n    }\n\n    &::-webkit-details-marker {\n      display: none;\n    }\n\n    .icon--caret-down {\n      font-size: 1ch;\n      margin-inline-start: -0.5ch;\n      transition: rotate 150ms ease-out;\n    }\n\n    .popup__section:not([open]) & {\n      .icon--caret-down {\n        rotate: -90deg;\n      }\n    }\n  }\n\n  .popup__list {\n    display: flex;\n    flex-direction: column;\n    inline-size: 100%;\n    list-style: none;\n    margin: 0;\n    max-inline-size: 100%;\n    padding: 0;\n    row-gap: 1px;\n\n    details & {\n      margin-inline-start: 0.75ch;\n    }\n  }\n\n  .popup__item {\n    align-items: center;\n    background: transparent;\n    border-radius: 0.3em;\n    display: flex;\n    inline-size: 100%;\n    max-inline-size: 100%;\n\n    @media (any-hover: hover) {\n      &:hover {\n        background: var(--color-ink-lightest);\n      }\n    }\n\n    &:has(.popup__btn[disabled]) {\n      pointer-events: none;\n    }\n\n    .checked {\n      display: none;\n    }\n\n    &[aria-checked=\"true\"] .checked {\n      display: block;\n    }\n\n    &[aria-selected] {\n      background-color: var(--color-selected);\n\n      @media (any-hover: hover) {\n        &:hover {\n          background-color: var(--color-selected);\n        }\n      }\n    }\n  }\n\n  /* The actionable thing with padding within popup__item */\n  .popup__btn {\n    --btn-border-radius: 0.3em;\n    --btn-border-size: 0;\n\n    flex: 1 1 auto;\n    font-weight: 500;\n    justify-content: start;\n    inline-size: 100%;\n    min-inline-size: 0;\n    max-inline-size: 100%;\n    padding: var(--inline-space-half) var(--popup-item-padding-inline);\n    text-align: start;\n\n    &:focus-visible {\n      z-index: 1;\n    }\n  }\n\n  .popup__icon {\n    --icon-size: 1em;\n\n    inline-size: var(--popup-icon-size);\n    margin-inline-start: var(--popup-item-padding-inline);\n  }\n\n  .popup__radio {\n    --icon-size: var(--text-x-small);\n\n    block-size: var(--popup-icon-size);\n    inline-size: var(--popup-icon-size);\n    margin-inline-start: var(--popup-item-padding-inline);\n    flex-shrink: 0;\n\n    &:hover {\n      --btn-border-color: var(--color-ink);\n    }\n  }\n\n  /* Animated\n  /* -------------------------------------------------------------------------- */\n\n  .popup--animated {\n    opacity: 0;\n    transform: scale(0.2);\n    transform-origin: top left; /* Works as `top center` because popups have `translate: -50%` */\n    transition: var(--dialog-duration) allow-discrete;\n    transition-property: display, opacity, overlay, transform;\n\n    &::backdrop {\n      background-color: var(--color-always-black);\n      opacity: 0;\n      transform: scale(1);\n      transition: var(--dialog-duration) allow-discrete;\n      transition-property: display, opacity, overlay;\n    }\n\n    &[open] {\n      opacity: 1;\n      transform: scale(1);\n\n      &::backdrop {\n        opacity: 0.5;\n      }\n    }\n\n    @starting-style {\n      &[open] {\n        opacity: 0;\n        transform: scale(0.2);\n      }\n\n      &[open]::backdrop {\n        opacity: 0;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/print.css",
    "content": "@media print {\n  /* Global\n  /* ------------------------------------------------------------------------ */\n\n  :root {\n    --color-ink: black;\n    --color-canvas: white;\n    --border-dark: 1px solid var(--color-ink);\n    --border-light: 1px solid color-mix(in oklch, var(--color-ink), transparent 75%);\n    --font-sans: \"Adwaita Sans\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Noto Sans\", Helvetica, Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Hiragino Sans GB\", \"Hiragino Sans\", \"Apple SD Gothic Neo\", \"Microsoft YaHei\", \"Meiryo\", \"Malgun Gothic\";\n  }\n\n  @page {\n    margin: 0.5in;\n  }\n\n  html {\n    font-size: 10pt;\n  }\n\n  main {\n    inline-size: unset;\n    margin: 0;\n    orphans: 3;\n    padding: 0;\n    widows: 2;\n  }\n\n  /* Browsers usually disable backgrounds on print, so this ensures icons\n    (which use BG masks) will show up. */\n  .icon,\n  .knob,\n  .switch {\n    -webkit-print-color-adjust: exact;\n    color-adjust: exact;\n    print-color-adjust: exact;\n  }\n\n  .nav__menu,\n  .nav__trigger,\n  .header__actions {\n    display: none;\n  }\n\n  .header {\n    padding: 0;\n  }\n\n  .header__title {\n    margin-block-end: 1ch;\n  }\n\n  /* Cards\n  /* -------------------------------------------------------------------------- */\n\n  .card {\n    --card-color: var(--color-ink) !important;\n\n    background: var(--color-canvas);\n    border: var(--border-light);\n    box-shadow: none;\n    break-inside: avoid;\n    color: var(--color-ink);\n  }\n\n  .card {\n    .card__board {\n      background: var(--color-canvas);\n      border-block-end: var(--border-light);\n      border-inline-end: var(--border-light);\n      color: var(--color-ink);\n    }\n  }\n\n  .card__title {\n    font-weight: bold;\n  }\n\n  /* Events\n  /* ------------------------------------------------------------------------ */\n\n  .events__columns {\n    border-inline: none;\n  }\n\n  .events__column-header {\n    background: none;\n    margin: 0;\n    padding-block: 1ch;\n  }\n\n  .events__time-block {\n    padding: 0 1ch;\n\n    .events__column:first-child & {\n      padding-inline-start: 0;\n    }\n\n    .events__column:last-child & {\n      padding-inline-end: 0;\n    }\n  }\n\n  .events__none {\n    padding-block: 2ch;\n  }\n\n  .event {\n    --card-color: var(--color-ink) !important;\n\n    background: var(--color-canvas);\n    border: var(--border-light);\n    box-shadow: none;\n    break-inside: avoid;\n    color: var(--color-ink);\n\n    .avatar {\n      --avatar-size: 2lh;\n    }\n  }\n\n  /* Boards\n  /* ------------------------------------------------------------------------ */\n\n  .filters,\n  .card--new,\n  .cards__decoration,\n  .card-columns:before,\n  .cards--maybe:before {\n    display: none;\n  }\n\n  .card-columns {\n    border-block-start: var(--border-light);\n    margin-block-end: 1ch;\n    min-block-size: unset;\n  }\n\n  .cards--on-deck,\n  .cards--doing {\n    padding-inline: 0;\n  }\n\n  .cards--maybe {\n    background: none;\n    margin: 0;\n    padding-inline: 1ch;\n\n    .card__header {\n      margin-inline: calc(-1 * var(--card-padding-inline));\n    }\n\n    .card__body {\n      padding-block-start: calc(var(--card-padding-block) / 2);\n    }\n  }\n\n  /* Card perma\n  /* ------------------------------------------------------------------------ */\n\n  .card-perma__notch,\n  .card-perma__actions,\n  .comment--new,\n  .comments__subscribers,\n  .card__meta-avatars--assignees > div > div:last-child,\n  .steps > .step:last-child,\n  .card__board-name .icon,\n  div:has(> .card__tag-picker-button),\n  .delete-card,\n  .header--card .header__title {\n    display: none;\n  }\n\n  .card-perma {\n    display: block;\n    inline-size: 100%;\n    margin: 0 0 1lh;\n\n    .card {\n      aspect-ratio: unset;\n    }\n\n    .card__title {\n      font-size: var(--text-x-large);\n    }\n  }\n\n  .card-perma__bg {\n    background: transparent;\n    padding: 0;\n  }\n\n  .comments {\n    --row-gap: 0;\n    --comment-padding-inline: 1.4lh;\n\n    padding-inline: 0;\n  }\n\n  .comment {\n    --comment-max: none;\n\n    border-block-end: var(--border-light);\n  }\n\n  .comment__content {\n    background: none;\n  }\n\n  .comment__avatar {\n    --btn-size: 2lh;\n\n    margin-inline-start: 0;\n  }\n\n  .comment__author h3 {\n    margin-inline: 0;\n  }\n\n  .comment__edit {\n    display: none;\n  }\n\n  .comment__body {\n    text-align: start;\n  }\n\n  .reactions {\n    margin-block-start: 0;\n  }\n\n  /* Settings\n  /* ------------------------------------------------------------------------ */\n\n  .settings__user-filter .input {\n    display: none;\n  }\n\n  .settings__panel {\n    border: none;\n    border-radius: 0;\n    box-shadow: none;\n    padding: 0;\n    text-align: left;\n\n    h2 {\n      &:before,\n      &:after {\n        display: none;\n      }\n    }\n  }\n\n  .settings__panel--users {\n    form:has(.btn--negative) {\n      display: none;\n    }\n\n    .btn {\n      background-color: transparent;\n      border: none;\n      color: var(--color-ink);\n      opacity: 1;\n    }\n\n    .btn:not(:has(input:checked)) {\n      opacity: 0;\n    }\n  }\n\n  .settings__panel--entropy {\n    display: none;\n  }\n\n  .settings__user-filter {\n    background: none;\n    margin: 0;\n    padding: 0;\n\n    /* Hide the \"Everyone\" switch */\n    > li:first-child {\n      display: none;\n    }\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/pwa.css",
    "content": "/* PWA install */\n.pwa__instructions {\n  @media (display-mode: standalone) {\n    display: none;\n  }\n}\n\n.pwa__installer {\n  display: none;\n\n  .pwa--can-install & {\n    display: block;\n  }\n}"
  },
  {
    "path": "app/assets/stylesheets/qr-codes.css",
    "content": "@layer components {\n  .qr-code {\n    aspect-ratio: 1;\n    border-radius: 1ch;\n    inline-size: clamp(20ch, 50dvh, 70ch);\n    margin-block: var(--block-space);\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/reactions.css",
    "content": "@layer components {\n  .reactions {\n    --btn-icon-size: 1.3em;\n    --column-gap: 0.4ch;\n    --reaction-border-color: var(--color-ink-lighter);\n    --row-gap: 0;\n\n    align-items: center;\n    display: flex;\n    flex-wrap: wrap;\n    gap: var(--inline-space-half);\n    inline-size: 100%;\n    z-index: 3;\n\n    &:has([open]) {\n      z-index: var(--z-popup);\n    }\n\n    &:not(:has(.reaction)) {\n      inline-size: auto;\n\n      .reactions__list {\n        display: none;\n      }\n\n      .reactions__trigger {\n        --btn-border-color: var(--color-ink-lightest);\n\n        background-color: var(--color-ink-lightest);\n      }\n    }\n  }\n\n  .reactions__trigger {\n    --btn-size: var(--reaction-size);\n    --btn-border-color: var(--reaction-border-color);\n\n    img {\n      block-size: 65%;\n      inline-size: 65%;\n    }\n  }\n\n  .reactions__list {\n    display: inline-flex;\n    flex-wrap: wrap;\n    gap: var(--inline-space-half);\n\n    &:not(:has(.reaction)) {\n      display: none;\n    }\n  }\n\n  /* Single reaction\n  /* -------------------------------------------------------------------------- */\n\n  .reaction {\n    --btn-size: 100%;\n    --reaction-hover-brightness: 0.9;\n\n    align-items: center;\n    background-color: var(--color-canvas);\n    block-size: var(--reaction-size);\n    border: 1px solid var(--reaction-border-color);\n    border-radius: 4rem;\n    display: inline-flex;\n    gap: 0.25ch;\n    max-inline-size: 100%;\n    opacity: 1;\n    padding: 0.1em 0.36em 0.1em 0.12em;\n    position: relative;\n    transition: opacity 100ms ease-in-out, transform 150ms ease-in-out;\n\n    &:has(span.txt-small.txt-medium) { /* emoji only reactions */\n      padding: 0.1em 0.12em;\n    }\n\n    .btn {\n      font-size: 0.6em;\n      inline-size: auto;\n    }\n\n    @media (any-hover: none) {\n      padding-inline-end: 0.12em;\n    }\n  }\n\n  .reaction--deleteable {\n    cursor: pointer;\n\n    @media (any-hover: hover) {\n      &:not(.expanded):hover {\n        filter: brightness(var(--reaction-hover-brightness));\n\n        html[data-theme=\"dark\"] & {\n          --reaction-hover-brightness: 1.25;\n        }\n\n        @media (prefers-color-scheme: dark) {\n          html:not([data-theme]) & {\n            --reaction-hover-brightness: 1.25;\n          }\n        }\n\n      }\n    }\n  }\n\n  /* Make the avatar and delete buttons fit nicely within the reaction */\n  .reaction__avatar,\n  .reaction__avatar .avatar,\n  .reaction__form-label,\n  .reaction form {\n    block-size: 100%;\n  }\n\n\n  .reaction__delete {\n    display: none;\n\n    .expanded & {\n      display: grid;\n    }\n  }\n\n  .reaction__form {\n    transition: none;\n\n    &.expanded {\n      animation: react 300ms both;\n      transform: translate3d(0, 0, 0);\n      transform-origin: left center;\n    }\n\n    &:has(.input:focus) {\n      outline: var(--focus-ring-size) solid var(--focus-ring-color);\n      outline-offset: -1px;\n    }\n\n    .reaction__form-label:focus {\n      outline: none;\n    }\n  }\n\n  .reaction__input {\n    --input-background: transparent;\n    --input-border-size: 0;\n    --input-padding: 0;\n\n    box-shadow: none;\n    display: inherit;\n    max-inline-size: 16ch;\n    min-inline-size: 2em;\n    outline: 0;\n  }\n\n  .reaction--deleting {\n    animation: scale-fade-out 0.2s both;\n  }\n\n  .reaction__menu-btn,\n  .reaction__submit-btn,\n  .reaction__cancel-btn {\n    --btn-size: 1.25rem;\n    --icon-size: var(--btn-size);\n\n    @media (any-hover: none) {\n      --btn-size: 2rem;\n      --icon-size: 90%;\n    }\n  }\n\n  .reaction__submit-btn {\n    color: oklch(var(--lch-green-dark));\n  }\n\n  .reaction__cancel-btn {\n    color: oklch(var(--lch-red-dark));\n  }\n\n\n  /* Menu\n  /* ------------------------------------------------------------------------ */\n\n  .reaction__menu {\n    position: relative;\n  }\n\n  .reaction__popup {\n    --panel-border-radius: 1em;\n    --panel-padding: var(--block-space-half) var(--inline-space);\n    --offset: calc(-1 * var(--panel-padding));\n\n    inset: var(--offset) auto auto var(--offset);\n    min-inline-size: auto;\n    transform: none;\n  }\n\n  .reaction__emoji-list {\n    display: grid;\n    gap: var(--inline-space-half);\n    grid-template-columns: repeat(10, 1fr);\n\n    @media (max-width: 639px) {\n      grid-template-columns: repeat(6, 1fr);\n    }\n\n    .btn {\n      --btn-size: calc(1.3rem * 1.3);\n\n      font-size: 1.3rem;\n      position: relative;\n\n      /* Make sure the focus ring sits on top of adjacent buttons */\n      &:hover,\n      &:focus-visible {\n        filter: none;\n        z-index: 1;\n      }\n\n      &:hover {\n        scale: 1.3;\n      }\n\n      @media (any-hover: none) {\n        --btn-size: calc(1.6rem * 1.3);\n\n        font-size: 1.6rem;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/reset.css",
    "content": "@layer reset {\n  /*\n  * Modern CSS Reset\n  * @link https://github.com/hankchizljaw/modern-css-reset\n  */\n\n  /* Box sizing rules */\n  *,\n  *::before,\n  *::after {\n    box-sizing: border-box;\n  }\n\n  /* Remove default margin */\n  body,\n  h1,\n  h2,\n  h3,\n  h4,\n  h5,\n  h6 {\n    margin: 0;\n  }\n\n  p,\n  li,\n  h1,\n  h2,\n  h3,\n  h4 {\n    /* Help prevent overflow of long words/names/URLs */\n    word-break: break-word;\n\n    /* Optional, not supported for all languages */\n    /* hyphens: auto; */\n  }\n\n  html,\n  body {\n    overflow-x: clip;\n  }\n\n  html {\n    /* scroll-behavior: smooth; */\n  }\n\n  /* Set core body defaults */\n  body {\n    min-height: 100dvh;\n    font-family: sans-serif;\n    font-size: 100%;\n    line-height: 1.5;\n    text-rendering: optimizeSpeed;\n  }\n\n  /* Make images easier to work with */\n  img {\n    display: block;\n    max-inline-size: 100%;\n  }\n\n  /* Inherit fonts for inputs and buttons */\n  input,\n  button,\n  textarea,\n  select {\n    font: inherit;\n  }\n\n  button {\n    cursor: pointer;\n  }\n\n  summary {\n    &::-webkit-details-marker {\n      display: none;\n    }\n\n    &::marker {\n      content: \"\";\n    }\n  }\n\n  /* Remove all animations and transitions for people that prefer not to see them */\n  @media (prefers-reduced-motion: reduce) {\n    *,\n    *::before,\n    *::after {\n      animation-duration: 0.01ms !important;\n      animation-iteration-count: 1 !important;\n      transition-duration: 0.01ms !important;\n      scroll-behavior: auto !important;\n    }\n\n    html {\n      scroll-behavior: initial;\n    }\n  }\n\n  dialog {\n    border: 0;\n    padding: 0;\n\n    &:where(:focus-visible):focus,\n    &:where(:focus-visible):active {\n      outline: 0;\n    }\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/search.css",
    "content": "summary {\n\n  &::-webkit-details-marker {\n    display: none !important;\n  }\n\n  &::marker {\n    display: none !important;\n  }\n\n  &::marker {\n    content: \"\";\n  }\n}\n\n@layer components {\n\n  .search {\n    --gap: 4vmin;\n    --width: 80ch;\n\n    display: flex;\n    flex-direction: column;\n    gap: var(--gap);\n    margin-inline: auto;\n    max-block-size: 100%;\n    overflow-y: auto;\n    overscroll-behavior: contain;\n    padding: var(--block-space);\n  }\n\n  /* Form\n  /* ------------------------------------------------------------------------ */\n\n  .search__input {\n    --clear-icon-size: 1em;\n\n    max-inline-size: 50ch;\n    position: relative;\n\n    &::-webkit-search-cancel-button {\n      display: none;\n    }\n\n    .bar__input & {\n      --focus-ring-size: 0;\n      --input-border-color: var(--color-ink-light);\n      --input-border-radius: 0;\n      --input-padding: 0.1em;\n\n      border-width: 0 0 1px;\n    }\n  }\n\n  .search__reset {\n    --btn-background: var(--color-terminal-bg);\n    --btn-size: 1.5lh;\n  }\n\n\n  /* Content\n  /* ------------------------------------------------------------------------ */\n\n  .search__list {\n    display: grid;\n    gap: var(--block-space);\n    list-style: none;\n    margin: 0 auto;\n    max-inline-size: min(80ch, 100%);\n    padding: 0;\n    text-align: start;\n  }\n\n  .search__blank-slate {\n    margin-block: 3em;\n    margin-inline: auto;\n    inline-size: fit-content;\n  }\n\n  .search__excerpt {\n    color: var(--color-ink);\n    font-size: var(--text-small);\n  }\n\n  .search__excerpt--comment {\n    --avatar-size: var(--comment-avatar-size);\n    --comment-avatar-size: 32px;\n    --padding: 1ch;\n\n    align-items: center;\n    background-color: var(--color-ink-lightest);\n    border-radius: 1ch;\n    display: flex;\n    gap: 1ch;\n    margin-inline-start: calc(var(--comment-avatar-size) / 2);\n    padding-block: 0.5ch;\n\n    .avatar {\n      margin-inline-start: calc(-0.5 * var(--comment-avatar-size));\n    }\n  }\n\n  .search__result {\n    color: var(--color-link);\n\n    &:not(&:hover) {\n      box-shadow: 0 0 0 1px var(--color-ink-lighter);\n    }\n  }\n\n  .search__title {\n    text-decoration: underline;\n  }\n\n  /* Perma\n  /* ------------------------------------------------------------------------ */\n\n  .search-perma {\n    .search__form > label,\n    .search__form:has(.search__input:placeholder-shown) .search__reset {\n      display: none;\n    }\n\n    .search__input {\n      max-inline-size: min(80ch, 100%);\n    }\n\n    .search {\n      padding-inline: 0;\n    }\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/separators.css",
    "content": "@layer components {\n  .separator {\n    block-size: 100%;\n    border-block: 0;\n    border-inline-end: 0;\n    border-inline-start: var(--border-size, 1px) var(--border-style, solid) var(--border-color, currentColor);\n    display: inline-flex;\n    inline-size: 0;\n  }\n\n  .separator--horizontal {\n    block-size: 0;\n    border-block-end: 0;\n    border-block-start: var(--border-size, 1px) var(--border-style, solid) var(--border-color, currentColor);\n    border-inline: 0;\n    display: flex;\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/settings.css",
    "content": "@layer components {\n  .settings {\n    --settings-spacer: var(--block-space);\n    --settings-item-padding-inline: 0.5ch;\n\n    align-items: start;\n    display: flex;\n    gap: calc(var(--settings-spacer) * 2);\n    flex-direction: column;\n    justify-content: center;\n    margin: 0 auto;\n    max-inline-size: min(100ch, 100%);\n\n    @media (min-width: 960px) {\n      flex-direction: row;\n\n      .settings__panel {\n        max-inline-size: 50%;\n      }\n    }\n  }\n\n  /* Sections & Panels\n  /* -------------------------------------------------------------------------- */\n\n  .settings__panel {\n    --panel-size: 100%;\n    --panel-padding: calc(var(--settings-spacer) / 1);\n\n    display: flex;\n    flex-direction: column;\n    gap: var(--panel-padding);\n    min-block-size: 100%;\n    min-inline-size: 0;\n\n    @media (min-width: 640px) {\n      --panel-padding: calc(var(--settings-spacer) * 2);\n    }\n  }\n\n  .settings__panel--users {\n    @media (min-width: 640px) {\n      max-height: 80dvh;\n    }\n\n    @media (min-width: 960px) {\n      max-height: calc(100dvh - 12rem);\n    }\n  }\n\n  .settings__section {\n    h2 {\n      font-size: var(--text-large);\n    }\n\n    > * + * {\n      margin-block-start: calc(var(--panel-padding) / 2);\n    }\n\n    &:is(:first-child):has(h2) {\n      margin-top: -0.33lh; /* Align h2 letters caps with panel padding */\n    }\n  }\n\n  .settings__section:has(.settings__scrollable-list) {\n    @media (min-width: 640px) {\n      display: flex;\n      flex: 1;\n      flex-direction: column;\n      min-height: 0;\n    }\n  }\n\n  .settings__scrollable-list {\n    flex: 1 1 auto;\n    list-style: none;\n    margin: calc(var(--settings-spacer) / -4) calc(-1 * var(--settings-item-padding-inline));\n    padding: 0;\n    overflow: auto;\n\n    li {\n      border-radius: 0.5em;\n\n      /* Add padding if it's not already on a link within */\n      &:not(:has(a:first-child)) { padding-inline-end: var(--settings-item-padding-inline); }\n      &:not(:has(a:last-child)) { padding-inline-end: var(--settings-item-padding-inline); }\n    }\n\n    a {\n      padding: calc(var(--settings-spacer) / 4) var(--settings-item-padding-inline);\n\n      @media(any-hover: hover) {\n        &:hover {\n          text-decoration: underline;\n        }\n      }\n    }\n\n    /* Only add a BG color when you can actually navigate */\n    .settings__user-filter:focus-within & {\n      [aria-selected] {\n        background: var(--color-selected);\n      }\n    }\n  }\n\n  /* Users\n  /* ------------------------------------------------------------------------ */\n\n  .settings__user-filter {\n    --btn-size: 3.5ch;\n    --avatar-size: var(--btn-size);\n\n    display: flex;\n    flex-direction: column;\n    gap: calc(var(--settings-spacer) / 2);\n    min-height: 0;\n  }\n\n  .settings__user-filter--bg {\n    background-color: var(--color-ink-lightest);\n    border-radius: 0.5em;\n    margin-block: 0;\n    padding: var(--settings-spacer);\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/spinners.css",
    "content": "@layer components {\n  .spinner {\n    position: relative;\n\n    &::before {\n      --mask: no-repeat radial-gradient(#000 68%, #0000 71%);\n      --dot-size: 1.25em;\n\n      -webkit-mask: var(--mask), var(--mask), var(--mask);\n      -webkit-mask-size: 28% 45%;\n      animation: submitting 1.3s infinite linear;\n      aspect-ratio: 8/5;\n      background: currentColor;\n      content: \"\";\n      inline-size: var(--dot-size);\n      inset: 50% 0.25em;\n      margin-block: calc((var(--dot-size) / 3) * -1);\n      margin-inline: calc((var(--dot-size) / 2) * -1);\n      position: absolute;\n    }\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/steps.css",
    "content": "@layer components {\n  .step {\n    display: grid;\n    grid-template-columns: 1em auto auto;\n    gap: calc(var(--inline-space) * 2/3);\n    inline-size: auto;\n  }\n\n  .step__checkbox {\n    --hover-color: var(--card-color);\n\n    appearance: none;\n    background-color: var(--color-canvas);\n    block-size: 1.1em;\n    border: 1px solid currentColor;\n    border-radius: 0.15em;\n    color: currentColor;\n    display: grid;\n    font: inherit;\n    inline-size: 1.1em;\n    margin: 0;\n    place-content: center;\n    transform: translateY(0.1em);\n\n    &::before {\n      background-color: CanvasText;\n      block-size: 0.65em;\n      box-shadow: inset 1em 1em currentColor;\n      clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);\n      content: \"\";\n      inline-size: 0.65em;\n      transform: scale(0);\n      transform-origin: center;\n      transition: 150ms transform ease-in-out;\n    }\n\n    &:checked::before {\n      transform: scale(1) rotate(10deg);\n    }\n\n    &:where([disabled]):not(:hover):not(:active) {\n      filter: none;\n      opacity: 0.5;\n    }\n  }\n\n  .step__content {\n    --input-border-radius: 0;\n    --input-border-size: 0;\n    --input-padding: 0;\n\n    border-bottom: 1px solid transparent;\n    color: currentColor;\n    font-weight: 500;\n    margin-block-end: calc(var(--block-space) * 1/3);\n\n    &:is(a, input[type=text]) {\n      --hover-size: 0;\n    }\n\n    .step:has(:checked) & {\n      opacity: 0.7;\n      text-decoration: line-through;\n    }\n\n    &::placeholder {\n      color: var(--card-color);\n    }\n\n    &:is(input) {\n      max-inline-size: 70ch;\n      min-inline-size: 30ch;\n\n      @supports (field-sizing: content) {\n        field-sizing: content;\n        max-inline-size: 100%;\n        min-inline-size: 15ch;\n      }\n    }\n  }\n\n  .step__content--edit {\n    border-bottom-color: currentColor;\n  }\n\n  .steps {\n    contain: inline-size;\n    display: grid;\n    list-style: none;\n    margin: 0;\n    max-inline-size: 100%;\n    padding: 0;\n  }\n\n  .steps__icon {\n    --icon-size: 0.875em;\n\n    background-color: var(--card-color);\n    block-size: 1.3em;\n    border-radius: 50%;\n    color: var(--color-ink-inverted);\n    display: grid;\n    inline-size: 1.3em;\n    place-content: center;\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/syntax.css",
    "content": "@layer components {\n  .highlight {\n    /* Named color values */\n    --keyword: lch(50.16 68.78 25.97);\n    --entity: lch(39.03 73.26 304.21);\n    --constant: lch(39.68 63.13 279.47);\n    --string: lch(19.22 34.92 275.47);\n    --variable: lch(57.9 81.69 53.33);\n    --comment: lch(47.93 7 254.8);\n    --entity-tag: lch(39.64 68.17 142.85);\n    --markup-heading: lch(39.68 63.13 279.47);\n    --markup-list: lch(40.44 43.36 84.69);\n    --markup-inserted: lch(39.64 68.17 142.85);\n    --markup-deleted: lch(39.64 68.17 31.45);\n\n    /* Redefine named color values for dark mode */\n    html[data-theme=\"dark\"] {\n      --keyword: lch(67.63 58.99 30.64);\n      --entity: lch(75.13 46.73 306.74);\n      --constant: lch(74.9 39.71 255.53);\n      --string: lch(74.9 39.71 255.53);\n      --variable: lch(76.17 61.1 61.97);\n      --comment: lch(60.83 6.66 254.46);\n      --entity-tag: lch(83.65 59.31 141.61);\n      --markup-heading: lch(47.93 71.67 280.72);\n      --markup-list: lch(83.84 57.9 85.03);\n      --markup-inserted: lch(83.65 59.31 141.61);\n      --markup-deleted: lch(73.8% 65 29.18);\n    }\n\n    @media (prefers-color-scheme: dark) {\n      html:not([data-theme]) {\n        --keyword: lch(67.63 58.99 30.64);\n        --entity: lch(75.13 46.73 306.74);\n        --constant: lch(74.9 39.71 255.53);\n        --string: lch(74.9 39.71 255.53);\n        --variable: lch(76.17 61.1 61.97);\n        --comment: lch(60.83 6.66 254.46);\n        --entity-tag: lch(83.65 59.31 141.61);\n        --markup-heading: lch(47.93 71.67 280.72);\n        --markup-list: lch(83.84 57.9 85.03);\n        --markup-inserted: lch(83.65 59.31 141.61);\n        --markup-deleted: lch(73.8% 65 29.18);\n      }\n    }\n\n    color: var(--color-ink);\n\n    .w {\n      color: var(--color-ink);\n    }\n\n    .k, .kd, .kn, .kp, .kr, .kt, .kv {\n      color: var(--keyword);\n    }\n\n    .gr {\n      color: var(--color-ink-lightest);\n    }\n\n    .gd {\n      color: var(--markup-deleted);\n      background-color: light-dark(lch(39.64 68.17 31.45 / 0.15), lch(39.64 68.17 31.45 / 0.2));\n    }\n\n    .nb, .nc, .no, .nn {\n      color: var(--variable);\n    }\n\n    .sr, .na, .nt {\n      color: var(--entity-tag);\n    }\n\n    .gi {\n      color: var(--markup-inserted);\n      background-color: light-dark(lch(49.14 52.75 142.85 / 0.15), lch(83.65 59.31 141.61 / 0.15));\n    }\n\n    .kc, .l, .ld, .m, .mb, .mf, .mh, .mi, .il, .mo, .mx, .sb, .bp, .ne, .nl, .py, .nv, .vc, .vg, .vi, .vm, .o, .ow {\n      color: var(--constant);\n    }\n\n    .gh {\n      color: var(--constant);\n      font-weight: bold;\n    }\n\n    .gu {\n      color: var(--constant);\n      font-weight: bold;\n    }\n\n    .s, .sa, .sc, .dl, .sd, .s2, .se, .sh, .sx, .s1, .ss {\n      color: var(--string);\n    }\n\n    .nd, .nf, .fm {\n      color: var(--entity);\n    }\n\n    .err {\n      color: var(--color-ink-inverted);\n      background-color: var(--markup-deleted);\n    }\n\n    .c, .ch, .cd, .cm, .cp, .cpf, .c1, .cs, .gl, .gt {\n      color: var(--comment);\n    }\n\n    .ni, .si {\n      color: var(--storage-modifier-import);\n    }\n\n    .ge {\n      color: var(--storage-modifier-import);\n      font-style: italic;\n    }\n\n    .gs {\n      color: var(--storage-modifier-import);\n      font-weight: bold;\n    }\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/theme-switcher.css",
    "content": "@layer components {\n  .theme-switcher {\n    @media (max-width: 479px) {\n      --row-gap: 1ch;\n\n      flex-direction: column;\n    }\n  }\n\n  .theme-switcher__btn {\n    --btn-background: var(--color-ink-lightest);\n    --btn-border-radius: 0.4em;\n    --btn-border-size: 0;\n    --btn-gap: 0.1lh;\n    --btn-padding: 1em;\n    --icon-size: 2em;\n\n    column-gap: var(--inline-space);\n    flex: 1;\n    flex-direction: column;\n    position: relative;\n    white-space: nowrap;\n\n    &:has(input:checked) {\n      --btn-background: var(--color-selected);\n      --btn-color: var(--color-ink);\n    }\n\n    @media (max-width: 479px) {\n      flex-direction: row;\n    }\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/toggles.css",
    "content": "@layer components {\n  .toggler--toggled {\n    .toggler__visible-when-off {\n      display: none;\n    }\n\n    .toggler__visible-when-on {\n      display: unset;\n    }\n  }\n\n  .toggler__visible-when-on {\n    display: none;\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/tooltips.css",
    "content": "@layer components {\n  [data-controller~=\"tooltip\"] {\n    --tooltip-delay: 750ms;\n    --tooltip-duration: 150ms;\n\n    .for-screen-reader {\n      background: var(--color-ink);\n      border-radius: 0.5ch;\n      color: var(--color-canvas);\n      font-size: var(--text-x-small);\n      font-weight: normal;\n      inset: -1ch auto auto 50%;\n      max-inline-size: min(50ch, calc(100vw - (var(--inline-space) * 2)));\n      opacity: 0;\n      padding: 0.25ch 1ch;\n      transition: var(--tooltip-duration) ease-out allow-discrete;\n      transition-property: opacity;\n      translate: -50% -100%;\n      text-overflow: ellipsis;\n\n      &.orient-right {\n        inset-inline: 0 auto;\n        translate: var(--orient-offset, 0px) -100%;\n      }\n\n      &.orient-left {\n        inset-inline: auto 0;\n        translate: var(--orient-offset, 0px) -100%;\n      }\n    }\n\n    @media(any-hover: hover) {\n      &:hover .for-screen-reader {\n        block-size: auto !important;\n        clip-path: none !important;\n        inline-size: auto !important;\n        opacity: 1;\n        transform: translate3d(0, 0, 0); /* Fixes Safari overflow rendering bug */\n        transition-delay: var(--tooltip-delay);\n        translate: -50% -100%;\n        z-index: var(--z-tooltip);\n\n        &.orient-left,\n        &.orient-right {\n          translate: 0 -100%;\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/trays.css",
    "content": "@layer components {\n  /* Container\n  /* ------------------------------------------------------------------------ */\n\n  .tray {\n    --tray-duration: 350ms;\n    --tray-margin: 0.5rem;\n    --tray-radius: 0.25rem;\n    --tray-item-height: 76px; /* FIXME: Magic number */\n\n    align-items: end;\n    block-size: var(--footer-height);\n    display: grid;\n    inset-block: auto env(safe-area-inset-bottom);\n    inline-size: var(--tray-size);\n    position: fixed;\n    transition: inset var(--tray-duration) var(--ease-out-overshoot-subtle);\n    z-index: var(--z-tray);\n\n    /* Make the dialog, expander, and actions inhabit the same space */\n    > * {\n      grid-column-start: 1;\n      grid-row-start: 1;\n    }\n\n    @media (max-width: 799px) {\n      &:has(.tray__dialog[open]) {\n        background-color: var(--color-terminal-bg);\n        inline-size: calc(100% - var(--tray-margin) * 2);\n        inset-inline-start: var(--tray-margin);\n        z-index: calc(var(--z-tray) + 2);\n      }\n    }\n  }\n\n  /* Dialog\n  /* ------------------------------------------------------------------------ */\n\n  .tray__dialog {\n    background-color: transparent;\n    display: flex;\n    flex-direction: column-reverse;\n    inline-size: auto;\n    inset: auto 0 0 0;\n    position: absolute;\n    transition: var(--tray-duration) var(--ease-out-overshoot-subtle);\n    transition-property: background-color, box-shadow, inset-block-end;\n\n    &[open] {\n      border-radius: var(--tray-radius);\n      box-shadow:\n        0 0 0 1px var(--color-ink-lighter),\n        0 0 16px oklch(var(--lch-black) / 33%);\n      inset-block-end: calc(var(--footer-height) - 0.75ch);\n    }\n\n    &:not([open]) {\n      block-size: var(--footer-height);\n      pointer-events: none;\n\n      /* On desktop, when there aren't items, tweak the hat so it doesn't look\n         like it's coming from the bottom of the viewport */\n      @media (min-width: 800px) {\n        &:not(:has(.tray__item--notification)) {\n          .tray__item--hat {\n            margin-block-end: 0;\n            opacity: 0;\n          }\n        }\n      }\n    }\n  }\n\n  /* Expander\n  /* ------------------------------------------------------------------------ */\n\n  .tray__toggle {\n    align-self: stretch;\n    background: none;\n    border: 0;\n    display: block;\n    padding: 0;\n    transition: opacity 100ms ease-out;\n\n    .icon {\n      color: var(--color-ink);\n      display: none;\n    }\n\n    .tray__toggle-text {\n      display: contents;\n    }\n\n    @media (max-width: 799px) {\n      /* When collapsed on mobile, make it small */\n      .tray__dialog:not([open]) ~ & {\n        inline-size: var(--footer-height);\n\n        .tray__toggle-btn {\n          border: 0;\n        }\n\n        .icon {\n          display: block;\n        }\n\n        .tray__toggle-text {\n          display: none;\n        }\n      }\n\n      /* Show a red dot if there are items to show */\n      .tray__dialog:not([open]):has(.tray__item--notification) ~ &:after {\n        background: oklch(var(--lch-red-medium));\n        block-size: 1ch;\n        border-radius: 50%;\n        content: \"\";\n        inline-size: 1ch;\n        inset: 25% 25% auto auto;\n        position: absolute;\n      }\n    }\n\n    /* On desktop… */\n    @media (min-width: 800px) {\n      .tray__dialog:not([open]):has(.tray__item:not(.tray__item--hat)) ~ & {\n        block-size: var(--tray-item-height);\n        translate: 0 -1.85rem;\n      }\n\n      /* Hide the UI when collapsed, but only if there are items */\n      .tray__dialog:not([open]):has(.tray__item--notification) ~ & {\n        opacity: 0;\n      }\n    }\n  }\n\n\n  .tray__toggle-btn {\n    --btn-background: transparent;\n    --btn-border-size: 0;\n    --btn-color: var(--color-ink);\n\n    inline-size: 100%;\n    opacity: 0.66;\n  }\n\n  /* Item\n  /* ------------------------------------------------------------------------ */\n\n  .tray__item {\n    --tray-item-delay: calc((var(--tray-item-index) - 1) * 20ms);\n    --tray-item-index: 1;\n    --tray-item-margin: calc(-1 * var(--tray-item-height) + var(--tray-item-offset));\n    --tray-item-offset: var(--block-space-half); /* The amount they overlap */\n    --tray-item-scale: calc(1 - (var(--tray-item-index) - 1) / 30);\n    --tray-item-z: calc(6 - var(--tray-item-index));\n\n    font-size: 10px;\n    margin-block-end: var(--tray-item-margin);\n    position: relative;\n\n    .tray__dialog & {\n      transition: var(--tray-duration) var(--ease-out-overshoot-subtle);\n      transition-delay: var(--tray-item-delay);\n      transition-property: margin, opacity, scale;\n\n\n      &:not(.tray__item--hat) {\n        z-index: var(--tray-item-z);\n      }\n\n      &:has(*:focus-visible) {\n        z-index: calc(var(--tray-item-z) + 1);\n      }\n\n      &:first-child {\n        --tray-item-margin: var(--tray-margin);\n      }\n\n      &:not(:first-child) {\n        scale: var(--tray-item-scale);\n      }\n\n      &:nth-child(1) { --tray-item-index: 1; }\n      &:nth-child(2) { --tray-item-index: 2; }\n      &:nth-child(3) { --tray-item-index: 3; }\n      &:nth-child(4) { --tray-item-index: 4; }\n      &:nth-child(5) { --tray-item-index: 5; }\n      &:nth-child(6) { --tray-item-index: 6; }\n      &:nth-child(7) { --tray-item-index: 7; }\n      &:nth-child(8) { --tray-item-index: 8; }\n      &:nth-child(9) { --tray-item-index: 9; }\n      &:nth-child(10) { --tray-item-index: 10; }\n    }\n\n    .tray__dialog[open] & {\n      --tray-item-margin: 0;\n      --tray-item-scale: 1;\n    }\n\n    .tray__dialog:not([open]) & {\n      @media (max-width: 799px) {\n        opacity: 0;\n      }\n    }\n\n    .bubble {\n      display: none;\n    }\n  }\n\n  .tray__item--hat {\n    --tray-hat-bg: var(--color-canvas);\n    --tray-item-scale: 1;\n\n    background-color: var(--tray-hat-bg);\n    border-block-end: 1px solid var(--color-ink-lighter);\n    border-radius: var(--tray-radius) var(--tray-radius) 0 0;\n    block-size: var(--tray-item-height);\n    display: flex;\n    opacity: 0;\n    padding: 0.5ch;\n\n    .btn {\n      --btn-background: var(--tray-hat-bg);\n      --btn-border-radius: 0.5ch;\n      --btn-padding: 1.25ch 0.5ch 1ch;\n\n      block-size: 100%;\n      display: flex;\n      flex-direction: column;\n      font-weight: normal;\n      gap: 0.25ch;\n      inline-size: 100%;\n\n      &:focus-visible {\n        z-index: 1;\n      }\n\n      @media (max-width: 1060px) {\n        > span:not(.icon) {\n          font-size: 12px;\n        }\n      }\n\n      .tray__dialog:not([open]) & {\n        pointer-events: none;\n      }\n    }\n\n    > *:is(:first-child, :last-child) {\n      inline-size: 128px;\n    }\n\n    .tray__dialog[open] & {\n      opacity: 1;\n    }\n\n    .tray__new-notifications {\n      display: none;\n      position: relative;\n    }\n\n    .tray__dialog:has(.tray__item:nth-child(1n + 7)) & {\n      .tray__old-notifications { display: none; }\n      .tray__new-notifications { display: flex; }\n\n      .btn:has(.tray__new-notifications) { /* Red dot */\n        &:after {\n          background-color: oklch(var(--lch-red-medium));\n          block-size: 1ch;\n          border-radius: 50%;\n          box-shadow: 0 0 0 1px var(--tray-hat-bg);\n          content: \"\";\n          inline-size: 1ch;\n          inset: 25% auto auto 50%;\n          position: absolute;\n          translate: 25% -75%;\n        }\n      }\n    }\n  }\n\n  /* Tray cards\n  /* ------------------------------------------------------------------------ */\n\n  .tray__item {\n    .card {\n      --card-padding-block: 1.5ch;\n      --card-padding-inline: 1.5ch;\n      --text-xx-large: 2em;\n\n      block-size: var(--tray-item-height);\n      view-transition-name: unset !important;\n\n      [open] & {\n        box-shadow: 0 0 0 1px var(--color-ink-lighter);\n        border: 0;\n        border-radius: 0;\n      }\n\n      html[data-theme=\"dark\"] & {\n        box-shadow: 0 0 0 1px var(--color-ink-lighter);\n      }\n\n      @media (prefers-color-scheme: dark) {\n        html:not([data-theme]) & {\n          box-shadow: 0 0 0 1px var(--color-ink-lighter);\n        }\n      }\n\n    }\n\n    .card__background {\n      display: none;\n    }\n\n    .card__body {\n      margin-block-start: 0.2em;\n      padding-block-end: 0;\n    }\n\n    .card__board {\n      padding-block: 0.25ch;\n    }\n\n    .card__title {\n      --lines: 1;\n\n      font-size: var(--text-small);\n      font-weight: bold;\n      min-block-size: 0;\n    }\n  }\n\n  /* Pin-specific styles\n  /* ------------------------------------------------------------------------ */\n\n  .tray--pins {\n    inset-inline: var(--tray-margin) auto;\n    view-transition-name: tray-pins;\n\n    #footer:has(.bar__placeholder[hidden]) & {\n      inset-inline-start: -100%;\n    }\n\n    /* Disable the expander if there aren't items to show */\n    .tray__dialog:not(:has(.tray__item)) ~ .tray__toggle {\n      opacity: 0.5;\n\n      &, .tray__toggle-btn {\n        pointer-events: none;\n      }\n    }\n\n    /* Add a border on mobile */\n    @media (max-width: 799px) {\n      .tray__dialog:not([open]) ~ .tray__toggle {\n        border-inline-end: 1px dashed var(--color-ink-light);\n        translate: calc(-1 * var(--tray-margin)) 0;\n      }\n    }\n  }\n\n  .tray__item--pin {\n    --tray-item-z: calc(10 - var(--tray-item-index));\n\n    position: relative;\n\n    [open] &[aria-selected] {\n      outline: 0;\n      z-index: calc(var(--tray-item-z) + 2);\n\n      .card__link {\n        border-radius: 0.25ch;\n        outline: var(--focus-ring-size) solid var(--focus-ring-color);\n        outline-offset: var(--focus-ring-offset);\n        z-index: 1;\n      }\n    }\n\n    /* Show 6 max items on smallest devices */\n    @media (max-height: 578px) {\n      &:nth-child(1n + 7) { display: none; }\n    }\n\n    /* 7 max */\n    @media (min-height: 578px) and (max-height: 656px) {\n      &:nth-child(1n + 8) { display: none; }\n    }\n\n    /* 8 max */\n    @media (min-height: 656px) and (max-height: 734px) {\n      &:nth-child(1n + 9) { display: none; }\n    }\n\n    /* 9 max */\n    @media (min-height: 734px) and (max-height: 812px) {\n      &:nth-child(1n + 10) { display: none; }\n    }\n\n    /* 10 max on larger screens */\n    @media (min-height: 812px) {\n      &:nth-child(1n + 11) { display: none; }\n    }\n\n    &:not([aria-selected]) .card__link:focus-visible,\n    .tray__dialog:not([open]) & .card__link:focus-visible {\n      --focus-ring-size: 0;\n    }\n\n    .tray__remove-pin-btn {\n      --btn-icon-size: 1.25em;\n      --btn-size: 2em;\n\n      background-color: var(--card-bg-color);\n      inset: 0 0 auto auto;\n      opacity: 0.66;\n      position: absolute;\n      z-index: 1;\n\n      &:hover {\n        opacity: 1;\n      }\n\n      .tray__dialog:not([open]) & {\n        opacity: 0;\n        pointer-events: none;\n      }\n\n      [aria-busy] & {\n        position: absolute !important;\n      }\n    }\n\n    .avatar,\n    .card__tags,\n    .card__meta .btn,\n    .card__meta-text:not(.card__meta-text--updated),\n    .card__stages,\n    .card__steps,\n    .card__boosts,\n    .card__comments,\n    .card__closed {\n      display: none;\n    }\n\n    .card__header {\n      margin-block-start: calc(var(--card-padding-block) * -1.1);\n      margin-inline: calc(-1 * var(--card-padding-inline));\n      max-inline-size: unset;\n    }\n\n    .card__body {\n      display: block;\n      margin-block-start: 0.3em;\n    }\n\n    .card__column-name--current {\n      --btn-padding: 0.1em 0.5em;\n\n      background: none !important;\n      border: 1px solid currentColor;\n      color: var(--color-ink);\n      display: inline-flex;\n      flex: 0 1 auto;\n      inline-size: fit-content;\n      margin: 0 0 0 auto;\n      transition: translate 150ms ease-out;\n      translate: -2em;\n\n      .tray__dialog:not([open]) & {\n        translate: 0;\n      }\n    }\n\n    .card__link {\n      z-index: 1;\n    }\n\n    .card__footer {\n      margin-block: -0.2em 2em;\n\n      .icon { display: none; }\n    }\n\n    .card__meta {\n      grid-template-areas: \"text-updated\";\n      grid-template-columns: 1fr;\n    }\n\n    .card__meta-text {\n      line-height: 1.5;\n    }\n\n    .card__meta-text--updated {\n      border: 0;\n      font-size: var(--text-x-small);\n      opacity: 0.66;\n      padding: 0;\n      text-transform: none;\n\n      .local-time-value {\n        font-weight: inherit;\n      }\n    }\n\n    .card__bubble {\n      display: none;\n    }\n  }\n\n  ::view-transition-group(tray-pins) {\n    z-index: 100;\n  }\n\n  /* Notification-specific styles\n  /* ------------------------------------------------------------------------ */\n\n  .tray--notifications {\n    inset-inline: auto var(--tray-margin);\n    view-transition-name: tray-notifications;\n\n    #footer:has(.bar__placeholder[hidden]) & {\n      inset-inline-end: -100%;\n    }\n\n    .tray__item,\n    [data-navigable-list-target~=item] {\n      [open] &[aria-selected] {\n        outline: 0;\n        z-index: calc(var(--tray-item-z) + 2);\n\n        .card,\n        .tray__item--hat & .btn {\n          border-radius: 0.25ch;\n          outline: var(--focus-ring-size) solid var(--focus-ring-color);\n          outline-offset: var(--focus-ring-offset);\n        }\n      }\n\n      &:nth-child(1n + 7) {\n        display: none;\n        pointer-events: none;\n        visibility: hidden;\n      }\n\n      &:not([aria-selected]) .card:focus-visible,\n      .tray__dialog:not([open]) & .card:focus-visible {\n        --focus-ring-size: 0;\n      }\n\n      .btn:focus-visible {\n        outline: 0;\n      }\n    }\n\n    &:not(:has(.card--notification)) {\n      .tray__notification-read-action {\n        visibility: hidden;\n      }\n    }\n\n    /* On mobile… */\n    @media (max-width: 799px) {\n      /* …add a border */\n      .tray__dialog:not([open]) ~ .tray__toggle {\n        border-inline-start: 1px dashed var(--color-ink-light);\n        translate: var(--tray-margin) 0;\n      }\n    }\n  }\n\n  ::view-transition-group(tray-notifications) {\n    z-index: 100;\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/user.css",
    "content": "@layer components {\n  .user-edit-link {\n    inset: 0 0 auto auto;\n    position: absolute;\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/utilities.css",
    "content": "@layer utilities {\n  /* Text */\n  .txt-xx-small { font-size: var(--text-xx-small); }\n  .txt-x-small { font-size: var(--text-x-small); }\n  .txt-small { font-size: var(--text-small); }\n  .txt-normal { font-size: var(--text-normal); }\n  .txt-medium { font-size: var(--text-medium); }\n  .txt-large { font-size: var(--text-large); }\n  .txt-x-large { font-size: var(--text-x-large); }\n  .txt-xx-large { font-size: var(--text-xx-large); }\n\n  .txt-align-center { text-align: center; }\n  .txt-align-start { text-align: start; }\n  .txt-align-end { text-align: end; }\n\n  .txt-current { color: currentColor; }\n  .txt-ink { color: var(--color-ink); }\n  .txt-reversed { color: var(--color-ink-inverted); }\n  .txt-negative { color: var(--color-negative); }\n  .txt-positive { color: var(--color-positive); }\n  .txt-subtle { color: var(--color-ink-dark); }\n  .txt-alert { color: var(--color-marker); }\n  .txt-undecorated { text-decoration: none; }\n  .txt-underline { text-decoration: underline; }\n  .txt-tight-lines { line-height: 1.2; }\n  .txt-nowrap { white-space: nowrap; }\n  .txt-balance { text-wrap: balance; }\n  .txt-break { word-break: break-word; }\n  .txt-uppercase { text-transform: uppercase; }\n  .txt-capitalize { text-transform: capitalize; }\n  .txt-capitalize-first-letter::first-letter { text-transform: capitalize; }\n  .txt-link { color: var(--color-link); text-decoration: underline; }\n\n  .font-weight-normal { font-weight: 400; }\n  .font-weight-medium { font-weight: 500; }\n  .font-weight-semibold { font-weight: 600; }\n  .font-weight-bold { font-weight: 700; }\n  .font-weight-black { font-weight: 900; }\n\n  /* Flexbox and Grid */\n  .justify-end { justify-content: end; }\n  .justify-start { justify-content: start; }\n  .justify-center { justify-content: center; }\n  .justify-space-between { justify-content: space-between; }\n\n  .align-center { align-items: center; }\n  .align-start { align-items: start; }\n  .align-end { align-items: end; }\n\n  .align-self-center { align-self: center; }\n  .align-self-end { align-self: end; }\n  .align-self-start { align-self: start; }\n\n  .v-align-middle { vertical-align: middle; }\n\n  .contain { contain: inline-size; }\n\n  .display-inline { display: inline; }\n\n  .flex { display: flex; }\n  .flex-inline { display: inline-flex; }\n  .flex-column { flex-direction: column; }\n  .flex-wrap { flex-wrap: wrap; }\n\n  .flex-1 { flex: 1; }\n  .flex-item-grow { flex-grow: 1; }\n  .flex-item-shrink { flex-shrink: 1; }\n  .flex-item-no-shrink { flex-shrink: 0; }\n  .flex-item-justify-start { margin-inline-end: auto; }\n  .flex-item-justify-end { margin-inline-start: auto; }\n\n  .gap {\n    column-gap: var(--column-gap, var(--inline-space));\n    row-gap: var(--row-gap, var(--block-space));\n  }\n\n  .gap-half {\n    column-gap: var(--column-gap, var(--inline-space-half));\n    row-gap: var(--row-gap, var(--block-space-half));\n  }\n\n  .gap-none {\n    --column-gap: 0;\n    --row-gap: 0;\n\n    gap: 0;\n  }\n\n  /* Sizing */\n  .full-width { inline-size: 100%; }\n  .min-width { min-inline-size: 0; }\n  .half-width { inline-size: 50%; }\n  .max-width { max-inline-size: 100%; }\n  .min-content { inline-size: min-content; }\n  .fit-content { inline-size: fit-content; }\n  .max-inline-size { max-inline-size: 100%; }\n\n  /* Overflow */\n  .overflow-x { overflow-x: auto; scroll-snap-type: x mandatory; scroll-behavior: smooth; }\n  .overflow-y { overflow-y: auto; scroll-snap-type: y mandatory; scroll-behavior: smooth; }\n  .overflow-clip { text-overflow: clip; white-space: nowrap; overflow: hidden; }\n  .overflow-ellipsis { text-overflow: ellipsis; white-space: nowrap; overflow: hidden; }\n\n  .overflow-line-clamp {\n    -webkit-line-clamp: var(--lines, 2);\n    -webkit-box-orient: vertical;\n    display: -webkit-box;\n    overflow: hidden;\n    text-overflow: clip;\n    white-space: normal;\n  }\n\n  /* Mouse pointer */\n  .non-clickable {\n    cursor: default;\n    pointer-events: none;\n  }\n\n  .cursor-pointer { cursor: pointer; }\n\n  /* Padding */\n  .pad { padding: var(--block-space) var(--inline-space); }\n  .pad-double { padding: var(--block-space-double) var(--inline-space-double); }\n\n  .pad-block { padding-block: var(--block-space); }\n  .pad-block-start { padding-block-start: var(--block-space); }\n  .pad-block-end { padding-block-end: var(--block-space); }\n  .pad-block-half { padding-block: var(--block-space-half); }\n  .pad-block-start-half { padding-block-start: var(--block-space-half); }\n\n  .pad-inline { padding-inline: var(--inline-space); }\n  .pad-inline-start { padding-inline-start: var(--inline-space); }\n  .pad-inline-end { padding-inline-end: var(--inline-space); }\n  .pad-inline-half { padding-inline: var(--inline-space-half); }\n  .pad-inline-double { padding-inline: var(--inline-space-double); }\n\n  .unpad { padding: 0; }\n  .unpad-block-end { padding-block-end: 0; }\n  .unpad-inline { padding-inline: 0; }\n\n  /* Margins */\n  .margin { margin: var(--block-space) var(--inline-space); }\n  .margin-block { margin-block: var(--block-space); }\n  .margin-block-half { margin-block: var(--block-space-half); }\n  .margin-block-start { margin-block-start: var(--block-space); }\n  .margin-block-start-half { margin-block-start: var(--block-space-half); }\n  .margin-block-start-auto { margin-block-start: auto; }\n  .margin-block-end { margin-block-end: var(--block-space); }\n  .margin-block-end-half { margin-block-end: var(--block-space-half); }\n  .margin-block-double { margin-block: var(--block-space-double); }\n  .margin-block-end-double { margin-block-end: var(--block-space-double); }\n  .margin-block-start-double { margin-block-start: var(--block-space-double); }\n\n  .margin-inline { margin-inline: var(--inline-space); }\n  .margin-inline-start { margin-inline-start: var(--inline-space); }\n  .margin-inline-start-half { margin-inline-start: var(--inline-space-half); }\n  .margin-inline-end { margin-inline-end: var(--inline-space); }\n  .margin-inline-end-half { margin-inline-end: var(--inline-space-half); }\n  .margin-inline-half { margin-inline: var(--inline-space-half); }\n  .margin-inline-double { margin-inline: var(--inline-space-double); }\n\n  .margin-none { margin: 0; }\n  .margin-none-block { margin-block: 0; }\n  .margin-none-block-start { margin-block-start: 0; }\n  .margin-none-block-end { margin-block-end: 0; }\n\n  .margin-none-inline { margin-inline: 0; }\n  .margin-none-inline-start { margin-inline-start: 0; }\n  .margin-none-inline-end { margin-inline-end: 0; }\n\n  .center { margin-inline: auto; }\n  .center-block { margin-block: auto; }\n\n  /* Position */\n  .position-relative { position: relative; }\n  .position-sticky { position: sticky; inset: var(--inset, 0 auto auto auto); z-index: var(--z, 1); }\n\n  /* Fills */\n  .fill { background-color: var(--color-canvas); }\n  .fill-black { background-color: var(--color-ink); }\n  .fill-white { background-color: var(--color-ink-inverted); }\n  .fill-shade { background-color: var(--color-ink-lightest); }\n  .fill-selected { background-color: var(--color-selected); }\n  .fill-highlight { background-color: var(--color-highlight); }\n  .fill-transparent { background-color: transparent; }\n\n  .fill-highlighter {\n    display: inline-block;\n    position: relative;\n    z-index: 1;\n\n    &::before {\n      background-color: var(--color-highlight);\n      border-radius: 0.2em;\n      content: \"\";\n      inset-block: 0;\n      inset-inline: -0.1em;\n      position: absolute;\n      transform: skewX(-10deg) rotate(1deg);\n      z-index: -1;\n    }\n  }\n\n  .translucent { opacity: var(--opacity, 0.66); }\n\n  /* Borders */\n  .border { border: var(--border-size, 1px) var(--border-style, solid) var(--border-color, var(--color-ink-lighter)); }\n  .border-block { border-block: var(--border-size, 1px) var(--border-style, solid) var(--border-color, var(--color-ink-lighter)); }\n  .border-bottom { border-block-end: var(--border-size, 1px) var(--border-style, solid) var(--border-color, var(--color-ink-lighter)); }\n  .border-top { border-block-start: var(--border-size, 1px) var(--border-style, solid) var(--border-color, var(--color-ink-lighter)); }\n  .borderless { border: 0; }\n\n  /* Border radius */\n  .border-radius { border-radius: var(--border-radius, 0.5em); }\n\n  /* Shadows */\n  .shadow { box-shadow: var(--shadow); }\n\n  /* Lists */\n  :where(.list-style-none) {\n    list-style: none;\n    margin: 0 auto;\n    padding: 0;\n  }\n\n  /* Accessibility */\n  .visually-hidden,\n  .for-screen-reader {\n    block-size: 1px;\n    clip-path: inset(50%);\n    inline-size: 1px;\n    overflow: hidden;\n    position: absolute;\n    white-space: nowrap;\n  }\n\n  /* Visibility */\n  [hidden] { display: none !important; }\n  .display-contents,\n  [contents] { display: contents; }\n\n  .hide-in-pwa {\n    @media (display-mode: standalone) {\n      display: none;\n    }\n  }\n\n  .hide-in-browser {\n    @media (display-mode: browser) {\n      display: none;\n    }\n  }\n\n  .hide-focus-ring {\n    --focus-ring-size: 0;\n  }\n\n  .hide-on-touch {\n    @media (any-hover: none) {\n      display: none;\n    }\n  }\n\n  .show-on-touch {\n    display: none;\n\n    @media (any-hover: none) {\n      display: unset;\n    }\n  }\n\n  .show-on-native {\n    body:not([data-platform~=native]) & {\n      display: none;\n    }\n  }\n\n  .hide-scrollbar {\n    -ms-overflow-style: none;  /* Edge */\n    scrollbar-width: none; /* FF */\n\n    /* Chrome/Safari/Opera */\n    &::-webkit-scrollbar {\n      display: none;\n    }\n  }\n\n  .hide-on-dark-mode {\n    html[data-theme=\"dark\"] & {\n      display: none;\n    }\n\n    html:not([data-theme]) & {\n      @media (prefers-color-scheme: dark) {\n        display: none;\n      }\n    }\n  }\n\n  .hide-on-light-mode {\n    html[data-theme=\"light\"] & {\n      display: none;\n    }\n\n    html:not([data-theme]) & {\n      @media (prefers-color-scheme: light) {\n        display: none;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/welcome-letter.css",
    "content": "@layer components {\n  .welcome-letter {\n    position: relative;\n    view-transition-name: welcome-letter;\n    z-index: var(--z-welcome);\n\n    h2, p {\n      text-wrap: pretty;\n    }\n  }\n\n  .welcome-letter__close {\n    inset: var(--block-space) var(--block-space) auto auto;\n    position: absolute;\n  }\n\n  .welcome-letter__signature {\n    background-color: currentColor;\n    block-size: 3em;\n    display: inline-block;\n    inline-size: 8em;\n    mask-image: url(\"jf-signature.svg\");\n    mask-position: center;\n    mask-repeat: no-repeat;\n    mask-size: 8em 3em;\n  }\n}\n"
  },
  {
    "path": "app/channels/application_cable/connection.rb",
    "content": "module ApplicationCable\n  class Connection < ActionCable::Connection::Base\n    identified_by :current_user\n\n    def connect\n      set_current_user || reject_unauthorized_connection\n    end\n\n    private\n      def set_current_user\n        if session = find_session_by_cookie\n          account = Account.find_by(external_account_id: request.env[\"fizzy.external_account_id\"])\n          Current.account = account\n          self.current_user = session.identity.users.find_by!(account: account) if account\n        end\n      end\n\n      def find_session_by_cookie\n        Session.find_signed(cookies.signed[:session_token])\n      end\n  end\nend\n"
  },
  {
    "path": "app/controllers/account/cancellations_controller.rb",
    "content": "class Account::CancellationsController < ApplicationController\n  before_action :ensure_owner\n\n  def create\n    Current.account.cancel\n    redirect_to session_menu_path(script_name: nil), notice: \"Account deleted\"\n  end\n\n  private\n    def ensure_owner\n      head :forbidden unless Current.user.owner?\n    end\nend\n"
  },
  {
    "path": "app/controllers/account/entropies_controller.rb",
    "content": "class Account::EntropiesController < ApplicationController\n  wrap_parameters :entropy, include: [ :auto_postpone_period_in_days ]\n\n  before_action :ensure_admin\n\n  def update\n    @account = Current.account\n    @account.entropy.update!(entropy_params)\n\n    respond_to do |format|\n      format.html { redirect_to account_settings_path, notice: \"Account updated\" }\n      format.json { render \"account/settings/show\", status: :ok }\n    end\n  rescue ActiveRecord::RecordInvalid\n    head :unprocessable_entity\n  end\n\n  private\n    def entropy_params\n      params.expect(entropy: [ :auto_postpone_period_in_days ])\n    end\nend\n"
  },
  {
    "path": "app/controllers/account/exports_controller.rb",
    "content": "class Account::ExportsController < ApplicationController\n  before_action :ensure_admin_or_owner\n  before_action :ensure_export_limit_not_exceeded, only: :create\n  before_action :set_export, only: :show\n\n  CURRENT_EXPORT_LIMIT = 10\n\n  def show\n    respond_to do |format|\n      format.html\n      format.json { @export ? render(:show) : head(:not_found) }\n    end\n  end\n\n  def create\n    @export = Current.account.exports.create!(user: Current.user)\n    @export.build_later\n\n    respond_to do |format|\n      format.html { redirect_to account_settings_path, notice: \"Export started. You'll receive an email when it's ready.\" }\n      format.json { render :show, status: :created }\n    end\n  end\n\n  private\n    def ensure_admin_or_owner\n      head :forbidden unless Current.user.admin? || Current.user.owner?\n    end\n\n    def ensure_export_limit_not_exceeded\n      head :too_many_requests if Current.account.exports.current.count >= CURRENT_EXPORT_LIMIT\n    end\n\n    def set_export\n      scope = request.format.json? ? Current.account.exports : Current.account.exports.completed\n      @export = scope.find_by(id: params[:id], user: Current.user)\n    end\nend\n"
  },
  {
    "path": "app/controllers/account/imports_controller.rb",
    "content": "class Account::ImportsController < ApplicationController\n  layout \"public\"\n\n  disallow_account_scope only: %i[ new create ]\n  allow_unauthorized_access only: :show\n  before_action :set_import, only: %i[ show ]\n  before_action :ensure_accessed_by_owner, only: %i[ show ]\n\n  def new\n  end\n\n  def create\n    signup = Signup.new(identity: Current.identity, full_name: \"Import\", skip_account_seeding: true)\n\n    if signup.complete\n      start_import(signup.account)\n    else\n      render :new, alert: \"Couldn't create account.\"\n    end\n  end\n\n  def show\n  end\n\n  private\n    def set_import\n      @import = Current.account.imports.find(params[:id])\n    end\n\n    def ensure_accessed_by_owner\n      head :forbidden unless @import.identity == Current.identity\n    end\n\n    def start_import(account)\n      import = nil\n\n      Current.set(account: account) do\n        import = account.imports.create!(identity: Current.identity, file: params[:file])\n        import.process_later\n      end\n\n      redirect_to account_import_path(import, script_name: account.slug)\n    end\nend\n"
  },
  {
    "path": "app/controllers/account/join_codes_controller.rb",
    "content": "class Account::JoinCodesController < ApplicationController\n  wrap_parameters :account_join_code, include: %i[ usage_limit ]\n\n  before_action :set_join_code\n  before_action :ensure_admin, only: %i[ update destroy ]\n\n  def show\n  end\n\n  def edit\n  end\n\n  def update\n    if @join_code.update(join_code_params)\n      respond_to do |format|\n        format.html { redirect_to account_join_code_path }\n        format.json { head :no_content }\n      end\n    else\n      respond_to do |format|\n        format.html { render :edit, status: :unprocessable_entity }\n        format.json { render json: @join_code.errors, status: :unprocessable_entity }\n      end\n    end\n  end\n\n  def destroy\n    @join_code.reset\n\n    respond_to do |format|\n      format.html { redirect_to account_join_code_path }\n      format.json { head :no_content }\n    end\n  end\n\n  private\n    def set_join_code\n      @join_code = Current.account.join_code\n    end\n\n    def join_code_params\n      params.expect account_join_code: [ :usage_limit ]\n    end\nend\n"
  },
  {
    "path": "app/controllers/account/settings_controller.rb",
    "content": "class Account::SettingsController < ApplicationController\n  wrap_parameters :account, include: %i[ name ]\n\n  before_action :ensure_admin, only: :update\n  before_action :set_account\n\n  def show\n    respond_to do |format|\n      format.html { @users = @account.users.active.alphabetically.includes(:identity) }\n      format.json\n    end\n  end\n\n  def update\n    @account.update!(account_params)\n\n    respond_to do |format|\n      format.html { redirect_to account_settings_path }\n      format.json { head :no_content }\n    end\n  end\n\n  private\n    def set_account\n      @account = Current.account\n    end\n\n    def account_params\n      params.expect account: %i[ name ]\n    end\nend\n"
  },
  {
    "path": "app/controllers/admin_controller.rb",
    "content": "class AdminController < ApplicationController\n  disallow_account_scope\n  before_action :ensure_staff\nend\n"
  },
  {
    "path": "app/controllers/application_controller.rb",
    "content": "class ApplicationController < ActionController::Base\n  include Authentication\n  include Authorization\n  include BlockSearchEngineIndexing\n  include CurrentRequest, CurrentTimezone, SetPlatform\n  include RequestForgeryProtection\n  include TurboFlash, ViewTransitions\n  include RoutingHeaders\n\n  etag { \"v1\" }\n  stale_when_importmap_changes\n  allow_browser versions: :modern\nend\n"
  },
  {
    "path": "app/controllers/boards/columns/closeds_controller.rb",
    "content": "class Boards::Columns::ClosedsController < ApplicationController\n  include BoardScoped\n\n  def show\n    set_page_and_extract_portion_from @board.cards.closed.recently_closed_first.preloaded\n    fresh_when etag: @page.records\n  end\nend\n"
  },
  {
    "path": "app/controllers/boards/columns/not_nows_controller.rb",
    "content": "class Boards::Columns::NotNowsController < ApplicationController\n  include BoardScoped\n\n  def show\n    set_page_and_extract_portion_from @board.cards.postponed.latest.preloaded\n    fresh_when etag: @page.records\n  end\nend\n"
  },
  {
    "path": "app/controllers/boards/columns/streams_controller.rb",
    "content": "class Boards::Columns::StreamsController < ApplicationController\n  include BoardScoped\n\n  def show\n    set_page_and_extract_portion_from @board.cards.awaiting_triage.latest.with_golden_first.preloaded\n    fresh_when etag: @page.records\n  end\nend\n"
  },
  {
    "path": "app/controllers/boards/columns_controller.rb",
    "content": "class Boards::ColumnsController < ApplicationController\n  wrap_parameters :column, include: %i[ name color ]\n\n  include BoardScoped\n\n  before_action :set_column, only: %i[ show update destroy ]\n\n  def index\n    @columns = @board.columns.sorted\n    fresh_when etag: @columns\n  end\n\n  def show\n    set_page_and_extract_portion_from @column.cards.active.latest.with_golden_first.preloaded\n    fresh_when etag: @page.records\n  end\n\n  def create\n    @column = @board.columns.create!(column_params)\n\n    respond_to do |format|\n      format.turbo_stream\n      format.json { render :show, status: :created, location: board_column_path(@board, @column, format: :json) }\n    end\n  end\n\n  def update\n    @column.update!(column_params)\n\n    respond_to do |format|\n      format.turbo_stream\n      format.json { head :no_content }\n    end\n  end\n\n  def destroy\n    @column.destroy\n\n    respond_to do |format|\n      format.html { redirect_back_or_to @board }\n      format.json { head :no_content }\n    end\n  end\n\n  private\n    def set_column\n      @column = @board.columns.find(params[:id])\n    end\n\n    def column_params\n      params.expect(column: [ :name, :color ])\n    end\nend\n"
  },
  {
    "path": "app/controllers/boards/entropies_controller.rb",
    "content": "class Boards::EntropiesController < ApplicationController\n  wrap_parameters :board, include: [ :auto_postpone_period_in_days ]\n\n  include BoardScoped\n\n  before_action :ensure_permission_to_admin_board\n\n  def update\n    @board.update!(entropy_params)\n\n    respond_to do |format|\n      format.turbo_stream\n      format.json { render \"boards/show\", status: :ok }\n    end\n  rescue ActiveRecord::RecordInvalid\n    head :unprocessable_entity\n  end\n\n  private\n    def entropy_params\n      params.expect(board: [ :auto_postpone_period_in_days ])\n    end\nend\n"
  },
  {
    "path": "app/controllers/boards/involvements_controller.rb",
    "content": "class Boards::InvolvementsController < ApplicationController\n  include BoardScoped\n\n  def update\n    @board.access_for(Current.user).update!(involvement: params[:involvement])\n\n    respond_to do |format|\n      format.html\n      format.turbo_stream\n      format.json { head :no_content }\n    end\n  end\nend\n"
  },
  {
    "path": "app/controllers/boards/publications_controller.rb",
    "content": "class Boards::PublicationsController < ApplicationController\n  include BoardScoped\n\n  before_action :ensure_permission_to_admin_board\n\n  def create\n    @board.publish\n\n    respond_to do |format|\n      format.turbo_stream\n      format.json { render partial: \"boards/board\", locals: { board: @board }, status: :created }\n    end\n  end\n\n  def destroy\n    @board.unpublish\n    @board.reload\n\n    respond_to do |format|\n      format.turbo_stream\n      format.json { head :no_content }\n    end\n  end\nend\n"
  },
  {
    "path": "app/controllers/boards_controller.rb",
    "content": "class BoardsController < ApplicationController\n  wrap_parameters :board, include: %i[ name all_access auto_postpone_period_in_days public_description ]\n\n  include FilterScoped\n\n  before_action :set_board, except: %i[ index new create ]\n  before_action :ensure_permission_to_admin_board, only: %i[ update destroy ]\n\n  def index\n    set_page_and_extract_portion_from Current.user.boards.ordered_by_recently_accessed.includes(creator: :identity)\n    fresh_when etag: @page.records\n  end\n\n  def show\n    if @filter.used?(ignore_boards: true)\n      show_filtered_cards\n    else\n      show_columns\n    end\n  end\n\n  def new\n    @board = Board.new\n  end\n\n  def create\n    @board = Board.create! board_params.with_defaults(all_access: true)\n\n    respond_to do |format|\n      format.html { redirect_to board_path(@board) }\n      format.json { render :show, status: :created, location: board_path(@board, format: :json) }\n    end\n  end\n\n  def edit\n    selected_user_ids = @board.users.ids\n    @selected_users, @unselected_users = \\\n      @board.account.users.active.alphabetically.includes(:identity).partition { |user| selected_user_ids.include? user.id }\n  end\n\n  def update\n    @board.update! board_params\n    @board.accesses.revise granted: grantees, revoked: revokees if grantees_changed?\n\n    respond_to do |format|\n      format.html do\n        if @board.accessible_to?(Current.user)\n          redirect_to edit_board_path(@board), notice: \"Saved\"\n        else\n          redirect_to root_path, notice: \"Saved (you were removed from the board)\"\n        end\n      end\n      format.json { head :no_content }\n    end\n  end\n\n  def destroy\n    @board.destroy\n\n    respond_to do |format|\n      format.html { redirect_to root_path }\n      format.json { head :no_content }\n    end\n  end\n\n  private\n    def set_board\n      @board = Current.user.boards.find params[:id]\n    end\n\n    def ensure_permission_to_admin_board\n      unless Current.user.can_administer_board?(@board)\n        head :forbidden\n      end\n    end\n\n    def grantees_changed?\n      params.key?(:user_ids)\n    end\n\n    def show_filtered_cards\n      @filter.board_ids = [ @board.id ]\n      set_page_and_extract_portion_from @filter.cards\n    end\n\n    def show_columns\n      cards = @board.cards.awaiting_triage.latest.with_golden_first.preloaded\n      set_page_and_extract_portion_from cards\n      fresh_when etag: [ @board, @page.records, @user_filtering, Current.account ]\n    end\n\n    def board_params\n      params.expect(board: [ :name, :all_access, :auto_postpone_period_in_days, :public_description ])\n    end\n\n    def grantees\n      @board.account.users.active.where id: grantee_ids\n    end\n\n    def revokees\n      @board.users.where.not id: grantee_ids\n    end\n\n    def grantee_ids\n      params.fetch :user_ids, []\n    end\nend\n"
  },
  {
    "path": "app/controllers/cards/assignments_controller.rb",
    "content": "class Cards::AssignmentsController < ApplicationController\n  include CardScoped\n\n  def new\n    @assigned_to = @card.assignees.active.alphabetically.where.not(id: Current.user)\n    @users = @board.users.active.alphabetically.where.not(id: @card.assignees).where.not(id: Current.user)\n    fresh_when etag: [ @users, @card.assignees ]\n  end\n\n  def create\n    if @card.toggle_assignment @board.users.active.find(params[:assignee_id])\n      respond_to do |format|\n        format.turbo_stream\n        format.json { head :no_content }\n      end\n    else\n      respond_to do |format|\n        format.turbo_stream\n        format.json { head :unprocessable_entity }\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "app/controllers/cards/boards_controller.rb",
    "content": "class Cards::BoardsController < ApplicationController\n  include BoardScoped\n\n  skip_before_action :set_board, only: %i[ edit ]\n  before_action :set_card\n\n  def edit\n    @boards = Current.user.boards.ordered_by_recently_accessed\n    fresh_when @boards\n  end\n\n  def update\n    @card.move_to(@board)\n\n    respond_to do |format|\n      format.html { redirect_to @card }\n      format.json { head :no_content }\n    end\n  end\n\n  private\n    def set_card\n      @card = Current.user.accessible_cards.find_by!(number: params[:card_id])\n    end\nend\n"
  },
  {
    "path": "app/controllers/cards/closures_controller.rb",
    "content": "class Cards::ClosuresController < ApplicationController\n  include CardScoped\n\n  def create\n    capture_card_location\n    @card.close\n    refresh_stream_if_needed\n\n    respond_to do |format|\n      format.turbo_stream\n      format.json { head :no_content }\n    end\n  end\n\n  def destroy\n    @card.reopen\n    refresh_stream_after_reopen\n\n    respond_to do |format|\n      format.turbo_stream\n      format.json { head :no_content }\n    end\n  end\n\n  private\n    def refresh_stream_after_reopen\n      if @card.awaiting_triage?\n        set_page_and_extract_portion_from @board.cards.awaiting_triage.latest.with_golden_first.preloaded\n      end\n    end\nend\n"
  },
  {
    "path": "app/controllers/cards/columns_controller.rb",
    "content": "class Cards::ColumnsController < ApplicationController\n  def edit\n    @card = Current.user.accessible_cards.find_by!(number: params[:card_id])\n    @columns = @card.board.columns.sorted\n\n    fresh_when etag: [ @card, @columns ]\n  end\nend\n"
  },
  {
    "path": "app/controllers/cards/comments/reactions_controller.rb",
    "content": "class Cards::Comments::ReactionsController < ApplicationController\n  wrap_parameters :reaction, include: %i[ content ]\n\n  include CardScoped\n\n  before_action :set_comment\n  before_action :set_reactable\n\n  with_options only: :destroy do\n    before_action :set_reaction\n    before_action :ensure_permission_to_administer_reaction\n  end\n\n  def index\n    render \"reactions/index\"\n  end\n\n  def new\n    render \"reactions/new\"\n  end\n\n  def create\n    @reaction = @reactable.reactions.create!(params.expect(reaction: :content))\n\n    respond_to do |format|\n      format.turbo_stream { render \"reactions/create\" }\n      format.json { render \"reactions/show\", status: :created }\n    end\n  end\n\n  def destroy\n    @reaction.destroy\n\n    respond_to do |format|\n      format.turbo_stream { render \"reactions/destroy\" }\n      format.json { head :no_content }\n    end\n  end\n\n  private\n    def set_comment\n      @comment = @card.comments.find(params[:comment_id])\n    end\n\n    def set_reactable\n      @reactable = @comment\n    end\n\n    def set_reaction\n      @reaction = @reactable.reactions.find(params[:id])\n    end\n\n    def ensure_permission_to_administer_reaction\n      head :forbidden if Current.user != @reaction.reacter\n    end\nend\n"
  },
  {
    "path": "app/controllers/cards/comments_controller.rb",
    "content": "class Cards::CommentsController < ApplicationController\n  wrap_parameters :comment, include: %i[ body created_at ]\n  include CardScoped\n\n  before_action :set_comment, only: %i[ show edit update destroy ]\n  before_action :ensure_creatorship, only: %i[ edit update destroy ]\n  before_action :ensure_card_is_commentable, only: :create\n\n  def index\n    set_page_and_extract_portion_from @card.comments.chronologically\n  end\n\n  def create\n    @comment = @card.comments.create!(comment_params)\n\n    respond_to do |format|\n      format.turbo_stream\n      format.json { render :show, status: :created, location: card_comment_path(@card, @comment, format: :json) }\n    end\n  end\n\n  def show\n  end\n\n  def edit\n  end\n\n  def update\n    @comment.update! comment_params\n\n    respond_to do |format|\n      format.turbo_stream\n      format.json { head :no_content }\n    end\n  end\n\n  def destroy\n    @comment.destroy\n\n    respond_to do |format|\n      format.turbo_stream\n      format.json { head :no_content }\n    end\n  end\n\n  private\n    def set_comment\n      @comment = @card.comments.find(params[:id])\n    end\n\n    def ensure_creatorship\n      head :forbidden if Current.user != @comment.creator\n    end\n\n    def ensure_card_is_commentable\n      head :forbidden unless @card.commentable?\n    end\n\n    def comment_params\n      params.expect(comment: [ :body, :created_at ])\n    end\nend\n"
  },
  {
    "path": "app/controllers/cards/drafts_controller.rb",
    "content": "class Cards::DraftsController < ApplicationController\n  include CardScoped\n\n  before_action :redirect_if_published\n\n  def show\n  end\n\n  private\n    def redirect_if_published\n      redirect_to @card unless @card.drafted?\n    end\nend\n"
  },
  {
    "path": "app/controllers/cards/goldnesses_controller.rb",
    "content": "class Cards::GoldnessesController < ApplicationController\n  include CardScoped\n\n  def create\n    @card.gild\n\n    respond_to do |format|\n      format.turbo_stream { render_card_replacement }\n      format.json { head :no_content }\n    end\n  end\n\n  def destroy\n    @card.ungild\n\n    respond_to do |format|\n      format.turbo_stream { render_card_replacement }\n      format.json { head :no_content }\n    end\n  end\nend\n"
  },
  {
    "path": "app/controllers/cards/images_controller.rb",
    "content": "class Cards::ImagesController < ApplicationController\n  include CardScoped\n\n  def destroy\n    @card.image.purge_later\n\n    respond_to do |format|\n      format.html { redirect_to @card }\n      format.json { head :no_content }\n    end\n  end\nend\n"
  },
  {
    "path": "app/controllers/cards/not_nows_controller.rb",
    "content": "class Cards::NotNowsController < ApplicationController\n  include CardScoped\n\n  def create\n    capture_card_location\n    @card.postpone\n    refresh_stream_if_needed\n\n    respond_to do |format|\n      format.turbo_stream\n      format.json { head :no_content }\n    end\n  end\nend\n"
  },
  {
    "path": "app/controllers/cards/pins_controller.rb",
    "content": "class Cards::PinsController < ApplicationController\n  include CardScoped\n\n  def show\n    fresh_when etag: @card.pin_for(Current.user) || \"none\"\n  end\n\n  def create\n    @pin = @card.pin_by Current.user\n\n    broadcast_add_pin_to_tray\n\n    respond_to do |format|\n      format.turbo_stream { render_pin_button_replacement }\n      format.json { head :no_content }\n    end\n  end\n\n  def destroy\n    @pin = @card.unpin_by Current.user\n\n    broadcast_remove_pin_from_tray\n\n    respond_to do |format|\n      format.turbo_stream { render_pin_button_replacement }\n      format.json { head :no_content }\n    end\n  end\n\n  private\n    def broadcast_add_pin_to_tray\n      @pin.broadcast_prepend_to [ Current.user, :pins_tray ], target: \"pins\", partial: \"my/pins/pin\"\n    end\n\n    def broadcast_remove_pin_from_tray\n      @pin.broadcast_remove_to [ Current.user, :pins_tray ]\n    end\n\n    def render_pin_button_replacement\n      render turbo_stream: turbo_stream.replace([ @card, :pin_button ], partial: \"cards/pins/pin_button\", locals: { card: @card })\n    end\nend\n"
  },
  {
    "path": "app/controllers/cards/previews_controller.rb",
    "content": "class Cards::PreviewsController < ApplicationController\n  include FilterScoped\n\n  before_action :set_filter, only: :index\n\n  def index\n    set_page_and_extract_portion_from @filter.cards\n  end\nend\n"
  },
  {
    "path": "app/controllers/cards/publishes_controller.rb",
    "content": "class Cards::PublishesController < ApplicationController\n  include CardScoped\n\n  def create\n    @card.publish\n\n    respond_to do |format|\n      format.html do\n        if add_another_param?\n          card = @board.cards.create!(status: :drafted)\n          redirect_to card_draft_path(card), notice: \"Card added\"\n        else\n          redirect_to @card.board\n        end\n      end\n\n      format.json { head :created }\n    end\n  end\n\n  private\n    def add_another_param?\n      params[:creation_type] == \"add_another\"\n    end\nend\n"
  },
  {
    "path": "app/controllers/cards/reactions_controller.rb",
    "content": "class Cards::ReactionsController < ApplicationController\n  wrap_parameters :reaction, include: %i[ content ]\n\n  include CardScoped\n\n  before_action :set_reactable\n\n  with_options only: :destroy do\n    before_action :set_reaction\n    before_action :ensure_permission_to_administer_reaction\n  end\n\n  def index\n    render \"reactions/index\"\n  end\n\n  def new\n    render \"reactions/new\"\n  end\n\n  def create\n    @reaction = @reactable.reactions.create!(params.expect(reaction: :content))\n\n    respond_to do |format|\n      format.turbo_stream { render \"reactions/create\" }\n      format.json { render \"reactions/show\", status: :created }\n    end\n  end\n\n  def destroy\n    @reaction.destroy\n\n    respond_to do |format|\n      format.turbo_stream { render \"reactions/destroy\" }\n      format.json { head :no_content }\n    end\n  end\n\n  private\n    def set_reactable\n      @reactable = @card\n    end\n\n    def set_reaction\n      @reaction = @reactable.reactions.find(params[:id])\n    end\n\n    def ensure_permission_to_administer_reaction\n      head :forbidden if Current.user != @reaction.reacter\n    end\nend\n"
  },
  {
    "path": "app/controllers/cards/readings_controller.rb",
    "content": "class Cards::ReadingsController < ApplicationController\n  include CardScoped\n\n  def create\n    @notification = @card.read_by(Current.user)\n    record_board_access\n\n    respond_to do |format|\n      format.turbo_stream\n      format.json { head :created }\n    end\n  end\n\n  def destroy\n    @notification = @card.unread_by(Current.user)\n    record_board_access\n\n    respond_to do |format|\n      format.turbo_stream\n      format.json { head :no_content }\n    end\n  end\n\n  private\n    def record_board_access\n      @card.board.accessed_by(Current.user)\n    end\nend\n"
  },
  {
    "path": "app/controllers/cards/self_assignments_controller.rb",
    "content": "class Cards::SelfAssignmentsController < ApplicationController\n  include CardScoped\n\n  def create\n    if @card.toggle_assignment(Current.user)\n      respond_to do |format|\n        format.turbo_stream { render \"cards/assignments/create\" }\n        format.json { head :no_content }\n      end\n    else\n      respond_to do |format|\n        format.turbo_stream { render \"cards/assignments/create\" }\n        format.json { head :unprocessable_entity }\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "app/controllers/cards/steps_controller.rb",
    "content": "class Cards::StepsController < ApplicationController\n  wrap_parameters :step, include: %i[ content completed ]\n\n  include CardScoped\n\n  before_action :set_step, only: %i[ show edit update destroy ]\n\n  def index\n    fresh_when etag: @card.steps\n  end\n\n  def create\n    @step = @card.steps.create!(step_params)\n\n    respond_to do |format|\n      format.turbo_stream\n      format.json { render :show, status: :created, location: card_step_path(@card, @step, format: :json) }\n    end\n  end\n\n  def show\n  end\n\n  def edit\n  end\n\n  def update\n    @step.update!(step_params)\n\n    respond_to do |format|\n      format.turbo_stream\n      format.json { render :show }\n    end\n  end\n\n  def destroy\n    @step.destroy!\n\n    respond_to do |format|\n      format.turbo_stream\n      format.json { head :no_content }\n    end\n  end\n\n  private\n    def set_step\n      @step = @card.steps.find(params[:id])\n    end\n\n    def step_params\n      params.expect(step: [ :content, :completed ])\n    end\nend\n"
  },
  {
    "path": "app/controllers/cards/taggings_controller.rb",
    "content": "class Cards::TaggingsController < ApplicationController\n  include CardScoped\n\n  def new\n    @tagged_with = @card.tags.alphabetically\n    @tags = Current.account.tags.all.alphabetically.where.not(id: @tagged_with)\n    fresh_when etag: [ @tags, @card.tags ]\n  end\n\n  def create\n    @card.toggle_tag_with sanitized_tag_title_param\n\n    respond_to do |format|\n      format.turbo_stream\n      format.json { head :no_content }\n    end\n  end\n\n  private\n    def sanitized_tag_title_param\n      params.required(:tag_title).strip.gsub(/\\A#/, \"\")\n    end\nend\n"
  },
  {
    "path": "app/controllers/cards/triages_controller.rb",
    "content": "class Cards::TriagesController < ApplicationController\n  include CardScoped\n\n  def create\n    column = @card.board.columns.find(params[:column_id])\n    @card.triage_into(column)\n\n    respond_to do |format|\n      format.html { redirect_to @card }\n      format.json { head :no_content }\n    end\n  end\n\n  def destroy\n    @card.send_back_to_triage\n\n    respond_to do |format|\n      format.html { redirect_to @card }\n      format.json { head :no_content }\n    end\n  end\nend\n"
  },
  {
    "path": "app/controllers/cards/watches_controller.rb",
    "content": "class Cards::WatchesController < ApplicationController\n  include CardScoped\n\n  def show\n    fresh_when etag: @card.watch_for(Current.user) || \"none\"\n  end\n\n  def create\n    @card.watch_by Current.user\n\n    respond_to do |format|\n      format.turbo_stream\n      format.json { head :no_content }\n    end\n  end\n\n  def destroy\n    @card.unwatch_by Current.user\n\n    respond_to do |format|\n      format.turbo_stream\n      format.json { head :no_content }\n    end\n  end\nend\n"
  },
  {
    "path": "app/controllers/cards_controller.rb",
    "content": "class CardsController < ApplicationController\n  wrap_parameters :card, include: %i[ title description image created_at last_active_at ]\n\n  include FilterScoped\n\n  before_action :set_board, only: %i[ create ]\n  before_action :set_card, only: %i[ show edit update destroy ]\n  before_action :redirect_if_drafted, only: :show\n  before_action :ensure_permission_to_administer_card, only: %i[ destroy ]\n\n  def index\n    set_page_and_extract_portion_from @filter.cards\n  end\n\n  def create\n    respond_to do |format|\n      format.html do\n        card = Current.user.draft_new_card_in(@board)\n        redirect_to card_draft_path(card)\n      end\n\n      format.json do\n        @card = @board.cards.create! card_params.merge(creator: Current.user, status: \"published\")\n        render :show, status: :created, location: card_path(@card, format: :json)\n      end\n    end\n  end\n\n  def show\n  end\n\n  def edit\n  end\n\n  def update\n    @card.update! card_params\n\n    respond_to do |format|\n      format.turbo_stream\n      format.json { render :show }\n    end\n  end\n\n  def destroy\n    @card.destroy!\n\n    respond_to do |format|\n      format.html { redirect_to @card.board, notice: \"Card deleted\" }\n      format.json { head :no_content }\n    end\n  end\n\n  private\n    def set_board\n      @board = Current.user.boards.find params[:board_id]\n    end\n\n    def set_card\n      @card = Current.user.accessible_cards.find_by!(number: params[:id])\n    end\n\n    def redirect_if_drafted\n      redirect_to card_draft_path(@card) if @card.drafted?\n    end\n\n    def ensure_permission_to_administer_card\n      head :forbidden unless Current.user.can_administer_card?(@card)\n    end\n\n    def card_params\n      params.expect(card: [ :title, :description, :image, :created_at, :last_active_at ])\n    end\nend\n"
  },
  {
    "path": "app/controllers/client_configurations_controller.rb",
    "content": "class ClientConfigurationsController < ApplicationController\n  skip_before_action :require_account, :require_authentication\n  allow_unauthorized_access\n\n  def show\n    expires_in 1.minute, public: true\n\n    render action: client_configuration_name\n  end\n\n  private\n    def client_configuration_name\n      \"#{params.require(:platform)}_v#{params.require(:version)}\"\n    end\nend\n"
  },
  {
    "path": "app/controllers/columns/cards/drops/closures_controller.rb",
    "content": "class Columns::Cards::Drops::ClosuresController < ApplicationController\n  include CardScoped\n\n  def create\n    @card.close\n  end\nend\n"
  },
  {
    "path": "app/controllers/columns/cards/drops/columns_controller.rb",
    "content": "class Columns::Cards::Drops::ColumnsController < ApplicationController\n  include CardScoped\n\n  def create\n    @column = @card.board.columns.find(params[:column_id])\n    @card.triage_into(@column)\n  end\nend\n"
  },
  {
    "path": "app/controllers/columns/cards/drops/not_nows_controller.rb",
    "content": "class Columns::Cards::Drops::NotNowsController < ApplicationController\n  include CardScoped\n\n  def create\n    @card.postpone\n  end\nend\n"
  },
  {
    "path": "app/controllers/columns/cards/drops/streams_controller.rb",
    "content": "class Columns::Cards::Drops::StreamsController < ApplicationController\n  include CardScoped\n\n  def create\n    @card.send_back_to_triage\n    set_page_and_extract_portion_from @board.cards.awaiting_triage.latest.with_golden_first\n  end\nend\n"
  },
  {
    "path": "app/controllers/columns/left_positions_controller.rb",
    "content": "class Columns::LeftPositionsController < ApplicationController\n  include ColumnScoped\n\n  def create\n    @left_column = @column.left_column\n    @column.move_left\n\n    respond_to do |format|\n      format.turbo_stream\n      format.json { head :created }\n    end\n  end\nend\n"
  },
  {
    "path": "app/controllers/columns/right_positions_controller.rb",
    "content": "class Columns::RightPositionsController < ApplicationController\n  include ColumnScoped\n\n  def create\n    @right_column = @column.right_column\n    @column.move_right\n\n    respond_to do |format|\n      format.turbo_stream\n      format.json { head :created }\n    end\n  end\nend\n"
  },
  {
    "path": "app/controllers/concerns/authentication/via_magic_link.rb",
    "content": "module Authentication::ViaMagicLink\n  extend ActiveSupport::Concern\n\n  included do\n    after_action :ensure_development_magic_link_not_leaked\n  end\n\n  private\n    def ensure_development_magic_link_not_leaked\n      unless Rails.env.development?\n        raise \"Leaking magic link via flash in #{Rails.env}?\" if flash[:magic_link_code].present?\n      end\n    end\n\n    def redirect_to_fake_session_magic_link(email_address, **options)\n      fake_magic_link = MagicLink.new(\n        identity: Identity.new(email_address: email_address),\n        code: SecureRandom.base32(6),\n        expires_at: MagicLink::EXPIRATION_TIME.from_now\n      )\n\n      redirect_to_session_magic_link fake_magic_link, **options\n    end\n\n    def redirect_to_session_magic_link(magic_link, return_to: nil)\n      serve_development_magic_link(magic_link)\n      set_pending_authentication_token(magic_link)\n      session[:return_to_after_authenticating] = return_to if return_to\n\n      respond_to do |format|\n        format.html { redirect_to main_app.session_magic_link_url(script_name: nil) }\n        format.json { render json: { pending_authentication_token: pending_authentication_token }, status: :created }\n      end\n    end\n\n    def serve_development_magic_link(magic_link)\n      if Rails.env.development? && magic_link.present?\n        flash[:magic_link_code] = magic_link.code\n        response.set_header(\"X-Magic-Link-Code\", magic_link.code)\n      end\n    end\n\n    def set_pending_authentication_token(magic_link)\n      cookies[:pending_authentication_token] = {\n        value: pending_authentication_token_verifier.generate(magic_link.identity.email_address, expires_at: magic_link.expires_at),\n        httponly: true,\n        same_site: :lax,\n        expires: magic_link.expires_at\n      }\n    end\n\n    def email_address_pending_authentication\n      pending_authentication_token_verifier.verified(pending_authentication_token)\n    end\n\n    def pending_authentication_token_verifier\n      Rails.application.message_verifier(:pending_authentication)\n    end\n\n    def pending_authentication_token\n      cookies[:pending_authentication_token]\n    end\n\n    def clear_pending_authentication_token\n      cookies.delete(:pending_authentication_token)\n    end\nend\n"
  },
  {
    "path": "app/controllers/concerns/authentication.rb",
    "content": "module Authentication\n  extend ActiveSupport::Concern\n\n  included do\n    before_action :require_account # Checking and setting account must happen first\n    before_action :require_authentication\n    helper_method :authenticated?\n    helper_method :email_address_pending_authentication\n\n    etag { Current.identity.id if authenticated? }\n\n    include Authentication::ViaMagicLink, LoginHelper\n  end\n\n  class_methods do\n    def require_unauthenticated_access(**options)\n      allow_unauthenticated_access **options\n      before_action :redirect_authenticated_user, **options\n    end\n\n    def allow_unauthenticated_access(**options)\n      skip_before_action :require_authentication, **options\n      before_action :resume_session, **options\n      allow_unauthorized_access **options\n    end\n\n    def disallow_account_scope(**options)\n      skip_before_action :require_account, **options\n      before_action :redirect_tenanted_request, **options\n    end\n  end\n\n  private\n    def authenticated?\n      Current.identity.present?\n    end\n\n    def require_account\n      unless Current.account.present?\n        redirect_to main_app.session_menu_path(script_name: nil)\n      end\n    end\n\n    def require_authentication\n      resume_session || authenticate_by_bearer_token || request_authentication\n    end\n\n    def resume_session\n      if session = find_session_by_cookie\n        set_current_session session\n      end\n    end\n\n    def find_session_by_cookie\n      Session.find_signed(cookies.signed[:session_token])\n    end\n\n    def authenticate_by_bearer_token\n      if request.authorization.to_s.include?(\"Bearer\")\n        authenticate_or_request_with_http_token do |token|\n          if identity = Identity.find_by_permissable_access_token(token, method: request.method)\n            Current.identity = identity\n          end\n        end\n      end\n    end\n\n    def request_authentication\n      if Current.account.present?\n        session[:return_to_after_authenticating] = request.url\n      end\n\n      redirect_to_login_url\n    end\n\n    def after_authentication_url\n      session.delete(:return_to_after_authenticating) || landing_url\n    end\n\n    def redirect_authenticated_user\n      redirect_to main_app.root_url if authenticated?\n    end\n\n    def redirect_tenanted_request\n      redirect_to main_app.root_url if Current.account.present?\n    end\n\n    def start_new_session_for(identity)\n      identity.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|\n        set_current_session session\n      end\n    end\n\n    def set_current_session(session)\n      Current.session = session\n      cookies.signed.permanent[:session_token] = { value: session.signed_id, httponly: true, same_site: :lax }\n    end\n\n    def terminate_session\n      Current.session.destroy\n      cookies.delete(:session_token)\n    end\n\n    def session_token\n      cookies[:session_token]\n    end\nend\n"
  },
  {
    "path": "app/controllers/concerns/authorization.rb",
    "content": "module Authorization\n  extend ActiveSupport::Concern\n\n  included do\n    before_action :ensure_can_access_account, if: :authenticated_account_access?\n  end\n\n  class_methods do\n    def allow_unauthorized_access(**options)\n      skip_before_action :ensure_can_access_account, **options\n    end\n\n    def require_access_without_a_user(**options)\n      skip_before_action :ensure_can_access_account, **options\n      before_action :redirect_existing_user, **options\n    end\n  end\n\n  private\n    def ensure_admin\n      head :forbidden unless Current.user.admin?\n    end\n\n    def ensure_staff\n      head :forbidden unless Current.identity.staff?\n    end\n\n    def authenticated_account_access?\n      Current.account.present? && authenticated?\n    end\n\n    def ensure_can_access_account\n      unless Current.account.active? && Current.user&.active?\n        respond_to do |format|\n          format.html { redirect_to session_menu_path(script_name: nil) }\n          format.json { head :forbidden }\n        end\n      end\n    end\n\n    def redirect_existing_user\n      redirect_to root_path if Current.user\n    end\nend\n"
  },
  {
    "path": "app/controllers/concerns/block_search_engine_indexing.rb",
    "content": "# Tell crawlers like Googlebot to drop pages entirely from search results, even\n# if other sites link to it\nmodule BlockSearchEngineIndexing\n  extend ActiveSupport::Concern\n\n  included do\n    after_action :block_search_engine_indexing\n  end\n\n  private\n    def block_search_engine_indexing\n      headers[\"X-Robots-Tag\"] = \"none\"\n    end\nend\n"
  },
  {
    "path": "app/controllers/concerns/board_scoped.rb",
    "content": "module BoardScoped\n  extend ActiveSupport::Concern\n\n  included do\n    before_action :set_board\n  end\n\n  private\n    def set_board\n      @board = Current.user.boards.find(params[:board_id])\n    end\n\n    def ensure_permission_to_admin_board\n      unless Current.user.can_administer_board?(@board)\n        head :forbidden\n      end\n    end\nend\n"
  },
  {
    "path": "app/controllers/concerns/card_scoped.rb",
    "content": "module CardScoped\n  extend ActiveSupport::Concern\n\n  included do\n    before_action :set_card, :set_board\n  end\n\n  private\n    def set_card\n      @card = Current.user.accessible_cards.find_by!(number: params[:card_id])\n    end\n\n    def set_board\n      @board = @card.board\n    end\n\n    def render_card_replacement\n      render turbo_stream: turbo_stream.replace([ @card, :card_container ], partial: \"cards/container\", method: :morph, locals: { card: @card.reload })\n    end\n\n    def capture_card_location\n      @source_column = @card.column\n      @was_in_stream = @card.awaiting_triage?\n    end\n\n    def refresh_stream_if_needed\n      if @was_in_stream\n        set_page_and_extract_portion_from @board.cards.awaiting_triage.latest.with_golden_first.preloaded\n      end\n    end\nend\n"
  },
  {
    "path": "app/controllers/concerns/column_scoped.rb",
    "content": "module ColumnScoped\n  extend ActiveSupport::Concern\n\n  included do\n    before_action :set_column\n  end\n\n  private\n    def set_column\n      @column = Current.user.accessible_columns.find(params[:column_id])\n    end\nend\n"
  },
  {
    "path": "app/controllers/concerns/current_request.rb",
    "content": "module CurrentRequest\n  extend ActiveSupport::Concern\n\n  included do\n    before_action do\n      Current.http_method = request.method\n      Current.request_id  = request.uuid\n      Current.user_agent  = request.user_agent\n      Current.ip_address  = request.ip\n      Current.referrer    = request.referrer\n    end\n  end\nend\n"
  },
  {
    "path": "app/controllers/concerns/current_timezone.rb",
    "content": "# FIXME: This should move upstream to Rails. It's a good pattern.\nmodule CurrentTimezone\n  extend ActiveSupport::Concern\n\n  included do\n    around_action :set_current_timezone\n\n    helper_method :timezone_from_cookie\n\n    etag { timezone_from_cookie }\n  end\n\n  private\n    def set_current_timezone(&)\n      Time.use_zone(timezone_from_cookie, &)\n    end\n\n    def timezone_from_cookie\n      @timezone_from_cookie ||= begin\n        timezone = cookies[:timezone]\n        ActiveSupport::TimeZone[timezone] if timezone.present?\n      end\n    end\nend\n"
  },
  {
    "path": "app/controllers/concerns/day_timelines_scoped.rb",
    "content": "module DayTimelinesScoped\n  extend ActiveSupport::Concern\n\n  included do\n    include FilterScoped\n\n    before_action :set_day_timeline\n  end\n\n  private\n    def set_day_timeline\n      @day_timeline = Current.user.timeline_for(day, filter: @filter)\n    end\n\n    def day\n      if params[:day].present?\n        Time.zone.parse(params[:day])\n      else\n        Time.current\n      end\n    rescue ArgumentError\n      head :not_found\n    end\nend\n"
  },
  {
    "path": "app/controllers/concerns/filter_scoped.rb",
    "content": "module FilterScoped\n  extend ActiveSupport::Concern\n\n  included do\n    before_action :set_filter\n    before_action :set_user_filtering\n  end\n\n  private\n    def set_filter\n      if params[:filter_id].present?\n        @filter = Current.user.filters.find(params[:filter_id])\n      else\n        @filter = Current.user.filters.from_params filter_params\n      end\n    end\n\n    def filter_params\n      params.with_defaults(**Filter.default_values).permit(*Filter::PERMITTED_PARAMS)\n    end\n\n    def set_user_filtering\n      @user_filtering = User::Filtering.new(Current.user, @filter, expanded: expanded_param)\n    end\n\n    def expanded_param\n      ActiveRecord::Type::Boolean.new.cast(params[:expand_all])\n    end\nend\n"
  },
  {
    "path": "app/controllers/concerns/request_forgery_protection.rb",
    "content": "module RequestForgeryProtection\n  extend ActiveSupport::Concern\n\n  included do\n    protect_from_forgery using: :header_only, with: :exception\n  end\n\n  private\n    def verified_via_header_only?\n      super || allowed_api_request?\n    end\n\n    def allowed_api_request?\n      sec_fetch_site_value.nil? && request.format.json?\n    end\nend\n"
  },
  {
    "path": "app/controllers/concerns/routing_headers.rb",
    "content": "module RoutingHeaders\n  extend ActiveSupport::Concern\n\n  included do\n    before_action :set_target_header\n  end\n\n  private\n    def set_target_header\n      response.headers[\"X-Kamal-Target\"] = request.headers[\"X-Kamal-Target\"]\n    end\nend\n"
  },
  {
    "path": "app/controllers/concerns/set_platform.rb",
    "content": "module SetPlatform\n  extend ActiveSupport::Concern\n\n  included do\n    helper_method :platform\n  end\n\n  private\n    def platform\n      @platform ||= ApplicationPlatform.new(cookies[:x_user_agent].presence || request.user_agent)\n    end\nend\n"
  },
  {
    "path": "app/controllers/concerns/turbo_flash.rb",
    "content": "module TurboFlash\n  extend ActiveSupport::Concern\n\n  included do\n    helper_method :turbo_stream_flash\n  end\n\n  private\n    def turbo_stream_flash(**flash_options)\n      turbo_stream.replace(:flash, partial: \"layouts/shared/flash\", locals: { flash: flash_options })\n    end\nend\n"
  },
  {
    "path": "app/controllers/concerns/view_transitions.rb",
    "content": "# FIXME: Upstream this fix to turbo-rails\nmodule ViewTransitions\n  extend ActiveSupport::Concern\n\n  included do\n    before_action :disable_view_transitions, if: :page_refresh?\n  end\n\n  private\n    def disable_view_transitions\n      @disable_view_transition = true\n    end\n\n    def page_refresh?\n      request.referrer.present? && request.referrer == request.url\n    end\nend\n"
  },
  {
    "path": "app/controllers/events/day_timeline/columns_controller.rb",
    "content": "class Events::DayTimeline::ColumnsController < ApplicationController\n  include DayTimelinesScoped\n\n  before_action :ensure_valid_column\n  before_action :set_column\n\n  def show\n    fresh_when @day_timeline\n  end\n\n  private\n    VALID_COLUMNS = %w[ added updated closed ]\n\n    def ensure_valid_column\n      head :not_found unless VALID_COLUMNS.include?(params[:id])\n    end\n\n    def set_column\n      @column = @day_timeline.public_send(\"#{params[:id]}_column\")\n    end\nend\n"
  },
  {
    "path": "app/controllers/events/days_controller.rb",
    "content": "class Events::DaysController < ApplicationController\n  include DayTimelinesScoped\n\n  def index\n    fresh_when @day_timeline\n  end\nend\n"
  },
  {
    "path": "app/controllers/events_controller.rb",
    "content": "class EventsController < ApplicationController\n  include DayTimelinesScoped\n\n  def index\n    fresh_when @day_timeline\n  end\nend\n"
  },
  {
    "path": "app/controllers/filters/settings_refreshes_controller.rb",
    "content": "class Filters::SettingsRefreshesController < ApplicationController\n  include FilterScoped\n\n  def create\n  end\nend\n"
  },
  {
    "path": "app/controllers/filters_controller.rb",
    "content": "class FiltersController < ApplicationController\n  before_action :set_filters\n\n  def create\n    @filter = Current.user.filters.remember filter_params\n  end\n\n  def destroy\n    @filter = Current.user.filters.find(params[:id])\n    @filter.destroy!\n  end\n\n  private\n    def set_filters\n      @filters = Current.user.filters\n    end\n\n    def filter_params\n      Filter.normalize_params(params.permit(*Filter::PERMITTED_PARAMS))\n    end\nend\n"
  },
  {
    "path": "app/controllers/join_codes_controller.rb",
    "content": "class JoinCodesController < ApplicationController\n  allow_unauthenticated_access\n  rate_limit to: 10, within: 3.minutes, only: :create, with: -> { head :too_many_requests }\n\n  before_action :set_join_code\n  before_action :ensure_join_code_is_valid\n  before_action :set_identity, only: :create\n\n  layout \"public\"\n\n  def new\n  end\n\n  def create\n    @join_code.redeem_if { |account| @identity.join(account) }\n    user = User.active.find_by!(account: @join_code.account, identity: @identity)\n\n    if @identity == Current.identity && user.setup?\n      redirect_to landing_url(script_name: @join_code.account.slug)\n    elsif @identity == Current.identity\n      redirect_to new_users_verification_url(script_name: @join_code.account.slug)\n    else\n      terminate_session if Current.identity\n\n      redirect_to_session_magic_link \\\n        @identity.send_magic_link,\n        return_to: new_users_verification_url(script_name: @join_code.account.slug)\n    end\n  end\n\n  private\n    def set_identity\n      @identity = Identity.find_or_initialize_by(email_address: params.expect(:email_address))\n\n      if @identity.new_record?\n        if @identity.invalid?\n          head :unprocessable_entity\n        else\n          @identity.save!\n        end\n      end\n    end\n\n    def set_join_code\n      @join_code ||= Account::JoinCode.find_by(code: params.expect(:code), account: Current.account)\n    end\n\n    def ensure_join_code_is_valid\n      if @join_code.nil?\n        head :not_found\n      elsif !@join_code.active?\n        render :inactive, status: :gone\n      end\n    end\nend\n"
  },
  {
    "path": "app/controllers/landings_controller.rb",
    "content": "class LandingsController < ApplicationController\n  def show\n    flash.keep(:welcome_letter)\n\n    if Current.user.boards.one?\n      redirect_to board_path(Current.user.boards.first)\n    else\n      redirect_to root_path\n    end\n  end\nend\n"
  },
  {
    "path": "app/controllers/my/access_tokens_controller.rb",
    "content": "class My::AccessTokensController < ApplicationController\n  wrap_parameters :access_token, include: %i[ description permission ]\n\n  skip_before_action :require_account\n\n  def index\n    @access_tokens = my_access_tokens.order(created_at: :desc)\n  end\n\n  def show\n    @access_token = my_access_tokens.find(verifier.verify(params[:id]))\n  rescue ActiveSupport::MessageVerifier::InvalidSignature\n    redirect_to my_access_tokens_path, alert: \"Token is no longer visible\"\n  end\n\n  def new\n    @access_token = my_access_tokens.new\n  end\n\n  def create\n    access_token = my_access_tokens.create!(access_token_params)\n\n    respond_to do |format|\n      format.html do\n        expiring_id = verifier.generate access_token.id, expires_in: 10.seconds\n        redirect_to my_access_token_path(expiring_id)\n      end\n\n      format.json do\n        render status: :created, json: \\\n          { id: access_token.id, token: access_token.token, description: access_token.description,\n            permission: access_token.permission, created_at: access_token.created_at.utc }\n      end\n    end\n  end\n\n  def destroy\n    my_access_tokens.find(params[:id]).destroy!\n\n    respond_to do |format|\n      format.html { redirect_to my_access_tokens_path }\n      format.json { head :no_content }\n    end\n  end\n\n  private\n    def my_access_tokens\n      Current.identity.access_tokens\n    end\n\n    def access_token_params\n      params.expect(access_token: %i[ description permission ])\n    end\n\n    def verifier\n      Rails.application.message_verifier(:access_tokens)\n    end\nend\n"
  },
  {
    "path": "app/controllers/my/identities_controller.rb",
    "content": "class My::IdentitiesController < ApplicationController\n  disallow_account_scope\n\n  def show\n    @identity = Current.identity\n  end\nend\n"
  },
  {
    "path": "app/controllers/my/menus_controller.rb",
    "content": "class My::MenusController < ApplicationController\n  def show\n    @filters = Current.user.filters.all\n    @boards = Current.user.boards.ordered_by_recently_accessed\n    @tags = Current.account.tags.all.alphabetically\n    @users = Current.account.users.active.alphabetically\n    @accounts = Current.identity.accounts.active\n\n    fresh_when etag: [ @filters, @boards, @tags, @users, @accounts ]\n  end\nend\n"
  },
  {
    "path": "app/controllers/my/passkey_challenges_controller.rb",
    "content": "class My::PasskeyChallengesController < ActionPack::Passkey::ChallengesController\n  include Authentication\n  include Authorization\n\n  allow_unauthenticated_access\n  disallow_account_scope\nend\n"
  },
  {
    "path": "app/controllers/my/passkeys_controller.rb",
    "content": "class My::PasskeysController < ApplicationController\n  include ActionPack::Passkey::Request\n\n  before_action :set_passkey, only: %i[ edit update destroy ]\n\n  def index\n    @passkeys = Current.identity.passkeys.order(name: :asc, created_at: :desc)\n    @creation_options = passkey_creation_options(holder: Current.identity)\n  end\n\n  def create\n    passkey = Current.identity.passkeys.register(passkey_creation_params)\n\n    redirect_to edit_my_passkey_path(passkey, created: true)\n  end\n\n  def edit\n  end\n\n  def update\n    @passkey.update!(params.expect(passkey: [ :name ]))\n    redirect_to my_passkeys_path\n  end\n\n  def destroy\n    @passkey.destroy!\n    redirect_to my_passkeys_path\n  end\n\n  private\n    def set_passkey\n      @passkey = Current.identity.passkeys.find(params[:id])\n    end\nend\n"
  },
  {
    "path": "app/controllers/my/pins_controller.rb",
    "content": "class My::PinsController < ApplicationController\n  def index\n    @pins = user_pins\n    fresh_when etag: [ @pins, @pins.collect(&:card) ]\n  end\n\n  private\n    def user_pins\n      Current.user.pins.includes(:card).ordered.limit(pins_limit)\n    end\n\n    def pins_limit\n      request.format.json? ? 100 : 20\n    end\nend\n"
  },
  {
    "path": "app/controllers/my/timezones_controller.rb",
    "content": "class My::TimezonesController < ApplicationController\n  def update\n    Current.user.settings.update!(timezone_name: timezone_param)\n  end\n\n  private\n    def timezone_param\n      params[:timezone_name]\n    end\nend\n"
  },
  {
    "path": "app/controllers/notifications/bulk_readings_controller.rb",
    "content": "class Notifications::BulkReadingsController < ApplicationController\n  def create\n    Current.user.notifications.unread.read_all\n\n    respond_to do |format|\n      format.html do\n        if from_tray?\n          head :ok\n        else\n          redirect_to notifications_path\n        end\n      end\n      format.json { head :no_content }\n    end\n  end\n\n  private\n    def from_tray?\n      params[:from_tray]\n    end\nend\n"
  },
  {
    "path": "app/controllers/notifications/readings_controller.rb",
    "content": "class Notifications::ReadingsController < ApplicationController\n  def create\n    @notification = Current.user.notifications.find(params[:notification_id])\n    @notification.read\n\n    respond_to do |format|\n      format.turbo_stream\n      format.json { head :no_content }\n    end\n  end\n\n  def destroy\n    @notification = Current.user.notifications.find(params[:notification_id])\n    @notification.unread\n\n    respond_to do |format|\n      format.turbo_stream\n      format.json { head :no_content }\n    end\n  end\nend\n"
  },
  {
    "path": "app/controllers/notifications/settings_controller.rb",
    "content": "class Notifications::SettingsController < ApplicationController\n  wrap_parameters :user_settings, include: %i[ bundle_email_frequency ]\n\n  before_action :set_settings\n\n  def show\n    @boards = Current.user.boards.alphabetically\n  end\n\n  def update\n    @settings.update!(settings_params)\n\n    respond_to do |format|\n      format.html { redirect_to notifications_settings_path, notice: \"Settings updated\" }\n      format.json { head :no_content }\n    end\n  end\n\n  private\n    def set_settings\n      @settings = Current.user.settings\n    end\n\n    def settings_params\n      params.expect(user_settings: :bundle_email_frequency)\n    end\nend\n"
  },
  {
    "path": "app/controllers/notifications/trays_controller.rb",
    "content": "class Notifications::TraysController < ApplicationController\n  MAX_ENTRIES_LIMIT = 100\n\n  def show\n    @notifications = unread_notifications\n    if include_read?\n      @notifications += read_notifications\n    end\n\n    # Invalidate on the whole set instead of the unread set since the max updated at in the unread set\n    # can stay the same when reading old notifications.\n    fresh_when etag: [ Current.user.notifications, include_read? ]\n  end\n\n  private\n    def unread_notifications\n      Current.user.notifications.unread.preloaded.ordered.limit(MAX_ENTRIES_LIMIT)\n    end\n\n    def read_notifications\n      Current.user.notifications.read.preloaded.ordered.limit(MAX_ENTRIES_LIMIT)\n    end\n\n    def include_read?\n      ActiveModel::Type::Boolean.new.cast(params[:include_read])\n    end\nend\n"
  },
  {
    "path": "app/controllers/notifications/unsubscribes_controller.rb",
    "content": "class Notifications::UnsubscribesController < ApplicationController\n  allow_unauthenticated_access\n  skip_forgery_protection\n\n  before_action :set_user\n\n  def new\n  end\n\n  def create\n    @user.settings.bundle_email_never!\n    redirect_to notifications_unsubscribe_path(access_token: params[:access_token])\n  end\n\n  def show\n  end\n\n  private\n    def set_user\n      unless @user = User.find_by_token_for(:unsubscribe, params[:access_token])\n        redirect_to root_path, alert: \"Invalid unsubscribe link\"\n      end\n    end\nend\n"
  },
  {
    "path": "app/controllers/notifications_controller.rb",
    "content": "class NotificationsController < ApplicationController\n  MAX_UNREAD_NOTIFICATIONS = 500\n  MAX_UNREAD_NOTIFICATIONS_VIA_API = 100\n\n  def index\n    @unread = Current.user.notifications.unread.ordered.preloaded.limit(max_unread_notifications) unless current_page_param\n    set_page_and_extract_portion_from Current.user.notifications.read.ordered.preloaded\n\n    respond_to do |format|\n      format.turbo_stream if current_page_param # Allows read-all action to side step pagination\n      format.html\n      format.json\n    end\n  end\n\n  private\n    def max_unread_notifications\n      request.format.json? ? MAX_UNREAD_NOTIFICATIONS_VIA_API : MAX_UNREAD_NOTIFICATIONS\n    end\nend\n"
  },
  {
    "path": "app/controllers/prompts/boards/users_controller.rb",
    "content": "class Prompts::Boards::UsersController < ApplicationController\n  include BoardScoped\n\n  def index\n    @users = @board.users.active.alphabetically\n\n    if stale? etag: @users\n      render layout: false\n    end\n  end\nend\n"
  },
  {
    "path": "app/controllers/prompts/cards_controller.rb",
    "content": "class Prompts::CardsController < ApplicationController\n  MAX_RESULTS = 10\n\n  def index\n    @cards = if filter_param.present?\n      prepending_exact_matches_by_id(search_cards)\n    else\n      published_cards.latest\n    end\n\n    if stale? etag: @cards\n      render layout: false\n    end\n  end\n\n  private\n    def filter_param\n      params[:filter]\n    end\n\n    def search_cards\n      published_cards\n        .mentioning(params[:filter], user: Current.user)\n        .reverse_chronologically\n        .limit(MAX_RESULTS)\n    end\n\n    def published_cards\n      Current.user.accessible_cards.published\n    end\n\n    def prepending_exact_matches_by_id(cards)\n      if card_by_id = Current.user.accessible_cards.find_by(number: params[:filter])\n        [ card_by_id ] + cards\n      else\n        cards\n      end\n    end\nend\n"
  },
  {
    "path": "app/controllers/prompts/tags_controller.rb",
    "content": "class Prompts::TagsController < ApplicationController\n  def index\n    @tags = Current.account.tags.all.alphabetically\n\n    if stale? etag: @tags\n      render layout: false\n    end\n  end\nend\n"
  },
  {
    "path": "app/controllers/prompts/users_controller.rb",
    "content": "class Prompts::UsersController < ApplicationController\n  def index\n    @users = Current.account.users.active.alphabetically\n\n    if stale? etag: @users\n      render layout: false\n    end\n  end\nend\n"
  },
  {
    "path": "app/controllers/public/base_controller.rb",
    "content": "class Public::BaseController < ApplicationController\n  allow_unauthenticated_access\n\n  before_action :set_board, :set_card, :set_public_cache_expiration\n  before_action :ensure_board_accessible\n\n  layout \"public\"\n\n  private\n    def set_board\n      @board = Board.find_by_published_key(params[:board_id] || params[:id])\n    end\n\n    def set_card\n      @card = @board.cards.published.find_by!(number: params[:id]) if params[:board_id] && params[:id]\n    end\n\n    def set_public_cache_expiration\n      expires_in 30.seconds, public: true\n    end\n\n    def ensure_board_accessible\n      raise ActionController::RoutingError, \"Not Found\" if @board&.account&.cancelled?\n    end\nend\n"
  },
  {
    "path": "app/controllers/public/boards/columns/closeds_controller.rb",
    "content": "class Public::Boards::Columns::ClosedsController < Public::BaseController\n  def show\n    set_page_and_extract_portion_from @board.cards.closed.published.recently_closed_first\n  end\nend\n"
  },
  {
    "path": "app/controllers/public/boards/columns/not_nows_controller.rb",
    "content": "class Public::Boards::Columns::NotNowsController < Public::BaseController\n  def show\n    set_page_and_extract_portion_from @board.cards.postponed.latest\n  end\nend\n"
  },
  {
    "path": "app/controllers/public/boards/columns/streams_controller.rb",
    "content": "class Public::Boards::Columns::StreamsController < Public::BaseController\n  def show\n    set_page_and_extract_portion_from @board.cards.awaiting_triage.latest.with_golden_first\n  end\nend\n"
  },
  {
    "path": "app/controllers/public/boards/columns_controller.rb",
    "content": "class Public::Boards::ColumnsController < Public::BaseController\n  before_action :set_column\n\n  def show\n    set_page_and_extract_portion_from @column.cards.active.latest.with_golden_first\n  end\n\n  private\n    # Unlike the other public controllers, this is using params[:id] to fetch the column instead of the card\n    def set_card\n    end\n\n    def set_column\n      @column = @board.columns.find(params[:id])\n    end\nend\n"
  },
  {
    "path": "app/controllers/public/boards_controller.rb",
    "content": "class Public::BoardsController < Public::BaseController\n  def show\n    set_page_and_extract_portion_from @board.cards.awaiting_triage.latest.with_golden_first\n  end\nend\n"
  },
  {
    "path": "app/controllers/public/cards_controller.rb",
    "content": "class Public::CardsController < Public::BaseController\n  def show\n  end\nend\n"
  },
  {
    "path": "app/controllers/pwa_controller.rb",
    "content": "class PwaController < ApplicationController\n  disallow_account_scope\n  skip_forgery_protection\n\n  # We need a stable URL at the root, so we can't use the regular asset path here.\n  def service_worker\n  end\nend\n"
  },
  {
    "path": "app/controllers/qr_codes_controller.rb",
    "content": "class QrCodesController < ApplicationController\n  allow_unauthenticated_access\n\n  def show\n    expires_in 1.year, public: true\n\n    qr_code_svg = RQRCode::QRCode\n      .new(QrCodeLink.from_signed(params[:id]).url)\n      .as_svg(viewbox: true, fill: :white, color: :black, offset: 16)\n\n    render svg: qr_code_svg\n  end\nend\n"
  },
  {
    "path": "app/controllers/searches/queries_controller.rb",
    "content": "class Searches::QueriesController < ApplicationController\n  def create\n    Current.user.remember_search(params[:q])\n    head :ok\n  end\nend\n"
  },
  {
    "path": "app/controllers/searches_controller.rb",
    "content": "class SearchesController < ApplicationController\n  include Turbo::DriveHelper\n\n  def show\n    @query = params[:q].blank? ? nil : params[:q]\n\n    if card = Current.user.accessible_cards.find_by_id(@query)\n      respond_to do |format|\n        format.html { @card = card }\n        format.json { set_page_and_extract_portion_from Current.user.accessible_cards.where(id: card.id) }\n      end\n    else\n      respond_to do |format|\n        format.html do\n          set_page_and_extract_portion_from Current.user.search(@query)\n          @recent_search_queries = Current.user.search_queries.order(updated_at: :desc).limit(10)\n        end\n\n        format.json do\n          set_page_and_extract_portion_from \\\n            Current.user.accessible_cards.mentioning(@query, user: Current.user).distinct.latest.preloaded\n        end\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "app/controllers/sessions/magic_links_controller.rb",
    "content": "class Sessions::MagicLinksController < ApplicationController\n  disallow_account_scope\n  require_unauthenticated_access\n  rate_limit to: 10, within: 15.minutes, only: :create, with: :rate_limit_exceeded\n  before_action :ensure_that_email_address_pending_authentication_exists\n\n  layout \"public\"\n\n  def show\n  end\n\n  def create\n    if magic_link = MagicLink.consume(code)\n      authenticate magic_link\n    else\n      invalid_code\n    end\n  end\n\n  private\n    def ensure_that_email_address_pending_authentication_exists\n      unless email_address_pending_authentication.present?\n        alert_message = \"Enter your email address to sign in.\"\n        respond_to do |format|\n          format.html { redirect_to new_session_path, alert: alert_message }\n          format.json { render json: { message: alert_message }, status: :unauthorized }\n        end\n      end\n    end\n\n    def code\n      params.expect(:code)\n    end\n\n    def authenticate(magic_link)\n      if ActiveSupport::SecurityUtils.secure_compare(email_address_pending_authentication || \"\", magic_link.identity.email_address)\n        sign_in magic_link\n      else\n        email_address_mismatch\n      end\n    end\n\n    def sign_in(magic_link)\n      clear_pending_authentication_token\n      start_new_session_for magic_link.identity\n\n      respond_to do |format|\n        format.html { redirect_to after_sign_in_url(magic_link) }\n        format.json { render json: { session_token: session_token, requires_signup_completion: requires_signup_completion?(magic_link) } }\n      end\n    end\n\n    def email_address_mismatch\n      clear_pending_authentication_token\n      alert_message = \"Something went wrong. Please try again.\"\n\n      respond_to do |format|\n        format.html { redirect_to new_session_path, alert: alert_message }\n        format.json { render json: { message: alert_message }, status: :unauthorized }\n      end\n    end\n\n    def invalid_code\n      respond_to do |format|\n        format.html { redirect_to session_magic_link_path, flash: { shake: true } }\n        format.json { render json: { message: \"Try another code.\" }, status: :unauthorized }\n      end\n    end\n\n    def after_sign_in_url(magic_link)\n      if requires_signup_completion?(magic_link)\n        new_signup_completion_path\n      else\n        after_authentication_url\n      end\n    end\n\n    def rate_limit_exceeded\n      rate_limit_exceeded_message = \"Try again in 15 minutes.\"\n      respond_to do |format|\n        format.html { redirect_to session_magic_link_path, alert: rate_limit_exceeded_message }\n        format.json { render json: { message: rate_limit_exceeded_message }, status: :too_many_requests }\n      end\n    end\n\n    def requires_signup_completion?(magic_link)\n      magic_link.for_sign_up?\n    end\nend\n"
  },
  {
    "path": "app/controllers/sessions/menus_controller.rb",
    "content": "class Sessions::MenusController < ApplicationController\n  disallow_account_scope\n\n  layout \"public\"\n\n  def show\n    @accounts = Current.identity.accounts.active\n\n    if @accounts.one?\n      redirect_to root_url(script_name: @accounts.first.slug)\n    end\n  end\nend\n"
  },
  {
    "path": "app/controllers/sessions/passkeys_controller.rb",
    "content": "class Sessions::PasskeysController < ApplicationController\n  include ActionPack::Passkey::Request\n\n  disallow_account_scope\n  require_unauthenticated_access\n  rate_limit to: 10, within: 3.minutes, only: :create, with: :rate_limit_exceeded\n\n  def create\n    if credential = ActionPack::Passkey.authenticate(passkey_request_params)\n      start_new_session_for credential.holder\n\n      respond_to do |format|\n        format.html { redirect_to after_authentication_url }\n        format.json { render json: { session_token: session_token } }\n      end\n    else\n      respond_to do |format|\n        format.html { redirect_to new_session_path, alert: \"That passkey didn't work. Try again.\" }\n        format.json { render json: { message: \"That passkey didn't work. Try again.\" }, status: :unauthorized }\n      end\n    end\n  end\n\n  private\n    def rate_limit_exceeded\n      rate_limit_exceeded_message = \"Try again later.\"\n\n      respond_to do |format|\n        format.html { redirect_to new_session_path, alert: rate_limit_exceeded_message }\n        format.json { render json: { message: rate_limit_exceeded_message }, status: :too_many_requests }\n      end\n    end\nend\n"
  },
  {
    "path": "app/controllers/sessions/transfers_controller.rb",
    "content": "class Sessions::TransfersController < ApplicationController\n  disallow_account_scope\n  require_unauthenticated_access\n\n  def show\n  end\n\n  def update\n    if identity = Identity.find_by_transfer_id(params[:id])\n      start_new_session_for identity\n      redirect_to session_menu_path(script_name: nil)\n    else\n      head :bad_request\n    end\n  end\nend\n"
  },
  {
    "path": "app/controllers/sessions_controller.rb",
    "content": "class SessionsController < ApplicationController\n  include ActionPack::Passkey::Request\n\n  disallow_account_scope\n  require_unauthenticated_access except: :destroy\n  rate_limit to: 10, within: 3.minutes, only: :create, with: :rate_limit_exceeded\n\n  layout \"public\"\n\n  def new\n    @request_options = passkey_request_options\n  end\n\n  def create\n    if identity = Identity.find_by(email_address: email_address)\n      sign_in identity\n    elsif Account.accepting_signups?\n      sign_up\n    else\n      redirect_to_fake_session_magic_link email_address\n    end\n  end\n\n  def destroy\n    terminate_session\n\n    respond_to do |format|\n      format.html { redirect_to_logout_url }\n      format.json { head :no_content }\n    end\n  end\n\n  private\n    def magic_link_from_sign_in_or_sign_up\n      if identity = Identity.find_by_email_address(email_address)\n        identity.send_magic_link\n      else\n        signup = Signup.new(email_address: email_address)\n        signup.create_identity if signup.valid?(:identity_creation) && Account.accepting_signups?\n      end\n    end\n\n    def email_address\n      params.expect(:email_address)\n    end\n\n    def rate_limit_exceeded\n      rate_limit_exceeded_message = \"Try again later.\"\n\n      respond_to do |format|\n        format.html { redirect_to new_session_path, alert: rate_limit_exceeded_message }\n        format.json { render json: { message: rate_limit_exceeded_message }, status: :too_many_requests }\n      end\n    end\n\n    def sign_in(identity)\n      redirect_to_session_magic_link identity.send_magic_link\n    end\n\n    def sign_up\n      signup = Signup.new(email_address: email_address)\n\n      if signup.valid?(:identity_creation)\n        magic_link = signup.create_identity\n        redirect_to_session_magic_link magic_link\n      else\n        respond_to do |format|\n          format.html { redirect_to new_session_path, alert: \"Something went wrong\" }\n          format.json { render json: { message: \"Something went wrong\" }, status: :unprocessable_entity }\n        end\n      end\n    end\nend\n"
  },
  {
    "path": "app/controllers/signups/completions_controller.rb",
    "content": "class Signups::CompletionsController < ApplicationController\n  wrap_parameters :signup, include: %i[ full_name ]\n\n  layout \"public\"\n\n  disallow_account_scope\n\n  def new\n    @signup = Signup.new(identity: Current.identity)\n  end\n\n  def create\n    @signup = Signup.new(signup_params)\n\n    if @signup.complete\n      welcome_to_account\n    else\n      invalid_signup\n    end\n  end\n\n  private\n    def signup_params\n      params.expect(signup: %i[ full_name ]).with_defaults(identity: Current.identity)\n    end\n\n    def welcome_to_account\n      respond_to do |format|\n        format.html do\n          flash[:welcome_letter] = true\n          redirect_to landing_url(script_name: @signup.account.slug)\n        end\n\n        format.json { head :created }\n      end\n    end\n\n    def invalid_signup\n      respond_to do |format|\n        format.html { render :new, status: :unprocessable_entity }\n        format.json { render json: { errors: @signup.errors.full_messages }, status: :unprocessable_entity }\n      end\n    end\nend\n"
  },
  {
    "path": "app/controllers/signups_controller.rb",
    "content": "class SignupsController < ApplicationController\n  wrap_parameters :signup, include: %i[ email_address ]\n\n  disallow_account_scope\n  allow_unauthenticated_access\n  rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_signup_path, alert: \"Try again later.\" }\n  before_action :redirect_authenticated_user\n  before_action :enforce_tenant_limit\n\n  layout \"public\"\n\n  def new\n    @signup = Signup.new\n  end\n\n  def create\n    signup = Signup.new(signup_params)\n    if signup.valid?(:identity_creation)\n      redirect_to_session_magic_link signup.create_identity\n    else\n      head :unprocessable_entity\n    end\n  end\n\n  private\n    def redirect_authenticated_user\n      redirect_to new_signup_completion_path if authenticated?\n    end\n\n    def enforce_tenant_limit\n      redirect_to new_session_url unless Account.accepting_signups?\n    end\n\n    def signup_params\n      params.expect signup: :email_address\n    end\nend\n"
  },
  {
    "path": "app/controllers/tags_controller.rb",
    "content": "class TagsController < ApplicationController\n  def index\n    set_page_and_extract_portion_from Current.account.tags.alphabetically\n  end\nend\n"
  },
  {
    "path": "app/controllers/users/avatars_controller.rb",
    "content": "class Users::AvatarsController < ApplicationController\n  allow_unauthenticated_access only: :show\n\n  before_action :set_user\n  before_action :ensure_permission_to_administer_user, only: :destroy\n\n  def show\n    if @user.system?\n      redirect_to view_context.image_path(\"system_user.png\")\n    elsif @user.avatar.attached?\n      redirect_to rails_blob_path(@user.avatar_thumbnail, disposition: \"inline\")\n    elsif stale? @user, cache_control: cache_control\n      render_initials\n    end\n  end\n\n  def destroy\n    @user.avatar.destroy\n\n    respond_to do |format|\n      format.html { redirect_to @user }\n      format.json { head :no_content }\n    end\n  end\n\n  private\n    def set_user\n      @user = Current.account.users.find(params[:user_id])\n    end\n\n    def ensure_permission_to_administer_user\n      head :forbidden unless Current.user.can_change?(@user)\n    end\n\n    def cache_control\n      if @user == Current.user\n        {}\n      else\n        { max_age: 30.minutes, stale_while_revalidate: 1.week }\n      end\n    end\n\n    def render_initials\n      render formats: :svg\n    end\nend\n"
  },
  {
    "path": "app/controllers/users/data_exports_controller.rb",
    "content": "class Users::DataExportsController < ApplicationController\n  before_action :set_user\n  before_action :ensure_current_user\n  before_action :ensure_export_limit_not_exceeded, only: :create\n  before_action :set_export, only: :show\n\n  CURRENT_EXPORT_LIMIT = 10\n\n  def show\n  end\n\n  def create\n    @user.data_exports.create!(account: Current.account).build_later\n    redirect_to @user, notice: \"Export started. You'll receive an email when it's ready.\"\n  end\n\n  private\n    def set_user\n      @user = Current.account.users.find(params[:user_id])\n    end\n\n    def ensure_current_user\n      head :forbidden unless @user == Current.user\n    end\n\n    def ensure_export_limit_not_exceeded\n      head :too_many_requests if @user.data_exports.current.count >= CURRENT_EXPORT_LIMIT\n    end\n\n    def set_export\n      @export = @user.data_exports.completed.find_by(id: params[:id])\n    end\nend\n"
  },
  {
    "path": "app/controllers/users/email_addresses/confirmations_controller.rb",
    "content": "class Users::EmailAddresses::ConfirmationsController < ApplicationController\n  allow_unauthenticated_access\n\n  before_action :set_user\n  rate_limit to: 5, within: 1.hour, only: :create\n\n  def show\n  end\n\n  def create\n    if @user.change_email_address_using_token(token)\n      terminate_session if Current.session\n      start_new_session_for @user.identity\n\n      redirect_to edit_user_url(script_name: @user.account.slug, id: @user)\n    else\n      render :invalid_token, status: :unprocessable_entity\n    end\n  end\n\n  private\n    def set_user\n      @user = Current.account.users.active.find(params[:user_id])\n    end\n\n    def token\n      params.expect :email_address_token\n    end\nend\n"
  },
  {
    "path": "app/controllers/users/email_addresses_controller.rb",
    "content": "class Users::EmailAddressesController < ApplicationController\n  before_action :set_user\n  rate_limit to: 5, within: 1.hour, only: :create\n\n  def new\n  end\n\n  def create\n    identity = Identity.find_by_email_address(new_email_address)\n\n    if identity&.users&.exists?(account: @user.account)\n      flash[:alert] = \"You already have a user in this account with that email address\"\n      redirect_to new_user_email_address_path(@user)\n    else\n      @user.send_email_address_change_confirmation(new_email_address)\n    end\n  end\n\n  private\n    def set_user\n      @user = Current.identity.users.find(params[:user_id])\n    end\n\n    def new_email_address\n      params.expect :email_address\n    end\nend\n"
  },
  {
    "path": "app/controllers/users/events_controller.rb",
    "content": "class Users::EventsController < ApplicationController\n  include FilterScoped\n\n  before_action :set_user, :set_filter, :set_user_filtering\n\n  def show\n    @filter = Current.user.filters.new(creator_ids: [ @user.id ])\n    @day_timeline = Current.user.timeline_for(day_param, filter: @filter)\n\n    fresh_when @day_timeline\n  end\n\n  private\n    def set_user\n      @user = Current.account.users.active.find(params[:user_id])\n    end\n\n    def day_param\n      if params[:day].present?\n        Time.zone.parse(params[:day])\n      else\n        Time.current\n      end\n    end\nend\n"
  },
  {
    "path": "app/controllers/users/joins_controller.rb",
    "content": "class Users::JoinsController < ApplicationController\n  wrap_parameters :user, include: %i[ name avatar ]\n\n  layout \"public\"\n\n  def new\n  end\n\n  def create\n    Current.user.update!(user_params)\n\n    respond_to do |format|\n      format.html { redirect_to landing_path }\n      format.json { head :no_content }\n    end\n  end\n\n  private\n    def user_params\n      params.expect(user: [ :name, :avatar ])\n    end\nend\n"
  },
  {
    "path": "app/controllers/users/push_subscriptions_controller.rb",
    "content": "class Users::PushSubscriptionsController < ApplicationController\n  wrap_parameters :push_subscription, include: %i[ endpoint p256dh_key auth_key ]\n\n  before_action :set_push_subscriptions\n\n  def index\n  end\n\n  def create\n    subscription = @push_subscriptions.create_with(user_agent: request.user_agent).create_or_find_by!(push_subscription_params)\n\n    respond_to do |format|\n      format.html { head :no_content }\n      format.json { head :created }\n    end\n  end\n\n  def destroy\n    @push_subscriptions.destroy_by(id: params[:id])\n\n    respond_to do |format|\n      format.html { redirect_to user_push_subscriptions_url }\n      format.json { head :no_content }\n    end\n  end\n\n  private\n    def set_push_subscriptions\n      @push_subscriptions = Current.user.push_subscriptions\n    end\n\n    def push_subscription_params\n      params.require(:push_subscription).permit(:endpoint, :p256dh_key, :auth_key)\n    end\nend\n"
  },
  {
    "path": "app/controllers/users/roles_controller.rb",
    "content": "class Users::RolesController < ApplicationController\n  wrap_parameters :user, include: %i[ role ]\n\n  before_action :set_user\n  before_action :ensure_permission_to_administer_user\n\n  def update\n    @user.update!(role_params)\n\n    respond_to do |format|\n      format.html { redirect_to account_settings_path }\n      format.json { head :no_content }\n    end\n  end\n\n  private\n    def set_user\n      @user = Current.account.users.active.find(params[:user_id])\n    end\n\n    def ensure_permission_to_administer_user\n      head :forbidden unless Current.user.can_administer?(@user)\n    end\n\n    def role_params\n      { role: params.require(:user)[:role].presence_in(%w[ member admin ]) || \"member\" }\n    end\nend\n"
  },
  {
    "path": "app/controllers/users/verifications_controller.rb",
    "content": "class Users::VerificationsController < ApplicationController\n  layout \"public\"\n\n  def new\n  end\n\n  def create\n    Current.user.verify\n    redirect_to new_users_join_path\n  end\nend\n"
  },
  {
    "path": "app/controllers/users_controller.rb",
    "content": "class UsersController < ApplicationController\n  wrap_parameters :user, include: %i[ name avatar ]\n\n  before_action :set_user, except: %i[ index ]\n  before_action :ensure_permission_to_change_user, only: %i[ update destroy ]\n\n  def index\n    set_page_and_extract_portion_from Current.account.users.active.alphabetically.includes(:identity)\n  end\n\n  def show\n  end\n\n  def edit\n  end\n\n  def update\n    if @user.update(user_params)\n      respond_to do |format|\n        format.html { redirect_to @user }\n        format.json { head :no_content }\n      end\n    else\n      respond_to do |format|\n        format.html { render :edit, status: :unprocessable_entity }\n        format.json { render json: @user.errors, status: :unprocessable_entity }\n      end\n    end\n  end\n\n  def destroy\n    @user.deactivate\n\n    respond_to do |format|\n      format.html { redirect_to account_settings_path }\n      format.json { head :no_content }\n    end\n  end\n\n  private\n    def set_user\n      @user = Current.account.users.active.find(params[:id])\n    end\n\n    def ensure_permission_to_change_user\n      head :forbidden unless Current.user.can_change?(@user)\n    end\n\n    def user_params\n      params.expect(user: [ :name, :avatar ])\n    end\nend\n"
  },
  {
    "path": "app/controllers/webhooks/activations_controller.rb",
    "content": "class Webhooks::ActivationsController < ApplicationController\n  include BoardScoped\n\n  before_action :ensure_admin\n\n  def create\n    @webhook = @board.webhooks.find(params[:webhook_id])\n    @webhook.activate\n\n    respond_to do |format|\n      format.html { redirect_to @webhook }\n      format.json { render \"webhooks/show\", status: :created }\n    end\n  end\nend\n"
  },
  {
    "path": "app/controllers/webhooks_controller.rb",
    "content": "class WebhooksController < ApplicationController\n  wrap_parameters :webhook, include: %i[ name url subscribed_actions ]\n\n  include BoardScoped\n\n  before_action :ensure_admin\n  before_action :set_webhook, except: %i[ index new create ]\n\n  def index\n    set_page_and_extract_portion_from @board.webhooks.ordered\n  end\n\n  def show\n  end\n\n  def new\n    @webhook = @board.webhooks.new\n  end\n\n  def create\n    @webhook = @board.webhooks.new(webhook_params)\n\n    if @webhook.save\n      respond_to do |format|\n        format.html { redirect_to @webhook }\n        format.json { render :show, status: :created, location: board_webhook_url(@webhook.board, @webhook, format: :json) }\n      end\n    else\n      respond_to do |format|\n        format.html { render :new, status: :unprocessable_entity }\n        format.json { render json: @webhook.errors, status: :unprocessable_entity }\n      end\n    end\n  end\n\n  def edit\n  end\n\n  def update\n    if @webhook.update(webhook_params.except(:url))\n      respond_to do |format|\n        format.html { redirect_to @webhook }\n        format.json { render :show }\n      end\n    else\n      respond_to do |format|\n        format.html { render :edit, status: :unprocessable_entity }\n        format.json { render json: @webhook.errors, status: :unprocessable_entity }\n      end\n    end\n  end\n\n  def destroy\n    @webhook.destroy!\n\n    respond_to do |format|\n      format.html { redirect_to board_webhooks_path }\n      format.json { head :no_content }\n    end\n  end\n\n  private\n    def set_webhook\n      @webhook = @board.webhooks.find(params[:id])\n    end\n\n    def webhook_params\n      params\n        .expect(webhook: [ :name, :url, subscribed_actions: [] ])\n        .merge(board_id: @board.id)\n    end\nend\n"
  },
  {
    "path": "app/helpers/accesses_helper.rb",
    "content": "module AccessesHelper\n  def access_menu_tag(board, **options, &)\n    tag.menu class: [ options[:class], { \"toggler--toggled\": board.all_access? } ], data: {\n      controller: \"filter toggle-class navigable-list\",\n      action: \"keydown->navigable-list#navigate filter:changed->navigable-list#reset\",\n      navigable_list_focus_on_selection_value: true,\n      navigable_list_actionable_items_value: true,\n      toggle_class_toggle_class: \"toggler--toggled\" }, &\n  end\n\n  def access_toggles_for(users, selected:, disabled: false)\n    render partial: \"boards/access_toggle\",\n      collection: users, as: :user,\n      locals: { selected: selected, disabled: disabled },\n      cached: ->(user) { [ user, selected, disabled ] }\n  end\n\n  def access_involvement_advance_button(board, user, show_watchers: true, icon_only: false)\n    access = board.access_for(user)\n\n    turbo_frame_tag dom_id(board, :involvement_button) do\n      concat board_watchers_list(board) if show_watchers\n      concat involvement_button(board, access, show_watchers, icon_only)\n    end\n  end\n\n  def board_watchers_list(board)\n    watchers = board.watchers.with_avatars.load\n\n    displayed_watchers = watchers.first(8)\n    overflow_count = watchers.size - 8\n\n    tag.div(class: \"divider divider--fade\") do\n      tag.strong(watchers.any? ? \"Watching for new cards\" : \"No one is watching for new cards\", class: \"txt-uppercase\")\n    end +\n    tag.div(avatar_tags(displayed_watchers), class: \"board-tools__watching\") do\n      tag.div(data: { controller: \"dialog\", action: \"keydown.esc->dialog#close click@document->dialog#closeOnClickOutside\" }) do\n        tag.button(\"+#{overflow_count}\", class: \"overflow-count btn btn--circle borderless\", data: { action: \"dialog#open\" }, aria: { label: \"Show #{overflow_count} more watchers\" }) +\n        tag.dialog(avatar_tags(watchers), class: \"board-tools__watching-dialog dialog panel\", data: { dialog_target: \"dialog\" }, aria: { hidden: \"true\" })\n      end if overflow_count > 0\n    end\n  end\n\n  def involvement_button(board, access, show_watchers, icon_only)\n    label_text = access.access_only? ? \"Watch this\" : \"Stop watching\"\n    button_to(\n      board_involvement_path(board), method: :put,\n      params: { show_watchers: show_watchers, involvement: next_involvement(access.involvement), icon_only: icon_only },\n      aria: { labelledby: dom_id(board, :involvement_label) },\n      title: (label_text if icon_only),\n      class: class_names(\"btn\", { \"btn--reversed\": access.watching? && icon_only }),\n      data: !icon_only && { bridge__overflow_menu_target: \"item\", bridge_title: label_text }) do\n        icon_tag(\"notification-bell-#{icon_only ? 'reverse-' : nil}#{access.involvement.dasherize}\") +\n        tag.span(label_text, class: class_names(\"txt-nowrap txt-uppercase\", \"for-screen-reader\": icon_only), id: dom_id(board, :involvement_label))\n    end\n  end\n\n  private\n    def next_involvement(involvement)\n      order = %w[ watching access_only ]\n      order[(order.index(involvement.to_s) + 1) % order.size]\n    end\nend\n"
  },
  {
    "path": "app/helpers/application_helper.rb",
    "content": "module ApplicationHelper\n  def page_title_tag\n    account_name = if Current.account && Current.session&.identity&.users&.many?\n      Current.account&.name\n    end\n    tag.title [ @page_title, account_name, \"Fizzy\" ].compact.join(\" | \")\n  end\n\n  def icon_tag(name, **options)\n    tag.span class: class_names(\"icon icon--#{name}\", options.delete(:class)), \"aria-hidden\": true, **options\n  end\n\n  def back_link_to(label, url, action, prefer_referrer: [], **options)\n    data = { controller: \"hotkey\", action: action }\n    if prefer_referrer.any?\n      data[:turbo_navigation_target] = \"referrerBackLink\"\n      data[:turbo_navigation_allowed_referrer_paths] = prefer_referrer.join(\",\")\n    end\n    link_to url, class: \"btn btn--back btn--circle-mobile\", data: data, **options do\n      icon_tag(\"arrow-left\") + tag.strong(\"Back to #{label}\", class: \"overflow-ellipsis\") + tag.kbd(\"ESC\", class: \"txt-x-small hide-on-touch\").html_safe\n    end\n  end\nend\n"
  },
  {
    "path": "app/helpers/avatars_helper.rb",
    "content": "module AvatarsHelper\n  def avatar_background_color(user)\n    user.avatar_background_color\n  end\n\n  def avatar_tag(user, hidden_for_screen_reader: false, **options)\n    link_to user_path(user), class: class_names(\"avatar btn btn--circle\", options.delete(:class)), data: { turbo_frame: \"_top\" },\n      aria: { hidden: hidden_for_screen_reader, label: user.name },\n      tabindex: hidden_for_screen_reader ? -1 : nil,\n      **options do\n      avatar_image_tag(user)\n    end\n  end\n\n  def avatar_tags(users, **options)\n    users.collect { avatar_tag(it, **options) }.join.html_safe\n  end\n\n  def mail_avatar_tag(user, size: 48, **options)\n    if user.avatar.attached?\n      image_tag user_avatar_url(user), alt: user.name, class: \"avatar\", size: size, **options\n    else\n      tag.span class: \"avatar\", style: \"background-color: #{avatar_background_color(user)};\" do\n        user.initials\n      end\n    end\n  end\n\n  def avatar_preview_tag(user, hidden_for_screen_reader: false, **options)\n    tag.span class: class_names(\"avatar\", options.delete(:class)),\n      aria: { hidden: hidden_for_screen_reader, label: user.name },\n      tabindex: hidden_for_screen_reader ? -1 : nil do\n      avatar_image_tag(user, **options)\n    end\n  end\n\n  def avatar_image_tag(user, **options)\n    image_tag user_avatar_path(user, script_name: user.account.slug), aria: { hidden: \"true\" }, size: 48, title: user.name, **options\n  end\nend\n"
  },
  {
    "path": "app/helpers/boards_helper.rb",
    "content": "module BoardsHelper\n  def link_back_to_board(board, prefer_referrer: [])\n    back_link_to board.name, board, \"keydown.left@document->hotkey#click keydown.esc@document->hotkey#click click->turbo-navigation#backIfSamePath\", prefer_referrer:\n  end\n\n  def link_to_edit_board(board)\n    link_to edit_board_path(board), class: \"btn btn--circle-mobile\",\n      data: { controller: \"tooltip\", bridge__overflow_menu_target: \"item\", bridge_title: \"Board settings\" } do\n      icon_tag(\"settings\") + tag.span(\"Settings for #{board.name}\", class: \"for-screen-reader\")\n    end\n  end\nend\n"
  },
  {
    "path": "app/helpers/bridge_helper.rb",
    "content": "module BridgeHelper\n  def bridge_icon(name)\n    asset_url(\"#{name}.svg\")\n  end\n\n  def bridged_button_to_board(board)\n    link_to \"Go to #{board.name}\", board, hidden: true, data: {\n      bridge__buttons_target: \"button\",\n      bridge_icon_url: bridge_icon(\"board\"),\n      bridge_title: \"Go to #{board.name}\"\n    }\n  end\n\n  def bridged_share_url_button(description = nil)\n    tag.button \"Share\", hidden: true, data: {\n      controller: \"bridge--share\",\n      action: \"bridge--share#shareUrl\",\n      bridge__overflow_menu_target: \"item\",\n      bridge_title: \"Share\",\n      bridge_share_description: description\n    }\n  end\n\n  def bridge_share_card_description(card)\n    date_added = card.created_at.strftime(\"%b %e\")\n    date_updated = card.last_active_at.strftime(\"%b %e\")\n    author = card.creator.familiar_name\n    assignees = card.assignees.any? ? \"assigned to #{card.assignees.map { |assignee| h assignee.familiar_name }.to_sentence}\" : \"not assigned\"\n    \"Added #{date_added} by #{author} and #{assignees}. Updated #{date_updated}\"\n  end\n\n  def bridge_share_board_description(board)\n    count_open = board.cards.active.count\n    count_in_stream = board.cards.awaiting_triage.count\n    \"#{count_open} open cards, #{count_in_stream} in MAYBE?\"\n  end\nend\n"
  },
  {
    "path": "app/helpers/cards_helper.rb",
    "content": "module CardsHelper\n  def card_article_tag(card, id: dom_id(card, :article), data: {}, **options, &block)\n    classes = [\n      options.delete(:class),\n      (\"golden-effect\" if card.golden?),\n      (\"card--postponed\" if card.postponed?),\n      (\"card--active\" if card.active?)\n    ].compact.join(\" \")\n\n    data[:drag_and_drop_top] = true if card.golden? && !card.closed? && !card.postponed?\n\n    tag.article \\\n      id: id,\n      style: \"--card-color: #{card.color}; view-transition-name: #{id}\",\n      class: classes,\n      data: data,\n      **options,\n      &block\n  end\n\n  def card_title_tag(card)\n    title = [\n      card.title,\n      \"added by #{card.creator.name}\",\n      \"in #{card.board.name}\"\n    ]\n    title << \"assigned to #{card.assignees.map(&:name).to_sentence}\" if card.assignees.any?\n    title.join(\" \")\n  end\n\n  def card_drafted_or_added(card)\n    card.drafted? ? \"Drafted\" : \"Added\"\n  end\n\n  def card_social_tags(card)\n    tag.meta(property: \"og:title\", content: \"#{card.title} | #{card.board.name}\") +\n    tag.meta(property: \"og:description\", content: format_excerpt(card&.description, length: 200)) +\n    tag.meta(property: \"og:image\", content: card.image.attached? ? \"#{request.base_url}#{url_for(card.image)}\" : \"#{request.base_url}/opengraph.png\") +\n    tag.meta(property: \"og:url\", content: card_url(card))\n  end\n\n  def button_to_remove_card_image(card)\n    button_to(card_image_path(card), method: :delete, class: \"btn\", data: { controller: \"tooltip\", action: \"dialog#close\" }) do\n      icon_tag(\"trash\") + tag.span(\"Remove background image\", class: \"for-screen-reader\")\n    end\n  end\nend\n"
  },
  {
    "path": "app/helpers/clipboard_helper.rb",
    "content": "module ClipboardHelper\n  def button_to_copy_to_clipboard(url, &)\n    tag.button class: \"btn\", data: {\n      controller: \"copy-to-clipboard tooltip\", action: \"copy-to-clipboard#copy\",\n      copy_to_clipboard_success_class: \"btn--success\", copy_to_clipboard_content_value: url\n    }, &\n  end\nend\n"
  },
  {
    "path": "app/helpers/columns_helper.rb",
    "content": "module ColumnsHelper\n  def button_to_set_column(card, column)\n    button_to \\\n      tag.span(column.name, class: \"overflow-ellipsis\"),\n      card_triage_path(card, column_id: column),\n      method: :post,\n      class: [ \"card__column-name btn\", { \"card__column-name--current\": column == card.column && card.open? } ],\n      disabled: column == card.column && card.open?,\n      style: \"--column-color: #{column.color}\",\n      form_class: \"flex gap-half\",\n      data: { turbo_frame: \"_top\", scroll_to_target: column == card.column && card.open? ? \"target\" : nil }\n  end\n\n  def column_tag(id:, name:, drop_url:, collapsed: true, selected: nil, card_color: \"var(--color-card-default)\", data: {}, **properties, &block)\n    classes = token_list(\"cards\", properties.delete(:class), \"is-collapsed\": collapsed, \"is-expanded\": !collapsed)\n    hotkeys_disabled = data[:card_hotkeys_disabled]\n\n    data = {\n      drag_and_drop_target: \"container\",\n      navigable_list_target: \"item\",\n      column_name: name,\n      drag_and_drop_url: drop_url,\n      drag_and_drop_css_variable_name: \"--card-color\",\n      drag_and_drop_css_variable_value: card_color\n    }.merge(data)\n\n    data[:action] = token_list(\n      \"turbo:before-morph-attribute->collapsible-columns#preventToggle\",\n      \"focus->navigable-list#select\",\n      data.delete(:action)\n    )\n\n    tag.section(id: id, class: classes, tabindex: \"0\", \"aria-selected\": selected, data: data, **properties) do\n      tag.div(class: \"cards__transition-container\", data: {\n        controller: \"navigable-list css-variable-counter\",\n        css_variable_counter_property_name_value: \"--card-count\",\n        navigable_list_supports_horizontal_navigation_value: \"false\",\n        navigable_list_prevent_handled_keys_value: \"true\",\n        navigable_list_auto_select_value: \"false\",\n        navigable_list_actionable_items_value: \"true\",\n        navigable_list_only_act_on_focused_items_value: \"true\",\n        card_hotkeys_disabled: hotkeys_disabled,\n        action: \"keydown->navigable-list#navigate\"\n      }, &block)\n    end\n  end\n\n  def column_frame_tag(id, src: nil, data: {}, **options, &block)\n    data = data.with_defaults \\\n      drag_and_drop_refresh: true,\n      controller: \"frame\",\n      action: \"turbo:before-frame-render->frame#morphRender turbo:before-morph-element->frame#morphReload\"\n    options[:refresh] = :morph if src.present?\n    turbo_frame_tag(id, src: src, data: data, **options, &block)\n  end\nend\n"
  },
  {
    "path": "app/helpers/comments_helper.rb",
    "content": "module CommentsHelper\n  def new_comment_placeholder(card)\n    if card.creator == Current.user && card.comments.empty?\n      \"Next, add some notes, context, pictures, or video about this…\"\n    else\n      \"Type your comment…\"\n    end\n  end\nend\n"
  },
  {
    "path": "app/helpers/emoji_helper.rb",
    "content": "module EmojiHelper\n  REACTIONS = {\n    \"👏\" => \"Clapping\",\n    \"👍\" => \"Thumbs up\",\n    \"🙌\" => \"Hands raised in celebration\",\n    \"💪\" => \"Flexed bicep\",\n    \"🤘\" => \"Sign of the horns\",\n    \"✊\" => \"Raised fist\",\n    \"✨\" => \"Sparkles\",\n    \"❤️\" => \"Red heart\",\n    \"💯\" => \"100 points\",\n    \"🎉\" => \"Party popper\",\n    \"🤩\" => \"Face with starry eyes\",\n    \"🥳\" => \"Partying face\",\n    \"😊\" => \"Smiling face with flush cheeks\",\n    \"😀\" => \"Grinning face\",\n    \"😂\" => \"Face with tears of joy\",\n    \"😅\" => \"Grinning face with sweat drop\",\n    \"😎\" => \"Smiling face with sunglasses\",\n    \"😉\" => \"Winking face\",\n    \"😜\" => \"Winking face with stuck out tongue\",\n    \"😬\" => \"Grimacing face\",\n    \"😮\" => \"Surprised face with open mouth\",\n    \"😳\" => \"Flushed face\",\n    \"🤔\" => \"Thinking face\",\n    \"😒\" => \"Unamused face\",\n    \"😢\" => \"Crying face\",\n    \"😭\" => \"Loudly crying face\",\n    \"😱\" => \"Face screaming in fear\",\n    \"👀\" => \"Eyes\",\n    \"🙏\" => \"Hands pressed together\",\n    \"💩\" => \"Pile of poop\",\n    \"👎\" => \"Thumbs down\",\n    \"✌️\" => \"Peace\",\n    \"👈\" => \"Finger pointing left\",\n    \"👆\" => \"Finger pointing Up\",\n    \"✋\" => \"Raised hand\",\n    \"👋\" => \"Waving hand\",\n    \"☀️\" => \"Sun\",\n    \"🌙\" => \"Moon\",\n    \"💥\" => \"Collision\",\n    \"🔥\" => \"Fire\",\n    \"🎂\" => \"Birthday cake\",\n    \"🍴\" => \"Fork and knife\",\n    \"💰\" => \"Money bag\",\n    \"🥇\" => \"Gold medal\",\n    \"🚨\" => \"Red flashing light\",\n    \"💡\" => \"Light bulb\",\n    \"🛠\" => \"Hammer and wrench\",\n    \"📈\" => \"Chart with upward trend\",\n    \"✅\" => \"Check mark\",\n    \"📢\" => \"Public address loudspeaker\"\n  }\nend\n"
  },
  {
    "path": "app/helpers/entropy_helper.rb",
    "content": "module EntropyHelper\n  def entropy_bubble_options_for(card)\n    {\n      daysBeforeReminder: card.entropy.days_before_reminder,\n      closesAt: card.entropy.auto_clean_at.iso8601,\n      action: \"Closes\"\n    }\n  end\n\n  def stalled_bubble_options_for(card)\n    if card.last_activity_spike_at\n      {\n        stalledAfterDays: card.entropy.days_before_reminder,\n        lastActivitySpikeAt: card.last_activity_spike_at.iso8601,\n        updatedAt: card.updated_at.iso8601,\n        action: \"Stalled\"\n      }\n    end\n  end\nend\n"
  },
  {
    "path": "app/helpers/events_helper.rb",
    "content": "module EventsHelper\n  def event_action_icon(event)\n    case event.action\n    when \"card_assigned\"\n      \"assigned\"\n    when \"card_unassigned\"\n      \"minus\"\n    when \"comment_created\"\n      \"comment\"\n    when \"card_title_changed\"\n      \"rename\"\n    when \"card_board_changed\", \"card_triaged\", \"card_postponed\", \"card_auto_postponed\"\n      \"move\"\n    else\n      \"person\"\n    end\n  end\n\n  def events_at_hour_container(column, hour, &block)\n    tag.div class: \"events__time-block\", style: \"grid-area: #{25 - hour}/#{column.index}\", &block\n  end\nend\n"
  },
  {
    "path": "app/helpers/excerpt_helper.rb",
    "content": "module ExcerptHelper\n  def format_excerpt(content, length: 200)\n    return \"\" if content.blank?\n\n    text = content.respond_to?(:to_plain_text) ? content.to_plain_text : content.to_s\n    text = text.gsub(/^>\\s*(.*)$/m, '> \\1')\n    text = text.gsub(/^\\s*[-+]\\s*(.*)$/m, '• \\1')\n    text = text.gsub(/^\\d+\\.\\s*(.*)$/m) { |m| m }\n    text = text.gsub(/\\s+/, \" \").strip\n    text.truncate(length)\n  end\nend\n"
  },
  {
    "path": "app/helpers/filters_helper.rb",
    "content": "module FiltersHelper\n  def filter_chip_tag(text, params)\n    link_to cards_path(params), class: \"btn txt-x-small btn--remove fill-selected flex-inline\" do\n      concat tag.span(text)\n      concat icon_tag(\"close\")\n    end\n  end\n\n  def filter_hidden_field_tag(key, value)\n    name = params[key].is_a?(Array) ? \"#{key}[]\" : key\n    hidden_field_tag name, value, id: nil\n  end\n\n  def filter_selected_boards_title(user_filtering)\n    user_filtering.selected_board_titles.collect { tag.strong it }.to_sentence.html_safe\n  end\n\n  def filter_place_menu_item(path, label, icon, new_window: false, current: false, turbo: true)\n    link_to_params = {}\n    link_to_params.merge!({ target: \"_blank\" }) if new_window\n    link_to_params.merge!({ data: { turbo: false } }) unless turbo\n\n    tag.li class: \"popup__item\", id: \"filter-place-#{label.parameterize}\", data: { filter_target: \"item\", navigable_list_target: \"item\" }, aria: { checked: current } do\n      concat icon_tag(icon, class: \"popup__icon\")\n      concat(link_to(path, link_to_params.merge(class: \"popup__btn btn\"), data: { turbo: turbo }) do\n        concat tag.span(label, class: \"overflow-ellipsis\")\n        concat icon_tag(\"check\", class: \"checked flex-item-justify-end\", \"aria-hidden\": true)\n      end)\n    end\n  end\n\n  def filter_dialog(label, &block)\n    tag.dialog class: \"margin-block-start-half popup panel flex-column align-start gap-half fill-white shadow txt-small\", data: {\n      action: \"turbo:before-cache@document->dialog#close keydown->navigable-list#navigate filter:changed->navigable-list#reset toggle->filter#filter\",\n      aria: { label: label, aria_description: label },\n      controller: \"navigable-list\",\n      dialog_target: \"dialog\",\n      navigable_list_focus_on_selection_value: false,\n      navigable_list_actionable_items_value: true\n    }, &block\n  end\n\n  def filter_title(title)\n    tag.strong title, class: \"popup__title pad-inline-half\", tabindex: \"-1\", data: { dialog_target: \"focusTouch\" }\n  end\n\n  def collapsible_nav_section(title, **properties, &block)\n    tag.details class: \"nav__section popup__section\", data: { action: \"toggle->nav-section-expander#toggle\", nav_section_expander_target: \"section\", nav_section_expander_key_value: title.parameterize }, open: true, **properties do\n      concat(tag.summary(class: \"popup__section-title\") do\n        concat icon_tag \"caret-down\"\n        concat title\n      end)\n      concat(tag.ul(class: \"popup__list\") do\n        capture(&block)\n      end)\n    end\n  end\n\n  def filter_hotkey_link(title, path, key, icon)\n    link_to path, class: \"popup__item btn borderless\", id: \"filter-hotkey-#{key}\", role: \"listitem\", data: { filter_target: \"item\", navigable_list_target: \"item\", controller: \"hotkey\", action: \"keydown.#{key}@document->hotkey#click keydown.shift+#{key}@document->hotkey#click\" } do\n      concat icon_tag(icon)\n      concat tag.span(title.html_safe)\n      concat tag.kbd(key)\n    end\n  end\n\n  def sorted_by_label(sort_value)\n    case sort_value\n    when \"newest\"\n      \"Newest to oldest\"\n    when \"oldest\"\n      \"Oldest to newest\"\n    when \"latest\"\n      \"Recently updated\"\n    else\n      sort_value.humanize\n    end\n  end\nend\n"
  },
  {
    "path": "app/helpers/forms_helper.rb",
    "content": "module FormsHelper\n  def auto_submit_form_with(**attributes, &)\n    data = attributes.delete(:data) || {}\n    data[:controller] = \"auto-submit #{data[:controller]}\".strip\n\n    if block_given?\n      form_with **attributes, data: data, &\n    else\n      form_with(**attributes, data: data) { }\n    end\n  end\n\n  def bridged_form_with(**attributes, &)\n    data = attributes.delete(:data) || {}\n    controllers = [ data[:controller], \"bridge--form\" ].compact.join(\" \").strip\n    actions = [\n      data[:action],\n      \"turbo:submit-start->bridge--form#submitStart\",\n      \"turbo:submit-end->bridge--form#submitEnd\"\n    ].compact.join(\" \").strip\n\n    data[:controller] = controllers\n    data[:action] = actions\n\n    if block_given?\n      form_with **attributes, data: data, &\n    else\n      form_with(**attributes, data: data) { }\n    end\n  end\nend\n"
  },
  {
    "path": "app/helpers/hotkeys_helper.rb",
    "content": "module HotkeysHelper\n  # Pass in an array of chorded keys, e.g. [\"ctrl\", \"shift\", \"J\"]\n  def hotkey_label(hotkey)\n    hotkey.map do |key|\n      if key == \"ctrl\" && platform.mac?\n        \"⌘\"\n      elsif key == \"enter\"\n        platform.mac? ? \"return\" : \"enter\"\n      else\n        key\n      end.capitalize\n    end.join(\"+\").gsub(/⌘\\+/, \"⌘\")\n  end\nend\n"
  },
  {
    "path": "app/helpers/html_helper.rb",
    "content": "module HtmlHelper\n  def format_html(html)\n    Loofah::HTML5::DocumentFragment.parse(html).scrub!(AutoLinkScrubber.new).to_html.html_safe\n  end\n\n  def card_html_title(card)\n    return card.title if card.title.blank?\n\n    ERB::Util.html_escape(card.title).gsub(/`([^`]+)`/, '<code>\\1</code>').html_safe\n  end\nend\n"
  },
  {
    "path": "app/helpers/login_helper.rb",
    "content": "module LoginHelper\n  def login_url\n    main_app.new_session_path(script_name: nil)\n  end\n\n  def logout_url\n    main_app.new_session_path\n  end\n\n  def redirect_to_login_url\n    redirect_to login_url, allow_other_host: true\n  end\n\n  def redirect_to_logout_url\n    redirect_to logout_url, allow_other_host: true\n  end\nend\n"
  },
  {
    "path": "app/helpers/messages_helper.rb",
    "content": "module MessagesHelper\n  def messages_tag(card, &)\n    turbo_frame_tag dom_id(card, :messages),\n      class: \"comments gap center\",\n      style: \"--card-color: #{card.color}\",\n      role: \"group\",\n      aria: { label: \"Messages\" },\n      data: { controller: \"toggle-class\", toggle_class_toggle_class: \"comments--system-expanded\" }, &\n  end\nend\n"
  },
  {
    "path": "app/helpers/my/menu_helper.rb",
    "content": "module My::MenuHelper\n  def jump_field_tag\n    text_field_tag :search, nil,\n      type: \"search\",\n      role: \"combobox\",\n      placeholder: \"Type to jump to a board, person, place, or tag…\",\n      class: \"input input--transparent txt-small\",\n      autofocus: true,\n      autocorrect: \"off\",\n      autocomplete: \"off\",\n      aria: { activedescendant: \"\" },\n      data: {\n        \"1p-ignore\": \"true\",\n        dialog_target: \"focusMouse\",\n        filter_target: \"input\",\n        nav_section_expander_target: \"input\",\n        navigable_list_target: \"input\",\n        action: \"input->filter#filter\" }\n  end\n\n  def my_menu_board_item(board)\n    my_menu_item(\"board\", board) do\n      link_to(tag.span(board.name, class: \"overflow-ellipsis\"), board, class: \"popup__btn btn\")\n    end\n  end\n\n  def my_menu_tag_item(the_tag)\n    my_menu_item(\"tag\", tag) do\n      link_to(tag.span(class: \"overflow-ellipsis\") do\n        tag.span(\"##{the_tag.title}\", class: \"visually-hidden\") + the_tag.title\n      end, cards_path(tag_ids: [ the_tag ]), class: \"popup__btn btn\", title: \"##{the_tag.title}\")\n    end\n  end\n\n  def my_menu_user_item(user)\n    my_menu_item(\"person\", user) do\n      link_to(tag.span(user.name, class: \"overflow-ellipsis\"), user, class: \"popup__btn btn\")\n    end\n  end\n\n  def my_menu_filter_item(filter)\n    my_menu_item(\"bookmark\", filter) do\n      link_to(cards_path(filter_id: filter.id), class: \"popup__btn btn\") do\n        tag.div(class: \"txt-tight-lines min-width txt-small overflow-ellipsis\") do\n          tag.div(tag.strong(filter.boards_label)) +\n          tag.div(filter.summary, class: \"txt-capitalize\")\n        end\n      end\n    end\n  end\n\n  def my_menu_item(item, record)\n    tag.li(class: \"popup__item\", data: { filter_target: \"item\", navigable_list_target: \"item\", id: \"filter-#{item}-#{record.id}\" }) do\n      icon_tag(item, class: \"popup__icon\") + yield\n    end\n  end\nend\n"
  },
  {
    "path": "app/helpers/notifications_helper.rb",
    "content": "module NotificationsHelper\n  def event_notification_title(event)\n    case event_notification_action(event)\n    when \"comment_created\" then \"RE: #{card_notification_title(event.eventable.card)}\"\n    else card_notification_title(event.eventable)\n    end\n  end\n\n  def event_notification_body(event)\n    creator = event.creator.name\n\n    case event_notification_action(event)\n    when \"card_assigned\" then \"Assigned to #{event.assignees.none? ? \"self\" : event.assignees.pluck(:name).to_sentence}\"\n    when \"card_unassigned\" then \"Unassigned by #{creator}\"\n    when \"card_published\" then \"Added by #{creator}\"\n    when \"card_closed\" then \"Moved to Done by #{creator}\"\n    when \"card_reopened\" then \"Reopened by #{creator}\"\n    when \"card_postponed\" then \"Moved to Not Now by #{creator}\"\n    when \"card_auto_postponed\" then \"Moved to Not Now due to inactivity\"\n    when \"card_title_changed\" then \"Renamed by #{creator}\"\n    when \"card_board_changed\" then \"Moved by #{creator}\"\n    when \"card_triaged\" then \"Moved to #{event.particulars.dig(\"particulars\", \"column\")} by #{creator}\"\n    when \"card_sent_back_to_triage\" then \"Moved back to Maybe? by #{creator}\"\n    when \"comment_created\" then comment_notification_body(event)\n    else creator\n    end\n  end\n\n  def notification_tag(notification, &)\n    tag.div id: dom_id(notification), class: \"tray__item tray__item--notification\", data: {\n      navigable_list_target: \"item\",\n      card_id: notification.card.id\n    } do\n      link_to(notification,\n        class: [ \"card card--notification\", { \"card--closed\": notification.card.closed? }, { \"unread\": !notification.read? } ],\n        data: { turbo_frame: \"_top\", badge_target: \"unread\", action: \"badge#update dialog#close\" },\n        style: { \"--card-color:\": notification.card.color },\n        &)\n    end\n  end\n\n  def notification_toggle_read_button(notification, url:)\n    if notification.read?\n      button_to url,\n          method: :delete,\n          class: \"card__notification-unread-indicator btn btn--circle borderless\",\n          title: \"Mark as unread\",\n          data: { action: \"form#submit:stop badge#update:stop\", form_target: \"submit\" },\n          form: { data: { controller: \"form\" } } do\n        concat(icon_tag(\"unseen\"))\n      end\n    else\n      button_to url,\n          class: \"card__notification-unread-indicator btn btn--circle borderless\",\n          title: \"Mark as read\",\n          data: { action: \"form#submit:stop badge#update:stop\", form_target: \"submit\" },\n          form: { data: { controller: \"form\" } } do\n        concat(icon_tag(\"remove\"))\n        concat(tag.span(notification.unread_count, class: \"badge-count\")) if notification.unread_count > 1\n      end\n    end\n  end\n\n  def notifications_next_page_link(page)\n    unless @page.last?\n      tag.div id: \"next_page\", data: { controller: \"fetch-on-visible\", fetch_on_visible_url_value: notifications_path(page: @page.next_param) }\n    end\n  end\n\n  def bundle_email_frequency_options_for(settings)\n    options_for_select([\n      [ \"Never\", \"never\" ],\n      [ \"Every few hours\", \"every_few_hours\" ],\n      [ \"Every day\", \"daily\" ],\n      [ \"Every week\", \"weekly\" ]\n    ], settings.bundle_email_frequency)\n  end\n\n  private\n    def event_notification_action(event)\n      if event.action.card_published? && event.eventable.assigned_to?(event.creator)\n        \"card_assigned\"\n      else\n        event.action\n      end\n    end\n\n    def comment_notification_body(event)\n      comment = event.eventable\n      comment.body.to_plain_text.truncate(200)\n    end\n\n    def card_notification_title(card)\n      card.title.presence || \"Card #{card.number}\"\n    end\nend\n"
  },
  {
    "path": "app/helpers/pagination_helper.rb",
    "content": "module PaginationHelper\n  def pagination_frame_tag(namespace, page, data: {}, **attributes, &)\n    turbo_frame_tag pagination_frame_id_for(namespace, page.number), data: { timeline_target: \"frame\", **data }, role: \"presentation\", **attributes, &\n  end\n\n  def link_to_next_page(namespace, page, activate_when_observed: false, label: default_pagination_label(activate_when_observed), data: {}, **attributes)\n    if page.before_last? && !params[:previous]\n      attributes[:class] = class_names(attributes[:class], \"btn txt-small center-block center\": !activate_when_observed)\n      pagination_link(namespace, page.number + 1, label: label, activate_when_observed: activate_when_observed, data: data, **attributes)\n    end\n  end\n\n  def pagination_link(namespace, page_number, activate_when_observed: false, label: default_pagination_label(activate_when_observed), url_params: {}, data: {}, **attributes)\n    link_to label, url_for(params.permit!.to_h.merge(page: page_number, **url_params)),\n      \"aria-label\": \"Load page #{page_number}\",\n      id: \"#{namespace}-pagination-link-#{page_number}\",\n      class: class_names(attributes.delete(:class), \"pagination-link\", { \"pagination-link--active-when-observed\" => activate_when_observed }),\n      data: {\n        frame: pagination_frame_id_for(namespace, page_number),\n        pagination_target: \"paginationLink\",\n        action: (\"click->pagination#loadPage:prevent\" unless activate_when_observed),\n        **data\n      },\n      **attributes\n  end\n\n  def pagination_frame_id_for(namespace, page_number)\n    \"#{namespace}-pagination-contents-#{page_number}\"\n  end\n\n  def with_manual_pagination(name, page, **properties)\n    pagination_list name, **properties do\n      concat(pagination_frame_tag(name, page) do\n        yield\n        concat link_to_next_page(name, page)\n      end)\n    end\n  end\n\n  def with_automatic_pagination(name, page, **properties)\n    pagination_list name, paginate_on_scroll: true, **properties do\n      concat(pagination_frame_tag(name, page) do\n        yield\n        concat link_to_next_page(name, page, activate_when_observed: true)\n      end)\n    end\n  end\n\n  def day_timeline_pagination_frame_tag(day_timeline, &)\n    turbo_frame_tag day_timeline_pagination_frame_id_for(day_timeline.day), data: { timeline_target: \"frame\" }, role: \"presentation\", refresh: :morph, &\n  end\n\n  def day_timeline_pagination_frame_id_for(day)\n    \"day-timeline-pagination-contents-#{day.strftime(\"%Y-%m-%d\")}\"\n  end\n\n  def day_timeline_pagination_link(day_timeline, filter)\n    if day_timeline.next_day\n      link_to \"Load more…\", events_days_path(day: day_timeline.next_day.strftime(\"%Y-%m-%d\"), **filter.as_params),\n        class: \"day-timeline-pagination-link\", data: { frame: day_timeline_pagination_frame_id_for(day_timeline.next_day), pagination_target: \"paginationLink\" }\n    end\n  end\n\n  private\n    def pagination_list(name, tag_element: :div, paginate_on_scroll: false, **properties, &block)\n      classes = properties.delete(:class)\n      properties[:id] ||= \"#{name}-pagination-list\"\n      tag.public_send tag_element,\n        class: token_list(name, \"display-contents\", classes),\n        data: { controller: \"pagination\", pagination_paginate_on_intersection_value: paginate_on_scroll },\n        **properties,\n        &block\n    end\n\n    def default_pagination_label(activate_when_observed)\n      \"Load more…\"\n    end\nend\n"
  },
  {
    "path": "app/helpers/qr_codes_helper.rb",
    "content": "module QrCodesHelper\n  def qr_code_image(url)\n    qr_code_link = QrCodeLink.new(url)\n    image_tag qr_code_path(qr_code_link.signed), class: \"qr-code center\", alt: \"QR Code\"\n  end\nend\n"
  },
  {
    "path": "app/helpers/reactions_helper.rb",
    "content": "module ReactionsHelper\n  def reaction_path_prefix_for(reactable)\n    case reactable\n    when Card then [ reactable ]\n    when Comment then [ reactable.card, reactable ]\n    else\n      raise ArgumentError, \"Unknown reactable type: #{reactable.class}\"\n    end\n  end\nend\n"
  },
  {
    "path": "app/helpers/rich_text_helper.rb",
    "content": "module RichTextHelper\n  def mentions_prompt(board)\n    content_tag \"lexxy-prompt\", \"\", trigger: \"@\", src: prompts_board_users_path(board), name: \"mention\"\n  end\n\n  def global_mentions_prompt\n    content_tag \"lexxy-prompt\", \"\", trigger: \"@\", src: prompts_users_path, name: \"mention\"\n  end\n\n  def tags_prompt\n    content_tag \"lexxy-prompt\", \"\", trigger: \"#\", src: prompts_tags_path, name: \"tag\"\n  end\n\n  def cards_prompt\n    content_tag \"lexxy-prompt\", \"\", trigger: \"#\", src: prompts_cards_path, name: \"card\", \"insert-editable-text\": true, \"remote-filtering\": true, \"supports-space-in-searches\": true\n  end\n\n  def general_prompts(board)\n    safe_join([ mentions_prompt(board), cards_prompt ])\n  end\nend\n"
  },
  {
    "path": "app/helpers/tenanting_helper.rb",
    "content": "module TenantingHelper\n  def tenanted_action_cable_meta_tag\n    tag \"meta\",\n        name: \"action-cable-url\",\n        content: \"#{request.script_name}#{ActionCable.server.config.mount_path}\"\n  end\nend\n"
  },
  {
    "path": "app/helpers/time_helper.rb",
    "content": "module TimeHelper\n  def local_datetime_tag(datetime, style: :time, **attributes)\n    # Render empty space to ensure it takes height until the local time is loaded via JS\n    tag.time \"&nbsp;\".html_safe, **attributes, datetime: datetime.to_i, data: { local_time_target: style, action: \"turbo:morph-element->local-time#refreshTarget\" }\n  end\nend\n"
  },
  {
    "path": "app/helpers/users_helper.rb",
    "content": "module UsersHelper\n  def role_display_name(user)\n    case user.role\n    when \"admin\" then \"Administrator\"\n    else user.role.titleize\n    end\n  end\nend\n"
  },
  {
    "path": "app/helpers/webhooks_helper.rb",
    "content": "module WebhooksHelper\n  ACTION_LABELS = {\n    card_published: \"Card added\",\n    card_title_changed: \"Card title changed\",\n    card_board_changed: \"Card board changed\",\n    comment_created: \"Comment added\",\n    card_assigned: \"Card assigned\",\n    card_unassigned: \"Card unassigned\",\n    card_triaged: \"Card column changed\",\n    card_closed: \"Card moved to “Done”\",\n    card_reopened: \"Card reopened\",\n    card_postponed: \"Card moved to “Not Now”\",\n    card_auto_postponed: \"Card moved to “Not Now” due to inactivity\",\n    card_sent_back_to_triage: \"Card moved back to “Maybe?”\"\n  }.with_indifferent_access.freeze\n\n  def webhook_action_options(actions = Webhook::PERMITTED_ACTIONS)\n    ACTION_LABELS.select { |key, _| actions.include?(key.to_s) }\n  end\n\n  def webhook_action_label(action)\n    ACTION_LABELS[action] || action.to_s.humanize\n  end\n\n  def link_to_webhooks(board, &)\n    link_to board_webhooks_path(board_id: board),\n        class: [ \"btn btn--circle-mobile\", { \"btn--reversed\": board.webhooks.any? } ],\n        data: { controller: \"tooltip\", bridge__overflow_menu_target: \"item\", bridge_title: \"Webhooks\" } do\n      icon_tag(\"world\") + tag.span(\"Webhooks\", class: \"for-screen-reader\")\n    end\n  end\nend\n"
  },
  {
    "path": "app/javascript/application.js",
    "content": "// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails\nimport \"@hotwired/turbo-rails\"\nimport \"@hotwired/hotwire-native-bridge\"\nimport \"initializers\"\nimport \"controllers\"\n\nimport \"lexxy\"\nimport \"@rails/actiontext\"\nimport \"lib/action_pack/passkey\"\n"
  },
  {
    "path": "app/javascript/controllers/application.js",
    "content": "import { Application } from \"@hotwired/stimulus\"\n\nconst application = Application.start()\n\n// Configure Stimulus development experience\napplication.debug = false\nwindow.Stimulus   = application\n\nexport { application }\n"
  },
  {
    "path": "app/javascript/controllers/assignment_limit_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n  static values = { limit: Number, count: Number }\n  static targets = [\"unassigned\", \"limitMessage\"]\n\n  connect() {\n    this.updateState()\n  }\n\n  countValueChanged() {\n    this.updateState()\n  }\n\n  updateState() {\n    const atLimit = this.countValue >= this.limitValue\n\n    this.unassignedTargets.forEach(el => {\n      el.hidden = atLimit\n    })\n\n    if (this.hasLimitMessageTarget) {\n      this.limitMessageTarget.hidden = !atLimit\n    }\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/auto_click_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n  connect() {\n    this.element.click()\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/auto_save_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\nimport { submitForm } from \"helpers/form_helpers\"\n\nconst AUTOSAVE_INTERVAL = 3000\n\nexport default class extends Controller {\n  #timer\n\n  // Lifecycle\n\n  disconnect() {\n    this.submit()\n  }\n\n  // Actions\n\n  async submit() {\n    if (this.#dirty) {\n      await this.#save()\n    }\n  }\n\n  change(event) {\n    if (event.target.form === this.element && !this.#dirty) {\n      this.#scheduleSave()\n    }\n  }\n\n  // Private\n\n  #scheduleSave() {\n    this.#timer = setTimeout(() => this.#save(), AUTOSAVE_INTERVAL)\n  }\n\n  async #save() {\n    this.#resetTimer()\n    await submitForm(this.element)\n  }\n\n  #resetTimer() {\n    clearTimeout(this.#timer)\n    this.#timer = null\n  }\n\n  get #dirty() {\n    return !!this.#timer\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/auto_submit_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n  connect() {\n    this.element.addEventListener(\"turbo:submit-end\", this.#handleSubmitEnd.bind(this), { once: true })\n    this.submit()\n  }\n\n  submit() {\n    this.#markAsBusy()\n    this.#disableSubmit()\n    this.element.requestSubmit()\n  }\n\n  #handleSubmitEnd(event) {\n    if (event.detail.success) {\n      this.element.remove()\n    } else {\n      this.#clearBusy()\n      this.#enableSubmit()\n    }\n  }\n\n  #markAsBusy() {\n    this.element.setAttribute(\"aria-busy\", \"true\")\n  }\n\n  #clearBusy() {\n    this.element.setAttribute(\"aria-busy\", \"false\")\n  }\n\n  #disableSubmit() {\n    this.#submitElements().forEach(element => element.disabled = true)\n  }\n\n  #enableSubmit() {\n    this.#submitElements().forEach(element => element.disabled = false)\n  }\n\n  #submitElements() {\n    return this.element.querySelectorAll(\"input[type=submit],button\")\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/autoresize_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n  static targets = [\"textarea\", \"wrapper\"]\n\n  connect() {\n    this.resize()\n  }\n\n  resize() {\n    this.wrapperTarget.setAttribute(\"data-autoresize-clone-value\", this.textareaTarget.value)\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/badge_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\nimport { onNextEventLoopTick } from \"helpers/timing_helpers\"\n\nexport default class extends Controller {\n  static targets = [ \"unread\" ]\n  static classes = [ \"unread\" ]\n\n  connect() {\n    onNextEventLoopTick(() => this.update())\n  }\n\n  update() {\n    onNextEventLoopTick(() => {\n      if (this.#available) {\n        const unreadCount = this.#unreadCount\n\n        if (unreadCount > 0) {\n          navigator.setAppBadge(unreadCount)\n        } else {\n          navigator.clearAppBadge()\n        }\n      }\n    })\n  }\n\n  clear() {\n    onNextEventLoopTick(() => {\n      if (this.#available) {\n        navigator.clearAppBadge()\n      }\n    })\n  }\n\n  get #unreadCount() {\n    return this.unreadTargets.filter(unreadTarget => unreadTarget.classList.contains(this.unreadClass)).length\n  }\n\n  get #available() {\n    return \"setAppBadge\" in navigator\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/bar_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\nimport { post } from \"@rails/request.js\"\nimport { nextFrame } from \"helpers/timing_helpers\";\n\nexport default class extends Controller {\n  static targets = [ \"turboFrame\", \"search\", \"searchInput\", \"form\", \"buttonsContainer\" ]\n  static outlets = [ \"dialog\" ]\n  static values = {\n    searchUrl: String,\n  }\n\n  dialogOutletConnected(outlet, element) {\n    outlet.close()\n    this.#clearTurboFrame()\n  }\n\n  reset() {\n    this.dialogOutlet.close()\n    this.#clearTurboFrame()\n\n    this.#showItem(this.buttonsContainerTarget)\n    this.#hideItem(this.searchTarget)\n  }\n\n  showModalAndSubmit(event) {\n    this.showModal()\n    this.formTarget.requestSubmit()\n    this.#restoreFocusAfterTurboFrameLoads()\n  }\n\n  showModal() {\n    this.dialogOutlet.open()\n  }\n\n  search(event) {\n    this.#showItem(this.searchTarget)\n    this.#hideItem(this.buttonsContainerTarget)\n\n    if (this.searchInputTarget.value.trim()) {\n      this.showModalAndSubmit()\n    } else {\n      this.#loadTurboFrame()\n    }\n  }\n\n  #restoreFocusAfterTurboFrameLoads() {\n    this.turboFrameTarget.addEventListener(\"turbo:frame-load\", () => {\n      this.searchInputTarget.focus()\n    }, { once: true })\n  }\n\n  #loadTurboFrame() {\n    this.turboFrameTarget.src = this.searchUrlValue\n  }\n\n  #clearTurboFrame() {\n    this.turboFrameTarget.removeAttribute(\"src\")\n    this.turboFrameTarget.innerHtml = \"\"\n  }\n\n  async #showItem(element) {\n    element.removeAttribute(\"hidden\")\n\n    const autofocusElement = element.querySelector(\"[autofocus]\")\n\n    autofocusElement?.focus()\n    await nextFrame()\n    autofocusElement?.select()\n  }\n\n  #hideItem(element) {\n    element.setAttribute(\"hidden\", \"hidden\")\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/beacon_controller.js",
    "content": "import { post } from \"@rails/request.js\"\nimport { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n  static values = { url: String }\n\n  connect() {\n    this.#sendBeacon()\n    this.onVisibilityChange = this.#sendBeacon.bind(this);\n    document.addEventListener(\"visibilitychange\", this.onVisibilityChange)\n  }\n\n  disconnect() {\n    this.#sendBeacon()\n    document.removeEventListener(\"visibilitychange\", this.onVisibilityChange)\n  }\n\n  #sendBeacon() {\n    if (!document.hidden) {\n      post(this.urlValue, { responseKind: \"turbo-stream\" })\n    }\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/boards_form_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\nimport { nextEventLoopTick } from \"helpers/timing_helpers\";\n\nexport default class extends Controller {\n  static targets = [\"meCheckbox\"]\n  static values = { selfRemovalPromptMessage: { type: String, default: \"Are you sure?\" } }\n\n  async submitWithWarning(event) {\n    if (this.hasMeCheckboxTarget && !this.meCheckboxTarget.checked && !this.confirmed) {\n      event.detail.formSubmission.stop()\n\n      const message = this.selfRemovalPromptMessageValue\n\n      if (confirm(message)) {\n        await nextEventLoopTick()\n        this.confirmed = true\n        this.element.requestSubmit()\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/bridge/buttons_controller.js",
    "content": "import { BridgeComponent } from \"@hotwired/hotwire-native-bridge\"\nimport { BridgeElement } from \"@hotwired/hotwire-native-bridge\"\n\nexport default class extends BridgeComponent {\n  static component = \"buttons\"\n  static targets = [ \"button\" ]\n\n  connect() {\n    super.connect()\n\n    if (!this.beforeUnloadHandler) {\n      this.beforeUnloadHandler = this.handleBeforeUnload.bind(this)\n    }\n\n    window.addEventListener(\"beforeunload\", this.beforeUnloadHandler)\n  }\n\n  disconnect() {\n    super.disconnect()\n\n    if (this.beforeUnloadHandler) {\n      window.removeEventListener(\"beforeunload\", this.beforeUnloadHandler)\n    }\n\n    this.notifyBridgeOfDisconnect()\n  }\n\n  buttonTargetConnected() {\n    this.notifyBridgeOfConnect()\n  }\n\n  buttonTargetDisconnected() {\n    if (!this.#isControllerTearingDown()) {\n      this.notifyBridgeOfConnect()\n    }\n  }\n\n  notifyBridgeOfConnect() {\n    const buttons = this.#enabledButtonTargets\n      .map((target, index) => {\n        const element = new BridgeElement(target)\n        return { ...element.getButton(), index }\n    })\n\n    this.send(\"connect\", { buttons }, message => {\n      this.#clickButton(message)\n    })\n  }\n\n  notifyBridgeOfDisconnect() {\n    this.send(\"disconnect\")\n  }\n\n  handleBeforeUnload() {\n    this.notifyBridgeOfDisconnect()\n  }\n\n  #clickButton(message) {\n    const selectedIndex = message.data.selectedIndex\n    this.#enabledButtonTargets[selectedIndex].click()\n  }\n\n  get #enabledButtonTargets() {\n    return this.buttonTargets\n      .filter(target => !target.closest(\"[data-bridge-disabled]\"))\n  }\n\n  #isControllerTearingDown() {\n    return !document.body.contains(this.element)\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/bridge/form_controller.js",
    "content": "import { BridgeComponent } from \"@hotwired/hotwire-native-bridge\"\nimport { BridgeElement } from \"@hotwired/hotwire-native-bridge\"\n\nexport default class extends BridgeComponent {\n  static component = \"form\"\n  static targets = [ \"submit\", \"cancel\" ]\n  static values = { submitTitle: String }\n\n  connect() {\n    super.connect()\n\n    if (!this.beforeUnloadHandler) {\n      this.beforeUnloadHandler = this.handleBeforeUnload.bind(this)\n    }\n\n    window.addEventListener(\"beforeunload\", this.beforeUnloadHandler)\n  }\n\n  disconnect() {\n    super.disconnect()\n\n    if (this.beforeUnloadHandler) {\n      window.removeEventListener(\"beforeunload\", this.beforeUnloadHandler)\n    }\n  }\n\n  submitTargetConnected() {\n    this.notifyBridgeOfConnect()\n    this.#observeSubmitTarget()\n  }\n\n  submitTargetDisconnected() {\n    this.notifyBridgeOfDisconnect()\n    this.submitObserver?.disconnect()\n  }\n\n  notifyBridgeOfConnect() {\n    const submitElement = new BridgeElement(this.submitTarget)\n    const cancelElement = this.hasCancelTarget ? new BridgeElement(this.cancelTarget) : null\n\n    const submitButton = { title: submitElement.title }\n    const cancelButton = cancelElement ? { title: cancelElement.title } : null\n\n    this.send(\"connect\", { submitButton, cancelButton }, message => this.receive(message))\n  }\n\n  receive(message) {\n    switch (message.event) {\n      case \"submit\":\n        this.submitTarget.click()\n        break\n      case \"cancel\":\n        this.cancelTarget.click()\n        break\n    }\n  }\n\n  notifyBridgeOfDisconnect() {\n    this.send(\"disconnect\")\n  }\n\n  submitStart() {\n    this.send(\"submitStart\")\n  }\n\n  submitEnd() {\n    this.send(\"submitEnd\")\n  }\n\n  handleBeforeUnload() {\n    this.notifyBridgeOfDisconnect()\n  }\n\n  #observeSubmitTarget() {\n    this.submitObserver = new MutationObserver(() => {\n      this.send(this.submitTarget.disabled ? \"submitDisabled\" : \"submitEnabled\")\n    })\n\n    this.submitObserver.observe(this.submitTarget, {\n      attributes: true,\n      attributeFilter: [ \"disabled\" ]\n    })\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/bridge/insets_controller.js",
    "content": "import { BridgeComponent } from \"@hotwired/hotwire-native-bridge\"\n\n// Bridge component to control custom safe-area insets from native apps.\n// Sets CSS variables --injected-safe-inset-(top|right|bottom|left).\nexport default class extends BridgeComponent {\n  static component = \"insets\"\n\n  connect() {\n    super.connect()\n    this.notifyBridgeOfConnect()\n  }\n\n  disconnect() {\n    super.disconnect()\n    this.send(\"disconnect\")\n  }\n\n  notifyBridgeOfConnect() {\n    this.send(\"connect\", {}, message => {\n      this.#setInsets(message.data)\n    })\n  }\n\n  #setInsets({ top, right, bottom, left }) {\n    const root = document.documentElement.style\n    root.setProperty(\"--injected-safe-inset-top\", `${top}px`)\n    root.setProperty(\"--injected-safe-inset-right\", `${right}px`)\n    root.setProperty(\"--injected-safe-inset-bottom\", `${bottom}px`)\n    root.setProperty(\"--injected-safe-inset-left\", `${left}px`)\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/bridge/overflow_menu_controller.js",
    "content": "import { BridgeComponent } from \"@hotwired/hotwire-native-bridge\"\nimport { BridgeElement } from \"@hotwired/hotwire-native-bridge\"\n\nexport default class extends BridgeComponent {\n  static component = \"overflow-menu\"\n  static targets = [ \"item\" ]\n\n  itemTargetConnected() {\n    this.notifyBridgeOfConnect()\n  }\n\n  itemTargetDisconnected() {\n    if (!this.#isControllerTearingDown) {\n      this.notifyBridgeOfConnect()\n    }\n  }\n\n  notifyBridgeOfConnect() {\n    const items = this.#enabledItemTargets\n      .map((target, index) => {\n        const element = new BridgeElement(target)\n        return { title: element.title, index }\n      })\n\n    this.send(\"connect\", { items }, message => {\n      this.#clickItem(message)\n    })\n  }\n\n  #clickItem(message) {\n    const selectedIndex = message.data.selectedIndex\n    this.#enabledItemTargets[selectedIndex].click()\n  }\n\n  get #enabledItemTargets() {\n    return this.itemTargets\n      .filter(target => !target.closest(\"[data-bridge-disabled]\"))\n  }\n\n  #isControllerTearingDown() {\n    return !document.body.contains(this.element)\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/bridge/share_controller.js",
    "content": "import { BridgeComponent } from \"@hotwired/hotwire-native-bridge\"\n\nexport default class extends BridgeComponent {\n  static component = \"share\"\n\n  shareUrl() {\n    const description = this.bridgeElement.bridgeAttribute(\"share-description\")\n    this.send(\"shareUrl\", {\n      title: document.title,\n      url: window.location.href,\n      description: description\n    })\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/bridge/stamp_controller.js",
    "content": "import { BridgeComponent } from \"@hotwired/hotwire-native-bridge\"\n\nexport default class extends BridgeComponent {\n  static component = \"stamp\"\n  static values = { scopeSelector: { type: String, default: \"body\" } }\n\n  connect() {\n    super.connect()\n\n    if (this.element.closest(this.scopeSelectorValue)) {\n      this.notifyBridgeOfConnect()\n      this.#observeStamp()\n    }\n  }\n\n  disconnect() {\n    super.disconnect()\n    this.notifyBridgeOfDisconnect()\n    this.stampObserver?.disconnect()\n  }\n\n  notifyBridgeOfConnect() {\n    const bridgeElement = this.bridgeElement\n\n    this.send(\"connect\", {\n      title: bridgeElement.title,\n      description: bridgeElement.bridgeAttribute(\"description\")\n    })\n  }\n\n  notifyBridgeOfDisconnect() {\n    this.send(\"disconnect\")\n  }\n\n  #observeStamp() {\n    this.stampObserver = new MutationObserver(() => {\n      this.notifyBridgeOfConnect()\n    })\n\n    this.stampObserver.observe(this.element, {\n      attributes: true\n    })\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/bridge/text_size_controller.js",
    "content": "import { BridgeComponent } from \"@hotwired/hotwire-native-bridge\"\n\nexport default class extends BridgeComponent {\n  static component = \"text-size\"\n\n  connect() {\n    super.connect()\n    this.notifyBridgeOfConnect()\n  }\n\n  disconnect() {\n    super.disconnect()\n    this.send(\"disconnect\")\n  }\n\n  notifyBridgeOfConnect() {\n    this.send(\"connect\", {}, message => {\n      this.#setTextSize(message.data)\n    })\n  }\n\n  #setTextSize(data) {\n    document.documentElement.dataset.textSize = data.textSize\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/bridge/title_controller.js",
    "content": "import { BridgeComponent } from \"@hotwired/hotwire-native-bridge\"\nimport { viewport } from \"helpers/bridge/viewport_helpers\"\nimport { nextFrame } from \"helpers/timing_helpers\"\n\nexport default class extends BridgeComponent {\n  static component = \"title\"\n  static targets = [ \"header\" ]\n  static values = { title: String }\n\n  async connect() {\n    super.connect()\n    await nextFrame()\n    this.#startObserver()\n    window.addEventListener(\"resize\", this.#windowResized)\n  }\n\n  disconnect() {\n    super.disconnect()\n    this.#stopObserver()\n    window.removeEventListener(\"resize\", this.#windowResized)\n  }\n\n  notifyBridgeOfVisibilityChange(visible) {\n    this.send(\"visibility\", { title: this.#title, elementVisible: visible })\n  }\n\n  // Intersection Observer\n\n  #startObserver() {\n    if (!this.hasHeaderTarget) return\n\n    this.observer = new IntersectionObserver(([ entry ]) =>\n      this.notifyBridgeOfVisibilityChange(entry.isIntersecting),\n      { rootMargin: `-${this.#topOffset}px 0px 0px 0px` }\n    )\n\n    this.observer.observe(this.headerTarget)\n    this.previousTopOffset = this.#topOffset\n  }\n\n  #stopObserver() {\n    this.observer?.disconnect()\n  }\n\n  #updateObserverIfNeeded() {\n    if (this.#topOffset === this.previousTopOffset) return\n\n    this.#stopObserver()\n    this.#startObserver()\n  }\n\n  #windowResized = () => {\n    this.#updateObserverIfNeeded()\n  }\n\n  get #title() {\n    return this.titleValue ? this.titleValue : document.title\n  }\n\n  get #topOffset() {\n    return viewport.top\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/bubble_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\nimport { signedDifferenceInDays } from \"helpers/date_helpers\"\n\nconst REFRESH_INTERVAL = 3_600_000 // 1 hour (in milliseconds)\n\nexport default class extends Controller {\n  static targets = [ \"entropy\", \"stalled\", \"top\", \"center\", \"bottom\" ]\n  static values = { entropy: Object, stalled: Object }\n\n  #timer\n\n  connect() {\n    this.#timer = setInterval(this.update.bind(this), REFRESH_INTERVAL)\n    this.update()\n  }\n\n  disconnect() {\n    clearInterval(this.#timer)\n  }\n\n  update() {\n    if (this.#hasEntropy) {\n      this.#showEntropy()\n    } else if (this.#isStalled) {\n      this.#showStalled()\n    } else {\n      this.#hide()\n    }\n  }\n\n  get #hasEntropy() {\n    return this.#entropyCleanupInDays < this.entropyValue.daysBeforeReminder\n  }\n\n  get #entropyCleanupInDays() {\n    return signedDifferenceInDays(new Date(), new Date(this.entropyValue.closesAt))\n  }\n\n  #showEntropy() {\n    this.#render({\n      top: this.#entropyCleanupInDays < 1 ? this.entropyValue.action : `${this.entropyValue.action} in`,\n      center: this.#entropyCleanupInDays < 1 ? \"!\" : this.#entropyCleanupInDays,\n      bottom: this.#entropyCleanupInDays < 1 ? \"Today\" : (this.#entropyCleanupInDays === 1 ? \"day\" : \"days\"),\n    })\n  }\n\n  #render({ top, center, bottom }) {\n    this.topTarget.innerHTML = top\n    this.centerTarget.innerHTML = center\n    this.bottomTarget.innerHTML = bottom\n\n    this.#show()\n  }\n\n  // Keep in sync with Card::Stallable#stalled? in app/models/card/stallable.rb\n  get #isStalled() {\n    return this.stalledValue.lastActivitySpikeAt &&\n      signedDifferenceInDays(new Date(this.stalledValue.lastActivitySpikeAt), new Date()) > this.stalledValue.stalledAfterDays &&\n      signedDifferenceInDays(new Date(this.stalledValue.updatedAt), new Date()) > this.stalledValue.stalledAfterDays\n  }\n\n  #showStalled() {\n    this.#render({\n      top: \"Stalled for\",\n      center: signedDifferenceInDays(new Date(this.stalledValue.lastActivitySpikeAt), new Date()),\n      bottom: \"days\"\n    })\n  }\n\n  #hide() {\n    this.element.toggleAttribute(\"hidden\", true)\n  }\n\n  #show() {\n    this.element.removeAttribute(\"hidden\")\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/card_hotkeys_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\nimport { post } from \"@rails/request.js\"\n\nexport default class extends Controller {\n  static outlets = [ \"navigable-list\" ]\n\n  connect() {\n    this.morphCompletePromise = null\n    this.morphCompleteResolver = null\n  }\n\n  handleKeydown(event) {\n    if (this.#shouldIgnore(event) || this.#hasModifier(event)) return\n\n    const handler = this.#keyHandlers[event.key.toLowerCase()]\n    if (handler) {\n      handler.call(this, event)\n    }\n  }\n\n  // Called when turbo:morph completes - resolves our waiting promise\n  handleMorphComplete() {\n    if (this.morphCompleteResolver) {\n      this.morphCompleteResolver()\n      this.morphCompleteResolver = null\n      this.morphCompletePromise = null\n    }\n  }\n\n  // Private\n\n  #shouldIgnore(event) {\n    const target = event.target\n    return target.tagName === \"INPUT\" ||\n           target.tagName === \"TEXTAREA\" ||\n           target.isContentEditable ||\n           target.closest(\"input, textarea, [contenteditable], lexxy-editor\")\n  }\n\n  #hasModifier(event) {\n    return event.metaKey || event.ctrlKey || event.altKey || event.shiftKey\n  }\n\n  get #selectedCard() {\n    // Find the navigable-list that currently has focus\n    const focusedList = this.navigableListOutlets.find(list => list.hasFocus)\n    if (!focusedList) return null\n\n    const currentItem = focusedList.currentItem\n    if (currentItem?.classList.contains(\"card\") && !this.#hotkeysDisabled(focusedList)) {\n      return { card: currentItem, controller: focusedList }\n    }\n    return null\n  }\n\n  async #postponeCard(event) {\n    const selection = this.#selectedCard\n    if (!selection) return\n\n    const url = selection.card.dataset.cardNotNowUrl\n    if (url) {\n      event.preventDefault()\n      await this.#performCardAction(url, selection)\n    }\n  }\n\n  async #closeCard(event) {\n    const selection = this.#selectedCard\n    if (!selection) return\n\n    const url = selection.card.dataset.cardClosureUrl\n    if (url) {\n      event.preventDefault()\n      await this.#performCardAction(url, selection)\n    }\n  }\n\n  async #assignToMe(event) {\n    const selection = this.#selectedCard\n    if (!selection) return\n\n    const url = selection.card.dataset.cardAssignToSelfUrl\n    if (url) {\n      event.preventDefault()\n      await post(url, { responseKind: \"turbo-stream\" })\n    }\n  }\n\n  async #performCardAction(url, selection) {\n    const { controller } = selection\n    const visibleItems = controller.visibleItems\n    const currentIndex = visibleItems.indexOf(selection.card)\n    const wasLastItem = currentIndex === visibleItems.length - 1\n\n    // Set up promise to wait for morph completion\n    this.morphCompletePromise = new Promise(resolve => {\n      this.morphCompleteResolver = resolve\n    })\n\n    await post(url, { responseKind: \"turbo-stream\" })\n\n    // Wait for Turbo Stream morph to complete\n    await Promise.race([\n      this.morphCompletePromise,\n      new Promise(resolve => setTimeout(resolve, 200)) // Fallback timeout\n    ])\n\n    // Select the next card (or previous if it was the last)\n    const newVisibleItems = controller.visibleItems\n    if (newVisibleItems.length === 0) {\n      controller.clearSelection()\n      return\n    }\n\n    if (wasLastItem) {\n      controller.selectLast()\n    } else {\n      const nextIndex = Math.min(currentIndex, newVisibleItems.length - 1)\n      if (newVisibleItems[nextIndex]) {\n        await controller.selectItem(newVisibleItems[nextIndex])\n      }\n    }\n  }\n\n  #hotkeysDisabled(navigableList) {\n    return navigableList?.element.dataset.cardHotkeysDisabled === \"true\"\n  }\n\n  #keyHandlers = {\n    \"[\"(event) { this.#postponeCard(event) },\n    \"]\"(event) { this.#closeCard(event) },\n    m(event) { this.#assignToMe(event) }\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/clear_offline_cache_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\nimport { Turbo } from \"@hotwired/turbo-rails\"\n\nexport default class extends Controller {\n  clearCache() {\n    Turbo.offline.clearCache()\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/clicker_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\nimport { nextFrame } from \"helpers/timing_helpers\";\n\nexport default class extends Controller {\n  static targets = [ \"clickable\" ]\n\n  async click() {\n    await nextFrame()\n    this.#clickable.click()\n  }\n\n  get #clickable() {\n    return this.hasClickableTarget ? this.clickableTarget : this.element\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/collapsible_columns_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\nimport { nextFrame, debounce } from \"helpers/timing_helpers\";\nimport { isNative } from \"helpers/platform_helpers\";\n\nexport default class extends Controller {\n  static classes = [ \"collapsed\", \"expanded\", \"noTransitions\", \"titleNotVisible\" ]\n  static targets = [ \"column\", \"button\", \"title\", \"maybeColumn\" ]\n  static values = {\n    board: String,\n    desktopBreakpoint: { type: String, default: \"(min-width: 640px)\" }\n  }\n\n  initialize() {\n    this.restoreState = debounce(this.restoreState.bind(this), 10)\n  }\n\n  async connect() {\n    this.mediaQuery = window.matchMedia(this.desktopBreakpointValue)\n    this.handlePlatform = this.#handlePlatform.bind(this)\n    this.mediaQuery.addEventListener(\"change\", this.handlePlatform)\n\n    await this.#restoreColumnsDisablingTransitions()\n    this.#setupIntersectionObserver()\n  }\n\n  disconnect() {\n    if (this._intersectionObserver) {\n      this._intersectionObserver.disconnect()\n      this._intersectionObserver = null\n    }\n    this.mediaQuery.removeEventListener(\"change\", this.handlePlatform)\n  }\n\n  toggle({ target }) {\n    const column = target.closest('[data-collapsible-columns-target~=\"column\"]')\n    this.#toggleColumn(column);\n  }\n\n  preventToggle(event) {\n    if (event.target.hasAttribute(\"data-collapsible-columns-target\") && event.detail.attributeName === \"class\") {\n      event.preventDefault()\n    }\n  }\n\n  async restoreState(event) {\n    await nextFrame()\n    await this.#restoreColumnsDisablingTransitions()\n  }\n\n  focusOnColumn({ target }) {\n    if (this.#isDesktop && this.#isCollapsed(target)) {\n      this.#collapseAllExcept(target)\n      this.#expand({ column: target })\n    }\n  }\n\n  frameColumnOnMobile(event) {\n    if (!this.#isDesktop) {\n      event.currentTarget.scrollIntoView({ behavior: \"smooth\", inline: \"center\" })\n    }\n  }\n\n  async #restoreColumnsDisablingTransitions() {\n    this.#disableTransitions()\n    this.#restoreColumns()\n    this.#handlePlatform()\n\n    await nextFrame()\n    this.#enableTransitions()\n  }\n\n  #disableTransitions() {\n    this.element.classList.add(this.noTransitionsClass)\n  }\n\n  #enableTransitions() {\n    this.element.classList.remove(this.noTransitionsClass)\n  }\n\n  #toggleColumn(column) {\n    this.#collapseAllExcept(column)\n\n    if (this.#isCollapsed(column)) {\n      this.#expand({ column })\n    } else {\n      this.#collapse(column)\n    }\n  }\n\n  #collapseAllExcept(clickedColumn) {\n    const columns = this.#isDesktop ? this.columnTargets.filter(c => c !== this.maybeColumnTarget) : this.columnTargets\n\n    columns.forEach(column => {\n      if (column !== clickedColumn) {\n        this.#collapse(column)\n      }\n    })\n  }\n\n  #isCollapsed(column) {\n    return column.classList.contains(this.collapsedClass)\n  }\n\n  #collapse(column) {\n    const key = this.#localStorageKeyFor(column)\n\n    this.#buttonFor(column)?.setAttribute(\"aria-expanded\", \"false\")\n    column.classList.remove(this.expandedClass)\n    column.classList.add(this.collapsedClass)\n    localStorage.removeItem(key)\n  }\n\n  #expand({ column, saveState = true, scrollBehavior = \"smooth\" }) {\n    this.#buttonFor(column)?.setAttribute(\"aria-expanded\", \"true\")\n    column.classList.remove(this.collapsedClass)\n    column.classList.add(this.expandedClass)\n\n    if (saveState) {\n      const key = this.#localStorageKeyFor(column)\n      localStorage.setItem(key, true)\n    }\n\n    if (window.matchMedia('(max-width: 639px)').matches) {\n      column.scrollIntoView({ behavior: scrollBehavior, inline: \"center\" })\n    }\n  }\n\n  #buttonFor(column) {\n    return this.buttonTargets.find(button => column.contains(button))\n  }\n\n  #restoreColumns() {\n    this.columnTargets.forEach(column => {\n      this.#restoreColumn(column)\n    })\n  }\n\n  #restoreColumn(column) {\n    const key = this.#localStorageKeyFor(column)\n    if (localStorage.getItem(key)) {\n      this.#collapseAllExcept(column)\n      this.#expand({ column, scrollBehavior: isNative() ? \"instant\" : \"smooth\" })\n    }\n  }\n\n  #localStorageKeyFor(column) {\n    return `expand-${this.boardValue}-${column.getAttribute(\"id\")}`\n  }\n\n  #setupIntersectionObserver() {\n    if (typeof IntersectionObserver === \"undefined\") return\n    if (this._intersectionObserver) this._intersectionObserver.disconnect()\n\n    this._intersectionObserver = new IntersectionObserver(entries => {\n      entries.forEach(entry => {\n        const title = entry.target\n        const column = title.closest(\".cards\")\n\n        if (!column) return\n\n        const offscreen = entry.intersectionRatio === 0\n        column.classList.toggle(this.titleNotVisibleClass, offscreen)\n      })\n    }, { threshold: [0] })\n\n    this.titleTargets.forEach(title => this._intersectionObserver.observe(title))\n  }\n\n  get #isDesktop() {\n    return this.mediaQuery?.matches\n  }\n\n  #handlePlatform() {\n    this.#isDesktop ? this.#handleDesktopMode() : this.#handleMobileMode()\n  }\n\n  async #handleDesktopMode() {\n    this.#expand({ column: this.maybeColumnTarget, saveState: false })\n    this.#maybeButton.setAttribute(\"disabled\", true)\n  }\n\n  #handleMobileMode() {\n    this.#maybeButton.removeAttribute(\"disabled\")\n\n    const expandedColumn = this.columnTargets.find(column => column !== this.maybeColumnTarget && !this.#isCollapsed(column))\n\n    if (expandedColumn) {\n      this.#collapseAllExcept(expandedColumn)\n    } else {\n      this.#collapseAllExcept(this.maybeColumnTarget)\n      this.#expand({ column: this.maybeColumnTarget, saveState: false })\n    }\n  }\n\n  get #maybeButton() {\n    return this.maybeColumnTarget.querySelector('[data-collapsible-columns-target=\"button\"]')\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/combobox_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n  #hiddenField\n\n  static targets = [ \"label\", \"item\", \"hiddenFieldTemplate\" ]\n  static values = {\n    selectPropertyName: { type: String, default: \"aria-checked\" },\n    defaultValue: String,\n    defaultLabel: String\n  }\n  static classes = [\"withDefault\"]\n\n  connect() {\n    this.#selectedItem = this.#selectedItem\n  }\n\n  change(event) {\n    const item = event.target.closest(\"[role='checkbox']\")\n    if (item) {\n      this.#selectedItem = item\n    }\n  }\n\n  get #selectedLabel() {\n    const selectedValue = this.#selectedItemValue()\n\n    if (this.hasDefaultLabelValue && (selectedValue === this.defaultValueValue || !selectedValue)) {\n      return this.defaultLabelValue\n    }\n\n    return this.#selectedItem?.dataset?.comboboxLabel || \"\"\n  }\n\n  get #selectedItem() {\n    return this.itemTargets.find(item => item.getAttribute(this.selectPropertyNameValue) === \"true\")\n  }\n\n  #selectedItemValue() {\n    return this.#selectedItem?.dataset?.comboboxValue || \"\"\n  }\n\n  set #selectedItem(item) {\n    if (!item) return\n\n    this.#clearSelection()\n    item.setAttribute(this.selectPropertyNameValue, \"true\")\n    this.labelTarget.textContent = this.#selectedLabel\n    this.hiddenField.value = item.dataset.comboboxValue\n    this.hiddenField.disabled = !item.dataset.comboboxValue\n    this.#updateWithDefaultClass()\n  }\n\n  #clearSelection() {\n    this.itemTargets.forEach(target => {\n      target.setAttribute(this.selectPropertyNameValue, \"false\")\n    })\n  }\n\n  get hiddenField() {\n    if (!this.#hiddenField) {\n      this.#hiddenField = this.#buildHiddenField()\n    }\n    return this.#hiddenField\n  }\n\n  #buildHiddenField() {\n    const [field] = this.hiddenFieldTemplateTarget.content.cloneNode(true).children\n    this.element.appendChild(field)\n    return field\n  }\n\n  #updateWithDefaultClass() {\n    if (this.hasWithDefaultClass && this.hasDefaultValueValue) {\n      const selectedValue = this.#selectedItemValue()\n      const shouldHaveClass = selectedValue === this.defaultValueValue\n\n      if (shouldHaveClass) {\n        this.element.classList.add(this.withDefaultClass)\n      } else {\n        this.element.classList.remove(this.withDefaultClass)\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/copy_to_clipboard_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n  static values = { content: String }\n  static classes = [ \"success\" ]\n\n  async copy(event) {\n    event.preventDefault()\n    this.reset()\n\n    try {\n      await navigator.clipboard.writeText(this.contentValue)\n      this.element.classList.add(this.successClass)\n    } catch {}\n  }\n\n  reset() {\n    this.element.classList.remove(this.successClass)\n    this.#forceReflow()\n  }\n\n  #forceReflow() {\n    this.element.offsetWidth\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/css_variable_counter_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\nimport { debounce } from \"helpers/timing_helpers\"\n\nexport default class extends Controller {\n  static targets = [ \"item\", \"counter\" ]\n  static values = {\n    propertyName: String,\n    maxValue: { type: Number, default: 15 } // should match first geared pagination page size\n  }\n\n  initialize() {\n    this.#updateCounter = debounce(this.#updateCounter.bind(this), 50)\n  }\n\n  connect() {\n    if (this.itemTargets.length > 0) {\n      this.#updateCounter()\n    }\n  }\n\n  itemTargetConnected() {\n    this.#updateCounter()\n  }\n\n  itemTargetDisconnected() {\n    this.#updateCounter()\n  }\n\n  #updateCounter = () => {\n    if (!this.hasCounterTarget) return\n\n    const count = Math.min(this.itemTargets.length, this.maxValueValue)\n    this.counterTarget.style.setProperty(this.propertyNameValue, count)\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/details_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n  static targets = [ \"details\" ]\n\n  close() {\n    this.detailsTarget.removeAttribute(\"open\")\n  }\n\n  closeOnClickOutside({ target }) {\n    if (!this.element.contains(target)) this.close()\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/dialog_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\nimport { orient } from \"helpers/orientation_helpers\"\nimport { isTouchDevice } from \"helpers/platform_helpers\"\n\nexport default class extends Controller {\n  static targets = [ \"dialog\", \"focusMouse\", \"focusTouch\" ]\n  static values = {\n    modal: { type: Boolean, default: false },\n    sizing: { type: Boolean, default: true },\n    autoOpen: { type: Boolean, default: false },\n    orient: { type: Boolean, default: true }\n  }\n\n  connect() {\n    this.dialogTarget.setAttribute(\"aria-hidden\", \"true\")\n    if (this.autoOpenValue) this.open()\n  }\n\n  focusTouchTargetConnected() {\n    this.#setupFocus()\n  }\n\n  open() {\n    const modal = this.modalValue\n\n    if (modal) {\n      this.dialogTarget.showModal()\n    } else {\n      this.dialogTarget.show()\n      if (this.orientValue) {\n        orient({ target: this.dialogTarget, anchor: this.element })\n      }\n    }\n\n    this.loadLazyFrames()\n    this.dialogTarget.setAttribute(\"aria-hidden\", \"false\")\n    this.dispatch(\"show\")\n  }\n\n  toggle() {\n    if (this.dialogTarget.open) {\n      this.close()\n    } else {\n      this.open()\n    }\n  }\n\n  close() {\n    this.dialogTarget.close()\n    this.dialogTarget.setAttribute(\"aria-hidden\", \"true\")\n    this.dialogTarget.blur()\n    orient({ target: this.dialogTarget, reset: true })\n    this.dispatch(\"close\")\n  }\n\n  closeOnClickOutside({ target }) {\n    if (!this.element.contains(target)) this.close()\n  }\n\n  preventCloseOnMorphing(event) {\n    if (event.detail?.attributeName === \"open\") {\n      event.preventDefault()\n      event.stopPropagation()\n    }\n  }\n\n  loadLazyFrames() {\n    Array.from(this.dialogTarget.querySelectorAll(\"turbo-frame\")).forEach(frame => { frame.loading = \"eager\" })\n  }\n\n  captureKey(event) {\n    if (event.key !== \"Escape\") { event.stopPropagation() }\n  }\n\n  #setupFocus() {\n    const touch = isTouchDevice()\n    if (this.hasFocusMouseTarget) this.focusMouseTarget.autofocus = !touch\n    if (this.hasFocusTouchTarget) this.focusTouchTarget.autofocus = touch\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/dialog_manager_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n  connect() {\n    this.element.addEventListener(\"dialog:show\", this.handleDialogShow.bind(this))\n  }\n\n  disconnect() {\n    this.element.removeEventListener(\"dialog:show\", this.handleDialogShow.bind(this))\n  }\n\n  handleDialogShow(event) {\n    this.#dialogControllers.forEach(dialogController => {\n      if (dialogController !== event.target) {\n        const dialog = dialogController.querySelector(\"dialog\")\n        dialog.removeAttribute(\"open\")\n      }\n    })\n  }\n\n  get #dialogControllers() {\n    return this.element.querySelectorAll('[data-controller~=\"dialog\"]')\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/drag_and_drop_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\nimport { post } from \"@rails/request.js\"\nimport { nextFrame } from \"helpers/timing_helpers\"\n\nexport default class extends Controller {\n  static targets = [ \"item\", \"container\" ]\n  static classes = [ \"draggedItem\", \"hoverContainer\" ]\n\n  // Actions\n\n  async dragStart(event) {\n    event.dataTransfer.effectAllowed = \"move\"\n    event.dataTransfer.dropEffect = \"move\"\n    event.dataTransfer.setData(\"37ui/move\", event.target)\n\n    await nextFrame()\n    this.dragItem = this.#itemContaining(event.target)\n    this.sourceContainer = this.#containerContaining(this.dragItem)\n    this.originalDraggedItemCssVariable = this.#containerCssVariableFor(this.sourceContainer)\n    this.dragItem.classList.add(this.draggedItemClass)\n  }\n\n  dragOver(event) {\n    event.preventDefault()\n    if (!this.dragItem) { return }\n\n    const container = this.#containerContaining(event.target)\n    this.#clearContainerHoverClasses()\n\n    if (!container) { return }\n\n    if (container !== this.sourceContainer) {\n      container.classList.add(this.hoverContainerClass)\n      this.#applyContainerCssVariableToDraggedItem(container)\n    } else {\n      this.#restoreOriginalDraggedItemCssVariable()\n    }\n  }\n\n  async drop(event) {\n    const targetContainer = this.#containerContaining(event.target)\n\n    if (!targetContainer || targetContainer === this.sourceContainer) { return }\n\n    this.wasDropped = true\n    this.#increaseCounter(targetContainer)\n    this.#decreaseCounter(this.sourceContainer)\n\n    const sourceContainer = this.sourceContainer\n    this.#insertDraggedItem(targetContainer, this.dragItem)\n    await this.#submitDropRequest(this.dragItem, targetContainer)\n    this.#reloadSourceFrame(sourceContainer)\n  }\n\n  dragEnd() {\n    this.dragItem.classList.remove(this.draggedItemClass)\n    this.#clearContainerHoverClasses()\n\n    if (!this.wasDropped) {\n      this.#restoreOriginalDraggedItemCssVariable()\n    }\n\n    this.sourceContainer = null\n    this.dragItem = null\n    this.wasDropped = false\n    this.originalDraggedItemCssVariable = null\n  }\n\n  #itemContaining(element) {\n    return this.itemTargets.find(item => item.contains(element) || item === element)\n  }\n\n  #containerContaining(element) {\n    return this.containerTargets.find(container => container.contains(element) || container === element)\n  }\n\n  #clearContainerHoverClasses() {\n    this.containerTargets.forEach(container => container.classList.remove(this.hoverContainerClass))\n  }\n\n  #applyContainerCssVariableToDraggedItem(container) {\n    const cssVariable = this.#containerCssVariableFor(container)\n    if (cssVariable) {\n      this.dragItem.style.setProperty(cssVariable.name, cssVariable.value)\n    }\n  }\n\n  #restoreOriginalDraggedItemCssVariable() {\n    if (this.originalDraggedItemCssVariable) {\n      const { name, value } = this.originalDraggedItemCssVariable\n      this.dragItem.style.setProperty(name, value)\n    }\n  }\n\n  #containerCssVariableFor(container) {\n    const { dragAndDropCssVariableName, dragAndDropCssVariableValue } = container.dataset\n    if (dragAndDropCssVariableName && dragAndDropCssVariableValue) {\n      return { name: dragAndDropCssVariableName, value: dragAndDropCssVariableValue }\n    }\n    return null\n  }\n\n  #increaseCounter(container) {\n    this.#modifyCounter(container, count => count + 1)\n  }\n\n  #decreaseCounter(container) {\n    this.#modifyCounter(container, count => Math.max(0, count - 1))\n  }\n\n  #modifyCounter(container, fn) {\n    const counterElement = container.querySelector(\"[data-drag-and-drop-counter]\")\n    if (counterElement) {\n      const currentValue = counterElement.textContent.trim()\n\n      if (!/^\\d+$/.test(currentValue)) return\n\n      counterElement.textContent = fn(parseInt(currentValue))\n    }\n  }\n\n  #insertDraggedItem(container, item) {\n    const itemContainer = container.querySelector(\"[data-drag-drop-item-container]\")\n    const topItems = itemContainer.querySelectorAll(\"[data-drag-and-drop-top]\")\n    const firstTopItem = topItems[0]\n    const lastTopItem = topItems[topItems.length - 1]\n\n    const isTopItem = item.hasAttribute(\"data-drag-and-drop-top\")\n    const referenceItem = isTopItem ? firstTopItem : lastTopItem\n\n    if (referenceItem) {\n      referenceItem[isTopItem ? \"before\" : \"after\"](item)\n    } else {\n      itemContainer.prepend(item)\n    }\n  }\n\n  async #submitDropRequest(item, container) {\n    const body = new FormData()\n    const id = item.dataset.id\n    const url = container.dataset.dragAndDropUrl.replaceAll(\"__id__\", id)\n\n    return post(url, { body, headers: { Accept: \"text/vnd.turbo-stream.html\" } })\n  }\n\n  #reloadSourceFrame(sourceContainer) {\n    const frame = sourceContainer.querySelector(\"[data-drag-and-drop-refresh]\")\n    if (frame) frame.reload()\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/drag_and_strum_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\n\nconst INSTRUMENTS = [\n  [\n   \"/audio/vibes/B3.mp3\",\n   \"/audio/vibes/C3.mp3\",\n   \"/audio/vibes/D4.mp3\",\n   \"/audio/vibes/E3.mp3\",\n   \"/audio/vibes/Fsharp4.mp3\",\n   \"/audio/vibes/G3.mp3\"\n  ],\n  [\n    \"/audio/banjo/B3.mp3\",\n    \"/audio/banjo/C3.mp3\",\n    \"/audio/banjo/D4.mp3\",\n    \"/audio/banjo/E3.mp3\",\n    \"/audio/banjo/Fsharp4.mp3\",\n    \"/audio/banjo/G3.mp3\"\n  ],\n  [\n    \"/audio/harpsichord/B3.mp3\",\n    \"/audio/harpsichord/C3.mp3\",\n    \"/audio/harpsichord/D4.mp3\",\n    \"/audio/harpsichord/E3.mp3\",\n    \"/audio/harpsichord/Fsharp4.mp3\",\n    \"/audio/harpsichord/G3.mp3\"\n  ],\n  [\n    \"/audio/mandolin/B3.mp3\",\n    \"/audio/mandolin/C3.mp3\",\n    \"/audio/mandolin/D4.mp3\",\n    \"/audio/mandolin/E3.mp3\",\n    \"/audio/mandolin/Fsharp4.mp3\",\n    \"/audio/mandolin/G3.mp3\"\n  ],\n  [\n   \"/audio/piano/B3.mp3\",\n   \"/audio/piano/C3.mp3\",\n   \"/audio/piano/D4.mp3\",\n   \"/audio/piano/E3.mp3\",\n   \"/audio/piano/Fsharp4.mp3\",\n   \"/audio/piano/G3.mp3\"\n  ],\n]\n\nexport default class extends Controller {\n  static targets = [ \"container\" ]\n\n  connect() {\n    this.instrumentIndex = 0\n    this.preloadedAudioFiles = []\n    document.addEventListener(\"keydown\", this.handleKeyDown.bind(this));\n  }\n\n  disconnect() {\n    document.removeEventListener(\"keydown\", this.handleKeyDown.bind(this));\n  }\n\n  handleKeyDown(event) {\n    if (event.shiftKey) {\n      this.instrumentIndex = this.#getInstrumentIndex(event)\n\n      if (this.instrumentIndex < INSTRUMENTS.length) {\n        this.#preloadAudioFiles(this.instrumentIndex)\n      }\n    }\n  }\n\n  dragEnter(event) {\n    event.preventDefault()\n    const container = this.#containerContaining(event.target)\n\n    if (!container) { return }\n\n    if (container !== this.sourceContainer && event.shiftKey) {\n      this.#playSound()\n    }\n  }\n\n  #getInstrumentIndex(event) {\n    const number = Number(event.code.replace(\"Digit\", \"\"))\n    return isNaN(number) ? 0 : number\n  }\n\n  #preloadAudioFiles(instrumentIndex) {\n    this.preloadedAudioFiles = []\n    const audioFiles = INSTRUMENTS[instrumentIndex];\n\n    if (audioFiles) {\n      this.preloadedAudioFiles = audioFiles.map(file => {\n        const audio = new Audio(file)\n        audio.load()\n        return audio\n      })\n    }\n  }\n\n  #containerContaining(element) {\n    return this.containerTargets.find(container => container.contains(element) || container === element)\n  }\n\n  #playSound() {\n    const randomIndex = Math.floor(Math.random() * this.preloadedAudioFiles.length)\n    const audio = this.preloadedAudioFiles[randomIndex]\n    const audioInstance = new Audio(audio.src)\n\n    audioInstance.play()\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/element_removal_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n  remove() {\n    this.element.remove()\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/expandable_on_native_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\nimport { isNative } from \"helpers/platform_helpers\"\n\nexport default class extends Controller {\n  static get shouldLoad() {\n    return isNative()\n  }\n\n  static values = { autoExpandSelector: String }\n\n  connect() {\n    this.element.open = this.hasAutoExpandSelectorValue && this.element.querySelector(this.autoExpandSelectorValue)\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/fetch_on_visible_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\nimport { get } from \"@rails/request.js\"\n\nexport default class extends Controller {\n  static values = { url: String }\n\n  connect() {\n    this.#observe()\n  }\n\n  #observe() {\n    const observer = new IntersectionObserver((entries) => {\n      const visible = !!entries.find(entry => entry.isIntersecting)\n      if (visible) {\n        this.#fetch()\n      }\n    })\n\n    observer.observe(this.element)\n  }\n\n  #fetch() {\n    get(this.urlValue, { responseKind: \"turbo-stream\" })\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/filter_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\nimport { debounce } from \"helpers/timing_helpers\"\nimport { filterMatches } from \"helpers/text_helpers\"\n\nexport default class extends Controller {\n  static targets = [ \"input\", \"item\" ]\n\n  initialize() {\n    this.filter = debounce(this.filter.bind(this), 100)\n  }\n\n  filter() {\n    this.itemTargets.forEach(item => {\n      if (filterMatches(item.textContent, this.inputTarget.value)) {\n        item.removeAttribute(\"hidden\")\n      } else {\n        item.toggleAttribute(\"hidden\", true)\n      }\n    })\n\n    this.dispatch(\"changed\")\n  }\n\n  clearInput() {\n    if (!this.hasInputTarget) return\n    this.inputTarget.value = \"\"\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/filter_form_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n  clearCategory({ params: { name } }) {\n    name.split(\",\").forEach(name => {\n      this.element.querySelectorAll(`input[name=\"${name}\"]`).forEach(input => {\n        input.checked = false\n      })\n    })\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/filter_settings_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\nimport { debounce } from \"helpers/timing_helpers\";\nimport { post } from \"@rails/request.js\"\n\nexport default class extends Controller {\n  static classes = [\"filtersSet\"]\n  static targets = [\"field\", \"form\"]\n  static values = { refreshUrl: String, noFilteringUrl: String, cardsUrl: String }\n\n  initialize() {\n    this.debouncedToggle = debounce(this.#toggle.bind(this), 50)\n  }\n\n  connect() {\n    this.#toggle()\n  }\n\n  change(event) {\n    this.#toggle()\n    this.#refreshSaveToggleButton()\n  }\n\n  resetIfNoFiltering(event) {\n    if (!this.#hasFiltersSet) {\n      this.#showNoFilteringUrl()\n      event.stopImmediatePropagation()\n    }\n  }\n\n  async fieldTargetConnected(field) {\n    this.debouncedToggle()\n  }\n\n  submitToGenericCardsView() {\n    this.formTarget.action = this.cardsUrlValue\n    this.formTarget.dataset.turboFrame = \"top\"\n    this.formTarget.requestSubmit()\n  }\n\n  #toggle() {\n    this.element.classList.toggle(this.filtersSetClass, this.#hasFiltersSet)\n  }\n\n  get #hasFiltersSet() {\n    return this.fieldTargets.some(field => this.#isFieldSet(field))\n  }\n\n  #isFieldSet(field) {\n    const value = field.value?.trim()\n\n    if (!value) return false\n\n    const defaultValue = this.#defaultValueForField(field)\n    return defaultValue ? value !== defaultValue : true\n  }\n\n  #defaultValueForField(field) {\n    const comboboxContainer = field.closest(\"[data-combobox-default-value-value]\")\n    return comboboxContainer?.dataset?.comboboxDefaultValueValue\n  }\n\n  #refreshSaveToggleButton() {\n    post(this.refreshUrlValue, {\n      body: this.#collectFilterFormData(),\n      responseKind: \"turbo-stream\"\n    })\n  }\n\n  #collectFilterFormData() {\n    const formData = new FormData()\n\n    this.formTargets.forEach(form => {\n      const hiddenFields = form.querySelectorAll('input[type=\"hidden\"]:not([disabled])[name]')\n      hiddenFields.forEach(field => {\n        formData.append(field.name, field.value)\n      })\n    })\n\n    return formData\n  }\n\n  #showNoFilteringUrl() {\n    Turbo.visit(this.noFilteringUrlValue, { frame: \"cards_container\", action: \"advance\" })\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/form_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\nimport { debounce, nextFrame } from \"helpers/timing_helpers\";\n\nexport default class extends Controller {\n  static targets = [ \"cancel\", \"submit\", \"input\" ]\n\n  static values = {\n    debounceTimeout: { type: Number, default: 300 }\n  }\n\n  #isComposing = false\n\n  initialize() {\n    this.debouncedSubmit = debounce(this.debouncedSubmit.bind(this), this.debounceTimeoutValue)\n  }\n\n  // IME Composition tracking\n  compositionStart() {\n    this.#isComposing = true\n  }\n\n  compositionEnd() {\n    this.#isComposing = false\n  }\n\n  submit() {\n    this.element.requestSubmit()\n  }\n\n  preventEmptySubmit(event) {\n    const input = this.hasInputTarget ? this.inputTarget : null\n\n    if (input) {\n      const value = (input.value || \"\").trim()\n      const isEmpty = value.length === 0\n\n      if (isEmpty) {\n        event.preventDefault()\n        input.setCustomValidity(input.dataset.validationMessage || \"Please fill out this field\")\n        input.reportValidity()\n        input.addEventListener(\"input\", () => input.setCustomValidity(\"\"), { once: true })\n      }\n    }\n  }\n\n  preventComposingSubmit(event) {\n    if (this.#isComposing) {\n      event.preventDefault()\n    }\n  }\n\n  debouncedSubmit(event) {\n    this.submit(event)\n  }\n\n  submitToTopTarget(event) {\n    const value = event.target.value?.trim()\n\n    if (!value) return false\n\n    this.element.setAttribute(\"data-turbo-frame\", \"_top\")\n    this.submit()\n  }\n\n  reset() {\n    this.element.reset()\n  }\n\n  cancel() {\n    this.cancelTarget?.click()\n  }\n\n  preventAttachment(event) {\n    event.preventDefault()\n  }\n\n  async disableSubmitWhenInvalid(event) {\n    await nextFrame()\n\n    if (this.element.checkValidity()) {\n      this.submitTarget.removeAttribute(\"disabled\")\n    } else {\n      this.submitTarget.toggleAttribute(\"disabled\", true)\n    }\n  }\n\n  select(event) {\n    event.target.select()\n  }\n\n  blurActiveInput() {\n    document.activeElement?.blur()\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/frame_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\nimport { Turbo } from \"@hotwired/turbo-rails\"\n\nexport default class extends Controller {\n  morphRender({ detail }) {\n    detail.render = function (currentElement, newElement) {\n      Turbo.morphChildren(currentElement, newElement)\n    }\n  }\n\n  morphReload(event) {\n    const newElement = event.detail.newElement\n    if (newElement && newElement.tagName === \"TURBO-FRAME\" && newElement.matches('[data-controller~=\"frame\"]')) {\n      event.preventDefault()\n      this.element.reload()\n    }\n  }\n\n  reload() {\n    this.element.reload()\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/frame_reloader_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n  static values = {\n    reloadInterval: { type: Number, default: 10 * 60 } // 10 minutes\n  }\n\n  connect() {\n    this.freshSince = Date.now()\n  }\n\n  reload() {\n    const now = Date.now()\n    const reloadIntervalMs = this.reloadIntervalValue * 1000\n\n    if ((now - this.freshSince) >= reloadIntervalMs) {\n      this.freshSince = now\n      this.element.reload()\n    }\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/hotkey_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n  click(event) {\n    if (this.#isClickable && !this.#shouldIgnore(event)) {\n      event.preventDefault()\n      this.element.click()\n    }\n  }\n\n  focus(event) {\n    if (this.#isClickable && !this.#shouldIgnore(event)) {\n      event.preventDefault()\n      this.element.focus()\n    }\n  }\n\n  #shouldIgnore(event) {\n    return event.defaultPrevented || event.target.closest(\"input, textarea, lexxy-editor\")\n  }\n\n  get #isClickable() {\n    return getComputedStyle(this.element).pointerEvents !== \"none\"\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/index.js",
    "content": "// Import and register all your controllers from the importmap under controllers/*\n\nimport { application } from \"controllers/application\"\n\n// Eager load all controllers defined in the import map under controllers/**/*_controller\nimport { eagerLoadControllersFrom } from \"@hotwired/stimulus-loading\"\neagerLoadControllersFrom(\"controllers\", application)\n\n// Lazy load controllers as they appear in the DOM (remember not to preload controllers in import map!)\n// import { lazyLoadControllersFrom } from \"@hotwired/stimulus-loading\"\n// lazyLoadControllersFrom(\"controllers\", application)\n"
  },
  {
    "path": "app/javascript/controllers/knob_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n  static targets = [ \"field\", \"option\", \"slider\" ]\n\n  connect() {\n    this.#index = this.#selectedOption.dataset.index\n  }\n\n  optionChanged({ target }) {\n    this.#index = target.dataset.index\n  }\n\n  sliderChanged({ target }) {\n    this.#index = target.value\n  }\n\n  set #index(index) {\n    this.fieldTarget.style.setProperty(\"--knob-index\", `${index}`);\n    this.sliderTarget.value = index\n    this.#optionForIndex(index).checked = true\n  }\n\n  get #selectedOption() {\n    return this.optionTargets.find(option => {\n      return option.checked\n    })\n  }\n\n  #optionForIndex(index) {\n    return this.optionTargets.find(option => {\n      return option.dataset.index === index;\n    })\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/lightbox_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n  static targets = [ \"caption\", \"image\", \"dialog\", \"zoomedImage\" ]\n\n  imageTargetConnected(element) {\n    element.addEventListener(\"click\", this.#handleImageClick)\n  }\n\n  imageTargetDisconnected(element) {\n    element.removeEventListener(\"click\", this.#handleImageClick)\n  }\n\n  #handleImageClick = (event) => {\n    event.preventDefault()\n    this.#open(event.currentTarget)\n  }\n\n  #open(link) {\n    this.dialogTarget.showModal()\n    this.#set(link)\n  }\n\n  // Wait for the transition to finish before resetting the image\n  handleTransitionEnd(event) {\n    if (event.target === this.dialogTarget && !this.dialogTarget.open) {\n      this.reset()\n    }\n  }\n\n  reset() {\n    this.zoomedImageTarget.src = \"\"\n    this.captionTarget.innerHTML = \"&nbsp;\"\n    this.dispatch('closed')\n  }\n\n  #set(target) {\n    const imageSrc = target.href\n    const caption = target.dataset.lightboxCaptionValue\n\n    this.zoomedImageTarget.src = imageSrc\n\n    if (caption) {\n      this.captionTarget.innerText = caption\n    }\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/local_save_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\nimport { debounce, nextFrame } from \"helpers/timing_helpers\"\n\nexport default class extends Controller {\n  static targets = [\"input\"]\n  static values = { key: String }\n\n  initialize() {\n    this.save = debounce(this.save.bind(this), 300)\n  }\n\n  connect() {\n    this.restoreContent()\n  }\n\n  submit({ detail: { success } }) {\n    if (success) {\n      this.#clear()\n    }\n  }\n\n  save() {\n    const content = this.inputTarget.value\n    if (content) {\n      localStorage.setItem(this.keyValue, content)\n    } else {\n      this.#clear()\n    }\n  }\n\n  async restoreContent() {\n    await nextFrame()\n    let savedContent = localStorage.getItem(this.keyValue)\n\n    if (savedContent) {\n      savedContent = `<div>${savedContent}</div>` // temporary for old markdown saves\n      this.inputTarget.value = savedContent\n      this.#triggerChangeEvent(savedContent)\n    }\n  }\n\n  // Private\n\n  #clear() {\n    localStorage.removeItem(this.keyValue)\n  }\n\n  #triggerChangeEvent(newValue) {\n    if (this.inputTarget.tagName === \"LEXXY-EDITOR\") {\n      this.inputTarget.dispatchEvent(new CustomEvent('lexxy:change', {\n        bubbles: true,\n        detail: {\n          previousContent: '',\n          newContent: newValue\n        }\n      }))\n    }\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/local_time_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\nimport { differenceInDays, secondsToDate } from \"helpers/date_helpers\"\n\nconst DEFAULT_LOCALE = \"en-US\"\n\nexport default class extends Controller {\n  static targets = [ \"time\", \"date\", \"datetime\", \"shortdate\", \"ago\", \"indays\", \"daysago\", \"agoorweekday\", \"timeordate\" ]\n  static values = { refreshInterval: Number }\n  static classes = [ \"local-time-value\"]\n\n  #timer\n\n  initialize() {\n    this.timeFormatter = new Intl.DateTimeFormat(DEFAULT_LOCALE, { timeStyle: \"short\" })\n    this.dateFormatter = new Intl.DateTimeFormat(DEFAULT_LOCALE, { dateStyle: \"long\" })\n    this.shortdateFormatter = new Intl.DateTimeFormat(DEFAULT_LOCALE, { month: \"short\", day: \"numeric\" })\n    this.datetimeFormatter = new Intl.DateTimeFormat(DEFAULT_LOCALE, { timeStyle: \"short\", dateStyle: \"short\" })\n    this.agoFormatter = new AgoFormatter()\n    this.daysagoFormatter = new DaysAgoFormatter()\n    this.datewithweekdayFormatter = new Intl.DateTimeFormat(DEFAULT_LOCALE, { weekday: \"long\", month: \"long\", day: \"numeric\" })\n    this.datewithweekdayFormatter = new Intl.DateTimeFormat(DEFAULT_LOCALE, { weekday: \"long\", month: \"long\", day: \"numeric\" })\n    this.indaysFormatter = new InDaysFormatter()\n    this.agoorweekdayFormatter = new DaysAgoOrWeekdayFormatter()\n    this.timeordateFormatter = new TimeOrDateFormatter()\n  }\n\n  connect() {\n    this.#timer = setInterval(() => this.#refreshRelativeTimes(), 30_000)\n  }\n\n  disconnect() {\n    clearInterval(this.#timer)\n  }\n\n  refreshAll() {\n    this.constructor.targets.forEach(targetName => {\n      this.targets.findAll(targetName).forEach(target => {\n        this.#formatTime(this[`${targetName}Formatter`], target)\n      })\n    })\n  }\n\n  refreshTarget(event) {\n    const target = event.target;\n    const targetName = target.dataset.localTimeTarget\n    this.#formatTime(this[`${targetName}Formatter`], target)\n  }\n\n  timeTargetConnected(target) {\n    this.#formatTime(this.timeFormatter, target)\n  }\n\n  dateTargetConnected(target) {\n    this.#formatTime(this.dateFormatter, target)\n  }\n\n  datetimeTargetConnected(target) {\n    this.#formatTime(this.datetimeFormatter, target)\n  }\n\n  shortdateTargetConnected(target) {\n    this.#formatTime(this.shortdateFormatter, target)\n  }\n\n  agoTargetConnected(target) {\n    this.#formatTime(this.agoFormatter, target)\n  }\n\n  indaysTargetConnected(target) {\n    this.#formatTime(this.indaysFormatter, target)\n  }\n\n  daysagoTargetConnected(target) {\n    this.#formatTime(this.daysagoFormatter, target)\n  }\n\n  agoorweekdayTargetConnected(target) {\n    this.#formatTime(this.agoorweekdayFormatter, target)\n  }\n\n  timeordateTargetConnected(target) {\n    this.#formatTime(this.timeordateFormatter, target)\n  }\n\n  #refreshRelativeTimes() {\n    this.agoTargets.forEach(target => {\n      this.#formatTime(this.agoFormatter, target)\n    })\n  }\n\n  #formatTime(formatter, target) {\n    const dt = secondsToDate(parseInt(target.getAttribute(\"datetime\")))\n    target.innerHTML = formatter.format(dt)\n    target.title = this.datetimeFormatter.format(dt)\n  }\n}\n\nclass AgoFormatter {\n  format(dt) {\n    const now = new Date()\n    const seconds = (now - dt) / 1000\n    const minutes = seconds / 60\n    const hours = minutes / 60\n    const days = hours / 24\n    const weeks = days / 7\n    const months = days / (365 / 12)\n    const years = days / 365\n\n    if (years >= 1) return this.#pluralize(\"year\", years)\n    if (months >= 1) return this.#pluralize(\"month\", months)\n    if (weeks >= 1) return this.#pluralize(\"week\", weeks)\n    if (days >= 1) return this.#pluralize(\"day\", days)\n    if (hours >= 1) return this.#pluralize(\"hour\", hours)\n    if (minutes >= 1) return this.#pluralize(\"minute\", minutes)\n\n    return \"Less than a minute ago\"\n  }\n\n  #pluralize(word, quantity) {\n    quantity = Math.floor(quantity)\n    const suffix = (quantity === 1) ? \"\" : \"s\"\n    return `${quantity} ${word}${suffix} ago`\n  }\n}\n\nclass DaysAgoFormatter {\n  format(date) {\n    const days = differenceInDays(date, new Date())\n\n    if (days <= 0) return styleableValue(\"today\")\n    if (days === 1) return styleableValue(\"yesterday\")\n    return `${styleableValue(days)} days ago`\n  }\n}\n\nclass DaysAgoOrWeekdayFormatter {\n  format(date) {\n    const days = differenceInDays(date, new Date())\n\n    if (days <= 1) {\n      return new DaysAgoFormatter().format(date)\n    } else {\n      return new Intl.DateTimeFormat(DEFAULT_LOCALE, { weekday: \"long\", month: \"long\", day: \"numeric\" }).format(date)\n    }\n  }\n}\n\nclass InDaysFormatter {\n  format(date) {\n    const days = differenceInDays(new Date(), date)\n\n    if (days <= 0) return styleableValue(\"today\")\n    if (days === 1) return styleableValue(\"tomorrow\")\n    return `in ${styleableValue(days)} days`\n  }\n}\n\nclass TimeOrDateFormatter {\n  format(date) {\n    const days = differenceInDays(date, new Date())\n\n    if (days >= 1) {\n      return new Intl.DateTimeFormat(DEFAULT_LOCALE, { month: \"short\", day: \"numeric\" }).format(date)\n    } else {\n      return new Intl.DateTimeFormat(DEFAULT_LOCALE, { timeStyle: \"short\" }).format(date)\n    }\n  }\n}\n\nfunction styleableValue(value) {\n  return `<span class=\"local-time-value\">${value}</span>`\n}\n"
  },
  {
    "path": "app/javascript/controllers/magic_link_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\nimport { onNextEventLoopTick } from \"helpers/timing_helpers\"\n\nexport default class extends Controller {\n  static targets = [ \"input\" ]\n\n  submitOnEnter(event) {\n    event.preventDefault()\n    this.submit()\n  }\n\n  submitOnPaste() {\n    onNextEventLoopTick(() => this.submit())\n  }\n\n  submit() {\n    if (this.inputTarget.disabled) return\n    this.element.requestSubmit()\n    this.inputTarget.disabled = true\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/multi_selection_combobox_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\nimport { toSentence } from \"helpers/text_helpers\"\n\nexport default class extends Controller {\n  #hiddenField\n\n  static targets = [ \"label\", \"item\", \"hiddenFieldTemplate\" ]\n  static values = {\n    selectPropertyName: { type: String, default: \"aria-checked\" },\n    defaultValue: String,\n    noSelectionLabel: { type: String, default: \"No selection\" },\n    labelPrefix: String\n  }\n\n  connect() {\n    this.refresh()\n  }\n\n  change(event) {\n    const item = event.target.closest(\"[role='checkbox']\")\n    if (item) {\n      this.#toggleSelection(item)\n    }\n  }\n\n  refresh() {\n    this.labelTarget.textContent = this.#selectedLabel\n    this.#updateHiddenFields()\n    this.#updateFilterShow()\n  }\n\n  clear(event) {\n    this.#deselectAll()\n    this.#updateHiddenFields()\n    this.labelTarget.textContent = this.#selectedLabel\n    this.#updateFilterShow()\n  }\n\n  get #selectedLabel() {\n    const selectedValues = this.#selectedValues()\n    if (selectedValues.length === 0) {\n      return this.noSelectionLabelValue\n    }\n\n    const labels = this.#selectedItems.map(item => item.dataset.multiSelectionComboboxLabel)\n    const sentence = toSentence(labels, {\n      two_words_connector: \" or \",\n      last_word_connector: \", or \"\n    })\n\n    return this.hasLabelPrefixValue ? `${this.labelPrefixValue} ${sentence}` : sentence\n  }\n\n  #toggleSelection(item) {\n    const isSelected = item.getAttribute(this.selectPropertyNameValue) === \"true\"\n\n    if (isSelected) {\n      item.setAttribute(this.selectPropertyNameValue, \"false\")\n    } else {\n      if (this.isAnExclusiveSelectionItemInvolved(item)) {\n        this.#deselectAll()\n      }\n\n      item.setAttribute(this.selectPropertyNameValue, \"true\")\n    }\n\n    this.#updateHiddenFields()\n    if (item.dataset.multiSelectionFieldName) {\n      this.#renameHiddenFields(item.dataset.multiSelectionFieldName)\n    }\n    this.labelTarget.textContent = this.#selectedLabel\n  }\n\n  isAnExclusiveSelectionItemInvolved(item) {\n    return this.#isExclusiveSelection(item) || Array.from(this.#selectedItems).some((item) => this.#isExclusiveSelection(item))\n  }\n\n  #isExclusiveSelection(item) {\n    return item.dataset.multiSelectionExclusive === \"true\"\n  }\n\n  #updateHiddenFields() {\n    this.#clearHiddenFields()\n    this.#addHiddenFields()\n    this.#updateFilterShow()\n  }\n\n  #deselectAll() {\n    this.itemTargets.forEach(item => {\n      item.setAttribute(this.selectPropertyNameValue, \"false\")\n    })\n  }\n\n  get #selectedItems() {\n    return this.itemTargets.filter(item =>\n      item.getAttribute(this.selectPropertyNameValue) === \"true\"\n    )\n  }\n\n  #selectedValues() {\n    return this.#selectedItems.map(item => item.dataset.multiSelectionComboboxValue)\n  }\n\n  #clearHiddenFields() {\n    this.#hiddenFields.forEach(field => {\n      field.remove()\n    })\n  }\n\n  #renameHiddenFields(fieldName) {\n    this.#hiddenFields.forEach(field => {\n      field.setAttribute(\"name\", fieldName)\n    })\n  }\n\n  get #hiddenFields() {\n    return this.element.querySelectorAll(\"input[type='hidden']\")\n  }\n\n  #addHiddenFields() {\n    this.#selectedValues().forEach(value => {\n      const [ field ] = this.hiddenFieldTemplateTarget.content.cloneNode(true).children\n      field.removeAttribute(\"id\")\n      field.value = value\n      this.element.appendChild(field)\n    })\n  }\n\n  #updateFilterShow() {\n    const hasSelection = this.#selectedValues().length > 0\n    this.element.setAttribute(\"data-filter-show\", hasSelection)\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/nav_section_expander_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n  static values = { key: String }\n  static targets = [ \"input\", \"section\" ]\n\n  sectionTargetConnected() {\n    this.#restoreToggles()\n  }\n\n  toggle(event) {\n    const section = event.target\n    if (section.hasAttribute(\"data-is-filtering\")) return\n\n    const key = this.#localStorageKeyFor(section)\n    if (section.open) {\n      localStorage.removeItem(key)\n    } else {\n      localStorage.setItem(key, true)\n    }\n  }\n\n  showWhileFiltering() {\n    if (this.inputTarget.value) {\n      this.#expandAll();\n    } else {\n      this.#restoreToggles()\n    }\n  }\n\n  #expandAll() {\n    this.sectionTargets.forEach(section => {\n      section.setAttribute(\"data-is-filtering\", true)\n      section.open = true\n    })\n  }\n\n  #restoreToggles() {\n    this.sectionTargets.forEach(section => {\n      const key = this.#localStorageKeyFor(section)\n      section.open = !localStorage.getItem(key)\n      section.removeAttribute(\"data-is-filtering\")\n    })\n  }\n\n  #localStorageKeyFor(section) {\n    return section.getAttribute(\"data-nav-section-expander-key-value\")\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/navigable_list_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\nimport { nextFrame } from \"helpers/timing_helpers\"\nimport { isMobile } from \"helpers/platform_helpers\"\n\nexport default class extends Controller {\n  static targets = [ \"item\", \"input\" ]\n  static values = {\n    reverseOrder: { type: Boolean, default: false },\n    selectionAttribute: { type: String, default: \"aria-selected\" },\n    focusOnSelection: { type: Boolean, default: true },\n    actionableItems: { type: Boolean, default: false },\n    reverseNavigation: { type: Boolean, default: false },\n    supportsHorizontalNavigation: { type: Boolean, default: true },\n    supportsVerticalNavigation: { type: Boolean, default: true },\n    hasNestedNavigation: { type: Boolean, default: false },\n    preventHandledKeys: { type: Boolean, default: false },\n    autoSelect: { type: Boolean, default: true },\n    autoScroll: { type: Boolean, default: true },\n    onlyActOnFocusedItems: { type: Boolean, default: false }\n  }\n\n  // Don't load for mobile devices\n  static get shouldLoad() {\n    return !isMobile()\n  }\n\n  connect() {\n    if (this.autoSelectValue) {\n      this.reset()\n    } else {\n      this.#activateManualSelection()\n    }\n  }\n\n  // Actions\n\n  reset(event) {\n    if (this.reverseOrderValue) {\n      this.selectLast()\n    } else {\n      this.selectFirst()\n    }\n  }\n\n  navigate(event) {\n    this.#keyHandlers[event.key]?.call(this, event)\n    this.#relayNavigationToParentNavigableList(event)\n  }\n\n  select({ target }) {\n    this.selectItem(target, true)\n  }\n\n  hoverSelect({ currentTarget }) {\n    this.selectItem(currentTarget)\n  }\n\n  selectCurrentOrReset(event) {\n    if (this.currentItem) {\n      this.#setCurrentFrom(this.currentItem)\n    } else {\n      this.reset()\n    }\n  }\n\n  selectFirst() {\n    this.#setCurrentFrom(this.#visibleItems[0])\n  }\n\n  selectLast() {\n    this.#setCurrentFrom(this.#visibleItems[this.#visibleItems.length - 1])\n  }\n\n  deselectWhenClickingOutside(event) {\n    if (this.element.contains(event.target)) {\n      return\n    }\n\n    this.#clearSelection()\n  }\n\n  // Public\n\n  async selectItem(item, skipFocus = false) {\n    await this.#selectCurrentElementInParent()\n\n    this.#clearSelection()\n    item.setAttribute(this.selectionAttributeValue, \"true\")\n    this.currentItem = item\n    this.#refreshActiveDescendant()\n\n    await nextFrame()\n\n    if (this.autoScrollValue) { this.currentItem.scrollIntoView({ block: \"nearest\", inline: \"nearest\" }) }\n    if (this.hasNestedNavigationValue) { this.#activateNestedNavigableList() }\n\n    if (!skipFocus && this.focusOnSelectionValue) { this.currentItem.focus({ preventScroll: !this.autoScrollValue }) }\n  }\n\n  isSelected(item) {\n    return item === this.currentItem\n  }\n\n  // Private\n\n  async #setCurrentFrom(element) {\n    const selectedItem = this.#visibleItems.find(item => item.contains(element))\n\n    if (selectedItem) {\n      await this.selectItem(selectedItem)\n    }\n  }\n\n  get #parentNavigableListController() {\n    const parentNavigableList = this.element.parentElement?.closest(\"[data-controller~='navigable-list']\")\n    if (parentNavigableList) {\n      return this.application.getControllerForElementAndIdentifier(parentNavigableList, \"navigable-list\")\n    }\n    return null\n  }\n\n  async #selectCurrentElementInParent() {\n    const parentController = this.#parentNavigableListController\n    if (parentController) {\n      const parentItem = this.element.closest(\"[data-navigable-list-target~='item']\")\n      const isAlreadySelected = parentController.isSelected(parentItem)\n      if (!isAlreadySelected) {\n        await parentController.selectItem(parentItem, true)\n      }\n    }\n  }\n\n  #clearSelection() {\n    for (const item of this.itemTargets) {\n      item.removeAttribute(this.selectionAttributeValue)\n    }\n  }\n\n  #refreshActiveDescendant() {\n    const id = this.currentItem?.getAttribute(\"id\")\n    if (this.hasInputTarget && id) {\n      this.inputTarget.setAttribute(\"aria-activedescendant\", id)\n    }\n  }\n\n  #activateNestedNavigableList() {\n    const nestedController = this.#nestedNavigableListController()\n    if (nestedController) {\n      nestedController.selectCurrentOrReset()\n      return true\n    }\n    return false\n  }\n\n  #nestedNavigableListController() {\n    const nestedElement = this.currentItem?.querySelector('[data-controller~=\"navigable-list\"]')\n    if (nestedElement) {\n      return this.application.getControllerForElementAndIdentifier(nestedElement, \"navigable-list\")\n    }\n    return null\n  }\n\n  #activateManualSelection() {\n    const preselectedItem = this.itemTargets.find(item => item.hasAttribute(this.selectionAttributeValue))\n    if (preselectedItem) {\n      this.#setCurrentFrom(preselectedItem)\n    }\n  }\n\n  // Stimulus won't let you handle keydown events with different handlers for the same (nested) stimulus controllers.\n  #relayNavigationToParentNavigableList(event) {\n    const parentController = this.#parentNavigableListController\n    if (parentController) {\n      parentController.element.focus({ preventScroll: !parentController.autoScrollValue })\n      parentController.navigate(event)\n    }\n  }\n\n  #selectPrevious() {\n    const index = this.#visibleItems.indexOf(this.currentItem)\n    if (index > 0) {\n      this.#setCurrentFrom(this.#visibleItems[index - 1])\n    }\n  }\n\n  #selectNext() {\n    const index = this.#visibleItems.indexOf(this.currentItem)\n    if (index >= 0 && index < this.#visibleItems.length - 1) {\n      this.#setCurrentFrom(this.#visibleItems[index + 1])\n    }\n  }\n\n  #handleArrowKey(event, fn) {\n    if (event.shiftKey || event.metaKey || event.ctrlKey) { return }\n    fn.call()\n    if (this.preventHandledKeysValue) {\n      event.preventDefault()\n    }\n  }\n\n  #clickCurrentItem(event) {\n    if (this.actionableItemsValue && this.currentItem && this.#visibleItems.length && this.#isFocusContainedOnNavigableItem) {\n      const clickableElement = this.currentItem.querySelector(\"a,button\") || this.currentItem\n      clickableElement.click()\n      event.preventDefault()\n    }\n  }\n\n  get #isFocusContainedOnNavigableItem() {\n    return !this.onlyActOnFocusedItemsValue || this.itemTargets.some(item => item === document.activeElement || item.contains(document.activeElement))\n  }\n\n  #toggleCurrentItem(event) {\n    if (this.actionableItemsValue && this.currentItem && this.#visibleItems.length) {\n      const toggleable = this.currentItem.querySelector(\"input[type=checkbox]\")\n      const isDisabled = toggleable.hasAttribute(\"disabled\")\n\n      if (toggleable) {\n        if (!isDisabled) {\n          toggleable.checked = !toggleable.checked\n          toggleable.dispatchEvent(new Event('change', { bubbles: true }))\n        }\n        event.preventDefault()\n      }\n    }\n  }\n\n  get #visibleItems() {\n    return this.itemTargets.filter(item => {\n      return item.checkVisibility() && !item.hidden\n    })\n  }\n\n  // Public accessors for card_hotkeys_controller outlet\n  get visibleItems() {\n    return this.#visibleItems\n  }\n\n  clearSelection() {\n    this.#clearSelection()\n    this.currentItem = null\n  }\n\n  get hasFocus() {\n    return this.element.contains(document.activeElement)\n  }\n\n  #keyHandlers = {\n    ArrowDown(event) {\n      if (this.supportsVerticalNavigationValue) {\n        const selectMethod = this.reverseNavigationValue ? this.#selectPrevious.bind(this) : this.#selectNext.bind(this)\n        this.#handleArrowKey(event, selectMethod)\n      }\n    },\n    ArrowUp(event) {\n      if (this.supportsVerticalNavigationValue) {\n        const selectMethod = this.reverseNavigationValue ? this.#selectNext.bind(this) : this.#selectPrevious.bind(this)\n        this.#handleArrowKey(event, selectMethod)\n      }\n    },\n    ArrowRight(event) {\n      if (this.supportsHorizontalNavigationValue) {\n        this.#handleArrowKey(event, this.#selectNext.bind(this))\n      }\n    },\n    ArrowLeft(event) {\n      if (this.supportsHorizontalNavigationValue) {\n        this.#handleArrowKey(event, this.#selectPrevious.bind(this))\n      }\n    },\n    Enter(event) {\n      // Skip handling during IME composition (e.g., Japanese input)\n      if (event.isComposing) { return }\n\n      if (event.shiftKey) {\n        this.#toggleCurrentItem(event)\n      } else {\n        this.#clickCurrentItem(event)\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/notifications_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\nimport { post } from \"@rails/request.js\"\n\nexport default class extends Controller {\n  static classes = [ \"enabled\" ]\n  static targets = [ \"subscribeButton\", \"explainer\" ]\n  static values = { subscriptionsUrl: String }\n  \n  async connect() {\n    if (!this.#allowed) return\n\n    switch(Notification.permission) {\n      case \"default\":\n        this.#showButtonToSubscribe()\n        break\n      case \"granted\":\n        const registration = await this.#getServiceWorkerRegistration()\n        const subscription = await registration?.pushManager?.getSubscription()\n\n        if (registration && subscription) {\n          this.element.classList.add(this.enabledClass)\n        } else {\n          this.#showButtonToSubscribe()\n        }\n        break\n    }\n  }\n\n  async attemptToSubscribe() {\n    if (this.#allowed) {\n      const registration = await this.#getServiceWorkerRegistration() || await this.#registerServiceWorker()\n\n      switch(Notification.permission) {\n        case \"denied\":  { break }\n        case \"granted\": { this.#subscribe(registration); break }\n        case \"default\": { this.#requestPermissionAndSubscribe(registration) }\n      }\n    }\n  }\n\n  async isEnabled() {\n    if (this.#allowed) {\n      const registration = await this.#getServiceWorkerRegistration()\n      const existingSubscription = await registration?.pushManager?.getSubscription()\n\n      return Notification.permission == \"granted\" && registration && existingSubscription\n    }\n  }\n\n  get #allowed() {\n    return navigator.serviceWorker && window.Notification\n  }\n\n  async #getServiceWorkerRegistration() {\n    return navigator.serviceWorker.getRegistration(\"/service-worker.js\", { scope: \"/\" })\n  }\n\n  async #registerServiceWorker() {\n    await navigator.serviceWorker.register(\"/service-worker.js\", { scope: \"/\" })\n    return navigator.serviceWorker.ready\n  }\n\n  async #subscribe(registration) {\n    registration.pushManager\n      .subscribe({ userVisibleOnly: true, applicationServerKey: this.#vapidPublicKey })\n      .then(subscription => {\n        this.#syncPushSubscription(subscription)\n      })\n  }\n\n  async #syncPushSubscription(subscription) {\n    const response = await post(this.subscriptionsUrlValue, { body: this.#extractJsonPayloadAsString(subscription), responseKind: \"turbo-stream\" })\n    if (response.ok) {\n      this.element.classList.add(this.enabledClass)\n      this.subscribeButtonTarget.hidden = true\n    } else {\n      subscription.unsubscribe()\n    }\n  }\n\n  #showButtonToSubscribe() {\n    this.subscribeButtonTarget.hidden = false\n    this.explainerTarget.hidden = true\n  }\n\n  async #requestPermissionAndSubscribe(registration) {\n    const permission = await Notification.requestPermission()\n    if (permission === \"granted\") this.#subscribe(registration)\n  }\n\n  get #vapidPublicKey() {\n    const encodedVapidPublicKey = document.querySelector('meta[name=\"vapid-public-key\"]').content\n    return this.#urlBase64ToUint8Array(encodedVapidPublicKey)\n  }\n\n  #extractJsonPayloadAsString(subscription) {\n    const { endpoint, keys: { p256dh, auth } } = subscription.toJSON()\n    return JSON.stringify({ push_subscription: { endpoint, p256dh_key: p256dh, auth_key: auth } })\n  }\n\n  // VAPID public key comes encoded as base64 but service worker registration needs it as a Uint8Array\n  #urlBase64ToUint8Array(base64String) {\n    const padding = \"=\".repeat((4 - base64String.length % 4) % 4)\n    const base64 = (base64String + padding).replace(/-/g, \"+\").replace(/_/g, \"/\")\n\n    const rawData = window.atob(base64)\n    const outputArray = new Uint8Array(rawData.length)\n\n    for (let i = 0; i < rawData.length; ++i) {\n      outputArray[i] = rawData.charCodeAt(i)\n    }\n\n    return outputArray\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/outlet_auto_save_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n  static outlets = [ \"auto-save\" ]\n\n  change(event) {\n    this.autoSaveOutlet.change(event)\n  }\n\n  submit() {\n    this.autoSaveOutlet.submit()\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/pagination_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\nimport { createElement } from \"helpers/html_helpers\"\nimport { delay, nextEvent } from \"helpers/timing_helpers\"\nimport { keepingScrollPosition } from \"helpers/scroll_helpers\"\nimport { get } from \"@rails/request.js\"\n\nconst DELAY_BEFORE_OBSERVING = 400\n\nexport default class extends Controller {\n  static targets = [ \"paginationLink\" ]\n  static values = {\n    paginateOnIntersection: { type: Boolean, default: false },\n    discardFrame: Boolean,\n    manualActivation: Boolean\n  }\n\n  initialize() {\n    if (!this.manualActivation) {\n      this.activate()\n    }\n  }\n\n  disconnect() {\n    this.observer?.disconnect()\n  }\n\n  async activate() {\n    await delay(DELAY_BEFORE_OBSERVING)\n\n    if (this.paginateOnIntersectionValue) {\n      this.observer = new IntersectionObserver(this.#intersect, { rootMargin: \"300px\", threshold: 1 })\n    }\n  }\n\n  async paginationLinkTargetConnected(linkElement) {\n    if (this.paginateOnIntersectionValue) {\n      await delay(DELAY_BEFORE_OBSERVING)\n      this.observer?.observe(linkElement)\n    }\n  }\n\n  // Actions\n\n  loadPage({ target }) {\n    this.#loadPaginationLink(target)\n  }\n\n  // Private\n\n  #intersect = ([ entry ]) => {\n    if (entry?.isIntersecting && entry.intersectionRatio === 1) {\n      this.#loadPaginationLink(entry.target)\n    }\n  }\n\n  #loadPaginationLink(linkElement) {\n    this.observer?.unobserve(linkElement)\n\n    keepingScrollPosition(this.#closestSiblingTo(linkElement) || linkElement.parentNode, this.#expandPaginationLink(linkElement))\n  }\n\n  #closestSiblingTo(element) {\n    return element.nextElementSibling || element.previousElementSibling\n  }\n\n  async #expandPaginationLink(linkElement) {\n    linkElement.setAttribute(\"aria-busy\", \"true\")\n\n    if (this.discardFrameValue) {\n      await this.#replacePaginationLinkWithFrameContents(linkElement)\n    } else {\n      await this.#replacePaginationLinkWithFrame(linkElement)\n    }\n\n    linkElement.removeAttribute(\"aria-busy\")\n  }\n\n  async #replacePaginationLinkWithFrameContents(linkElement) {\n    linkElement.outerHTML = await this.#loadHtmlFrom(linkElement)\n  }\n\n  async #loadHtmlFrom(linkElement) {\n    const response = await get(linkElement.href, { responseKind: \"html\" })\n    const html = await response.text\n    const doc = new DOMParser().parseFromString(html, \"text/html\")\n    const element = doc.querySelector(`turbo-frame#${linkElement.dataset.frame}`)\n    return element ? element.innerHTML.trim() : \"\"\n  }\n\n  #replacePaginationLinkWithFrame(linkElement) {\n    const turboFrame = this.#buildTurboFrameFor(linkElement)\n    this.#insertTurboFrameAtPosition(linkElement, turboFrame)\n  }\n\n  #buildTurboFrameFor(linkElement) {\n    const turboFrame = createElement(\"turbo-frame\", {\n      id: linkElement.dataset.frame,\n      src: linkElement.href,\n      refresh: \"morph\",\n      target: \"_top\"\n    })\n\n    this.#keepScrollPositionOnFrameRender(turboFrame, linkElement)\n\n    return turboFrame\n  }\n\n  async #keepScrollPositionOnFrameRender(turboFrame, linkElement) {\n    await nextEvent(turboFrame, \"turbo:before-frame-render\")\n\n    keepingScrollPosition(linkElement, nextEvent(turboFrame, \"turbo:frame-render\"))\n  }\n\n  #insertTurboFrameAtPosition(linkElement, turboFrame) {\n    const container = linkElement.parentNode.parentNode\n\n    if (linkElement.parentNode.firstElementChild === linkElement) {\n      container.prepend(turboFrame)\n    } else {\n      container.append(turboFrame)\n    }\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/reaction_delete_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n  static classes = [ \"deleteable\", \"reveal\", \"perform\" ]\n  static targets = [ \"button\", \"content\" ]\n  static values = { reacterId: String }\n\n  connect() {\n    if (this.#currentUserIsReacter) {\n      this.#setAccessibleAttributes()\n    }\n  }\n\n  reveal() {\n    if (this.#currentUserIsReacter) {\n      this.element.classList.toggle(this.revealClass)\n      this.contentTarget.ariaExpanded = this.element.classList.contains(this.revealClass)\n      this.buttonTarget.focus()\n    }\n  }\n\n  perform() {\n    this.element.classList.add(this.performClass)\n  }\n\n  #setAccessibleAttributes() {\n    this.contentTarget.role = \"button\"\n    this.contentTarget.tabIndex = 0\n    this.contentTarget.ariaExpanded = false\n    this.element.classList.add(this.deleteableClass)\n  }\n\n  get #currentUserIsReacter() {\n    return Current.user.id === this.reacterIdValue\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/reaction_emoji_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n  static targets = [ \"input\" ]\n\n  insertEmoji(event) {\n    const emojiChar = event.target.getAttribute(\"data-emoji\")\n    const value = this.inputTarget.value\n    const newValue = `${value}${emojiChar}`\n\n    if (this.inputTarget.maxLength > 0 && newValue.length <= this.inputTarget.maxLength) {\n      this.inputTarget.value = newValue\n    }\n\n    this.inputTarget.focus()\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/related_element_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n  static targets = [ \"related\" ]\n  static classes = [ \"highlight\" ]\n\n  connect() {\n    this.#highlight(null)\n  }\n\n  highlight(event) {\n    this.#highlight(event.currentTarget.dataset.relatedElementGroupValue)\n  }\n\n  unhighlight() {\n    this.#highlight(null)\n  }\n\n  #highlight(groupValue) {\n    this.relatedTargets.forEach(element =>\n      element.classList.toggle(this.highlightClass, element.dataset.relatedElementGroupValue === groupValue)\n    )\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/retarget_links_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n  connect() {\n    this.element.querySelectorAll(\"a\").forEach(this.#retargetLink.bind(this))\n  }\n\n  #retargetLink(link) {\n    link.target = this.#targetsSameDomain(link) ? \"_top\" : \"_blank\"\n  }\n\n  #targetsSameDomain(link) {\n    return link.href.startsWith(window.location.origin)\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/scroll_to_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n  static targets = [ \"target\" ]\n\n  connect() {\n    this.#scrollTargetIntoView()\n  }\n\n  #scrollTargetIntoView() {\n    if(this.hasTargetTarget) {\n      this.element.scrollTo({\n        top: this.targetTarget.offsetTop - this.element.offsetHeight / 2 + this.targetTarget.offsetHeight / 2,\n        left: this.targetTarget.offsetLeft - this.element.offsetWidth / 2 + this.targetTarget.offsetWidth / 2,\n        behavior: \"instant\"\n      })\n    }\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/search_form_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n  static targets = [\"searchInput\"]\n\n  clearInput() {\n    if (this.searchInputTarget.value) {\n      this.searchInputTarget.value = \"\"\n      this.searchInputTarget.focus()\n    } else {\n      this.dispatch(\"reset\")\n    }\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/soft_keyboard_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\nimport { nextEventNamed } from \"helpers/timing_helpers\"\nimport { isTouchDevice } from \"helpers/platform_helpers\"\n\nexport default class extends Controller {\n  // Only load for touch devices\n  static get shouldLoad() {\n    return isTouchDevice()\n  }\n\n  // Use a fake input to trigger the soft keyboard on actions that load async content\n  // See https://gist.github.com/cathyxz/73739c1bdea7d7011abb236541dc9aaa\n  async open(event) {\n    const fakeInput = this.#focusOnFakeInput()\n    this.#removeOnFocusOut(fakeInput)\n  }\n\n  #focusOnFakeInput() {\n    const fakeInput = document.createElement(\"input\")\n\n    fakeInput.setAttribute(\"type\", \"text\")\n    fakeInput.setAttribute(\"class\", \"input--invisible\")\n\n    this.element.appendChild(fakeInput)\n    fakeInput.focus()\n\n    return fakeInput\n  }\n\n  async #removeOnFocusOut(element) {\n    await nextEventNamed(\"focusout\", element)\n    element.remove()\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/syntax_highlight_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\nimport { highlightAll } from \"lexxy\"\n\nexport default class extends Controller {\n  connect() {\n    highlightAll()\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/theme_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n  static targets = [\"lightButton\", \"darkButton\", \"autoButton\"]\n\n  connect() {\n    this.#applyStoredTheme()\n  }\n\n  setLight() {\n    this.#theme = \"light\"\n  }\n\n  setDark() {\n    this.#theme = \"dark\"\n  }\n\n  setAuto() {\n    this.#theme = \"auto\"\n  }\n\n  get #storedTheme() {\n    return localStorage.getItem(\"theme\") || \"auto\"\n  }\n\n  set #theme(theme) {\n    localStorage.setItem(\"theme\", theme)\n\n    const currentTheme = document.documentElement.getAttribute(\"data-theme\") || \"auto\"\n    const hasChanged = currentTheme !== theme\n\n    const prefersReducedMotion = window.matchMedia?.(\"(prefers-reduced-motion: reduce)\")?.matches\n    const animate = hasChanged && !prefersReducedMotion\n\n    const applyTheme = () => {\n      if (theme === \"auto\") {\n        document.documentElement.removeAttribute(\"data-theme\")\n      } else {\n        document.documentElement.setAttribute(\"data-theme\", theme)\n      }\n\n      this.#updateButtons()\n    }\n\n    if (animate && document.startViewTransition) {\n      document.startViewTransition(applyTheme)\n    } else {\n      applyTheme()\n    }\n  }\n\n  #applyStoredTheme() {\n    this.#theme = this.#storedTheme\n  }\n\n  #updateButtons() {\n    const storedTheme = this.#storedTheme\n\n    if (this.hasLightButtonTarget) { this.lightButtonTarget.checked = (storedTheme === \"light\") }\n    if (this.hasDarkButtonTarget)  { this.darkButtonTarget.checked  = (storedTheme === \"dark\") }\n    if (this.hasAutoButtonTarget)  { this.autoButtonTarget.checked  = (storedTheme !== \"light\" && storedTheme !== \"dark\") }\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/timezone_cookie_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n  connect() {\n    this.#setTimezoneCookie()\n  }\n\n  #setTimezoneCookie() {\n    const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone\n    document.cookie = `timezone=${encodeURIComponent(timezone)}; path=/`\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/toggle_class_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n  static classes = [ \"toggle\" ]\n  static targets = [ \"checkbox\" ]\n\n  toggle() {\n    this.element.classList.toggle(this.toggleClass)\n  }\n\n  add() {\n    this.element.classList.add(this.toggleClass)\n  }\n\n  remove() {\n    this.element.classList.remove(this.toggleClass)\n  }\n\n  checkAll() {\n    this.checkboxTargets.forEach(checkbox => {\n      checkbox.checked = true\n    })\n  }\n\n  checkNone() {\n    this.checkboxTargets.forEach(checkbox => {\n      if (checkbox.dataset.boardsFormTarget === \"meCheckbox\") return\n      checkbox.checked = false\n    })\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/toggle_enable_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n  static targets = [ \"element\" ]\n\n  toggle() {\n    this.elementTargets.forEach((element) => {\n      element.toggleAttribute(\"disabled\")\n    })\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/tooltip_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\nimport { orient } from \"helpers/orientation_helpers\"\n\nexport default class extends Controller {\n  static targets = [ \"tooltip\" ]\n\n  connect() {\n    this.element.addEventListener(\"mouseenter\", this.mouseEnter.bind(this))\n    this.element.addEventListener(\"mouseout\", this.mouseOut.bind(this))\n  }\n\n  disconnect() {\n    this.element.removeEventListener(\"mouseenter\", this.mouseEnter.bind(this))\n    this.element.removeEventListener(\"mouseout\", this.mouseOut.bind(this))\n  }\n\n  mouseEnter(event) {\n    orient({ target: this.#tooltipElement, anchor: this.element })\n  }\n\n  mouseOut(event) {\n    orient({ target: this.#tooltipElement, reset: true })\n  }\n\n  get #tooltipElement() {\n    return this.element.querySelector(\".for-screen-reader\")\n  }\n\n  get #tooltipText() {\n    return this.#tooltipElement.innerText\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/touch_placeholder_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\nimport { isTouchDevice } from \"helpers/platform_helpers\"\n\nexport default class extends Controller {\n  static get shouldLoad() {\n    return isTouchDevice()\n  }\n\n  static values = { placeholder: String }\n\n  connect() {\n    if (this.hasPlaceholderValue) {\n      this.element.placeholder = this.placeholderValue\n    }\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/turbo_navigation_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n  static values = { label: String }\n  static targets = [ \"referrerBackLink\" ]\n\n  rememberLocation() {\n    sessionStorage.setItem(\"referrerUrl\", window.location.href)\n    sessionStorage.setItem(\"referrerLabel\", this.labelValue)\n  }\n\n  backIfSamePath(event) {\n    if (event.ctrlKey || event.metaKey || event.shiftKey) { return }\n\n    const link = event.target.closest(\"a\")\n    const targetUrl = new URL(link.href)\n\n    if (this.#referrerPath && targetUrl.pathname === this.#referrerPath) {\n      event.preventDefault()\n      Turbo.visit(this.#referrerUrl)\n    }\n  }\n\n  referrerBackLinkTargetConnected(link) {\n    if (!this.#referrerUrl || !this.#referrerLabel) { return }\n\n    const stripTrailingSlash = path => path.replace(/\\/$/, \"\")\n    const allowedPaths = (link.dataset.turboNavigationAllowedReferrerPaths || \"\").split(\",\").map(stripTrailingSlash)\n    const referrerPath = stripTrailingSlash(new URL(this.#referrerUrl).pathname)\n    if (!allowedPaths.includes(referrerPath)) { return }\n\n    link.href = this.#referrerUrl\n    const strong = link.querySelector(\"strong\")\n    if (strong) { strong.textContent = `Back to ${this.#referrerLabel}` }\n  }\n\n  get #referrerPath() {\n    if (!this.#referrerUrl) return null\n    return new URL(this.#referrerUrl).pathname\n  }\n\n  get #referrerUrl() {\n    return sessionStorage.getItem(\"referrerUrl\")\n  }\n\n  get #referrerLabel() {\n    return sessionStorage.getItem(\"referrerLabel\")\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/upload_preview_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n  static targets = [ \"image\", \"input\", \"fileName\", \"placeholder\" ]\n\n  previewImage() {\n    if (this.#file) {\n      this.imageTarget.src = URL.createObjectURL(this.#file)\n      this.imageTarget.onload = () => URL.revokeObjectURL(this.imageTarget.src)\n    }\n  }\n\n  previewFileName() {\n    this.#file ? this.#showFileName() : this.#showPlaceholder()\n  }\n\n  #showFileName() {\n    this.fileNameTarget.innerText = this.#file.name\n    this.fileNameTarget.removeAttribute(\"hidden\")\n    this.placeholderTarget.setAttribute(\"hidden\", true)\n  }\n\n  #showPlaceholder() {\n    this.placeholderTarget.removeAttribute(\"hidden\")\n    this.fileNameTarget.setAttribute(\"hidden\", true)\n  }\n\n  get #file() {\n    return this.inputTarget.files[0]\n  }\n}\n"
  },
  {
    "path": "app/javascript/helpers/bridge/viewport_helpers.js",
    "content": "let top = 0\nconst viewportTarget = window.visualViewport || window\n\nexport const viewport = {\n  get top() {\n    return top\n  },\n  get height() {\n    return viewportTarget.height || window.innerHeight\n  }\n}\n\nfunction update() {\n  requestAnimationFrame(() => {\n    const styles = getComputedStyle(document.documentElement)\n    const customInset = styles.getPropertyValue(\"--custom-safe-inset-top\")\n    const fallbackInset = styles.getPropertyValue(\"--safe-area-inset-top\")\n    const insetValue = (customInset || fallbackInset).trim()\n    top = parseInt(insetValue || \"0\", 10) || 0\n  })\n}\n\nviewportTarget.addEventListener(\"resize\", update)\nupdate()\n"
  },
  {
    "path": "app/javascript/helpers/date_helpers.js",
    "content": "export function differenceInDays(fromDate, toDate) {\n  return Math.round(Math.abs((beginningOfDay(toDate) - beginningOfDay(fromDate)) / (1000 * 60 * 60 * 24)))\n}\n\nexport function signedDifferenceInDays(fromDate, toDate) {\n  return Math.round((beginningOfDay(toDate) - beginningOfDay(fromDate)) / (1000 * 60 * 60 * 24))\n}\n\nexport function beginningOfDay(date) {\n  return new Date(date.getFullYear(), date.getMonth(), date.getDate())\n}\n\nexport function secondsToDate(seconds) {\n  return new Date(seconds * 1000)\n}\n"
  },
  {
    "path": "app/javascript/helpers/form_helpers.js",
    "content": "import { FetchRequest } from \"@rails/request.js\"\n\nexport async function submitForm(form) {\n  const request = new FetchRequest(form.method, form.action, {\n    body: new FormData(form)\n  })\n\n  return await request.perform()\n}\n"
  },
  {
    "path": "app/javascript/helpers/html_helpers.js",
    "content": "export function createElement(name, properties) {\n  const element = document.createElement(name)\n\n  for (var key in properties) {\n    element.setAttribute(key, properties[key])\n  }\n\n  return element\n}\n"
  },
  {
    "path": "app/javascript/helpers/orientation_helpers.js",
    "content": "const EDGE_THRESHOLD = 16\n\nexport function orient({ target, anchor = null, reset = false }) {\n  target.classList.remove(\"orient-left\", \"orient-right\")\n  target.style.removeProperty(\"--orient-offset\")\n\n  if (reset) return\n\n  const targetGeometry = geometry(target)\n  const anchorGeometry = geometry(anchor)\n  const shouldOrientLeft = targetGeometry.spaceOnRight < EDGE_THRESHOLD && targetGeometry.spaceOnRight < targetGeometry.spaceOnLeft\n  const shouldOrientRight = targetGeometry.spaceOnLeft < EDGE_THRESHOLD && targetGeometry.spaceOnLeft < targetGeometry.spaceOnRight\n\n  if (shouldOrientLeft) {\n    orientLeft({ el: target, targetGeometry, anchorGeometry })\n  } else if (shouldOrientRight) {\n    orientRight({ el: target, targetGeometry, anchorGeometry })\n  }\n}\n\nfunction orientLeft({ el, targetGeometry, anchorGeometry }) {\n  const offset = Math.min(0, anchorGeometry.spaceOnLeft + anchorGeometry.width - targetGeometry.width) * -1\n  el.classList.add(\"orient-left\")\n  el.style.setProperty(\"--orient-offset\", `${offset}px`)\n}\n\nfunction orientRight({ el, targetGeometry, anchorGeometry }) {\n  const offset = Math.max(0, anchorGeometry.spaceOnLeft + targetGeometry.width - window.innerWidth) * -1\n  el.classList.add(\"orient-right\")\n  el.style.setProperty(\"--orient-offset\", `${offset}px`)\n}\n\nfunction geometry(el) {\n  const rect = el.getBoundingClientRect()\n  return {\n    spaceOnLeft: rect.left,\n    spaceOnRight: window.innerWidth - rect.right,\n    width: rect.width\n  }\n}\n"
  },
  {
    "path": "app/javascript/helpers/platform_helpers.js",
    "content": "export function isTouchDevice() {\n  return \"ontouchstart\" in window && navigator.maxTouchPoints > 0\n}\n\nexport function isIos() {\n  return /iPhone|iPad/.test(navigator.userAgent)\n}\n\nexport function isAndroid() {\n  return /Android/.test(navigator.userAgent)\n}\n\nexport function isMobile() {\n  return isIos() || isAndroid()\n}\n\nexport function isNative() {\n  return /Hotwire Native/.test(navigator.userAgent)\n}\n"
  },
  {
    "path": "app/javascript/helpers/scroll_helpers.js",
    "content": "export async function keepingScrollPosition(element, promise) {\n  const originalPosition = element.getBoundingClientRect()\n\n  await promise\n\n  const currentPosition = element.getBoundingClientRect()\n\n  const yDiff = currentPosition.top - originalPosition.top\n  const xDiff = currentPosition.left - originalPosition.left\n\n  findNearestScrollableYAncestor(element).scrollTop += yDiff\n  findNearestScrollableXAncestor(element).scrollLeft += xDiff\n}\n\nexport function isFullyVisible(element, container = document.documentElement) {\n  const elementRect = element.getBoundingClientRect()\n  const containerRect = container.getBoundingClientRect()\n\n  return elementRect.top >= containerRect.top &&\n    elementRect.bottom <= containerRect.bottom &&\n    elementRect.left >= containerRect.left &&\n    elementRect.right <= containerRect.right\n}\n\nexport function isScrolledToBottom(element, threshold = 100) {\n  return (element.scrollHeight - element.scrollTop - element.clientHeight) < threshold\n}\n\nexport function scrollToBottom(element) {\n  element.scrollTop = element.scrollHeight\n}\n\nexport function scrollIntoView(element, options = { inline: \"center\", block: \"center\", behavior: \"instant\" }) {\n  element.scrollIntoView(options)\n}\n\n// Private\n\nfunction findNearestScrollableYAncestor(refElement) {\n  return findNearest(refElement, (element) => {\n    const largerThanVisibleArea = element.scrollHeight > element.clientHeight\n\n    const overflowY = getComputedStyle(element).overflowY\n    const scrollableStyle = overflowY === \"scroll\" || overflowY === \"auto\"\n\n    return largerThanVisibleArea && scrollableStyle\n  })\n}\n\nfunction findNearestScrollableXAncestor(refElement) {\n  return findNearest(refElement, (element) => {\n    const largerThanVisibleArea = element.scrollWidth > element.clientWidth\n\n    const overflowX = getComputedStyle(element).overflowX\n    const scrollableStyle = overflowX === \"scroll\" || overflowX === \"auto\"\n\n    return largerThanVisibleArea && scrollableStyle\n  })\n}\n\nfunction findNearest(element, fn) {\n  while (element) {\n    if (fn(element)) {\n      return element\n    } else {\n      element = element.parentElement\n    }\n  }\n\n  return document.documentElement\n}\n"
  },
  {
    "path": "app/javascript/helpers/text_helpers.js",
    "content": "export function isMultiLineString(string) {\n  return /\\r|\\n/.test(string)\n}\n\nexport function normalizeFilteredText(string) {\n  return string\n    .toLowerCase()\n    .normalize(\"NFD\").replace(/[\\u0300-\\u036f]/g, \"\") // Remove diacritics\n}\n\nexport function filterMatches(text, potentialMatch) {\n  return normalizeFilteredText(text).includes(normalizeFilteredText(potentialMatch))\n}\n\nexport function toSentence(array, options = {}) {\n  const defaultConnectors = {\n    words_connector: \", \",\n    two_words_connector: \" and \",\n    last_word_connector: \", and \"\n  }\n\n  const connectors = { ...defaultConnectors, ...options }\n\n  if (array.length === 0) {\n    return \"\"\n  }\n\n  if (array.length === 1) {\n    return array[0]\n  }\n\n  if (array.length === 2) {\n    return array.join(connectors.two_words_connector)\n  }\n\n  return array.slice(0, -1).join(connectors.words_connector) + connectors.last_word_connector + array[array.length - 1]\n}\n"
  },
  {
    "path": "app/javascript/helpers/timing_helpers.js",
    "content": "export function throttle(fn, delay = 1000) {\n  let timeoutId = null\n\n  return (...args) => {\n    if (!timeoutId) {\n      fn(...args)\n      timeoutId = setTimeout(() => timeoutId = null, delay)\n    }\n  }\n}\n\nexport function debounce(fn, delay = 1000) {\n  let timeoutId = null\n\n  return (...args) => {\n    clearTimeout(timeoutId)\n    timeoutId = setTimeout(() => fn.apply(this, args), delay)\n  }\n}\n\nexport function nextEventLoopTick() {\n  return delay(0)\n}\n\nexport function onNextEventLoopTick(callback) {\n  setTimeout(callback, 0)\n}\n\nexport function nextEvent(element, eventName) {\n  return new Promise(resolve => element.addEventListener(eventName, resolve, { once: true }))\n}\n\nexport function nextFrame() {\n  return new Promise(requestAnimationFrame)\n}\n\nexport function nextEventNamed(eventName, element = window) {\n  return new Promise(resolve => element.addEventListener(eventName, resolve, { once: true }))\n}\n\nexport function delay(ms) {\n  return new Promise(resolve => setTimeout(resolve, ms))\n}\n"
  },
  {
    "path": "app/javascript/initializers/bridge/bridge_element.js",
    "content": "import { BridgeElement } from \"@hotwired/hotwire-native-bridge\"\n\nBridgeElement.prototype.getButton = function() {\n  return {\n    title: this.title,\n    icon: this.getIcon(),\n    displayTitle: this.getDisplayTitle(),\n    displayAsPrimaryAction: this.getDisplayAsPrimaryAction(),\n    slot: this.getSlot()\n  }\n}\n\nBridgeElement.prototype.getIcon = function() {\n  const url = this.bridgeAttribute(\"icon-url\")\n\n  if (url) {\n    return { url }\n  }\n\n  return null\n}\n\nBridgeElement.prototype.getDisplayTitle = function() {\n  return !!this.bridgeAttribute(\"display-title\")\n}\n\nBridgeElement.prototype.getDisplayAsPrimaryAction = function() {\n  return !!this.bridgeAttribute(\"display-as-primary-action\")\n}\n\nBridgeElement.prototype.getSlot = function () {\n  return this.bridgeAttribute(\"slot\") ?? \"right\"\n}\n"
  },
  {
    "path": "app/javascript/initializers/current.js",
    "content": "class Current {\n  get user() {\n    const currentUserId = this.#extractContentFromMetaTag(\"current-user-id\")\n\n    if (currentUserId) {\n      return { id: currentUserId }\n    }\n  }\n\n  #extractContentFromMetaTag(name) {\n    return document.head.querySelector(`meta[name=\"${name}\"]`)?.getAttribute(\"content\")\n  }\n}\n\nwindow.Current = new Current()\n"
  },
  {
    "path": "app/javascript/initializers/index.js",
    "content": "import \"initializers/current\"\nimport \"initializers/bridge/bridge_element\"\nimport \"initializers/offline\"\nimport \"initializers/lexxy_markdown_paste\"\n"
  },
  {
    "path": "app/javascript/initializers/lexxy_markdown_paste.js",
    "content": "document.addEventListener(\"lexxy:insert-markdown\", (event) => {\n  event.detail.addBlockSpacing()\n})\n"
  },
  {
    "path": "app/javascript/initializers/offline.js",
    "content": "import { Turbo } from \"@hotwired/turbo-rails\"\n\nif (Current.user) {\n  Turbo.offline.start(\"/service-worker.js\", {\n    scope: \"/\",\n    native: true,\n    preload: /\\/assets\\//\n  })\n}\n"
  },
  {
    "path": "app/javascript/lib/action_pack/passkey.js",
    "content": "// JS companion for the ActionPack::Passkey Ruby helpers.\n//\n// Binds click handlers to passkey buttons and manages the WebAuthn ceremony\n// lifecycle (challenge refresh, credential creation/authentication, form submission).\n//\n// Expected data attributes:\n//   [data-passkey=\"create\"]              — triggers the registration ceremony\n//   [data-passkey=\"sign_in\"]             — triggers the authentication ceremony\n//   [data-passkey-mediation=\"conditional\"] — on a <form>, enables autofill-assisted sign in\n//   [data-passkey-errors]                — container whose data-passkey-error-state is set on failure\n//   [data-passkey-error=\"error|cancelled\"] — children shown/hidden via CSS based on error state\n//   [data-passkey-field=\"...\"]           — hidden fields populated before form submission\n//\n// Custom events (all bubble):\n//   passkey:start   — ceremony begun\n//   passkey:success — credential obtained, form about to submit\n//   passkey:error   — ceremony failed; detail: { error, cancelled }\n//\n// Meta tags (rendered by the Ruby form helpers):\n//   <meta name=\"passkey-creation-options\"> — JSON WebAuthn creation options\n//   <meta name=\"passkey-request-options\">  — JSON WebAuthn request options\n//   <meta name=\"passkey-challenge-url\">    — endpoint to refresh the challenge nonce\n\nimport { register, authenticate } from \"lib/action_pack/webauthn\"\n\nlet listeners\nlet currentDocument\n\ndocument.addEventListener(\"DOMContentLoaded\", setup)\ndocument.addEventListener(\"turbo:load\", setup)\ndocument.addEventListener(\"turbo:before-cache\", teardown)\n\n// Set error state on the nearest [data-passkey-errors] container.\n// The app's CSS is responsible for showing/hiding children based on\n// the data-passkey-error-state attribute (\"error\" or \"cancelled\").\ndocument.addEventListener(\"passkey:error\", ({ target, detail: { cancelled } }) => {\n  const container = target.closest(\"[data-passkey-errors]\")\n\n  if (container) {\n    container.dataset.passkeyErrorState = cancelled ? \"cancelled\" : \"error\"\n  }\n})\n\n// Bind click handlers to passkey buttons and attempt conditional mediation.\n// Guards against duplicate setup.\nfunction setup() {\n  if (currentDocument !== document.documentElement) {\n    currentDocument = document.documentElement\n\n    listeners?.abort()\n    listeners = new AbortController()\n\n    for (const button of document.querySelectorAll('[data-passkey=\"create\"]')) {\n      button.addEventListener(\"click\", () => createPasskey(button), { signal: listeners.signal })\n    }\n\n    for (const button of document.querySelectorAll('[data-passkey=\"sign_in\"]')) {\n      button.addEventListener(\"click\", () => signInWithPasskey(button), { signal: listeners.signal })\n    }\n\n    attemptConditionalMediation()\n  }\n}\n\n// Reset transient DOM state and unbind event handlers to prevent leaks and duplicate handlers.\nfunction teardown() {\n  currentDocument = null\n  listeners?.abort()\n\n  for (const button of document.querySelectorAll('[data-passkey][disabled]')) {\n    button.disabled = false\n  }\n\n  for (const container of document.querySelectorAll(\"[data-passkey-errors]\")) {\n    delete container.dataset.passkeyErrorState\n  }\n}\n\n// Run the WebAuthn registration ceremony: refresh the challenge, prompt the\n// browser to create a credential, fill the form's hidden fields, and submit.\nasync function createPasskey(button) {\n  const form = button.closest(\"form\")\n\n  if (form) {\n    button.disabled = true\n    button.dispatchEvent(new CustomEvent(\"passkey:start\", { bubbles: true }))\n\n    try {\n      if (!passkeysAvailable()) throw new Error(\"Passkeys are not supported by this browser\")\n\n      const creationOptions = getCreationOptions()\n      if (!creationOptions) throw new Error(\"Missing passkey creation options\")\n\n      await refreshChallenge(creationOptions)\n      const passkey = await register(creationOptions)\n\n      button.dispatchEvent(new CustomEvent(\"passkey:success\", { bubbles: true }))\n      fillCreateForm(form, passkey)\n      form.submit()\n    } catch (error) {\n      button.disabled = false\n\n      const cancelled = error.name === \"AbortError\" || error.name === \"NotAllowedError\"\n      button.dispatchEvent(new CustomEvent(\"passkey:error\", { bubbles: true, detail: { error, cancelled } }))\n    }\n  }\n}\n\nfunction passkeysAvailable() {\n  return !!window.PublicKeyCredential\n}\n\n// Read WebAuthn creation options from the <meta> tag rendered by\n// +passkey_creation_options_meta_tag+. Returns undefined if the tag is missing.\nfunction getCreationOptions() {\n  return getOptions(\"passkey-creation-options\")\n}\n\n// Parse and return the JSON content of a <meta> tag by name.\nfunction getOptions(name) {\n  const meta = document.querySelector(`meta[name=\"${name}\"]`)\n\n  if (meta) {\n    return JSON.parse(meta.content)\n  }\n}\n\n// POST to the challenge endpoint to get a fresh nonce, preventing replay attacks\n// when the page has been open for a while before the user initiates the ceremony.\nasync function refreshChallenge(options) {\n  const url = document.querySelector('meta[name=\"passkey-challenge-url\"]')?.content\n  if (!url) throw new Error(\"Missing passkey challenge URL\")\n  const token = document.querySelector('meta[name=\"csrf-token\"]')?.content\n\n  const response = await fetch(url, {\n    method: \"POST\",\n    credentials: \"same-origin\",\n    headers: {\n      \"X-CSRF-Token\": token,\n      \"Accept\": \"application/json\"\n    }\n  })\n\n  if (!response.ok) throw new Error(\"Failed to refresh challenge\")\n\n  const { challenge } = await response.json()\n  options.challenge = challenge\n}\n\n// Populate the registration form's hidden fields with the credential response.\n// Clones the transports template input for each reported transport.\nfunction fillCreateForm(form, passkey) {\n  form.querySelector('[data-passkey-field=\"client_data_json\"]').value = passkey.client_data_json\n  form.querySelector('[data-passkey-field=\"attestation_object\"]').value = passkey.attestation_object\n\n  const template = form.querySelector('[data-passkey-field=\"transports\"]')\n  for (const transport of passkey.transports) {\n    const input = template.cloneNode()\n    input.value = transport\n    template.before(input)\n  }\n  template.remove()\n}\n\n// Run the WebAuthn authentication ceremony: refresh the challenge, prompt the\n// browser to sign with an existing credential, fill the form, and submit.\nasync function signInWithPasskey(button) {\n  const form = button.closest(\"form\")\n\n  if (form) {\n    button.disabled = true\n    button.dispatchEvent(new CustomEvent(\"passkey:start\", { bubbles: true }))\n\n    try {\n      if (!passkeysAvailable()) throw new Error(\"Passkeys are not supported by this browser\")\n\n      const requestOptions = getRequestOptions()\n      if (!requestOptions) throw new Error(\"Missing passkey request options\")\n\n      await refreshChallenge(requestOptions)\n      const passkey = await authenticate(requestOptions)\n\n      button.dispatchEvent(new CustomEvent(\"passkey:success\", { bubbles: true }))\n      fillSignInForm(form, passkey)\n      form.submit()\n    } catch (error) {\n      button.disabled = false\n\n      const cancelled = error.name === \"AbortError\" || error.name === \"NotAllowedError\"\n      button.dispatchEvent(new CustomEvent(\"passkey:error\", { bubbles: true, detail: { error, cancelled } }))\n    }\n  }\n}\n\n// Read WebAuthn request options from the <meta> tag rendered by\n// +passkey_request_options_meta_tag+. Returns undefined if the tag is missing.\nfunction getRequestOptions() {\n  return getOptions(\"passkey-request-options\")\n}\n\n// Populate the authentication form's hidden fields with the assertion response.\nfunction fillSignInForm(form, passkey) {\n  form.querySelector('[data-passkey-field=\"id\"]').value = passkey.id\n  form.querySelector('[data-passkey-field=\"client_data_json\"]').value = passkey.client_data_json\n  form.querySelector('[data-passkey-field=\"authenticator_data\"]').value = passkey.authenticator_data\n  form.querySelector('[data-passkey-field=\"signature\"]').value = passkey.signature\n}\n\n// Start the conditional mediation (autofill) ceremony if the page opts in with\n// a form[data-passkey-mediation=\"conditional\"] and the browser supports it.\n// Unlike the button-driven ceremonies, this runs automatically on page load.\nasync function attemptConditionalMediation() {\n  if (await conditionalMediationAvailable()) {\n    const form = document.querySelector('form[data-passkey-mediation=\"conditional\"]')\n    form.dispatchEvent(new CustomEvent(\"passkey:start\", { bubbles: true }))\n\n    const requestOptions = getRequestOptions()\n\n    try {\n      await refreshChallenge(requestOptions)\n\n      const passkey = await authenticate(requestOptions, { mediation: \"conditional\" })\n\n      form.dispatchEvent(new CustomEvent(\"passkey:success\", { bubbles: true }))\n      fillSignInForm(form, passkey)\n      form.submit()\n    } catch (error) {\n      const cancelled = error.name === \"AbortError\" || error.name === \"NotAllowedError\"\n      form.dispatchEvent(new CustomEvent(\"passkey:error\", { bubbles: true, detail: { error, cancelled } }))\n    }\n  }\n}\n\n// Check all preconditions for conditional mediation: the page has opted in,\n// request options are present, the browser supports passkeys, and the browser\n// supports the conditional mediation UI (autofill).\nasync function conditionalMediationAvailable() {\n  return isConditionalMediationFormPresent() &&\n         getRequestOptions() &&\n         passkeysAvailable() &&\n         await window.PublicKeyCredential.isConditionalMediationAvailable?.()\n}\n\nfunction isConditionalMediationFormPresent() {\n  return !!document.querySelector('form[data-passkey-mediation=\"conditional\"]')\n}\n"
  },
  {
    "path": "app/javascript/lib/action_pack/webauthn.js",
    "content": "// Thin wrapper around the browser WebAuthn API (navigator.credentials).\n//\n// Handles the base64url ↔ ArrayBuffer conversions required by the spec so\n// callers can work with plain JSON objects from the server-rendered meta tags.\n\n// Call navigator.credentials.create() with the given creation options.\n// Returns { client_data_json, attestation_object, transports } with all\n// binary fields encoded as base64url strings ready for form submission.\nexport async function register(options) {\n  const publicKey = prepareCreationOptions(options)\n  const credential = await navigator.credentials.create({ publicKey })\n\n  return {\n    client_data_json: new TextDecoder().decode(credential.response.clientDataJSON),\n    attestation_object: bufferToBase64url(credential.response.attestationObject),\n    transports: credential.response.getTransports?.() || []\n  }\n}\n\n// Call navigator.credentials.get() with the given request options.\n// Accepts an optional signal (AbortSignal) and mediation hint (\"conditional\"\n// for autofill UI). Returns { id, client_data_json, authenticator_data, signature }\n// with binary fields encoded as base64url strings.\nexport async function authenticate(options, { signal, mediation } = {}) {\n  const publicKey = prepareRequestOptions(options)\n  const credential = await navigator.credentials.get({ publicKey, signal, mediation })\n\n  return {\n    id: credential.id,\n    client_data_json: new TextDecoder().decode(credential.response.clientDataJSON),\n    authenticator_data: bufferToBase64url(credential.response.authenticatorData),\n    signature: bufferToBase64url(credential.response.signature)\n  }\n}\n\n// Convert JSON creation options into the format expected by the browser:\n// decode base64url challenge, user.id, and excludeCredentials[].id into ArrayBuffers.\nfunction prepareCreationOptions(options) {\n  return {\n    ...options,\n    challenge: base64urlToBuffer(options.challenge),\n    user: { ...options.user, id: base64urlToBuffer(options.user.id) },\n    excludeCredentials: (options.excludeCredentials || []).map(cred => ({\n      ...cred,\n      id: base64urlToBuffer(cred.id)\n    }))\n  }\n}\n\n// Convert JSON request options into the format expected by the browser:\n// decode base64url challenge and allowCredentials[].id into ArrayBuffers.\n// Strips allowCredentials entirely when empty so the browser prompts for\n// any available credential (required for conditional mediation).\nfunction prepareRequestOptions(options) {\n  const prepared = {\n    ...options,\n    challenge: base64urlToBuffer(options.challenge)\n  }\n\n  if (options.allowCredentials?.length) {\n    prepared.allowCredentials = options.allowCredentials.map(cred => ({\n      ...cred,\n      id: base64urlToBuffer(cred.id)\n    }))\n  } else {\n    delete prepared.allowCredentials\n  }\n\n  return prepared\n}\n\nfunction base64urlToBuffer(base64url) {\n  const base64 = base64url.replace(/-/g, \"+\").replace(/_/g, \"/\")\n  const padding = \"=\".repeat((4 - base64.length % 4) % 4)\n  const binary = atob(base64 + padding)\n  return Uint8Array.from(binary, c => c.charCodeAt(0)).buffer\n}\n\nfunction bufferToBase64url(buffer) {\n  const bytes = new Uint8Array(buffer)\n  const binary = String.fromCharCode(...bytes)\n  return btoa(binary).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\")\n}\n"
  },
  {
    "path": "app/jobs/account/data_import_job.rb",
    "content": "class Account::DataImportJob < ApplicationJob\n  include ActiveJob::Continuable\n\n  queue_as :backend\n  discard_on Account::DataTransfer::RecordSet::IntegrityError, ZipFile::InvalidFileError\n\n  def perform(import)\n    step :check do |step|\n      import.check \\\n        start: step.cursor,\n        callback: ->(record_set:, file:) { step.set!([ record_set.model.name, file ]) }\n    end\n\n    step :process do |step|\n      import.process \\\n        start: step.cursor,\n        callback: ->(record_set:, files:) { step.set!([ record_set.model.name, files.last ]) }\n    end\n  end\nend\n"
  },
  {
    "path": "app/jobs/account/incinerate_due_job.rb",
    "content": "class Account::IncinerateDueJob < ApplicationJob\n  include ActiveJob::Continuable\n\n  queue_as :incineration\n\n  def perform\n    step :incineration do |step|\n      Account.due_for_incineration.find_each do |account|\n        account.incinerate\n        step.checkpoint!\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "app/jobs/application_job.rb",
    "content": "class ApplicationJob < ActiveJob::Base\n  # Automatically retry jobs that encountered a deadlock\n  # retry_on ActiveRecord::Deadlocked\n\n  # Most jobs are safe to ignore if the underlying records are no longer available\n  # discard_on ActiveJob::DeserializationError\nend\n"
  },
  {
    "path": "app/jobs/board/clean_inaccessible_data_job.rb",
    "content": "class Board::CleanInaccessibleDataJob < ApplicationJob\n  discard_on ActiveJob::DeserializationError\n\n  def perform(user, board)\n    board.clean_inaccessible_data_for(user)\n  end\nend\n"
  },
  {
    "path": "app/jobs/card/activity_spike/detection_job.rb",
    "content": "class Card::ActivitySpike::DetectionJob < ApplicationJob\n  discard_on ActiveJob::DeserializationError\n\n  def perform(card)\n    card.detect_activity_spikes\n  end\nend\n"
  },
  {
    "path": "app/jobs/card/clean_inaccessible_data_job.rb",
    "content": "class Card::CleanInaccessibleDataJob < ApplicationJob\n  discard_on ActiveJob::DeserializationError\n\n  def perform(card)\n    card.clean_inaccessible_data\n  end\nend\n"
  },
  {
    "path": "app/jobs/card/remove_inaccessible_notifications_job.rb",
    "content": "class Card::RemoveInaccessibleNotificationsJob < ApplicationJob\n  discard_on ActiveJob::DeserializationError\n\n  def perform(card)\n    card.remove_inaccessible_notifications\n  end\nend\n"
  },
  {
    "path": "app/jobs/concerns/smtp_delivery_error_handling.rb",
    "content": "module SmtpDeliveryErrorHandling\n  extend ActiveSupport::Concern\n\n  included do\n    # Retry delivery to possibly-unavailable remote mailservers.\n    retry_on Net::OpenTimeout, Net::ReadTimeout, Socket::ResolutionError, wait: :polynomially_longer\n\n    # Net::SMTPServerBusy is SMTP error code 4xx, a temporary error.\n    # Common one we've seen is 452 4.3.1 Insufficient system storage.\n    # Patiently retry.\n    retry_on Net::SMTPServerBusy, wait: :polynomially_longer\n\n    # SMTP error 50x.\n    rescue_from Net::SMTPSyntaxError do |error|\n      case error.message\n      when /\\A501 5\\.1\\.3/\n        # Ignore undeliverable email addresses.\n        Sentry.capture_exception error, level: :info if Fizzy.saas?\n      else\n        raise\n      end\n    end\n\n    # SMTP error 5xx except 50x and 53x.\n    # * 550 5.1.1: Unknown users\n    # * 552 5.6.0: Message/headers too large\n    rescue_from Net::SMTPFatalError do |error|\n      case error.message\n      when /\\A550 5\\.1\\.1/, /\\A552 5\\.6\\.0/, /\\A555 5\\.5\\.4/\n        Sentry.capture_exception error, level: :info if Fizzy.saas?\n      else\n        raise\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "app/jobs/data_export_job.rb",
    "content": "class DataExportJob < ApplicationJob\n  queue_as :backend\n\n  discard_on ActiveJob::DeserializationError\n\n  def perform(export)\n    export.build\n  end\nend\n"
  },
  {
    "path": "app/jobs/delete_unused_tags_job.rb",
    "content": "class DeleteUnusedTagsJob < ApplicationJob\n  def perform\n    Tag.unused.find_each do |tag|\n      tag.destroy!\n    end\n  end\nend\n"
  },
  {
    "path": "app/jobs/event/webhook_dispatch_job.rb",
    "content": "require \"active_job/continuable\"\n\nclass Event::WebhookDispatchJob < ApplicationJob\n  include ActiveJob::Continuable\n\n  queue_as :webhooks\n\n  discard_on ActiveJob::DeserializationError\n\n  def perform(event)\n    step :dispatch do |step|\n      Webhook.active.triggered_by(event).find_each(start: step.cursor) do |webhook|\n        webhook.trigger(event)\n        step.advance! from: webhook.id\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "app/jobs/mention/create_job.rb",
    "content": "class Mention::CreateJob < ApplicationJob\n  discard_on ActiveJob::DeserializationError\n\n  def perform(record, mentioner:)\n    record.create_mentions(mentioner:)\n  end\nend\n"
  },
  {
    "path": "app/jobs/notification/bundle/deliver_all_job.rb",
    "content": "class Notification::Bundle::DeliverAllJob < ApplicationJob\n  queue_as :backend\n\n  discard_on ActiveJob::DeserializationError\n\n  def perform\n    Notification::Bundle.deliver_all\n  end\nend\n"
  },
  {
    "path": "app/jobs/notification/bundle/deliver_job.rb",
    "content": "class Notification::Bundle::DeliverJob < ApplicationJob\n  include SmtpDeliveryErrorHandling\n\n  queue_as :backend\n\n  discard_on ActiveJob::DeserializationError\n\n  def perform(bundle)\n    bundle.deliver\n  end\nend\n"
  },
  {
    "path": "app/jobs/notification/push_job.rb",
    "content": "class Notification::PushJob < ApplicationJob\n  def perform(notification)\n    notification.push\n  end\nend\n"
  },
  {
    "path": "app/jobs/notify_recipients_job.rb",
    "content": "class NotifyRecipientsJob < ApplicationJob\n  discard_on ActiveJob::DeserializationError\n\n  def perform(notifiable)\n    notifiable.notify_recipients\n  end\nend\n"
  },
  {
    "path": "app/jobs/push_notification_job.rb",
    "content": "class PushNotificationJob < ApplicationJob\n  discard_on ActiveJob::DeserializationError\n\n  def perform(notification)\n    notification.push\n  end\nend\n"
  },
  {
    "path": "app/jobs/storage/materialize_job.rb",
    "content": "class Storage::MaterializeJob < ApplicationJob\n  queue_as :backend\n  limits_concurrency to: 1, key: ->(owner) { owner }\n\n  discard_on ActiveJob::DeserializationError\n\n  def perform(owner)\n    owner.materialize_storage\n  end\nend\n"
  },
  {
    "path": "app/jobs/storage/reconcile_job.rb",
    "content": "class Storage::ReconcileJob < ApplicationJob\n  class ReconcileAborted < StandardError; end\n\n  queue_as :backend\n  limits_concurrency to: 1, key: ->(owner) { owner }\n\n  discard_on ActiveJob::DeserializationError\n\n  retry_on ReconcileAborted, wait: 1.minute, attempts: 3\n\n  def perform(owner)\n    raise ReconcileAborted, \"Could not get stable snapshot for #{owner.class}##{owner.id}\" unless owner.reconcile_storage\n  end\nend\n"
  },
  {
    "path": "app/jobs/webhook/delivery_job.rb",
    "content": "class Webhook::DeliveryJob < ApplicationJob\n  queue_as :webhooks\n\n  discard_on ActiveJob::DeserializationError\n\n  def perform(delivery)\n    delivery.deliver\n  end\nend\n"
  },
  {
    "path": "app/mailers/account_mailer.rb",
    "content": "class AccountMailer < ApplicationMailer\n  def cancellation(cancellation)\n    @account = cancellation.account\n    @user = cancellation.initiated_by\n\n    mail(\n      to: @user.identity.email_address,\n      subject: \"Your Fizzy account was cancelled\"\n    )\n  end\nend\n"
  },
  {
    "path": "app/mailers/application_mailer.rb",
    "content": "class ApplicationMailer < ActionMailer::Base\n  default from: ENV.fetch(\"MAILER_FROM_ADDRESS\", \"Fizzy <support@fizzy.do>\")\n\n  layout \"mailer\"\n  append_view_path Rails.root.join(\"app/views/mailers\")\n  helper AvatarsHelper, HtmlHelper\n\n  private\n    def default_url_options\n      if Current.account\n        super.merge(script_name: Current.account.slug)\n      else\n        super\n      end\n    end\nend\n"
  },
  {
    "path": "app/mailers/concerns/mailers/unsubscribable.rb",
    "content": "module Mailers::Unsubscribable\n  extend ActiveSupport::Concern\n\n  included do\n    after_action :set_unsubscribe_headers\n  end\n\n  def set_unsubscribe_headers\n    headers[\"List-Unsubscribe-Post\"] = \"List-Unsubscribe=One-Click\"\n    headers[\"List-Unsubscribe\"]      = \"<#{notifications_unsubscribe_url(access_token: @unsubscribe_token)}>\"\n  end\nend\n"
  },
  {
    "path": "app/mailers/export_mailer.rb",
    "content": "class ExportMailer < ApplicationMailer\n  helper_method :export_download_url\n\n  def completed(export)\n    @export = export\n    @user = export.user\n\n    mail to: @user.identity.email_address, subject: \"Your Fizzy data export is ready for download\"\n  end\n\n  private\n    def export_download_url(export)\n      if export.is_a?(User::DataExport)\n        user_data_export_url(export.user, export)\n      else\n        account_export_url(export)\n      end\n    end\nend\n"
  },
  {
    "path": "app/mailers/import_mailer.rb",
    "content": "class ImportMailer < ApplicationMailer\n  def completed(identity, account)\n    @account = account\n    mail to: identity.email_address, subject: \"Your Fizzy account import is done\"\n  end\n\n  def failed(import)\n    @import = import\n    mail to: import.identity.email_address, subject: \"Your Fizzy account import failed\"\n  end\nend\n"
  },
  {
    "path": "app/mailers/magic_link_mailer.rb",
    "content": "class MagicLinkMailer < ApplicationMailer\n  def sign_in_instructions(magic_link)\n    @magic_link = magic_link\n    @identity = @magic_link.identity\n\n    mail to: @identity.email_address, subject: \"Your Fizzy code is #{ @magic_link.code }\"\n  end\nend\n"
  },
  {
    "path": "app/mailers/notification/bundle_mailer.rb",
    "content": "class Notification::BundleMailer < ApplicationMailer\n  include Mailers::Unsubscribable\n\n  helper NotificationsHelper\n\n  def notification(bundle)\n    @user = bundle.user\n    @bundle = bundle\n    @notifications = bundle.notifications\n      .preload(:card, :creator, source: [ :board, :creator ])\n      .reject { |n| n.source.nil? || n.card.nil? }\n    @unsubscribe_token = @user.generate_token_for(:unsubscribe)\n\n    if @notifications.any?\n      mail \\\n        to: bundle.user.identity.email_address,\n        subject: \"Fizzy#{ \" (#{ Current.account.name })\" if @user.identity.accounts.many? }: New notifications\"\n    end\n  end\nend\n"
  },
  {
    "path": "app/mailers/user_mailer.rb",
    "content": "class UserMailer < ApplicationMailer\n  def email_change_confirmation(email_address:, token:, user:)\n    @token = token\n    @user = user\n    mail to: email_address, subject: \"Confirm your new email address\"\n  end\nend\n"
  },
  {
    "path": "app/models/access.rb",
    "content": "class Access < ApplicationRecord\n  belongs_to :account, default: -> { user.account }\n  belongs_to :board, touch: true\n  belongs_to :user, touch: true\n\n  enum :involvement, %i[ access_only watching ].index_by(&:itself), default: :access_only\n\n  scope :ordered_by_recently_accessed, -> { order(accessed_at: :desc) }\n\n  after_destroy_commit :clean_inaccessible_data_later\n\n  def accessed\n    touch :accessed_at unless recently_accessed?\n  end\n\n  private\n    def recently_accessed?\n      accessed_at&.> 5.minutes.ago\n    end\n\n    def clean_inaccessible_data_later\n      Board::CleanInaccessibleDataJob.perform_later(user, board)\n    end\nend\n"
  },
  {
    "path": "app/models/account/cancellable.rb",
    "content": "module Account::Cancellable\n  extend ActiveSupport::Concern\n\n  included do\n    has_one :cancellation, dependent: :destroy\n\n    define_callbacks :cancel\n    define_callbacks :reactivate\n  end\n\n  def cancel(initiated_by: Current.user)\n    with_lock do\n      if cancellable? && active?\n        run_callbacks :cancel do\n          create_cancellation!(initiated_by: initiated_by)\n        end\n\n        AccountMailer.cancellation(cancellation).deliver_later\n      end\n    end\n  end\n\n  def reactivate\n    with_lock do\n      if cancelled?\n        run_callbacks :reactivate do\n          cancellation.destroy\n        end\n      end\n    end\n  end\n\n  def cancelled?\n    cancellation.present?\n  end\n\n  def cancellable?\n    Account.accepting_signups?\n  end\nend\n"
  },
  {
    "path": "app/models/account/cancellation.rb",
    "content": "class Account::Cancellation < ApplicationRecord\n  belongs_to :account\n  belongs_to :initiated_by, class_name: \"User\"\nend\n"
  },
  {
    "path": "app/models/account/data_transfer/account_record_set.rb",
    "content": "class Account::DataTransfer::AccountRecordSet < Account::DataTransfer::RecordSet\n  ACCOUNT_ATTRIBUTES = %w[\n    join_code\n    name\n  ]\n\n  JOIN_CODE_ATTRIBUTES = %w[\n    code\n    usage_count\n    usage_limit\n  ]\n\n  def initialize(account)\n    super(account: account, model: Account)\n  end\n\n  private\n    def records\n      [ account ]\n    end\n\n    def export_record(account)\n      zip.add_file \"data/account.json\", account.as_json.merge(join_code: account.join_code.as_json).to_json\n    end\n\n    def files\n      [ \"data/account.json\" ]\n    end\n\n    def import_batch(files)\n      account_data = load(files.first)\n      join_code_data = account_data.delete(\"join_code\")\n\n      account.update!(name: account_data.fetch(\"name\"), cards_count: account_data.fetch(\"cards_count\", 0))\n      account.join_code.update!(join_code_data.slice(\"usage_count\", \"usage_limit\"))\n      account.join_code.update(code: join_code_data.fetch(\"code\"))\n    end\n\n    def check_record(file_path)\n      data = load(file_path)\n\n      unless (ACCOUNT_ATTRIBUTES - data.keys).empty?\n        raise IntegrityError, \"Account record missing required fields\"\n      end\n\n      unless data.key?(\"join_code\")\n        raise IntegrityError, \"Account record missing 'join_code' field\"\n      end\n\n      unless data[\"join_code\"].is_a?(Hash)\n        raise IntegrityError, \"'join_code' field must be a JSON object\"\n      end\n\n      unless (JOIN_CODE_ATTRIBUTES - data[\"join_code\"].keys).empty?\n        raise IntegrityError, \"'join_code' field missing required keys\"\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/account/data_transfer/action_text/rich_text_record_set.rb",
    "content": "class Account::DataTransfer::ActionText::RichTextRecordSet < Account::DataTransfer::RecordSet\n  ATTRIBUTES = %w[\n    account_id\n    body\n    created_at\n    id\n    name\n    record_id\n    record_type\n    updated_at\n  ].freeze\n\n  def initialize(account)\n    super(account: account, model: ::ActionText::RichText)\n  end\n\n  private\n    def records\n      ::ActionText::RichText.where(account: account)\n    end\n\n    def export_record(rich_text)\n      data = rich_text.as_json.merge(\"body\" => transform_body_for_export(rich_text.body))\n      zip.add_file \"data/action_text_rich_texts/#{rich_text.id}.json\", data.to_json\n    end\n\n    def files\n      zip.glob(\"data/action_text_rich_texts/*.json\")\n    end\n\n    def import_batch(files)\n      batch_data = files.map do |file|\n        data = load(file)\n        data[\"body\"] = transform_body_for_import(data[\"body\"])\n        data.slice(*ATTRIBUTES).merge(\"account_id\" => account.id)\n      end\n\n      ::ActionText::RichText.insert_all!(batch_data)\n    end\n\n    def check_record(file_path)\n      data = load(file_path)\n      expected_id = File.basename(file_path, \".json\")\n\n      unless data[\"id\"].to_s == expected_id\n        raise IntegrityError, \"ActionTextRichText record ID mismatch: expected #{expected_id}, got #{data['id']}\"\n      end\n\n      missing = ATTRIBUTES - data.keys\n      if missing.any?\n        raise IntegrityError, \"#{file_path} is missing required fields: #{missing.join(', ')}\"\n      end\n\n      check_associations_dont_exist(data)\n    end\n\n    def transform_body_for_export(content)\n      return nil if content.blank?\n\n      html = convert_sgids_to_gids(content)\n      relativize_urls(html)\n    end\n\n    def convert_sgids_to_gids(content)\n      content.send(:attachment_nodes).each do |node|\n        sgid = SignedGlobalID.parse(node[\"sgid\"], for: ::ActionText::Attachable::LOCATOR_NAME)\n\n        record = begin\n          sgid&.find\n        rescue ActiveRecord::RecordNotFound\n          nil\n        end\n\n        if record&.account_id == account.id\n          node[\"gid\"] = record.to_global_id.to_s\n          node.remove_attribute(\"sgid\")\n        end\n      end\n\n      content.fragment.source.to_html\n    end\n\n    def relativize_urls(html)\n      host = Rails.application.routes.default_url_options[:host]\n      return html unless host\n\n      fragment = Nokogiri::HTML.fragment(html)\n\n      fragment.css(\"a[href]\").each do |link|\n        uri = URI.parse(link[\"href\"]) rescue nil\n\n        if uri.respond_to?(:host) && uri.host == host\n          link[\"href\"] = uri.path\n          link[\"href\"] += \"?#{uri.query}\" if uri.query\n          link[\"href\"] += \"##{uri.fragment}\" if uri.fragment\n        end\n      end\n\n      fragment.to_html\n    end\n\n    def transform_body_for_import(body)\n      return body if body.blank?\n\n      Nokogiri::HTML.fragment(body)\n        .then { convert_gids_to_sgids(it) }\n        .then { replace_account_slugs(it) }\n        .to_html\n    end\n\n    def convert_gids_to_sgids(fragment)\n      fragment.css(\"action-text-attachment[gid]\").each do |node|\n        gid = GlobalID.parse(node[\"gid\"])\n\n        if gid\n          record = begin\n            gid.find\n          rescue ActiveRecord::RecordNotFound\n            nil\n          end\n\n          if record&.account_id == account.id\n            node[\"sgid\"] = record.attachable_sgid\n            node.remove_attribute(\"gid\")\n          end\n        end\n      end\n\n      fragment\n    end\n\n    def replace_account_slugs(fragment)\n      fragment.css(\"a[href]\").each do |link|\n        match = link[\"href\"].match(AccountSlug::PATH_INFO_MATCH)\n\n        if match\n          path = match.post_match.presence || \"/\"\n          valid_path = Rails.application.routes.recognize_path(path) rescue nil\n          link[\"href\"] = \"#{account.slug}#{path}\" if valid_path\n        end\n      end\n\n      fragment\n    end\nend\n"
  },
  {
    "path": "app/models/account/data_transfer/active_storage/attachment_record_set.rb",
    "content": "class Account::DataTransfer::ActiveStorage::AttachmentRecordSet < Account::DataTransfer::RecordSet\n  def initialize(account)\n    super(account: account, model: ::ActiveStorage::Attachment)\n  end\n\n  private\n    def records\n      ::ActiveStorage::Attachment.where(account: account)\n        .where.not(record_type: INTERNAL_RECORD_TYPES)\n    end\nend\n"
  },
  {
    "path": "app/models/account/data_transfer/active_storage/blob_record_set.rb",
    "content": "class Account::DataTransfer::ActiveStorage::BlobRecordSet < Account::DataTransfer::RecordSet\n  def initialize(account)\n    super(\n      account: account,\n      model: ::ActiveStorage::Blob,\n      attributes: ::ActiveStorage::Blob.column_names - %w[service_name]\n    )\n  end\n\n  private\n    def records\n      ::ActiveStorage::Blob.where(account: account).where.not(id: excluded_blob_ids)\n    end\n\n    def excluded_blob_ids\n      ::ActiveStorage::Attachment.where(account: account, record_type: INTERNAL_RECORD_TYPES).select(:blob_id)\n    end\n\n    def import_batch(files)\n      batch_data = files.map do |file|\n        data = load(file)\n        data.slice(*attributes).merge(\n          \"account_id\" => account.id,\n          \"key\" => ::ActiveStorage::Blob.generate_unique_secure_token(length: ::ActiveStorage::Blob::MINIMUM_TOKEN_LENGTH),\n          \"service_name\" => ::ActiveStorage::Blob.service.name\n        )\n      end\n\n      model.insert_all!(batch_data)\n    end\nend\n"
  },
  {
    "path": "app/models/account/data_transfer/active_storage/file_record_set.rb",
    "content": "class Account::DataTransfer::ActiveStorage::FileRecordSet < Account::DataTransfer::RecordSet\n  def initialize(account)\n    super(account: account, model: ::ActiveStorage::Blob)\n  end\n\n  private\n    def records\n      ::ActiveStorage::Blob.where(account: account).where.not(id: excluded_blob_ids)\n    end\n\n    def excluded_blob_ids\n      ::ActiveStorage::Attachment.where(account: account, record_type: INTERNAL_RECORD_TYPES).select(:blob_id)\n    end\n\n    def export_record(blob)\n      if blob.service.exist?(blob.key)\n        zip.add_file(\"storage/#{blob.key}\", compress: false) do |out|\n          blob.download { |chunk| out.write(chunk) }\n        end\n      end\n    end\n\n    def files\n      zip.glob(\"storage/*\")\n    end\n\n    def import_batch(files)\n      files.each do |file|\n        old_key = file.delete_prefix(\"storage/\")\n        blob_id = old_key_to_blob_id[old_key]\n        raise IntegrityError, \"Storage file #{old_key} has no matching blob metadata in export\" unless blob_id\n\n        blob = ::ActiveStorage::Blob.find_by(id: blob_id, account: account)\n        raise IntegrityError, \"Blob #{blob_id} not found for storage key #{old_key}\" unless blob\n\n        zip.read(file) do |stream|\n          blob.upload(stream)\n        end\n      end\n    end\n\n    def old_key_to_blob_id\n      @old_key_to_blob_id ||= build_old_key_to_blob_id\n    end\n\n    def build_old_key_to_blob_id\n      zip.glob(\"data/active_storage_blobs/*.json\").each_with_object({}) do |file, map|\n        data = load(file)\n        old_key = data[\"key\"]\n        if map.key?(old_key)\n          raise IntegrityError, \"Duplicate blob key in export: #{old_key}\"\n        end\n        map[old_key] = data[\"id\"]\n      end\n    end\n\n    def with_zip(zip)\n      @old_key_to_blob_id = nil\n      super\n    end\n\n    def check_record(file_path)\n      old_key = file_path.delete_prefix(\"storage/\")\n\n      unless old_key_to_blob_id.key?(old_key)\n        raise IntegrityError, \"Storage file #{old_key} has no matching blob metadata in export\"\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/account/data_transfer/entropy_record_set.rb",
    "content": "class Account::DataTransfer::EntropyRecordSet < Account::DataTransfer::RecordSet\n  def initialize(account)\n    super(account: account, model: Entropy)\n  end\n\n  private\n    def import_batch(files)\n      batch_data = files.map do |file|\n        data = load(file)\n        data.slice(*attributes).merge(\"account_id\" => account.id)\n      end\n\n      container_keys = batch_data.map { |d| [ d[\"container_type\"], d[\"container_id\"] ] }\n      existing_containers = Entropy\n        .where(account_id: account.id)\n        .where(container_type: container_keys.map(&:first), container_id: container_keys.map(&:last))\n        .pluck(:container_type, :container_id)\n        .to_set\n\n      to_update, to_insert = batch_data.partition do |data|\n        existing_containers.include?([ data[\"container_type\"], data[\"container_id\"] ])\n      end\n\n      to_update.each do |data|\n        Entropy\n          .find_by(account_id: account.id, container_type: data[\"container_type\"], container_id: data[\"container_id\"])\n          .update!(data.slice(\"auto_postpone_period\"))\n      end\n\n      Entropy.insert_all!(to_insert) if to_insert.any?\n    end\nend\n"
  },
  {
    "path": "app/models/account/data_transfer/manifest.rb",
    "content": "class Account::DataTransfer::Manifest\n  attr_reader :account\n\n  def initialize(account)\n    @account = account\n  end\n\n  def each_record_set(start: nil)\n    raise ArgumentError, \"No block given\" unless block_given?\n\n    started = start.nil?\n    record_class, last_id = start if start\n\n    record_sets.each do |record_set|\n      if started\n        yield record_set\n      elsif record_set.model.name == record_class\n        started = true\n        yield record_set, last_id\n      end\n    end\n  end\n\n  private\n    def record_sets\n      [\n        Account::DataTransfer::AccountRecordSet.new(account),\n        Account::DataTransfer::UserRecordSet.new(account),\n        *build_record_sets(\n          ::User::Settings,\n          ::Tag,\n          ::Board,\n          ::Column\n        ),\n        Account::DataTransfer::EntropyRecordSet.new(account),\n        *build_record_sets(\n          ::Board::Publication,\n          ::Webhook,\n          ::Access,\n          ::Card,\n          ::Comment,\n          ::Step,\n          ::Assignment,\n          ::Tagging,\n          ::Closure,\n          ::Card::Goldness,\n          ::Card::NotNow,\n          ::Card::ActivitySpike,\n          ::Watch,\n          ::Pin,\n          ::Reaction,\n          ::Mention,\n          ::Filter,\n          ::Webhook::DelinquencyTracker,\n          ::Event,\n          ::Notification,\n          ::Notification::Bundle,\n          ::Webhook::Delivery\n        ),\n        Account::DataTransfer::ActiveStorage::BlobRecordSet.new(account),\n        Account::DataTransfer::ActiveStorage::AttachmentRecordSet.new(account),\n        Account::DataTransfer::ActionText::RichTextRecordSet.new(account),\n        Account::DataTransfer::ActiveStorage::FileRecordSet.new(account)\n      ].then { set_importable_model_names(it) }\n    end\n\n    def build_record_sets(*models)\n      models.map do |model|\n        Account::DataTransfer::RecordSet.new(account: account, model: model)\n      end\n    end\n\n    def set_importable_model_names(record_sets)\n      model_names = record_sets.filter_map { |record_set| record_set.model&.name }\n      record_sets.each { |record_set| record_set.importable_model_names = model_names }\n      record_sets\n    end\nend\n"
  },
  {
    "path": "app/models/account/data_transfer/record_set.rb",
    "content": "class Account::DataTransfer::RecordSet\n  class IntegrityError < StandardError; end\n  class ConflictError < IntegrityError; end\n\n  IMPORT_BATCH_SIZE = 100\n  INTERNAL_RECORD_TYPES = %w[Export Account::Import].freeze\n\n  attr_accessor :importable_model_names\n  attr_reader :account, :model, :attributes\n\n  def initialize(account:, model:, attributes: nil, importable_model_names: nil)\n    @account = account\n    @model = model\n    @attributes = (attributes || model.column_names).map(&:to_s)\n    @importable_model_names = importable_model_names || [ model.name ]\n  end\n\n  def export(to:, start: nil)\n    with_zip(to) do\n      block = lambda do |record|\n        export_record(record)\n      end\n\n      records.respond_to?(:find_each) ? records.find_each(&block) : records.each(&block)\n    end\n  end\n\n  def import(from:, start: nil, callback: nil)\n    with_zip(from) do\n      file_list = files\n      file_list = skip_to(file_list, start) if start\n\n      file_list.each_slice(IMPORT_BATCH_SIZE) do |file_batch|\n        import_batch(file_batch)\n        callback&.call(record_set: self, files: file_batch)\n      end\n    end\n  end\n\n  def check(from:, start: nil, callback: nil)\n    with_zip(from) do\n      file_list = files\n      file_list = skip_to(file_list, start) if start\n\n      file_list.each do |file_path|\n        check_record(file_path)\n        callback&.call(record_set: self, file: file_path)\n      end\n    end\n  end\n\n  private\n    attr_reader :zip\n\n    def with_zip(zip)\n      old_zip = @zip\n      @zip = zip\n      yield\n    ensure\n      @zip = old_zip\n    end\n\n    def records\n      model.where(account_id: account.id)\n    end\n\n    def export_record(record)\n      zip.add_file \"data/#{model_dir}/#{record.id}.json\", record.to_json\n    end\n\n    def files\n      zip.glob(\"data/#{model_dir}/*.json\")\n    end\n\n    def import_batch(files)\n      batch_data = files.map do |file|\n        data = load(file)\n        data.slice(*attributes).merge(\"account_id\" => account.id).tap do |record_data|\n          record_data[\"updated_at\"] = Time.current if record_data.key?(\"updated_at\")\n        end\n      end\n\n      model.insert_all!(batch_data)\n    end\n\n    def check_record(file_path)\n      data = load(file_path)\n      expected_id = File.basename(file_path, \".json\")\n\n      unless data[\"id\"].to_s == expected_id\n        raise IntegrityError, \"#{model} record ID mismatch: expected #{expected_id}, got #{data['id']}\"\n      end\n\n      missing = attributes - data.keys\n      if missing.any?\n        raise IntegrityError, \"#{file_path} is missing required fields: #{missing.join(', ')}\"\n      end\n\n      if model.exists?(id: data[\"id\"])\n        raise ConflictError, \"#{model} record with ID #{data['id']} already exists\"\n      end\n\n      check_associations_dont_exist(data)\n    end\n\n    def check_associations_dont_exist(data)\n      model.reflect_on_all_associations(:belongs_to).each do |association|\n        foreign_key = association.foreign_key.to_s\n\n        if associated_id = data[foreign_key]\n          check_association_doesnt_exist(data, association, associated_id)\n        end\n      end\n    end\n\n    def check_association_doesnt_exist(data, association, associated_id)\n      if association.polymorphic?\n        type_column = association.foreign_type.to_s\n        associated_class = verify_model_type(data[type_column])\n      else\n        associated_class = association.klass\n      end\n\n      if associated_class.exists?(id: associated_id)\n        raise ConflictError, \"#{model} record references existing #{association.name} (#{associated_class}) with ID #{associated_id}\"\n      end\n    end\n\n    def verify_model_type(type_name)\n      if importable_model_names.include?(type_name)\n        type_name.constantize\n      else\n        raise IntegrityError, \"Unrecognized model type: #{type_name}\"\n      end\n    end\n\n    def skip_to(file_list, last_id)\n      index = file_list.index(last_id)\n\n      if index\n        file_list[(index + 1)..]\n      else\n        file_list\n      end\n    end\n\n    def load(file_path)\n      JSON.parse(zip.read(file_path))\n    rescue ArgumentError => e\n      raise IntegrityError, e.message\n    end\n\n    def model_dir\n      model.table_name\n    end\nend\n"
  },
  {
    "path": "app/models/account/data_transfer/user_record_set.rb",
    "content": "class Account::DataTransfer::UserRecordSet < Account::DataTransfer::RecordSet\n  ATTRIBUTES = %w[\n    id\n    email_address\n    name\n    role\n    active\n    verified_at\n    created_at\n    updated_at\n  ]\n\n  def initialize(account)\n    super(account: account, model: User)\n  end\n\n  private\n    def records\n      User.where(account: account)\n    end\n\n    def export_record(user)\n      zip.add_file \"data/users/#{user.id}.json\", user.as_json.merge(email_address: user.identity&.email_address).to_json\n    end\n\n    def files\n      zip.glob(\"data/users/*.json\")\n    end\n\n    def import_batch(files)\n      batch_data = files.map do |file|\n        user_data = load(file)\n        email_address = user_data.delete(\"email_address\")\n\n        identity = Identity.find_or_create_by!(email_address: email_address) if email_address.present?\n\n        user_data.slice(*ATTRIBUTES).merge(\n          \"account_id\" => account.id,\n          \"identity_id\" => identity&.id\n        )\n      end\n\n      conflicting_identity_ids = batch_data.pluck(\"identity_id\").compact\n      account.users.where(identity_id: conflicting_identity_ids).destroy_all\n\n      User.insert_all!(batch_data)\n    end\n\n    def check_record(file_path)\n      data = load(file_path)\n      expected_id = File.basename(file_path, \".json\")\n\n      unless data[\"id\"].to_s == expected_id\n        raise IntegrityError, \"User record ID mismatch: expected #{expected_id}, got #{data['id']}\"\n      end\n\n      unless (ATTRIBUTES - data.keys).empty?\n        raise IntegrityError, \"#{file_path} is missing required fields\"\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/account/entropic.rb",
    "content": "module Account::Entropic\n  extend ActiveSupport::Concern\n\n  DEFAULT_ENTROPY_PERIOD = 30.days\n\n  included do\n    has_one :entropy, as: :container, dependent: :destroy\n    after_create -> { create_entropy!(auto_postpone_period: DEFAULT_ENTROPY_PERIOD, account: self) }\n  end\nend\n"
  },
  {
    "path": "app/models/account/export.rb",
    "content": "class Account::Export < Export\n  private\n    def filename\n      \"fizzy-account-#{account_id}-export-#{id}.zip\"\n    end\n\n    def populate_zip(zip)\n      Account::DataTransfer::Manifest.new(account).each_record_set do |record_set|\n        record_set.export(to: zip)\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/account/external_id_sequence.rb",
    "content": "# Provides sequential IDs for +external_account_id+ when creating accounts without one.\nclass Account::ExternalIdSequence < ApplicationRecord\n  class << self\n    def next\n      with_lock do |sequence|\n        sequence.increment!(:value).value\n      end\n    end\n\n    def value\n      first&.value || self.next\n    end\n\n    private\n      def with_lock\n        transaction do\n          sequence = lock.first_or_create!(value: initial_value)\n          yield sequence\n        end\n      end\n\n      def initial_value\n        Account.maximum(:external_account_id) || 0\n      end\n  end\nend\n"
  },
  {
    "path": "app/models/account/import.rb",
    "content": "class Account::Import < ApplicationRecord\n  broadcasts_refreshes\n\n  belongs_to :account\n  belongs_to :identity\n\n  has_one_attached :file\n\n  enum :status, %w[ pending processing completed failed ].index_by(&:itself), default: :pending\n  enum :failure_reason, %w[ conflict invalid_export ].index_by(&:itself), prefix: :failed_due_to, scopes: false\n\n  scope :expired, -> { where(completed_at: ...24.hours.ago).or(where(status: :failed, created_at: ...7.days.ago)) }\n\n  def self.cleanup\n    expired.each(&:cleanup)\n  end\n\n  def process_later\n    Account::DataImportJob.perform_later(self)\n  end\n\n  def check(start: nil, callback: nil)\n    processing!\n\n    ZipFile.read_from(file.blob) do |zip|\n      Account::DataTransfer::Manifest.new(account).each_record_set(start: start) do |record_set, last_id|\n        record_set.check(from: zip, start: last_id, callback: callback)\n      end\n    end\n  rescue Account::DataTransfer::RecordSet::ConflictError => e\n    mark_as_failed(:conflict)\n    raise e\n  rescue Account::DataTransfer::RecordSet::IntegrityError, ZipFile::InvalidFileError => e\n    mark_as_failed(:invalid_export)\n    raise e\n  rescue => e\n    mark_as_failed\n    raise e\n  end\n\n  def process(start: nil, callback: nil)\n    processing!\n\n    ZipFile.read_from(file.blob) do |zip|\n      Account::DataTransfer::Manifest.new(account).each_record_set(start: start) do |record_set, last_id|\n        record_set.import(from: zip, start: last_id, callback: callback)\n      end\n    end\n\n    add_importer_to_all_access_boards\n    reconcile_cards_count\n    reconcile_account_storage\n\n    mark_completed\n  rescue Account::DataTransfer::RecordSet::ConflictError => e\n    mark_as_failed(:conflict)\n    raise e\n  rescue Account::DataTransfer::RecordSet::IntegrityError, ZipFile::InvalidFileError => e\n    mark_as_failed(:invalid_export)\n    raise e\n  rescue => e\n    mark_as_failed\n    raise e\n  end\n\n  def cleanup\n    destroy\n    account.destroy if failed?\n  end\n\n  private\n    def mark_completed\n      update!(status: :completed, completed_at: Time.current)\n      ImportMailer.completed(identity, account).deliver_later\n    end\n\n    def mark_as_failed(failure_reason = nil)\n      update!(status: :failed, failure_reason: failure_reason)\n      ImportMailer.failed(self).deliver_later\n    end\n\n    def reconcile_cards_count\n      account.update_column :cards_count, [ account.cards_count, account.cards.maximum(:number).to_i ].max\n    end\n\n    def add_importer_to_all_access_boards\n      importer = account.users.find_by!(identity: identity)\n\n      account.boards.all_access.find_each do |board|\n        board.accesses.grant_to(importer)\n      end\n    end\n\n    def reconcile_account_storage\n      account.boards.each(&:reconcile_storage)\n      account.reconcile_storage\n      account.materialize_storage\n    end\nend\n"
  },
  {
    "path": "app/models/account/incineratable.rb",
    "content": "module Account::Incineratable\n  extend ActiveSupport::Concern\n\n  INCINERATION_GRACE_PERIOD = 30.days\n\n  included do\n    scope :due_for_incineration, -> { joins(:cancellation).where(account_cancellations: { created_at: ...INCINERATION_GRACE_PERIOD.ago }) }\n\n    define_callbacks :incinerate\n  end\n\n  def incinerate\n    run_callbacks :incinerate do\n      account.destroy\n    end\n  end\nend\n"
  },
  {
    "path": "app/models/account/join_code.rb",
    "content": "class Account::JoinCode < ApplicationRecord\n  CODE_LENGTH = 12\n  USAGE_LIMIT_MAX = 10_000_000_000\n\n  belongs_to :account\n\n  validates :usage_limit, numericality: { less_than_or_equal_to: USAGE_LIMIT_MAX, message: \"cannot be larger than the population of the planet\" }\n\n  scope :active, -> { where(\"usage_count < usage_limit\") }\n\n  before_create :generate_code, if: -> { code.blank? }\n\n  def redeem_if(&block)\n    with_lock do\n      increment!(:usage_count) if active? && block.call(account)\n    end\n  end\n\n  def active?\n    usage_count < usage_limit\n  end\n\n  def reset\n    generate_code\n    self.usage_count = 0\n    save!\n  end\n\n  private\n    def generate_code\n      self.code = loop do\n        candidate = SecureRandom.base58(CODE_LENGTH).scan(/.{4}/).join(\"-\")\n        break candidate unless self.class.exists?(code: candidate)\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/account/multi_tenantable.rb",
    "content": "module Account::MultiTenantable\n  extend ActiveSupport::Concern\n\n  included do\n    cattr_accessor :multi_tenant, default: false\n  end\n\n  class_methods do\n    def accepting_signups?\n      multi_tenant || Account.none?\n    end\n  end\nend\n"
  },
  {
    "path": "app/models/account/seedeable.rb",
    "content": "module Account::Seedeable\n  extend ActiveSupport::Concern\n\n  def setup_customer_template\n    Account::Seeder.new(self, users.admin.first).seed\n  end\nend\n"
  },
  {
    "path": "app/models/account/seeder.rb",
    "content": "class Account::Seeder\n  attr_reader :account, :creator\n\n  def initialize(account, creator)\n    @account = account\n    @creator = creator\n  end\n\n  def seed\n    Current.set(user: creator, account: account) do\n      populate\n    end\n  end\n\n  def seed!\n    raise \"You can't run in production environments\" unless Rails.env.local?\n\n    delete_everything\n    seed\n  end\n\n  private\n    def populate\n      # ---------------\n      # Playground Board\n      # ---------------\n      playground = account.boards.create! name: \"Playground\", creator: creator, all_access: true\n      playground.update! auto_postpone_period: 365.days\n\n      # Cards\n      playground.cards.create! creator: creator, title: \"Finally, watch this Fizzy orientation video\", status: \"published\", description: <<~HTML\n        <p>There’s a whole lot more you can do in Fizzy. In the video below, 37signals founder and CEO, Jason Fried, will walk you through the basics in just 17 minutes.</p>\n        <action-text-attachment url=\"https://videos.37signals.com/fizzy/assets/videos/fizzyorientation-4k.mp4\" caption=\"Fizzy orientation\" content-type=\"video/mp4\" filename=\"fizzyorientation-4k.mp4\"></action-text-attachment>\n      HTML\n\n      # TODO: Replace the video here with a screencap of creating a passkey\n      playground.cards.create! creator: creator, title: \"Then, set up a Passkey\", status: \"published\", description: <<~HTML\n        <p>Passkeys let you sign in securely without using passwords or email codes. To set one up, open the Fizzy menu and go to “<b><strong>My Profile > Manage Passkeys</b></strong>”. Using a passkey is optional, but recommended.</p>\n        <action-text-attachment url=\"https://videos.37signals.com/fizzy/assets/videos/creating_a_passkey.mp4\" alt=\"Demo of adding a passkey\" caption=\"Create a passkey to sign in without passwords or email codes\" content-type=\"video/mp4\" filename=\"creating_a_passkey.mp4\"></action-text-attachment>\n      HTML\n\n      playground.cards.create! creator: creator, title: \"Now, grab the invite link to invite someone else\", status: \"published\", description: <<~HTML\n        <p>Open the Fizzy menu, select “<b><strong>+ Add people</b></strong>”, then copy the invite link. You can give this link to someone else so they can make a login for themselves in your account.</p>\n        <action-text-attachment url=\"https://videos.37signals.com/fizzy/assets/images/invite-link.gif\" alt=\"Demo of copying invite link\" caption=\"Get a link to invite co-workers\" content-type=\"image/*\" filename=\"invite-link.gif\" presentation=\"gallery\"></action-text-attachment>\n      HTML\n\n      playground.cards.create! creator: creator, title: \"Then, head back home to check out activity\", status: \"published\", description: <<~HTML\n        <p>Hit “1” or pull down the Fizzy menu and select “Home”.</p>\n        <action-text-attachment url=\"https://videos.37signals.com/fizzy/assets/images/back-to-home.gif\" alt=\"Demo of visiting Home\" caption=\"Go back to Home for Latest Activity\" content-type=\"image/*\" filename=\"back-to-home.gif\" presentation=\"gallery\"></action-text-attachment>\n      HTML\n\n      playground.cards.create! creator: creator, title: \"Now, check out all cards assigned to you\", status: \"published\", description: <<~HTML\n        <p>Pull down the Fizzy menu at the top of the screen, and select “<b><strong>Assigned to me</b></strong>” or just hit “2” on your keyboard any time.</p>\n        <action-text-attachment url=\"https://videos.37signals.com/fizzy/assets/images/all-assigned.gif\" alt=\"Demo of navigating to 'Assigned to Me'\" caption=\"See all cards assigned to me\" content-type=\"image/*\" filename=\"all-assigned.gif\" presentation=\"gallery\"></action-text-attachment>\n      HTML\n\n      playground.cards.create! creator: creator, title: \"Then, open the Fizzy menu\", status: \"published\", description: <<~HTML\n        <p>The Fizzy menu is how you get around the app. Click “<b><strong>Fizzy</b></strong>” at the top of the screen or hit the “J” key on your keyboard to pop it open.</p>\n        <action-text-attachment url=\"https://videos.37signals.com/fizzy/assets/images/open-menu.gif\" alt=\"Demo of opening the main menu\" caption=\"Open the Fizzy menu\" content-type=\"image/*\" filename=\"open-menu.gif\" presentation=\"gallery\"></action-text-attachment>\n      HTML\n\n      playground.cards.create! creator: creator, title: \"Next, assign this card to yourself\", status: \"published\", description: <<~HTML\n        <p>Click the little head with the + next to it, then pick yourself.</p>\n        <action-text-attachment url=\"https://videos.37signals.com/fizzy/assets/images/assign-to-self.gif\" alt=\"Demo of assigning a card\" caption=\"Assign this to yourself\" content-type=\"image/*\" filename=\"assign-to-self.gif\" presentation=\"gallery\"></action-text-attachment>\n      HTML\n\n      playground.cards.create! creator: creator, title: \"Now, tag this card “Design” then move it to YES\", status: \"published\", description: <<~HTML\n        <p>Click the little Tag icon, type “design”, then “<b><strong>Create tag</b></strong>”. Then, move the card to the new “YES” column you created in the previous step.</p>\n        <action-text-attachment url=\"https://videos.37signals.com/fizzy/assets/images/tag-design.gif\" alt=\"Demo of tagging a card\" caption=\"Tag this #design\" content-type=\"image/*\" filename=\"tag-design.gif\" presentation=\"gallery\"></action-text-attachment>\n      HTML\n\n      playground.cards.create! creator: creator, title: \"Next, make two more columns\", status: \"published\", description: <<~HTML\n        <ol>\n          <li>Make one called \"Yes\"</li>\n          <li>Make another called \"Working on\"</li>\n        </ol>\n        <p>Go back to the Board view, click the little “+” to the right of the DONE column, name the column, pick a color, then do it again.</p>\n        <p><br></p>\n        <p>After that, drag this card to “DONE” or select “DONE” in the sidebar.</p>\n        <action-text-attachment url=\"https://videos.37signals.com/fizzy/assets/images/make-columns.gif\" alt=\"Demo of adding columns\" caption=\"Make two more columns\" content-type=\"image/*\" filename=\"make-columns.gif\" presentation=\"gallery\"></action-text-attachment>\n      HTML\n\n      playground.cards.create! creator: creator, title: \"Second, move this card to NOT NOW\", status: \"published\", description: <<~HTML\n        <p>You can either select “NOT NOW” over in the sidebar, or you can go back out to the board view and drag this card into the “NOT NOW” column on the left side.</p>\n        <p><br></p>\n        <action-text-attachment url=\"https://videos.37signals.com/fizzy/assets/images/not-now.gif\" alt=\"Demo of moving a card to Not Now\" caption=\"Move to Not Now\" content-type=\"image/*\" filename=\"not-now.gif\" presentation=\"gallery\"></action-text-attachment>\n      HTML\n\n      playground.cards.create! creator: creator, title: \"First, rename this card\", status: \"published\", description: <<~HTML\n        <ol>\n          <li>Click the title and you can rename the card, change the description, or add more information to the card.</li>\n          <li>Then, hit \"Mark as Done\" at the bottom of the card.</li>\n          <li>Finally, hit “<b><strong>Back to Playground</strong></b>” in the top left of the screen to go back to the board.</li>\n        </ol>\n        <action-text-attachment url=\"https://videos.37signals.com/fizzy/assets/images/rename.gif\" alt=\"Demo of renaming a card\" caption=\"Rename this card\" content-type=\"image/*\" filename=\"rename.gif\" presentation=\"gallery\"></action-text-attachment>\n      HTML\n    end\n\n    def delete_everything\n      Current.set(user: creator, account: account) do\n        account.boards.destroy_all\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/account/storage.rb",
    "content": "module Account::Storage\n  extend ActiveSupport::Concern\n  include Storage::Totaled\n\n  private\n    def calculate_real_storage_bytes\n      boards.sum { |board| board.send(:calculate_real_storage_bytes) }\n    end\nend\n"
  },
  {
    "path": "app/models/account.rb",
    "content": "class Account < ApplicationRecord\n  include Account::Storage, Cancellable, Entropic, Incineratable, MultiTenantable, Seedeable\n\n  has_one :join_code, dependent: :destroy\n  has_many :users, dependent: :destroy\n  has_many :boards, dependent: :destroy\n  has_many :cards, dependent: :destroy\n  has_many :webhooks, dependent: :destroy\n  has_many :tags, dependent: :destroy\n  has_many :columns, dependent: :destroy\n  has_many :entropies, dependent: :destroy\n  has_many :exports, class_name: \"Account::Export\", dependent: :destroy\n  has_many :imports, class_name: \"Account::Import\", dependent: :destroy\n\n  scope :importing, -> { left_joins(:imports).where(account_imports: { status: %i[pending processing failed] }) }\n  scope :active, -> { where.missing(:cancellation).and(where.not(id: importing)) }\n\n  before_create :assign_external_account_id\n  after_create :create_join_code\n\n  validates :name, presence: true\n\n  class << self\n    def create_with_owner(account:, owner:)\n      create!(**account).tap do |account|\n        account.users.create!(role: :system, name: \"System\")\n        account.users.create!(**owner.with_defaults(role: :owner, verified_at: Time.current))\n      end\n    end\n  end\n\n  def slug\n    \"/#{AccountSlug.encode(external_account_id)}\"\n  end\n\n  def account\n    self\n  end\n\n  def system_user\n    users.find_by!(role: :system)\n  end\n\n  def active?\n    !cancelled? && !importing?\n  end\n\n  def importing?\n    imports.where(status: %i[pending processing failed]).exists?\n  end\n\n  private\n    def assign_external_account_id\n      self.external_account_id ||= ExternalIdSequence.next\n    end\nend\n"
  },
  {
    "path": "app/models/admin.rb",
    "content": "module Admin\nend\n"
  },
  {
    "path": "app/models/application_platform.rb",
    "content": "class ApplicationPlatform < PlatformAgent\n  def ios?\n    match? /iPhone|iPad/\n  end\n\n  def android?\n    match? /Android/\n  end\n\n  def mac?\n    match? /Macintosh/\n  end\n\n  def chrome?\n    user_agent.browser.match? /Chrome/\n  end\n\n  def edge?\n    user_agent.browser.match? /Edg/\n  end\n\n  def firefox?\n    user_agent.browser.match? /Firefox|FxiOS/\n  end\n\n  def safari?\n    user_agent.browser.match? /Safari/\n  end\n\n  def mobile?\n    ios? || android?\n  end\n\n  def desktop?\n    !mobile?\n  end\n\n  def native?\n    match? /Hotwire Native/\n  end\n\n  def windows?\n    operating_system == \"Windows\"\n  end\n\n  def bridge_name\n    case\n    when native? && android? then :android\n    when native? && ios?     then :ios\n    end\n  end\n\n  def bridge_components\n    extract_list_from_native_user_agent(\"bridge-components\")\n  end\n\n  def type\n    if native? && android?\n      \"native android\"\n    elsif native? && ios?\n      \"native ios\"\n    elsif mobile?\n      \"mobile web\"\n    else\n      \"desktop web\"\n    end\n  end\n\n  def operating_system\n    case user_agent.platform\n    when /Android/   then \"Android\"\n    when /iPad/      then \"iPad\"\n    when /iPhone/    then \"iPhone\"\n    when /Macintosh/ then \"macOS\"\n    when /Windows/   then \"Windows\"\n    when /CrOS/      then \"ChromeOS\"\n    else\n      os =~ /Linux/ ? \"Linux\" : os\n    end\n  end\n\n  private\n    def extract_list_from_native_user_agent(prefix)\n      if native?\n        user_agent.to_s.match(/#{Regexp.escape(prefix)}: \\[(.*?)\\]/) { |matches| matches[1] }.to_s\n      else\n        \"\"\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/application_record.rb",
    "content": "class ApplicationRecord < ActiveRecord::Base\n  primary_abstract_class\n\n  configure_replica_connections\nend\n"
  },
  {
    "path": "app/models/assignment.rb",
    "content": "class Assignment < ApplicationRecord\n  LIMIT = 100\n\n  belongs_to :account, default: -> { card.account }\n  belongs_to :card, touch: true\n\n  belongs_to :assignee, class_name: \"User\"\n  belongs_to :assigner, class_name: \"User\"\n\n  validate :within_limit, on: :create\n\n  private\n    def within_limit\n      if card.assignments.count >= LIMIT\n        errors.add(:base, \"Card already has the maximum of #{LIMIT} assignees\")\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/board/accessible.rb",
    "content": "module Board::Accessible\n  extend ActiveSupport::Concern\n\n  included do\n    has_many :accesses, dependent: :delete_all do\n      def revise(granted: [], revoked: [])\n        transaction do\n          grant_to granted\n          revoke_from revoked\n        end\n      end\n\n      def grant_to(users)\n        Access.insert_all Array(users).collect { |user| { id: ActiveRecord::Type::Uuid.generate, board_id: proxy_association.owner.id, user_id: user.id, account_id: proxy_association.owner.account.id } }\n      end\n\n      def revoke_from(users)\n        destroy_by user: users unless proxy_association.owner.all_access?\n      end\n    end\n\n    has_many :users, through: :accesses\n    has_many :access_only_users, -> { merge(Access.access_only) }, through: :accesses, source: :user\n\n    scope :all_access, -> { where(all_access: true) }\n\n    after_create :grant_access_to_creator\n    after_save_commit :grant_access_to_everyone\n  end\n\n  def accessed_by(user)\n    access_for(user).accessed\n  end\n\n  def access_for(user)\n    accesses.find_by(user: user)\n  end\n\n  def accessible_to?(user)\n    access_for(user).present?\n  end\n\n  def clean_inaccessible_data_for(user)\n    return if accessible_to?(user)\n\n    mentions_for_user(user).destroy_all\n    notifications_for_user(user).destroy_all\n    watches_for(user).destroy_all\n    pins_for(user).destroy_all\n  end\n\n  def watchers\n    users.active.where(accesses: { involvement: :watching })\n  end\n\n  private\n    def grant_access_to_creator\n      accesses.create(user: creator, involvement: :watching)\n    end\n\n    def grant_access_to_everyone\n      accesses.grant_to(account.users.active) if all_access_previously_changed?(to: true)\n    end\n\n    def mentions_for_user(user)\n      # Query handles 2 paths:\n      #\n      # 1. Mention->Card\n      # 2. Mention->Comment->Card\n      board_id_binary = ActiveRecord::Type::Uuid.new.serialize(id)\n\n      user.mentions\n        .joins(\"LEFT JOIN cards ON mentions.source_id = cards.id AND mentions.source_type = 'Card'\")\n        .joins(\"LEFT JOIN comments ON mentions.source_id = comments.id AND mentions.source_type = 'Comment'\")\n        .joins(\"LEFT JOIN cards AS comment_cards ON comments.card_id = comment_cards.id\")\n        .where(\"(mentions.source_type = 'Card' AND cards.board_id = ?) OR (mentions.source_type = 'Comment' AND comment_cards.board_id = ?)\", board_id_binary, board_id_binary)\n    end\n\n    def notifications_for_user(user)\n      # Query handles 2 paths:\n      #\n      # 1. Notification->Event->Card\n      # 2. Notification->Event->Comment->Card\n      #\n      # Notification->Event->Mention->Card and Notification->Event->Mention->Comment->Card are\n      # handled by destroying mentions_for_user.\n      uuid_type = ActiveRecord::Type.lookup(:uuid, adapter: :trilogy)\n      board_id_binary = uuid_type.serialize(id)\n\n      user.notifications\n        .joins(\"LEFT JOIN events ON notifications.source_id = events.id AND notifications.source_type = 'Event'\")\n        .joins(\"LEFT JOIN cards AS event_cards ON events.eventable_id = event_cards.id AND events.eventable_type = 'Card'\")\n        .joins(\"LEFT JOIN comments AS event_comments ON events.eventable_id = event_comments.id AND events.eventable_type = 'Comment'\")\n        .joins(\"LEFT JOIN cards AS event_comment_cards ON event_comments.card_id = event_comment_cards.id\")\n        .where(\"(notifications.source_type = 'Event' AND events.eventable_type = 'Card' AND event_cards.board_id = ?) OR\n              (notifications.source_type = 'Event' AND events.eventable_type = 'Comment' AND event_comment_cards.board_id = ?)\",\n               board_id_binary, board_id_binary)\n    end\n\n    def watches_for(user)\n      Watch.where(card: cards, user: user)\n    end\n\n    def pins_for(user)\n      Pin.where(card: cards, user: user)\n    end\nend\n"
  },
  {
    "path": "app/models/board/auto_postponing.rb",
    "content": "module Board::AutoPostponing\n  extend ActiveSupport::Concern\n\n  included do\n    before_create :set_default_auto_postpone_period\n  end\n\n  private\n    def set_default_auto_postpone_period\n      self.auto_postpone_period ||= Entropy::DEFAULT_AUTO_POSTPONE_PERIOD_IN_DAYS.days unless attribute_present?(:auto_postpone_period)\n    end\nend\n"
  },
  {
    "path": "app/models/board/broadcastable.rb",
    "content": "module Board::Broadcastable\n  extend ActiveSupport::Concern\n\n  included do\n    broadcasts_refreshes\n    broadcasts_refreshes_to ->(board) { [ board.account, :all_boards ] }\n  end\nend\n"
  },
  {
    "path": "app/models/board/cards.rb",
    "content": "module Board::Cards\n  extend ActiveSupport::Concern\n\n  included do\n    has_many :cards, dependent: :destroy\n\n    after_update_commit -> { cards.touch_all }, if: :saved_change_to_name?\n  end\nend\n"
  },
  {
    "path": "app/models/board/entropic.rb",
    "content": "module Board::Entropic\n  extend ActiveSupport::Concern\n\n  included do\n    delegate :auto_postpone_period, :auto_postpone_period_in_days, to: :entropy\n    has_one :entropy, as: :container, dependent: :destroy\n  end\n\n  def entropy\n    super || account.entropy\n  end\n\n  def auto_postpone_period=(new_value)\n    entropy ||= association(:entropy).reader || self.build_entropy\n    entropy.update! auto_postpone_period: new_value\n  end\n\n  def auto_postpone_period_in_days=(value)\n    self.auto_postpone_period = value.to_i.days.to_i\n  end\nend\n"
  },
  {
    "path": "app/models/board/publication.rb",
    "content": "class Board::Publication < ApplicationRecord\n  belongs_to :account, default: -> { board.account }\n  belongs_to :board, touch: true\n\n  has_secure_token :key\nend\n"
  },
  {
    "path": "app/models/board/publishable.rb",
    "content": "module Board::Publishable\n  extend ActiveSupport::Concern\n\n  included do\n    has_one :publication, class_name: \"Board::Publication\", dependent: :destroy\n    scope :published, -> { joins(:publication) }\n  end\n\n  class_methods do\n    def find_by_published_key(key)\n      Board::Publication.find_by!(key: key).board\n    end\n  end\n\n  def published?\n    publication.present?\n  end\n  alias_method :publicly_accessible?, :published?\n\n  def publish\n    create_publication! unless published?\n  end\n\n  def unpublish\n    publication&.destroy\n  end\nend\n"
  },
  {
    "path": "app/models/board/storage.rb",
    "content": "module Board::Storage\n  extend ActiveSupport::Concern\n  include Storage::Totaled\n\n  # Board's own embeds (public_description) count toward itself\n  def board_for_storage_tracking\n    self\n  end\n\n  private\n    BATCH_SIZE = 1000\n\n    # Calculate actual storage by summing blob sizes.\n    #\n    # Storage tracking is a business abstraction - we count what users upload.\n    # Original upload bytes only; variants/previews/derivatives excluded.\n    # Physical storage optimizations (deduplication, compression) don't affect quotas.\n    def calculate_real_storage_bytes\n      @card_ids = nil  # Clear memoization for fresh calculation\n      card_image_bytes + card_embed_bytes + comment_embed_bytes + board_embed_bytes\n    end\n\n    def card_ids\n      @card_ids ||= cards.ids\n    end\n\n    def card_image_bytes\n      sum_blob_bytes_in_batches \\\n        ActiveStorage::Attachment.where(record_type: \"Card\", name: \"image\"),\n        card_ids\n    end\n\n    def card_embed_bytes\n      sum_embed_bytes_for \"Card\", card_ids\n    end\n\n    def comment_embed_bytes\n      card_ids.each_slice(BATCH_SIZE).sum do |batch|\n        sum_embed_bytes_for \"Comment\", Comment.where(card_id: batch).ids\n      end\n    end\n\n    def board_embed_bytes\n      sum_embed_bytes_for \"Board\", [ id ]\n    end\n\n    def sum_embed_bytes_for(record_type, record_ids)\n      rich_text_ids = ActionText::RichText \\\n        .where(record_type: record_type, record_id: record_ids).ids\n\n      sum_blob_bytes_in_batches \\\n        ActiveStorage::Attachment.where(record_type: \"ActionText::RichText\", name: \"embeds\"),\n        rich_text_ids\n    end\n\n    def sum_blob_bytes_in_batches(base_scope, record_ids)\n      # Count per-attachment to match ledger model.\n      # Same blob attached 3 times = 3x bytes (business abstraction, not physical storage).\n      #\n      # Do NOT remove the join thinking it's a performance optimization - it's required\n      # for correct per-attachment counting. We keep ActiveStorage/ActionText in the same\n      # database (realm/geo partitioning, not functionality partitioning), so cross-table\n      # joins are fine.\n      record_ids.each_slice(BATCH_SIZE).sum do |batch_ids|\n        base_scope\n          .where(record_id: batch_ids)\n          .joins(:blob)\n          .sum(\"active_storage_blobs.byte_size\")\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/board/triageable.rb",
    "content": "module Board::Triageable\n  extend ActiveSupport::Concern\n\n  included do\n    has_many :columns, dependent: :destroy\n  end\nend\n"
  },
  {
    "path": "app/models/board.rb",
    "content": "class Board < ApplicationRecord\n  include Accessible, AutoPostponing, Board::Storage, Broadcastable, Cards, Entropic, Filterable, Publishable, ::Storage::Tracked, Triageable\n\n  belongs_to :creator, class_name: \"User\", default: -> { Current.user }\n  belongs_to :account, default: -> { creator.account }\n\n  has_rich_text :public_description\n\n  has_many :tags, -> { distinct }, through: :cards\n  has_many :events\n  has_many :webhooks, dependent: :destroy\n\n  scope :alphabetically, -> { order(\"lower(name)\") }\n  scope :ordered_by_recently_accessed, -> { merge(Access.ordered_by_recently_accessed) }\nend\n"
  },
  {
    "path": "app/models/card/accessible.rb",
    "content": "module Card::Accessible\n  extend ActiveSupport::Concern\n\n  included do\n    delegate :accessible_to?, to: :board\n  end\n\n  def publicly_accessible?\n    published? && board.publicly_accessible?\n  end\n\n  def clean_inaccessible_data\n    accessible_user_ids = board.accesses.pluck(:user_id)\n    pins.where.not(user_id: accessible_user_ids).in_batches.destroy_all\n    watches.where.not(user_id: accessible_user_ids).in_batches.destroy_all\n  end\n\n  private\n    def grant_access_to_assignees\n      board.accesses.grant_to(assignees)\n    end\n\n    def clean_inaccessible_data_later\n      Card::CleanInaccessibleDataJob.perform_later(self)\n    end\nend\n"
  },
  {
    "path": "app/models/card/activity_spike/detector.rb",
    "content": "class Card::ActivitySpike::Detector\n  attr_reader :card\n\n  def initialize(card)\n    @card = card\n  end\n\n  def detect\n    if has_activity_spike?\n      register_activity_spike\n      true\n    else\n      false\n    end\n  end\n\n  private\n    def has_activity_spike?\n      card.entropic? && (multiple_people_commented? || card_was_just_assigned? || card_was_just_reopened?)\n    end\n\n    def register_activity_spike\n      Card.suppressing_turbo_broadcasts do\n        Card::ActivitySpike.find_or_create_by!(card: card).touch\n      end\n    end\n\n    def multiple_people_commented?(minimum_comments: 3, minimum_participants: 2)\n      card.comments\n        .where(created_at: recent_period.seconds.ago..)\n        .group(:card_id)\n        .having(\"COUNT(*) >= ?\", minimum_comments)\n        .having(\"COUNT(DISTINCT creator_id) >= ?\", minimum_participants)\n        .exists?\n    end\n\n    def recent_period\n      card.entropy.auto_clean_period * 0.33\n    end\n\n    def card_was_just_assigned?\n      card.assigned? && card_was_just?(:assigned)\n    end\n\n    def card_was_just_reopened?\n      card.open? && card_was_just?(:reopened)\n    end\n\n    def card_was_just?(action)\n      last_event&.action&.to_s == \"card_#{action}\" && last_event.created_at > 1.minute.ago\n    end\n\n    def last_event\n      card.events.order(:created_at).last\n    end\nend\n"
  },
  {
    "path": "app/models/card/activity_spike.rb",
    "content": "class Card::ActivitySpike < ApplicationRecord\n  belongs_to :account, default: -> { card.account }\n  belongs_to :card, touch: true\nend\n"
  },
  {
    "path": "app/models/card/assignable.rb",
    "content": "module Card::Assignable\n  extend ActiveSupport::Concern\n\n  included do\n    has_many :assignments, dependent: :delete_all\n    has_many :assignees, through: :assignments\n\n    scope :unassigned, -> { where.missing :assignments }\n    scope :assigned_to, ->(users) { joins(:assignments).where(assignments: { assignee: users }).distinct }\n    scope :assigned_by, ->(users) { joins(:assignments).where(assignments: { assigner: users }).distinct }\n  end\n\n  def toggle_assignment(user)\n    assigned_to?(user) ? unassign(user) : assign(user)\n  end\n\n  def assigned_to?(user)\n    assignments.any? { |a| a.assignee_id == user.id }\n  end\n\n  def assigned?\n    assignments.any?\n  end\n\n  private\n    def assign(user)\n      assignment = assignments.create assignee: user, assigner: Current.user\n\n      if assignment.persisted?\n        watch_by user\n        track_event :assigned, assignee_ids: [ user.id ]\n      end\n    rescue ActiveRecord::RecordNotUnique\n      # Already assigned\n    end\n\n    def unassign(user)\n      destructions = assignments.destroy_by assignee: user\n      track_event :unassigned, assignee_ids: [ user.id ] if destructions.any?\n    end\nend\n"
  },
  {
    "path": "app/models/card/broadcastable.rb",
    "content": "module Card::Broadcastable\n  extend ActiveSupport::Concern\n\n  included do\n    broadcasts_refreshes\n\n    before_update :remember_if_preview_changed\n  end\n\n  private\n    def remember_if_preview_changed\n      @preview_changed ||= title_changed? || column_id_changed? || board_id_changed?\n    end\n\n    def preview_changed?\n      @preview_changed\n    end\nend\n"
  },
  {
    "path": "app/models/card/closeable.rb",
    "content": "module Card::Closeable\n  extend ActiveSupport::Concern\n\n  included do\n    has_one :closure, dependent: :destroy\n\n    scope :closed, -> { joins(:closure) }\n    scope :open, -> { where.missing(:closure) }\n\n    scope :recently_closed_first, -> { closed.order(closures: { created_at: :desc }) }\n    scope :closed_at_window, ->(window) { closed.where(closures: { created_at: window }) }\n    scope :closed_by, ->(users) { closed.where(closures: { user_id: Array(users) }) }\n  end\n\n  def closed?\n    closure.present?\n  end\n\n  def open?\n    !closed?\n  end\n\n  def closed_by\n    closure&.user\n  end\n\n  def closed_at\n    closure&.created_at\n  end\n\n  def close(user: Current.user)\n    unless closed?\n      transaction do\n        not_now&.destroy\n        create_closure! user: user\n        track_event :closed, creator: user\n      end\n    end\n  end\n\n  def reopen(user: Current.user)\n    if closed?\n      transaction do\n        closure&.destroy\n        track_event :reopened, creator: user\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "app/models/card/colored.rb",
    "content": "module Card::Colored\n  extend ActiveSupport::Concern\n\n  def color\n    column&.color || Column::Colored::DEFAULT_COLOR\n  end\nend\n"
  },
  {
    "path": "app/models/card/commentable.rb",
    "content": "module Card::Commentable\n  extend ActiveSupport::Concern\n\n  included do\n    has_many :comments, dependent: :destroy\n  end\n\n  def commentable?\n    published?\n  end\n\n  private\n    STORAGE_BATCH_SIZE = 1000\n\n    # Override to include comments, but only load comments that have attachments.\n    # Cards can have thousands of comments; most won't have attachments.\n    # Streams in batches to avoid loading all IDs into memory at once.\n    def storage_transfer_records\n      comment_ids_with_attachments = storage_comment_ids_with_attachments\n\n      if comment_ids_with_attachments.any?\n        [ self, *comments.where(id: comment_ids_with_attachments).to_a ]\n      else\n        [ self ]\n      end\n    end\n\n    def storage_comment_ids_with_attachments\n      direct = []\n      rich_text_map = {}\n\n      # Stream comment IDs in batches to avoid loading all into memory\n      comments.in_batches(of: STORAGE_BATCH_SIZE) do |batch|\n        batch_ids = batch.pluck(:id)\n\n        direct.concat \\\n          ActiveStorage::Attachment\n            .where(record_type: \"Comment\", record_id: batch_ids)\n            .distinct\n            .pluck(:record_id)\n\n        ActionText::RichText\n          .where(record_type: \"Comment\", record_id: batch_ids)\n          .pluck(:id, :record_id)\n          .each { |rt_id, comment_id| rich_text_map[rt_id] = comment_id }\n      end\n\n      embed_comment_ids = if rich_text_map.any?\n        rich_text_map.keys.each_slice(STORAGE_BATCH_SIZE).flat_map do |batch_ids|\n          ActiveStorage::Attachment\n            .where(record_type: \"ActionText::RichText\", record_id: batch_ids)\n            .distinct\n            .pluck(:record_id)\n        end.filter_map { |rt_id| rich_text_map[rt_id] }\n      else\n        []\n      end\n\n      (direct + embed_comment_ids).uniq\n    end\nend\n"
  },
  {
    "path": "app/models/card/entropic.rb",
    "content": "module Card::Entropic\n  extend ActiveSupport::Concern\n\n  included do\n    scope :due_to_be_postponed, -> do\n      active\n        .joins(board: :account)\n        .left_outer_joins(board: :entropy)\n        .joins(\"LEFT OUTER JOIN entropies AS account_entropies ON account_entropies.account_id = accounts.id AND account_entropies.container_type = 'Account' AND account_entropies.container_id = accounts.id\")\n        .where(\"last_active_at <= #{connection.date_subtract('?', 'COALESCE(entropies.auto_postpone_period, account_entropies.auto_postpone_period)')}\", Time.now)\n    end\n\n    scope :postponing_soon, -> do\n      now = Time.now\n      active\n        .joins(board: :account)\n        .left_outer_joins(board: :entropy)\n        .joins(\"LEFT OUTER JOIN entropies AS account_entropies ON account_entropies.account_id = accounts.id AND account_entropies.container_type = 'Account' AND account_entropies.container_id = accounts.id\")\n        .where(\"last_active_at > #{connection.date_subtract('?', 'COALESCE(entropies.auto_postpone_period, account_entropies.auto_postpone_period)')}\", now)\n        .where(\"last_active_at <= #{connection.date_subtract('?', 'COALESCE(entropies.auto_postpone_period, account_entropies.auto_postpone_period) * 0.75')}\", now)\n    end\n\n    delegate :auto_postpone_period, to: :board\n  end\n\n  class_methods do\n    def auto_postpone_all_due\n      due_to_be_postponed.find_each do |card|\n        card.auto_postpone(user: card.account.system_user)\n      end\n    end\n  end\n\n  def entropy\n    Card::Entropy.for(self)\n  end\n\n  def entropic?\n    entropy.present?\n  end\nend\n"
  },
  {
    "path": "app/models/card/entropy.rb",
    "content": "class Card::Entropy\n  attr_reader :card, :auto_clean_period\n\n  class << self\n    def for(card)\n      return unless card.last_active_at\n\n      new(card, card.auto_postpone_period)\n    end\n  end\n\n  def initialize(card, auto_clean_period)\n    @card = card\n    @auto_clean_period = auto_clean_period\n  end\n\n  def auto_clean_at\n    card.last_active_at + auto_clean_period\n  end\n\n  def days_before_reminder\n    (auto_clean_period * 0.25).seconds.in_days.round\n  end\nend\n"
  },
  {
    "path": "app/models/card/eventable/system_commenter.rb",
    "content": "class Card::Eventable::SystemCommenter\n  include ERB::Util\n\n  attr_reader :card, :event\n\n  def initialize(card, event)\n    @card, @event = card, event\n  end\n\n  def comment\n    return unless comment_body.present?\n\n    card.comments.create! creator: card.account.system_user, body: comment_body, created_at: event.created_at\n  end\n\n  private\n    def comment_body\n      case event.action\n      when \"card_assigned\"\n        \"#{creator_name} <strong>assigned</strong> this to #{assignee_names}.\"\n      when \"card_unassigned\"\n        \"#{creator_name} <strong>unassigned</strong> from #{assignee_names}.\"\n      when \"card_closed\"\n        \"<strong>Moved</strong> to “Done” by #{creator_name}\"\n      when \"card_reopened\"\n        \"<strong>Reopened</strong> by #{creator_name}\"\n      when \"card_postponed\"\n        \"#{creator_name} <strong>moved</strong> this to “Not Now”\"\n      when \"card_auto_postponed\"\n        \"<strong>Moved</strong> to “Not Now” due to inactivity\"\n      when \"card_title_changed\"\n        \"#{creator_name} <strong>changed the title</strong> from “#{old_title}” to “#{new_title}”.\"\n      when \"card_board_changed\"\n        \"#{creator_name} <strong>moved</strong> this from “#{old_board}” to “#{new_board}”.\"\n      when \"card_triaged\"\n        \"#{creator_name} <strong>moved</strong> this to “#{column}”\"\n      when \"card_sent_back_to_triage\"\n        \"#{creator_name} <strong>moved</strong> this back to “Maybe?”\"\n      end\n    end\n\n    def creator_name\n      h event.creator.name\n    end\n\n    def assignee_names\n      h event.assignees.pluck(:name).to_sentence\n    end\n\n    def old_title\n      h event.particulars.dig(\"particulars\", \"old_title\")\n    end\n\n    def new_title\n      h event.particulars.dig(\"particulars\", \"new_title\")\n    end\n\n    def old_board\n      h event.particulars.dig(\"particulars\", \"old_board\")\n    end\n\n    def new_board\n      h event.particulars.dig(\"particulars\", \"new_board\")\n    end\n\n    def column\n      h event.particulars.dig(\"particulars\", \"column\")\n    end\nend\n"
  },
  {
    "path": "app/models/card/eventable.rb",
    "content": "module Card::Eventable\n  extend ActiveSupport::Concern\n\n  include ::Eventable\n\n  included do\n    before_create { self.last_active_at ||= created_at || Time.current }\n\n    after_save :track_title_change, if: :saved_change_to_title?\n  end\n\n  def event_was_created(event)\n    transaction do\n      create_system_comment_for(event)\n      touch_last_active_at unless was_just_published?\n    end\n  end\n\n  def touch_last_active_at\n    # Not using touch so that we can detect attribute change on callbacks\n    update!(last_active_at: Time.current)\n  end\n\n  private\n    def should_track_event?\n      published?\n    end\n\n    def track_title_change\n      if title_before_last_save.present?\n        track_event \"title_changed\", particulars: { old_title: title_before_last_save, new_title: title }\n      end\n    end\n\n    def create_system_comment_for(event)\n      SystemCommenter.new(self, event).comment\n    end\nend\n"
  },
  {
    "path": "app/models/card/exportable.rb",
    "content": "module Card::Exportable\n  extend ActiveSupport::Concern\n  include ActionView::Helpers::TagHelper\n\n  def export_json\n    JSON.pretty_generate({\n      number: number,\n      title: title,\n      board: board.name,\n      status: export_status,\n      creator: export_user(creator),\n      description: export_html(description),\n      created_at: created_at.iso8601,\n      updated_at: updated_at.iso8601,\n      comments: comments.chronologically.map do |comment|\n        {\n          id: comment.id,\n          body: export_html(comment.body),\n          creator: export_user(comment.creator),\n          created_at: comment.created_at.iso8601\n        }\n      end\n    })\n  end\n\n  def export_attachments\n    collect_attachments.map do |attachment|\n      { path: export_attachment_path(attachment.blob), blob: attachment.blob }\n    end\n  end\n\n  private\n    def export_html(rich_text)\n      return \"\" if rich_text.blank?\n\n      rich_text.body.render_attachments do |attachment|\n        attachment_representation(attachment)\n      end.to_html\n    end\n\n    def attachment_representation(attachment)\n      case attachable = attachment.attachable\n      when ActiveStorage::Blob\n        path = export_attachment_path(attachable)\n        if attachable.image?\n          tag.img(src: path, alt: attachable.filename)\n        else\n          tag.a(attachable.filename, href: path)\n        end\n      when ActionText::Attachables::RemoteImage\n        tag.img(src: attachable.url, alt: \"Remote image\")\n      else\n        attachment.to_html\n      end\n    end\n\n    def export_user(user)\n      {\n        id: user.id,\n        name: user.name,\n        email: user.identity&.email_address\n      }\n    end\n\n    def export_attachment_path(blob)\n      \"#{number}/#{blob.key}_#{blob.filename}\"\n    end\n\n    def collect_attachments\n      (attachments.to_a + comments.flat_map { |c| c.attachments.to_a }).uniq(&:blob_id)\n    end\n\n    def export_status\n      case\n      when closed?\n        \"Done\"\n      when postponed?\n        \"Not now\"\n      when column.present?\n        column.name\n      else\n        \"Maybe?\"\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/card/golden.rb",
    "content": "module Card::Golden\n  extend ActiveSupport::Concern\n\n  included do\n    has_one :goldness, dependent: :destroy, class_name: \"Card::Goldness\"\n\n    scope :golden, -> { joins(:goldness) }\n    scope :with_golden_first, -> { left_outer_joins(:goldness).prepend_order(\"card_goldnesses.id IS NULL\").preload(:goldness) }\n  end\n\n  def golden?\n    goldness.present?\n  end\n\n  def gild\n    create_goldness! unless golden?\n  end\n\n  def ungild\n    goldness&.destroy\n  end\nend\n"
  },
  {
    "path": "app/models/card/goldness.rb",
    "content": "class Card::Goldness < ApplicationRecord\n  belongs_to :account, default: -> { card.account }\n  belongs_to :card, touch: true\nend\n"
  },
  {
    "path": "app/models/card/mentions.rb",
    "content": "module Card::Mentions\n  extend ActiveSupport::Concern\n\n  included do\n    include ::Mentions\n\n    def mentionable?\n      published?\n    end\n\n    def should_check_mentions?\n      was_just_published?\n    end\n  end\nend\n"
  },
  {
    "path": "app/models/card/multistep.rb",
    "content": "module Card::Multistep\n  extend ActiveSupport::Concern\n\n  included do\n    has_many :steps, dependent: :destroy\n  end\nend\n"
  },
  {
    "path": "app/models/card/not_now.rb",
    "content": "class Card::NotNow < ApplicationRecord\n  belongs_to :account, default: -> { card.account }\n  belongs_to :card, class_name: \"::Card\", touch: true\n  belongs_to :user, optional: true\nend\n"
  },
  {
    "path": "app/models/card/pinnable.rb",
    "content": "module Card::Pinnable\n  extend ActiveSupport::Concern\n\n  included do\n    has_many :pins, dependent: :destroy\n\n    after_update_commit :broadcast_pin_updates, if: :preview_changed?\n  end\n\n  def pinned_by?(user)\n    pins.exists?(user: user)\n  end\n\n  def pin_for(user)\n    pins.find_by(user: user)\n  end\n\n  def pin_by(user)\n    pins.find_or_create_by!(user: user)\n  end\n\n  def unpin_by(user)\n    pins.find_by(user: user).tap { it.destroy }\n  end\n\n  private\n    def broadcast_pin_updates\n      pins.find_each do |pin|\n        pin.broadcast_replace_later_to [ pin.user, :pins_tray ], partial: \"my/pins/pin\"\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/card/postponable.rb",
    "content": "module Card::Postponable\n  extend ActiveSupport::Concern\n\n  included do\n    has_one :not_now, dependent: :destroy, class_name: \"Card::NotNow\"\n\n    scope :postponed, -> { open.published.joins(:not_now) }\n    scope :active, -> { open.published.where.missing(:not_now) }\n  end\n\n  def postponed?\n    open? && published? && not_now.present?\n  end\n\n  def postponed_at\n    not_now&.created_at\n  end\n\n  def postponed_by\n    not_now&.user\n  end\n\n  def active?\n    open? && published? && !postponed?\n  end\n\n  def auto_postpone(**args)\n    postpone(**args, event_name: :auto_postponed)\n  end\n\n  def postpone(user: Current.user, event_name: :postponed)\n    transaction do\n      send_back_to_triage(skip_event: true)\n      reopen\n      activity_spike&.destroy\n      create_not_now!(user: user) unless postponed?\n      track_event event_name, creator: user\n    end\n  end\n\n  def resume\n    transaction do\n      reopen\n      activity_spike&.destroy\n      not_now&.destroy\n    end\n  end\nend\n"
  },
  {
    "path": "app/models/card/promptable.rb",
    "content": "module Card::Promptable\n  extend ActiveSupport::Concern\n\n  included do\n    include Rails.application.routes.url_helpers\n  end\n\n  def to_prompt\n    <<~PROMPT\n      BEGIN OF CARD #{id}\n\n      **Title:** #{title.first(1000)}\n      **Description:**\n\n      #{description.to_plain_text.first(10_000)}\n\n      #### Metadata\n\n      * Id: #{id}\n      * Created by: #{creator.name}}\n      * Assigned to: #{assignees.map(&:name).join(\", \")}\n      * Column: #{column_prompt_label}\n      * Created at: #{created_at}}\n      * Board id: #{board_id}\n      * Board name: #{board.name}\n      * Number of comments: #{comments.count}\n      * Path: #{card_path(self, script_name: account.slug)}\n\n      END OF CARD #{id}\n    PROMPT\n  end\n\n  private\n    def column_prompt_label\n      if open?\n        if postponed?\n          \"Not now\"\n        elsif triaged?\n          \"#{column&.name}\"\n        else\n          \"Maybe?\"\n        end\n      else\n        \"Closed (by #{closed_by&.name} at #{closed_at})\"\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/card/readable.rb",
    "content": "module Card::Readable\n  extend ActiveSupport::Concern\n\n  def read_by(user)\n    user.notifications.find_by(card: self)&.tap(&:read)\n  end\n\n  def unread_by(user)\n    user.notifications.find_by(card: self)&.tap(&:unread)\n  end\n\n  def remove_inaccessible_notifications\n    accessible_user_ids = board.accesses.pluck(:user_id)\n    notification_sources.each do |sources|\n      inaccessible_notifications_from(sources, accessible_user_ids).in_batches.destroy_all\n    end\n  end\n\n  private\n    def remove_inaccessible_notifications_later\n      Card::RemoveInaccessibleNotificationsJob.perform_later(self)\n    end\n\n    def event_notification_sources\n      events.or(comment_creation_events)\n    end\n\n    def comment_creation_events\n      Event.where(eventable: comments)\n    end\n\n    def inaccessible_notifications_from(sources, accessible_user_ids)\n      Notification.where(source: sources).where.not(user_id: accessible_user_ids)\n    end\n\n    def notification_sources\n      [ events, comment_creation_events, mentions, comment_mentions ]\n    end\n\n    def mention_notification_sources\n      mentions.or(comment_mentions)\n    end\n\n    def comment_mentions\n      Mention.where(source: comments)\n    end\nend\n"
  },
  {
    "path": "app/models/card/searchable.rb",
    "content": "module Card::Searchable\n  extend ActiveSupport::Concern\n\n  included do\n    include ::Searchable\n\n    scope :mentioning, ->(query, user:) do\n      search_record_class = Search::Record.for(user.account_id)\n      joins(search_record_class.card_join).merge(search_record_class.for_query(query, user: user))\n    end\n  end\n\n  def search_title\n    title\n  end\n\n  def search_content\n    description.to_plain_text\n  end\n\n  def search_card_id\n    id\n  end\n\n  def search_board_id\n    board_id\n  end\n\n  def searchable?\n    published?\n  end\nend\n"
  },
  {
    "path": "app/models/card/stallable.rb",
    "content": "module Card::Stallable\n  extend ActiveSupport::Concern\n\n  STALLED_AFTER_LAST_SPIKE_PERIOD = 14.days\n\n  included do\n    has_one :activity_spike, class_name: \"Card::ActivitySpike\", dependent: :destroy\n\n    scope :with_activity_spikes, -> { joins(:activity_spike) }\n    scope :stalled, -> { open.active.with_activity_spikes.where(card_activity_spikes: { updated_at: ..STALLED_AFTER_LAST_SPIKE_PERIOD.ago }, updated_at: ..STALLED_AFTER_LAST_SPIKE_PERIOD.ago) }\n\n    before_update :remember_to_detect_activity_spikes\n    after_update_commit :detect_activity_spikes_later, if: :should_detect_activity_spikes?\n  end\n\n  # Keep in sync with #isStalled in app/javascript/controllers/bubble_controller.js\n  def stalled?\n    if activity_spike.present?\n      open? && last_activity_spike_at < STALLED_AFTER_LAST_SPIKE_PERIOD.ago && updated_at < STALLED_AFTER_LAST_SPIKE_PERIOD.ago\n    end\n  end\n\n  def last_activity_spike_at\n    activity_spike&.updated_at\n  end\n\n  def detect_activity_spikes\n    Card::ActivitySpike::Detector.new(self).detect\n  end\n\n  private\n    def remember_to_detect_activity_spikes\n      @should_detect_activity_spikes = published? && last_active_at_changed?\n    end\n\n    def should_detect_activity_spikes?\n      @should_detect_activity_spikes\n    end\n\n    def detect_activity_spikes_later\n      Card::ActivitySpike::DetectionJob.perform_later(self)\n    end\nend\n"
  },
  {
    "path": "app/models/card/statuses.rb",
    "content": "module Card::Statuses\n  extend ActiveSupport::Concern\n\n  included do\n    enum :status, %w[ drafted published ].index_by(&:itself)\n\n    before_save :mark_if_just_published\n    after_create -> { track_event :published }, if: :published?\n  end\n\n  attr_accessor :was_just_published\n  alias_method :was_just_published?, :was_just_published\n\n  def publish\n    transaction do\n      self.created_at = Time.current\n      published!\n      track_event :published\n    end\n  end\n\n  private\n    def mark_if_just_published\n      self.was_just_published = true if published? && status_changed?\n    end\nend\n"
  },
  {
    "path": "app/models/card/taggable.rb",
    "content": "module Card::Taggable\n  extend ActiveSupport::Concern\n\n  included do\n    has_many :taggings, dependent: :destroy\n    has_many :tags, through: :taggings\n\n    scope :tagged_with, ->(tags) { joins(:taggings).where(taggings: { tag: tags }) }\n  end\n\n  def toggle_tag_with(title)\n    tag = account.tags.find_or_create_by!(title: title)\n\n    transaction do\n      if tagged_with?(tag)\n        taggings.destroy_by tag: tag\n      else\n        taggings.create tag: tag\n      end\n    end\n  end\n\n  def tagged_with?(tag)\n    tags.include? tag\n  end\nend\n"
  },
  {
    "path": "app/models/card/triageable.rb",
    "content": "module Card::Triageable\n  extend ActiveSupport::Concern\n\n  included do\n    belongs_to :column, optional: true, touch: true\n\n    scope :awaiting_triage, -> { active.where.missing(:column) }\n    scope :triaged, -> { active.joins(:column) }\n  end\n\n  def triaged?\n    active? && column.present?\n  end\n\n  def awaiting_triage?\n    active? && !triaged?\n  end\n\n  def triage_into(column)\n    raise \"The column must belong to the card board\" unless board == column.board\n\n    transaction do\n      resume\n      update! column: column\n      track_event \"triaged\", particulars: { column: column.name }\n    end\n  end\n\n  def send_back_to_triage(skip_event: false)\n    transaction do\n      resume\n      update! column: nil\n      track_event \"sent_back_to_triage\" unless skip_event\n    end\n  end\nend\n"
  },
  {
    "path": "app/models/card/watchable.rb",
    "content": "module Card::Watchable\n  extend ActiveSupport::Concern\n\n  included do\n    has_many :watches, dependent: :destroy\n    has_many :watchers, -> { active.merge(Watch.watching) }, through: :watches, source: :user\n\n    after_create :subscribe_creator\n  end\n\n  def watched_by?(user)\n    watch_for(user)&.watching?\n  end\n\n  def watch_for(user)\n    watches.find_by(user: user)\n  end\n\n  def watch_by(user)\n    watches.where(user: user).first_or_create.update!(watching: true)\n  end\n\n  def unwatch_by(user)\n    watches.where(user: user).first_or_create.update!(watching: false)\n  end\n\n  private\n    def subscribe_creator\n      # Avoid touching to not interfere with the abandon card detection system\n      Card.no_touching do\n        watch_by creator\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/card.rb",
    "content": "class Card < ApplicationRecord\n  include Accessible, Assignable, Attachments, Broadcastable, Closeable, Colored, Commentable,\n    Entropic, Eventable, Exportable, Golden, Mentions, Multistep, Pinnable, Postponable, Promptable,\n    Readable, Searchable, Stallable, Statuses, Storage::Tracked, Taggable, Triageable, Watchable\n\n  belongs_to :account, default: -> { board.account }\n  belongs_to :board\n  belongs_to :creator, class_name: \"User\", default: -> { Current.user }\n\n  has_many :reactions, -> { order(:created_at) }, as: :reactable, dependent: :delete_all\n  has_one_attached :image, dependent: :purge_later\n\n  has_rich_text :description\n\n  before_save :set_default_title, if: :published?\n  before_create :assign_number\n\n  after_save   -> { board.touch }, if: :published?\n  after_touch  -> { board.touch }, if: :published?\n  after_update :handle_board_change, if: :saved_change_to_board_id?\n\n  scope :reverse_chronologically, -> { order created_at:     :desc, id: :desc }\n  scope :chronologically,         -> { order created_at:     :asc,  id: :asc  }\n  scope :latest,                  -> { order last_active_at: :desc, id: :desc }\n  scope :with_users,              -> { preload(creator: [ :avatar_attachment, :account ], assignees: [ :avatar_attachment, :account ]) }\n  scope :preloaded,               -> { with_users.preload(:column, :tags, :steps, :closure, :goldness, :activity_spike, :image_attachment, reactions: :reacter, board: [ :entropy, :columns ], not_now: [ :user ]).with_rich_text_description_and_embeds }\n\n  scope :indexed_by, ->(index) do\n    case index\n    when \"stalled\" then stalled\n    when \"postponing_soon\" then postponing_soon\n    when \"closed\" then closed\n    when \"not_now\" then postponed.latest\n    when \"golden\" then golden\n    when \"draft\" then drafted\n    else all\n    end\n  end\n\n  scope :sorted_by, ->(sort) do\n    case sort\n    when \"newest\" then reverse_chronologically\n    when \"oldest\" then chronologically\n    when \"latest\" then latest\n    else latest\n    end\n  end\n\n  def card\n    self\n  end\n\n  def to_param\n    number.to_s\n  end\n\n  def move_to(new_board)\n    transaction do\n      card.update!(board: new_board)\n      card.events.update_all(board_id: new_board.id)\n      Event.where(eventable: card.comments).update_all(board_id: new_board.id)\n    end\n  end\n\n  def filled?\n    title.present? || description.present?\n  end\n\n  private\n    def set_default_title\n      self.title = \"Untitled\" if title.blank?\n    end\n\n    def handle_board_change\n      old_board = account.boards.find_by(id: board_id_before_last_save)\n\n      transaction do\n        update! column: nil\n        track_board_change_event(old_board.name)\n        grant_access_to_assignees unless board.all_access?\n      end\n\n      remove_inaccessible_notifications_later\n      clean_inaccessible_data_later\n    end\n\n    def track_board_change_event(old_board_name)\n      track_event \"board_changed\", particulars: { old_board: old_board_name, new_board: board.name }\n    end\n\n    def assign_number\n      self.number ||= account.increment!(:cards_count).cards_count\n    end\nend\n"
  },
  {
    "path": "app/models/closure.rb",
    "content": "class Closure < ApplicationRecord\n  belongs_to :account, default: -> { card.account }\n  belongs_to :card, touch: true\n  belongs_to :user, optional: true\nend\n"
  },
  {
    "path": "app/models/color.rb",
    "content": "Color = Struct.new(:name, :value)\n\nclass Color\n  class << self\n    def for_value(value)\n      COLORS.find { |it| it.value == value }\n    end\n  end\n\n  def to_s\n    value\n  end\n\n  COLORS = {\n    \"Blue\" => \"var(--color-card-default)\",\n    \"Gray\" => \"var(--color-card-1)\",\n    \"Tan\" => \"var(--color-card-2)\",\n    \"Yellow\" => \"var(--color-card-3)\",\n    \"Lime\" => \"var(--color-card-4)\",\n    \"Aqua\" => \"var(--color-card-5)\",\n    \"Violet\" => \"var(--color-card-6)\",\n    \"Purple\" => \"var(--color-card-7)\",\n    \"Pink\" => \"var(--color-card-8)\"\n  }.collect { |name, value| new(name, value) }.freeze\nend\n"
  },
  {
    "path": "app/models/column/colored.rb",
    "content": "module Column::Colored\n  extend ActiveSupport::Concern\n\n  DEFAULT_COLOR = Color::COLORS.first\n\n  included do\n    before_validation -> { self[:color] ||= DEFAULT_COLOR.value }\n  end\n\n  def color\n    Color.for_value(super) || DEFAULT_COLOR\n  end\nend\n"
  },
  {
    "path": "app/models/column/positioned.rb",
    "content": "module Column::Positioned\n  extend ActiveSupport::Concern\n\n  included do\n    scope :sorted, -> { order(position: :asc) }\n\n    before_create :set_position\n  end\n\n  def move_left\n    swap_position_with left_column\n  end\n\n  def move_right\n    swap_position_with right_column\n  end\n\n  def left_column\n    board.columns.where(\"position < ?\", position).sorted.last\n  end\n\n  def right_column\n    board.columns.where(\"position > ?\", position).sorted.first\n  end\n\n  def leftmost?\n    left_column.nil?\n  end\n\n  def rightmost?\n    right_column.nil?\n  end\n\n  def adjacent_columns\n    board.columns.where(id: [ left_column&.id, right_column&.id ].compact)\n  end\n\n  private\n    def set_position\n      max_position = board.columns.maximum(:position) || 0\n      self.position = max_position + 1\n    end\n\n    def swap_position_with(other_column)\n      return if other_column.nil?\n\n      transaction do\n        old_position = self.position\n        self.update_column(:position, other_column.position)\n        other_column.update_column(:position, old_position)\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/column.rb",
    "content": "class Column < ApplicationRecord\n  include Colored, Positioned\n\n  belongs_to :account, default: -> { board.account }\n  belongs_to :board, touch: true\n  has_many :cards, dependent: :nullify\n\n  after_save_commit    -> { cards.touch_all }, if: -> { saved_change_to_name? || saved_change_to_color? }\n  after_destroy_commit -> { board.cards.touch_all }\nend\n"
  },
  {
    "path": "app/models/comment/eventable.rb",
    "content": "module Comment::Eventable\n  extend ActiveSupport::Concern\n\n  include ::Eventable\n\n  included do\n    after_create_commit :track_creation\n  end\n\n  def event_was_created(event)\n    card.touch_last_active_at\n  end\n\n  private\n    def should_track_event?\n      !creator.system?\n    end\n\n    def track_creation\n      track_event(\"created\", board: card.board, creator: creator)\n    end\nend\n"
  },
  {
    "path": "app/models/comment/mentions.rb",
    "content": "module Comment::Mentions\n  extend ActiveSupport::Concern\n\n  included do\n    include ::Mentions\n\n    def mentionable?\n      card.published?\n    end\n  end\nend\n"
  },
  {
    "path": "app/models/comment/promptable.rb",
    "content": "module Comment::Promptable\n  extend ActiveSupport::Concern\n\n  included do\n    include Rails.application.routes.url_helpers\n  end\n\n  def to_prompt\n    <<~PROMPT\n        BEGIN OF COMMENT #{id}\n\n        **Content:**\n\n        #{body.to_plain_text.first(5000)}\n\n        #### Metadata\n\n        * Id: #{id}\n        * Card id: #{card.number}\n        * Card title: #{card.title}\n        * Created by: #{creator.name}}\n        * Created at: #{created_at}}\n        * Path: #{card_path(card, anchor: ActionView::RecordIdentifier.dom_id(self), script_name: account.slug)}\n        END OF COMMENT #{id}\n      PROMPT\n  end\nend\n"
  },
  {
    "path": "app/models/comment/searchable.rb",
    "content": "module Comment::Searchable\n  extend ActiveSupport::Concern\n\n  included do\n    include ::Searchable\n  end\n\n  def search_title\n    nil\n  end\n\n  def search_content\n    body.to_plain_text\n  end\n\n  def search_card_id\n    card_id\n  end\n\n  def search_board_id\n    card.board_id\n  end\n\n  def searchable?\n    card.published?\n  end\nend\n"
  },
  {
    "path": "app/models/comment.rb",
    "content": "class Comment < ApplicationRecord\n  include Attachments, Eventable, Mentions, Promptable, Searchable, Storage::Tracked\n\n  belongs_to :account, default: -> { card.account }\n  belongs_to :card, touch: true\n  belongs_to :creator, class_name: \"User\", default: -> { Current.user }\n  has_many :reactions, -> { order(:created_at) }, as: :reactable, dependent: :delete_all\n\n  has_rich_text :body\n\n  validate :card_is_commentable\n\n  scope :chronologically, -> { order created_at: :asc, id: :desc }\n  scope :preloaded, -> { with_rich_text_body.includes(reactions: :reacter) }\n  scope :by_system, -> { joins(:creator).where(creator: { role: :system }) }\n  scope :by_user, -> { joins(:creator).where.not(creator: { role: :system }) }\n\n  after_create_commit :watch_card_by_creator\n\n  delegate :publicly_accessible?, :accessible_to?, :board, :watch_by, to: :card\n\n  def to_partial_path\n    \"cards/#{super}\"\n  end\n\n  private\n    def card_is_commentable\n      errors.add(:card, \"does not allow comments\") unless card.commentable?\n    end\n\n    def watch_card_by_creator\n      card.watch_by creator\n    end\nend\n"
  },
  {
    "path": "app/models/concerns/attachments.rb",
    "content": "module Attachments\n  extend ActiveSupport::Concern\n\n  # Variants used by ActionText embeds. Processed immediately on attachment to avoid\n  # read replica issues (lazy variants would attempt writes on read replicas).\n  #\n  # Patched into ActionText::RichText in config/initializers/action_text.rb\n  VARIANTS = {\n    # vipsthumbnail used to create sized image variants has a intent setting to manage colors during\n    # resize. By setting an invalid intent value the gif-incompatible intent filtering is skipped and\n    # the gif can be rendered with all its frame intact.\n    #\n    # Only `n` is accepted as an override, using the full parameter name `intent` doesn’t work.\n    #\n    # This was cargo-culted from know-it-all and I imagine it may be fixed at some point.\n    small: { loader: { n: -1 }, resize_to_limit: [ 800, 600 ] },\n    large: { loader: { n: -1 }, resize_to_limit: [ 1024, 768 ] }\n  }\n\n  def attachments\n    rich_text_record&.embeds || []\n  end\n\n  def has_attachments?\n    attachments.any?\n  end\n\n  def remote_images\n    @remote_images ||= rich_text_record&.body&.attachables&.grep(ActionText::Attachables::RemoteImage) || []\n  end\n\n  def has_remote_images?\n    remote_images.any?\n  end\n\n  def remote_videos\n    @remote_videos ||= rich_text_record&.body&.attachables&.grep(ActionText::Attachables::RemoteVideo) || []\n  end\n\n  def has_remote_videos?\n    remote_videos.any?\n  end\n\n  private\n    def rich_text_record\n      @rich_text_record ||= begin\n        association = self.class.reflect_on_all_associations(:has_one).find { it.klass == ActionText::RichText }\n        public_send(association.name)\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/concerns/eventable.rb",
    "content": "module Eventable\n  extend ActiveSupport::Concern\n\n  included do\n    has_many :events, as: :eventable, dependent: :destroy\n  end\n\n  def track_event(action, creator: Current.user, board: self.board, **particulars)\n    if should_track_event?\n      board.events.create!(action: \"#{eventable_prefix}_#{action}\", creator:, board:, eventable: self, particulars:)\n    end\n  end\n\n  def event_was_created(event)\n  end\n\n  private\n    def should_track_event?\n      true\n    end\n\n    def eventable_prefix\n      self.class.name.demodulize.underscore\n    end\nend\n"
  },
  {
    "path": "app/models/concerns/filterable.rb",
    "content": "module Filterable\n  extend ActiveSupport::Concern\n\n  included do\n    has_and_belongs_to_many :filters\n\n    after_update { filters.touch_all }\n    before_destroy :remove_from_filters\n  end\n\n  private\n    # FIXME: This is too inefficient to have part of a destroy transaction.\n    # Need to find a way to use a job or a single query.\n    def remove_from_filters\n      filters.each { it.resource_removed self }\n    end\nend\n"
  },
  {
    "path": "app/models/concerns/mentions.rb",
    "content": "module Mentions\n  extend ActiveSupport::Concern\n\n  included do\n    has_many :mentions, as: :source, dependent: :destroy\n    has_many :mentionees, through: :mentions\n    after_save_commit :create_mentions_later, if: :should_create_mentions?\n  end\n\n  def create_mentions(mentioner: Current.user)\n    scan_mentionees.each do |mentionee|\n      mentionee.mentioned_by mentioner, at: self\n    end\n  end\n\n  def mentionable_content\n    rich_text_associations.collect { send(it.name)&.to_plain_text }.compact.join(\" \")\n  end\n\n  def scan_mentionees\n    mentionees_from_attachments & mentionable_users\n  end\n\n  private\n    def mentionees_from_attachments\n      rich_text_associations.flat_map { send(it.name)&.body&.attachments&.collect { it.attachable } }.compact\n    end\n\n    def mentionable_users\n      board.users\n    end\n\n    def rich_text_associations\n      self.class.reflect_on_all_associations(:has_one).filter { it.klass == ActionText::RichText }\n    end\n\n    def should_create_mentions?\n      mentionable? && (mentionable_content_changed? || should_check_mentions?)\n    end\n\n    def mentionable_content_changed?\n      rich_text_associations.any? { send(it.name)&.body_previously_changed? }\n    end\n\n    def create_mentions_later\n      Mention::CreateJob.perform_later(self, mentioner: Current.user)\n    end\n\n    # Template method\n    def mentionable?\n      true\n    end\n\n    def should_check_mentions?\n      false\n    end\nend\n"
  },
  {
    "path": "app/models/concerns/notifiable.rb",
    "content": "module Notifiable\n  extend ActiveSupport::Concern\n\n  included do\n    has_many :notifications, as: :source, dependent: :destroy\n\n    after_create_commit :notify_recipients_later\n  end\n\n  def notify_recipients\n    Notifier.for(self)&.notify\n  end\n\n  def notifiable_target\n    self\n  end\n\n  private\n    def notify_recipients_later\n      NotifyRecipientsJob.perform_later self\n    end\nend\n"
  },
  {
    "path": "app/models/concerns/searchable.rb",
    "content": "module Searchable\n  extend ActiveSupport::Concern\n\n  SEARCH_CONTENT_LIMIT = 32.kilobytes\n\n  included do\n    after_create_commit :create_in_search_index\n    after_update_commit :update_in_search_index\n    after_destroy_commit :remove_from_search_index\n  end\n\n  def reindex\n    update_in_search_index\n  end\n\n  private\n    def create_in_search_index\n      if searchable?\n        search_record_class.create!(search_record_attributes)\n      end\n    end\n\n    def update_in_search_index\n      if searchable?\n        search_record_class.upsert!(search_record_attributes)\n      else\n        remove_from_search_index\n      end\n    end\n\n    def remove_from_search_index\n      search_record_class.find_by(searchable_type: self.class.name, searchable_id: id)&.destroy\n    end\n\n    def search_record_attributes\n      {\n        account_id: account_id,\n        searchable_type: self.class.name,\n        searchable_id: id,\n        card_id: search_card_id,\n        board_id: search_board_id,\n        title: search_title,\n        content: search_record_content,\n        created_at: created_at\n      }\n    end\n\n    def search_record_content\n      search_content&.truncate_bytes(SEARCH_CONTENT_LIMIT, omission: \"\")\n    end\n\n    def search_record_class\n      Search::Record.for(account_id)\n    end\n\n  # Models must implement these methods:\n  # - account_id: returns the account id\n  # - search_title: returns title string or nil\n  # - search_content: returns content string\n  # - search_card_id: returns the card id (self.id for cards, card_id for comments)\n  # - search_board_id: returns the board id\n  # - searchable?: returns whether this record should be indexed\nend\n"
  },
  {
    "path": "app/models/concerns/storage/totaled.rb",
    "content": "module Storage::Totaled\n  extend ActiveSupport::Concern\n\n  included do\n    has_one :storage_total, as: :owner, class_name: \"Storage::Total\", dependent: :destroy\n    has_many :storage_entries, class_name: \"Storage::Entry\", foreign_key: foreign_key_for_storage\n  end\n\n  class_methods do\n    def foreign_key_for_storage\n      \"#{model_name.singular}_id\"\n    end\n  end\n\n  # Fast: materialized snapshot (may be slightly stale)\n  def bytes_used\n    storage_total&.bytes_stored || 0\n  end\n\n  # Exact: snapshot + pending entries\n  def bytes_used_exact\n    create_or_find_storage_total.current_usage\n  end\n\n  def materialize_storage_later\n    Storage::MaterializeJob.perform_later(self)\n  end\n\n  # Materialize all pending entries into snapshot\n  def materialize_storage\n    total = create_or_find_storage_total\n\n    total.with_lock do\n      latest_entry_id = storage_entries.maximum(:id)\n\n      if latest_entry_id && total.last_entry_id != latest_entry_id\n        scope = storage_entries.where(id: ..latest_entry_id)\n        scope = scope.where.not(id: ..total.last_entry_id) if total.last_entry_id\n        delta_sum = scope.sum(:delta)\n\n        total.update! bytes_stored: total.bytes_stored + delta_sum, last_entry_id: latest_entry_id\n      end\n    end\n  end\n\n  # Reconcile ledger against actual attachment storage.\n  #\n  # Uses two-cursor approach for consistency: capture cursor before AND after the\n  # scan. If they differ, entries were added during the scan and we can't get an\n  # accurate diff without risking double-counting or undercounting.\n  #\n  # Returns true if reconciled successfully, false if aborted due to concurrent\n  # writes. Caller (ReconcileJob) handles retries to avoid amplification.\n  def reconcile_storage\n    cursor_before = storage_entries.maximum(:id)\n    real_bytes = calculate_real_storage_bytes\n    cursor_after = storage_entries.maximum(:id)\n\n    if cursor_before != cursor_after\n      Rails.logger.warn \"[Storage] Reconcile aborted for #{self.class}##{id}: cursor moved during scan\"\n      false\n    else\n      ledger_bytes = cursor_after ? storage_entries.where(id: ..cursor_after).sum(:delta) : 0\n      diff = real_bytes - ledger_bytes\n\n      if diff.nonzero?\n        Rails.logger.info \"[Storage] Reconcile #{self.class}##{id}: adjusting by #{diff} bytes\"\n        Storage::Entry.record \\\n          account: is_a?(Account) ? self : account,\n          board: is_a?(Board) ? self : nil,\n          recordable: nil,\n          delta: diff,\n          operation: \"reconcile\"\n      end\n\n      true\n    end\n  end\n\n  private\n    def create_or_find_storage_total\n      self.storage_total ||= Storage::Total.create_or_find_by!(owner: self)\n    end\n\n    def calculate_real_storage_bytes\n      raise NotImplementedError, \"Subclass must implement calculate_real_storage_bytes\"\n    end\nend\n"
  },
  {
    "path": "app/models/concerns/storage/tracked.rb",
    "content": "# Storage tracking is a business abstraction - we count what users upload.\n# Original upload bytes only; variants/previews/derivatives excluded.\n# Physical storage optimizations (deduplication, compression) don't affect quotas.\nmodule Storage::Tracked\n  extend ActiveSupport::Concern\n\n  included do\n    before_update :track_board_transfer, if: :board_transfer?\n  end\n\n  # Return self as the trackable record for storage entries\n  def storage_tracked_record\n    self\n  end\n\n  # Override in models where board is determined differently (e.g., Board itself)\n  def board_for_storage_tracking\n    board\n  end\n\n  # Total bytes for all attachments on this record\n  def storage_bytes\n    attachments_for_storage.sum { |a| a.blob.byte_size }\n  end\n\n  private\n    def board_transfer?\n      respond_to?(:will_save_change_to_board_id?) && will_save_change_to_board_id?\n    end\n\n    def track_board_transfer\n      old_board = Board.find_by(id: attribute_in_database(:board_id))\n      records = storage_transfer_records.compact\n      return if records.empty?\n\n      attachments_by_record = storage_attachments_for_records(records)\n\n      attachments_by_record.each do |recordable, attachments|\n        bytes = attachments.sum { |attachment| attachment.blob.byte_size }\n        next if bytes.zero?\n\n        # Debit old board\n        if old_board\n          Storage::Entry.record \\\n            account: account,\n            board: old_board,\n            recordable: recordable,\n            delta: -bytes,\n            operation: \"transfer_out\"\n        end\n\n        # Credit new board\n        Storage::Entry.record \\\n          account: account,\n          board: board,\n          recordable: recordable,\n          delta: bytes,\n          operation: \"transfer_in\"\n      end\n    end\n\n    def storage_transfer_records\n      [ self ]\n    end\n\n    # Override if needed. Default = all direct attachments\n    def attachments_for_storage(recordable = self)\n      storage_attachments_for_records([ recordable ]).fetch(recordable, [])\n    end\n\n    def storage_attachments_for_records(recordables)\n      records = Array(recordables).compact\n      return {} if records.empty?\n\n      # Build lookup for records by (type, id) to avoid N+1 when resolving RichText parents\n      records_by_key = records.index_by { |r| [ r.class.name, r.id ] }\n\n      rich_texts = ActionText::RichText.where(record: records)\n      rich_text_to_parent = rich_texts.to_h { |rt| [ rt.id, records_by_key[[ rt.record_type, rt.record_id ]] ] }\n\n      attachments = ActiveStorage::Attachment\n        .where(record: records + rich_texts)\n        .includes(:blob)\n        .to_a\n\n      attachments.each_with_object(Hash.new { |h, k| h[k] = [] }) do |attachment, grouped|\n        # Resolve parent without N+1: use lookup for RichText, direct for others\n        recordable = if attachment.record_type == \"ActionText::RichText\"\n          rich_text_to_parent[attachment.record_id]\n        else\n          records_by_key[[ attachment.record_type, attachment.record_id ]]\n        end\n\n        grouped[recordable] << attachment if recordable\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/current.rb",
    "content": "class Current < ActiveSupport::CurrentAttributes\n  attribute :session, :user, :identity, :account\n  attribute :http_method, :request_id, :user_agent, :ip_address, :referrer\n\n  def session=(value)\n    super(value)\n\n    if value.present?\n      self.identity = session.identity\n    end\n  end\n\n  def identity=(identity)\n    super(identity)\n\n    if identity.present?\n      self.user = identity.users.find_by(account: account)\n    end\n  end\n\n  def with_account(value, &)\n    with(account: value, &)\n  end\n\n  def without_account(&)\n    with(account: nil, &)\n  end\nend\n"
  },
  {
    "path": "app/models/entropy.rb",
    "content": "class Entropy < ApplicationRecord\n  DEFAULT_AUTO_POSTPONE_PERIOD_IN_DAYS = 30\n  AUTO_POSTPONE_PERIODS_IN_DAYS = [ 3, 7, 30, 90, 365, 11 ].freeze\n  AUTO_POSTPONE_PERIODS_IN_SECONDS = AUTO_POSTPONE_PERIODS_IN_DAYS.map { |n| n.day.in_seconds }.freeze\n\n  belongs_to :account, default: -> { container.account }\n  belongs_to :container, polymorphic: true\n\n  validates :auto_postpone_period, inclusion: { in: AUTO_POSTPONE_PERIODS_IN_SECONDS }\n\n  after_commit -> { container.cards.touch_all if container }\n\n  def auto_postpone_period_in_days\n    days = auto_postpone_period / 1.day.to_i\n\n    if days.in?(AUTO_POSTPONE_PERIODS_IN_DAYS)\n      days\n    else\n      default_auto_postpone_period_in_days\n    end\n  end\n\n  def auto_postpone_period_in_days=(new_value)\n    self.auto_postpone_period = new_value.to_i.days.to_i\n  end\n\n  private\n    def default_auto_postpone_period_in_days\n      if container.is_a?(Board)\n        container.account.entropy.auto_postpone_period_in_days\n      else\n        DEFAULT_AUTO_POSTPONE_PERIOD_IN_DAYS\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/event/description.rb",
    "content": "class Event::Description\n  include ActionView::Helpers::TagHelper\n  include ERB::Util\n\n  attr_reader :event, :user\n\n  def initialize(event, user)\n    @event = event\n    @user = user\n  end\n\n  def to_html\n    to_sentence(creator_tag, card_title_tag).html_safe\n  end\n\n  def to_plain_text\n    to_sentence(creator_name, quoted(card.title)).html_safe\n  end\n\n  private\n    def to_sentence(creator, card_title)\n      if event.action.comment_created?\n        comment_sentence(creator, card_title)\n      else\n        action_sentence(creator, card_title)\n      end\n    end\n\n    def creator_tag\n      tag.span data: { creator_id: event.creator.id } do\n        tag.span(\"You\", data: { only_visible_to_you: true }) +\n        tag.span(event.creator.name, data: { only_visible_to_others: true })\n      end\n    end\n\n    def card_title_tag\n      tag.span card.title, class: \"txt-underline\"\n    end\n\n    def creator_name\n      h event.creator.name\n    end\n\n    def quoted(text)\n      h %(\"#{text}\")\n    end\n\n    def card\n      @card ||= event.action.comment_created? ? event.eventable.card : event.eventable\n    end\n\n    def comment_sentence(creator, card_title)\n      \"#{creator} commented on #{card_title}\"\n    end\n\n    def action_sentence(creator, card_title)\n      case event.action\n      when \"card_assigned\"\n        assigned_sentence(creator, card_title)\n      when \"card_unassigned\"\n        unassigned_sentence(creator, card_title)\n      when \"card_published\"\n        \"#{creator} added #{card_title}\"\n      when \"card_closed\"\n        %(#{creator} moved #{card_title} to \"Done\")\n      when \"card_reopened\"\n        \"#{creator} reopened #{card_title}\"\n      when \"card_postponed\"\n        %(#{creator} moved #{card_title} to \"Not Now\")\n      when \"card_auto_postponed\"\n        %(#{card_title} moved to \"Not Now\" due to inactivity)\n      when \"card_resumed\"\n        \"#{creator} resumed #{card_title}\"\n      when \"card_title_changed\"\n        renamed_sentence(creator, card_title)\n      when \"card_board_changed\", \"card_collection_changed\"\n        moved_sentence(creator, card_title)\n      when \"card_triaged\"\n        triaged_sentence(creator, card_title)\n      when \"card_sent_back_to_triage\"\n        %(#{creator} moved #{card_title} back to \"Maybe?\")\n      end\n    end\n\n    def assigned_sentence(creator, card_title)\n      if event.assignees.include?(user)\n        \"#{creator} will handle #{card_title}\"\n      else\n        \"#{creator} assigned #{assignee_names} to #{card_title}\"\n      end\n    end\n\n    def unassigned_sentence(creator, card_title)\n      \"#{creator} unassigned #{unassigned_names} from #{card_title}\"\n    end\n\n    def renamed_sentence(creator, card_title)\n      %(#{creator} renamed #{card_title} (was: \"#{old_title}\"))\n    end\n\n    def moved_sentence(creator, card_title)\n      %(#{creator} moved #{card_title} to \"#{new_location}\")\n    end\n\n    def triaged_sentence(creator, card_title)\n      %(#{creator} moved #{card_title} to \"#{column}\")\n    end\n\n    def assignee_names\n      h event.assignees.pluck(:name).to_sentence\n    end\n\n    def unassigned_names\n      h(event.assignees.include?(user) ? \"yourself\" : assignee_names)\n    end\n\n    def old_title\n      h event.particulars.dig(\"particulars\", \"old_title\")\n    end\n\n    def new_location\n      h(event.particulars.dig(\"particulars\", \"new_board\") || event.particulars.dig(\"particulars\", \"new_collection\"))\n    end\n\n    def column\n      h event.particulars.dig(\"particulars\", \"column\")\n    end\nend\n"
  },
  {
    "path": "app/models/event/particulars.rb",
    "content": "module Event::Particulars\n  extend ActiveSupport::Concern\n\n  included do\n    store_accessor :particulars, :assignee_ids\n  end\n\n  def assignees\n    @assignees ||= User.where id: assignee_ids\n  end\nend\n"
  },
  {
    "path": "app/models/event/promptable.rb",
    "content": "module Event::Promptable\n  extend ActiveSupport::Concern\n\n  def to_prompt\n    <<~PROMPT\n        BEGIN OF EVENT #{id}\n        ## Event #{action} (#{eventable_type} #{eventable_id}))\n\n        * Created at: #{created_at}\n        * Created by: #{creator.name}\n\n        #{eventable.to_prompt}\n        END OF EVENT #{id}\n      PROMPT\n  end\nend\n"
  },
  {
    "path": "app/models/event.rb",
    "content": "class Event < ApplicationRecord\n  include Notifiable, Particulars, Promptable\n\n  belongs_to :account, default: -> { board.account }\n  belongs_to :board\n  belongs_to :creator, class_name: \"User\"\n  belongs_to :eventable, polymorphic: true\n\n  has_many :webhook_deliveries, class_name: \"Webhook::Delivery\", dependent: :delete_all\n\n  scope :chronologically, -> { order created_at: :asc, id: :desc }\n  scope :preloaded, -> {\n    includes(:creator, :board, {\n      eventable: [\n        :goldness, :closure, :image_attachment,\n        { rich_text_body: :embeds_attachments },\n        { rich_text_description: :embeds_attachments },\n        { card: [ :goldness, :closure, :image_attachment ] }\n      ]\n    })\n  }\n\n  after_create -> { eventable.event_was_created(self) }\n  after_create_commit :dispatch_webhooks\n\n  delegate :card, to: :eventable\n\n  def action\n    super.inquiry\n  end\n\n  def notifiable_target\n    eventable\n  end\n\n  def description_for(user)\n    Event::Description.new(self, user)\n  end\n\n  private\n    def dispatch_webhooks\n      Event::WebhookDispatchJob.perform_later(self)\n    end\nend\n"
  },
  {
    "path": "app/models/export.rb",
    "content": "class Export < ApplicationRecord\n  belongs_to :account\n  belongs_to :user\n\n  has_one_attached :file\n\n  enum :status, %w[ pending processing completed failed ].index_by(&:itself), default: :pending\n\n  scope :current, -> { where(created_at: 24.hours.ago..) }\n  scope :expired, -> { where(completed_at: ...24.hours.ago) }\n\n  def self.cleanup\n    expired.destroy_all\n  end\n\n  def build_later\n    DataExportJob.perform_later(self)\n  end\n\n  def build\n    processing!\n\n    with_context do\n      ZipFile.create_for(file, filename: filename) do |zip|\n        populate_zip(zip)\n      end\n      mark_completed\n      ExportMailer.completed(self).deliver_later\n    end\n  rescue => e\n    update!(status: :failed)\n    raise e\n  end\n\n  def mark_completed\n    update!(status: :completed, completed_at: Time.current)\n  end\n\n  def accessible_to?(accessor)\n    accessor == user\n  end\n\n  private\n    def filename\n      \"fizzy-export-#{id}.zip\"\n    end\n\n    def with_context\n      Current.set(account: account) do\n        old_url_options = ActiveStorage::Current.url_options\n        ActiveStorage::Current.url_options = Rails.application.routes.default_url_options\n\n        yield\n      ensure\n        ActiveStorage::Current.url_options = old_url_options\n      end\n    end\n\n    def populate_zip(zip)\n      raise NotImplementedError, \"Subclasses must implement populate_zip\"\n    end\nend\n"
  },
  {
    "path": "app/models/filter/fields.rb",
    "content": "module Filter::Fields\n  extend ActiveSupport::Concern\n\n  INDEXES = %w[ all closed not_now stalled postponing_soon golden ]\n  SORTED_BY = %w[ newest oldest latest ]\n\n  delegate :default_value?, to: :class\n\n  class_methods do\n    def default_values\n      { indexed_by: \"all\", sorted_by: \"latest\" }\n    end\n\n    def default_value?(key, value)\n      default_values[key.to_sym].eql?(value)\n    end\n\n    def indexed_by_human_name(index)\n      case index\n      when \"postponing_soon\"\n        \"Closing soon\"\n      when \"closed\"\n        \"Done\"\n      when \"all\"\n        \"Open\"\n      else\n        index.humanize\n      end\n    end\n  end\n\n  included do\n    store_accessor :fields, :assignment_status, :indexed_by, :sorted_by, :terms,\n      :card_ids, :creation, :closure\n\n    def assignment_status\n      super.to_s.inquiry\n    end\n\n    def indexed_by\n      (super || default_indexed_by).inquiry\n    end\n\n    def sorted_by\n      (super || default_sorted_by).inquiry\n    end\n\n    def creation_window\n      TimeWindowParser.parse(creation)\n    end\n\n    def closure_window\n      TimeWindowParser.parse(closure)\n    end\n\n    def terms\n      Array(super)\n    end\n\n    def terms=(value)\n      super(Array(value).filter(&:present?))\n    end\n  end\n\n  def with(**fields)\n    creator.filters.from_params(as_params).tap do |filter|\n      fields.each do |key, value|\n        filter.public_send(\"#{key}=\", value)\n      end\n    end\n  end\n\n  def default_indexed_by\n    self.class.default_values[:indexed_by]\n  end\n\n  def default_indexed_by?\n    default_value?(:indexed_by, indexed_by)\n  end\n\n  def default_sorted_by\n    self.class.default_values[:sorted_by]\n  end\n\n  def default_sorted_by?\n    default_value?(:sorted_by, sorted_by)\n  end\nend\n"
  },
  {
    "path": "app/models/filter/params.rb",
    "content": "module Filter::Params\n  extend ActiveSupport::Concern\n\n  PERMITTED_PARAMS = [\n    :assignment_status,\n    :indexed_by,\n    :sorted_by,\n    :creation,\n    :closure,\n    card_ids: [],\n    assignee_ids: [],\n    creator_ids: [],\n    closer_ids: [],\n    board_ids: [],\n    tag_ids: [],\n    terms: []\n  ]\n\n  class_methods do\n    def find_by_params(params)\n      find_by params_digest: digest_params(params)\n    end\n\n    def digest_params(params)\n      Digest::MD5.hexdigest normalize_params(params).to_json\n    end\n\n    def normalize_params(params)\n      params\n        .to_h\n        .compact_blank\n        .reject(&method(:default_value?))\n        .collect { |name, value| [ name, value.is_a?(Array) ? value.collect(&:to_s) : value.to_s ] }\n        .sort_by { |name, _| name.to_s }\n        .to_h\n    end\n  end\n\n  included do\n    before_save { self.params_digest = self.class.digest_params(as_params) }\n  end\n\n  def used?(ignore_boards: false)\n    tags.any? || assignees.any? || creators.any? || closers.any? ||\n      terms.any? || card_ids&.any? || (!ignore_boards && boards.present?) ||\n      assignment_status.unassigned? || !indexed_by.all? || !sorted_by.latest?\n  end\n\n  # +as_params+ uses `resource#ids` instead of `#resource_ids`\n  # because the latter won't work on unpersisted filters.\n  def as_params\n    @as_params ||= {}.tap do |params|\n      params[:indexed_by]        = indexed_by\n      params[:sorted_by]         = sorted_by\n      params[:creation]          = creation\n      params[:closure]           = closure\n      params[:assignment_status] = assignment_status\n      params[:terms]             = terms\n      params[:tag_ids]           = tags.ids\n      params[:board_ids]    = boards.ids\n      params[:card_ids]          = card_ids\n      params[:assignee_ids]      = assignees.ids\n      params[:creator_ids]       = creators.ids\n      params[:closer_ids]        = closers.ids\n    end.compact_blank.reject(&method(:default_value?))\n  end\n\n  def as_params_without(key, value)\n    as_params.dup.tap do |params|\n      if params[key].is_a?(Array)\n        params[key] = params[key] - [ value ]\n        params.delete(key) if params[key].empty?\n      elsif params[key] == value\n        params.delete(key)\n      end\n    end\n  end\n\n  def params_digest\n    super.presence || self.class.digest_params(as_params)\n  end\nend\n"
  },
  {
    "path": "app/models/filter/resources.rb",
    "content": "module Filter::Resources\n  extend ActiveSupport::Concern\n\n  included do\n    has_and_belongs_to_many :tags\n    has_and_belongs_to_many :boards\n    has_and_belongs_to_many :assignees, class_name: \"User\", join_table: \"assignees_filters\", association_foreign_key: \"assignee_id\"\n    has_and_belongs_to_many :creators, class_name: \"User\", join_table: \"creators_filters\", association_foreign_key: \"creator_id\"\n    has_and_belongs_to_many :closers, class_name: \"User\", join_table: \"closers_filters\", association_foreign_key: \"closer_id\"\n  end\n\n  def resource_removed(resource)\n    kind = resource.class.model_name.plural\n    send \"#{kind}=\", send(kind).without(resource)\n    @boards = nil\n    empty? ? destroy! : save!\n  rescue ActiveRecord::RecordNotUnique\n    destroy!\n  end\n\n  def boards\n    @boards ||= creator.boards.where id: super.ids\n  end\n\n  def board_titles\n    if boards.none?\n      creator.boards.one? ? [ creator.boards.first.name ] : [ \"all boards\" ]\n    else\n      boards.map(&:name)\n    end\n  end\n\n  def boards_label\n    board_titles.to_sentence\n  end\nend\n"
  },
  {
    "path": "app/models/filter/summarized.rb",
    "content": "module Filter::Summarized\n  def summary\n    [ index_summary, sort_summary, tag_summary, assignee_summary, creator_summary, terms_summary ].compact.to_sentence\n  end\n\n  private\n    def index_summary\n      unless indexed_by.all?\n        indexed_by.humanize\n      end\n    end\n\n    def sort_summary\n      unless sorted_by.latest?\n        sorted_by.humanize\n      end\n    end\n\n    def tag_summary\n      if tags.any?\n        \"#{tags.map(&:hashtag).to_choice_sentence}\"\n      end\n    end\n\n    def assignee_summary\n      if assignees.any?\n        \"assigned to #{assignees.pluck(:name).to_choice_sentence}\"\n      elsif assignment_status.unassigned?\n        \"assigned to no one\"\n      end\n    end\n\n    def terms_summary\n      if terms.any?\n        \"matching #{terms.map { |term| %Q(\"#{term}\") }.to_sentence}\"\n      end\n    end\n\n    def creator_summary\n      if creators.any?\n        \"added by #{creators.pluck(:name).to_choice_sentence}\"\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/filter.rb",
    "content": "class Filter < ApplicationRecord\n  include Fields, Params, Resources, Summarized\n\n  belongs_to :creator, class_name: \"User\", default: -> { Current.user }\n  belongs_to :account, default: -> { creator.account }\n\n  class << self\n    def from_params(params)\n      find_by_params(params) || build(params)\n    end\n\n    def remember(attrs)\n      create!(attrs)\n    rescue ActiveRecord::RecordNotUnique\n      find_by_params(attrs).tap(&:touch)\n    end\n  end\n\n  def cards\n    @cards ||= begin\n      result = creator.accessible_cards.preloaded.published\n      result = result.indexed_by(indexed_by)\n      result = result.sorted_by(sorted_by)\n      result = result.where(id: card_ids) if card_ids.present?\n      result = result.where.missing(:not_now) unless include_not_now_cards?\n      result = result.open unless include_closed_cards?\n      result = result.unassigned if assignment_status.unassigned?\n      result = result.assigned_to(assignees.ids) if assignees.present?\n      result = result.where(creator_id: creators.ids) if creators.present?\n      result = result.where(board: boards.ids) if boards.present?\n      result = result.tagged_with(tags.ids) if tags.present?\n      result = result.where(cards: { created_at: creation_window }) if creation_window\n      result = result.closed_at_window(closure_window) if closure_window\n      result = result.closed_by(closers) if closers.present?\n      result = terms.reduce(result) do |result, term|\n        result.mentioning(term, user: creator)\n      end\n\n      result.distinct\n    end\n  end\n\n  def empty?\n    self.class.normalize_params(as_params).blank?\n  end\n\n  def single_board\n    boards.first if boards.one?\n  end\n\n  def single_workflow\n    boards.first.workflow if boards.pluck(:workflow_id).uniq.one?\n  end\n\n  def cacheable?\n    boards.exists?\n  end\n\n  def cache_key\n    ActiveSupport::Cache.expand_cache_key params_digest, \"filter\"\n  end\n\n  def only_closed?\n    indexed_by.closed? || closure_window || closers.present?\n  end\n\n  private\n    def include_closed_cards?\n      only_closed? || card_ids.present?\n    end\n\n    def include_not_now_cards?\n      indexed_by.not_now? || card_ids.present?\n    end\nend\n"
  },
  {
    "path": "app/models/identity/access_token.rb",
    "content": "class Identity::AccessToken < ApplicationRecord\n  belongs_to :identity\n\n  has_secure_token\n  enum :permission, %w[ read write ].index_by(&:itself), default: :read\n\n  def allows?(method)\n    method.in?(%w[ GET HEAD ]) || write?\n  end\nend\n"
  },
  {
    "path": "app/models/identity/joinable.rb",
    "content": "module Identity::Joinable\n  extend ActiveSupport::Concern\n\n  def join(account, **attributes)\n    attributes[:name] ||= email_address\n\n    transaction do\n      account.users.find_or_create_by!(identity: self) do |user|\n        user.assign_attributes(attributes)\n      end.previously_new_record?\n    end\n  end\nend\n"
  },
  {
    "path": "app/models/identity/transferable.rb",
    "content": "module Identity::Transferable\n  extend ActiveSupport::Concern\n\n  TRANSFER_LINK_EXPIRY_DURATION = 4.hours\n\n  class_methods do\n    def find_by_transfer_id(id)\n      find_signed(id, purpose: :transfer)\n    end\n  end\n\n  def transfer_id\n    signed_id(purpose: :transfer, expires_in: TRANSFER_LINK_EXPIRY_DURATION)\n  end\nend\n"
  },
  {
    "path": "app/models/identity.rb",
    "content": "class Identity < ApplicationRecord\n  include Joinable, Transferable\n\n  has_passkeys name: :email_address, display_name: -> { Current.user&.name || email_address }\n\n  has_many :access_tokens, dependent: :destroy\n  has_many :magic_links, dependent: :destroy\n  has_many :sessions, dependent: :destroy\n  has_many :users, dependent: :nullify\n  has_many :accounts, through: :users\n\n  has_one_attached :avatar\n\n  before_destroy :deactivate_users, prepend: true\n\n  validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP }\n  normalizes :email_address, with: ->(value) { value.strip.downcase.presence }\n\n  def self.find_by_permissable_access_token(token, method:)\n    if (access_token = AccessToken.find_by(token: token)) && access_token.allows?(method)\n      access_token.identity\n    end\n  end\n\n  def send_magic_link(**attributes)\n    attributes[:purpose] = attributes.delete(:for) if attributes.key?(:for)\n\n    magic_links.create!(attributes).tap do |magic_link|\n      MagicLinkMailer.sign_in_instructions(magic_link).deliver_later\n    end\n  end\n\n  def users_with_active_accounts\n    users.joins(:account).merge(Account.active).includes(:account)\n  end\n\n  private\n    def deactivate_users\n      users.find_each(&:deactivate)\n    end\nend\n"
  },
  {
    "path": "app/models/magic_link/code.rb",
    "content": "module MagicLink::Code\n  CODE_SUBSTITUTIONS = { \"O\" => \"0\", \"I\" => \"1\", \"L\" => \"1\" }.freeze\n\n  class << self\n    def generate(length)\n      SecureRandom.base32(length)\n    end\n\n    def sanitize(code)\n      if code.present?\n        normalize_code(code)\n          .then { apply_substitutions(it) }\n          .then { remove_invalid_characters(it) }\n      end\n    end\n\n    private\n      def normalize_code(code)\n        code.to_s.upcase\n      end\n\n      def apply_substitutions(code)\n        CODE_SUBSTITUTIONS.reduce(code) { |result, (from, to)| result.gsub(from, to) }\n      end\n\n      def remove_invalid_characters(code)\n        code.gsub(/[^#{SecureRandom::BASE32_ALPHABET.join}]/, \"\")\n      end\n  end\nend\n"
  },
  {
    "path": "app/models/magic_link.rb",
    "content": "class MagicLink < ApplicationRecord\n  CODE_LENGTH = 6\n  EXPIRATION_TIME = 15.minutes\n\n  belongs_to :identity\n\n  enum :purpose, %w[ sign_in sign_up ], prefix: :for, default: :sign_in\n\n  scope :active, -> { where(expires_at: Time.current...) }\n  scope :stale, -> { where(expires_at: ..Time.current) }\n\n  before_validation :generate_code, on: :create\n  before_validation :set_expiration, on: :create\n\n  validates :code, uniqueness: true, presence: true\n\n  class << self\n    def consume(code)\n      active.find_by(code: Code.sanitize(code))&.consume\n    end\n\n    def cleanup\n      stale.delete_all\n    end\n  end\n\n  def consume\n    destroy\n    self\n  end\n\n  private\n    def generate_code\n      self.code ||= loop do\n        candidate = Code.generate(CODE_LENGTH)\n        break candidate unless self.class.exists?(code: candidate)\n      end\n    end\n\n    def set_expiration\n      self.expires_at ||= EXPIRATION_TIME.from_now\n    end\nend\n"
  },
  {
    "path": "app/models/mention.rb",
    "content": "class Mention < ApplicationRecord\n  include Notifiable\n\n  belongs_to :account, default: -> { source.account }\n  belongs_to :source, polymorphic: true\n  belongs_to :mentioner, class_name: \"User\"\n  belongs_to :mentionee, class_name: \"User\", inverse_of: :mentions\n\n  after_create_commit :watch_source_by_mentionee\n\n  delegate :card, to: :source\n\n  def self_mention?\n    mentioner == mentionee\n  end\n\n  def notifiable_target\n    source\n  end\n\n  private\n    def watch_source_by_mentionee\n      source.watch_by(mentionee)\n    end\nend\n"
  },
  {
    "path": "app/models/notification/bundle.rb",
    "content": "class Notification::Bundle < ApplicationRecord\n  belongs_to :account, default: -> { user.account }\n  belongs_to :user\n\n  enum :status, %i[ pending processing delivered ]\n\n  scope :due, -> { pending.where(\"ends_at <= ?\", Time.current) }\n  scope :containing, ->(notification) { where(\"starts_at <= ? AND ends_at > ?\", notification.updated_at, notification.updated_at) }\n  scope :overlapping_with, ->(other_bundle) do\n    where(\n      \"(starts_at <= ? AND ends_at >= ?) OR (starts_at <= ? AND ends_at >= ?) OR (starts_at >= ? AND ends_at <= ?)\",\n      other_bundle.starts_at, other_bundle.starts_at,\n      other_bundle.ends_at, other_bundle.ends_at,\n      other_bundle.starts_at, other_bundle.ends_at\n    )\n  end\n\n  before_validation :set_default_window, if: :new_record?\n\n  validate :validate_no_overlapping\n\n  class << self\n    def deliver_all\n      due.in_batches do |batch|\n        jobs = batch.collect { DeliverJob.new(it) }\n        ActiveJob.perform_all_later jobs\n      end\n    end\n\n    def deliver_all_later\n      DeliverAllJob.perform_later\n    end\n  end\n\n  def notifications\n    user.notifications.where(updated_at: window).unread\n  end\n\n  def deliver\n    user.in_time_zone do\n      Current.with_account(user.account) do\n        processing!\n\n        Notification::BundleMailer.notification(self).deliver if deliverable?\n\n        delivered!\n      end\n    end\n  end\n\n  def deliver_later\n    DeliverJob.perform_later(self)\n  end\n\n  def flush\n    update!(ends_at: Time.current)\n    deliver_later\n  end\n\n  def set_default_window\n    self.starts_at ||= Time.current\n    self.ends_at ||= self.starts_at + user.settings.bundle_aggregation_period\n  end\n\n  private\n    def window\n      starts_at..ends_at\n    end\n\n    def validate_no_overlapping\n      if overlapping_bundles.exists?\n        errors.add(:base, \"Bundle window overlaps with an existing pending bundle with id #{overlapping_bundles.first.id}\")\n      end\n    end\n\n    def deliverable?\n      user.settings.bundling_emails? && notifications.any? && account.active?\n    end\n\n    def overlapping_bundles\n      user.notification_bundles.where.not(id: id).overlapping_with(self)\n    end\nend\n"
  },
  {
    "path": "app/models/notification/default_payload.rb",
    "content": "class Notification::DefaultPayload\n  attr_reader :notification\n\n  delegate :card, to: :notification\n\n  def initialize(notification)\n    @notification = notification\n  end\n\n  def to_h\n    { title: title, body: body, url: url }\n  end\n\n  def title\n    \"New notification\"\n  end\n\n  def body\n    \"You have a new notification\"\n  end\n\n  def url\n    notifications_url\n  end\n\n  def category\n    \"default\"\n  end\n\n  def high_priority?\n    false\n  end\n\n  def base_url\n    Rails.application.routes.url_helpers.root_url(**url_options.except(:script_name)).chomp(\"/\")\n  end\n\n  def avatar_url\n    Rails.application.routes.url_helpers.user_avatar_url(notification.creator, **url_options)\n  end\n\n  private\n    def card_url(card)\n      Rails.application.routes.url_helpers.card_url(card, **url_options)\n    end\n\n    def notifications_url\n      Rails.application.routes.url_helpers.notifications_url(**url_options)\n    end\n\n    def url_options\n      base_options = Rails.application.routes.default_url_options.presence ||\n        Rails.application.config.action_mailer.default_url_options ||\n        {}\n      base_options.merge(script_name: notification.account.slug)\n    end\nend\n"
  },
  {
    "path": "app/models/notification/event_payload.rb",
    "content": "class Notification::EventPayload < Notification::DefaultPayload\n  include ExcerptHelper\n\n  def title\n    case event.action\n    when \"comment_created\"\n      \"RE: #{card_title}\"\n    else\n      card_title\n    end\n  end\n\n  def body\n    case event.action\n    when \"comment_created\"\n      format_excerpt(event.eventable.body, length: 200)\n    when \"card_assigned\"\n      \"Assigned to you by #{event.creator.name}\"\n    when \"card_published\"\n      \"Added by #{event.creator.name}\"\n    when \"card_closed\"\n      card.closure ? \"Moved to Done by #{event.creator.name}\" : \"Closed by #{event.creator.name}\"\n    when \"card_reopened\"\n      \"Reopened by #{event.creator.name}\"\n    when \"card_triaged\"\n      if column_name.present?\n        \"Moved to #{column_name} by #{event.creator.name}\"\n      else\n        \"Moved by #{event.creator.name}\"\n      end\n    when \"card_sent_back_to_triage\"\n      \"Moved back to Maybe? by #{event.creator.name}\"\n    when \"card_board_changed\", \"card_collection_changed\"\n      if new_location_name.present?\n        \"Moved to #{new_location_name} by #{event.creator.name}\"\n      else\n        \"Moved by #{event.creator.name}\"\n      end\n    when \"card_title_changed\"\n      if new_title.present?\n        \"Renamed to #{new_title} by #{event.creator.name}\"\n      else\n        \"Renamed by #{event.creator.name}\"\n      end\n    when \"card_postponed\"\n      \"Moved to Not Now by #{event.creator.name}\"\n    when \"card_auto_postponed\"\n      \"Moved to Not Now due to inactivity\"\n    else\n      \"Updated by #{event.creator.name}\"\n    end\n  end\n\n  def url\n    case event.action\n    when \"comment_created\"\n      card_url_with_comment_anchor(event.eventable)\n    else\n      card_url(card)\n    end\n  end\n\n  def category\n    case event.action\n    when \"card_assigned\" then \"assignment\"\n    when \"comment_created\" then \"comment\"\n    else \"card\"\n    end\n  end\n\n  def high_priority?\n    event.action.card_assigned?\n  end\n\n  private\n    def event\n      notification.source\n    end\n\n    def card_title\n      card.title.presence || \"Card #{card.number}\"\n    end\n\n    def event_particulars\n      event.particulars.dig(\"particulars\") || {}\n    end\n\n    def column_name\n      event_particulars[\"column\"]\n    end\n\n    def new_location_name\n      event_particulars[\"new_board\"] || event_particulars[\"new_collection\"]\n    end\n\n    def new_title\n      event_particulars[\"new_title\"]\n    end\n\n    def card_url_with_comment_anchor(comment)\n      Rails.application.routes.url_helpers.card_url(\n        comment.card,\n        anchor: ActionView::RecordIdentifier.dom_id(comment),\n        **url_options\n      )\n    end\nend\n"
  },
  {
    "path": "app/models/notification/mention_payload.rb",
    "content": "class Notification::MentionPayload < Notification::DefaultPayload\n  include ExcerptHelper\n\n  def title\n    \"#{mention.mentioner.first_name} mentioned you\"\n  end\n\n  def body\n    format_excerpt(mention.source.mentionable_content, length: 200)\n  end\n\n  def url\n    card_url(card)\n  end\n\n  def category\n    \"mention\"\n  end\n\n  def high_priority?\n    true\n  end\n\n  private\n    def mention\n      notification.source\n    end\nend\n"
  },
  {
    "path": "app/models/notification/push_target/web.rb",
    "content": "class Notification::PushTarget::Web < Notification::PushTarget\n  def process\n    if subscriptions.any?\n      Rails.configuration.x.web_push_pool.queue(notification.payload.to_h, subscriptions)\n    end\n  end\n\n  private\n    def subscriptions\n      @subscriptions ||= notification.user.push_subscriptions\n    end\nend\n"
  },
  {
    "path": "app/models/notification/push_target.rb",
    "content": "class Notification::PushTarget\n  attr_reader :notification\n\n  delegate :card, to: :notification\n\n  def self.process(notification)\n    new(notification).process\n  end\n\n  def initialize(notification)\n    @notification = notification\n  end\n\n  def process\n    raise NotImplementedError\n  end\nend\n"
  },
  {
    "path": "app/models/notification/pushable.rb",
    "content": "module Notification::Pushable\n  extend ActiveSupport::Concern\n\n  included do\n    class_attribute :push_targets, default: []\n\n    after_save_commit :push_later, if: :source_id_previously_changed?\n  end\n\n  class_methods do\n    def register_push_target(target)\n      target = resolve_push_target(target)\n      push_targets << target unless push_targets.include?(target)\n    end\n\n    private\n      def resolve_push_target(target)\n        if target.is_a?(Symbol)\n          \"Notification::PushTarget::#{target.to_s.classify}\".constantize\n        else\n          target\n        end\n      end\n  end\n\n  def push_later\n    Notification::PushJob.perform_later(self)\n  end\n\n  def push\n    return unless pushable?\n\n    self.class.push_targets.each { |target| push_to(target) }\n  end\n\n  def payload\n    \"Notification::#{payload_type}Payload\".constantize.new(self)\n  end\n\n  private\n    def pushable?\n      !creator.system? && user.active? && account.active?\n    end\n\n    def push_to(target)\n      target.process(self)\n    end\n\n    def payload_type\n      source_type.presence_in(%w[ Event Mention ]) || \"Default\"\n    end\nend\n"
  },
  {
    "path": "app/models/notification.rb",
    "content": "class Notification < ApplicationRecord\n  include Notification::Pushable\n\n  belongs_to :account, default: -> { user.account }\n  belongs_to :user\n  belongs_to :creator, class_name: \"User\"\n  belongs_to :source, polymorphic: true\n  belongs_to :card\n\n  scope :unread, -> { where(read_at: nil) }\n  scope :read, -> { where.not(read_at: nil) }\n  scope :ordered, -> { order(read_at: :desc, updated_at: :desc) }\n  scope :preloaded, -> {\n    preload(\n      :creator, :account,\n      card: [ :board, :column, :closure, :not_now ],\n      source: [ :board, :creator, { eventable: [ :closure, :board, :assignments ] } ]\n    )\n  }\n\n  before_validation :set_card\n  after_create :bundle\n  after_update :bundle, if: :source_id_previously_changed?\n\n  after_create_commit  -> { broadcast_prepend_later_to user, :notifications, target: \"notifications\" }\n  after_update_commit  -> { broadcast_update }\n  after_destroy_commit -> { broadcast_remove_to user, :notifications }\n\n  delegate :notifiable_target, to: :source\n  delegate :identity, to: :user\n\n  class << self\n    def read_all\n      all.each(&:read)\n    end\n\n    def unread_all\n      all.each(&:unread)\n    end\n  end\n\n  def read\n    update!(read_at: Time.current, unread_count: 0)\n  end\n\n  def unread\n    update!(read_at: nil, unread_count: 1)\n  end\n\n  def read?\n    read_at.present?\n  end\n\n  private\n    def set_card\n      self.card = source.card\n    end\n\n    def bundle\n      user.bundle(self) if user.settings.bundling_emails?\n    end\n\n    def broadcast_update\n      if read?\n        broadcast_remove_to(user, :notifications)\n      else\n        broadcast_prepend_later_to(user, :notifications, target: \"notifications\")\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/notifier/card_event_notifier.rb",
    "content": "class Notifier::CardEventNotifier < Notifier\n  delegate :creator, to: :source\n  delegate :board, to: :card\n\n  private\n    def recipients\n      case source.action\n      when \"card_assigned\"\n        source.assignees.excluding(creator)\n      when \"card_published\"\n        board.watchers.without(creator, *card.scan_mentionees).including(*card.assignees).uniq\n      when \"comment_created\"\n        card.watchers.without(creator, *source.eventable.scan_mentionees)\n      else\n        board.watchers.without(creator)\n      end\n    end\n\n    def card\n      source.eventable\n    end\nend\n"
  },
  {
    "path": "app/models/notifier/comment_event_notifier.rb",
    "content": "class Notifier::CommentEventNotifier < Notifier\n  delegate :creator, to: :source\n\n  private\n    def recipients\n      card.watchers.without(creator, *source.eventable.scan_mentionees)\n    end\n\n    def card\n      source.eventable.card\n    end\nend\n"
  },
  {
    "path": "app/models/notifier/mention_notifier.rb",
    "content": "class Notifier::MentionNotifier < Notifier\n  alias mention source\n\n  private\n    def recipients\n      if mention.self_mention?\n        []\n      else\n        [ mention.mentionee ]\n      end\n    end\n\n    def creator\n      mention.mentioner\n    end\nend\n"
  },
  {
    "path": "app/models/notifier.rb",
    "content": "class Notifier\n  attr_reader :source\n\n  class << self\n    def for(source)\n      case source\n      when Event\n        \"Notifier::#{source.eventable.class}EventNotifier\".safe_constantize&.new(source)\n      when Mention\n        MentionNotifier.new(source)\n      end\n    end\n  end\n\n  def notify\n    if should_notify?\n      # Processing recipients in order avoids deadlocks if notifications overlap.\n      recipients.sort_by(&:id).map do |recipient|\n        notification = Notification.create_or_find_by(user: recipient, card: source.card) do |n|\n          n.source = source\n          n.creator = creator\n          n.unread_count = 1\n        end\n\n        unless notification.previously_new_record?\n          # Always include source_type in the update to prevent a race condition between\n          # concurrent Event and Mention notifier jobs: without this, Rails' dirty tracking\n          # may skip source_type when it hasn't changed from the stale in-memory value,\n          # even though another job has since modified it in the database, leaving\n          # source_type and source_id mismatched.\n          notification.source_type_will_change!\n          notification.update!(source: source, creator: creator, read_at: nil, unread_count: notification.unread_count + 1)\n        end\n\n        notification\n      end\n    end\n  end\n\n  private\n    def initialize(source)\n      @source = source\n    end\n\n    def should_notify?\n      !creator.system?\n    end\nend\n"
  },
  {
    "path": "app/models/passkey/authenticator.rb",
    "content": "class Passkey::Authenticator < Data.define(:aaguids, :name, :icon)\n  class << self\n    def find_by_aaguid(aaguid)\n      registry[aaguid]\n    end\n\n    def registry\n      @registry ||= Hash.new.tap do |registry|\n        all.each do |authenticator|\n          authenticator.aaguids.each do |aaguid|\n            registry[aaguid] = authenticator\n          end\n        end\n      end\n    end\n\n    def all\n      Rails.application.config_for(:passkey_aaguids).each_value.map do |attrs|\n        new(aaguids: attrs[:aaguids], name: attrs[:name], icon: attrs[:icon])\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "app/models/pin.rb",
    "content": "class Pin < ApplicationRecord\n  belongs_to :account, default: -> { user.account }\n  belongs_to :card\n  belongs_to :user\n\n  scope :ordered, -> { order(created_at: :desc) }\nend\n"
  },
  {
    "path": "app/models/push/subscription.rb",
    "content": "class Push::Subscription < ApplicationRecord\n  PERMITTED_ENDPOINT_HOSTS = %w[\n    jmt17.google.com\n    fcm.googleapis.com\n    updates.push.services.mozilla.com\n    web.push.apple.com\n    notify.windows.com\n  ].freeze\n\n  belongs_to :account, default: -> { user.account }\n  belongs_to :user\n\n  validates :endpoint, presence: true\n  validate :validate_endpoint_url\n\n  def notification(**params)\n    WebPush::Notification.new(\n      **params,\n      badge: user.notifications.unread.count,\n      endpoint: endpoint,\n      endpoint_ip: resolved_endpoint_ip,\n      p256dh_key: p256dh_key,\n      auth_key: auth_key\n    )\n  end\n\n  def resolved_endpoint_ip\n    return @resolved_endpoint_ip if defined?(@resolved_endpoint_ip)\n    @resolved_endpoint_ip = SsrfProtection.resolve_public_ip(endpoint_uri&.host)\n  end\n\n  private\n    def endpoint_uri\n      @endpoint_uri ||= URI.parse(endpoint) if endpoint.present?\n    rescue URI::InvalidURIError\n      nil\n    end\n\n    def validate_endpoint_url\n      if endpoint_uri.nil?\n        errors.add(:endpoint, \"is not a valid URL\")\n      elsif endpoint_uri.scheme != \"https\"\n        errors.add(:endpoint, \"must use HTTPS\")\n      elsif !permitted_endpoint_host?\n        errors.add(:endpoint, \"is not a permitted push service\")\n      elsif resolved_endpoint_ip.nil?\n        errors.add(:endpoint, \"resolves to a private or invalid IP address\")\n      end\n    end\n\n    def permitted_endpoint_host?\n      host = endpoint_uri&.host&.downcase\n      PERMITTED_ENDPOINT_HOSTS.any? { |permitted| host&.end_with?(permitted) }\n    end\nend\n"
  },
  {
    "path": "app/models/push.rb",
    "content": "module Push\n  def self.table_name_prefix\n    \"push_\"\n  end\nend\n"
  },
  {
    "path": "app/models/qr_code_link.rb",
    "content": "class QrCodeLink\n  attr_reader :url\n\n  class << self\n    def from_signed(signed)\n      new verifier.verify(signed, purpose: :qr_code)\n    end\n\n    def verifier\n      ActiveSupport::MessageVerifier.new(secret, url_safe: true)\n    end\n\n    private\n      def secret\n        Rails.application.key_generator.generate_key(\"qr_codes\")\n      end\n  end\n\n  def initialize(url)\n    @url = url\n  end\n\n  def signed\n    self.class.verifier.generate(@url, purpose: :qr_code)\n  end\nend\n"
  },
  {
    "path": "app/models/reaction.rb",
    "content": "class Reaction < ApplicationRecord\n  belongs_to :account, default: -> { reactable.account }\n  belongs_to :reactable, polymorphic: true, touch: true\n  belongs_to :reacter, class_name: \"User\", default: -> { Current.user }\n\n  scope :ordered, -> { order(:created_at) }\n\n  after_create :register_card_activity\n\n  delegate :all_emoji?, to: :content\n\n  private\n    def register_card_activity\n      reactable.card.touch_last_active_at\n    end\nend\n"
  },
  {
    "path": "app/models/search/highlighter.rb",
    "content": "class Search::Highlighter\n  OPENING_MARK = \"<mark class=\\\"circled-text\\\"><span></span>\"\n  CLOSING_MARK = \"</mark>\"\n  ELIPSIS = \"...\"\n\n  attr_reader :query\n\n  def initialize(query)\n    @query = query\n  end\n\n  def highlight(text)\n    result = text.dup\n\n    terms.each do |term|\n      result.gsub!(/\\b(#{Regexp.escape(term)}\\w*)\\b/i) do |match|\n        \"#{OPENING_MARK}#{match}#{CLOSING_MARK}\"\n      end\n    end\n\n    escape_highlight_marks(result)\n  end\n\n  def snippet(text, max_words: 20)\n    words = text.split(/\\s+/)\n    match_index = words.index { |word| terms.any? { |term| word.downcase.include?(term.downcase) } }\n\n    if words.length <= max_words\n      highlight(text)\n    elsif match_index\n      start_index = [ 0, match_index - max_words / 2 ].max\n      end_index = [ words.length - 1, start_index + max_words - 1 ].min\n\n      snippet_text = words[start_index..end_index].join(\" \")\n      snippet_text = \"...#{snippet_text}\" if start_index > 0\n      snippet_text = \"#{snippet_text}...\" if end_index < words.length - 1\n\n      highlight(snippet_text)\n    else\n      text.truncate_words(max_words, omission: \"...\")\n    end\n  end\n\n  private\n    def terms\n      @terms ||= begin\n        terms = []\n\n        query.scan(/\"([^\"]+)\"/) do |phrase|\n          terms << phrase.first\n        end\n\n        unquoted = query.gsub(/\"[^\"]+\"/, \"\")\n        unquoted.split(/\\s+/).each do |word|\n          terms << word if word.present?\n        end\n\n        terms.uniq\n      end\n    end\n\n    def escape_highlight_marks(html)\n      CGI.escapeHTML(html)\n        .gsub(CGI.escapeHTML(OPENING_MARK), OPENING_MARK.html_safe)\n        .gsub(CGI.escapeHTML(CLOSING_MARK), CLOSING_MARK.html_safe)\n        .html_safe\n    end\nend\n"
  },
  {
    "path": "app/models/search/query.rb",
    "content": "class Search::Query < ApplicationRecord\n  belongs_to :account, default: -> { user&.account || Current.account }\n  belongs_to :user, optional: true\n\n  validates :terms, presence: true\n  before_validation :sanitize_terms\n\n  delegate :to_s, to: :terms\n\n  class << self\n    def wrap(query)\n      if query.is_a?(self)\n        query\n      else\n        self.new(terms: query)\n      end\n    end\n  end\n\n  private\n    def sanitize_terms\n      self.terms = sanitize(terms)\n    end\n\n    def sanitize(terms)\n      if terms.present?\n        terms = remove_invalid_search_characters(self.terms)\n        terms = remove_unbalanced_quotes(terms)\n        terms.presence\n      else\n        terms\n      end\n    end\n\n    def remove_invalid_search_characters(terms)\n      terms.gsub(/[^\\w\"]/, \" \")\n    end\n\n    def remove_unbalanced_quotes(terms)\n      if terms.count(\"\\\"\").even?\n        terms\n      else\n        terms.gsub(\"\\\"\", \" \")\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/search/record/sqlite/fts.rb",
    "content": "class Search::Record::SQLite::Fts < ApplicationRecord\n  self.table_name = \"search_records_fts\"\n  self.primary_key = \"rowid\"\n\n  # FTS5 virtual table columns\n  attribute :rowid, :integer\n  attribute :title, :string\n  attribute :content, :string\n\n  # FTS5 virtual tables don't expose rowid in the schema by default\n  # We need to explicitly select it when loading records\n  scope :with_rowid, -> { select(:rowid, :title, :content) }\n\n  def self.upsert(rowid, title, content)\n    connection.exec_query(\n      \"INSERT OR REPLACE INTO search_records_fts(rowid, title, content) VALUES (?, ?, ?)\",\n      \"Search::Record::SQLite::Fts Upsert\",\n      [ rowid, title, content ]\n    )\n  end\nend\n"
  },
  {
    "path": "app/models/search/record/sqlite.rb",
    "content": "module Search::Record::SQLite\n  extend ActiveSupport::Concern\n\n  included do\n    attribute :result_title, :string\n    attribute :result_content, :string\n\n    has_one :search_records_fts, -> { with_rowid },\n      class_name: \"Search::Record::SQLite::Fts\", foreign_key: :rowid, primary_key: :id, dependent: :destroy\n\n    after_save :upsert_to_fts5_table\n\n    scope :matching, ->(query, account_id) {\n      joins(\"INNER JOIN search_records_fts ON search_records_fts.rowid = #{table_name}.id\")\n        .where(\"search_records_fts MATCH ?\", query)\n    }\n  end\n\n  class_methods do\n    def search_fields(query)\n      opening_mark = connection.quote(Search::Highlighter::OPENING_MARK)\n      closing_mark = connection.quote(Search::Highlighter::CLOSING_MARK)\n      ellipsis = connection.quote(Search::Highlighter::ELIPSIS)\n\n      [ \"highlight(search_records_fts, 0, #{opening_mark}, #{closing_mark}) AS result_title\",\n        \"snippet(search_records_fts, 1, #{opening_mark}, #{closing_mark}, #{ellipsis}, 20) AS result_content\",\n        \"#{connection.quote(query.terms)} AS query\" ]\n    end\n\n    def for(account_id)\n      self\n    end\n  end\n\n  def card_title\n    escape_fts_highlight(result_title || card.title)\n  end\n\n  def card_description\n    escape_fts_highlight(result_content) unless comment\n  end\n\n  def comment_body\n    escape_fts_highlight(result_content) if comment\n  end\n\n  private\n    def escape_fts_highlight(html)\n      return nil unless html.present?\n\n      CGI.escapeHTML(html)\n        .gsub(CGI.escapeHTML(Search::Highlighter::OPENING_MARK), Search::Highlighter::OPENING_MARK)\n        .gsub(CGI.escapeHTML(Search::Highlighter::CLOSING_MARK), Search::Highlighter::CLOSING_MARK)\n        .html_safe\n    end\n\n    def upsert_to_fts5_table\n      Fts.upsert(id, title, content)\n    end\nend\n"
  },
  {
    "path": "app/models/search/record/trilogy.rb",
    "content": "module Search::Record::Trilogy\n  extend ActiveSupport::Concern\n\n  SHARD_COUNT = 16\n\n  included do\n    self.abstract_class = true\n    before_save :set_account_key, :stem_content\n\n    scope :matching, ->(query, account_id) do\n      full_query = \"+account#{account_id} +(#{Search::Stemmer.stem(query)})\"\n      where(\"MATCH(#{table_name}.account_key, #{table_name}.content, #{table_name}.title) AGAINST(? IN BOOLEAN MODE)\", full_query)\n    end\n\n    SHARD_CLASSES = SHARD_COUNT.times.map do |shard_id|\n      Class.new(self) do\n        self.table_name = \"search_records_#{shard_id}\"\n\n        def self.name\n          \"Search::Record\"\n        end\n      end\n    end.freeze\n  end\n\n  class_methods do\n    def shard_id_for_account(account_id)\n      Zlib.crc32(account_id.to_s) % SHARD_COUNT\n    end\n\n    def search_fields(query)\n      \"#{connection.quote(query.terms)} AS query\"\n    end\n\n    def for(account_id)\n      SHARD_CLASSES[shard_id_for_account(account_id)]\n    end\n  end\n\n  def card_title\n    highlight(card.title, show: :full) if card_id\n  end\n\n  def card_description\n    highlight(card.description.to_plain_text, show: :snippet) if card_id\n  end\n\n  def comment_body\n    highlight(comment.body.to_plain_text, show: :snippet) if comment\n  end\n\n  private\n    def stem_content\n      self.title = Search::Stemmer.stem(title) if title_changed?\n      self.content = Search::Stemmer.stem(content) if content_changed?\n    end\n\n    def set_account_key\n      self.account_key = \"account#{account_id}\"\n    end\n\n    def highlight(text, show:)\n      if text.present? && attribute?(:query)\n        highlighter = Search::Highlighter.new(query)\n        show == :snippet ? highlighter.snippet(text) : highlighter.highlight(text)\n      else\n        text\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/search/record.rb",
    "content": "class Search::Record < ApplicationRecord\n  include const_get(connection.adapter_name)\n\n  belongs_to :searchable, polymorphic: true\n  belongs_to :card\n\n  validates :account_id, :searchable_type, :searchable_id, :card_id, :board_id, :created_at, presence: true\n\n  class << self\n    def upsert!(attributes)\n      record = find_by(searchable_type: attributes[:searchable_type], searchable_id: attributes[:searchable_id])\n      if record\n        record.update!(attributes)\n        record\n      else\n        create!(attributes)\n      end\n    end\n\n    def card_join\n      \"INNER JOIN #{table_name} ON #{table_name}.card_id = cards.id\"\n    end\n  end\n\n  scope :for_query, ->(query, user:) do\n    query = Search::Query.wrap(query)\n\n    if query.valid? && user.board_ids.any?\n      matching(query.to_s, user.account_id).where(account_id: user.account_id, board_id: user.board_ids)\n    else\n      none\n    end\n  end\n\n  scope :search, ->(query, user:) do\n    query = Search::Query.wrap(query)\n\n    for_query(query, user: user)\n      .includes(:searchable, card: [ :board, :creator ])\n      .order(created_at: :desc)\n      .select(:id, :account_id, :searchable_type, :searchable_id, :card_id, :board_id, :title, :content, :created_at, *search_fields(query))\n  end\n\n  def source\n    searchable_type == \"Comment\" ? searchable : card\n  end\n\n  def comment\n    searchable if searchable_type == \"Comment\"\n  end\nend\n"
  },
  {
    "path": "app/models/search/result.rb",
    "content": "class Search::Result < ApplicationRecord\n  attribute :card_id, :uuid\n  attribute :comment_id, :uuid\n  attribute :creator_id, :uuid\n\n  belongs_to :creator, class_name: \"User\"\n  belongs_to :card, foreign_key: :card_id, optional: true\n  belongs_to :comment, foreign_key: :comment_id, optional: true\n\n  def card_title\n    highlight(card.title, show: :full) if card_id\n  end\n\n  def card_description\n    highlight(card.description.to_plain_text, show: :snippet) if card_id\n  end\n\n  def comment_body\n    highlight(comment.body.to_plain_text, show: :snippet) if comment_id\n  end\n\n  def source\n    comment_id.present? ? comment : card\n  end\n\n  def readonly?\n    true\n  end\n\n  private\n    def highlight(text, show:)\n      if text.present? && attribute?(:query)\n        highlighter = Search::Highlighter.new(query)\n        show == :snippet ? highlighter.snippet(text) : highlighter.highlight(text)\n      else\n        text\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/search/stemmer.rb",
    "content": "module Search::Stemmer\n  extend self\n\n  STEMMER = Mittens::Stemmer.new\n\n  def stem(value)\n    if value.present?\n      value.gsub(/[^\\w\\s]/, \" \").split(/\\s+/).map { |word| STEMMER.stem(word.downcase) }.join(\" \")\n    else\n      value\n    end\n  end\nend\n"
  },
  {
    "path": "app/models/search.rb",
    "content": "module Search\n  def self.table_name_prefix\n    \"search_\"\n  end\nend\n"
  },
  {
    "path": "app/models/session.rb",
    "content": "class Session < ApplicationRecord\n  belongs_to :identity\nend\n"
  },
  {
    "path": "app/models/signup/account_name_generator.rb",
    "content": "class Signup::AccountNameGenerator\n  SUFFIX = \"Fizzy\".freeze\n\n  attr_reader :identity, :name\n\n  def initialize(identity:, name:)\n    @identity = identity\n    @name = name\n  end\n\n  def generate\n    next_index = current_index + 1\n\n    if next_index == 1\n      \"#{prefix} #{SUFFIX}\"\n    else\n      \"#{prefix} #{next_index.ordinalize} #{SUFFIX}\"\n    end\n  end\n\n  private\n    def current_index\n      existing_indices.max || 0\n    end\n\n    def existing_indices\n      Current.without_account do\n        identity.accounts.filter_map do |account|\n          if account.name.match?(first_account_name_regex)\n            1\n          elsif match = account.name.match(nth_account_name_regex)\n            match[1].to_i\n          end\n        end\n      end\n    end\n\n    def first_account_name_regex\n      @first_account_name_regex ||= /\\A#{prefix}\\s+#{SUFFIX}\\Z/i\n    end\n\n    def nth_account_name_regex\n      @nth_account_name_regex ||= /\\A#{prefix}\\s+(1st|2nd|3rd|\\d+th)\\s+#{SUFFIX}/i\n    end\n\n    def prefix\n      @prefix ||= \"#{first_name}'s\"\n    end\n\n    def first_name\n      name.strip.split(\" \", 2).first\n    end\nend\n"
  },
  {
    "path": "app/models/signup.rb",
    "content": "class Signup\n  include ActiveModel::Model\n  include ActiveModel::Attributes\n  include ActiveModel::Validations\n\n  attr_accessor :full_name, :email_address, :identity, :skip_account_seeding\n  attr_reader :account, :user\n\n  validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP }, on: :identity_creation\n  validates :full_name, :identity, presence: true, on: :completion\n  validates :full_name, length: { maximum: 240 }\n\n  def initialize(...)\n    super\n\n    @email_address = @identity.email_address if @identity\n  end\n\n  def create_identity\n    @identity = Identity.find_or_create_by!(email_address: email_address)\n    @identity.send_magic_link for: :sign_up\n  end\n\n  def complete\n    if valid?(:completion)\n      begin\n        @tenant = create_tenant\n        create_account\n        true\n      rescue => error\n        destroy_account\n        handle_account_creation_error(error)\n\n        errors.add(:base, \"Something went wrong, and we couldn't create your account. Please give it another try.\")\n        Rails.error.report(error, severity: :error)\n        Rails.logger.error error\n        Rails.logger.error error.backtrace.join(\"\\n\")\n\n        false\n      end\n    else\n      false\n    end\n  end\n\n  private\n    # Override to customize the handling of external accounts associated to the account.\n    def create_tenant\n      nil\n    end\n\n    # Override to inject custom handling for account creation errors\n    def handle_account_creation_error(error)\n    end\n\n    def create_account\n      @account = Account.create_with_owner(\n        account: {\n          external_account_id: @tenant,\n          name: generate_account_name\n        },\n        owner: {\n          name: full_name,\n          identity: identity\n        }\n      )\n      @user = @account.users.find_by!(role: :owner)\n      @account.setup_customer_template unless skip_account_seeding\n    end\n\n    def generate_account_name\n      AccountNameGenerator.new(identity: identity, name: full_name).generate\n    end\n\n\n    def destroy_account\n      @account&.destroy!\n\n      @user = nil\n      @account = nil\n      @tenant = nil\n    end\n\n    def subscription_attributes\n      subscription = FreeV1Subscription\n\n      {}.tap do |attributes|\n        attributes[:name]  = subscription.to_param\n        attributes[:price] = subscription.price\n      end\n    end\n\n    def request_attributes\n      {}.tap do |attributes|\n        attributes[:remote_address] = Current.ip_address\n        attributes[:user_agent]     = Current.user_agent\n        attributes[:referrer]       = Current.referrer\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/ssrf_protection.rb",
    "content": "module SsrfProtection\n  extend self\n\n  DNS_RESOLUTION_TIMEOUT = 2\n\n  DNS_NAMESERVERS = %w[\n    1.1.1.1\n    8.8.8.8\n  ]\n\n  DISALLOWED_IP_RANGES = [\n    IPAddr.new(\"0.0.0.0/8\"),     # \"This\" network (RFC1700)\n    IPAddr.new(\"100.64.0.0/10\"), # Carrier-grade NAT (RFC6598)\n    IPAddr.new(\"198.18.0.0/15\")  # Benchmark testing (RFC2544)\n  ].freeze\n\n  def resolve_public_ip(hostname)\n    ip_addresses = resolve_dns(hostname)\n    public_ips = ip_addresses.reject { |ip| blocked_address?(ip) }\n    public_ips.sort_by { |ipaddr| ipaddr.ipv4? ? 0 : 1 }.first&.to_s\n  end\n\n  def blocked_address?(ip)\n    ip = IPAddr.new(ip.to_s) unless ip.is_a?(IPAddr)\n\n    ip.private? ||\n      ip.loopback? ||\n      ip.link_local? ||\n      ip.ipv4_mapped? ||\n      ip.ipv4_compat? ||\n      in_disallowed_range?(ip)\n  end\n\n  private\n    def resolve_dns(hostname)\n      ip_addresses = []\n\n      Resolv::DNS.open(nameserver: DNS_NAMESERVERS, timeouts: DNS_RESOLUTION_TIMEOUT) do |dns|\n        dns.each_address(hostname) do |ip_address|\n          ip_addresses << IPAddr.new(ip_address.to_s)\n        end\n      end\n\n      ip_addresses\n    end\n\n    def in_disallowed_range?(ip)\n      DISALLOWED_IP_RANGES.any? { |range| range.include?(ip) }\n    end\nend\n"
  },
  {
    "path": "app/models/step.rb",
    "content": "class Step < ApplicationRecord\n  belongs_to :account, default: -> { card.account }\n  belongs_to :card, touch: true\n\n  scope :completed, -> { where(completed: true) }\n\n  validates :content, presence: true\n\n  def completed?\n    completed\n  end\nend\n"
  },
  {
    "path": "app/models/storage/attachment_tracking.rb",
    "content": "module Storage::AttachmentTracking\n  extend ActiveSupport::Concern\n\n  included do\n    # Snapshot IDs in before_destroy since parent record may be deleted\n    # by the time after_destroy_commit runs\n    before_destroy :snapshot_storage_context\n    after_create_commit :record_storage_attach\n    after_destroy_commit :record_storage_detach\n  end\n\n  private\n    def record_storage_attach\n      return unless storage_tracked_record\n\n      Storage::Entry.record \\\n        account: storage_tracked_record.account,\n        board: storage_tracked_record.board_for_storage_tracking,\n        recordable: storage_tracked_record,\n        blob: blob,\n        delta: blob.byte_size,\n        operation: \"attach\"\n    end\n\n    def record_storage_detach\n      return unless @storage_snapshot\n\n      Storage::Entry.record \\\n        account: @storage_snapshot[:account],\n        board: @storage_snapshot[:board],\n        recordable: @storage_snapshot[:recordable],\n        blob: blob,\n        delta: -blob.byte_size,\n        operation: \"detach\"\n    end\n\n    # Snapshot records in before_destroy since parent may be deleted by the time\n    # after_destroy_commit runs. The records may be destroyed but .id still works.\n    def snapshot_storage_context\n      return unless storage_tracked_record\n\n      @storage_snapshot = {\n        account: storage_tracked_record.account,\n        board: storage_tracked_record.board_for_storage_tracking,\n        recordable: storage_tracked_record\n      }\n    end\n\n    def storage_tracked_record\n      record.try(:storage_tracked_record)\n    end\nend\n"
  },
  {
    "path": "app/models/storage/entry.rb",
    "content": "class Storage::Entry < ApplicationRecord\n  belongs_to :account\n  belongs_to :board, optional: true\n  belongs_to :recordable, polymorphic: true, optional: true\n\n  scope :pending, ->(last_entry_id) { where.not(id: ..last_entry_id) if last_entry_id }\n\n  # Records may be destroyed (during cascading deletes) but .id still works.\n  # Skip entirely if account is destroyed - no need to track storage for deleted accounts.\n  # Skip materialize jobs for destroyed boards since there's nothing to update.\n  def self.record(delta:, operation:, account:, board: nil, recordable: nil, blob: nil)\n    return if delta.zero?\n    return if account.destroyed?\n\n    entry = create! \\\n      account_id: account.id,\n      board_id: board&.id,\n      recordable_type: recordable&.class&.name,\n      recordable_id: recordable&.id,\n      blob_id: blob&.id,\n      delta: delta,\n      operation: operation,\n      user_id: Current.user&.id,\n      request_id: Current.request_id\n\n    account.materialize_storage_later\n    board&.materialize_storage_later unless board&.destroyed?\n\n    entry\n  end\nend\n"
  },
  {
    "path": "app/models/storage/total.rb",
    "content": "class Storage::Total < ApplicationRecord\n  belongs_to :owner, polymorphic: true\n\n  def pending_entries\n    owner.storage_entries.pending(last_entry_id)\n  end\n\n  # Exact current usage (snapshot + pending)\n  def current_usage\n    bytes_stored + pending_entries.sum(:delta)\n  end\nend\n"
  },
  {
    "path": "app/models/storage.rb",
    "content": "module Storage\n  def self.table_name_prefix\n    \"storage_\"\n  end\n\n  # Record types that participate in storage tracking (ledger entries created on attach).\n  # The no-reuse validation uses this to scope its check.\n  #\n  # IMPORTANT: Update this constant when adding tracked attachments to new models.\n  # If you add a direct attachment (not via RichText embeds) to Comment, Board, or\n  # another model with Storage::Tracked, you must add its record_type here or the\n  # no-reuse validation won't protect it.\n  TRACKED_RECORD_TYPES = %w[Card ActionText::RichText].freeze\n\n  # Account ID for template/demo blobs that can be reused cross-tenant.\n  # Set to nil to disable the whitelist (no exemptions).\n  TEMPLATE_ACCOUNT_ID = nil\nend\n"
  },
  {
    "path": "app/models/tag/attachable.rb",
    "content": "module Tag::Attachable\n  extend ActiveSupport::Concern\n\n  included do\n    include ActionText::Attachable\n\n    def attachable_plain_text_representation(...)\n      \"##{title}\"\n    end\n  end\nend\n"
  },
  {
    "path": "app/models/tag.rb",
    "content": "class Tag < ApplicationRecord\n  include Attachable, Filterable\n\n  belongs_to :account, default: -> { Current.account }\n  has_many :taggings, dependent: :destroy\n  has_many :cards, through: :taggings\n\n  validates :title, format: { without: /\\A#/ }\n  normalizes :title, with: -> { it.downcase }\n\n  scope :alphabetically, -> { order(\"lower(title)\") }\n  scope :unused, -> { left_outer_joins(:taggings).where(taggings: { id: nil }) }\n\n  def hashtag\n    \"#\" + title\n  end\n\n  def cards_count\n    cards.open.count\n  end\nend\n"
  },
  {
    "path": "app/models/tagging.rb",
    "content": "class Tagging < ApplicationRecord\n  belongs_to :account, default: -> { card.account }\n  belongs_to :tag\n  belongs_to :card, touch: true\nend\n"
  },
  {
    "path": "app/models/time_window_parser.rb",
    "content": "class TimeWindowParser\n  attr_reader :now\n\n  HUMAN_NAMES_BY_VALUE = {\n    \"today\" => \"Today\",\n    \"yesterday\" => \"Yesterday\",\n    \"thisweek\" => \"This week\",\n    \"thismonth\" => \"This month\",\n    \"thisyear\" => \"This year\",\n    \"lastweek\" => \"Last week\",\n    \"lastmonth\" => \"Last month\",\n    \"lastyear\" => \"Last year\"\n  }\n\n  VALUES = HUMAN_NAMES_BY_VALUE.keys\n\n  class << self\n    def parse(string)\n      new.parse(string)\n    end\n\n    def human_name_for(value)\n      HUMAN_NAMES_BY_VALUE[value]\n    end\n  end\n\n  def initialize(now: Time.current)\n    @now = now\n  end\n\n  def parse(string)\n    case normalize(string)\n    when \"today\"\n      now.all_day\n    when \"yesterday\"\n      (now - 1.day).all_day\n    when \"thisweek\"\n      now.all_week\n    when \"thismonth\"\n      now.all_month\n    when \"thisyear\"\n      now.all_year\n    when \"lastweek\"\n      (now - 1.week).all_week\n    when \"lastmonth\"\n      (now - 1.month).all_month\n    when \"lastyear\"\n      (now - 1.year).all_year\n    end\n  end\n\n  private\n    def normalize(string)\n      if string\n        string.downcase.gsub(/[\\s_\\-]/, \"\")\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/user/accessor.rb",
    "content": "module User::Accessor\n  extend ActiveSupport::Concern\n\n  included do\n    has_many :accesses, dependent: :destroy\n    has_many :boards, through: :accesses\n    has_many :accessible_columns, through: :boards, source: :columns\n    has_many :accessible_cards, through: :boards, source: :cards\n    has_many :accessible_comments, through: :accessible_cards, source: :comments\n\n    after_create_commit :grant_access_to_boards, unless: :system?\n  end\n\n  def draft_new_card_in(board)\n    board.cards.find_or_initialize_by(creator: self, status: \"drafted\").tap do |card|\n      card.update!(created_at: Time.current, updated_at: Time.current, last_active_at: Time.current)\n    end\n  end\n\n  private\n    def grant_access_to_boards\n      Access.insert_all account.boards.all_access.ids.collect { |board_id| { id: ActiveRecord::Type::Uuid.generate, board_id: board_id, user_id: id, account_id: account.id } }\n    end\nend\n"
  },
  {
    "path": "app/models/user/assignee.rb",
    "content": "module User::Assignee\n  extend ActiveSupport::Concern\n\n  included do\n    has_many :assignments, foreign_key: :assignee_id, dependent: :destroy\n    has_many :assignings, foreign_key: :assigner_id, class_name: \"Assignment\"\n    has_many :assigned_cards, through: :assignments, source: :card\n  end\nend\n"
  },
  {
    "path": "app/models/user/attachable.rb",
    "content": "module User::Attachable\n  extend ActiveSupport::Concern\n\n  included do\n    include ActionText::Attachable\n\n    def attachable_plain_text_representation(...)\n      \"@#{first_name.downcase}\"\n    end\n  end\nend\n"
  },
  {
    "path": "app/models/user/avatar.rb",
    "content": "require \"zlib\"\n\nmodule User::Avatar\n  extend ActiveSupport::Concern\n\n  ALLOWED_AVATAR_CONTENT_TYPES = %w[ image/jpeg image/png image/gif image/webp ].freeze\n  MAX_AVATAR_DIMENSIONS = { width: 4096, height: 4096 }.freeze\n  AVATAR_COLORS = %w[\n    #AF2E1B #CC6324 #3B4B59 #BFA07A #ED8008 #ED3F1C #BF1B1B #736B1E #D07B53\n    #736356 #AD1D1D #BF7C2A #C09C6F #698F9C #7C956B #5D618F #3B3633 #67695E\n  ].freeze\n\n  included do\n    has_one_attached :avatar do |attachable|\n      attachable.variant :thumb, resize_to_fill: [ 256, 256 ], process: :immediately\n    end\n\n    scope :with_avatars, -> { preload(:account, :avatar_attachment) }\n\n    validate :avatar_content_type_allowed, :avatar_dimensions_allowed, if: :avatar_attached?\n  end\n\n  def avatar_attached?\n    avatar.attached?\n  end\n\n  def avatar_thumbnail\n    avatar.variable? ? avatar.variant(:thumb) : avatar\n  end\n\n  def avatar_background_color\n    AVATAR_COLORS[Zlib.crc32(to_param) % AVATAR_COLORS.size]\n  end\n\n  # Avatars are always publicly accessible\n  def publicly_accessible?\n    true\n  end\n\n  private\n    def avatar_content_type_allowed\n      if !ALLOWED_AVATAR_CONTENT_TYPES.include?(avatar.content_type)\n        errors.add(:avatar, \"must be a JPEG, PNG, GIF, or WebP image\")\n      end\n    end\n\n    def avatar_dimensions_allowed\n      return unless avatar.blob.analyzed? || avatar.blob.analyze\n\n      width = avatar.blob.metadata[:width]\n      height = avatar.blob.metadata[:height]\n\n      if width && width > MAX_AVATAR_DIMENSIONS[:width]\n        errors.add(:avatar, \"width must be less than #{MAX_AVATAR_DIMENSIONS[:width]}px\")\n      end\n\n      if height && height > MAX_AVATAR_DIMENSIONS[:height]\n        errors.add(:avatar, \"height must be less than #{MAX_AVATAR_DIMENSIONS[:height]}px\")\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/user/configurable.rb",
    "content": "module User::Configurable\n  extend ActiveSupport::Concern\n\n  included do\n    has_one :settings, class_name: \"User::Settings\", dependent: :destroy\n    has_many :push_subscriptions, class_name: \"Push::Subscription\", dependent: :delete_all\n\n    after_create :create_settings, unless: :system?\n\n    delegate :timezone, to: :settings, allow_nil: true\n  end\n\n  def in_time_zone(&block)\n    Time.use_zone(timezone, &block)\n  end\nend\n"
  },
  {
    "path": "app/models/user/data_export.rb",
    "content": "class User::DataExport < Export\n  private\n    def filename\n      \"fizzy-user-data-export-#{id}.zip\"\n    end\n\n    def populate_zip(zip)\n      exportable_cards.find_each do |card|\n        add_card_to_zip(zip, card)\n      end\n    end\n\n    def exportable_cards\n      user.accessible_cards.includes(\n        :board,\n        creator: :identity,\n        comments: { creator: :identity },\n        rich_text_description: { embeds_attachments: :blob }\n      )\n    end\n\n    def add_card_to_zip(zip, card)\n      zip.add_file(\"#{card.number}.json\", card.export_json)\n\n      card.export_attachments.each do |attachment|\n        zip.add_file(attachment[:path], compress: false) do |f|\n          attachment[:blob].download { |chunk| f.write(chunk) }\n        end\n      rescue ActiveStorage::FileNotFoundError\n        # Skip attachments where the file is missing from storage\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/user/day_timeline/column.rb",
    "content": "class User::DayTimeline::Column\n  include ActionView::Helpers::TagHelper, ActionView::Helpers::OutputSafetyHelper, TimeHelper\n\n  attr_reader :index, :id, :base_title, :day_timeline, :events\n\n  def initialize(day_timeline, id, base_title, index, events)\n    @id = id\n    @day_timeline = day_timeline\n    @base_title = base_title\n    @index = index\n    @events = events\n  end\n\n  def title\n    date_tag = local_datetime_tag(day_timeline.day, style: :agoorweekday)\n    parts = [ base_title, date_tag ]\n    parts << tag.span(\"(#{full_events_count})\", class: \"font-weight-normal\") if full_events_count > 0\n    safe_join(parts, \" \")\n  end\n\n  def events_by_hour\n    limited_events.group_by { it.created_at.hour }\n  end\n\n  def has_more_events?\n    limited_events.count < full_events_count\n  end\n\n  def hidden_events_count\n    full_events_count - limited_events.count\n  end\n\n  def to_param\n    id\n  end\n\n  private\n    def limited_events\n      @limited_events ||= events.limit(100).load\n    end\n\n    def full_events_count\n      @full_events_count ||= events.count\n    end\nend\n"
  },
  {
    "path": "app/models/user/day_timeline/serializable.rb",
    "content": "module User::DayTimeline::Serializable\n  extend ActiveSupport::Concern\n\n  included do\n    include GlobalID::Identification # For active job serialization\n    alias id to_json\n  end\n\n  class_methods do\n    def find(id)\n      data = JSON.parse(id).with_indifferent_access\n      user = User.find(data[:user_id])\n      day = Time.zone.parse(data[:day])\n      filter = user.filters.from_params data[:filter_params]\n\n      new(user, day, filter)\n    end\n\n    def tenanted?\n      # TODO: Check with Mike\n      false\n    end\n  end\n\n  def as_json(options = {})\n    { user_id: user.id, day: day.to_s, filter_params: filter.as_params }\n  end\nend\n"
  },
  {
    "path": "app/models/user/day_timeline.rb",
    "content": "class User::DayTimeline\n  include Serializable\n\n  attr_reader :user, :day, :filter\n\n  delegate :today?, to: :day\n\n  def initialize(user, day, filter)\n    @user, @day, @filter = user, day, filter\n  end\n\n  def has_activity?\n    events.any?\n  end\n\n  def events\n    filtered_events.where(created_at: window).order(created_at: :desc)\n  end\n\n  def next_day\n    latest_event_before&.created_at\n  end\n\n  def earliest_time\n    next_day&.tomorrow&.beginning_of_day\n  end\n\n  def latest_time\n    day.yesterday.beginning_of_day\n  end\n\n  def added_column\n    @added_column ||= build_column(:added, \"Added\", 1, events.where(action: %w[card_published card_reopened]))\n  end\n\n  def updated_column\n    @updated_column ||= build_column(:updated, \"Updated\", 2, events.where.not(action: %w[card_published card_closed card_reopened]))\n  end\n\n  def closed_column\n    @closed_column ||= build_column(:closed, \"Done\", 3, events.where(action: \"card_closed\"))\n  end\n\n  def cache_key\n    ActiveSupport::Cache.expand_cache_key [ user, filter, day.to_date, events ], \"day-timeline\"\n  end\n\n  private\n    TIMELINEABLE_ACTIONS = %w[\n      card_assigned\n      card_unassigned\n      card_published\n      card_closed\n      card_reopened\n      card_collection_changed\n      card_board_changed\n      card_postponed\n      card_auto_postponed\n      card_triaged\n      card_sent_back_to_triage\n      comment_created\n    ]\n\n    def filtered_events\n      @filtered_events ||= begin\n        events = timelineable_events\n        events = events.where(creator_id: filter.creators.ids) if filter.creators.present?\n        events\n      end\n    end\n\n    def timelineable_events\n      Event\n        .preloaded\n        .where(board: boards)\n        .where(action: TIMELINEABLE_ACTIONS)\n    end\n\n    def boards\n      filter.boards.presence || user.boards\n    end\n\n    def latest_event_before\n      filtered_events.where(created_at: ...day.beginning_of_day).chronologically.last\n    end\n\n    def build_column(id, base_title, index, events)\n      Column.new(self, id, base_title, index, events)\n    end\n\n    def window\n      day.all_day\n    end\nend\n"
  },
  {
    "path": "app/models/user/email_address_changeable.rb",
    "content": "module User::EmailAddressChangeable\n  EMAIL_CHANGE_TOKEN_PURPOSE = \"change_email_address\"\n  EMAIL_CHANGE_TOKEN_EXPIRATION = 30.minutes\n\n  extend ActiveSupport::Concern\n\n  def change_email_address_using_token(token)\n    parsed_token = SignedGlobalID.parse(token, for: EMAIL_CHANGE_TOKEN_PURPOSE)\n\n    old_email_address = parsed_token&.params&.fetch(\"old_email_address\")\n    new_email_address = parsed_token&.params&.fetch(\"new_email_address\")\n\n    if parsed_token.nil? || parsed_token.find != self || identity.email_address != old_email_address\n      false\n    else\n      change_email_address(new_email_address)\n    end\n  end\n\n  def send_email_address_change_confirmation(new_email_address)\n    token = generate_email_address_change_token(\n      to: new_email_address,\n      expires_in: EMAIL_CHANGE_TOKEN_EXPIRATION\n    )\n\n    UserMailer.email_change_confirmation(\n      email_address: new_email_address,\n      token: token,\n      user: self\n    ).deliver_later\n  end\n\n  def change_email_address(new_email_address)\n    transaction do\n      new_identity = Identity.find_or_create_by!(email_address: new_email_address)\n      update!(identity: new_identity)\n    end\n  end\n\n  private\n    def generate_email_address_change_token(from: identity.email_address, to:, **options)\n      options = options.with_defaults(\n        for: EMAIL_CHANGE_TOKEN_PURPOSE,\n        old_email_address: from,\n        new_email_address: to,\n      )\n\n      to_sgid(**options).to_s\n    end\nend\n"
  },
  {
    "path": "app/models/user/filtering.rb",
    "content": "class User::Filtering\n  attr_reader :user, :filter, :expanded\n\n  delegate :as_params, :single_board, to: :filter\n  delegate :only_closed?, to: :filter\n\n  def initialize(user, filter, expanded: false)\n    @user, @filter, @expanded = user, filter, expanded\n  end\n\n  def boards\n    @boards ||= user.boards.ordered_by_recently_accessed\n  end\n\n  def selected_board_titles\n    filter.board_titles\n  end\n\n  def selected_boards_label\n    filter.boards_label\n  end\n\n  def tags\n    @tags ||= account.tags.all.alphabetically\n  end\n\n  def users\n    @users ||= account.users.active.alphabetically\n  end\n\n  def filters\n    @filters ||= user.filters.all\n  end\n\n  def expanded?\n    @expanded\n  end\n\n  def any?\n    filter.used?(ignore_boards: true)\n  end\n\n  def show_indexed_by?\n    !filter.indexed_by.all?\n  end\n\n  def show_sorted_by?\n    !filter.sorted_by.latest?\n  end\n\n  def show_tags?\n    return unless Tag.any?\n    filter.tags.any?\n  end\n\n  def show_assignees?\n    filter.assignees.any?\n  end\n\n  def show_creators?\n    filter.creators.any?\n  end\n\n  def show_closers?\n    filter.closers.any?\n  end\n\n  def show_boards?\n    filter.boards.any?\n  end\n\n  def single_board_or_first\n    # Default to the first selected or, when no selection, to the first one\n    filter.boards.first || boards.first\n  end\n\n  def cache_key\n    ActiveSupport::Cache.expand_cache_key([ user, filter, expanded?, boards, tags, users, filters ], \"user-filtering\")\n  end\n\n  private\n    def account\n      user.account\n    end\nend\n"
  },
  {
    "path": "app/models/user/mentionable.rb",
    "content": "module User::Mentionable\n  extend ActiveSupport::Concern\n\n  included do\n    has_many :mentions, dependent: :destroy, inverse_of: :mentionee\n\n    # Need to set in the included block so that it overrides Action Text's\n    def to_attachable_partial_path\n      \"users/attachable\"\n    end\n  end\n\n  def mentioned_by(mentioner, at:)\n    mentions.find_or_create_by! source: at, mentioner: mentioner\n  end\n\n  def mentionable_handles\n    [ initials, first_name, first_name_with_last_name_initial ].collect(&:downcase)\n  end\n\n  def content_type\n    \"application/vnd.actiontext.mention\"\n  end\n\n  private\n    def first_name_with_last_name_initial\n      \"#{first_name}#{last_name&.first}\"\n    end\nend\n"
  },
  {
    "path": "app/models/user/named.rb",
    "content": "module User::Named\n  extend ActiveSupport::Concern\n\n  included do\n    scope :alphabetically, -> { order(\"lower(name)\") }\n  end\n\n  def first_name\n    name.split(/\\s/).first\n  end\n\n  def last_name\n    name.split(/\\s/, 2).last\n  end\n\n  def initials\n    name.scan(/\\b\\p{L}/).join.upcase\n  end\n\n  def familiar_name\n    names = name.split\n    return name if names.length <= 1\n    \"#{names.first}\\u00A0#{names[1..].map { |n| \"#{n[0]}.\" }.join}\"\n  end\nend\n"
  },
  {
    "path": "app/models/user/notifiable.rb",
    "content": "module User::Notifiable\n  extend ActiveSupport::Concern\n\n  included do\n    has_many :notifications, dependent: :destroy\n    has_many :notification_bundles, class_name: \"Notification::Bundle\", dependent: :destroy\n\n    generates_token_for :unsubscribe, expires_in: 1.month\n  end\n\n  def bundle(notification)\n    with_lock do\n      find_or_create_bundle_for(notification)\n    end\n  end\n\n  private\n    def find_or_create_bundle_for(notification)\n      find_bundle_for(notification) || expand_pending_bundle_for(notification) || create_bundle_for(notification)\n    end\n\n    def find_bundle_for(notification)\n      notification_bundles.pending.containing(notification).last\n    end\n\n    def expand_pending_bundle_for(notification)\n      pending = notification_bundles.pending.last\n      if pending.present? && notification.updated_at < pending.starts_at\n        pending.update!(starts_at: notification.updated_at) # expand the window to include this notification\n      end\n    end\n\n    def create_bundle_for(notification)\n      notification_bundles.create!(starts_at: notification.updated_at)\n    end\nend\n"
  },
  {
    "path": "app/models/user/role.rb",
    "content": "module User::Role\n  extend ActiveSupport::Concern\n\n  included do\n    enum :role, %i[ owner admin member system ].index_by(&:itself), scopes: false\n\n    scope :owner, -> { where(active: true, role: :owner) }\n    scope :admin, -> { where(active: true, role: %i[ owner admin ]) }\n    scope :member, -> { where(active: true, role: :member) }\n    scope :active, -> { where(active: true, role: %i[ owner admin member ]) }\n\n    def admin?\n      super || owner?\n    end\n  end\n\n  def can_change?(other)\n    (admin? && !other.owner?) || other == self\n  end\n\n  def can_administer?(other)\n    admin? && !other.owner? && other != self\n  end\n\n  def can_administer_board?(board)\n    admin? || board.creator == self\n  end\n\n  def can_administer_card?(card)\n    admin? || card.creator == self\n  end\nend\n"
  },
  {
    "path": "app/models/user/searcher.rb",
    "content": "module User::Searcher\n  extend ActiveSupport::Concern\n\n  included do\n    has_many :search_queries, class_name: \"Search::Query\", dependent: :destroy\n  end\n\n  def search(terms)\n    Search::Record.for(account_id).search(terms, user: self)\n  end\n\n  def remember_search(terms)\n    search_queries.find_or_create_by(terms: terms).tap do |search_query|\n      search_query.touch unless search_query.invalid? || search_query.previously_new_record?\n    end\n  end\nend\n"
  },
  {
    "path": "app/models/user/settings.rb",
    "content": "class User::Settings < ApplicationRecord\n  belongs_to :account, default: -> { user.account }\n  belongs_to :user\n\n  enum :bundle_email_frequency, %i[ never every_few_hours daily weekly ],\n    default: :every_few_hours, prefix: :bundle_email\n\n  after_update :review_pending_bundles, if: :saved_change_to_bundle_email_frequency?\n\n  def bundle_aggregation_period\n    case bundle_email_frequency\n    when \"every_few_hours\"\n      4.hours\n    when \"daily\"\n      1.day\n    when \"weekly\"\n      1.week\n    else\n      1.day\n    end\n  end\n\n  def bundling_emails?\n    !bundle_email_never? && !user.system? && user.active? && user.verified?\n  end\n\n  def timezone\n    if timezone_name.present?\n      ActiveSupport::TimeZone[timezone_name] || default_timezone\n    else\n      default_timezone\n    end\n  end\n\n  private\n    def review_pending_bundles\n      if bundling_emails?\n        flush_pending_bundles\n      else\n        cancel_pending_bundles\n      end\n    end\n\n    def cancel_pending_bundles\n      user.notification_bundles.pending.find_each do |bundle|\n        bundle.destroy\n      end\n    end\n\n    def flush_pending_bundles\n      user.notification_bundles.pending.find_each(&:flush)\n    end\n\n    def default_timezone\n      ActiveSupport::TimeZone[\"UTC\"]\n    end\nend\n"
  },
  {
    "path": "app/models/user/timelined.rb",
    "content": "module User::Timelined\n  extend ActiveSupport::Concern\n\n  included do\n    has_many :accessible_events, through: :boards, source: :events\n  end\n\n  def timeline_for(day, filter:)\n    User::DayTimeline.new(self, day, filter)\n  end\nend\n"
  },
  {
    "path": "app/models/user/transferable.rb",
    "content": "module User::Transferable\n  extend ActiveSupport::Concern\n\n  TRANSFER_LINK_EXPIRY_DURATION = 4.hours\n\n  class_methods do\n    def find_by_transfer_id(id)\n      find_signed(id, purpose: :transfer)\n    end\n  end\n\n  def transfer_id\n    signed_id(purpose: :transfer, expires_in: TRANSFER_LINK_EXPIRY_DURATION)\n  end\nend\n"
  },
  {
    "path": "app/models/user/watcher.rb",
    "content": "module User::Watcher\n  extend ActiveSupport::Concern\n  included do\n    has_many :watches, dependent: :destroy\n  end\nend\n"
  },
  {
    "path": "app/models/user.rb",
    "content": "class User < ApplicationRecord\n  include Accessor, Assignee, Attachable, Avatar, Configurable, EmailAddressChangeable,\n    Mentionable, Named, Notifiable, Role, Searcher, Watcher\n  include Timelined # Depends on Accessor\n\n  belongs_to :account\n  belongs_to :identity, optional: true\n\n  validates :name, presence: true\n\n  has_many :comments, inverse_of: :creator, dependent: :destroy\n\n  has_many :filters, foreign_key: :creator_id, inverse_of: :creator, dependent: :destroy\n  has_many :closures, dependent: :nullify\n  has_many :pins, dependent: :destroy\n  has_many :pinned_cards, through: :pins, source: :card\n  has_many :data_exports, class_name: \"User::DataExport\", dependent: :destroy\n\n  def deactivate\n    transaction do\n      accesses.destroy_all\n      update! active: false, identity: nil\n      close_remote_connections\n    end\n  end\n\n  def setup?\n    name != identity.email_address\n  end\n\n  def verified?\n    verified_at.present?\n  end\n\n  def verify\n    update!(verified_at: Time.current) unless verified?\n  end\n\n  private\n    def close_remote_connections\n      ActionCable.server.remote_connections.where(current_user: self).disconnect(reconnect: false)\n    end\nend\n"
  },
  {
    "path": "app/models/watch.rb",
    "content": "class Watch < ApplicationRecord\n  belongs_to :account, default: -> { user.account }\n  belongs_to :user\n  belongs_to :card, touch: true\n\n  scope :watching, -> { where(watching: true) }\n  scope :not_watching, -> { where(watching: false) }\nend\n"
  },
  {
    "path": "app/models/webhook/delinquency_tracker.rb",
    "content": "class Webhook::DelinquencyTracker < ApplicationRecord\n  DELINQUENCY_THRESHOLD = 10\n  DELINQUENCY_DURATION = 1.hour\n\n  belongs_to :account, default: -> { webhook.account }\n  belongs_to :webhook\n\n  def record_delivery_of(delivery)\n    if delivery.succeeded?\n      reset\n    else\n      mark_first_failure_time if consecutive_failures_count.zero?\n      increment!(:consecutive_failures_count, touch: true)\n\n      webhook.deactivate if delinquent?\n    end\n  end\n\n  private\n    def reset\n      update_columns consecutive_failures_count: 0, first_failure_at: nil\n    end\n\n    def mark_first_failure_time\n      update_columns first_failure_at: Time.current\n    end\n\n    def delinquent?\n      failing_for_too_long? && too_many_consecutive_failures?\n    end\n\n    def failing_for_too_long?\n      if first_failure_at\n        first_failure_at.before?(DELINQUENCY_DURATION.ago)\n      else\n        false\n      end\n    end\n\n    def too_many_consecutive_failures?\n      consecutive_failures_count >= DELINQUENCY_THRESHOLD\n    end\nend\n"
  },
  {
    "path": "app/models/webhook/delivery.rb",
    "content": "class Webhook::Delivery < ApplicationRecord\n  include Rails.application.routes.url_helpers\n\n  class ResponseTooLarge < StandardError; end\n\n  STALE_TRESHOLD = 7.days\n  USER_AGENT = \"fizzy/1.0.0 Webhook\"\n  ENDPOINT_TIMEOUT = 7.seconds\n  MAX_RESPONSE_SIZE = 100.kilobytes\n\n  belongs_to :account, default: -> { webhook.account }\n  belongs_to :webhook\n  belongs_to :event\n\n  store :request, coder: JSON\n  store :response, coder: JSON\n\n  enum :state, %w[ pending in_progress completed errored ].index_by(&:itself), default: :pending\n\n  scope :ordered, -> { order created_at: :desc, id: :desc }\n  scope :stale, -> { where(created_at: ...STALE_TRESHOLD.ago) }\n\n  after_create_commit :deliver_later\n\n  def self.cleanup(batch_size: 500, pause: 0.1)\n    sleep pause until stale.limit(batch_size).delete_all.zero?\n  end\n\n  def deliver_later\n    Webhook::DeliveryJob.perform_later(self)\n  end\n\n  def deliver\n    in_progress!\n\n    self.request[:headers] = headers\n    self.response = perform_request\n    self.state = :completed\n    save!\n\n    webhook.delinquency_tracker.record_delivery_of(self)\n  rescue\n    errored!\n    raise\n  end\n\n  def failed?\n    (errored? || completed?) && !succeeded?\n  end\n\n  def succeeded?\n    completed? && response[:error].blank? && response[:code].between?(200, 299)\n  end\n\n  private\n    def perform_request\n      if resolved_ip.nil?\n        { error: :private_uri }\n      else\n        request = Net::HTTP::Post.new(uri, headers).tap { |request| request.body = payload }\n\n        response = http.request(request) do |net_http_response|\n          stream_body_with_limit(net_http_response)\n        end\n\n        { code: response.code.to_i }\n      end\n    rescue ResponseTooLarge\n      { error: :response_too_large }\n    rescue Resolv::ResolvTimeout, Resolv::ResolvError, SocketError\n      { error: :dns_lookup_failed }\n    rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ETIMEDOUT\n      { error: :connection_timeout }\n    rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ECONNRESET\n      { error: :destination_unreachable }\n    rescue OpenSSL::SSL::SSLError\n      { error: :failed_tls }\n    end\n\n    def stream_body_with_limit(response)\n      bytes_read = 0\n      response.read_body do |chunk|\n        bytes_read += chunk.bytesize\n        raise ResponseTooLarge if bytes_read > MAX_RESPONSE_SIZE\n      end\n    end\n\n    def resolved_ip\n      return @resolved_ip if defined?(@resolved_ip)\n      @resolved_ip = SsrfProtection.resolve_public_ip(uri.host)\n    end\n\n    def uri\n      @uri ||= URI(webhook.url)\n    end\n\n    def http\n      Net::HTTP.new(uri.host, uri.port).tap do |http|\n        http.ipaddr = resolved_ip\n        http.use_ssl = (uri.scheme == \"https\")\n        http.open_timeout = ENDPOINT_TIMEOUT\n        http.read_timeout = ENDPOINT_TIMEOUT\n      end\n    end\n\n    def headers\n      {\n        \"User-Agent\" => USER_AGENT,\n        \"Content-Type\" => content_type,\n        \"X-Webhook-Signature\" => signature,\n        \"X-Webhook-Timestamp\" => event.created_at.utc.iso8601\n      }\n    end\n\n    def signature\n      OpenSSL::HMAC.hexdigest(\"SHA256\", webhook.signing_secret, payload)\n    end\n\n    def content_type\n      if webhook.for_campfire?\n        \"text/html\"\n      elsif webhook.for_basecamp?\n        \"application/x-www-form-urlencoded\"\n      else\n        \"application/json\"\n      end\n    end\n\n    def payload\n      @payload ||= if webhook.for_basecamp?\n        { content: render_payload(formats: :html) }.to_query\n      elsif webhook.for_campfire?\n        render_payload(formats: :html)\n      elsif webhook.for_slack?\n        slack_payload\n      else\n        render_payload(formats: :json)\n      end\n    end\n\n    def render_payload(**options)\n      webhook.renderer.render(layout: false, template: \"webhooks/event\", assigns: { event: event }, **options).strip\n    end\n\n    def convert_html_to_mrkdwn(html)\n      document = Nokogiri::HTML5(html)\n\n      document.css(\"a\").each do |a|\n        a.replace(\"<#{a[\"href\"].strip}|#{a.text}>\") if a[\"href\"].present?\n      end\n\n      document.css(\"b\").each do |b|\n        b.replace(\"*#{b.text}*\")\n      end\n\n      document.css(\"i\").each do |i|\n        i.replace(\"_#{i.text}_\")\n      end\n\n      document.text\n    end\n\n    def slack_payload\n      text = event.description_for(nil).to_plain_text\n      url = polymorphic_url(event.eventable, base_url_options.merge(script_name: account.slug))\n\n      { text: \"#{text} <#{url}|Open in Fizzy>\" }.to_json\n    end\n\n    def base_url_options\n      Rails.application.routes.default_url_options.presence ||\n        Rails.application.config.action_mailer.default_url_options\n    end\nend\n"
  },
  {
    "path": "app/models/webhook/triggerable.rb",
    "content": "module Webhook::Triggerable\n  extend ActiveSupport::Concern\n\n  included do\n    scope :triggered_by, ->(event) { where(board: event.board).triggered_by_action(event.action) }\n    scope :triggered_by_action, ->(action) { where(\"subscribed_actions LIKE ?\", \"%\\\"#{action}\\\"%\") }\n  end\n\n  def trigger(event)\n    deliveries.create!(event: event) unless account.cancelled?\n  end\nend\n"
  },
  {
    "path": "app/models/webhook.rb",
    "content": "class Webhook < ApplicationRecord\n  include Triggerable\n\n  SLACK_WEBHOOK_URL_REGEX = %r{//hooks\\.slack\\.com/services/T[^\\/]+/B[^\\/]+/[^\\/]+\\Z}i\n  CAMPFIRE_WEBHOOK_URL_REGEX = %r{/rooms/\\d+/\\d+-[^\\/]+/messages\\Z}i\n  BASECAMP_CAMPFIRE_WEBHOOK_URL_REGEX = %r{/\\d+/integrations/[^\\/]+/buckets/\\d+/chats/\\d+/lines\\Z}i\n\n  PERMITTED_SCHEMES = %w[ http https ].freeze\n  PERMITTED_ACTIONS = %w[\n    card_assigned\n    card_closed\n    card_postponed\n    card_auto_postponed\n    card_board_changed\n    card_published\n    card_reopened\n    card_sent_back_to_triage\n    card_triaged\n    card_unassigned\n    comment_created\n  ].freeze\n\n  has_secure_token :signing_secret\n\n  has_many :deliveries, dependent: :delete_all\n  has_one :delinquency_tracker, dependent: :delete\n\n  belongs_to :account, default: -> { board.account }\n  belongs_to :board\n\n  serialize :subscribed_actions, type: Array, coder: JSON\n\n  scope :ordered, -> { order(name: :asc, id: :desc) }\n  scope :active, -> { where(active: true) }\n\n  after_create :create_delinquency_tracker!\n\n  normalizes :subscribed_actions, with: ->(value) { Array.wrap(value).map(&:to_s).uniq & PERMITTED_ACTIONS }\n  normalizes :url, with: -> { it.strip }\n\n  validates :name, presence: true\n  validate :validate_url\n\n  def activate\n    update! active: true unless active?\n  end\n\n  def deactivate\n    update! active: false\n  end\n\n  def renderer\n    @renderer ||= ApplicationController.renderer.new(script_name: account.slug, https: !Rails.env.local?)\n  end\n\n  def for_basecamp?\n    url.match? BASECAMP_CAMPFIRE_WEBHOOK_URL_REGEX\n  end\n\n  def for_campfire?\n    url.match? CAMPFIRE_WEBHOOK_URL_REGEX\n  end\n\n  def for_slack?\n    url.match? SLACK_WEBHOOK_URL_REGEX\n  end\n\n  private\n    def validate_url\n      uri = URI.parse(url.presence)\n\n      if PERMITTED_SCHEMES.exclude?(uri.scheme)\n        errors.add :url, \"must use #{PERMITTED_SCHEMES.to_choice_sentence}\"\n      end\n    rescue URI::InvalidURIError\n      errors.add :url, \"not a URL\"\n    end\nend\n"
  },
  {
    "path": "app/models/zip_file/reader/io.rb",
    "content": "class ZipFile::Reader::IO\n  def initialize(entry, io)\n    @entry = entry\n    @io = io\n    @extractor = @entry.extractor_from(@io)\n  end\n\n  def read(length = nil, buffer = nil)\n    return nil if @extractor.eof?\n\n    data = @extractor.extract(length)\n    return nil if data.nil?\n\n    if buffer\n      buffer.replace(data)\n      buffer\n    else\n      data\n    end\n  end\n\n  def eof?\n    @extractor.eof?\n  end\n\n  def rewind\n    @extractor = @entry.extractor_from(@io)\n    0\n  end\n\n  def size\n    @entry.uncompressed_size\n  end\nend\n"
  },
  {
    "path": "app/models/zip_file/reader.rb",
    "content": "class ZipFile::Reader\n  def initialize(io)\n    @io = io\n    @reader = ZipKit::FileReader.read_zip_structure(io: io)\n  rescue ZipKit::FileReader::ReadError, ZipKit::FileReader::MissingEOCD, ZipKit::FileReader::UnsupportedFeature => e\n    raise ZipFile::InvalidFileError, e.message\n  end\n\n  def read(file_path)\n    entry = @reader.find { |e| e.filename == file_path }\n    raise ArgumentError, \"File not found in zip: #{file_path}\" unless entry\n    raise ArgumentError, \"Cannot read directory entry: #{file_path}\" if entry.filename.end_with?(\"/\")\n\n    if block_given?\n      yield ZipFile::Reader::IO.new(entry, @io)\n    else\n      entry.extractor_from(@io).extract\n    end\n  end\n\n  def glob(pattern)\n    @reader.map(&:filename).select { |name| File.fnmatch(pattern, name) }.sort\n  end\n\n  def exists?(file_path)\n    @reader.any? { |e| e.filename == file_path }\n  end\nend\n"
  },
  {
    "path": "app/models/zip_file/remote_io.rb",
    "content": "class ZipFile::RemoteIO < ZipKit::RemoteIO\n  def initialize(url, ssl_verify_peer: true)\n    super(url)\n    @ssl_verify_peer = ssl_verify_peer\n  end\n\n  protected\n    def request_range(range)\n      with_http do |http|\n        request = Net::HTTP::Get.new(@uri)\n        request.range = range\n        response = http.request(request)\n\n        case response.code\n        when \"206\", \"200\"\n          response.body\n        else\n          raise \"Remote at #{@uri} replied with code #{response.code}\"\n        end\n      end\n    end\n\n    def request_object_size\n      with_http do |http|\n        request = Net::HTTP::Get.new(@uri)\n        request.range = 0..0\n        response = http.request(request)\n\n        case response.code\n        when \"206\"\n          content_range_header_value = response[\"Content-Range\"]\n          content_range_header_value.split(\"/\").last.to_i\n        when \"200\"\n          response[\"Content-Length\"].to_i\n        else\n          raise \"Remote at #{@uri} replied with code #{response.code}\"\n        end\n      end\n    end\n\n  private\n    def with_http\n      http = Net::HTTP.new(@uri.hostname, @uri.port)\n      http.use_ssl = @uri.scheme == \"https\"\n      http.verify_mode = OpenSSL::SSL::VERIFY_NONE unless @ssl_verify_peer\n      http.start { yield http }\n    end\nend\n"
  },
  {
    "path": "app/models/zip_file/writer.rb",
    "content": "class ZipFile::Writer\n  attr_reader :byte_size\n\n  def initialize(io = nil)\n    @entries = []\n    @byte_size = 0\n    @output_io = io\n    @streamer = nil\n    @digest = Digest::MD5.new\n  end\n\n  def stream_to(io)\n    @output_io = io\n  end\n\n  def write(data)\n    @output_io.write(data)\n    @byte_size += data.bytesize\n    @digest.update(data)\n    data.bytesize\n  end\n\n  def add_file(path, content = nil, compress: true)\n    @entries << path\n    write_method = compress ? :write_deflated_file : :write_stored_file\n\n    if block_given?\n      streamer.public_send(write_method, path) { |sink| yield sink }\n    else\n      streamer.public_send(write_method, path) { |sink| sink.write(content) }\n    end\n  end\n\n  def glob(pattern)\n    @entries.select { |e| File.fnmatch(pattern, e) }.sort\n  end\n\n  def exists?(path)\n    @entries.include?(path)\n  end\n\n  def close\n    streamer.close\n  end\n\n  def checksum\n    Base64.strict_encode64(@digest.digest)\n  end\n\n  private\n    def streamer\n      @streamer ||= ZipKit::Streamer.new(@output_io)\n    end\nend\n"
  },
  {
    "path": "app/models/zip_file.rb",
    "content": "class ZipFile\n  class InvalidFileError < StandardError; end\n\n  class << self\n    def create_for(attachment, filename:)\n      raise ArgumentError, \"No block given\" unless block_given?\n\n      reflection = attachment.record.class.reflect_on_attachment(attachment.name)\n      service_name = reflection.options[:service_name] || ActiveStorage::Blob.service.name\n      service = ActiveStorage::Blob.services.fetch(service_name)\n\n      if s3_service?(service)\n        create_for_s3(attachment, filename: filename, service: service) { |zip| yield zip }\n      else\n        create_for_disk(attachment, filename: filename) { |zip| yield zip }\n      end\n    end\n\n    def read_from(blob)\n      raise ArgumentError, \"No block given\" unless block_given?\n\n      if s3_service?(blob.service)\n        read_from_s3(blob) { |zip| yield zip }\n      else\n        read_from_disk(blob) { |zip| yield zip }\n      end\n    end\n\n    private\n      def s3_service?(service)\n        # The S3 service doesn't get loaded in development unless it's used\n        defined?(ActiveStorage::Service::S3Service) && service.is_a?(ActiveStorage::Service::S3Service)\n      end\n\n      def create_for_s3(attachment, filename:, service:)\n        blob = ActiveStorage::Blob.create_before_direct_upload!(\n          filename: filename,\n          content_type: \"application/zip\",\n          byte_size: 0,\n          checksum: \"pending\"\n        )\n\n        writer = Writer.new\n\n        # Use S3's upload_stream directly for write-based streaming.\n        # ActiveStorage's upload method expects a read-based IO, but ZipKit\n        # needs a write-based stream. The TransferManager's upload_stream\n        # yields a writable IO that we can stream directly to.\n        service.send(:upload_stream,\n          key: blob.key,\n          content_type: \"application/zip\",\n          part_size: 100.megabytes\n        ) do |write_stream|\n          write_stream.binmode\n          writer.stream_to(write_stream)\n          yield writer\n          writer.close\n        end\n\n        blob.update!(byte_size: writer.byte_size, checksum: writer.checksum)\n        attachment.attach(blob)\n      rescue Aws::S3::MultipartUploadError => e\n        if e.errors.any?\n          raise e.errors.first\n        else\n          raise e\n        end\n      end\n\n      def create_for_disk(attachment, filename:)\n        tempfile = Tempfile.new([ \"export\", \".zip\" ])\n        tempfile.binmode\n\n        writer = Writer.new(tempfile)\n        yield writer\n        writer.close\n\n        tempfile.rewind\n        attachment.attach(io: tempfile, filename: filename, content_type: \"application/zip\")\n      ensure\n        tempfile&.close\n        tempfile&.unlink\n      end\n\n      def read_from_s3(blob)\n        url = blob.url(expires_in: 6.hour)\n        ssl_verify_peer = blob.service.client.client.config.ssl_verify_peer\n        remote_io = RemoteIO.new(url, ssl_verify_peer: ssl_verify_peer)\n        reader = Reader.new(remote_io)\n        yield reader\n      end\n\n      def read_from_disk(blob)\n        blob.open do |file|\n          reader = Reader.new(file)\n          yield reader\n        end\n      end\n  end\nend\n"
  },
  {
    "path": "app/views/account/exports/show.html.erb",
    "content": "<% if @export.present? %>\n  <% @page_title = \"Download Export\" %>\n<% else %>\n  <% @page_title = \"Download Expired\" %>\n<% end %>\n\n<% content_for :header do %>\n  <div class=\"header__actions header__actions--start\">\n    <%= back_link_to \"Account Settings\", account_settings_path, \"keydown.left@document->hotkey#click keydown.esc@document->hotkey#click\" %>\n  </div>\n<% end %>\n\n<div class=\"panel panel--wide shadow center flex flex-column align-center gap\">\n  <h2 class=\"txt-large font-weight-black margin-none\"><%= @page_title %></h2>\n\n  <% if @export.present? %>\n    <div>Your export is ready. The download should start automatically.</div>\n\n    <%= link_to \"Download your data\", rails_blob_path(@export.file, disposition: \"attachment\"),\n          id: \"download-link\",\n          class: \"btn btn--link\",\n          data: { turbo: false, controller: \"auto-click\" } %>\n  <% else %>\n    <div>That download link has expired. You’ll need to <%= link_to \"request a new export\", account_settings_path, class: \"txt-link\" %>.</div>\n  <% end %>\n\n</div>\n"
  },
  {
    "path": "app/views/account/exports/show.json.jbuilder",
    "content": "json.(@export, :id, :status)\njson.created_at @export.created_at.utc\n\nif @export.completed? && @export.file.attached?\n  json.download_url rails_blob_url(@export.file, disposition: \"attachment\")\nend\n"
  },
  {
    "path": "app/views/account/imports/new.html.erb",
    "content": "<% @page_title = \"Import an account\" %>\n<div class=\"panel panel--centered flex flex-column gap\" style=\"--panel-size: 54ch;\">\n  <header>\n    <h1 class=\"txt-x-large font-weight-black txt-tight-lines margin-block-end-none\">Import a Fizzy account</h1>\n\n    <% if Fizzy.saas? %>\n      <div class=\"font-weight-semibold margin-block-end\">Running Fizzy on your own server and want us to host on <a class=\"txt-ink txt-underline\" href=\"https://fizzy.do\" target=\"_blank\" rel=\"noopener noreferrer\">fizzy.do</a> instead?</div>\n      <div>Export your self-hosted account, then upload the .zip file below.</div>\n    <% else %>\n      <div class=\"font-weight-semibold margin-block-end\">Ready to host Fizzy on your own server?</div>\n      <div>Export your <a class=\"txt-ink txt-underline\" href=\"https://fizzy.do\" target=\"_blank\" rel=\"noopener noreferrer\">fizzy.do</a> account, then upload the .zip file below.</div>\n    <% end %>\n  </header>\n\n  <%= form_with url: account_imports_path, class: \"flex flex-column gap\", data: { controller: \"form upload-preview\" }, multipart: true do |form| %>\n    <label class=\"btn input--upload\">\n      <div data-upload-preview-target=\"placeholder\">Choose a file…</div>\n      <div data-upload-preview-target=\"fileName\" hidden></div>\n      <%= form.file_field :file, accept: \".zip\", required: true, data: { action: \"upload-preview#previewFileName\", upload_preview_target: \"input\" } %>\n    </label>\n\n    <button type=\"submit\" class=\"btn btn--link center\" data-form-target=\"submit\">\n      <span>Start Import →</span>\n    </button>\n  <% end %>\n\n  <p class=\"txt-small\">If you run into issues or would like assistance, just <a href=\"mailto:support@fizzy.do\" class=\"txt-ink txt-underline\">send us an email</a>.</p>\n</div>\n\n<% content_for :footer do %>\n  <%= render \"sessions/footer\" %>\n<% end %>\n"
  },
  {
    "path": "app/views/account/imports/show.html.erb",
    "content": "<% @page_title = \"Import status\" %>\n\n<%= turbo_stream_from @import %>\n\n<div class=\"panel panel--centered flex flex-column gap-half\" style=\"--panel-size: 54ch;\">\n  <h1 class=\"txt-x-large font-weight-black margin-block-end\">Import status</h1>\n\n  <% case @import.status %>\n  <% when \"pending\", \"processing\" %>\n    <div class=\"import-status\">\n      Your import is in progress. This may take a while for large accounts.\n    </div>\n  <% when \"completed\" %>\n    <div class=\"import-status import-status--success\">\n      <div><strong>Your import was successful!</strong></div>\n      <%= link_to \"Go to your account →\", landing_url(script_name: @import.account.slug), class: \"btn btn--link\" %>\n    </div>\n  <% when \"failed\" %>\n    <div class=\"import-status import-status--error\">\n      <div><strong>Import failed</strong></div>\n      <% if @import.failed_due_to_conflict? %>\n        <div>The account you’re trying to import already exists. Make sure you’re importing a <%= Fizzy.saas? ? \"self-hosted\" : \"fizzy.do\" %> account.</div>\n      <% elsif @import.failed_due_to_invalid_export? %>\n        <div>The .zip file you uploaded doesn’t look like a Fizzy account export.</div>\n      <% else %>\n        <div>This may be due to corrupted export data or a conflict with existing data. Please try again with a fresh export.</div>\n      <% end %>\n      <%= link_to \"Try again\", new_account_import_path, class: \"btn\" %>\n    </div>\n  <% end %>\n</div>\n\n<% content_for :footer do %>\n  <%= render \"sessions/footer\" %>\n<% end %>\n"
  },
  {
    "path": "app/views/account/join_codes/edit.html.erb",
    "content": "<% @page_title = \"Change usage limit\" %>\n\n<% content_for :header do %>\n  <div class=\"header__actions header__actions--start\">\n    <%= back_link_to \"Invite link\", account_join_code_path, \"keydown.left@document->hotkey#click keydown.esc@document->hotkey#click\" %>\n  </div>\n<% end %>\n\n<article class=\"panel panel--wide center shadow flex flex-column gap-half\" style=\"view-transition-name: <%= dom_id(@join_code) %>\">\n  <header class=\"margin-block-end-half\">\n    <h2 class=\"txt-large margin-none font-weight-black\"><%= @page_title %></h2>\n    <p class=\"txt-medium margin-none\">How many times can this link be used to join the account?</p>\n  </header>\n\n  <%= form_with model: @join_code, url: account_join_code_path, method: :patch, data: { controller: \"form\" }, html: { class: \"flex flex-column gap\" } do |form| %>\n    <%= form.number_field :usage_limit,\n          required: true, autofocus: true,\n          in: 0..Account::JoinCode::USAGE_LIMIT_MAX,\n          class: \"input center txt-large fit-content font-weight-black txt-align-center\", style: \"max-inline-size: 8ch\",\n          data: { action: \"keydown.esc@document->form#cancel focus->form#select\" } %>\n\n    <% if @join_code.errors.any? %>\n      <div class=\"txt-negative txt-small\">\n        <% @join_code.errors.full_messages.each do |message| %>\n          <p class=\"margin-block-none\"><%= message %></p>\n        <% end %>\n      </div>\n    <% end %>\n\n    <p class=\"margin-none txt-subtle\">\n      This code has been used <%= @join_code.usage_count %>/<%= @join_code.usage_limit_in_database %> times.\n    </p>\n\n    <%= form.button type: :submit, class: \"btn btn--link center txt-medium\", data: { form_target: \"submit\" } do %>\n      <span>Save changes</span>\n    <% end %>\n\n    <%= link_to \"Go back\", account_join_code_path, data: { form_target: \"cancel\" }, hidden: true %>\n  <% end %>\n</article>\n"
  },
  {
    "path": "app/views/account/join_codes/show.html.erb",
    "content": "<% @page_title = \"Add people\" %>\n\n<% content_for :header do %>\n  <div class=\"header__actions header__actions--start\">\n    <%= back_link_to \"Account Settings\", account_settings_path, \"keydown.left@document->hotkey#click keydown.esc@document->hotkey#click\" %>\n  </div>\n<% end %>\n\n<div class=\"panel panel--wide shadow center flex flex-column gap\" style=\"view-transition-name: <%= dom_id(@join_code) %>\">\n  <header>\n    <h2 class=\"txt-large margin-none font-weight-black\"><%= @page_title %></h2>\n    <p class=\"txt-medium margin-none\">Share the link below to invite people to this account</p>\n  </header>\n\n  <% url = join_url(code: @join_code.code, script_name: Current.account.slug) %>\n  <div class=\"flex align-center gap-half\">\n    <input type=\"text\" class=\"input flex-item-grow\" value=\"<%= url %>\" readonly>\n\n    <% if Current.user.admin? %>\n      <%= button_to account_join_code_path, method: :delete, class: \"btn btn--circle txt-small\", data: {\n            turbo_confirm: \"Are you sure you want to generate a new link? The previous code will stop working.\" } do %>\n        <%= icon_tag \"refresh\" %>\n        <span class=\"for-screen-reader\">Generate a new code</span>\n      <% end %>\n    <% end %>\n  </div>\n\n  <div class=\"center flex flex-wrap justify-center align-center gap\">\n    <%= tag.button class: \"btn btn--link\", data: {\n          controller: \"copy-to-clipboard\", action: \"copy-to-clipboard#copy\",\n          copy_to_clipboard_success_class: \"btn--success\", copy_to_clipboard_content_value: url } do %>\n      <%= icon_tag \"copy-paste\" %>\n      <span class=\"txt-nowrap\">Copy invite link</span>\n    <% end %>\n\n    <div data-controller=\"dialog\" data-dialog-modal-value=\"true\" class=\"flex-inline\">\n      <%= tag.button class: \"btn\", data: { action: \"dialog#open\" } do %>\n        <%= icon_tag \"qr-code\" %>\n        <span>Get QR code</span>\n      <% end %>\n\n      <dialog class=\"dialog panel shadow\" data-dialog-target=\"dialog\" style=\"--panel-size: 50ch;\">\n        <p class=\"margin-none-block-start txt-balance\">\n          <strong>Scan this code to join <%= Current.account.name %>:</strong>\n        </p>\n\n        <%= qr_code_image(url) %>\n\n        <form method=\"dialog\" class=\"margin-block-start flex justify-center\">\n          <button class=\"btn\">\n            <span>Done</span>\n          </button>\n        </form>\n      </dialog>\n    </div>\n  </div>\n\n  <footer class=\"txt-small\">\n    <hr class=\"separator--horizontal full-width margin-block\" style=\"--border-color: var(--color-ink-lighter)\">\n\n    <p class=\"margin-none <%= \"txt-negative\" if !@join_code.active? %>\">\n      This code has been used <%= @join_code.usage_count %>/<%= @join_code.usage_limit %> times\n      (<%= @join_code.active? ? @join_code.usage_limit - @join_code.usage_count : \"none\" %> remaining)\n\n      <% if Current.user.admin? %>\n        <%= link_to edit_account_join_code_path, class: @join_code.active? ? \"txt-link\" : \"txt-negative txt-underline\" do %>\n          <span>Change limit</span>\n        <% end %>\n      <% end %>\n    </p>\n  </footer>\n</div>\n"
  },
  {
    "path": "app/views/account/join_codes/show.json.jbuilder",
    "content": "json.(@join_code, :code, :usage_count, :usage_limit)\njson.url join_url(code: @join_code.code, script_name: Current.account.slug)\njson.active !!@join_code.active?\n"
  },
  {
    "path": "app/views/account/settings/_cancellation.html.erb",
    "content": "<% if Current.account.cancellable? && Current.user.owner? %>\n  <section class=\"settings__section\">\n    <header>\n      <h2 class=\"divider txt-negative\">Cancel account</h2>\n      <div>Delete your Fizzy account.</div>\n    </header>\n\n    <div data-controller=\"dialog\" data-dialog-modal-value=\"true\">\n      <button type=\"button\" class=\"btn btn--negative\" data-action=\"dialog#open\">Delete account</button>\n\n      <dialog class=\"dialog panel panel--wide shadow\" data-dialog-target=\"dialog\" style=\"--panel-size: 48ch;\">\n        <h2 class=\"margin-none txt-large txt-negative\">Delete your account?</h2>\n        <ul class=\"txt-align-start\">\n          <li>All users, including you, will lose access</li>\n          <% if Current.account.try(:active_subscription) %>\n            <li>Your subscription will be canceled</li>\n          <% end %>\n          <li>After 30 days, your data will be permanently deleted</li>\n        </ul>\n\n        <div class=\"flex gap justify-center\">\n          <button type=\"button\" class=\"btn\" data-action=\"dialog#close\">Cancel</button>\n          <%= button_to \"Delete my account\", account_cancellation_path, method: :post, class: \"btn btn--negative\", form: { data: { action: \"submit->dialog#close\", turbo: false } } %>\n        </div>\n      </dialog>\n    </div>\n  </section>\n<% end %>\n"
  },
  {
    "path": "app/views/account/settings/_entropy.html.erb",
    "content": "<section class=\"settings__section\">\n  <header>\n    <h2 class=\"divider\">Auto close</h2>\n    <div>Fizzy doesn’t let stale cards stick around forever. Cards automatically move to “Not Now” if there is no activity for a specific period of time. <em>This is the default, global setting — you can override it on each board.</em></div>\n  </header>\n\n  <%= render \"entropy/auto_close\", model: account.entropy, url: account_entropy_path, disabled: !Current.user.admin? %>\n</section>\n"
  },
  {
    "path": "app/views/account/settings/_export.html.erb",
    "content": "<section class=\"settings__section\">\n  <header>\n    <h2 class=\"divider\">Export account data</h2>\n    <div>Download a complete archive of all account data.</div>\n  </header>\n\n  <div data-controller=\"dialog\" data-dialog-modal-value=\"true\" data-action=\"keydown.esc->dialog#close\">\n    <button type=\"button\" class=\"btn\" data-action=\"dialog#open\">Begin export...</button>\n\n    <dialog class=\"dialog panel panel--wide shadow\" data-dialog-target=\"dialog\" style=\"--panel-size: 48ch;\">\n      <h2 class=\"txt-large\">Export all account data</h2>\n      <p>This will generate a ZIP archive of all data in this account including all boards, cards, users, and settings.</p>\n      <p>We’ll email you a link to download the file when it’s ready. The link will expire after 24 hours.</p>\n\n      <div class=\"flex gap justify-center\">\n        <%= button_to \"Start export\", account_exports_path, method: :post, class: \"btn btn--link\", form: { data: { action: \"submit->dialog#close\" } } %>\n        <button type=\"button\" class=\"btn\" data-action=\"dialog#close\">Cancel</button>\n      </div>\n    </dialog>\n  </div>\n</section>\n"
  },
  {
    "path": "app/views/account/settings/_name.html.erb",
    "content": "<section class=\"settings__section\">\n  <%= form_with model: account, url: account_settings_path, method: :put, scope: :account, data: { controller: \"form\" }, class: \"flex gap-half\" do |form| %>\n    <strong class=\"full-width\"><%= form.text_field :name, required: true, class: \"input input--transparent full-width txt-medium\", placeholder: \"Account name…\", data: { action: \"input->form#disableSubmitWhenInvalid\" }, readonly: !Current.user.admin? %></strong>\n\n    <% if Current.user.admin? %>\n      <%= form.button class: \"btn btn--circle btn--link txt-medium\", data: { form_target: \"submit\" }, disabled: form.object do %>\n        <%= icon_tag \"arrow-right\" %>\n        <span class=\"for-screen-reader\">Save changes</span>\n      <% end %>\n    <% end %>\n  <% end %>\n</section>\n"
  },
  {
    "path": "app/views/account/settings/_user.html.erb",
    "content": "<li class=\"flex align-center gap-half\" data-filter-target=\"item\" data-navigable-list-target=\"item\" role=\"option\">\n  <%= link_to user, class: \"txt-ink flex gap-half align-center min-width\" do %>\n    <%= avatar_preview_tag user, hidden_for_screen_reader: true %>\n    <div class=\"txt-align-start overflow-ellipsis\">\n      <strong><%= user.name %></strong>\n      <div class=\"txt-x-small\"><%= user.identity.email_address %></div>\n    </div>\n  <% end %>\n\n  <hr class=\"separator--horizontal flex-item-grow\" style=\"--border-color: var(--color-ink-medium); --border-style: dashed\" aria-hidden=\"true\">\n\n  <%= form_with model: user, url: user_role_path(user), data: { controller: \"form\" }, method: :patch do | form | %>\n    <span title=\"<%= role_display_name(user) %>\">\n      <label class=\"btn btn--circle\" for=\"<%= dom_id(user, :role) %>\" aria-label=\"Role: <%= role_display_name(user) %>\">\n        <%= icon_tag \"crown\" %>\n        <span class=\"for-screen-reader\">Role: <%= role_display_name(user) %></span>\n        <%= form.check_box :role, { data: { action: \"form#submit\" }, disabled: !Current.user.can_administer?(user), checked: user.admin?, hidden: true, id: dom_id(user, :role) }, \"admin\", \"member\" %>\n      </label>\n    </span>\n  <% end %>\n\n  <%# FIXME: Move this Current.user check to a stimulus controller that just checks for admin? or the like we so we can cache user list %>\n  <%= button_to user, method: :delete, class: \"btn btn--circle btn--negative\",\n        disabled: !Current.user.can_administer?(user),\n        data: { turbo_confirm: \"Are you sure you want to permanently remove this person from the account?\" } do %>\n    <%= icon_tag \"minus\" %>\n    <span class=\"for-screen-reader\">Remove <%= user.name %> from the account</span>\n  <% end %>\n</li>\n"
  },
  {
    "path": "app/views/account/settings/_users.html.erb",
    "content": "<section class=\"settings__section\">\n  <header>\n    <h2 class=\"divider\">People on this account</h2>\n  </header>\n\n  <%= tag.div class: \"settings__user-filter flex flex-column gap\", data: {\n    controller: \"filter navigable-list\",\n    action: \"keydown->navigable-list#navigate filter:changed->navigable-list#reset\",\n    navigable_list_focus_on_selection_value: true,\n    navigable_list_actionable_items_value: true\n  } do %>\n\n    <div class=\"settings__user-filter\">\n      <input placeholder=\"Filter…\" class=\"input input--transparent full-width txt-small\" type=\"search\" autocorrect=\"off\" autocomplete=\"off\" data-1p-ignore=\"true\" data-filter-target=\"input\" data-action=\"input->filter#filter\">\n\n      <ul class=\"settings__scrollable-list margin-block-half\" data-filter-target=\"list\" role=\"listbox\">\n        <%= render partial: \"account/settings/user\", collection: users %>\n      </ul>\n    </div>\n\n    <%= link_to account_join_code_path, class: \"btn btn--link center\" do %>\n      <%= icon_tag \"add\" %>\n      <span>Invite people</span>\n    <% end %>\n  <% end %>\n</section>\n"
  },
  {
    "path": "app/views/account/settings/show.html.erb",
    "content": "<% @page_title = \"Account Settings\" %>\n\n<% content_for :header do %>\n  <h1 class=\"header__title\" data-bridge--title-target=\"header\">\n    <%= @page_title %>\n    <% unless Current.user.admin? %>\n      <div class=\"txt-normal font-weight-normal\">Only admins can change these settings</div>\n    <% end %>\n  </h1>\n<% end %>\n\n<div class=\"settings margin-block-start-half\">\n  <div class=\"settings__panel settings__panel--users panel shadow center\">\n    <%= render \"account/settings/name\", account: @account %>\n    <%= render \"account/settings/users\", users: @users %>\n  </div>\n\n  <div class=\"settings__panel settings__panel--entropy panel shadow center\">\n    <%= render \"account/settings/entropy\", account: @account %>\n    <%= render \"account/settings/export\" if Current.user.admin? || Current.user.owner? %>\n    <%= render \"account/settings/cancellation\" %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/account/settings/show.json.jbuilder",
    "content": "json.(@account, :id, :name, :cards_count)\njson.created_at @account.created_at.utc\njson.auto_postpone_period_in_days @account.entropy.auto_postpone_period_in_days\n"
  },
  {
    "path": "app/views/action_text/attachables/_remote_image.html.erb",
    "content": "<figure class=\"attachment attachment--preview\">\n  <%= image_tag remote_image.url, skip_pipeline: true, width: remote_image.width, height: remote_image.height %>\n  <% if caption = remote_image.try(:caption) %>\n    <figcaption class=\"attachment__caption\">\n      <%= caption %>\n    </figcaption>\n  <% end %>\n</figure>\n"
  },
  {
    "path": "app/views/action_text/attachables/_remote_video.html.erb",
    "content": "<figure class=\"attachment attachment--preview attachment--video\">\n  <%= tag.video controls: true, width: remote_video.width, height: remote_video.height do %>\n    <%= tag.source src: remote_video.url, type: remote_video.content_type %>\n  <% end %>\n  <% if caption = remote_video.try(:caption) %>\n    <figcaption class=\"attachment__caption\">\n      <%= caption %>\n    </figcaption>\n  <% end %>\n</figure>\n"
  },
  {
    "path": "app/views/active_storage/blobs/_blob.html.erb",
    "content": "<% if blob.representable? %>\n  <figure class=\"attachment attachment--preview attachment--<%= blob.filename.extension %>\">\n    <%= render \"active_storage/blobs/web/representation\", blob: blob %>\n\n    <figcaption class=\"attachment__caption\">\n      <% if caption = blob.try(:caption) %>\n        <span><%= caption %></span>\n      <% else %>\n        <span class=\"attachment__name\"><%= blob.filename %></span>\n      <% end %>\n      <span> · </span>\n      <span class=\"attachment__size\"><%= number_to_human_size blob.byte_size %></span>\n      <span> · </span>\n      <%= link_to rails_blob_path(blob, disposition: :attachment), class: \"attachment__link\", download: blob.filename, title: \"Download #{blob.filename}\" do %>\n        <span>Download</span>\n      <% end %>\n    </figcaption>\n  </figure>\n<% else %>\n  <div class=\"attachment attachment--file attachment--<%= blob.filename.extension -%>\">\n    <%= render \"active_storage/blobs/web/representation\", blob: blob %>\n\n    <div class=\"attachment__caption\">\n      <div>\n        <% if caption = blob.try(:caption) %>\n          <strong><%= caption %></strong>\n        <% else %>\n          <strong class=\"attachment__name\"><%= blob.filename %></strong>\n        <% end %>\n      </div>\n      <div>\n        <span class=\"attachment__size\"><%= number_to_human_size blob.byte_size %></span>\n        <span> · </span>\n        <%= link_to rails_blob_path(blob, disposition: :attachment), class: \"attachment__link\", download: blob.filename, title: \"Download #{blob.filename}\" do %>\n          <span>Download</span>\n        <% end %>\n      </div>\n    </div>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/active_storage/blobs/web/_representation.html.erb",
    "content": "<% variant = Attachments::VARIANTS[local_assigns[:in_gallery] ? :small : :large] %>\n<% width = blob.metadata[\"width\"] %>\n<% height = blob.metadata[\"height\"] %>\n\n<% if blob.video? %>\n  <%= tag.video \\\n        src: rails_blob_path(blob),\n        controls: true,\n        preload: :none,\n        style: \"aspect-ratio: #{width} / #{height};\",\n        width: width,\n        height: height %>\n<% elsif blob.audio? %>\n  <audio controls=\"true\" width=\"100%\" preload=\"metadata\">\n    <source src=\"<%= rails_blob_path(blob) %>\" type=\"<%= blob.content_type %>\">\n  </audio>\n<% elsif blob.variable? %>\n  <%= link_to rails_representation_path(blob.variant(variant)), data: { lightbox_target: \"image\", lightbox_caption_value: blob.filename.to_s } do %>\n    <%= image_tag rails_representation_path(blob.variant(variant)), width: width, height: height %>\n  <% end %>\n<% elsif blob.previewable? %>\n  <%= image_tag rails_representation_path(blob.preview(variant)), width: width, height: height %>\n<% else %>\n  <span class=\"attachment__icon\"><%= blob.filename.extension&.downcase.presence || \"unknown\" %></span>\n<% end %>\n"
  },
  {
    "path": "app/views/bar/_bar.html.erb",
    "content": "<div class=\"bar full-width\" data-controller=\"bar\" data-bar-dialog-outlet=\"#bar-modal\"\n    data-bar-search-url-value=\"<%= search_path %>\">\n  <div class=\"flex justify-center bar__placeholder\" data-bar-target=\"buttonsContainer\">\n    <%= tag.button class: \"btn btn--plain\", data: {\n          controller: \"hotkey\", action: \"bar#search keydown.k@document->hotkey#click\" } do %>\n      <span class=\"display-contents\">Search <kbd class=\"hide-on-touch\">K</kbd></span>\n    <% end %>\n  </div>\n\n  <div class=\"bar__input\" data-bar-target=\"search\" hidden>\n    <%= render \"searches/form\", query_terms: \"\", target_turbo_frame: \"bar_content\" %>\n  </div>\n\n  <%= tag.dialog id: \"bar-modal\", class: \"bar__modal\", data: {\n        controller: \"dialog\", dialog_target: \"dialog\", action: \"keydown.esc@document->bar#reset:stop\" } do %>\n    <%= turbo_frame_tag \"bar_content\", data: { bar_target: \"turboFrame\" } %>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/boards/_access_toggle.html.erb",
    "content": "<li class=\"flex align-center gap-half\" data-filter-target=\"item\" data-navigable-list-target=\"item\">\n  <%= link_to user, class: \"txt-ink flex gap-half align-center min-width\" do %>\n    <%= avatar_preview_tag user, hidden_for_screen_reader: true %>\n    <div class=\"txt-align-start overflow-ellipsis\">\n      <strong><%= user.name %></strong>\n      <div class=\"txt-x-small\"><%= user.identity.email_address %></div>\n    </div>\n  <% end %>\n\n  <hr class=\"separator--horizontal flex-item-grow\" style=\"--border-color: var(--color-ink-medium); --border-style: dashed\" aria-hidden=\"true\">\n\n  <%= icon_tag \"check\", class: \"toggler__visible-when-on margin-inline-end\" %>\n\n  <label class=\"switch toggler__visible-when-off flex-item-no-shrink\">\n    <% checkbox_data = { toggle_class_target: \"checkbox\" } %>\n    <% checkbox_data[:boards_form_target] = \"meCheckbox\" if user == Current.user %>\n    <%= check_box_tag \"user_ids[]\", user.id, selected, class: \"switch__input\", id: nil, data: checkbox_data, disabled: disabled %>\n    <span class=\"switch__btn round\"></span>\n    <span class=\"for-screen-reader\">Give <%= user.name %> access</span>\n  </label>\n</li>\n"
  },
  {
    "path": "app/views/boards/_board.json.jbuilder",
    "content": "json.cache! board do\n  json.(board, :id, :name, :all_access)\n  json.created_at board.created_at.utc\n  json.auto_postpone_period_in_days board.auto_postpone_period_in_days\n  json.url board_url(board)\n\n  json.creator board.creator, partial: \"users/user\", as: :user\n\n  json.public_url published_board_url(board) if board.published?\nend\n"
  },
  {
    "path": "app/views/boards/columns/_empty_placeholder.html.erb",
    "content": "<div class=\"blank-slate blank-slate--default\">No cards here</div>\n<div class=\"blank-slate blank-slate--drag overflow-ellipsis\">Drag cards here</div>\n"
  },
  {
    "path": "app/views/boards/columns/closeds/show.html.erb",
    "content": "<% @page_title = \"Column: Done\" %>\n\n<% content_for :header do %>\n  <div class=\"header__actions header__actions--start\">\n    <%= link_back_to_board(@board) %>\n  </div>\n\n  <h1 class=\"header__title divider divider--fade full-width\" data-bridge--title-target=\"header\">\n    <span class=\"overflow-ellipsis\"><%= @page_title %></span>\n  </h1>\n<% end %>\n\n<section class=\"cards cards--grid\">\n  <%= turbo_frame_tag :closed_column do %>\n    <div class=\"cards__list hide-scrollbar\" data-drag-drop-item-container>\n      <% if @page.used? %>\n        <%= with_automatic_pagination :closed_column, @page do %>\n          <%= render \"cards/display/previews\", cards: @page.records, draggable: true %>\n        <% end %>\n      <% else %>\n        <%= render \"boards/columns/empty_placeholder\" %>\n      <% end %>\n    </div>\n  <% end %>\n</section>\n"
  },
  {
    "path": "app/views/boards/columns/closeds/show.json.jbuilder",
    "content": "json.array! @page.records, partial: \"cards/card\", as: :card\n"
  },
  {
    "path": "app/views/boards/columns/create.turbo_stream.erb",
    "content": "<%= turbo_stream.before(\"closed-cards\", partial: \"boards/show/column\", method: :morph, locals: { column: @column }) %>\n<%= render \"columns/refresh_adjacent_columns\", column: @column %>\n"
  },
  {
    "path": "app/views/boards/columns/index.json.jbuilder",
    "content": "json.array! @columns, partial: \"columns/column\", as: :column\n"
  },
  {
    "path": "app/views/boards/columns/not_nows/show.html.erb",
    "content": "<% @page_title = \"Column: Not Now\" %>\n\n<% content_for :header do %>\n  <div class=\"header__actions header__actions--start\">\n    <%= link_back_to_board(@board) %>\n  </div>\n\n  <h1 class=\"header__title divider divider--fade full-width\" data-bridge--title-target=\"header\">\n    <span class=\"overflow-ellipsis\"><%= @page_title %></span>\n  </h1>\n<% end %>\n\n<section class=\"cards cards--grid\">\n  <%= turbo_frame_tag :not_now_column do %>\n    <div class=\"cards__list hide-scrollbar\" data-drag-drop-item-container>\n      <% if @page.used? %>\n        <%= with_automatic_pagination :not_now_column, @page do %>\n          <%= render \"cards/display/previews\", cards: @page.records, draggable: true %>\n        <% end %>\n      <% else %>\n        <%= render \"boards/columns/empty_placeholder\" %>\n      <% end %>\n    </div>\n  <% end %>\n</section>\n"
  },
  {
    "path": "app/views/boards/columns/not_nows/show.json.jbuilder",
    "content": "json.array! @page.records, partial: \"cards/card\", as: :card\n"
  },
  {
    "path": "app/views/boards/columns/show.html.erb",
    "content": "<% @page_title = \"Column: #{ @column.name }\" %>\n\n<% content_for :header do %>\n  <div class=\"header__actions header__actions--start\">\n    <%= link_back_to_board(@column.board) %>\n  </div>\n\n  <h1 class=\"header__title divider divider--fade full-width\" data-bridge--title-target=\"header\">\n    <span class=\"overflow-ellipsis\"><%= @page_title %></span>\n  </h1>\n<% end %>\n\n<section class=\"cards cards--grid\">\n  <%= turbo_frame_tag @column, :cards do %>\n    <div class=\"cards__list hide-scrollbar\" data-drag-drop-item-container>\n      <% if @page.used? %>\n        <%= with_automatic_pagination dom_id(@column, :cards), @page do %>\n          <%= render \"cards/display/previews\", cards: @page.records, draggable: true %>\n        <% end %>\n      <% else %>\n        <%= render \"boards/columns/empty_placeholder\" %>\n      <% end %>\n    </div>\n  <% end %>\n</section>\n"
  },
  {
    "path": "app/views/boards/columns/show.json.jbuilder",
    "content": "json.partial! \"columns/column\", column: @column\n"
  },
  {
    "path": "app/views/boards/columns/streams/show.html.erb",
    "content": "<% @page_title = \"Column: Maybe?\" %>\n\n<% content_for :header do %>\n  <div class=\"header__actions header__actions--start\">\n    <%= link_back_to_board(@board) %>\n  </div>\n\n  <h1 class=\"header__title divider divider--fade full-width\" data-bridge--title-target=\"header\">\n    <span class=\"overflow-ellipsis\"><%= @page_title %></span>\n  </h1>\n<% end %>\n\n<section class=\"cards cards--grid\">\n  <%= turbo_frame_tag :stream_column do %>\n    <div class=\"cards__list hide-scrollbar\" data-drag-drop-item-container>\n      <% if @page.used? %>\n        <%= with_automatic_pagination :stream_column, @page do %>\n          <%= render \"cards/display/previews\", cards: @page.records, draggable: true %>\n        <% end %>\n      <% else %>\n        <%= render \"boards/columns/empty_placeholder\" %>\n      <% end %>\n    </div>\n  <% end %>\n</section>\n"
  },
  {
    "path": "app/views/boards/columns/streams/show.json.jbuilder",
    "content": "json.array! @page.records, partial: \"cards/card\", as: :card\n"
  },
  {
    "path": "app/views/boards/columns/update.turbo_stream.erb",
    "content": "<%= turbo_stream.replace(dom_id(@column), partial: \"boards/show/column\", method: :morph, locals: { column: @column }) %>\n"
  },
  {
    "path": "app/views/boards/edit/_auto_close.html.erb",
    "content": "<%= turbo_frame_tag board, :entropy do %>\n  <div class=\"margin-block-end\">\n    <h2 class=\"divider txt-large\">Auto close</h2>\n    <p class=\"margin-none-block-start\">Fizzy doesn’t let stale cards stick around forever. Cards automatically move to “Not now” if no one updates, comments, or moves a card for…</p>\n    <%= render \"entropy/auto_close\", model: board, url: board_entropy_path(board), disabled: !Current.user.can_administer_board?(board) %>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/boards/edit/_delete.html.erb",
    "content": "<div class=\"txt-align-center margin-block-start-auto\" data-controller=\"dialog\" data-dialog-modal-value=\"true\" data-action=\"keydown.esc->dialog#close:stop\">\n  <button type=\"button\" class=\"btn txt-negative borderless txt-small\" data-action=\"dialog#open\">\n    <%= icon_tag \"trash\" %>\n    <span>Delete this board</span>\n  </button>\n  <dialog class=\"dialog panel fill-white shadow gap flex-column\" style=\"--panel-size: 40ch\" data-dialog-target=\"dialog\">\n    <h3 class=\"txt-large txt-bold\">Delete this board?</h3>\n    <p class=\"txt-medium margin-block-half\">Are you sure you want to permanently delete this board and all the cards on it? This can't be undone.</p>\n    <div class=\"flex gap-half justify-center margin-block-start\">\n      <button type=\"button\" class=\"btn\" data-action=\"dialog#close\">Cancel</button>\n      <%= button_to board_path(board), method: :delete, class: \"btn txt-negative\", data: { turbo_frame: \"_top\" } do %>\n        <span>Delete board</span>\n      <% end %>\n    </div>\n  </dialog>\n</div>\n"
  },
  {
    "path": "app/views/boards/edit/_name.html.erb",
    "content": "<div class=\"flex align-center gap\">\n  <label class=\"flex-item-grow\">\n    <strong><%= form.text_field :name, name: \"board[name]\", class: \"input full-width txt-medium\",\n          required: true, autofocus: false, placeholder: \"Board name…\",\n          data: { action: \"keydown.enter->form#submit:prevent, keydown.esc->form#cancel\" },\n          readonly: !Current.user.can_administer_board?(board) %></strong>\n  </label>\n</div>\n"
  },
  {
    "path": "app/views/boards/edit/_publication.html.erb",
    "content": "<%= turbo_frame_tag @board, :publication do %>\n  <header>\n    <h2 class=\"divider txt-large\">Public link</h2>\n    <div>Turn on the Public link to share this board with anyone in the world. They won’t need to log in and they won’t be able to see anything else in Fizzy.</div>\n  </header>\n\n  <% if board.published? %>\n    <div class=\"border-radius pad fill-selected\">\n      <%= form_with url: board_publication_path(board), method: :delete, class: \"flex-inline center justify-between gap\", data: { controller: \"form\" } do |form| %>\n        <label class=\"flex gap cursor-pointer\">\n          <span class=\"txt-large\"><%= icon_tag \"lock\" %></span>\n          <span class=\"switch flex align-center justify-between\">\n            <%= form.check_box :published, class: \"switch__input\", checked: true, data: { action: \"change->form#submit\" }, disabled: !Current.user.can_administer_board?(@board) %>\n            <span class=\"switch__btn round\"></span>\n          </span>\n          <span class=\"for-screen-reader\">Turn off the public link</span>\n        </label>\n        <span class=\"txt-large\" aria-hidden=\"true\"><%= icon_tag \"world\" %></span>\n      <% end %>\n\n      <div class=\"flex align-center gap-half margin-block\">\n        <%= text_field_tag :publication_url, published_board_url(board), readonly: true, class: \"full-width input fill-white\" %>\n        <div class=\"flex align-center justify-center gap-half\">\n          <%= button_to_copy_to_clipboard(published_board_url(board)) do %>\n            <%= icon_tag \"copy-paste\" %>\n            <span class=\"for-screen-reader\">Copy public link</span>\n          <% end %>\n        </div>\n      </div>\n      <strong class=\"margin-block-end-half flex justify-center txt-small\">Add an optional description to the public page</strong>\n      <div class=\"border-radius input fill-white\">\n        <%= form_with model: board, class: \"txt-align-start\", data: { controller: \"form\", turbo_frame: \"_top\" } do |form| %>\n          <%= form.rich_textarea :public_description, class: \"lexxy-content txt-small\",\n            placeholder: \"Add a public note about this board…\",\n            data: { action: \"keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop\" },\n            readonly: !Current.user.can_administer_board?(@board) %>\n          <%= form.button \"Save changes\", type: :submit, class: \"btn txt-small\", title: \"Save changes (#{ hotkey_label([\"ctrl\", \"enter\"]) })\" %>\n        <% end %>\n      </div>\n    </div>\n  <% else %>\n    <div class=\"border-radius pad fill-shade\">\n      <%= form_with url: board_publication_path(board), method: :post, class: \"flex-inline center justify-between gap\", data: { controller: \"form\" } do |form| %>\n        <span class=\"txt-large\" aria-hidden=\"true\"><%= icon_tag \"lock\" %></span>\n        <label class=\"flex gap cursor-pointer\">\n          <span class=\"switch flex align-center justify-between\">\n            <%= form.check_box :published, class: \"switch__input\", checked: false, data: { action: \"change->form#submit\" }, disabled: !Current.user.can_administer_board?(@board) %>\n            <span class=\"switch__btn round\"></span>\n          </span>\n          <span class=\"txt-large\"><%= icon_tag \"world\" %></span>\n          <span class=\"for-screen-reader\">Turn on the public link</span>\n        </label>\n      <% end %>\n    </div>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/boards/edit/_users.html.erb",
    "content": "<% disabled = !Current.user.can_administer_board?(board) %>\n<header>\n  <h2 class=\"divider txt-medium margin-block-start\">Who can access this board?</h2>\n</header>\n\n<%= access_menu_tag board, class: \"settings__user-filter unpad margin-none\" do %>\n  <li class=\"flex align-center gap-half\">\n    <figure class=\"avatar fill-selected\">\n      <%= icon_tag \"everyone\" %>\n      <span class=\"for-screen-reader\">Everyone</span>\n    </figure>\n\n    <div class=\"min-width\">\n      <div class=\"overflow-ellipsis\"><strong>Everyone</strong></div>\n    </div>\n\n    <hr class=\"separator--horizontal flex-item-grow\" style=\"--border-color: var(--color-ink-medium); --border-style: dashed\" aria-hidden=\"true\" />\n\n    <label for=\"board_all_access\" class=\"switch\">\n      <%= form.check_box :all_access, class: \"switch__input\", checked: board.all_access?, disabled:, data: { action: \"change->toggle-class#toggle\" } %>\n      <span class=\"switch__btn round\"></span>\n      <span class=\"for-screen-reader\">Give everyone access to this board</span>\n    </label>\n  </li>\n\n  <input placeholder=\"Filter…\" class=\"input input--transparent full-width txt-small\" type=\"search\" autocorrect=\"off\" autocomplete=\"off\" data-1p-ignore=\"true\" data-filter-target=\"input\" data-action=\"input->filter#filter\">\n\n  <div class=\"toggler__visible-when-off\">\n    <div class=\"flex align-center justify-end gap-half\">\n      <%= button_tag \"Select all\", type: \"button\", class: \"btn btn--plain txt-x-small txt-link font-weight-normal\", data: { action: \"click->toggle-class#checkAll\" }, disabled: %>\n      <span class=\"txt-subtle\">&middot;</span>\n      <%= button_tag \"Select none\", type: \"button\", class: \"btn btn--plain txt-x-small txt-link font-weight-normal\", data: { action: \"click->toggle-class#checkNone\" }, disabled: %>\n    </div>\n  </div>\n\n  <ul class=\"settings__scrollable-list\" data-filter-target=\"list\">\n    <%= access_toggles_for selected_users, selected: true, disabled: %>\n    <%= access_toggles_for unselected_users, selected: false, disabled: %>\n  </ul>\n<% end %>\n"
  },
  {
    "path": "app/views/boards/edit.html.erb",
    "content": "<% @page_title = \"Board Settings\" %>\n\n<% content_for :header do %>\n  <div class=\"header__actions header__actions--start\">\n    <%= link_back_to_board(@board) %>\n  </div>\n\n  <h1 class=\"header__title\" data-bridge--title-target=\"header\">\n    <div><%= @page_title %></div>\n    <% unless Current.user.can_administer_board?(@board) %>\n      <div class=\"txt-normal font-weight-normal\">Only admins can change these settings</div>\n    <% end %>\n  </h1>\n<% end %>\n\n<section class=\"settings\">\n  <div class=\"settings__panel settings__panel--users panel shadow\">\n    <%= form_with model: @board, class: \"display-contents\", data: {\n          controller: \"form boards-form bridge--form\",\n          boards_form_self_removal_prompt_message_value: \"Are you sure you want to remove yourself from this board? You won’t be able to get back in unless someone invites you.\",\n          action: \"turbo:submit-start->boards-form#submitWithWarning\" } do |form| %>\n      <%= render \"boards/edit/name\", form: form, board: @board %>\n      <%= render \"boards/edit/users\", board: @board, selected_users: @selected_users, unselected_users: @unselected_users, form: form %>\n\n      <button type=\"submit\" id=\"log_in\" class=\"btn btn--link center txt-normal\" data-bridge--form-target=\"submit\" <%= \"disabled\" unless Current.user.can_administer_board?(@board) %>>\n        <span>Save changes</span>\n      </button>\n\n      <%= link_to \"Cancel and go back\", @board, data: { form_target: \"cancel\", turbo_frame: \"_top\" }, hidden: true %>\n    <% end %>\n  </div>\n\n  <div class=\"settings__panel panel shadow\">\n    <%= render \"boards/edit/auto_close\", board: @board %>\n    <%= render \"boards/edit/publication\", board: @board %>\n    <%= render \"boards/edit/delete\", board: @board if Current.user.can_administer_board?(@board) %>\n  </div>\n</section>\n"
  },
  {
    "path": "app/views/boards/entropies/update.turbo_stream.erb",
    "content": "<%= turbo_stream.replace([ @board, :entropy ], partial: \"boards/edit/auto_close\", locals:{ board: @board }) %>\n<%= turbo_stream_flash(notice: \"Saved\") %>\n"
  },
  {
    "path": "app/views/boards/index.json.jbuilder",
    "content": "json.array! @page.records, partial: \"boards/board\", as: :board\n"
  },
  {
    "path": "app/views/boards/involvements/update.html.erb",
    "content": "<%= access_involvement_advance_button(@board, Current.user, show_watchers: params[:show_watchers] == \"true\", icon_only: params[:icon_only] == \"true\") %>\n"
  },
  {
    "path": "app/views/boards/new.html.erb",
    "content": "<% @page_title = \"Create a new board\" %>\n<% @body_class = \"compact-on-touch\" %>\n\n<div class=\"panel panel--centered\">\n  <%= bridged_form_with model: @board, class: \"flex flex-column gap\", data: { controller: \"form\", action: \"submit->form#preventEmptySubmit\" } do |form| %>\n    <h1 class=\"txt-x-large margin-none font-weight-black\"><%= @page_title %></h1>\n    <%= form.text_field :name, required: true, class: \"input full-width\", autofocus: true, autocomplete: \"off\", placeholder: \"Name it…\", data: { form_target: \"input\", action: \"keydown.esc@document->form#cancel\", validation_message: \"Board names can’t be blank\" } %>\n\n    <button type=\"submit\" class=\"btn btn--link center\" data-bridge--form-target=\"submit\">\n      <span>Create board</span>\n      <%= icon_tag \"arrow-right\" %>\n    </button>\n\n    <%= link_to \"Cancel and go back\", root_path, data: { form_target: \"cancel\", turbo_frame: \"_top\" }, hidden: true %>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/boards/publications/create.turbo_stream.erb",
    "content": "<%= turbo_stream.replace([ @board, :publication ], partial: \"boards/edit/publication\", locals:{ board: @board }) %>\n<%= turbo_stream_flash(notice: \"Saved\") %>\n"
  },
  {
    "path": "app/views/boards/publications/destroy.turbo_stream.erb",
    "content": "<%= turbo_stream.replace([ @board, :publication ], partial: \"boards/edit/publication\", locals:{ board: @board }) %>\n<%= turbo_stream_flash(notice: \"Saved\") %>\n"
  },
  {
    "path": "app/views/boards/show/_closed.html.erb",
    "content": "<%= column_tag id: \"closed-cards\", name: \"Done\", drop_url: columns_card_drops_closure_path(\"__id__\"), class: \"cards--closed\", style: \"--card-color: var(--color-card-complete);\",\n  data: {\n    card_hotkeys_disabled: true,\n    drag_and_strum_target: \"container\",\n    collapsible_columns_target: \"column\",\n    action: \"focus->collapsible-columns#focusOnColumn\"\n  } do %>\n  <header class=\"cards__header\">\n    <%= render \"boards/show/expander\", title: \"Done\", count: board.cards.closed.count, column_id: \"closed-cards\" %>\n    <%= render \"boards/show/menu/maximize\", column_path: board_columns_closed_path(board) %>\n  </header>\n  <%= column_frame_tag :closed_column, src: board_columns_closed_path(board) %>\n<% end %>\n"
  },
  {
    "path": "app/views/boards/show/_column.html.erb",
    "content": "<%= column_tag id: dom_id(column), name: column.name, drop_url: columns_card_drops_column_path(\"__id__\", column_id: column.id), card_color: column.color.to_s,\n      class: \"cards--doing\", style: \"--card-color: #{column.color};\",\n  data: {\n    drag_and_strum_target: \"container\",\n    collapsible_columns_target: \"column\",\n    controller: \"clicker\",\n    action: \"turbo:before-stream-render@document->collapsible-columns#restoreState focus->collapsible-columns#focusOnColumn dialog:show->collapsible-columns#frameColumnOnMobile\"\n  } do %>\n  <header class=\"cards__header\">\n    <%= render \"boards/show/menu/column\", column: column %>\n    <%= render \"boards/show/expander\", title: column.name, count: column.cards.active.count, column_id: dom_id(column) %>\n\n    <%= link_to board_column_path(column.board, column), class: \"cards__maximize-button btn btn--circle txt-x-small borderless\", data: { turbo_frame: \"_top\" } do %>\n      <%= icon_tag \"grid\", class: \"translucent\" %>\n      <span class=\"for-screen-reader\">Maximize column</span>\n    <% end %>\n  </header>\n  <%= column_frame_tag dom_id(column, :cards), src: board_column_path(column.board, column) %>\n<% end %>\n"
  },
  {
    "path": "app/views/boards/show/_columns.html.erb",
    "content": "<%= tag.div class: \"card-columns hide-scrollbar\", data: {\n      controller: \"collapsible-columns drag-and-drop drag-and-strum navigable-list card-hotkeys\",\n      drag_and_drop_dragged_item_class: \"drag-and-drop__dragged-item\",\n      drag_and_drop_hover_container_class: \"drag-and-drop__hover-container\",\n      collapsible_columns_board_value: board.id,\n      collapsible_columns_collapsed_class: \"is-collapsed\",\n      collapsible_columns_expanded_class: \"is-expanded\",\n      collapsible_columns_no_transitions_class: \"no-transitions\",\n      collapsible_columns_title_not_visible_class: \"is-off-screen\",\n      navigable_list_supports_vertical_navigation_value: false,\n      navigable_list_has_nested_navigation_value: true,\n      navigable_list_prevent_handled_keys_value: true,\n      navigable_list_auto_select_value: false,\n      navigable_list_auto_scroll_value: false,\n      card_hotkeys_navigable_list_outlet: \".cards__transition-container\",\n      native_prevent_pull_to_refresh: true,\n      action: \"\n        keydown->navigable-list#navigate\n        keydown->card-hotkeys#handleKeydown\n        turbo:morph@document->card-hotkeys#handleMorphComplete\n        dragstart->drag-and-drop#dragStart\n        dragover->drag-and-drop#dragOver\n        dragenter->drag-and-strum#dragEnter\n        drop->drag-and-drop#drop\n        dragend->drag-and-drop#dragEnd\n        click@document->navigable-list#deselectWhenClickingOutside\" } do %>\n  <div class=\"card-columns__left\">\n    <%= render \"boards/show/not_now\", board: board %>\n  </div>\n\n  <%= render \"boards/show/stream\", board: board, page: page %>\n\n  <div class=\"card-columns__right\">\n    <%= render partial: \"boards/show/column\", collection: board.columns.sorted, cached: ->(column){ [ column, column.leftmost?, column.rightmost? ] } %>\n    <%= render \"boards/show/closed\", board: board %>\n\n    <%= render \"boards/show/menu/columns\", board: board %>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/boards/show/_expander.html.erb",
    "content": "<button class=\"cards__expander btn btn--plain\" data-collapsible-columns-target=\"button\" data-css-variable-counter-target=\"counter\" data-action=\"click->collapsible-columns#toggle click->navigable-list#selectCurrentOrReset\"\n  style=\"--card-count: <%= [ count, 15 ].min %>\" aria-controls=\"<%= column_id %>\" aria-expanded=\"false\">\n  <span class=\"cards__expander-count\" data-drag-and-drop-counter=\"true\"><%= count > 99 ? \"99+\" : count %></span>\n  <h2 class=\"cards__expander-title\" data-collapsible-columns-target=\"title\">\n    <%= title %>\n    <%= icon_tag \"collapse\" %>\n  </h2>\n</button>\n<%= yield if block_given? %>\n"
  },
  {
    "path": "app/views/boards/show/_filtered_cards.html.erb",
    "content": "<section class=\"cards cards--grid\">\n  <div class=\"cards__list hide-scrollbar\">\n    <%= with_automatic_pagination :filtered_cards_paginated_container, page do %>\n      <%= render \"cards/display/previews\", cards: page.records, draggable: true %>\n    <% end %>\n    <div class=\"blank-slate\">No cards match this filter</div>\n  </div>\n</section>\n"
  },
  {
    "path": "app/views/boards/show/_not_now.html.erb",
    "content": "<%= column_tag id: \"not-now\", name: \"Not Now\", drop_url: columns_card_drops_not_now_path(\"__id__\"), class: \"cards--on-deck\", style: \"--card-color: var(--color-card-complete);\",\n  data: {\n    card_hotkeys_disabled: true,\n    collapsible_columns_target: \"column\",\n    drag_and_strum_target: \"container\",\n    action: \"focus->collapsible-columns#focusOnColumn\"\n  } do %>\n  <header class=\"cards__header\">\n    <%= render \"boards/show/expander\", title: \"Not Now\", count: board.cards.postponed.count, column_id: \"not-now\" %>\n    <%= render \"boards/show/menu/maximize\", column_path: board_columns_not_now_path(board) %>\n  </header>\n  <%= column_frame_tag :not_now_column, src: board_columns_not_now_path(board) %>\n<% end %>\n"
  },
  {
    "path": "app/views/boards/show/_stream.html.erb",
    "content": "<%= column_tag id: \"maybe\", name: \"Maybe?\", drop_url: columns_card_drops_stream_path(\"__id__\"), collapsed: false, selected: \"true\", class: \"cards--maybe\", data: {\n  drag_and_strum_target: \"container\",\n  collapsible_columns_target: \"column maybeColumn\",\n  action: \"focus->collapsible-columns#focusOnColumn\"\n} do %>\n  <header class=\"cards__header\">\n    <%= render \"boards/show/expander\", title: \"Maybe?\", count: board.cards.awaiting_triage.count, column_id: \"maybe\" %>\n    <%= render \"boards/show/menu/maximize\", column_path: board_columns_stream_path(board) %>\n  </header>\n  <div data-bridge-disabled=\"true\">\n    <%= render \"columns/show/add_card_button\", board: board %>\n  </div>\n  <div class=\"cards__list hide-scrollbar\" data-drag-drop-item-container>\n    <% if page.used? %>\n      <%= with_automatic_pagination \"maybe\", page do %>\n        <%= render \"cards/display/previews\", cards: page.records, draggable: true %>\n      <% end %>\n    <% end %>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/boards/show/menu/_column.html.erb",
    "content": "<div id=\"<%= dom_id(column, :menu) %>\" class=\"cards__menu\" data-controller=\"dialog\" data-dialog-orient-value=\"false\" data-action=\"keydown.esc->dialog#close click@document->dialog#closeOnClickOutside\">\n  <button class=\"btn btn--circle txt-x-small borderless\" data-action=\"click->dialog#open\">\n    <%= icon_tag \"menu-dots-horizontal\", class: \"translucent\" %>\n    <span class=\"for-screen-reader\">Column options</span>\n  </button>\n  <dialog class=\"popup popup--align-right panel flex-column gap fill-white shadow txt-small margin-block-double\" data-dialog-target=\"dialog\">\n    <ul class=\"popup__list\">\n      <li class=\"popup__item\">\n        <button class=\"popup__btn btn\" data-action=\"click->clicker#click\">\n          <%= icon_tag \"pencil\" %>\n          <span>Edit column</span>\n        </button>\n      </li>\n\n      <li class=\"popup__item\">\n        <%= button_to column_left_position_path(column),\n              class: \"popup__btn btn\",\n              form_class: \"display-contents\",\n              disabled: column.leftmost? do %>\n          <%= icon_tag \"column-left\" %>\n          <span>Move to the left</span>\n        <% end %>\n      </li>\n\n      <li class=\"popup__item\">\n        <%= button_to column_right_position_path(column),\n              class: \"popup__btn btn\",\n              form_class: \"display-contents\",\n              disabled: column.rightmost? do %>\n          <%= icon_tag \"column-right\" %>\n          <span>Move to the right</span>\n        <% end %>\n      </li>\n\n      <li class=\"popup__item\">\n        <%= button_to board_column_path(column.board, column),\n              method: :delete,\n              class: \"popup__btn btn txt-negative\",\n                form_class: \"display-contents\",\n              form: { data: { turbo_confirm: \"Are you sure you want to delete this column? This will move the cards back to Maybe.\" } } do %>\n          <%= icon_tag \"trash\" %>\n          <span>Delete column</span>\n        <% end %>\n      </li>\n    </ul>\n  </dialog>\n\n  <div data-controller=\"dialog\" data-action=\"keydown.esc->dialog#close click@document->dialog#closeOnClickOutside\">\n    <button id=\"<%= dom_id(column, :manage_menu) %>\" class=\"popup__btn btn\" data-action=\"click->dialog#open:stop\" data-clicker-target=\"clickable\" hidden aria-hidden>Save changes</button>\n    <dialog class=\"popup panel flex-column gap-half fill-white shadow txt-small margin-block-double\" data-dialog-target=\"dialog\" data-action=\"turbo:before-morph-attribute->dialog#preventCloseOnMorphing\">\n      <%= render \"boards/show/menu/column_form\", board: column.board, column: column, label: \"Save changes\" %>\n    </dialog>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/boards/show/menu/_column_form.html.erb",
    "content": "<%= form_with model: [board, column], data: { controller: \"form\", action: \"turbo:submit-end->dialog#close turbo:submit-end->form#reset keydown->dialog#captureKey\" } do |form| %>\n  <%= form.text_field :name, class: \"input\", placeholder: \"Name this column\", value: column.name,\n        required: true, autocomplete: \"off\", pattern: \".*\\\\S.*\", title: \"Column name cannot be blank\", data: { action: \"focus->form#select\" } %>\n\n  <div class=\"color-picker__colors\">\n    <% Color::COLORS.each do |color| %>\n      <label class=\"btn txt-small borderless\" style=\"--btn-background: <%= color %>\" title=\"<%= color.name %>\">\n        <%= form.radio_button :color, color.value, checked: (column.color == color || (column.new_record? && color == Column::Colored::DEFAULT_COLOR)) %>\n        <%= icon_tag \"check\", class: \"checked\" %>\n        <span class=\"for-screen-reader\"><%= color.name %></span>\n      </label>\n    <% end %>\n  </div>\n\n  <%= form.submit label, class: \"btn btn--link\" %>\n<% end %>\n"
  },
  {
    "path": "app/views/boards/show/menu/_columns.html.erb",
    "content": "<div class=\"cards__new-column\" data-controller=\"dialog\" data-action=\"keydown.esc->dialog#close click@document->dialog#closeOnClickOutside\" data-turbo-permanent>\n  <button class=\"btn btn--circle btn--circle-mobile txt-x-small borderless\" data-controller=\"tooltip\" data-action=\"click->dialog#open:stop\">\n    <%= icon_tag \"add\", class: \"translucent\" %>\n    <span class=\"for-screen-reader\">Add a column</span>\n  </button>\n  <dialog class=\"popup panel flex-column gap-half fill-white shadow txt-small margin-block-double\" data-dialog-target=\"dialog\">\n    <%= render \"boards/show/menu/column_form\", board: board, column: Column.new, label: \"Add column\" %>\n  </dialog>\n</div>\n"
  },
  {
    "path": "app/views/boards/show/menu/_maximize.html.erb",
    "content": "<%= link_to column_path, class: \"cards__maximize-button btn btn--circle txt-x-small borderless\", data: { turbo_frame: \"_top\" } do %>\n  <%= icon_tag \"grid\", class: \"translucent\" %>\n  <span class=\"for-screen-reader\">Expand column</span>\n<% end %>\n"
  },
  {
    "path": "app/views/boards/show.html.erb",
    "content": "<% @page_title = @board.name %>\n<% @body_class = \"contained-scrolling\" %>\n<% turbo_exempts_page_from_cache %>\n\n<%= turbo_stream_from @board %>\n\n<% content_for :header do %>\n  <div class=\"header__actions header__actions--start hide-on-native\">\n    <%= link_to_webhooks(@board) if Current.user.admin? %>\n  </div>\n\n  <h1 class=\"header__title divider divider--fade full-width\" data-bridge--title-target=\"header\">\n    <span class=\"overflow-ellipsis\"><%= @board.name %></span>\n  </h1>\n\n  <div class=\"header__actions header__actions--end hide-on-native\">\n    <%= link_to_edit_board @board %>\n  </div>\n<% end %>\n\n<%= render \"filters/settings\", filter_url: board_path(@board), user_filtering: @user_filtering, no_filtering_url: board_path(@board) do |form| %>\n  <%= hidden_field_tag \"board_ids[]\", @board.id %>\n<% end %>\n\n<%= turbo_frame_tag :cards_container do %>\n  <% if @filter.used?(ignore_boards: true) %>\n    <%= render \"boards/show/filtered_cards\", page: @page %>\n  <% else %>\n    <%= render \"columns/show/add_card_button\", board: @board %>\n    <%= render \"boards/show/columns\", page: @page, board: @board %>\n  <% end %>\n<% end %>\n\n<%= bridged_share_url_button(bridge_share_board_description(@board)) %>\n"
  },
  {
    "path": "app/views/boards/show.json.jbuilder",
    "content": "json.partial! \"boards/board\", board: @board\n"
  },
  {
    "path": "app/views/cards/_broadcasts.html.erb",
    "content": "<% if filter.boards.any? %>\n  <% filter.boards.each do |board| %>\n    <%= turbo_stream_from board %>\n  <% end %>\n<% else %>\n  <%= turbo_stream_from [ Current.account, :all_boards ] %>\n<% end %>\n"
  },
  {
    "path": "app/views/cards/_card.json.jbuilder",
    "content": "json.cache! card do\n  json.(card, :id, :number, :title, :status)\n  json.description card.description.to_plain_text\n  json.description_html card.description.to_s\n  json.image_url card.image.presence && url_for(card.image)\n  json.has_attachments card.has_attachments?\n\n  json.tags card.tags.pluck(:title).sort\n\n  json.closed card.closed?\n  json.postponed card.postponed?\n  json.golden card.golden?\n  json.last_active_at card.last_active_at.utc\n  json.created_at card.created_at.utc\n\n  json.url card_url(card)\n\n  json.board card.board, partial: \"boards/board\", as: :board\n  json.column card.column, partial: \"columns/column\", as: :column if card.column\n  json.creator card.creator, partial: \"users/user\", as: :user\n  json.assignees card.assignees.limit(5), partial: \"users/user\", as: :user\n  json.has_more_assignees card.assignees.size > 5\n\n  json.comments_url card_comments_url(card)\n  json.reactions_url card_reactions_url(card)\nend\n"
  },
  {
    "path": "app/views/cards/_container.html.erb",
    "content": "<section id=\"<%= dom_id(card, :card_container) %>\" class=\"card-perma\" style=\"--card-color: <%= card.color %>;\" data-controller=\"dialog-manager bridge--form\">\n  <% cache card do %>\n    <div class=\"card-perma__actions card-perma__actions--left\">\n      <%= render \"cards/container/gild\", card: card if card.published? && !card.closed? %>\n      <%= render \"cards/container/image\", card: card %>\n    </div>\n\n    <div class=\"card-perma__bg\">\n      <%= card_article_tag card, class: \"card\" do %>\n        <header class=\"card__header\">\n          <%= render \"cards/display/perma/board\", card: card %>\n          <%= render \"cards/display/perma/tags\", card: card %>\n        </header>\n\n        <div class=\"card__body justify-space-between\">\n          <div class=\"card__content\">\n            <%= render \"cards/container/content\", card: card %>\n            <%= render \"cards/display/perma/steps\", card: card %>\n          </div>\n\n          <% if card.published? %>\n            <%= render \"cards/triage/columns\", card: card %>\n          <% end %>\n\n          <%= render \"cards/display/common/stamp\", card: card %>\n        </div>\n\n        <footer class=\"card__footer\">\n          <%= render \"cards/display/perma/meta\", card: card %>\n          <%= render \"cards/display/perma/background\", card: card %>\n          <%= render \"reactions/reactions\", reactable: card %>\n        </footer>\n      <% end %>\n\n      <% if card.entropic? %>\n        <%= render \"cards/display/preview/bubble\", card: card %>\n      <% end %>\n    </div>\n  <% end %>\n\n  <% if card.published? %>\n    <%= render \"cards/container/footer/published\", card: card %>\n  <% end %>\n</section>\n"
  },
  {
    "path": "app/views/cards/_delete.html.erb",
    "content": "<div data-controller=\"dialog\" data-dialog-modal-value=\"true\" data-action=\"keydown.esc->dialog#close:stop\">\n  <button type=\"button\" class=\"btn txt-negative borderless txt-small\" data-action=\"dialog#open\">\n    <%= icon_tag \"trash\" %>\n    <span>Delete this card</span>\n  </button>\n  <dialog class=\"dialog panel fill-white shadow gap flex-column\" style=\"--panel-size: 40ch\" data-dialog-target=\"dialog\">\n    <h3 class=\"txt-large txt-bold\">Delete this card?</h3>\n    <p class=\"txt-medium margin-block-half\">Are you sure you want to permanently delete this card?</p>\n    <div class=\"flex gap-half justify-center margin-block-start\">\n      <button type=\"button\" class=\"btn\" data-action=\"dialog#close\">Cancel</button>\n      <%= button_to card_path(card), method: :delete, class: \"btn txt-negative\", data: { turbo_frame: \"_top\" } do %>\n        <span>Delete card</span>\n      <% end %>\n    </div>\n  </dialog>\n</div>\n"
  },
  {
    "path": "app/views/cards/_messages.html.erb",
    "content": "<%= messages_tag(card) do %>\n  <% if card.published? %>\n    <%= render partial: \"cards/comments/comment\", collection: card.comments.preloaded.chronologically, cached: true %>\n    <% if Fizzy.saas? %>\n      <%= render \"cards/comments/saas/new\", card: card %>\n    <% else %>\n      <%= render \"cards/comments/new\", card: card %>\n    <% end %>\n\n    <%= render \"cards/comments/watchers\", card: card %>\n  <% end %>\n\n  <% if Current.user.can_administer_card?(card) %>\n    <footer class=\"delete-card txt-align-center full-width flex flex-column margin-block-start-double\">\n      <hr class=\"separator--horizontal full-width margin-block\" style=\"--border-color: var(--color-ink-lighter)\">\n      <%= render \"cards/delete\", card: card %>\n    </footer>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/cards/assignments/_user.html.erb",
    "content": "<li class=\"popup__item\" data-filter-target=\"item\" data-navigable-list-target=\"item\" <%= tag.attributes(data: local_assigns.fetch(:data, {})) %> aria-checked=\"<%= card.assignees.include?(user) %>\">\n  <%= button_to card_assignments_path(card, params: { assignee_id: user.id }), method: :post,\n        class: \"popup__btn btn\", form_class: \"max-width flex-item-grow\" do %>\n    <span class=\"overflow-ellipsis flex-item-grow\"><%= local_assigns.fetch(:user_label, user.name) %></span>\n    <%= yield if block_given? %>\n    <%= icon_tag \"check\", size: 18, class: \"checked flex-item-no-shrink flex-item-justify-end\", style: \"--icon-size: 1em\" %>\n  <% end %>\n</li>\n"
  },
  {
    "path": "app/views/cards/assignments/create.turbo_stream.erb",
    "content": "<%= turbo_stream.replace([ @card, :meta ], partial: \"/cards/display/perma/meta\", method: \"morph\", locals: { card: @card.reload }) %>\n"
  },
  {
    "path": "app/views/cards/assignments/new.html.erb",
    "content": "<%= turbo_frame_tag @card, :assignment do %>\n  <%= tag.div class: \"max-width full-width\", data: {\n\t\t\t\taction: \"turbo:before-cache@document->dialog#close dialog:show@document->navigable-list#reset keydown->navigable-list#navigate filter:changed->navigable-list#reset\",\n\t\t\t\tcontroller: \"filter navigable-list assignment-limit\",\n\t\t\t\tdialog_target: \"dialog\",\n\t\t\t\tnavigable_list_focus_on_selection_value: false,\n\t\t\t\tnavigable_list_actionable_items_value: true,\n\t\t\t\tassignment_limit_limit_value: Assignment::LIMIT,\n\t\t\t\tassignment_limit_count_value: @card.assignments.count } do %>\n\n    <div class=\"flex align-start justify-space-between\">\n      <strong class=\"popup__title\">Assign this to…</strong>\n      <kbd class=\"txt-xx-small hide-on-touch\">a</kbd>\n    </div>\n\n    <%= text_field_tag :search, nil, placeholder: \"Filter…\", class: \"input input--transparent txt-small margin-block-half\", autofocus: true,\n          type: \"search\", autocorrect: \"off\", autocomplete: \"off\", data: { \"1p-ignore\": \"true\", filter_target: \"input\", action: \"input->filter#filter\" } %>\n\n    <ul class=\"popup__list\" data-filter-target=\"list\">\n      <%= render \"user\", card: @card, user: Current.user, user_label: \"Me\" do %>\n        <span class=\"visually-hidden\"><%= Current.user.name %></span>\n        <kbd class=\"txt-xx-small hide-on-touch\">m</kbd>\n      <% end %>\n      <%= render collection: @assigned_to, partial: \"user\", locals: { card: @card } %>\n      <% @users.each do |user| %>\n        <%= render \"user\", card: @card, user: user, data: { assignment_limit_target: \"unassigned\" } %>\n      <% end %>\n    </ul>\n\n    <div class=\"popup__footer\" hidden data-assignment-limit-target=\"limitMessage\">\n      Maximum <%= Assignment::LIMIT %> assignees\n    </div>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/cards/boards/edit.html.erb",
    "content": "<%= turbo_frame_tag \"board_picker\" do %>\n  <%= tag.div class: \"max-width full-width\", data: {\n        action: \"turbo:before-cache@document->dialog#close dialog:show@document->navigable-list#reset keydown->navigable-list#navigate filter:changed->navigable-list#reset\",\n        controller: \"filter navigable-list\",\n        dialog_target: \"dialog\",\n        navigable_list_focus_on_selection_value: false,\n        navigable_list_actionable_items_value: true } do %>\n    <%= filter_title \"Move this card to…\" %>\n\n    <%= text_field_tag :search, nil, placeholder: \"Filter…\", class: \"input input--transparent txt-small margin-block-half font-weight-normal\", autofocus: true,\n          type: \"search\", autocorrect: \"off\", autocomplete: \"off\", data: { \"1p-ignore\": \"true\", filter_target: \"input\", dialog_target: \"focusMouse\", action: \"input->filter#filter\" } %>\n\n    <ul class=\"popup__list margin-block-start-half margin-none-block-end\" data-filter-target=\"list\">\n      <% @boards.each do |board| %>\n        <li class=\"popup__item\" data-filter-target=\"item\" data-navigable-list-target=\"item\" aria-checked=\"<%= @card.board == board %>\">\n          <%= button_to card_board_path(@card, params: { board_id: board.id }), method: :patch,\n                class: \"popup__btn btn\",\n                form_class: \"max-width flex-item-grow\" do %>\n            <span class=\"overflow-ellipsis flex-item-grow\"><%= board.name %></span>\n            <%= icon_tag \"check\", size: 18, class: \"checked flex-item-no-shrink flex-item-justify-end\", style: \"--icon-size: 1em\" %>\n          <% end %>\n        </li>\n      <% end %>\n    </ul>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/cards/closures/create.turbo_stream.erb",
    "content": "<%= turbo_stream.replace(\"closed-cards\", partial: \"boards/show/closed\", method: :morph, locals: { board: @card.board }) %>\n\n<% if @source_column %>\n  <%= turbo_stream.replace(dom_id(@source_column), partial: \"boards/show/column\", method: :morph, locals: { column: @source_column }) %>\n<% elsif @was_in_stream %>\n  <%= turbo_stream.replace(\"maybe\", partial: \"boards/show/stream\", method: :morph, locals: { board: @card.board, page: @page }) %>\n<% end %>\n\n<%= turbo_stream.replace([ @card, :card_container ], partial: \"cards/container\", method: :morph, locals: { card: @card.reload }) %>\n"
  },
  {
    "path": "app/views/cards/closures/destroy.turbo_stream.erb",
    "content": "<%= turbo_stream.replace(\"closed-cards\", partial: \"boards/show/closed\", method: :morph, locals: { board: @card.board }) %>\n\n<% if @card.column %>\n  <%= turbo_stream.replace(dom_id(@card.column), partial: \"boards/show/column\", method: :morph, locals: { column: @card.column }) %>\n<% elsif @card.awaiting_triage? %>\n  <%= turbo_stream.replace(\"maybe\", partial: \"boards/show/stream\", method: :morph, locals: { board: @card.board, page: @page }) %>\n<% end %>\n\n<%= turbo_stream.replace([ @card, :card_container ], partial: \"cards/container\", method: :morph, locals: { card: @card.reload }) %>\n"
  },
  {
    "path": "app/views/cards/columns/_column.html.erb",
    "content": "<%= render \"cards/display/previews\", cards: column.cards, draggable: draggable %>\n"
  },
  {
    "path": "app/views/cards/columns/edit.html.erb",
    "content": "<%= turbo_frame_tag @card, :columns do %>\n  <div class=\"card__stages\" role=\"radiogroup\" data-controller=\"scroll-to\">\n    <legend class=\"for-screen-reader\">Choose a column for this card</legend>\n\n    <%= button_to \"Not now\", card_not_now_path(@card),\n        class: [ \"card__column-name btn\", { \"card__column-name--current\": @card.postponed? } ],\n        style: \"--column-color: var(--color-card-complete)\",\n        disabled: @card.postponed?,\n        role: \"radio\",\n        aria: { checked: @card.postponed? },\n        data: { scroll_to_target: @card.postponed? ? \"target\" : nil },\n        form_class: \"flex gap-half\" %>\n\n    <%= button_to \"Maybe?\", card_triage_path(@card), method: :delete,\n        class: [ \"card__column-name card__column-name--stream btn\", { \"card__column-name--current\": @card.awaiting_triage? } ],\n        style: \"--column-color: var(--color-card-default)\",\n        disabled: @card.awaiting_triage?,\n        role: \"radio\",\n        aria: { checked: @card.awaiting_triage? },\n        data: { scroll_to_target: @card.awaiting_triage? ? \"target\" : nil },\n        form_class: \"flex gap-half\" %>\n\n    <% @columns.each do |column| %>\n      <%= button_to_set_column @card, column %>\n    <% end %>\n\n    <%= button_to card_closure_path(@card),\n        class: [ \"card__column-name btn\", { \"card__column-name--current\": @card.closed? } ],\n        style: \"--column-color: var(--color-card-complete)\",\n        disabled: @card.closed?,\n        role: \"radio\",\n        aria: { checked: @card.closed? },\n        data: { scroll_to_target: @card.closed? ? \"target\" : nil },\n        form_class: \"flex gap-half\" do %>\n          <%= icon_tag \"check\", class: \"icon--mobile-only\" %>\n          Done\n    <% end %>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/cards/comments/_comment.html.erb",
    "content": "<% cache comment do %>\n  <%# Helper Dependency Updated: avatar_image_tag 2025-12-15 %>\n  <%= turbo_frame_tag comment, :container, class: { \"comment-by-system\": comment.creator.system? } do %>\n    <%# Cache bump 2025-12-14: action text attachment rendering changed for lightbox -%>\n    <div id=\"<%= dom_id(comment) %>\" data-creator-id=\"<%= comment.creator_id %>\" class=\"comment align-start full-width\">\n      <figure class=\"comment__avatar flex-item-no-shrink\" aria-hidden=\"true\">\n        <%= avatar_tag comment.creator, hidden_for_screen_reader: true %>\n      </figure>\n\n      <div class=\"comment__content flex flex-column flex-item-grow full-width\">\n        <div class=\"comment__author flex align-center gap-half\">\n          <h3 class=\"font-weight-normal txt-normal flex-item-justify-start min-width overflow-ellipsis\">\n            <strong>\n              <%= link_to comment.creator.name, comment.creator, class: \"txt-ink btn btn--plain fill-transparent\", data: { turbo_frame: \"_top\" } %>\n            </strong>\n\n            <%= link_to comment, class: \"comment__permalink-title\", data: { turbo_frame: \"_top\" } do %>\n              <%= local_datetime_tag comment.created_at, style: :agoorweekday %>,\n              <%= local_datetime_tag comment.created_at, style: :time %>\n            <% end %>\n          </h3>\n\n          <button class=\"comment__history btn btn--circle-mobile borderless txt-x-small\" data-action=\"toggle-class#toggle\">\n            <%= icon_tag \"history\", class: \"icon--mobile-only\" %>\n            <span>Full history</span>\n          </button>\n\n          <%= link_to edit_card_comment_path(comment.card, comment),\n                class: \"comment__edit btn btn--circle borderless translucent\", data: { only_visible_to_you: true } do %>\n            <%= icon_tag \"menu-dots-horizontal\" %>\n            <span class=\"for-screen-reader\">Edit this comment</span>\n          <% end %>\n        </div>\n\n        <div class=\"comment__body lexxy-content\" data-controller=\"syntax-highlight retarget-links\" data-turbo-permanent>\n          <%= comment.body %>\n        </div>\n\n        <%= render \"reactions/reactions\", reactable: comment %>\n      </div>\n    </div>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/cards/comments/_comment.json.jbuilder",
    "content": "json.cache! comment do\n  json.(comment, :id)\n\n  json.created_at comment.created_at.utc\n  json.updated_at comment.updated_at.utc\n\n  json.body do\n    json.plain_text comment.body.to_plain_text\n    json.html comment.body.to_s\n  end\n\n  json.creator comment.creator, partial: \"users/user\", as: :user\n\n  json.card do\n    json.id comment.card_id\n    json.url card_url(comment.card)\n  end\n\n  json.reactions_url card_comment_reactions_url(comment.card, comment)\n  json.url card_comment_url(comment.card, comment)\nend\n"
  },
  {
    "path": "app/views/cards/comments/_new.html.erb",
    "content": "<div id=\"<%= dom_id(card, :new_comment) %>\" class=\"comment comment--new flex-inline align-start full-width\">\n  <figure class=\"comment__avatar flex-item-no-shrink\" aria-hidden=\"true\">\n    <%= avatar_tag Current.user, hidden_for_screen_reader: true %>\n  </figure>\n\n  <div class=\"comment__content flex-inline flex-column full-width\">\n    <div class=\"comment__body\" data-turbo-permanent>\n      <%= form_with model: Comment.new, url: card_comments_path(card), class: \"flex flex-column gap full-width\",\n            data: { controller: \"form local-save\",\n                  local_save_key_value: \"comment-#{card.id}\",\n                  action: \"turbo:submit-end->local-save#submit turbo:submit-end->form#blurActiveInput keydown.ctrl+enter->form#debouncedSubmit:prevent keydown.meta+enter->form#debouncedSubmit:prevent keydown.esc->form#cancel:stop\" } do |form| %>\n        <%= form.rich_textarea :body, required: true, placeholder: new_comment_placeholder(card),\n              data: { local_save_target: \"input\", action: \"lexxy:change->form#disableSubmitWhenInvalid lexxy:change->local-save#save turbo:morph-element->local-save#restoreContent\" } do %>\n          <%= general_prompts(@card.board) %>\n        <% end %>\n\n        <span class=\"flex-inline align-center gap\">\n          <%= form.button class: \"comment__submit btn btn--reversed\",\n              title: \"Post this comment (#{ hotkey_label([\"ctrl\", \"enter\"]) })\",\n              data: { form_target: \"submit\" }, disabled: true do %>\n            <span>Post</span>\n          <% end %>\n        </span>\n      <% end %>\n    </div>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/cards/comments/_watchers.html.erb",
    "content": "<div id=\"<%= dom_id(card, :comment_watchers) %>\" class=\"comments__subscribers flex flex-column margin-block-start txt-align-start full-width\">\n  <strong class=\"txt-uppercase\">Subscribers</strong>\n\n  <p class=\"margin-none-block-start margin-block-end-half\">\n    <%= pluralize(card.watchers.active.count, \"person\") %> will be notified when someone comments on this.\n  </p>\n\n  <div class=\"flex align-center flex-wrap gap-half max-width txt-normal\">\n    <% card.watchers.active.alphabetically.each do |watcher| %>\n      <%= avatar_tag watcher %>\n    <% end %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/cards/comments/create.turbo_stream.erb",
    "content": "<%= turbo_stream.before [ @card, :new_comment ], partial: \"cards/comments/comment\", locals: { comment: @comment } %>\n\n<%= turbo_stream.update [ @card, :new_comment ], partial: \"cards/comments/new\", locals: { card: @card } %>\n"
  },
  {
    "path": "app/views/cards/comments/destroy.turbo_stream.erb",
    "content": "<%= turbo_stream.remove [ @comment, :container ] %>\n"
  },
  {
    "path": "app/views/cards/comments/edit.html.erb",
    "content": "<%= turbo_frame_tag @comment, :container do %>\n  <div id=\"<%= dom_id(@comment) %>\" class=\"comment flex align-start full-width\">\n    <figure class=\"comment__avatar flex-item-no-shrink\" aria-hidden=\"true\">\n      <%= avatar_tag @comment.creator, hidden_for_screen_reader: true %>\n    </figure>\n\n    <div class=\"comment__content flex flex-column flex-item-grow full-width\">\n      <div class=\"comment__body\" data-turbo-permanent>\n        <%= form_with model: [ @card, @comment ], class: \"flex flex-column gap full-width\",\n              data: { controller: \"form\", action: \"keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop\" } do |form| %>\n          <%= form.rich_textarea :body, required: true, autofocus: true, placeholder: new_comment_placeholder(@card) do %>\n            <%= general_prompts(@card.board) %>\n          <% end %>\n          <div class=\"flex gap-half justify-start align-center\">\n            <%= form.button class: \"btn btn--reversed\", type: :submit, title: \"Save changes (#{ hotkey_label([\"ctrl\", \"enter\"]) })\" do %>\n              <span>Save</span>\n            <% end %>\n            <%= link_to card_comment_path(@card, @comment), class: \"btn\", data: { form_target: \"cancel\" },title: \"Cancel (#{ hotkey_label([\"esc\"]) })\" do %>\n              <span>Cancel</span>\n            <% end %>\n\n            <%= tag.button type: :submit, class: \"btn btn--negative flex-item-justify-end\", form: dom_id(@comment, :delete_form),\n                  data: { turbo_confirm: \"Are you sure you want to delete this comment?\" } do %>\n              <%= icon_tag \"trash\" %>\n              <span class=\"for-screen-reader\">Delete</span>\n            <% end %>\n          </div>\n        <% end %>\n\n        <%= form_with url: card_comment_path(@card, @comment), method: :delete, id: dom_id(@comment, :delete_form), data: { turbo_frame: \"_top\" } %>\n      </div>\n    </div>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/cards/comments/index.json.jbuilder",
    "content": "json.array! @page.records, partial: \"cards/comments/comment\", as: :comment\n"
  },
  {
    "path": "app/views/cards/comments/show.html.erb",
    "content": "<%= render \"cards/comments/comment\", comment: @comment %>\n"
  },
  {
    "path": "app/views/cards/comments/show.json.jbuilder",
    "content": "json.partial! \"cards/comments/comment\", comment: @comment\n"
  },
  {
    "path": "app/views/cards/comments/update.turbo_stream.erb",
    "content": "<%= turbo_stream.replace [ @comment, :container ], partial: \"cards/comments/comment\", locals: { comment: @comment } %>\n"
  },
  {
    "path": "app/views/cards/container/_closure.html.erb",
    "content": "<div class=\"display-contents\" id=\"<%= dom_id(card, :card_closure_toggle) %>\">\n  <% if card.closed? %>\n    <div class=\"card-perma__closure-message flex gap-half justify-center\">\n      <span>Completed by <%= card.closed_by.name %> on <%= local_datetime_tag(card.closed_at, style: :shortdate) %>.</span>\n      <%= button_to card_closure_path(card), method: :delete, class: \"btn btn--plain borderless fill-transparent\" do %>\n        <span class=\"pad-inline-end\">Undo</span>\n      <% end %>\n      <%= bridged_button_to_board(card.board) %>\n    </div>\n  <% else %>\n    <div class=\"card-perma__notch card-perma__notch--bottom\">\n      <%= render \"cards/container/closure_buttons\", card: card %>\n    </div>\n    <% if card.entropic? && card.open? && !card.postponed? %>\n      <div class=\"card-perma__closure-message\" id=\"<%= dom_id(card, :closure_notice) %>\">\n        Moves to “Not Now” <%= local_datetime_tag(card.entropy.auto_clean_at, style: :indays) -%> if there’s no activity.\n      </div>\n    <% end %>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/cards/container/_closure_buttons.html.erb",
    "content": "<div class=\"flex gap-half\">\n  <%= link_to edit_card_path(card), class: \"btn btn--circle-mobile borderless\",\n        data: {\n          controller: \"hotkey\",\n          action: \"keydown.e@document->hotkey#click\",\n          bridge__overflow_menu_target: \"item\",\n          bridge_title: \"Edit\",\n          turbo_frame: dom_id(card, :edit)\n        } do %>\n    <%= icon_tag \"pencil\", class: \"icon--mobile-only\" %>\n    <span>Edit</span>\n    <kbd class=\"txt-x-small hide-on-touch\">e</kbd>\n  <% end %>\n\n  <%= button_to card_closure_path(card), class: \"btn btn--circle-mobile borderless hide-on-native\",\n        data: {\n          controller: \"hotkey\",\n          form_target: \"submit\",\n          bridge__buttons_target: (\"button\" unless card.postponed?),\n          bridge_title: \"Mark done\",\n          bridge_display_as_primary_action: true,\n          bridge_display_title: true, bridge_icon_url: bridge_icon(\"check\"),\n          action: \"keydown.d@document->hotkey#click\"\n        },\n        form: { data: { controller: \"form\" } } do %>\n    <%= icon_tag \"check\", class: \"icon--mobile-only\" %>\n    <span class=\"overflow-ellipsis\">Mark as Done</span>\n    <kbd class=\"txt-x-small hide-on-touch\">d</kbd>\n  <% end %>\n\n  <%= bridged_button_to_board(card.board) %>\n</div>\n"
  },
  {
    "path": "app/views/cards/container/_content.html.erb",
    "content": "<% if card.published? %>\n  <div data-turbo-permanent>\n  <%= turbo_frame_tag card, :edit do %>\n    <%# When canceling an edit (with the ESC key), restore the button area to show \"Edit\" instead of \"Save changes\". %>\n    <%= turbo_stream.replace dom_id(card, :card_closure_toggle) do %>\n      <%= render \"cards/container/closure\", card: card %>\n    <% end %>\n\n    <%= render \"cards/container/content_display\", card: card %>\n  <% end %>\n  </div>\n<% else %>\n  <%= form_with model: card, id: \"card_form\", data: { controller: \"autoresize auto-save\" } do |form| %>\n    <h1 class=\"card__title\">\n      <%= form.label :title, class: \"flex flex-column align-center autoresize__wrapper\", data: { autoresize_target: \"wrapper\", autoresize_clone_value: \"\" } do %>\n        <%= form.text_area :title, placeholder: \"Name it…\",\n              class: \"card-field__title autoresize__textarea input input--textarea full-width borderless txt-align-start hide-focus-ring hide-scrollbar\",\n              autofocus: card.title.blank?, rows: 1, dir: \"auto\", maxlength: 255,\n              data: { autoresize_target: \"textarea\", action: \"input->autoresize#resize auto-save#change blur->auto-save#submit keydown.enter->auto-save#submit:prevent\" } %>\n      <% end %>\n    </h1>\n\n    <%= form.rich_textarea :description, class: \"card__description lexxy-content\",\n          placeholder: \"Add some notes, context, pictures, or video about this…\",\n          data: { action: \"lexxy:change->auto-save#change focusout->auto-save#submit\" } do %>\n      <%= general_prompts(card.board) %>\n    <% end %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/cards/container/_content_display.html.erb",
    "content": "<h1 class=\"card__title flex align-start gap-half\">\n  <%= link_to card_html_title(card), edit_card_path(card), class: \"card__title-link\" %>\n</h1>\n\n<% unless card.description.blank? %>\n  <div class=\"card__description lexxy-content\" data-controller=\"syntax-highlight retarget-links\">\n    <%= card.description %>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/cards/container/_gild.html.erb",
    "content": "<% if card.golden? %>\n  <%= button_to card_goldness_path(card), method: :delete, class: \"btn btn--reversed btn--circle-mobile\",\n        data: { controller: \"tooltip hotkey\", action: \"keydown.shift+g@document->hotkey#click\", bridge__overflow_menu_target: \"item\", bridge_title: \"Demote to normal\" } do %>\n    <%= icon_tag \"golden-ticket\" %>\n    <span class=\"for-screen-reader\">Demote to normal (shift+g)</span>\n  <% end %>\n<% else %>\n  <%= button_to card_goldness_path(card), class: \"btn btn--circle-mobile\",\n        data: { controller: \"tooltip hotkey\", action: \"keydown.shift+g@document->hotkey#click\", bridge__overflow_menu_target: \"item\", bridge_title: \"Promote to Golden Ticket\" } do %>\n    <%= icon_tag \"golden-ticket\" %>\n    <span class=\"for-screen-reader\">Promote to Golden Ticket (shift+g)</span>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/cards/container/_image.html.erb",
    "content": "<% if card.image.attached? %>\n  <%= button_to card_image_path(card), method: :delete, class: \"btn hide-on-native\",\n        data: { controller: \"tooltip\" } do %>\n    <%= icon_tag \"picture-remove\" %>\n    <span class=\"for-screen-reader\">Remove background image</span>\n  <% end %>\n<% elsif !card.closed? %>\n  <%= form_with model: card, data: { controller: \"form\" } do |form| %>\n    <label class=\"card-perma__image-btn btn input--file btn--circle-mobile hide-on-native\" data-controller=\"tooltip\">\n      <%= icon_tag \"picture-add\" %>\n      <span class=\"for-screen-reader\">Add background image</span>\n\n      <%= form.file_field :image, class: \"input\",\n            accept: \"image/png, image/jpeg, image/jpg, image/webp\",\n            aria: { label: \"Add background image\" },\n            data: { action: \"upload-preview#previewImage form#submit\", upload_preview_target: \"input\" } %>\n    </label>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/cards/container/_save_button.html.erb",
    "content": "<div class=\"card-perma__notch card-perma__notch--bottom flex-column\">\n  <%= button_tag type: :submit, form: dom_id(card, :edit_form), class: \"btn borderless\",\n        title: \"Save changes (#{ hotkey_label([\"ctrl\", \"enter\"]) })\",\n        data: { controller: \"hotkey\", bridge__form_target: \"submit\", action: \"keydown.ctrl+enter@document->hotkey#click keydown.meta+enter@document->hotkey#click\" } do %>\n    <span>Save changes</span>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/cards/container/footer/_create.html.erb",
    "content": "<div class=\"card-perma__notch card-perma__notch--bottom flex-column\">\n  <div class=\"card-perma__notch-new-card-buttons\">\n    <%= button_to card_publish_path(card), name: \"creation_type\", value: \"add\", class: \"btn\",\n          title: \"Create card (#{ hotkey_label([\"ctrl\", \"enter\"]) })\",\n          form: { data: { controller: \"form bridge--form\" } },\n          data: { form_target: \"submit\", bridge__form_target: \"submit\", controller: \"clicker\", action: \"keydown.ctrl+enter@document->clicker#click keydown.meta+enter@document->clicker#click\" } do %>\n      <span>Create card</span>\n    <% end %>\n\n    <%= button_to card_publish_path(card), method: :post, class: \"btn btn--reversed\", name: \"creation_type\", value: \"add_another\",\n          title: \"Create and add another (#{ hotkey_label([\"ctrl\", \"shift\", \"enter\"]) })\", form: { data: { controller: \"form\" } },\n          data: { form_target: \"submit\", controller: \"clicker\", action: \"keydown.ctrl+shift+enter@document->clicker#click keydown.meta+shift+enter@document->clicker#click\" } do %>\n      <span>Create and add another</span>\n    <% end %>\n  </div>\n\n  <%= render \"cards/container/footer/saas/storage_limit_notice\" if Fizzy.saas? %>\n</div>\n\n"
  },
  {
    "path": "app/views/cards/container/footer/_published.html.erb",
    "content": "<%# FIXME: Let's move this aside outside of the card container section so these frames don't reload/flicker when card is replaced %>\n<div class=\"card-perma__actions card-perma__actions--right\">\n  <%= turbo_frame_tag card, :watch, src: card_watch_path(card), target: \"_top\", refresh: :morph do %>\n    <%= button_to card_watch_path(card), class: \"btn\", data: { controller: \"tooltip\" } do %>\n      <%= icon_tag \"bell-off\" %> <span class=\"for-screen-reader\">Watch this</span>\n    <% end %>\n  <% end %>\n  <%= turbo_frame_tag card, :pin, src: card_pin_path(card), refresh: :morph do %>\n    <%= button_to card_pin_path(card), class: \"btn\", data: { controller: \"tooltip\" } do %>\n      <%= icon_tag \"unpinned\" %> <span class=\"for-screen-reader\">Pin this card</span>\n    <% end %>\n  <% end %>\n</div>\n\n<%= render \"cards/container/closure\", card: card %>\n"
  },
  {
    "path": "app/views/cards/display/_preview.html.erb",
    "content": "<% draggable = local_assigns.fetch(:draggable, false) && card.published? %>\n\n<% card_data = {\n  id: card.number,\n  drag_and_drop_target: \"item\",\n  navigable_list_target: \"item\",\n  css_variable_counter_target: \"item\"\n} %>\n\n<% if card.open? %>\n  <% card_data[:card_not_now_url] = card_not_now_path(card) %>\n  <% card_data[:card_closure_url] = card_closure_path(card) %>\n  <% card_data[:action] = \"mouseenter->navigable-list#hoverSelect\" %>\n  <% card_data[:card_assign_to_self_url] = card_self_assignment_path(card) %>\n<% end %>\n\n<%= card_article_tag card, class: \"card\", draggable: draggable, data: card_data, tabindex: 0 do %>\n  <div class=\"flex flex-column flex-item-grow max-inline-size\">\n    <%= link_to card_path(card), draggable: false, class: \"card__link\", title: card_title_tag(card), data: { action: \"dialog#close\", turbo_frame: \"_top\" } do %>\n      <span class=\"for-screen-reader\"><%= card.title %></span>\n    <% end %>\n\n    <header class=\"card__header\">\n      <%= render \"cards/display/preview/board\", card: card %>\n      <%= render \"cards/display/preview/tags\", card: card %>\n      <%= render \"cards/display/preview/steps\", card: card %>\n      <%= icon_tag \"attachment\", class: \"card__attachments-indicator translucent\" if card.has_attachments? %>\n\n      <% if card.triaged? %>\n        <span class=\"btn justify-start card__column-name card__column-name--current txt-uppercase min-width max-width\">\n          <span class=\"overflow-ellipsis \"><%= card.column.name %></span>\n        </span>\n      <% end %>\n    </header>\n\n    <div class=\"card__body justify-space-between\">\n      <div class=\"card__content\">\n        <h3 class=\"card__title overflow-line-clamp\">\n          <%= card_html_title(card) %>\n        </h3>\n      </div>\n\n      <%= render \"cards/display/preview/columns\", card: card %>\n      <%= render \"cards/display/common/stamp\", card: card %>\n    </div>\n  </div>\n\n  <footer class=\"card__footer flex gap-half\">\n    <%= render \"cards/display/preview/meta\", card: card, preview: true %>\n    <div class=\"card__counts\">\n      <%= render \"cards/display/preview/boosts\", card: card %>\n      <%= render \"cards/display/preview/comments\", card: card %>\n    </div>\n    <%= render \"cards/display/common/background\", card: card %>\n  </footer>\n\n  <% if card.entropic? %>\n    <%= render \"cards/display/preview/bubble\", card: card %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/cards/display/_previews.html.erb",
    "content": "<%= render partial: \"cards/display/preview\", collection: cards, as: :card, locals: { draggable: local_assigns.fetch(:draggable, false) }, cached: true %>\n"
  },
  {
    "path": "app/views/cards/display/_public_preview.html.erb",
    "content": "<%= card_article_tag card, class: \"card\" do %>\n  <div class=\"flex flex-column flex-item-grow max-inline-size\">\n    <header class=\"card__header\">\n      <div class=\"card__board flex align-start\">\n          <span class=\"card__id\">\n            <span class=\"for-screen-reader\">Card number</span>\n            <%= card.number %>\n          </span>\n        <span class=\"card__board-name\">\n            <span class=\"overflow-ellipsis\"><%= card.board.name %></span>\n          </span>\n      </div>\n\n      <%= render \"cards/display/preview/tags\", card: card %>\n    </header>\n\n    <div class=\"card__body justify-space-between\">\n      <div class=\"card__content\">\n        <h1 class=\"card__title overflow-line-clamp\">\n          <%= card_html_title(card) %>\n        </h1>\n      </div>\n\n      <%= render \"cards/display/public_preview/columns\", card: card if card.triaged? %>\n      <%= render \"cards/display/common/stamp\", card: card %>\n    </div>\n  </div>\n\n  <footer class=\"card__footer\">\n    <%= render \"cards/display/public_preview/meta\", card: card %>\n  </footer>\n\n  <%= render \"cards/display/common/background\", card: card %>\n\n  <%= link_to published_card_path(card), class: \"card__link\", title: card_title_tag(card), data: { turbo_frame: \"_top\" } do %>\n    <span class=\"for-screen-reader\"><%= card.title %></span>\n  <% end %>\n\n  <% if card.entropic? %>\n    <%= render \"cards/display/preview/bubble\", card: card %>\n    <span class=\"card__board-name\"></span>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/cards/display/_public_previews.html.erb",
    "content": "<%= render partial: \"cards/display/public_preview\", collection: cards, as: :card, cached: true %>\n"
  },
  {
    "path": "app/views/cards/display/common/_assignees.html.erb",
    "content": "<div id=\"<%= dom_id(card, :assignees) %>\" class=\"position-relative\"\n    data-controller=\"dialog\"\n    data-action=\"keydown.esc->dialog#close click@document->dialog#closeOnClickOutside mouseenter->dialog#loadLazyFrames\" <%= \"hidden\" if card.closed? %>>\n  <button class=\"card__assignees-trigger\" data-action=\"click->dialog#open:stop keydown.a@document->hotkey#click\" data-controller=\"tooltip hotkey\" tabindex=<%= (local_assigns.key?(:preview) && local_assigns[:preview]) ? -1 : 0 %>>\n    <% card.assignees.each do |assignee| %>\n      <%= avatar_preview_tag assignee, tabindex: (local_assigns.key?(:preview) && local_assigns[:preview]) ? -1 : 0 %>\n    <% end %>\n\n    <span class=\"btn card__hide-on-index\" style=\"--btn-background: var(--card-bg-color);\">\n      <%= icon_tag \"person-add\" %>\n      <span class=\"for-screen-reader\">Assign</span>\n    </span>\n  </button>\n\n  <% unless local_assigns[:preview] %>\n    <%= button_to \"Assign to me\", card_self_assignment_path(card), method: :post, data: {controller: \"hotkey\", action: \"keydown.m@document->hotkey#click\" }, hidden: true %>\n  <% end %>\n\n  <dialog class=\"popup panel flex-column align-start gap-half fill-white shadow\" data-dialog-target=\"dialog\" data-action=\"turbo:before-morph-attribute->dialog#preventCloseOnMorphing turbo:submit-end->dialog#close\">\n    <%= yield %>\n  </dialog>\n</div>\n"
  },
  {
    "path": "app/views/cards/display/common/_background.html.erb",
    "content": "<% if card.image.present? %>\n  <div class=\"card__background\">\n    <%= image_tag card.image.presence || \"\", size: 120, data: { upload_preview_target: \"image\" } %>\n  </div>\n\n  <%= yield %>\n<% end %>\n"
  },
  {
    "path": "app/views/cards/display/common/_board.html.erb",
    "content": "<div class=\"card__id\">\n  <span class=\"for-screen-reader\">Card number</span>\n  <%= card.number %>\n</div>\n<div class=\"card__board-name\">\n  <span class=\"overflow-ellipsis\"><%= card.board.name %></span>\n  <%= yield %>\n</div>\n"
  },
  {
    "path": "app/views/cards/display/common/_meta.html.erb",
    "content": "<div class=\"card__meta\" id=\"<%= dom_id(card, :meta) %>\">\n  <div class=\"card__meta-avatars card__meta-avatars--author\">\n    <%= avatar_tag card.creator, tabindex: (local_assigns.key?(:preview) && local_assigns[:preview]) ? -1 : 0 %>\n  </div>\n\n  <span class=\"card__meta-text card__meta-text--added\">\n    <%= card_drafted_or_added(card) %> <%= local_datetime_tag(card.created_at, style: :daysago) %>\n  </span>\n\n  <span class=\"card__meta-text card__meta-text--author\">\n    By <strong><%= card.creator.familiar_name %></strong>\n  </span>\n\n  <span class=\"card__meta-text card__meta-text--updated overflow-ellipsis\">\n    <%= icon_tag \"refresh--meta\" %>\n    Updated\n    <%= local_datetime_tag(card.last_active_at, style: :daysago) %>\n  </span>\n\n  <span class=\"card__meta-text card__meta-text--assignees overflow-ellipsis\">\n    <%= icon_tag \"arrow-right\" if card.assignees.any? %>\n    <%= card.assignees.any? ? \"Assigned to\" : \"Not assigned\" %>\n    <%= card.assignees.map { |assignee| \"<strong>#{h assignee.familiar_name}</strong>\" }.to_sentence.html_safe %>\n  </span>\n\n  <div class=\"card__meta-avatars card__meta-avatars--assignees\">\n    <%= yield %>\n  </div>\n</div>\n\n"
  },
  {
    "path": "app/views/cards/display/common/_stamp.html.erb",
    "content": "<% if card.postponed? %>\n  <%= tag.div class: token_list(\"card__closed\", \"card__closed--system\": card.postponed_by&.system?), data: {\n    controller: \"bridge--stamp\",\n    bridge__stamp_scope_selector_value: \".card-perma\",\n    bridge_title: \"Not Now\",\n    bridge_description: card.postponed_at.strftime(\"%b %d, %Y\")\n  } do %>\n    <span class=\"card__closed-title\" data-text=\"Not Now\">Not Now</span>\n    <strong class=\"card__closed-date\"><%= card.postponed_at.strftime(\"%b %d, %Y\") %></strong>\n    <span class=\"card__closed-by-line\">by <span class=\"card__closed-by\"><%= card.postponed_by&.familiar_name %></span></span>\n  <% end %>\n<% end %>\n\n<% if card.closed? %>\n  <%= tag.div class: \"card__closed\", data: {\n    controller: \"bridge--stamp\",\n    bridge__stamp_scope_selector_value: \".card-perma\",\n    bridge_title: \"Done\",\n    bridge_description: card.closed_at.strftime(\"%b %d, %Y\")\n  } do %>\n    <span class=\"card__closed-title\" data-text=\"Done\">Done</span>\n    <strong class=\"card__closed-date\"><%= card.closed_at.strftime(\"%b %d, %Y\") %></strong>\n    <span class=\"card__closed-by-line\">by <span class=\"card__closed-by\"><%= card.closed_by.familiar_name %></span></span>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/cards/display/mini/_assignees.html.erb",
    "content": "<%= turbo_frame_tag card, :assignees do %>\n  <% card.assignees.each do |assignee| %>\n    <%= avatar_tag assignee %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/cards/display/mini/_meta.html.erb",
    "content": "<%= render \"cards/display/common/meta\", card: card do %>\n  <%= render \"cards/display/mini/assignees\", card: card %>\n<% end %>\n"
  },
  {
    "path": "app/views/cards/display/mini/_tags.html.erb",
    "content": "<%= render \"cards/display/preview/tags\", card: card %>\n"
  },
  {
    "path": "app/views/cards/display/perma/_assignees.html.erb",
    "content": "<%= render \"cards/display/common/assignees\", card: card do %>\n  <%= turbo_frame_tag card, :assignment, src: new_card_assignment_path(card), loading: :lazy, refresh: \"morph\" %>\n<% end %>\n"
  },
  {
    "path": "app/views/cards/display/perma/_background.html.erb",
    "content": "<%= render \"cards/display/common/background\", card: card do %>\n  <%= link_to card.image.presence, class: \"card__zoom-bg-btn btn\", data: { controller: \"tooltip\", lightbox_target: \"image\" } do %>\n    <%= icon_tag \"picture-zoom\" %>\n    <span class=\"for-screen-reader\">Zoom background image</span>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/cards/display/perma/_board.html.erb",
    "content": "<div class=\"card__board\" data-controller=\"dialog\" data-action=\"keydown.esc->dialog#close:stop click@document->dialog#closeOnClickOutside mouseenter->dialog#loadLazyFrames\">\n  <%= render \"cards/display/common/board\", card: card do %>\n    <%= icon_tag \"caret-down\", class: \"txt-xx-small\", hidden: card.closed? %>\n  <% end %>\n  <button class=\"card__board-picker-button btn btn--plain\" data-action=\"click->dialog#open:stop\" aria-label=\"Choose a board for this card\" <%= \"hidden\" if card.closed? %>></button>\n  <dialog class=\"popup popup--align-right panel flex-column align-start gap-half fill-white shadow\" data-dialog-target=\"dialog\" data-action=\"turbo:before-morph-attribute->dialog#preventCloseOnMorphing turbo:submit-end->dialog#close\">\n    <%= turbo_frame_tag \"board_picker\", src: edit_card_board_path(card), target: \"_top\", loading: :lazy, refresh: \"morph\" %>\n  </dialog>\n</div>\n"
  },
  {
    "path": "app/views/cards/display/perma/_meta.html.erb",
    "content": "<%= render \"cards/display/common/meta\", card: card do %>\n  <%= render \"cards/display/perma/assignees\", card: card %>\n<% end %>\n"
  },
  {
    "path": "app/views/cards/display/perma/_steps.html.erb",
    "content": "<ol class=\"steps txt-small margin-block-start-auto\">\n  <%= render partial: \"cards/steps/step\", collection: card.steps, as: :step %>\n\n  <% unless card.closed? %>\n    <li id=\"<%= dom_id(card, :new_step) %>\" class=\"step\">\n      <input type=\"checkbox\" class=\"step__checkbox\" disabled>\n      <%= form_with model: [card, Step.new], url: card_steps_path(card), class: \"min-width\", data: { controller: \"form\", action: \"submit->form#preventEmptySubmit submit->form#preventComposingSubmit turbo:submit-end->form#reset\" } do |form| %>\n        <%= form.text_field :content, class: \"input step__content hide-focus-ring\", placeholder: \"Add a step…\", autocomplete: \"off\", data: { form_target: \"input\", \"1p-ignore\": \"true\", action: \"compositionstart->form#compositionStart compositionend->form#compositionEnd\" }, aria: { label: \"Add a step\" } %>\n      <% end %>\n    </li>\n  <% end %>\n</ol>\n"
  },
  {
    "path": "app/views/cards/display/perma/_tags.html.erb",
    "content": "<div id=\"<%= dom_id(card, :tags) %>\" class=\"card__tags\">\n  <div data-controller=\"dialog\"\n        data-action=\"keydown.esc->dialog#close click@document->dialog#closeOnClickOutside mouseenter->dialog#loadLazyFrames\" <%= \"hidden\" if card.closed? %>>\n    <button class=\"card__tag-picker-button btn btn--ensure-tap-target-size card__hide-on-index\" style=\"--btn-background: var(--card-bg-color);\"\n        data-controller=\"tooltip hotkey\" data-action=\"click->dialog#open:stop keydown.t@document->hotkey#click\">\n      <%= icon_tag \"tag\" %>\n      <span class=\"for-screen-reader\">Add a tag</span>\n    </button>\n\n    <dialog class=\"popup panel flex-column align-start justify-start fill-white shadow txt-small\"\n        data-dialog-target=\"dialog\"\n        data-action=\"turbo:before-morph-attribute->dialog#preventCloseOnMorphing turbo:submit-end->dialog#close\">\n      <%= turbo_frame_tag card, :tagging, src: new_card_tagging_path(card), loading: :lazy, refresh: :morph %>\n    </dialog>\n  </div>\n\n  <% if card.tags.any? %>\n    <div class=\"card__tags-list min-width\">\n      <% card.tags.each_with_index do |tag, index| %>\n        <%= link_to cards_path(board_ids: [ card.board ], tag_ids: [ tag.id ]),\n              class: \"card__tag btn btn--plain min-width txt-uppercase fill-transparent\" do %>\n          <%= tag.title %>\n        <% end %><%= \",\" unless index == card.tags.size - 1 %>\n      <% end %>\n    </div>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/cards/display/preview/_assignees.html.erb",
    "content": "<%= render \"cards/display/common/assignees\", card: card, preview: true %>\n"
  },
  {
    "path": "app/views/cards/display/preview/_board.html.erb",
    "content": "<div class=\"card__board\">\n  <%= render \"cards/display/common/board\", card: card  %>\n</div>\n"
  },
  {
    "path": "app/views/cards/display/preview/_boosts.html.erb",
    "content": "<% boosts = card.reactions %>\n<% if boosts.any? %>\n  <div class=\"card__boosts\">\n    <%= image_tag \"boost-color.svg\", aria: { hidden: true } %>\n    <span><%= boosts.size %></span>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/cards/display/preview/_bubble.html.erb",
    "content": "<%= tag.div \\\n      id: dom_id(card, \"bubble\"),\n      hidden: true,\n      class: \"bubble\",\n      data: {\n        controller: \"bubble\",\n        action: \"turbo:morph-element->bubble#update:self\",\n        bubble_entropy_value: entropy_bubble_options_for(card).to_json,\n        bubble_stalled_value: stalled_bubble_options_for(card)&.to_json\n      } do %>\n\n  <svg viewBox=\"0 0 200 100\">\n    <path id=\"<%= dom_id(card, \"bubble-top-half\") %>\" fill=\"transparent\" d=\"M 20,100 A 80,80 0 0,1 180,100\"/>\n    <text text-anchor=\"middle\" fill=\"currentColor\">\n      <textPath href=\"#<%= dom_id(card, \"bubble-top-half\") %>\" startOffset=\"50%\" dominant-baseline=\"middle\" data-bubble-target=\"top\"></textPath>\n    </text>\n  </svg>\n\n  <span class=\"bubble__number\" data-bubble-target=\"center\"></span>\n\n  <svg viewBox=\"0 0 200 100\">\n    <path id=\"<%= dom_id(card, \"bubble-bottom-half\") %>\" d=\"M 20,0 A 80,80 0 0,0 180,0\" fill=\"transparent\"/>\n    <text text-anchor=\"middle\" fill=\"currentColor\">\n      <textPath href=\"#<%= dom_id(card, \"bubble-bottom-half\") %>\" startOffset=\"50%\" dominant-baseline=\"middle\" data-bubble-target=\"bottom\"></textPath>\n    </text>\n  </svg>\n<% end %>\n"
  },
  {
    "path": "app/views/cards/display/preview/_columns.html.erb",
    "content": "  <div class=\"card__stages\">\n    <% card.board.columns.each do |column| %>\n      <%= tag.span column.name, class: [\"card__column-name btn overflow-ellipsis\", { \"card__column-name--current\": column == card.column }] %>\n    <% end %>\n  </div>\n"
  },
  {
    "path": "app/views/cards/display/preview/_comments.html.erb",
    "content": "<% comments = card.comments.by_user %>\n<% if comments.any? %>\n  <div class=\"card__comments\">\n    <%= icon_tag \"comment\" %>\n    <span><%= comments.count %></span>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/cards/display/preview/_meta.html.erb",
    "content": "<div class=\"card__meta\" id=\"<%= dom_id(card, :meta) %>\">\n  <div class=\"card__meta-avatars card__meta-avatars--author\">\n    <%= avatar_tag card.creator, tabindex: (local_assigns.key?(:preview) && local_assigns[:preview]) ? -1 : 0 %>\n  </div>\n\n  <span class=\"card__meta-text card__meta-text--added\">\n    <%= card_drafted_or_added(card) %> <%= local_datetime_tag(card.created_at, style: :daysago) %>\n  </span>\n\n  <span class=\"card__meta-text card__meta-text--author\">\n    <%= card.creator.familiar_name %>\n  </span>\n\n  <span class=\"card__meta-text card__meta-text--updated overflow-ellipsis\">\n    <%= icon_tag \"refresh--meta\", aria: { label: \"Last updated\" } %>\n    <%= local_datetime_tag(card.last_active_at, style: :daysago) %>\n  </span>\n\n  <span class=\"card__meta-text card__meta-text--assignees overflow-ellipsis\">\n    <%= icon_tag(\"arrow-right\", aria: { label: \"Assigned to\" }) if card.assignees.any? %>\n    <%= card.assignees.map { |assignee| h assignee.familiar_name }.to_sentence(two_words_connector: \" / \", last_word_connector: \" / \").html_safe %>\n  </span>\n\n  <div class=\"card__meta-avatars card__meta-avatars--assignees\">\n    <%= render \"cards/display/preview/assignees\", card: card %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/cards/display/preview/_people.html.erb",
    "content": "<%= render \"cards/display/common/people\", card: card do%>\n  <%= render \"cards/display/preview/assignees\", card: card, preview: true %>\n<% end %>\n"
  },
  {
    "path": "app/views/cards/display/preview/_steps.html.erb",
    "content": "<% if card.steps.any? %>\n  <div class=\"card__steps align-center gap-half flex-item-justify-end flex-item-no-shrink\">\n    <span class=\"steps__icon\">\n      <%= icon_tag \"check\" %>\n    </span>\n    <strong><%= \"#{card.steps.completed.count}/#{card.steps.count}\" %></strong>\n  </div>\n<% end %>"
  },
  {
    "path": "app/views/cards/display/preview/_tags.html.erb",
    "content": "<% if card.tags.any?  %>\n  <div class=\"card__tags\">\n    <%= icon_tag \"tag-outline\", class: \"translucent\" %>\n    <div class=\"min-width overflow-ellipsis\">\n      <% card.tags.each_with_index do |tag, index| %>\n        <span class=\"card__tag btn btn--plain overflow-ellipsis\">\n          <%= tag.title %><%= \",\" unless index == card.tags.size - 1 %>\n        </span>\n      <% end %>\n    </div>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/cards/display/public_preview/_columns.html.erb",
    "content": "<div id=\"<%= dom_id(card, :columns) %>\" class=\"card__stages\">\n  <% card.board.columns.each do |column| %>\n    <%= tag.div column.name,\n          class: [\"card__column-name overflow-ellipsis flex align-center gap-half btn non-clickable no-hover\", { \"card__column-name--current\": column == card.column }] %>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/cards/display/public_preview/_meta.html.erb",
    "content": "<div class=\"card__meta\" id=\"<%= dom_id(card, :meta) %>\">\n  <div class=\"card__meta-avatars card__meta-avatars--author\">\n    <%= avatar_preview_tag card.creator %>\n  </div>\n\n  <span class=\"card__meta-text card__meta-text--added\">\n    <%= card_drafted_or_added(card) %> <%= local_datetime_tag(card.created_at, style: :daysago) %>\n  </span>\n\n  <span class=\"card__meta-text card__meta-text--author\">\n    By <strong><%= card.creator.familiar_name %></strong>\n  </span>\n\n  <span class=\"card__meta-text card__meta-text--updated overflow-ellipsis\">\n    Updated\n    <%= local_datetime_tag(card.last_active_at, style: :daysago) %>\n  </span>\n\n  <span class=\"card__meta-text card__meta-text--assignees overflow-ellipsis\">\n    <%= \"Assigned to\" if card.assignees.any? %>\n    <%= card.assignees.map { |assignee| \"<strong>#{h assignee.familiar_name}</strong>\" }.to_sentence.html_safe %>\n  </span>\n\n  <div class=\"card__meta-avatars card__meta-avatars--assignees\" id=\"<%= dom_id(card, :assignees) %>\">\n    <% card.assignees.each do |assignee| %>\n      <%= avatar_preview_tag assignee %>\n    <% end %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/cards/drafts/_container.html.erb",
    "content": "<section id=\"<%= dom_id(card, :card_container) %>\" class=\"card-perma card-perma--draft\" style=\"--card-color: <%= card.color %>;\">\n  <% cache card do %>\n    <div class=\"card-perma__actions card-perma__actions--left\">\n      <%= render \"cards/container/image\", card: card %>\n    </div>\n\n    <div class=\"card-perma__bg\">\n      <%= card_article_tag card, class: \"card\" do %>\n        <header class=\"card__header\">\n          <%= render \"cards/display/perma/board\", card: card %>\n          <%= render \"cards/display/perma/tags\", card: card %>\n        </header>\n\n        <div class=\"card__body justify-space-between\">\n          <div class=\"card__content\">\n            <%= render \"cards/container/content\", card: card %>\n            <%= render \"cards/display/perma/steps\", card: card %>\n          </div>\n        </div>\n\n        <footer class=\"card__footer\">\n          <%= render \"cards/display/perma/meta\", card: card %>\n          <%= render \"cards/display/perma/background\", card: card %>\n        </footer>\n      <% end %>\n    </div>\n  <% end %>\n\n  <% if Fizzy.saas? %>\n    <%= render \"cards/container/footer/saas/create\", card: card %>\n  <% else %>\n    <%= render \"cards/container/footer/create\", card: card %>\n  <% end %>\n</section>\n"
  },
  {
    "path": "app/views/cards/drafts/show.html.erb",
    "content": "<% @page_title = @card.title %>\n<% @header_class = \"header--card\" %>\n\n<% content_for :header do %>\n  <div class=\"header__actions header__actions--start\">\n    <%= link_back_to_board(@card.board) %>\n  </div>\n<% end %>\n\n<div data-controller=\"beacon lightbox\" data-beacon-url-value=\"<%= card_reading_path(@card) %>\">\n  <%= render \"cards/drafts/container\", card: @card %>\n\n  <%= render \"layouts/lightbox\" do %>\n    <%= button_to_remove_card_image(@card) if @card.image.attached? %>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/cards/edit.html.erb",
    "content": "<%= turbo_frame_tag @card, :edit do %>\n  <%# When entering edit mode, this turbo-stream updates the button area to show\n      \"Save changes\" instead of \"Edit\". Turbo processes this stream as part of the\n      frame response. %>\n  <%= turbo_stream.update dom_id(@card, :card_closure_toggle) do %>\n    <%= render \"cards/container/save_button\", card: @card %>\n  <% end %>\n\n  <%= form_with model: @card, id: dom_id(@card, :edit_form),\n        data: { controller: \"autoresize form local-save\", local_save_key_value: \"card-#{@card.id}\", action: \"turbo:submit-end->local-save#submit\" } do |form| %>\n    <h1 class=\"card__title flex align-start gap-half\">\n      <%= form.label :title, class: \"flex flex-column align-center autoresize__wrapper\", data: { autoresize_target: \"wrapper\", autoresize_clone_value: \"\" } do %>\n        <%= form.text_area :title, class: \"card-field__title autoresize__textarea input input--textarea full-width borderless txt-align-start hide-focus-ring hide-scrollbar\",\n              required: true, autofocus: true, placeholder: \"Name it…\", rows: 1, dir: \"auto\", maxlength: 255,\n              data: { autoresize_target: \"textarea\", action: \"input->autoresize#resize keydown.enter->form#submit:prevent keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel focus->form#select\" } %>\n      <% end %>\n    </h1>\n\n    <%= form.rich_textarea :description, class: \"card__description lexxy-content\",\n          placeholder: \"Add some notes, context, pictures, or video about this…\",\n          data: { local_save_target: \"input\", action: \"lexxy:change->local-save#save turbo:morph-element->local-save#restoreContent keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop\" } do %>\n      <%= general_prompts(@card.board) %>\n    <% end %>\n\n    <%= link_to \"Close editor and discard changes\", @card,\n          data: { form_target: \"cancel\", bridge__form_target: \"cancel\", bridge_title: \"Cancel\" }, hidden: true %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/cards/index.html.erb",
    "content": "<% @page_title = @user_filtering.selected_boards_label %>\n<% turbo_exempts_page_from_cache %>\n\n<%= render \"cards/broadcasts\", filter: @filter %>\n\n<% content_for :header do %>\n  <h1 class=\"header__title divider divider--fade full-width\" data-bridge--title-target=\"header\">\n    <span class=\"overflow-ellipsis txt-capitalize-first-letter\"><%= @user_filtering.selected_boards_label %></span>\n  </h1>\n\n  <div class=\"header__actions header__actions--end\">\n    <% if board = @filter.single_board %>\n      <%= link_to_edit_board board %>\n    <% end %>\n  </div>\n<% end %>\n\n<%= render \"filters/settings\", filter_url: cards_path, user_filtering: @user_filtering, no_filtering_url: cards_path %>\n\n<%= turbo_frame_tag :cards_container do %>\n  <section class=\"cards cards--grid\">\n    <div class=\"cards__list hide-scrollbar\">\n      <%= with_automatic_pagination :cards_paginated_container, @page do %>\n        <%= render \"cards/display/previews\", cards: @page.records, draggable: true %>\n      <% end %>\n      <div class=\"blank-slate\">No cards match this filter</div>\n    </div>\n  </section>\n<% end %>\n"
  },
  {
    "path": "app/views/cards/index.json.jbuilder",
    "content": "json.array! @page.records, partial: \"cards/card\", as: :card\n"
  },
  {
    "path": "app/views/cards/not_nows/create.turbo_stream.erb",
    "content": "<%= turbo_stream.replace(\"not-now\", partial: \"boards/show/not_now\", method: :morph, locals: { board: @card.board }) %>\n\n<% if @source_column %>\n  <%= turbo_stream.replace(dom_id(@source_column), partial: \"boards/show/column\", method: :morph, locals: { column: @source_column }) %>\n<% elsif @was_in_stream %>\n  <%= turbo_stream.replace(\"maybe\", partial: \"boards/show/stream\", method: :morph, locals: { board: @card.board, page: @page }) %>\n<% end %>\n\n<%= turbo_stream.replace([ @card, :card_container ], partial: \"cards/container\", method: :morph, locals: { card: @card.reload }) %>\n"
  },
  {
    "path": "app/views/cards/pins/_pin_button.html.erb",
    "content": "<div id=\"<%= dom_id(card, :pin_button) %>\">\n  <% if card.pinned_by? Current.user %>\n    <%= button_to card_pin_path(card), method: :delete, class: \"btn btn--reversed btn--circle-mobile\",\n          data: { controller: \"tooltip hotkey\", action: \"keydown.shift+p@document->hotkey#click\", bridge__overflow_menu_target: \"item\", bridge_title: \"Unpin this card\" } do %>\n      <%= icon_tag \"pinned\" %> <span class=\"for-screen-reader\">Unpin this card (shift+p)</span>\n    <% end %>\n  <% else %>\n    <%= button_to card_pin_path(card), class: \"btn btn--circle-mobile\",\n          data: { controller: \"tooltip hotkey\", action: \"keydown.shift+p@document->hotkey#click\", bridge__overflow_menu_target: \"item\", bridge_title: \"Pin this card\" } do %>\n      <%= icon_tag \"unpinned\" %> <span class=\"for-screen-reader\">Pin this card (shift+p)</span>\n    <% end %>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/cards/pins/show.html.erb",
    "content": "<%= turbo_frame_tag @card, :pin do %>\n  <%= render \"cards/pins/pin_button\", card: @card %>\n<% end %>\n"
  },
  {
    "path": "app/views/cards/previews/index.turbo_stream.erb",
    "content": "<%= turbo_stream.remove \"#{params[:target]}-load-page-#{@page.number}\" %>\n\n<%= turbo_stream.append params[:target] do %>\n  <%= render partial: \"cards/display/preview\", collection: @page.records, as: :card, cached: true %>\n\n  <% unless @page.last? %>\n    <%= cards_next_page_link params[:target], page: @page, filter: @filter, fetch_on_visible: @filter.indexed_by.closed? %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/cards/readings/create.turbo_stream.erb",
    "content": "<%= turbo_stream.remove @notification if @notification %>\n"
  },
  {
    "path": "app/views/cards/show.html.erb",
    "content": "<% @page_title = @card.title %>\n<% @header_class = \"header--card\" %>\n\n<% content_for :head do %>\n  <%= card_social_tags(@card) %>\n<% end %>\n\n<% content_for :header do %>\n  <div class=\"header__actions header__actions--start\">\n    <%= link_back_to_board @card.board, prefer_referrer: [ root_path, cards_path, board_path(@card.board) ] %>\n  </div>\n<% end %>\n\n<%= turbo_stream_from @card %>\n<%= turbo_stream_from @card, :activity %>\n\n<div data-controller=\"beacon lightbox\" data-beacon-url-value=\"<%= card_reading_path(@card) %>\">\n  <%= render \"cards/container\", card: @card %>\n  <%= render \"cards/messages\",  card: @card %>\n\n  <%= render \"layouts/lightbox\" do %>\n    <%= button_to_remove_card_image(@card) if @card.image.attached? %>\n  <% end %>\n</div>\n\n<%= bridged_share_url_button(bridge_share_card_description(@card)) %>\n"
  },
  {
    "path": "app/views/cards/show.json.jbuilder",
    "content": "json.partial! \"cards/card\", card: @card\njson.steps @card.steps, partial: \"cards/steps/step\", as: :step\n"
  },
  {
    "path": "app/views/cards/steps/_step.html.erb",
    "content": "<%= turbo_frame_tag step do %>\n  <li class=\"step\">\n    <%= form_with model: [step.card, step], data: { controller: \"form\" } do |form| %>\n      <%= form.check_box :completed, { class: \"step__checkbox\", data: { action: \"change->form#submit\" } } %>\n    <% end %>\n    <%= link_to step.content, edit_card_step_path(step.card, step), class: \"step__content\" %>\n  </li> \n<% end %>\n"
  },
  {
    "path": "app/views/cards/steps/_step.json.jbuilder",
    "content": "json.cache! step do\n  json.(step, :id, :content, :completed)\nend\n"
  },
  {
    "path": "app/views/cards/steps/create.turbo_stream.erb",
    "content": "<%= turbo_stream.before dom_id(@card, :new_step) do %>\n  <%= render \"cards/steps/step\", step: @step %>\n<% end %>\n"
  },
  {
    "path": "app/views/cards/steps/destroy.turbo_stream.erb",
    "content": "<%= turbo_stream.remove @step %>\n"
  },
  {
    "path": "app/views/cards/steps/edit.html.erb",
    "content": "<%= turbo_frame_tag @step do %>\n  <div class=\"flex align-center gap-half\">\n    <%= form_with model: [@card, @step], class: \"step\", data: { controller: \"form\" } do |form| %>\n      <%= form.check_box :completed, { class: \"step__checkbox\", checked: @step.completed?, disabled: true } %>\n      <%= form.text_field :content, class: \"input step__content step__content--edit hide-focus-ring\", placeholder: \"Name this step…\", required: true, autofocus: true, autocomplete: \"off\",\n          data: { action: \"keydown.esc->form#cancel focus->form#select\", \"1p-ignore\": \"true\" } %>\n      <%= form.button type: \"submit\", class: \"btn btn--positive txt-xx-small\" do %>\n        <%= icon_tag \"check\" %>\n        <span class=\"for-screen-reader\">Save changes</span>\n      <% end %>\n      <%= link_to \"Cancel changes\", card_step_path(@card, @step), data: { form_target: \"cancel\" }, hidden: true %>\n    <% end %>\n    <%= button_to card_step_path(@card, @step), method: :delete, class: \"btn btn--negative txt-xx-small\" do %>\n      <%= icon_tag \"trash\" %>\n      <span class=\"for-screen-reader\">Delete this step</span>\n    <% end %>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/cards/steps/index.json.jbuilder",
    "content": "json.array! @card.steps, partial: \"cards/steps/step\", as: :step\n"
  },
  {
    "path": "app/views/cards/steps/show.html.erb",
    "content": "<%= turbo_frame_tag @step do %>\n  <%= render \"cards/steps/step\", step: @step %>\n<% end %>"
  },
  {
    "path": "app/views/cards/steps/show.json.jbuilder",
    "content": "json.partial! \"cards/steps/step\", step: @step\n"
  },
  {
    "path": "app/views/cards/steps/update.turbo_stream.erb",
    "content": "<%= turbo_stream.replace @step do %>\n  <%= render \"cards/steps/step\", step: @step %>\n<% end %>\n\n<%= turbo_stream.replace dom_id(@card, \"bubble\") do %>\n  <%= render \"cards/display/preview/bubble\", card: @card %>\n<% end %>\n"
  },
  {
    "path": "app/views/cards/taggings/_tag.html.erb",
    "content": "<li class=\"popup__item\" data-filter-target=\"item\" data-navigable-list-target=\"item\" aria-checked=\"<%= card.tagged_with?(tag) %>\">\n  <%= button_to card_taggings_path(card, params: { tag_title: tag.title }), method: :post,\n      class: \"popup__btn btn\",\n      form_class: \"max-width flex-item-grow\" do %>\n    <span class=\"overflow-ellipsis flex-item-grow\"><%= tag.hashtag %></span>\n    <%= icon_tag \"check\", size: 18, class: \"checked flex-item-no-shrink flex-item-justify-end\", style: \"--icon-size: 1em\" %>\n  <% end %>\n</li>\n"
  },
  {
    "path": "app/views/cards/taggings/create.turbo_stream.erb",
    "content": "<%= turbo_stream.replace([ @card, :tags ], partial: \"cards/display/perma/tags\", method: \"morph\", locals: { card: @card.reload }) %>\n"
  },
  {
    "path": "app/views/cards/taggings/new.html.erb",
    "content": "<%= turbo_frame_tag @card, :tagging do %>\n  <div class=\"flex-column full-width\"\n    data-controller=\"form filter navigable-list\"\n    data-navigable-list-focus-on-selection-value=\"false\"\n    data-navigable-list-actionable-items-value=\"true\"\n    data-action=\"keydown->navigable-list#navigate filter:changed->navigable-list#reset dialog:show@document->navigable-list#reset\"\n  >\n    <div class=\"flex align-start justify-space-between\">\n      <strong class=\"popup__title\">Tag this…</strong><kbd class=\"txt-xx-small hide-on-touch\">t</kbd>\n    </div>\n\n    <%= form_with url: card_taggings_path(@card),\n          id: dom_id(@card, :tags_form),\n          data: { controller: \"form\", action: \"submit->form#preventEmptySubmit submit->form#preventComposingSubmit\" },\n          class: \"flex flex-column gap-half full-width margin-block-half\" do |form| %>\n      <%= form.text_field :tag_title, placeholder: @tags.any? ? \"Add a new tag or filter…\" : \"Name this tag…\", class: \"input txt-small full-width\",\n            autocomplete: \"off\", autofocus: true, data: { filter_target: \"input\", form_target: \"input\", action: \"input->filter#filter compositionstart->form#compositionStart compositionend->form#compositionEnd\" } %>\n    <% end %>\n\n    <ul class=\"popup__list\" data-filter-target=\"list\">\n      <%= render collection: @tagged_with, partial: \"tag\", locals: { card: @card } %>\n      <%= render collection: @tags, partial: \"tag\", locals: { card: @card } %>\n\n      <li class=\"popup__item\" data-navigable-list-target=\"item\">\n        <%= button_tag \"Create a new tag\", type: \"submit\", form: dom_id(@card, :tags_form), class: \"btn popup__btn\", data: { form_target: \"submit\" } do %>\n          <%= icon_tag \"add\" %>\n          <span>Create tag</span>\n        <% end %>\n      </li>\n    </ul>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/cards/triage/_columns.html.erb",
    "content": "<%= turbo_frame_tag card, :columns, src: edit_card_column_path(card), target: \"_top\", refresh: \"morph\" %>\n"
  },
  {
    "path": "app/views/cards/update.turbo_stream.erb",
    "content": "<% container_partial = @card.drafted? ? \"cards/drafts/container\" : \"cards/container\" %>\n<%= turbo_stream.replace dom_id(@card, :card_container), partial: container_partial, method: :morph, locals: { card: @card.reload } %>\n\n<%= turbo_stream.update dom_id(@card, :edit) do %>\n  <%= render \"cards/container/content_display\", card: @card %>\n<% end %>\n\n<%= turbo_stream.replace dom_id(@card, :card_closure_toggle) do %>\n  <%= render \"cards/container/closure\", card: @card %>\n<% end %>\n"
  },
  {
    "path": "app/views/cards/watches/_refresh.turbo_stream.erb",
    "content": "<%= turbo_stream.replace dom_id(card, :watch_button) do %>\n  <%= render \"cards/watches/watch_button\", card: card %>\n<% end %>\n\n<%= turbo_stream.replace dom_id(card, :comment_watchers) do %>\n  <%= render \"cards/comments/watchers\", card: card %>\n<% end %>\n"
  },
  {
    "path": "app/views/cards/watches/_watch_button.html.erb",
    "content": "<div id=\"<%= dom_id(card, :watch_button) %>\">\n  <% if card.watched_by? Current.user %>\n    <%= button_to card_watch_path(card), method: :delete, class: \"btn btn--reversed btn--circle-mobile\",\n          data: { controller: \"tooltip hotkey\", action: \"keydown.shift+n@document->hotkey#click\", bridge__overflow_menu_target: \"item\", bridge_title: \"Stop watching\" } do %>\n      <%= icon_tag \"bell\" %>\n      <span class=\"for-screen-reader\">Stop watching (shift+n)</span>\n    <% end %>\n  <% else %>\n    <%= button_to card_watch_path(card), class: \"btn btn--circle-mobile\",\n          data: { controller: \"tooltip hotkey\", action: \"keydown.shift+n@document->hotkey#click\", bridge__overflow_menu_target: \"item\", bridge_title: \"Watch this\" } do %>\n      <%= icon_tag \"bell-off\" %>\n      <span class=\"for-screen-reader\">Watch this (shift+n)</span>\n    <% end %>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/cards/watches/create.turbo_stream.erb",
    "content": "<%= render \"cards/watches/refresh\", card: @card %>"
  },
  {
    "path": "app/views/cards/watches/destroy.turbo_stream.erb",
    "content": "<%= render \"cards/watches/refresh\", card: @card %>\n"
  },
  {
    "path": "app/views/cards/watches/show.html.erb",
    "content": "<%= turbo_frame_tag @card, :watch do %>\n  <%= render \"cards/watches/watch_button\", card: @card %>\n<% end %>\n"
  },
  {
    "path": "app/views/client_configurations/android_v1.json",
    "content": "{\n  \"settings\": {},\n  \"rules\": [\n    {\n      \"patterns\": [\n        \".*\"\n      ],\n      \"properties\": {\n        \"context\": \"default\",\n        \"presentation\": \"default\",\n        \"query_string_presentation\": \"replace\",\n        \"uri\": \"hotwire://fragment/web\",\n        \"fallback_uri\": \"hotwire://fragment/web\",\n        \"pull_to_refresh_enabled\": true\n      }\n    },\n    {\n      \"patterns\": [\n        \"/new$\",\n        \"/new\\\\?.+$\",\n        \"/edit$\",\n        \"/edit\\\\?.+$\",\n        \"/cards/[0-9]+/draft\",\n        \"/notifications/settings\",\n        \"/account/settings\",\n        \"/account/join_code\"\n      ],\n      \"properties\": {\n        \"context\": \"modal\",\n        \"pull_to_refresh_enabled\": false\n      }\n    },\n    {\n      \"patterns\": [\n        \"/devices$\"\n      ],\n      \"properties\": {\n        \"context\": \"modal\",\n        \"pull_to_refresh_enabled\": false,\n        \"allow_untenanted_navigation\": true\n      }\n    },\n    {\n      \"patterns\": [\n        \"/native/login/blank\"\n      ],\n      \"properties\": {\n        \"uri\": \"hotwire://fragment/login/blank\"\n      }\n    },\n    {\n      \"patterns\": [\n        \"/native/login/email\"\n      ],\n      \"properties\": {\n        \"uri\": \"hotwire://fragment/login/email\"\n      }\n    },\n    {\n      \"patterns\": [\n        \"/native/login/magic_code\"\n      ],\n      \"properties\": {\n        \"uri\": \"hotwire://fragment/login/magic_code\"\n      }\n    },\n    {\n      \"patterns\": [\n        \"/native/login/signup_completion\"\n      ],\n      \"properties\": {\n        \"uri\": \"hotwire://fragment/login/signup_completion\"\n      }\n    },\n    {\n      \"patterns\": [\n        \"/native/settings\"\n      ],\n      \"properties\": {\n        \"uri\": \"hotwire://fragment/settings\"\n      }\n    },\n    {\n      \"patterns\": [\n        \"/my/pins\"\n      ],\n      \"properties\": {\n        \"uri\": \"hotwire://fragment/pins\"\n      }\n    },\n    {\n      \"patterns\": [\n        \"/notifications$\"\n      ],\n      \"properties\": {\n        \"uri\": \"hotwire://fragment/notifications\"\n      }\n    }\n  ]\n}\n"
  },
  {
    "path": "app/views/client_configurations/ios_v1.json",
    "content": "{\n  \"settings\": {\n  },\n  \"rules\": [\n    {\n      \"patterns\": [\n        \".*\"\n      ],\n      \"properties\": {\n        \"context\": \"default\",\n        \"query_string_presentation\": \"replace\",\n        \"pull_to_refresh_enabled\": true\n      }\n    },\n    {\n      \"patterns\": [\n        \"/new$\",\n        \"/new\\\\?.+$\",\n        \"/edit$\",\n        \"/edit\\\\?.+$\",\n        \"/accounts$\",\n        \"/cards/[0-9]+/draft\"\n      ],\n      \"properties\": {\n        \"context\": \"modal\",\n        \"pull_to_refresh_enabled\": false\n      }\n    },\n    {\n      \"patterns\": [\n        \"/native/my_menu$\"\n      ],\n      \"properties\": {\n        \"context\": \"modal\",\n        \"view_controller\": \"main_menu\"\n      }\n    },\n    {\n      \"patterns\": [\n        \"/native/add_account$\"\n      ],\n      \"properties\": {\n        \"context\": \"modal\",\n        \"view_controller\": \"login\"\n      }\n    },\n    {\n      \"patterns\": [\n        \"/native/login$\"\n      ],\n      \"properties\": {\n        \"view_controller\": \"login\"\n      }\n    },\n    {\n      \"patterns\": [\n        \"/my/pins$\"\n      ],\n      \"properties\": {\n        \"view_controller\": \"pinned\"\n      }\n    },\n    {\n      \"patterns\": [\n        \"/notifications/tray$\"\n      ],\n      \"properties\": {\n        \"view_controller\": \"notifications\"\n      }\n    },\n    {\n      \"patterns\": [\n        \"internal/devtools/enable*\"\n      ],\n      \"properties\": {\n        \"context\": \"modal\",\n        \"view_controller\": \"dev_settings_launcher\"\n      }\n    },\n    {\n      \"patterns\": [\n        \"/native/settings$\"\n      ],\n      \"properties\": {\n        \"view_controller\": \"settings\"\n      }\n    }\n  ]\n}\n"
  },
  {
    "path": "app/views/columns/_column.json.jbuilder",
    "content": "json.cache! column do\n  json.(column, :id, :name, :color)\n  json.created_at column.created_at.utc\nend\n"
  },
  {
    "path": "app/views/columns/_refresh_adjacent_columns.turbo_stream.erb",
    "content": "<% column.adjacent_columns.each do |adjacent_column| %>\n  <%= turbo_stream.replace(dom_id(adjacent_column), partial: \"boards/show/column\", method: :morph, locals: { column: adjacent_column }) %>\n<% end %>\n"
  },
  {
    "path": "app/views/columns/cards/drops/closures/create.turbo_stream.erb",
    "content": "<%= turbo_stream.replace(\"closed-cards\", partial: \"boards/show/closed\", method: :morph, locals:{ board: @card.board }) %>\n"
  },
  {
    "path": "app/views/columns/cards/drops/columns/create.turbo_stream.erb",
    "content": "<%= turbo_stream.replace(dom_id(@column), partial: \"boards/show/column\", method: :morph, locals: { column: @column }) %>\n"
  },
  {
    "path": "app/views/columns/cards/drops/not_nows/create.turbo_stream.erb",
    "content": "<%= turbo_stream.replace(\"not-now\", partial: \"boards/show/not_now\", method: :morph, locals:{ board: @card.board }) %>\n"
  },
  {
    "path": "app/views/columns/cards/drops/streams/create.turbo_stream.erb",
    "content": "<%= turbo_stream.replace(\"maybe\", partial: \"boards/show/stream\", method: :morph, locals:{ board: @card.board, page: @page }) %>\n"
  },
  {
    "path": "app/views/columns/left_positions/create.turbo_stream.erb",
    "content": "<% if @left_column %>\n  <%= turbo_stream.remove(dom_id(@column)) %>\n  <%= turbo_stream.before(@left_column, partial: \"boards/show/column\", locals: { column: @column }) %>\n  <%= render \"columns/refresh_adjacent_columns\", column: @column %>\n<% end %>\n"
  },
  {
    "path": "app/views/columns/right_positions/create.turbo_stream.erb",
    "content": "<% if @right_column %>\n  <%= turbo_stream.remove(dom_id(@column)) %>\n  <%= turbo_stream.after(@right_column, partial: \"boards/show/column\", locals: { column: @column }) %>\n  <%= render \"columns/refresh_adjacent_columns\", column: @column %>\n<% end %>\n"
  },
  {
    "path": "app/views/columns/show/_add_card_button.html.erb",
    "content": "<div class=\"board-tools card\">\n  <%= button_to board_cards_path(board), method: :post, class: \"btn btn--link\",\n        form: { data: { turbo_frame: \"_top\" } },\n        data: { controller: \"hotkey\", action: \"keydown.c@document->hotkey#click\", bridge__buttons_target: \"button\", bridge_title: \"Add card\", bridge_display_as_primary_action: true, bridge_display_title: true, bridge_icon_url: bridge_icon(\"add\") } do %>\n    <%= icon_tag \"add\", class: \"show-on-touch\" %>\n    <span>Add a card</span>\n    <kbd class=\"hide-on-touch\">C</kbd>\n  <% end %>\n\n  <footer>\n    <%= access_involvement_advance_button(board, Current.user, show_watchers: true) %>\n  </footer>\n</div>\n"
  },
  {
    "path": "app/views/entropy/_auto_close.html.erb",
    "content": "<% url = local_assigns[:url] %>\n<% disabled = local_assigns[:disabled] %>\n\n<div class=\"flex align-start gap txt-align-center\">\n  <div class=\"flex flex-column flex-1\">\n    <%= form_with model: model, url: url, data: { controller: \"form\" } do |form| %>\n      <%= render \"entropy/knob\",\n            form: form,\n            name: :auto_postpone_period_in_days,\n            current_value: model.auto_postpone_period_in_days,\n            knob_options: Entropy::AUTO_POSTPONE_PERIODS_IN_DAYS,\n            label: \"Days until auto-close\",\n            disabled: disabled  %>\n    <% end %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/entropy/_knob.html.erb",
    "content": "<%\n  current_index = knob_options.index(current_value) || knob_options.index(Entropy::DEFAULT_AUTO_POSTPONE_PERIOD_IN_DAYS)\n%>\n\n<fieldset class=\"knob\" data-controller=\"knob\" data-knob-target=\"field\" style=\"--knob-options: <%= knob_options.length %>; --knob-index: <%= current_index %>\">\n  <div class=\"position-relative\" role=\"radiogroup\" aria-labelledby=\"<%= dom_id(form.object, name) %>\">\n    <% knob_options.each_with_index do |value, index| %>\n      <label class=\"knob__option\" style=\"--i: <%= index %>\">\n        <%= form.radio_button name,\n              value,\n              data: {\n                action: \"change->knob#optionChanged change->form#submit\",\n                index: index,\n                knob_target: \"option\"\n              },\n              disabled: disabled %>\n        <span><%= value %></span>\n      </label>\n    <% end %>\n\n    <%= form.range_field :slider,\n          class: \"knob__slider\",\n          data: { action: \"input->knob#sliderChanged change->form#submit\", knob_target: \"slider\" },\n          \"aria-hidden\": true,\n          max: knob_options.length - 1,\n          min: 0,\n          disabled: disabled\n    %>\n\n    <div class=\"knob__knob\" aria-hidden></div>\n  </div>\n\n  <div id=\"<%= dom_id(form.object, name) %>\" class=\"knob__label\">Days</div>\n</fieldset>\n"
  },
  {
    "path": "app/views/event_summaries/_event_summary.html.erb",
    "content": "<% unless event_summary.body.blank? %>\n  <div class=\"comment comment__event flex full-width\">\n    <div class=\"comment__content txt-tight-lines full-width\" data-controller=\"event-summary\">\n      <%= event_summary.body %>\n    </div>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/events/_day.html.erb",
    "content": "<section class=\"events__day\" data-controller=\"related-element\" data-related-element-highlight-class=\"event--related\">\n  <%= render \"events/day_timeline/columns\", day_timeline: day_timeline %>\n  <%= render \"events/empty_days\", day_timeline: day_timeline %>\n</section>\n"
  },
  {
    "path": "app/views/events/_empty_days.html.erb",
    "content": "<% earliest, latest = day_timeline.earliest_time, day_timeline.latest_time %>\n\n<% if earliest.present? %>\n  <% if earliest == latest %>\n    <div class=\"events__none\">\n      <h2 class=\"txt-medium margin-none\"><%= local_datetime_tag earliest, style: :agoorweekday, class: \"txt-ink txt-capitalize\" %></h2>\n      <p class=\"margin-none\">No activity</p>\n    </div>\n  <% elsif earliest < latest %>\n    <div class=\"events__none\">\n      <h2 class=\"txt-medium margin-none\"><%= local_datetime_tag earliest, style: :agoorweekday, class: \"txt-ink txt-capitalize\" %> – <%= local_datetime_tag latest, style: :agoorweekday, class: \"txt-ink txt-capitalize\" %></h2>\n      <p class=\"margin-none\">No activity for <%= (latest - earliest).seconds.in_days.round + 1 %> days</p>\n    </div>\n  <% end %>\n<% else %>\n  <div class=\"events__none\">\n    <p class=\"margin-none\"> <%= day_timeline.has_activity? ? \"No more activity\" : \"No activity\" %></p>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/events/_event.html.erb",
    "content": "<% cache event do %>\n  <%# Template Dependency Updated: _layout.html.erb 2026-01-26 %>\n  <% if lookup_context.exists?(\"events/event/eventable/_#{event.action}\") %>\n    <%= render \"events/event/eventable/#{event.action}\", event: event %>\n  <% else %>\n    <%= render \"events/event/eventable/#{event.eventable_type.demodulize.underscore}\", event: event %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/events/day_timeline/_column.html.erb",
    "content": "<div class=\"events__column\">\n  <h3 class=\"events__column-header\">\n    <span><%= column.title %></span>\n\n    <% if column.events_by_hour.any? %>\n      <%= link_to events_day_timeline_column_path(column, day: column.day_timeline.day.to_date), class: \"events__maximize-button btn btn--circle txt-x-small borderless\", data: { turbo_frame: \"_top\" } do %>\n        <%= icon_tag \"grid\", class: \"translucent\" %>\n        <span class=\"for-screen-reader\">Expand column</span>\n      <% end %>\n    <% end %>\n  </h3>\n  <% column.events_by_hour.each do |hour, events| %>\n    <%= events_at_hour_container(column, hour) do %>\n      <%= render partial: \"events/event\", collection: events, cached: true %>\n      <%= local_datetime_tag events.first.created_at, class: \"event__timestamp txt-small translucent\" %>\n    <% end %>\n  <% end %>\n\n  <% if column.has_more_events? %>\n    <div class=\"events__column-footer fill-highlight txt-x-small border-radius\" style=\"grid-column-start: <%= column.index %>\">\n      Showing the 100 most recent (<%= column.hidden_events_count %> are hidden)\n    </div>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/events/day_timeline/_columns.html.erb",
    "content": "<% cache [ day_timeline.events ] do %>\n  <%# Template Dependency Updated: _layout.html.erb 2026-01-26 %>\n  <% if day_timeline.has_activity? %>\n    <div class=\"events__columns\">\n      <%= render \"events/day_timeline/column\", column: day_timeline.added_column %>\n      <%= render \"events/day_timeline/column\", column: day_timeline.updated_column %>\n      <%= render \"events/day_timeline/column\", column: day_timeline.closed_column %>\n    </div>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/events/day_timeline/columns/_events.html.erb",
    "content": "<div class=\"events--grid\">\n  <% column.events_by_hour.each do |hour, events| %>\n    <% if events.any? %>\n      <%= render partial: \"events/event\", collection: events, cached: true %>\n    <% end %>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/events/day_timeline/columns/show.html.erb",
    "content": "<% @page_title = @column.base_title %>\n\n<% content_for :header do %>\n  <div class=\"header__actions header__actions--start\">\n    <%= back_link_to \"Activity\", root_path, \"keydown.left@document->hotkey#click keydown.esc@document->hotkey#click\" %>\n  </div>\n\n  <h1 class=\"header__title\" data-bridge--title-target=\"header\">\n    <%= @column.title %>\n  </h1>\n<% end %>\n\n<%= render \"events/day_timeline/columns/events\", column: @column %>"
  },
  {
    "path": "app/views/events/days/index.html.erb",
    "content": "<%= day_timeline_pagination_frame_tag @day_timeline do %>\n  <%= render \"events/day\", day_timeline: @day_timeline %>\n  <%= day_timeline_pagination_link(@day_timeline, @filter) %>\n<% end %>\n"
  },
  {
    "path": "app/views/events/event/_attachments.html.erb",
    "content": "<% if eventable&.has_attachments? || eventable&.has_remote_images? || eventable&.has_remote_videos? %>\n  <span class=\"event_attachments margin-block-half flex align-center gap-half\">\n    <%= render partial: \"events/event/attachments/attachment\", collection: eventable.attachments %>\n    <%= render partial: \"events/event/attachments/remote_image\", collection: eventable.remote_images %>\n    <%= render partial: \"events/event/attachments/remote_video\", collection: eventable.remote_videos %>\n  </span>\n<% end %>\n"
  },
  {
    "path": "app/views/events/event/_layout.html.erb",
    "content": "<%= link_to event.notifiable_target,\n      id: dom_id(event, \"timelined\"),\n      class: \"event event--#{ event.action } #{ \"golden-effect\" if event.card.golden? } center-block flex flex-column full-width align-start justify-start position-relative\",\n      style: \"--card-color: #{ card.closed? ? \"var(--color-card-complete)\" : card.color };\",\n      data: {\n        turbo_frame: \"_top\",\n        related_element_target: \"related\",\n        related_element_group_value: card.id,\n        action: \"mouseover->related-element#highlight mouseout->related-element#unhighlight\" } do %>\n  <div class=\"card__header\">\n    <h4 class=\"card__board\">\n      <span class=\"card__id\">\n        <span class=\"for-screen-reader\">Card number</span>\n        <%= card.number %>\n      </span>\n      <span class=\"card__board-name\">\n        <span class=\"overflow-ellipsis\"><%= event.board.name %></span>\n      </span>\n    </h4>\n\n    <% unless event.action.in?(%w[ card_closed card_published card_reopened ]) %>\n      <%= icon_tag event_action_icon(event), class: \"event__icon\" %>\n    <% end %>\n  </div>\n  <div class=\"event__content\">\n    <div class=\"flex align-start gap\">\n      <div class=\"avatar txt-x-small\">\n        <%= avatar_image_tag(event.creator) %>\n      </div>\n\n      <div class=\"flex flex-column min-width txt-small align-start txt-align-start\" style=\"color: color-mix(in srgb, var(--card-color) 40%, var(--color-ink));\">\n        <strong class=\"event__title txt-break overflow-line-clamp txt-tight-lines\">\n          <%= event.description_for(Current.user).to_html %>\n        </strong>\n\n        <%= yield %>\n      </div>\n    </div>\n  </div>\n\n  <% if event.card.image.present? %>\n    <div class=\"card__background\">\n      <%= image_tag event.card.image.presence || \"\", size: 120, aria: { hidden: true } %>\n    </div>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/events/event/attachments/_attachment.html.erb",
    "content": "<% variant = Attachments::VARIANTS[:small] %>\n<% width = attachment.metadata[\"width\"] %>\n<% height = attachment.metadata[\"height\"] %>\n\n<% if attachment.previewable? %>\n  <%= image_tag rails_representation_path(attachment.preview(variant)), class: \"attachment attachment--image\", width: width, height: height %>\n<% elsif attachment.variable? %>\n  <%= image_tag rails_representation_path(attachment.variant(variant)), class: \"attachment attachment--image\", width: width, height: height %>\n<% else %>\n  <div class=\"attachment attachment--file attachment--<%= attachment.filename.extension -%>\">\n    <span class=\"attachment__icon\"><%= attachment.filename.extension&.downcase.presence || \"unknown\" %></span>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/events/event/attachments/_remote_image.html.erb",
    "content": "<%= image_tag remote_image.url, skip_pipeline: true, class: \"attachment attachment--image\", width: remote_image.width, height: remote_image.height %>\n"
  },
  {
    "path": "app/views/events/event/attachments/_remote_video.html.erb",
    "content": "<%= tag.video controls: true, class: \"attachment attachment--video\", width: remote_video.width, height: remote_video.height do %>\n  <%= tag.source src: remote_video.url, type: remote_video.content_type %>\n<% end %>\n"
  },
  {
    "path": "app/views/events/event/eventable/_card.html.erb",
    "content": "<%= render \"events/event/layout\", card: event.eventable, event: event %>\n"
  },
  {
    "path": "app/views/events/event/eventable/_card_published.html.erb",
    "content": "<%= render \"events/event/layout\", card: event.eventable, event: event do %>\n  <span class=\"txt-break overflow-line-clamp txt-tight-lines\">\n    <%= format_excerpt(event&.eventable.description, length: 200) -%>\n  </span>\n\n  <%= render \"events/event/attachments\", eventable: event.eventable %>\n<% end %> "
  },
  {
    "path": "app/views/events/event/eventable/_comment.html.erb",
    "content": "<%= render \"events/event/layout\", card: event.eventable.card, event: event do %>\n  <span class=\"txt-break overflow-line-clamp txt-tight-lines\">\n    <%= format_excerpt(event&.eventable.body, length: 200) -%>\n  </span>\n\n  <%= render \"events/event/attachments\", eventable: event.eventable %>\n<% end %>\n"
  },
  {
    "path": "app/views/events/index/_add_board_button.html.erb",
    "content": "<div class=\"header__actions header__actions--end\">\n  <div>\n    <%= link_to new_board_path, class: \"btn btn--link btn--circle-mobile\",\n          data: { controller: \"hotkey\", action: \"keydown.b@document->hotkey#click\", bridge__buttons_target: \"button\", bridge_title: \"Add board\", bridge_display_title: true, bridge_icon_url: bridge_icon(\"board\"), bridge_slot: \"left\" } do %>\n      <%= icon_tag \"board\" %>\n      <span class=\"overflow-ellipsis\">Add a board</span>\n      <kbd class=\"hide-on-touch\">B</kbd>\n    <% end %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/events/index/_add_card_button.html.erb",
    "content": "<div class=\"header__actions header__actions--start\">\n  <% if board = user_filtering.single_board_or_first %>\n    <%= button_to board_cards_path(board), method: :post, class: \"btn btn--link btn--circle-mobile\",\n          data: { controller: \"hotkey\", action: \"keydown.c@document->hotkey#click\", bridge__buttons_target: \"button\", bridge_title: \"Add card\", bridge_display_as_primary_action: true, bridge_display_title: true, bridge_icon_url: bridge_icon(\"add\") } do %>\n      <%= icon_tag \"add\" %>\n      <span class=\"overflow-ellipsis\">Add a card</span>\n      <kbd class=\"hide-on-touch\">C</kbd>\n    <% end %>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/events/index/_filter.html.erb",
    "content": "<%= form_with url: events_path,\n      method: :get, class: \"display-inline position-relative\",\n      data: { controller: \"form\", turbo_frame: \"cards_container\", turbo_action: \"advance\" } do |form| %>\n\n  <% unless user_filtering.boards.one? %>\n    <%= render \"events/index/filter/board\", user_filtering: %>\n  <% end %>\n\n  <% unless user_filtering.users.one? %>\n    <span>by</span>\n    <%= render \"events/index/filter/user\", user_filtering: %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/events/index/filter/_board.html.erb",
    "content": "<% filter = user_filtering.filter %>\n\n<%= tag.div class: \"flex-inline flex-wrap position-relative quick-filter\",\n      data: {\n        controller: \"dialog filter multi-selection-combobox\",\n        action: \"keydown.esc->dialog#close click@document->dialog#closeOnClickOutside turbo:morph@window->multi-selection-combobox#refresh dialog:close@document->filter#clearInput\",\n        filter_show: user_filtering.show_boards?,\n        multi_selection_combobox_no_selection_label_value: user_filtering.selected_boards_label } do %>\n  <button type=\"button\" class=\"btn borderless btn--plain events__filter-select\" data-action=\"click->dialog#toggle:stop\">\n    <span data-multi-selection-combobox-target=\"label\">\n    </span>\n  </button>\n\n  <template data-multi-selection-combobox-target=\"hiddenFieldTemplate\">\n    <%= hidden_field_tag \"board_ids[]\", nil, data: { filter_settings_target: \"field\" } %>\n  </template>\n\n  <%= filter_dialog \"Board…\" do %>\n    <%= filter_title \"Board…\" %>\n    <%= text_field_tag nil, nil, id: nil, placeholder: \"Filter…\", class: \"input input--transparent txt-small font-weight-normal\", autofocus: true,\n          type: \"search\", autocorrect: \"off\", autocomplete: \"off\", data: { \"1p-ignore\": \"true\", filter_target: \"input\", dialog_target: \"focusMouse\", action: \"input->filter#filter\" } %>\n\n    <ul class=\"popup__list\" data-filter-target=\"list\" role=\"listbox\">\n      <% user_filtering.boards.each do |board| %>\n        <%= tag.li class: \"popup__item\", data: {\n              filter_target: \"item\", navigable_list_target: \"item\", multi_selection_combobox_target: \"item\", multi_selection_combobox_value: board.id, multi_selection_combobox_label: board.name },\n              role: \"checkbox\", aria: { checked: filter.boards.include?(board) } do %>\n          <button type=\"button\" class=\"btn popup__btn\" data-action=\"dialog#close multi-selection-combobox#change filter-settings#change form#submit\">\n            <span class=\"overflow-ellipsis flex-item-grow\"><%= board.name %></span>\n            <%= icon_tag \"check\", class: \"checked flex-item-justify-end\" %>\n          </button>\n        <% end %>\n      <% end %>\n    </ul>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/events/index/filter/_user.html.erb",
    "content": "<% filter = user_filtering.filter %>\n\n<%= tag.div class: \"flex-inline flex-wrap position-relative quick-filter\",\n      data: {\n        controller: \"dialog filter multi-selection-combobox\",\n        action: \"keydown.esc->dialog#close click@document->dialog#closeOnClickOutside turbo:morph@window->multi-selection-combobox#refresh dialog:close@document->filter#clearInput\",\n        filter_show: user_filtering.show_creators?,\n        multi_selection_combobox_no_selection_label_value: \"everyone\" } do %>\n  <button type=\"button\" class=\"btn borderless btn--plain events__filter-select\" data-action=\"click->dialog#toggle:stop\">\n    <span data-multi-selection-combobox-target=\"label\">\n    </span>\n  </button>\n\n  <template data-multi-selection-combobox-target=\"hiddenFieldTemplate\">\n    <%= hidden_field_tag \"creator_ids[]\", nil, data: { filter_settings_target: \"field\" } %>\n  </template>\n\n  <%= filter_dialog \"Person…\" do %>\n    <%= filter_title \"Person…\" %>\n    <%= text_field_tag nil, nil, id: nil, placeholder: \"Filter…\", class: \"input input--transparent txt-small font-weight-normal\", autofocus: true,\n          type: \"search\", autocorrect: \"off\", autocomplete: \"off\", data: { \"1p-ignore\": \"true\", filter_target: \"input\", dialog_target: \"focusMouse\", action: \"input->filter#filter\" } %>\n\n    <ul class=\"popup__list\" data-filter-target=\"list\" role=\"listbox\">\n      <% user_filtering.users.each do |user| %>\n        <%= tag.li class: \"popup__item\", data: {\n              filter_target: \"item\", navigable_list_target: \"item\", multi_selection_combobox_target: \"item\", multi_selection_combobox_value: user.id, multi_selection_combobox_label: user.familiar_name },\n              role: \"checkbox\", aria: { checked: filter.creators.include?(user) } do %>\n          <button type=\"button\" class=\"btn popup__btn\" data-action=\"dialog#close multi-selection-combobox#change form#submit\">\n            <span class=\"overflow-ellipsis flex-item-grow\"><%= user.name %></span>\n            <%= icon_tag \"check\", class: \"checked flex-item-justify-end\" %>\n          </button>\n        <% end %>\n      <% end %>\n    </ul>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/events/index.html.erb",
    "content": "<% @page_title = \"Home\" %>\n<% @header_class = \"header--events\" %>\n\n<%= render \"cards/broadcasts\", filter: @filter %>\n\n<% content_for :header do %>\n  <%= render \"events/index/add_card_button\", user_filtering: @user_filtering %>\n\n  <h1 class=\"header__title\" data-controller=\"dialog-manager\" data-bridge--title-target=\"header\">\n    <% if @user_filtering.boards.many? %>\n      <span>Activity <%= @user_filtering.filter.boards.any? ? \"in\" : \"across\" %></span>\n    <% else %>\n      <span>Latest Activity</span>\n    <% end %>\n\n    <%= render \"events/index/filter\", user_filtering: @user_filtering %>\n  </h1>\n\n  <%= render \"events/index/add_board_button\", user_filtering: @user_filtering %>\n<% end %>\n\n<%= tag.div id: \"activity\", class: \"events\", data: { controller: \"pagination\", pagination_paginate_on_intersection_value: true } do %>\n  <%= day_timeline_pagination_frame_tag @day_timeline do %>\n    <%= render \"events/day\", day_timeline: @day_timeline %>\n\n    <%= day_timeline_pagination_link(@day_timeline, @filter) %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/filters/_filter_toggle.html.erb",
    "content": "<div id=\"filter-settings-save-toggle\">\n  <% if filter.persisted? %>\n    <%= button_to filter_path(filter), method: :delete, class: \"btn txt-x-small btn--reversed\", data: { controller: \"tooltip\" }, form_class: \"inline\" do %>\n      <%= icon_tag \"bookmark-outline\" %>\n      <span class=\"for-screen-reader\">Delete custom view</span>\n    <% end %>\n  <% else %>\n    <%= button_to filters_path(filter.as_params), method: :post, class: \"btn txt-x-small\", data: { controller: \"tooltip\" }, form_class: \"inline\" do %>\n      <%= icon_tag \"bookmark-outline\" %>\n      <span class=\"for-screen-reader\">Save custom view</span>\n    <% end %>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/filters/_settings.html.erb",
    "content": "<%= tag.details class: \"expandable-on-native\",\n      open: true,\n      data: {\n        controller: \"expandable-on-native\",\n        expandable_on_native_auto_expand_selector_value: \"[data-filter-show=true]\" } do %>\n    <%= tag.summary \"Toggle filters\", class: \"btn btn--plain margin-block-end\", data: { bridge__buttons_target: \"button\", bridge_title: \"Toggle filters\", bridge_icon_url: bridge_icon(\"funnel\") } %>\n    <%= tag.aside \\\n          class: class_names(\"filters margin-block-end\", { \"filters--expanded\": user_filtering.expanded? }),\n          data: {\n            controller: \"toggle-enable toggle-class filter-settings dialog-manager\",\n            toggle_class_toggle_class: \"filters--expanded\",\n            filter_settings_filters_set_class: \"filters--has-filters-set\",\n            filter_settings_no_filtering_url_value: no_filtering_url,\n            filter_settings_refresh_url_value: settings_refresh_path,\n            filter_settings_cards_url_value: cards_path,\n            turbo_permanent: true } do %>\n      <%= form_with url: filter_url, method: :get, class: \"display-contents\", data: {\n            controller: \"form\",\n            turbo_frame: \"cards_container\",\n            filter_settings_target: \"form\",\n            action: \"turbo:submit-end->filter-settings#resetIfNoFiltering\",\n            turbo_action: \"advance\" } do |form| %>\n        <%= hidden_field_tag :expand_all, true, disabled: !user_filtering.expanded?, data: { toggle_enable_target: \"element\" } %>\n\n        <%= yield form if block_given? %>\n\n          <%= render \"filters/settings/terms\", filter: user_filtering.filter, form: form do %>\n            <%= yield form if block_given? %>\n          <% end %>\n          <%= render \"filters/settings/controls\", user_filtering: user_filtering, form: form %>\n\n          <%= render \"filters/settings/toggle\", user_filtering: user_filtering %>\n      <% end %>\n      <%= render \"filters/settings/manage\", user_filtering: user_filtering, no_filtering_url: no_filtering_url %>\n    <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/filters/create.turbo_stream.erb",
    "content": "<%= turbo_stream.replace(\"filter-settings-save-toggle\", partial: \"filters/filter_toggle\", locals: { filter: @filter }) %>\n"
  },
  {
    "path": "app/views/filters/destroy.turbo_stream.erb",
    "content": "<%= turbo_stream.replace(\"filter-settings-save-toggle\", partial: \"filters/filter_toggle\", locals: { filter: @filter }) %>\n"
  },
  {
    "path": "app/views/filters/settings/_assignees.html.erb",
    "content": "<% filter = user_filtering.filter %>\n<%= tag.div class: \"quick-filter\",\n      data: {\n        controller: \"dialog filter multi-selection-combobox\",\n        action: \"keydown.esc->dialog#close click@document->dialog#closeOnClickOutside dialog:close@document->filter#clearInput\",\n        filter_show: user_filtering.show_assignees?,\n        multi_selection_combobox_no_selection_label_value: \"Assigned to…\",\n        multi_selection_combobox_label_prefix_value: \"Assigned to\" } do %>\n    <button type=\"button\" class=\"btn input input--select flex-inline txt-x-small\" data-action=\"click->dialog#toggle:stop\">\n      <span class=\"overflow-ellipsis\" data-multi-selection-combobox-target=\"label\">\n      </span>\n    </button>\n\n    <template data-multi-selection-combobox-target=\"hiddenFieldTemplate\">\n      <%= hidden_field_tag \"assignee_ids[]\", nil, data: { filter_settings_target: \"field\" } %>\n    </template>\n\n    <%= filter_dialog \"Assigned to…\" do %>\n      <%= filter_title \"Assigned to…\" %>\n      <% if Current.account.users.active.many? %>\n        <%= text_field_tag nil, nil, id: nil, placeholder: \"Filter…\", class: \"input input--transparent txt-small\", autofocus: true,\n              type: \"search\", autocorrect: \"off\", autocomplete: \"off\", data: { \"1p-ignore\": \"true\", filter_target: \"input\", dialog_target: \"focusMouse\", action: \"input->filter#filter\" } %>\n      <% end %>\n\n      <ul class=\"popup__list\" data-filter-target=\"list\" role=\"listbox\">\n        <%= tag.li class: \"popup__item\", data: {\n              filter_target: \"item\", navigable_list_target: \"item\", multi_selection_combobox_target: \"item\", multi_selection_combobox_value: \"unassigned\",\n              multi_selection_combobox_label: \"No one\", multi_selection_field_name: \"assignment_status\", multi_selection_exclusive: true },\n              role: \"checkbox\", aria: { checked: filter.assignment_status.unassigned? } do %>\n          <button type=\"button\" class=\"btn popup__btn\" data-action=\"dialog#close multi-selection-combobox#change filter-settings#change form#submit\">\n            <span class=\"overflow-ellipsis flex-item-grow\">No one</span>\n            <%= icon_tag \"check\", class: \"checked flex-item-justify-end\" %>\n          </button>\n        <% end %>\n\n        <% user_filtering.users.each do |user| %>\n          <%= tag.li class: \"popup__item\", data: {\n                filter_target: \"item\", navigable_list_target: \"item\", multi_selection_combobox_target: \"item\", multi_selection_combobox_value: user.id, multi_selection_combobox_label: user.familiar_name },\n                role: \"checkbox\", aria: { checked: filter.assignees.include?(user) } do %>\n            <button type=\"button\" class=\"btn popup__btn\" data-action=\"dialog#close multi-selection-combobox#change filter-settings#change form#submit\">\n              <span class=\"overflow-ellipsis flex-item-grow\"><%= user.name %></span>\n              <%= icon_tag \"check\", class: \"checked flex-item-justify-end\" %>\n            </button>\n          <% end %>\n        <% end %>\n      </ul>\n    <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/filters/settings/_boards.html.erb",
    "content": "<% filter = user_filtering.filter %>\n\n<%= tag.div class: \"quick-filter\",\n      data: {\n        controller: \"dialog filter multi-selection-combobox\",\n        action: \"keydown.esc->dialog#close click@document->dialog#closeOnClickOutside dialog:close->filter#clearInput\",\n        filter_show: user_filtering.show_boards?,\n        multi_selection_combobox_no_selection_label_value: \"Board…\",\n        multi_selection_combobox_label_prefix_value: \"\" } do %>\n    <button type=\"button\" class=\"btn input input--select flex-inline txt-x-small\" data-action=\"click->dialog#toggle:stop\">\n      <span class=\"overflow-ellipsis\" data-multi-selection-combobox-target=\"label\">\n      </span>\n    </button>\n\n    <template data-multi-selection-combobox-target=\"hiddenFieldTemplate\">\n      <%= hidden_field_tag \"board_ids[]\", nil, data: { filter_settings_target: \"field\" } %>\n    </template>\n\n    <%= filter_dialog \"Board…\" do %>\n      <%= filter_title \"Board…\" %>\n      <% if user_filtering.boards.many? %>\n        <%= text_field_tag nil, nil, id: nil, placeholder: \"Filter…\", class: \"input input--transparent txt-small\", autofocus: true,\n              type: \"search\", autocorrect: \"off\", autocomplete: \"off\", data: { \"1p-ignore\": \"true\", filter_target: \"input\", dialog_target: \"focusMouse\", action: \"input->filter#filter\" } %>\n      <% end %>\n\n      <ul class=\"popup__list\" data-filter-target=\"list\" role=\"listbox\">\n        <% user_filtering.boards.each do |board| %>\n          <%= tag.li class: \"popup__item\", data: {\n                filter_target: \"item\", navigable_list_target: \"item\", multi_selection_combobox_target: \"item\", multi_selection_combobox_value: board.id, multi_selection_combobox_label: board.name },\n                role: \"checkbox\", aria: { checked: filter.boards.include?(board) } do %>\n            <button type=\"button\" class=\"btn popup__btn\" data-action=\"dialog#close multi-selection-combobox#change filter-settings#change filter-settings#submitToGenericCardsView:stop\">\n              <span class=\"overflow-ellipsis flex-item-grow\"><%= board.name %></span>\n              <%= icon_tag \"check\", class: \"checked flex-item-justify-end\" %>\n            </button>\n          <% end %>\n        <% end %>\n      </ul>\n    <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/filters/settings/_cards.html.erb",
    "content": "<% if filter.card_ids.present? %>\n  <%= filter_chip_tag \"Cards #{filter.card_ids.join(\", \")}\", filter.as_params.without(:card_ids) %>\n<% end %>\n"
  },
  {
    "path": "app/views/filters/settings/_closers.html.erb",
    "content": "<% filter = user_filtering.filter %>\n\n<%= tag.div class: \"quick-filter\",\n      data: {\n        controller: \"dialog filter multi-selection-combobox\",\n        action: \"keydown.esc->dialog#close click@document->dialog#closeOnClickOutside dialog:close@document->filter#clearInput\",\n        filter_show: user_filtering.show_closers?,\n        multi_selection_combobox_no_selection_label_value: \"Closed by…\",\n        multi_selection_combobox_label_prefix_value: \"Closed by\" } do %>\n    <button type=\"button\" class=\"btn input input--select flex-inline txt-x-small\" data-action=\"click->dialog#toggle:stop\">\n      <span class=\"overflow-ellipsis\" data-multi-selection-combobox-target=\"label\">\n      </span>\n    </button>\n\n    <template data-multi-selection-combobox-target=\"hiddenFieldTemplate\">\n      <%= hidden_field_tag \"closer_ids[]\", nil, data: { filter_settings_target: \"field\" } %>\n    </template>\n\n    <%= filter_dialog \"Closed by…\" do %>\n      <%= filter_title \"Closed by…\" %>\n      <% if user_filtering.users.many? %>\n        <%= text_field_tag nil, nil, id: nil, placeholder: \"Filter…\", class: \"input input--transparent txt-small\", autofocus: true,\n              type: \"search\", autocorrect: \"off\", autocomplete: \"off\", data: { \"1p-ignore\": \"true\", filter_target: \"input\", dialog_target: \"focusMouse\", action: \"input->filter#filter\" } %>\n      <% end %>\n\n      <ul class=\"popup__list\" data-filter-target=\"list\" role=\"listbox\">\n        <% user_filtering.users.each do |user| %>\n          <%= tag.li class: \"popup__item\", data: {\n                filter_target: \"item\", navigable_list_target: \"item\", multi_selection_combobox_target: \"item\", multi_selection_combobox_value: user.id, multi_selection_combobox_label: user.familiar_name },\n                role: \"checkbox\", aria: { checked: filter.closers.include?(user) } do %>\n            <button type=\"button\" class=\"btn popup__btn\" data-action=\"dialog#close multi-selection-combobox#change filter-settings#change form#submit\">\n              <span class=\"overflow-ellipsis flex-item-grow\"><%= user.name %></span>\n              <%= icon_tag \"check\", class: \"checked flex-item-justify-end\" %>\n            </button>\n          <% end %>\n        <% end %>\n      </ul>\n    <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/filters/settings/_controls.html.erb",
    "content": "<%= render \"filters/settings/boards\", user_filtering: user_filtering %>\n<%= render \"filters/settings/sorted_by\", user_filtering: user_filtering %>\n<%= render \"filters/settings/indexed_by\", user_filtering: user_filtering %>\n<%= render \"filters/settings/tags\", user_filtering: user_filtering %>\n<%= render \"filters/settings/assignees\", user_filtering: user_filtering %>\n<%= render \"filters/settings/creators\", user_filtering: user_filtering %>\n<%= render \"filters/settings/closers\", user_filtering: user_filtering %>\n\n"
  },
  {
    "path": "app/views/filters/settings/_creators.html.erb",
    "content": "<% filter = user_filtering.filter %>\n\n<%= tag.div class: \"quick-filter\",\n      data: {\n        controller: \"dialog filter multi-selection-combobox\",\n        action: \"keydown.esc->dialog#close click@document->dialog#closeOnClickOutside dialog:close@document->filter#clearInput\",\n        filter_show: user_filtering.show_creators?,\n        multi_selection_combobox_no_selection_label_value: \"Added by…\",\n        multi_selection_combobox_label_prefix_value: \"Added by\" } do %>\n    <button type=\"button\" class=\"btn input input--select flex-inline txt-x-small\" data-action=\"click->dialog#toggle:stop\">\n      <span class=\"overflow-ellipsis\" data-multi-selection-combobox-target=\"label\">\n      </span>\n    </button>\n\n    <template data-multi-selection-combobox-target=\"hiddenFieldTemplate\">\n      <%= hidden_field_tag \"creator_ids[]\", nil, data: { filter_settings_target: \"field\" } %>\n    </template>\n\n    <%= filter_dialog \"Added by…\" do %>\n      <%= filter_title \"Added by…\" %>\n      <% if user_filtering.users.many? %>\n        <%= text_field_tag nil, nil, id: nil, placeholder: \"Filter…\", class: \"input input--transparent txt-small\", autofocus: true,\n              type: \"search\", autocorrect: \"off\", autocomplete: \"off\", data: { \"1p-ignore\": \"true\", filter_target: \"input\", dialog_target: \"focusMouse\", action: \"input->filter#filter\" } %>\n      <% end %>\n\n      <ul class=\"popup__list\" data-filter-target=\"list\" role=\"listbox\">\n        <% user_filtering.users.each do |user| %>\n          <%= tag.li class: \"popup__item\", data: {\n                filter_target: \"item\", navigable_list_target: \"item\", multi_selection_combobox_target: \"item\", multi_selection_combobox_value: user.id, multi_selection_combobox_label: user.familiar_name },\n                role: \"checkbox\", aria: { checked: filter.creators.include?(user) } do %>\n            <button type=\"button\" class=\"btn popup__btn\" data-action=\"dialog#close multi-selection-combobox#change filter-settings#change form#submit\">\n              <span class=\"overflow-ellipsis flex-item-grow\"><%= user.name %></span>\n              <%= icon_tag \"check\", class: \"checked flex-item-justify-end\" %>\n            </button>\n          <% end %>\n        <% end %>\n      </ul>\n    <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/filters/settings/_indexed_by.html.erb",
    "content": "<% filter = user_filtering.filter %>\n\n<%= tag.div class: class_names(\"quick-filter\"),\n      data: { controller: \"dialog filter combobox\",\n            action: \"keydown.esc->dialog#close click@document->dialog#closeOnClickOutside dialog:close@document->filter#clearInput\",\n            filter_show: user_filtering.show_indexed_by?,\n            combobox_default_value_value: \"all\",\n            combobox_default_label_value: \"Status…\",\n            combobox_with_default_class: \"quick-filter--with-default\" } do %>\n    <button type=\"button\" class=\"btn input input--select flex-inline txt-x-small\" data-action=\"click->dialog#toggle:stop\">\n      <span class=\"overflow-ellipsis\" data-combobox-target=\"label\">\n      </span>\n    </button>\n\n    <template data-combobox-target=\"hiddenFieldTemplate\">\n      <%= hidden_field_tag :indexed_by, nil, data: { filter_settings_target: \"field\" } %>\n    </template>\n\n    <%= filter_dialog \"Filter by…\" do %>\n      <strong class=\"popup__title\">Filter by status…</strong>\n\n      <ul class=\"popup__list\" data-filter-target=\"list\" role=\"listbox\">\n        <% Filter::INDEXES.each do |index| %>\n          <% label = Filter.indexed_by_human_name(index) %>\n          <%= tag.li class: \"popup__item\", data: { navigable_list_target: \"item\", combobox_target: \"item\", combobox_value: index, combobox_label: label }, role: \"checkbox\", aria: { checked: filter.indexed_by == index } do %>\n            <button type=\"button\" class=\"btn popup__btn\" data-action=\"dialog#close combobox#change filter-settings#change form#submit\">\n              <span class=\"overflow-ellipsis flex-item-grow\"><%= label %></span>\n              <%= icon_tag \"check\", class: \"checked flex-item-justify-end\" %>\n            </button>\n          <% end %>\n        <% end %>\n      </ul>\n    <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/filters/settings/_manage.html.erb",
    "content": "<% filter = user_filtering.filter %>\n<% clear_url = filter.single_board ? board_path(filter.single_board) : no_filtering_url %>\n\n<div class=\"filters__manage gap-half\">\n  <%= link_to clear_url, class: \"btn btn--remove txt-x-small\", data: { controller: \"hotkey tooltip\", action: \"keydown.esc@document->hotkey#click\"} do %>\n    <%= icon_tag \"close\" %>\n    <span class=\"for-screen-reader\">Clear all</span>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/filters/settings/_sorted_by.html.erb",
    "content": "<% filter = user_filtering.filter %>\n\n<%= tag.div class: class_names(\"quick-filter\"),\n      data: {\n        controller: \"dialog filter combobox\",\n        action: \"keydown.esc->dialog#close click@document->dialog#closeOnClickOutside dialog:close@document->filter#clearInput\",\n        filter_show: user_filtering.show_sorted_by?,\n        combobox_default_value_value: \"latest\",\n        combobox_with_default_class: \"quick-filter--with-default\" } do %>\n    <button type=\"button\" class=\"btn input input--select flex-inline txt-x-small\" data-action=\"click->dialog#toggle:stop\">\n      <span class=\"overflow-ellipsis\" data-combobox-target=\"label\">\n      </span>\n    </button>\n\n    <template data-combobox-target=\"hiddenFieldTemplate\">\n      <%= hidden_field_tag :sorted_by, nil, data: { filter_settings_target: \"field\" } %>\n    </template>\n\n    <%= filter_dialog \"Sort by…\" do %>\n      <strong class=\"popup__title\">Sort by…</strong>\n\n      <ul class=\"popup__list\" data-filter-target=\"list\" role=\"listbox\">\n        <% Filter::SORTED_BY.each do |sort| %>\n          <%= tag.li class: \"popup__item\", data: {\n                navigable_list_target: \"item\", combobox_target: \"item\", combobox_value: sort, combobox_label: sorted_by_label(sort) },\n                role: \"checkbox\", aria: { checked: filter.sorted_by == sort } do %>\n            <button type=\"button\" class=\"btn popup__btn\" data-action=\"dialog#close combobox#change filter-settings#change form#submit\">\n              <span class=\"overflow-ellipsis flex-item-grow\"><%= sorted_by_label(sort) %></span>\n              <%= icon_tag \"check\", class: \"checked flex-item-justify-end\" %>\n            </button>\n          <% end %>\n        <% end %>\n      </ul>\n    <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/filters/settings/_tags.html.erb",
    "content": "<% filter = user_filtering.filter %>\n\n<%= tag.div class: \"quick-filter\",\n      data: {\n        controller: \"dialog filter multi-selection-combobox\",\n        action: \"keydown.esc->dialog#close click@document->dialog#closeOnClickOutside dialog:close@document->filter#clearInput\",\n        filter_show: user_filtering.show_tags?,\n        multi_selection_combobox_no_selection_label_value: \"Tagged…\" } do %>\n  <button type=\"button\" class=\"btn input input--select flex-inline txt-x-small\" data-action=\"click->dialog#toggle:stop\">\n    <span class=\"overflow-ellipsis\" data-multi-selection-combobox-target=\"label\"></span>\n  </button>\n\n  <template data-multi-selection-combobox-target=\"hiddenFieldTemplate\">\n    <%= hidden_field_tag \"tag_ids[]\", nil, data: { filter_settings_target: \"field\" } %>\n  </template>\n\n  <%= filter_dialog \"Tagged…\" do %>\n    <%= filter_title \"Tagged…\" %>\n    <% if user_filtering.tags.many? %>\n      <%= text_field_tag nil, nil, id: nil, placeholder: \"Filter…\", class: \"input input--transparent txt-small\", autofocus: true,\n            type: \"search\", autocorrect: \"off\", autocomplete: \"off\", data: { \"1p-ignore\": \"true\", filter_target: \"input\", dialog_target: \"focusMouse\", action: \"input->filter#filter\" } %>\n    <% end %>\n\n    <ul class=\"popup__list\" data-filter-target=\"list\" role=\"listbox\">\n      <% user_filtering.tags.each do |tag| %>\n        <%= content_tag(:li, class: \"popup__item\", data: {\n              filter_target: \"item\", navigable_list_target: \"item\", multi_selection_combobox_target: \"item\", multi_selection_combobox_value: tag.id, multi_selection_combobox_label: tag.hashtag },\n              role: \"checkbox\", aria: { checked: filter.tags.include?(tag) }) do %>\n          <button type=\"button\" class=\"btn popup__btn\" data-action=\"dialog#close multi-selection-combobox#change filter-settings#change form#submit\">\n            <span class=\"overflow-ellipsis flex-item-grow\"><%= tag.hashtag %></span>\n            <%= icon_tag \"check\", class: \"checked flex-item-justify-end\" %>\n          </button>\n        <% end %>\n      <% end %>\n    </ul>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/filters/settings/_terms.html.erb",
    "content": "<%= form.search_field \"terms[]\", placeholder: \"Filter these cards… [F]\", class: \"filter__terms input txt-x-small\",\n      autofocus: false, autocomplete: :off, autocorrect: \"off\", data: {\n            \"1p-ignore\": \"true\",\n            controller: \"hotkey touch-placeholder\",\n            filter_settings_target: \"field\",\n            touch_placeholder_placeholder_value: \"Filter these cards…\",\n            action: \"keydown.f@document->hotkey#focus input->filter-settings#resetIfNoFiltering input->form#debouncedSubmit keydown.enter->form#submitToTopTarget blur->form#submitToTopTarget\" } %>\n\n<% if filter.terms.present? %>\n  <% filter.terms.each do |term| %>\n    <%= filter_chip_tag %Q(\"#{term}\"), filter.as_params_without(:terms, term) %>\n    <%= hidden_field_tag \"terms[]\", term, data: { filter_settings_target: \"field\" } %>\n  <% end %>\n\n  <%= yield form if block_given? %>\n<% end %>\n"
  },
  {
    "path": "app/views/filters/settings/_time_window.html.erb",
    "content": "<% filter = user_filtering.filter %>\n<%= tag.div class: \"quick-filter\",\n      data: { controller: \"dialog\", action: \"keydown.esc->dialog#close click@document->dialog#closeOnClickOutside\", filter_show: filter.public_send(\"#{name}_window\").present? } do %>\n    <button class=\"btn input input--select flex-inline txt-x-small\" data-action=\"click->dialog#open:stop\">\n      <span class=\"overflow-ellipsis\">\n        <%= \"#{label} #{TimeWindowParser.human_name_for(filter.public_send(name))&.downcase}\" %>\n      </span>\n    </button>\n\n    <dialog class=\"events__popup popup panel flex-column align-start gap-half fill-white shadow\"\n      aria-label=\"Created…\" aria-description=\"Created…\"\n      data-dialog-target=\"dialog\" data-action=\"turbo:before-cache@document->dialog#close\">\n      <strong class=\"popup__title margin-block-start-half pad-inline-half\"><%= label %>…</strong>\n      <%= form_with url: cards_path, method: :get, class: \"popup__list\",\n            data: { controller: \"form\" } do |form| %>\n        <% filter.as_params.except(name).each do |key, value| %>\n          <%= filter_hidden_field_tag key, value %>\n        <% end %>\n\n        <% TimeWindowParser::VALUES.each do |value| %>\n          <div class=\"popup__btn btn\">\n            <%= form.radio_button name, value,\n                  checked: filter.public_send(name) == value,\n                  data: { action: \"change->form#submit\" } %>\n\n            <%= form.label name, TimeWindowParser.human_name_for(value), value: value, class: \"overflow-ellipsis\" %>\n            <%= icon_tag \"check\", class: \"checked flex-item-justify-end\" %>\n          </div>\n        <% end %>\n      <% end %>\n    </dialog>\n<% end %>\n"
  },
  {
    "path": "app/views/filters/settings/_toggle.html.erb",
    "content": "<% filter = user_filtering.filter %>\n\n<% if user_filtering.expanded? %>\n  <button type=\"button\" class=\"btn txt-x-small btn--reversed\" aria-selected=\"true\" data-action=\"toggle-class#toggle toggle-enable#toggle\" data-controller=\"tooltip\">\n    <%= icon_tag \"filter\" %>\n    <span class=\"for-screen-reader\">Close filter options</span>\n  </button>\n<% else %>\n  <button type=\"button\" class=\"btn txt-x-small filter-toggle\" data-action=\"toggle-class#toggle toggle-enable#toggle\" data-controller=\"tooltip\">\n    <%= icon_tag \"filter\" %>\n    <span class=\"filters__show-when-expanded for-screen-reader\">Collapse filter options</span>\n    <span class=\"filters__show-when-collapsed for-screen-reader\">Expand filter options</span>\n  </button>\n<% end %>\n"
  },
  {
    "path": "app/views/filters/settings_refreshes/create.turbo_stream.erb",
    "content": "<%= turbo_stream.replace(\"filter-settings-save-toggle\", partial: \"filters/filter_toggle\", locals: { filter: @filter }) %>\n"
  },
  {
    "path": "app/views/join_codes/inactive.html.erb",
    "content": "<% @page_title = \"That code is all used up\" %>\n\n<div class=\"panel panel--centered flex flex-column gap-half\">\n  <h1 class=\"txt-x-large font-weight-black margin-none\"><%= @page_title %></h1>\n\n  <p class=\"txt-medium margin-none\">Ask someone from <%= @join_code.account.name %> to send you a new link or increase the limit.</p>\n\n  <p class=\"txt-medium\">\n    <%= link_to \"OK\", \"https://www.fizzy.do\", class: \"btn btn--link\" %>\n  </p>\n</div>\n\n<% content_for :footer do %>\n  <%= render \"sessions/footer\" %>\n<% end %>\n"
  },
  {
    "path": "app/views/join_codes/new.html.erb",
    "content": "<% @page_title = \"Join #{@join_code.account.name} in Fizzy\" %>\n\n<div class=\"panel panel--centered flex flex-column gap-half\">\n  <h1 class=\"txt-x-large font-weight-black margin-none\"><%= @page_title %></h1>\n\n  <%= form_with url: join_path(code: params[:code], tenant: params[:tenant]), class: \"flex flex-column gap txt-medium\", data: { controller: \"form\" } do |form| %>\n    <div class=\"flex align-center gap\">\n      <label class=\"flex align-center gap input input--actor\">\n        <%= form.email_field :email_address, required: true, class: \"input full-width\", autofocus: true, autocomplete: \"username\", placeholder: \"Email address\" %>\n      </label>\n    </div>\n\n    <button type=\"submit\" id=\"log_in\" class=\"btn btn--link center\" data-form-target=\"submit\">\n      <span>Continue</span>\n      <%= icon_tag \"arrow-right\" %>\n    </button>\n  <% end %>\n</div>\n\n<% content_for :footer do %>\n  <%= render \"sessions/footer\" %>\n<% end %>\n"
  },
  {
    "path": "app/views/layouts/_lightbox.html.erb",
    "content": "<%= tag.dialog class:\"lightbox\", aria: { label: \"Image Viewer (Press escape to close)\" },\n    data: { controller: \"dialog\", dialog_target: \"dialog\", dialog_modal_value: \"true\", lightbox_target: \"dialog\", action: \"keydown.esc->dialog#close:stop transitionend->lightbox#handleTransitionEnd\" } do %>\n\n  <figure class=\"lightbox__figure\">\n    <img src=\"\" class=\"lightbox__image\" data-lightbox-target=\"zoomedImage\" />\n    <figcaption class=\"lightbox__caption\" data-lightbox-target=\"caption\" tabindex=\"-1\" data-dialog-target=\"focusTouch\">&nbsp</figcaption>\n  </figure>\n\n  <div class=\"lightbox__actions\">\n    <%= yield %>\n    <button class=\"btn fill-white\" data-action=\"dialog#close\" data-controller=\"hotkey tooltip\" data-dialog-target=\"focusMouse\">\n      <%= icon_tag \"remove\" %>\n      <span class=\"for-screen-reader\">Close (esc)</span>\n    </button>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/layouts/_theme_preference.html.erb",
    "content": "<%= javascript_tag nonce: true do %>\n  const theme = localStorage.getItem(\"theme\")\n  if (theme && theme !== \"auto\") {\n    document.documentElement.dataset.theme = theme\n  }\n<% end %>\n"
  },
  {
    "path": "app/views/layouts/action_text/contents/_content.html.erb",
    "content": "<div class=\"action-text-content\">\n  <%= format_html yield -%>\n</div>\n"
  },
  {
    "path": "app/views/layouts/application.html.erb",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <%= render \"layouts/shared/head\" %>\n\n  <body class=\"<%= @body_class %>\"\n    data-controller=\"local-time timezone-cookie turbo-navigation theme bridge--title bridge--text-size bridge--insets\"\n    data-action=\"turbo:morph@window->local-time#refreshAll turbo:before-visit@document->turbo-navigation#rememberLocation\"\n    data-turbo-navigation-label-value=\"<%= @page_title %>\"\n    data-platform=\"<%= platform.type %>\"\n    data-bridge-platform=\"<%= platform.bridge_name %>\"\n    data-bridge-components=\"<%= platform.bridge_components %>\"\n    data-bridge--title-title-value=\"<%= @page_title %>\">\n    <div id=\"global-container\" data-controller=\"bridge--buttons bridge--overflow-menu\">\n      <header class=\"header header--mobile-actions-stack <%= @header_class %>\" id=\"header\">\n        <a href=\"#main\" class=\"header__skip-navigation btn\" data-turbo=\"false\">Skip to main content</a>\n        <%= render \"my/menu\" if Current.user %>\n        <%= yield :header %>\n      </header>\n\n      <%= render \"layouts/shared/flash\" %>\n      <%= render \"layouts/shared/time_zone\" if Current.user %>\n\n      <main id=\"main\">\n        <%= yield %>\n      </main>\n    </div>\n\n    <footer id=\"footer\" class=\"hide-on-native\">\n      <%= yield :footer %>\n\n      <% if Current.user && !@hide_footer_frames %>\n        <div id=\"footer_frames\" data-turbo-permanent=\"true\">\n          <%= render \"bar/bar\" %>\n          <%= render \"my/pins/tray\" %>\n          <%= render \"notifications/tray\" %>\n        </div>\n      <% end %>\n\n      <%= render \"layouts/shared/welcome_letter\" if flash[:welcome_letter] %>\n    </footer>\n  </body>\n</html>\n"
  },
  {
    "path": "app/views/layouts/mailer.html.erb",
    "content": "<!DOCTYPE html>\n<html>\n  <head>\n    <meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\">\n    <style>\n      html {\n        -ms-text-size-adjust: 100%;\n        -webkit-text-size-adjust: 100%;\n      }\n\n      body {\n        color: #17233C;\n        font-family: system-ui, sans-serif;\n        font-size: 16px;\n        line-height: 1.3;\n        margin: 0;\n        padding: 16px;\n      }\n\n      img {\n        border: 0 none;\n        height: auto;\n        line-height: 100%;\n        outline: none;\n        text-decoration: none;\n      }\n\n      a {\n        color: #2d71e5;\n      }\n\n      a img {\n        border: 0 none;\n      }\n\n      table, td {\n        border-collapse: collapse;\n      }\n\n      #body {\n        height: 100% !important;\n        margin: 0;\n        padding: 0;\n        width: 100% !important;\n      }\n\n      .avatar__container {\n        vertical-align: top;\n        width: 3em;\n      }\n\n      .avatar {\n        aspect-ratio: 1;\n        border-radius: 2.5em;\n        color: white;\n        display: block;\n        font-weight: 600;\n        height: 2.5em;\n        line-height: 2.5em;\n        mso-line-height-rule: exactly;\n        object-fit: cover;\n        overflow: hidden;\n        text-align: center;\n        width: 2.5em;\n      }\n\n      .card__title {\n        font-size: 1.1em;\n        font-weight: 900;\n        margin-bottom: 0;\n        margin-top: 0;\n      }\n\n      .footer {\n        border-top: 1px solid #ccc;\n        margin-top: 32px;\n        padding-top: 1em;\n      }\n\n      .notification {\n        margin-top: 16px;\n        margin-bottom: 16px;\n      }\n\n      .notification__author {\n        font-size: 0.8em;\n        opacity: 0.66;\n        margin: 0;\n      }\n\n      .notification__board {\n        font-size: 1.4em;\n        font-weight: 900;\n        margin-bottom: 16px;\n        margin-top: 0;\n      }\n\n      .notification__board a {\n        color: #17233C;\n        text-decoration: none;\n      }\n\n      .notification__board a:hover {\n        text-decoration: underline;\n      }\n\n      .notification__board-separator {\n        border: none;\n        border-top: 1px solid #ddd;\n        margin: 2em 0 1em;\n      }\n\n      .notification__details {\n        margin-bottom: 0;\n        margin-top: 0;\n      }\n\n      .notificaton__mention {\n        background-color: #f8e3ab;\n        border-radius: 0.7em 0.2em 0.7em 0.2em;\n        color: inherit;\n        display: inline-flex;\n        padding: 0.1em 0.3em;\n      }\n\n      .margin-block-end-double {\n        margin-bottom: 2em;\n      }\n\n      .margin-block-start-double {\n        margin-top: 2em;\n      }\n\n      .subtitle {\n        font-size: 1.2em;\n        font-weight: normal;\n        margin-bottom: 1em;\n        margin-top: 0.1em;\n      }\n\n      .title {\n        font-size: 1.4em;\n        font-weight: 900;\n        margin-bottom: 0;\n      }\n\n      .txt-large {\n        font-size: 1.2em;\n      }\n    </style>\n  </head>\n\n  <body>\n    <table id=\"body\">\n      <%= yield %>\n    </table>\n  </body>\n</html>\n"
  },
  {
    "path": "app/views/layouts/mailer.text.erb",
    "content": "<%= yield %>\n"
  },
  {
    "path": "app/views/layouts/public.html.erb",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <%= render \"layouts/shared/head\" %>\n\n  <body class=\"public <%= @body_class %>\" data-controller=\"local-time timezone-cookie bridge--title bridge--text-size bridge--insets\" data-action=\"turbo:morph@window->local-time#refreshAll\" data-platform=\"<%= platform.type %>\" data-bridge--title-title-value=\"<%= @page_title %>\">\n    <div id=\"global-container\">\n      <header class=\"header\" id=\"header\">\n        <a href=\"#main\" class=\"header__skip-navigation btn\" data-turbo=\"false\">Skip to main content</a>\n        <nav>\n          <%= link_to \"https://fizzy.do\", class: \"header__logo center flex-inline align-center\" do %>\n            <span><%= image_tag \"logo.png\", alt: \"\" %></span>\n            <svg height=\"30\" viewBox=\"0 0 96 30\" width=\"96\" xmlns=\"http://www.w3.org/2000/svg\" title=\"Fizzy\"><path clip-rule=\"evenodd\" d=\"m93.5609 8.52856c.9033.04314 1.3769.47352 1.4199 1.33398.1291 2.40966.0859 5.24976.1289 7.48726l.1289 7.3575c0 15.4902-10.2406 15.8354-16.8242 14.7168-.8606-.1722-1.2482-.7322-1.1621-1.5928l.3867-3.7002c.086-1.0327.732-1.2905 1.6787-.9463 4.3889 1.5919 9.122-.4737 9.0791-4.7334 0-1.0757-.6026-1.2052-1.2481-.3877-.9036 1.0757-2.2802 2.1943-4.3886 2.1944-3.0552 0-8.2188-1.2046-8.2188-11.962 0-2.6677-.086-5.5076-.0429-8.47652 0-.90362.5162-1.37702 1.4199-1.33399 1.2048.04302 2.3665.00006 3.5283-.04297.9036 0 1.4199.47328 1.4199 1.41992 0 3.22686-.0859 5.63626-.0859 6.45406 0 7.7023 1.7647 8.2187 3.7871 8.2618 2.1512.0427 3.5709-1.5491 3.8291-5.2061v-.9902c0-4.604.0861-6.2821-.043-8.47659-.086-.9036.3444-1.41986 1.2481-1.46289zm-43.4629-.21484c1.1186-.04303 1.5064.68823.9472 1.63476-1.8502 3.05502-5.3796 8.73462-8.3486 13.42482-.4733.7745-.1713 1.334.7754 1.334 2.1511.043 2.9261.0431 5.5937-.086.9464-.0429 1.4199.4306 1.42 1.377-.0431.8605-.0001 1.8504 0 2.7109 0 .9036-.4305 1.3769-1.334 1.377-3.9588.043-9.4238-.1721-15.6201-.043-1.1618 0-1.5487-.6881-.9463-1.6348l8.3906-13.2099c.4733-.7315.1721-1.3769-.7315-1.377-2.4096-.043-4.9915.0429-6.8418.1289-.9036.0431-1.4199-.3874-1.4199-1.291-.043-.9035 0-1.9792 0-2.92576 0-.86059.7316-1.33399 2.0225-1.33399 6.1531.04303 12.1341-.04291 16.0928-.08593zm21.1367 0c1.1186-.04294 1.5056.68817.9463 1.63476-1.8503 3.05502-5.3787 8.73462-8.3477 13.42482-.4733.7745-.1721 1.3339.7744 1.334 2.1514.043 2.9261.0431 5.5938-.086.9465-.043 1.4198.4305 1.4199 1.377-.043.8605 0 1.8504 0 2.7109 0 .9036-.4304 1.377-1.334 1.377-3.9586.043-9.4232-.1721-15.6191-.043-1.1617 0-1.5494-.6883-.9473-1.6348l8.3916-13.2099c.4733-.7315.1712-1.377-.7324-1.377-2.4096-.043-4.9916.0429-6.8418.1289-.9033.0429-1.4199-.3875-1.4199-1.291-.0431-.9035 0-1.9792 0-2.92576 0-.86051.7318-1.3339 2.0224-1.33399 6.1533.04303 12.135-.04291 16.0938-.08593zm-43.3252.25781c.9035-.04295 1.4199.38753 1.4199 1.24805.043 1.50602 0 3.65782 0 12.39262 0 5.6796-.086 5.4215 0 6.4111.1291.9036-.3444 1.4198-1.248 1.4629l-3.9161-.086c-.8604-.0431-1.3769-.4735-1.4199-1.3339-.1291-2.4097-.0859-5.2499-.1289-7.4874-.1291-5.5505-.0429-7.8742-.0859-11.23042-.043-.90357.4733-1.37695 1.4199-1.37695 1.7212.08606 2.8832.08606 3.959 0zm-21.88185-8.56250162c2.36657.04302752 4.77645.00000269 8.30465 0 1.2909 0 3.0554.04302142 4.6905 0 .7314 0 1.119.38683562 1.1191 1.16113162-.043 1.46292-.086 3.3134-.043 4.77637 0 .77451-.4305 1.11924-1.205 1.0332-1.2909-.12909-3.3564-.25866-5.5079-.34473-2.0653-.12908-2.1945.04296-4.25972.04297-.8606 0-1.42065.43055-1.59277 1.20508v.04297c-.12909.64544-.12891 1.03327-.12891 1.67871v.51567c.04303.9035.51653 1.3338 1.37696 1.3769 2.40954.043 5.46464.0002 7.70214-.1289.7315-.043 1.1621.3016 1.1621 1.0762l-.0859 4.3886c0 .7315-.4306 1.1192-1.1621 1.0762-2.2374-.043-4.7329-.0439-7.35746-.0869-.9035-.043-1.37677.4736-1.41992 1.334l-.17285 4.3467c0 2.6244.04379 4.0872.17285 5.292.12909.7315-.25872 1.1621-.99023 1.1621l-5.29297.0429c-.731221-.0001-1.075196-.3877-1.075196-1.1191-.000045-1.2478-.2149575-2.8399-.128907-5.7656.129086-5.7659.085256-13.7261-.12988242-21.81546-.04302878-.774508.34469842-1.162087 1.07617542-1.162105 1.592-.0430291 3.70034-.1719109 4.94824-.12890662zm19.90235.34374962c2.6247.00004 3.5283 1.377542 3.5283 3.055662-.0001 1.72099-.9038 3.09762-3.5283 3.09766-2.6677 0-3.5712-1.37665-3.5713-3.09766 0-1.67815.9034-3.055662 3.5713-3.055662z\" fill=\"currentColor\" fill-rule=\"evenodd\"/></svg>\n          <% end %>\n        </nav>\n\n        <%= yield :header %>\n      </header>\n\n      <%= render \"layouts/shared/flash\" %>\n\n      <main id=\"main\">\n        <%= yield %>\n      </main>\n    </div>\n\n    <footer id=\"footer\">\n      <%= yield :footer %>\n    </footer>\n  </body>\n</html>\n"
  },
  {
    "path": "app/views/layouts/shared/_colophon.html.erb",
    "content": "<%= link_to \"https://www.fizzy.do\", class: \"txt-current font-weight-bold txt-nowrap\", target: \"_blank\", rel: \"noopener noreferrer\" do %>\n  <%= icon_tag \"fizzy\", class: \"v-align-middle\" %>\n  Fizzy™\n<% end %>\nis designed, built, and backed by\n<%= link_to \"https://37signals.com\", class: \"txt-current font-weight-bold txt-nowrap\", target: \"_blank\", rel: \"noopener noreferrer\" do %>\n  <%= icon_tag \"37signals\", class: \"v-align-middle\" %>\n  37signals™\n<% end %>\n"
  },
  {
    "path": "app/views/layouts/shared/_flash.html.erb",
    "content": "<%= turbo_frame_tag :flash do %>\n  <% if notice = flash[:notice] || flash[:alert] %>\n    <div class=\"flash\" data-controller=\"element-removal\" data-action=\"animationend->element-removal#remove\">\n      <div class=\"flash__inner shadow\">\n        <%= notice %>\n      </div>\n    </div>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/layouts/shared/_head.html.erb",
    "content": "<head>\n  <%= page_title_tag %>\n\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, user-scalable=no, viewport-fit=cover\">\n  <% unless @disable_view_transition %>\n    <meta name=\"view-transition\" content=\"same-origin\">\n  <% end %>\n  <meta name=\"color-scheme\" content=\"light dark\">\n  <meta name=\"theme-color\" content=\"#ffffff\" media=\"(prefers-color-scheme: light)\">\n  <meta name=\"theme-color\" content=\"#0d181d\" media=\"(prefers-color-scheme: dark)\">\n  <%= csrf_meta_tags %>\n  <%= csp_meta_tag %>\n  <%= tag.meta name: \"current-user-id\", content: Current.user.id if Current.user %>\n  <%= tag.meta name: \"vapid-public-key\", content: Rails.configuration.x.vapid.public_key %>\n\n  <% turbo_refreshes_with method: :morph, scroll: :preserve %>\n\n  <%= render \"layouts/theme_preference\" %>\n  <%= stylesheet_link_tag :app, \"data-turbo-track\": \"reload\" %>\n  <%= javascript_importmap_tags %>\n\n  <%= tenanted_action_cable_meta_tag %>\n  <%= render \"layouts/shared/user_css\" %>\n\n  <%= yield :head %>\n\n  <link rel=\"manifest\" href=\"<%= pwa_manifest_path(format: :json) %>\">\n  <link rel=\"icon\" href=\"/favicon.png\" type=\"image/png\">\n  <link rel=\"apple-touch-icon\" href=\"/apple-touch-icon.png\">\n</head>\n"
  },
  {
    "path": "app/views/layouts/shared/_time_zone.html.erb",
    "content": "<% if timezone_from_cookie.present? && timezone_from_cookie != Current.user.timezone %>\n  <%= auto_submit_form_with url: my_timezone_path, method: :put do %>\n    <%= hidden_field_tag :timezone_name, timezone_from_cookie.name %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/layouts/shared/_user_css.html.erb",
    "content": "<% if Current.user %>\n  <style>\n    [data-creator-id=\"<%= Current.user.id %>\"] {\n      [data-only-visible-to-others] { display: none; }\n    }\n    [data-creator-id]:not([data-creator-id=\"<%= Current.user.id %>\"]) {\n      [data-only-visible-to-you] { display: none; }\n    }\n  </style>\n<% end %>\n"
  },
  {
    "path": "app/views/layouts/shared/_welcome_letter.html.erb",
    "content": "<div data-controller=\"dialog\" data-dialog-auto-open-value=\"true\" data-dialog-modal-value=\"true\">\n  <dialog class=\"welcome-letter panel panel--wide shadow dialog\" data-dialog-target=\"dialog\">\n    <button class=\"welcome-letter__close btn txt-x-small\" data-action=\"dialog#close\">\n      <%= icon_tag \"close\" %>\n      <span class=\"for-screen-reader\">Close dialog</span>\n    </button>\n\n    <div class=\"txt-align-center margin-block-end\">\n      <span class=\"welcome-letter__avatar txt-xx-large avatar center\"><%= image_tag \"jf-avatar.jpg\", size: 36%></span>\n    </div>\n\n    <h2 class=\"txt-medium margin-none txt-tight-lines\">Welcome, and thanks for signing up for Fizzy.</h2>\n\n    <p></p>\n\n    <p>To get you started, we set you up with a Fizzy board called <em>Playground</em>. It’s got a few cards designed to help you learn Fizzy itself. Open each card, go through the simple steps, and you’ll be an expert in Fizzy in no time. You’ll see the <em>Playground</em> when you close this message.</p>\n\n    <p>If you ever need a hand, please contact me directly at jason@37signals.com. I'm here for you, we’re all here for you.</p>\n\n    <p>Thanks again and all the best,</p>\n\n    <span class=\"welcome-letter__signature\"></span>\n\n    <p><strong>Jason Fried</strong>, jason@37signals.com<br>\n      <em>CEO &amp; co-founder of 37signals, makers of Fizzy, Basecamp, and HEY</em>\n    </p>\n  </dialog>\n</div>\n"
  },
  {
    "path": "app/views/mailers/account_mailer/cancellation.html.erb",
    "content": "<p>Your Fizzy account <strong><%= @account.name %></strong> was cancelled.</p>\n\n<h2>What happens now?</h2>\n\n<ul>\n  <li>No one can access the account anymore</li>\n  <% if @account.try(:subscription) %>\n  <li>We won't charge you anymore</li>\n  <% end %>\n  <li>Everything in the account will be deleted in <%= distance_of_time_in_words_to_now(Account::Incineratable::INCINERATION_GRACE_PERIOD.from_now) %></li>\n</ul>\n\n<i>Changed your mind?</i>\n<p>If you want to cancel this deletion and restore your account, send us an email to <a href=\"mailto:support@fizzy.do\">support@fizzy.do</a> as soon as possible.</p>\n"
  },
  {
    "path": "app/views/mailers/account_mailer/cancellation.text.erb",
    "content": "Your Fizzy account \"<%= @account.name %>\" was cancelled.\n\nWHAT HAPPENS NOW?\n\n- No one can access the account anymore\n<% if @account.try(:subscription) %>\n- We won't charge you anymore\n<% end %>\n- Everything in the account will be deleted in <%= distance_of_time_in_words_to_now(Account::Incineratable::INCINERATION_GRACE_PERIOD.from_now) %>\n\nCHANGED YOUR MIND?\n\nIf you want to cancel this deletion and restore your account, send us an email to support@fizzy.do as soon as possible.\n"
  },
  {
    "path": "app/views/mailers/export_mailer/completed.html.erb",
    "content": "<h1 class=\"title\">Download your Fizzy data</h1>\n<p class=\"subtitle\">Your Fizzy data export has finished processing and is ready to download.</p>\n\n<p><%= link_to \"Download your data\", export_download_url(@export) %></p>\n\n<p class=\"footer\">Need help? <%= mail_to \"support@fizzy.do\", \"Send us an email\" %>.</p>\n"
  },
  {
    "path": "app/views/mailers/export_mailer/completed.text.erb",
    "content": "Your Fizzy data export has finished processing and is ready to download.\n\nDownload your data: <%= export_download_url(@export) %>\n"
  },
  {
    "path": "app/views/mailers/identity_mailer/email_change_confirmation.text.erb",
    "content": "Confirm your email address change\n<%= \"=\" * 80 %>\n\nHit the link below to use this email address in Fizzy:\n\n<%= user_email_address_confirmation_url(user_id: @user.id, email_address_token: @token) %>\n\nIf you didn’t request this change, you can ignore this email. Your email address WILL NOT be changed unless you hit the button.\n"
  },
  {
    "path": "app/views/mailers/import_mailer/completed.html.erb",
    "content": "<p class=\"subtitle\">Your import of <%= @account.name %> is complete!</p>\n\n<p><%= link_to \"Go to your account\", landing_url(script_name: @account.slug) %></p>\n\n<p class=\"footer\">Need help? <%= mail_to \"support@fizzy.do\", \"Send us an email\" %>.</p>\n"
  },
  {
    "path": "app/views/mailers/import_mailer/completed.text.erb",
    "content": "Your import of <%= @account.name %> is complete!\n\nHere's the URL: <%= landing_url(script_name: @account.slug) %>\n"
  },
  {
    "path": "app/views/mailers/import_mailer/failed.html.erb",
    "content": "<p class=\"subtitle\">Unfortunately, we couldn't import your Fizzy account.</p>\n\n<% if @import.failed_due_to_conflict? %>\n  <p>It looks like the account you are trying to import already exists.</p>\n<% elsif @import.failed_due_to_invalid_export? %>\n  <p>The ZIP file isn't a Fizzy account export.</p>\n<% else %>\n  <p>This may be due to corrupted export data or a conflict with existing data. Please try again with a fresh export, or reach out for help if the problem persists.</p>\n<% end %>\n\n<p class=\"footer\">Need help? <%= mail_to \"support@fizzy.do\", \"Send us an email\" %>.</p>\n"
  },
  {
    "path": "app/views/mailers/import_mailer/failed.text.erb",
    "content": "Unfortunately, we couldn't import your Fizzy account.\n\n<% if @import.failed_due_to_conflict? -%>\nIt looks like the account you are trying to import already exists.\n<% elsif @import.failed_due_to_invalid_export? -%>\nThe ZIP file isn't a Fizzy account export.\n<% else -%>\nThis may be due to corrupted export data or a conflict with existing data. Please try again with a fresh export, or reach out for help if the problem persists.\n<% end -%>\n\nNeed help? Send us an email at support@fizzy.do\n"
  },
  {
    "path": "app/views/mailers/magic_link_mailer/sign_in_instructions.html.erb",
    "content": "\n<% if @magic_link.for_sign_in? %>\n  <h1 class=\"title\">Fizzy verification code</h1>\n  <p class=\"subtitle\">Please enter this 6-character verification code on the Fizzy sign-in page:</p>\n<% else %>\n  <h1 class=\"title\">Welcome to Fizzy!</h1>\n  <p class=\"subtitle\">Please enter this 6-character verification code to finish creating your account:</p>\n<% end %>\n\n<strong class=\"txt-large\"><%= @magic_link.code %></strong>\n\n<p>This code will work for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>.</p>\n\n<% if account = @magic_link.identity.accounts.last %>\n  <p>P.S. You can make your account more secure and sign-in faster with a <%= link_to \"Passkey\", my_passkeys_url(script_name: account.slug) %></p>\n<% end %>\n\n<p class=\"footer\">Need help? <%= mail_to \"support@fizzy.do\", \"Send us an email\"%>.\n</p>\n\n"
  },
  {
    "path": "app/views/mailers/magic_link_mailer/sign_in_instructions.text.erb",
    "content": "<% if @magic_link.for_sign_in? %>\nPlease enter this 6-character verification code on the Fizzy sign-in page:\n<% else %>\nPlease enter this 6-character verification code to finish creating your account:\n<% end %>\n\n<%= @magic_link.code %>\n\nThis code will work for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>.\n\n<% if account = @magic_link.identity.accounts.last %>\nP.S. If you want to sign-in faster, and make your account more secure, add a passkey: <%= my_passkeys_url(script_name: account.slug) %>\n<% end %>\n"
  },
  {
    "path": "app/views/mailers/notification/bundle_mailer/_notification.html.erb",
    "content": "<table class=\"notification\">\n  <tr>\n    <td class=\"avatar__container\">\n      <%= mail_avatar_tag(notification.creator) %>\n    </td>\n    <td class=\"what\">\n      <%= render \"notification/bundle_mailer/#{notification.source_type.underscore}/body\", notification: notification %>\n    </td>\n  </tr>\n</table>\n"
  },
  {
    "path": "app/views/mailers/notification/bundle_mailer/_notification.text.erb",
    "content": "<%= render \"notification/bundle_mailer/#{notification.source_type.underscore}/body\", notification: notification %>\n\n"
  },
  {
    "path": "app/views/mailers/notification/bundle_mailer/event/_body.html.erb",
    "content": "<% event = notification.source %>\n\n<p class=\"notification__author\">\n  <%= notification.creator.familiar_name %>\n</p>\n<p class=\"notification__details\">\n  <%= event_notification_body(event) %>\n</p>\n"
  },
  {
    "path": "app/views/mailers/notification/bundle_mailer/event/_body.text.erb",
    "content": "<% event = notification.source %>\n\n<%= notification.creator.name %>:\n<%= event_notification_body(event).squish %>\n<%= url_for notification %>"
  },
  {
    "path": "app/views/mailers/notification/bundle_mailer/mention/_body.html.erb",
    "content": "<% mention = notification.source %>\n\n<strong class=\"notificaton__mention\">\n  <%= \"#{mention.mentioner.first_name} mentioned you:\" %>\n</strong>\n<p class=\"notification__details\">\n  <%= mention.source.mentionable_content.truncate(250) %>\n</p>\n"
  },
  {
    "path": "app/views/mailers/notification/bundle_mailer/mention/_body.text.erb",
    "content": "<% mention = notification.source %>\n\n<%= mention.mentioner.first_name %> mentioned you:\n<%= mention.source.mentionable_content.truncate(250) %>\n<%= url_for mention %>"
  },
  {
    "path": "app/views/mailers/notification/bundle_mailer/notification.html.erb",
    "content": "<tr>\n  <td>\n    <h1 class=\"title\">Notifications since <%= @bundle.starts_at.strftime(\"%-l%P on %A, %B %-d\") %></h1>\n    <p class=\"subtitle margin-block-end-double\">\n      You have <%= link_to pluralize(@notifications.count, \"new notification\"), notifications_url %><%= \" in #{ Current.account.name }\" if @user.identity.accounts.many? %>.\n    </p>\n\n    <% @notifications.group_by { |n| n.card.board }.sort_by { |board, _| board.name.downcase }.each do |board, board_notifications| %>\n      <hr class=\"notification__board-separator\">\n      <h2 class=\"notification__board\"><%= link_to board.name, board %></h2>\n      <% board_notifications.group_by(&:card).each do |card, notifications| %>\n        <%= link_to card, class: \"card__title\" do %>#<%= card.number %> <%= card_html_title(card) %><% end %>\n        <%= render partial: \"notification/bundle_mailer/notification\", collection: notifications, as: :notification %>\n      <% end %>\n    <% end %>\n\n    <p class=\"footer\">Fizzy emails you about new notifications every few hours. <br>\n      <%= link_to \"Change how often you get these\", notifications_settings_url %>\n      or <%= link_to \"unsubscribe from all email notifications\", new_notifications_unsubscribe_url(access_token: @unsubscribe_token) %>.\n    </p>\n  </td>\n</tr>\n"
  },
  {
    "path": "app/views/mailers/notification/bundle_mailer/notification.text.erb",
    "content": "Notifications since <%= @bundle.starts_at.strftime(\"%-l%P on %A, %B %-d\") %>\nYou have <%= pluralize @notifications.count, \"new notification\" %><%= \" in #{ Current.account.name }\" if @user.identity.accounts.many? %>.\n\n<% @notifications.group_by { |n| n.card.board }.sort_by { |board, _| board.name.downcase }.each do |board, board_notifications| %>\n<%= board.name %>\n<%= \"-\" * board.name.length %>\n\n<% board_notifications.group_by(&:card).each do |card, notifications| %>\n<%= \"##{ card.number } #{ card.title }\" %>\n<%= render partial: \"notification/bundle_mailer/notification\", collection: notifications, as: :notification %>\n<% end %>\n<% end %>\n\n--------------------------------------------------------------------------------\n\nFizzy emails you about new notifications every few hours.\n\nChange how often you get these:\n<%= notifications_settings_url %>\n\nUnsubscribe from all email notifications:\n<%= new_notifications_unsubscribe_url(access_token: @unsubscribe_token) %>\n"
  },
  {
    "path": "app/views/my/_menu.html.erb",
    "content": "<nav class=\"nav hide-on-native\"\n    data-controller=\"dialog\"\n    data-action=\"keydown.esc->dialog#close click@document->dialog#closeOnClickOutside mouseenter->dialog#loadLazyFrames\">\n  <%= tag.button class:\"nav__trigger input input--select center flex-inline align-center txt-normal\", data: {\n        action: \"click->dialog#open keydown.j@document->hotkey#click keydown.meta+j@document->hotkey#click keydown.ctrl+j@document->hotkey#click\",\n        controller: \"hotkey\" } do %>\n    <span><%= image_tag \"logo.png\", alt: \"\" %></span>\n    <svg height=\"30\" viewBox=\"0 0 96 30\" width=\"96\" xmlns=\"http://www.w3.org/2000/svg\" title=\"Fizzy\"><path clip-rule=\"evenodd\" d=\"m93.5609 8.52856c.9033.04314 1.3769.47352 1.4199 1.33398.1291 2.40966.0859 5.24976.1289 7.48726l.1289 7.3575c0 15.4902-10.2406 15.8354-16.8242 14.7168-.8606-.1722-1.2482-.7322-1.1621-1.5928l.3867-3.7002c.086-1.0327.732-1.2905 1.6787-.9463 4.3889 1.5919 9.122-.4737 9.0791-4.7334 0-1.0757-.6026-1.2052-1.2481-.3877-.9036 1.0757-2.2802 2.1943-4.3886 2.1944-3.0552 0-8.2188-1.2046-8.2188-11.962 0-2.6677-.086-5.5076-.0429-8.47652 0-.90362.5162-1.37702 1.4199-1.33399 1.2048.04302 2.3665.00006 3.5283-.04297.9036 0 1.4199.47328 1.4199 1.41992 0 3.22686-.0859 5.63626-.0859 6.45406 0 7.7023 1.7647 8.2187 3.7871 8.2618 2.1512.0427 3.5709-1.5491 3.8291-5.2061v-.9902c0-4.604.0861-6.2821-.043-8.47659-.086-.9036.3444-1.41986 1.2481-1.46289zm-43.4629-.21484c1.1186-.04303 1.5064.68823.9472 1.63476-1.8502 3.05502-5.3796 8.73462-8.3486 13.42482-.4733.7745-.1713 1.334.7754 1.334 2.1511.043 2.9261.0431 5.5937-.086.9464-.0429 1.4199.4306 1.42 1.377-.0431.8605-.0001 1.8504 0 2.7109 0 .9036-.4305 1.3769-1.334 1.377-3.9588.043-9.4238-.1721-15.6201-.043-1.1618 0-1.5487-.6881-.9463-1.6348l8.3906-13.2099c.4733-.7315.1721-1.3769-.7315-1.377-2.4096-.043-4.9915.0429-6.8418.1289-.9036.0431-1.4199-.3874-1.4199-1.291-.043-.9035 0-1.9792 0-2.92576 0-.86059.7316-1.33399 2.0225-1.33399 6.1531.04303 12.1341-.04291 16.0928-.08593zm21.1367 0c1.1186-.04294 1.5056.68817.9463 1.63476-1.8503 3.05502-5.3787 8.73462-8.3477 13.42482-.4733.7745-.1721 1.3339.7744 1.334 2.1514.043 2.9261.0431 5.5938-.086.9465-.043 1.4198.4305 1.4199 1.377-.043.8605 0 1.8504 0 2.7109 0 .9036-.4304 1.377-1.334 1.377-3.9586.043-9.4232-.1721-15.6191-.043-1.1617 0-1.5494-.6883-.9473-1.6348l8.3916-13.2099c.4733-.7315.1712-1.377-.7324-1.377-2.4096-.043-4.9916.0429-6.8418.1289-.9033.0429-1.4199-.3875-1.4199-1.291-.0431-.9035 0-1.9792 0-2.92576 0-.86051.7318-1.3339 2.0224-1.33399 6.1533.04303 12.135-.04291 16.0938-.08593zm-43.3252.25781c.9035-.04295 1.4199.38753 1.4199 1.24805.043 1.50602 0 3.65782 0 12.39262 0 5.6796-.086 5.4215 0 6.4111.1291.9036-.3444 1.4198-1.248 1.4629l-3.9161-.086c-.8604-.0431-1.3769-.4735-1.4199-1.3339-.1291-2.4097-.0859-5.2499-.1289-7.4874-.1291-5.5505-.0429-7.8742-.0859-11.23042-.043-.90357.4733-1.37695 1.4199-1.37695 1.7212.08606 2.8832.08606 3.959 0zm-21.88185-8.56250162c2.36657.04302752 4.77645.00000269 8.30465 0 1.2909 0 3.0554.04302142 4.6905 0 .7314 0 1.119.38683562 1.1191 1.16113162-.043 1.46292-.086 3.3134-.043 4.77637 0 .77451-.4305 1.11924-1.205 1.0332-1.2909-.12909-3.3564-.25866-5.5079-.34473-2.0653-.12908-2.1945.04296-4.25972.04297-.8606 0-1.42065.43055-1.59277 1.20508v.04297c-.12909.64544-.12891 1.03327-.12891 1.67871v.51567c.04303.9035.51653 1.3338 1.37696 1.3769 2.40954.043 5.46464.0002 7.70214-.1289.7315-.043 1.1621.3016 1.1621 1.0762l-.0859 4.3886c0 .7315-.4306 1.1192-1.1621 1.0762-2.2374-.043-4.7329-.0439-7.35746-.0869-.9035-.043-1.37677.4736-1.41992 1.334l-.17285 4.3467c0 2.6244.04379 4.0872.17285 5.292.12909.7315-.25872 1.1621-.99023 1.1621l-5.29297.0429c-.731221-.0001-1.075196-.3877-1.075196-1.1191-.000045-1.2478-.2149575-2.8399-.128907-5.7656.129086-5.7659.085256-13.7261-.12988242-21.81546-.04302878-.774508.34469842-1.162087 1.07617542-1.162105 1.592-.0430291 3.70034-.1719109 4.94824-.12890662zm19.90235.34374962c2.6247.00004 3.5283 1.377542 3.5283 3.055662-.0001 1.72099-.9038 3.09762-3.5283 3.09766-2.6677 0-3.5712-1.37665-3.5713-3.09766 0-1.67815.9034-3.055662 3.5713-3.055662z\" fill=\"currentColor\" fill-rule=\"evenodd\"/></svg>\n    <kbd class=\"kbd txt-xx-small hide-on-touch\">J</kbd>\n  <% end %>\n\n  <%= tag.dialog class: \"nav__menu filter popup popup--animated panel margin-block-start-half\", data: {\n        action: \"turbo:before-cache@document->dialog#close turbo:frame-render->navigable-list#reset keydown->navigable-list#navigate filter:changed->navigable-list#reset filter:changed->nav-section-expander#showWhileFiltering toggle->filter#filter\",\n        controller: \"filter navigable-list nav-section-expander\",\n        dialog_target: \"dialog\",\n        navigable_list_focus_on_selection_value: false,\n        navigable_list_actionable_items_value: true,\n        turbo_permanent: true } do %>\n    <%= turbo_frame_tag \"my_menu\", src: my_menu_path, loading: :lazy, target: \"_top\" do %>\n      <% # Passing empty block to avoid double-render  %>\n      <%= render(\"my/menus/jump\") { } %>\n    <% end %>\n  <% end %>\n</nav>\n"
  },
  {
    "path": "app/views/my/access_tokens/_access_token.html.erb",
    "content": "<tr style=\"view-transition-name: <%= dom_id(access_token) %>\">\n  <td><strong><%= access_token.description %></strong></td>\n  <td><%= access_token.permission.humanize %></td>\n  <td><%= local_datetime_tag access_token.created_at, style: :datetime %></td>\n  <td>\n    <%= button_to my_access_token_path(access_token), method: :delete,\n          class: \"btn txt-negative btn--circle txt-x-small borderless fill-transparent\",\n          data: { turbo_confirm: \"Are you sure you want to permanently revoke this access token?\" } do %>\n      <%= icon_tag \"trash\" %>\n      <span class=\"for-screen-reader\">Edit this token</span>\n    <% end %>\n  </td>\n</tr>\n"
  },
  {
    "path": "app/views/my/access_tokens/_access_token.json.jbuilder",
    "content": "json.(access_token, :id, :description, :permission)\njson.created_at access_token.created_at.utc\n"
  },
  {
    "path": "app/views/my/access_tokens/index.html.erb",
    "content": "<% @page_title = \"Personal access tokens\" %>\n\n<% content_for :header do %>\n  <div class=\"header__actions header__actions--start\">\n    <%= back_link_to \"My profile\", user_path(Current.user), \"keydown.left@document->hotkey#click keydown.esc@document->hotkey#click\" %>\n  </div>\n\n  <h1 class=\"header__title\" data-bridge--title-target=\"header\"><%= @page_title %></h1>\n<% end %>\n\n<section class=\"panel panel--wide shadow center webhooks\">\n  <% if @access_tokens.any? %>\n    <p class=\"margin-none-block-start\">Tokens you have generated that can be used to access the Fizzy API.</p>\n    <table class=\"access-tokens margin-block-end-double max-width txt-small\">\n      <thead>\n        <tr>\n          <th>Description</th>\n          <th>Permission</th>\n          <th>Created</th>\n          <th></th>\n        </tr>\n      </thead>\n      <tbody>\n        <%= render partial: \"my/access_tokens/access_token\", collection: @access_tokens %>\n      </tbody>\n    </table>\n  <% else %>\n    <p class=\"margin-none-block-start\">Personal access tokens can be used like a password to access the Fizzy developer API. You can have as many tokens as you need and revoke access to each one at any time.</p>\n  <% end %>\n\n  <%= link_to new_my_access_token_path, class: \"btn btn--link\" do %>\n    <%= icon_tag \"add\" %>\n    <span>Generate a new access token</span>\n  <% end %>\n</section>\n"
  },
  {
    "path": "app/views/my/access_tokens/index.json.jbuilder",
    "content": "json.array! @access_tokens, partial: \"my/access_tokens/access_token\", as: :access_token\n"
  },
  {
    "path": "app/views/my/access_tokens/new.html.erb",
    "content": "<% @page_title = \"Generate a personal access token\" %>\n\n<% content_for :header do %>\n  <div class=\"header__actions header__actions--start\">\n    <%= back_link_to \"tokens\", my_access_tokens_path, \"keydown.left@document->hotkey#click keydown.esc@document->hotkey#click\" %>\n  </div>\n\n  <h1 class=\"header__title\" data-bridge--title-target=\"header\"><%= @page_title %></h1>\n<% end %>\n\n<article class=\"panel panel--wide shadow center txt-align-start\" style=\"view-transition-name: <%= dom_id(@access_token) %>\">\n  <%= form_with model: @access_token, url: my_access_tokens_path, scope: :access_token, data: { controller: \"form\" }, html: { class: \"flex flex-column gap\" } do |form| %>\n    <div class=\"flex flex-column gap-half\">\n      <strong><%= form.label :description, \"Access token description\" %></strong>\n      <%= form.text_field :description, required: true, autofocus: true, class: \"input\", placeholder: \"e.g. Github\", data: { action: \"keydown.esc@document->form#cancel\" } %>\n    </div>\n\n    <div class=\"flex flex-column gap-half\">\n      <strong><%= form.label :permission %></strong>\n      <%= form.select :permission, options_for_select({ \"Read\" => \"read\", \"Read + Write\" => \"write\"}), {}, class: \"input input--select\" %>\n    </div>\n\n    <%= form.button type: :submit, class: \"btn btn--link center txt-medium\" do %>\n      <span>Generate access token</span>\n    <% end %>\n\n     <%= link_to \"Cancel and go back\", my_access_tokens_path, data: { form_target: \"cancel\" }, hidden: true %>\n  <% end %>\n</article>\n"
  },
  {
    "path": "app/views/my/access_tokens/show.html.erb",
    "content": "<% @page_title = \"New personal access token\" %>\n\n<% content_for :header do %>\n  <div class=\"header__actions header__actions--start\">\n    <%= back_link_to \"Tokens\", my_access_tokens_path, \"keydown.left@document->hotkey#click keydown.esc@document->hotkey#click\" %>\n  </div>\n\n  <h1 class=\"header__title\" data-bridge--title-target=\"header\"><%= @page_title %></h1>\n<% end %>\n\n<article class=\"panel panel--wide shadow center txt-align-start\" style=\"view-transition-name: <%= dom_id(@access_token) %>\">\n  <div class=\"flex flex-column gap\">\n    <label class=\"flex flex-column gap-half txt-align-start\">\n      <strong><%= @access_token.description %> (<%= @access_token.permission == \"write\" ? \"Read + Write\" : \"Read\" %>)</strong>\n      <input type=\"text\" value=\"<%= @access_token.token %>\" class=\"input\" readonly>\n    </label>\n    <p class=\"margin-none txt-small\">Be sure to save this access token now because you won’t be able to see it again.</p>\n\n    <%= tag.button class: \"btn btn--link center\", data: {\n      controller: \"copy-to-clipboard\", action: \"copy-to-clipboard#copy\",\n          copy_to_clipboard_success_class: \"btn--success\", copy_to_clipboard_content_value: @access_token.token } do %>\n      <%= icon_tag \"copy-paste\" %>\n      <span>Copy access token</span>\n    <% end %>\n  </div>\n</article>\n"
  },
  {
    "path": "app/views/my/identities/_account.json.jbuilder",
    "content": "json.cache! account do\n  json.(account, :id, :name, :slug)\n  json.created_at account.created_at.utc\nend\n"
  },
  {
    "path": "app/views/my/identities/show.json.jbuilder",
    "content": "json.id @identity.id\n\njson.accounts @identity.users_with_active_accounts do |user|\n  json.partial! \"my/identities/account\", account: user.account\n  json.user user, partial: \"users/user\", as: :user\nend\n"
  },
  {
    "path": "app/views/my/menus/_accounts.html.erb",
    "content": "<% if accounts.many? %>\n  <% cache [ Current.identity, accounts, Current.account ] do %>\n    <%= collapsible_nav_section \"Accounts\" do %>\n      <%# Bust cache 1 Dec 2025 %>\n      <% accounts.each do |account| %>\n        <%= filter_place_menu_item landing_path(script_name: account.slug), account.name, \"marker\", current: account == Current.account, turbo: false %>\n      <% end %>\n    <% end %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/my/menus/_boards.html.erb",
    "content": "<%= collapsible_nav_section \"Boards\" do %>\n  <li class=\"popup__item\" data-filter-target=\"item\" data-navigable-list-target=\"item\">\n    <%= icon_tag \"add\", class: \"popup__icon\" %>\n    <%= link_to new_board_path, class: \"popup__btn btn\" do %>\n      <span class=\"overflow-ellipsis\">Add a board</span>\n    <% end %>\n  </li>\n\n  <% boards.each do |board| %>\n    <%= my_menu_board_item(board) %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/my/menus/_custom_views.html.erb",
    "content": "<%= collapsible_nav_section \"Custom views\", id: \"my-filters\" do %>\n  <%= form_with url: cards_path, method: :get, data: { controller: \"form\" } do |form| %>\n    <li class=\"popup__item overflow-ellipsis\" data-navigable-list-target=\"item\" data-filter-target=\"item\" id=\"filter-custom-create\">\n      <%= icon_tag \"bookmark\", class: \"popup__icon\" %>\n      <%= link_to cards_path(expand_all: true), class: \"popup__btn btn\" do %>\n        <span>Create a custom view</span>\n      <% end %>\n    </li>\n\n    <% filters.each do |filter| %>\n      <%= my_menu_filter_item(filter) %>\n    <% end %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/my/menus/_jump.html.erb",
    "content": "<div class=\"flex gap\">\n  <div class=\"flex flex-column full-width justify-center gap-half\">\n    <div class=\"nav__header\" tabindex=\"-1\" data-dialog-target=\"focusTouch\">\n      <% if Current.identity.accounts.many? %>\n        <div class=\"nav__header-title max-width\">\n          <div class=\"overflow-ellipsis\"><strong><%= Current.account.name %></strong></div>\n        </div>\n      <% end %>\n      <div class=\"nav__header-actions nav__header-actions--end nav__close\">\n        <button class=\"btn txt-xx-small\" data-action=\"dialog#close\">\n          <%= icon_tag \"close\" %>\n          <span class=\"for-screen-reader\">Close menu</span>\n        </button>\n      </div>\n    </div>\n    <%= jump_field_tag %>\n  </div>\n</div>\n\n<div class=\"nav__scroll-container\">\n  <div class=\"nav__hotkeys margin-block-end-half\" role=\"list\">\n    <%= filter_hotkey_link \"Home\", root_path, 1, \"home\" %>\n    <%= filter_hotkey_link \"Assigned to me\", cards_path(assignee_ids: [Current.user.id]), 2, \"clipboard\" %>\n    <%= filter_hotkey_link \"Added by me\", cards_path(creator_ids: [Current.user.id]), 3, \"person-add\" %>\n  </div>\n\n  <%= yield %>\n\n  <div class=\"nav__blank-slate blank-slate\">\n    Nothing matches that filter\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/my/menus/_people.html.erb",
    "content": "<%= collapsible_nav_section \"People\" do %>\n  <li class=\"popup__item\" data-filter-target=\"item\" data-navigable-list-target=\"item\">\n    <%= icon_tag \"add\", class: \"popup__icon\" %>\n    <%= link_to account_join_code_path, class: \"popup__btn btn\" do %>\n      <span class=\"overflow-ellipsis\">Invite people</span>\n    <% end %>\n  </li>\n\n  <% users.each do |user| %>\n    <%= my_menu_user_item(user) %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/my/menus/_settings.html.erb",
    "content": "<%= collapsible_nav_section \"Settings\" do %>\n  <%= filter_place_menu_item account_settings_path, \"Account Settings\", \"settings\" %>\n  <%= filter_place_menu_item user_path(Current.user), \"My Profile\", \"person\" %>\n  <%= filter_place_menu_item notifications_path, \"All notifications\", \"bell\" %>\n  <%= filter_place_menu_item notifications_settings_path, \"Notification Settings\", \"settings\" %>\n\n  <%= tag.li class: \"popup__item\", data: { filter_target: \"item\", navigable_list_target: \"item\" } do %>\n    <%= icon_tag \"logout\", class: \"popup__icon\" %>\n    <%= button_to session_path(script_name: nil), method: :delete, class: \"popup__btn btn\", form: { data: { turbo: false, controller: \"clear-offline-cache\", action: \"submit->clear-offline-cache#clearCache\" } } do %>\n      <span>Sign out</span>\n    <% end %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/my/menus/_shortcuts.html.erb",
    "content": "<%= collapsible_nav_section \"Shortcuts\", class: \"nav__section popup__section nav__section--secret\" do %>\n  <%= filter_place_menu_item cards_path(indexed_by: :golden), \"Golden cards\", \"filter\" %>\n  <%= filter_place_menu_item cards_path(indexed_by: :stalled), \"Stalled cards\", \"filter\" %>\n  <%= filter_place_menu_item cards_path(indexed_by: :postponing_soon), \"Cards closing soon\", \"filter\" %>\n  <%= filter_place_menu_item cards_path(creation: \"today\"), \"Added today\", \"filter\" %>\n  <%= filter_place_menu_item cards_path(closure: \"today\"), \"Done today\", \"filter\" %>\n<% end %>"
  },
  {
    "path": "app/views/my/menus/_tags.html.erb",
    "content": "<%= collapsible_nav_section \"Tags\" do %>\n  <% tags.each do |tag| %>\n    <%= my_menu_tag_item(tag) %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/my/menus/show.html.erb",
    "content": "<%= turbo_frame_tag \"my_menu\", target: \"_top\" do %>\n  <%= render \"my/menus/jump\" do %>\n    <%= render \"my/menus/boards\", boards: @boards %>\n    <%= render \"my/menus/tags\", tags: @tags %>\n    <%= render \"my/menus/people\", users: @users %>\n    <%= render \"my/menus/settings\" %>\n    <%= render \"my/menus/shortcuts\" %>\n    <%= render \"my/menus/accounts\", accounts: @accounts %>\n  <% end %>\n\n  <footer class=\"nav__footer\">\n    <%= render \"layouts/shared/colophon\" %>\n  </footer>\n<% end %>\n"
  },
  {
    "path": "app/views/my/passkeys/_passkey.html.erb",
    "content": "<li class=\"credential\" style=\"view-transition-name: <%= dom_id(passkey) %>\">\n  <%= link_to edit_my_passkey_path(passkey), class: \"credential__link\" do %>\n    <% if icon = passkey.authenticator&.icon %>\n      <%= image_tag icon[:light], size: 24, class: \"flex-item-no-shrink hide-on-dark-mode\", aria: { hidden: true } %>\n      <%= image_tag icon[:dark], size: 24, class: \"flex-item-no-shrink hide-on-light-mode\", aria: { hidden: true } %>\n    <% else %>\n      <%= image_tag \"passkeys/generic_light.svg\", size: 24, class: \"flex-item-no-shrink hide-on-dark-mode\", aria: { hidden: true } %>\n      <%= image_tag \"passkeys/generic_dark.svg\", size: 24, class: \"flex-item-no-shrink hide-on-light-mode\", aria: { hidden: true } %>\n    <% end %>\n    <strong class=\"overflow-ellipsis min-width\"><%= passkey.name.presence || \"Passkey\" %></strong>\n    <strong class=\"credential__arrow\">→</strong>\n  <% end %>\n</li>\n"
  },
  {
    "path": "app/views/my/passkeys/edit.html.erb",
    "content": "<% @page_title = \"Edit Passkey\" %>\n\n<% content_for :header do %>\n  <div class=\"header__actions header__actions--start\">\n    <%= back_link_to \"Passkeys\", my_passkeys_path, \"keydown.left@document->hotkey#click keydown.esc@document->hotkey#click\" %>\n  </div>\n\n  <h1 class=\"header__title\" data-bridge--title-target=\"header\"><%= @page_title %></h1>\n<% end %>\n\n<section class=\"panel panel--wide shadow center txt-align-start flex flex-column gap\">\n  <% if params[:created] %>\n    <p class=\"margin-none\">Your passkey has been registered. Give it a name so you can identify it later.</p>\n  <% end %>\n\n  <%= form_with model: @passkey, scope: :passkey, url: my_passkey_path(@passkey), html: { class: \"flex flex-column gap\" } do |form| %>\n    <div class=\"flex flex-column gap-half\">\n      <strong><%= form.label \"Name your passkey\" %></strong>\n      <%= form.text_field :name, autofocus: true, class: \"input\", placeholder: \"e.g. MacBook Pro, iPhone\", data: { \"1p-ignore\": \"\" }, autocomplete: \"off\" %>\n    </div>\n\n    <%= form.submit \"Save\", class: \"btn btn--link center\" %>\n  <% end %>\n\n  <div class=\"txt-align-center\">\n    <%= button_to my_passkey_path(@passkey), method: :delete,\n          class: \"btn txt-negative borderless txt-small\",\n          data: { turbo_confirm: \"Are you sure you want to remove this passkey?\" } do %>\n      <%= icon_tag \"trash\" %>\n      <span>Remove this passkey</span>\n    <% end %>\n  </div>\n</section>\n"
  },
  {
    "path": "app/views/my/passkeys/index.html.erb",
    "content": "<% @page_title = \"Passkeys\" %>\n\n<% content_for :head do %>\n  <%= passkey_creation_options_meta_tag(@creation_options) %>\n<% end %>\n\n<% content_for :header do %>\n  <div class=\"header__actions header__actions--start\">\n    <%= back_link_to \"My profile\", user_path(Current.user), \"keydown.left@document->hotkey#click keydown.esc@document->hotkey#click\" %>\n  </div>\n\n  <h1 class=\"header__title\" data-bridge--title-target=\"header\"><%= @page_title %></h1>\n<% end %>\n\n<section class=\"panel panel--wide shadow center\" data-passkey-errors>\n  <p class=\"font-weight-medium margin-none-block-start\">Passkeys let you sign in securely without a password or email code.</p>\n\n  <% if @passkeys.any? %>\n    <ul class=\"margin-none-block-start margin-block-end-double unpad\">\n      <%= render partial: \"my/passkeys/passkey\", collection: @passkeys %>\n    </ul>\n  <% end %>\n\n  <footer>\n    <%= passkey_creation_button my_passkeys_path, class: \"btn btn--link center txt-medium\" do %>\n      <%= icon_tag \"add\" %>\n      <span>Register a passkey</span>\n    <% end %>\n\n    <p class=\"txt-small txt-subtle txt-balance margin-none-block-end\">\n      Your browser will prompt you to create a passkey using your device's biometrics, PIN, or security key\n    </p>\n\n    <p data-passkey-error=\"error\" class=\"txt-negative\">\n      Something went wrong while registering your passkey.\n    </p>\n\n    <p data-passkey-error=\"cancelled\" class=\"txt-subtle\">\n      Passkey registration was cancelled.\n      Try again when you are ready.\n    </p>\n  </footer>\n</section>\n"
  },
  {
    "path": "app/views/my/pins/_pin.html.erb",
    "content": "<div class=\"tray__item tray__item--pin\" id=\"<%= dom_id pin %>\" data-navigable-list-target=\"item\">\n  <%= render \"cards/display/preview\", card: pin.card %>\n  <%= button_to card_pin_path(pin.card), method: :delete, class: \"tray__remove-pin-btn btn btn--circle borderless\" do %>\n    <%= icon_tag \"pinned\" %> <span class=\"for-screen-reader\">Unpin this card</span>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/my/pins/_tray.html.erb",
    "content": "<%= turbo_stream_from Current.user, :pins_tray %>\n\n<section class=\"tray tray--pins\" data-controller=\"dialog\">\n  <%= tag.dialog id: \"pin-tray\", class: \"tray__dialog\", data: {\n        action: \"keydown->navigable-list#navigate dialog:show@document->navigable-list#reset keydown.esc->dialog#close:stop click@document->dialog#closeOnClickOutside\",\n        controller: \"navigable-list\",\n        dialog_target: \"dialog\",\n        navigable_list_actionable_items_value: \"true\",\n        navigable_list_reverse_navigation_value: \"true\" },\n        turbo_permanent: true do %>\n    <%= turbo_frame_tag \"pins\", src: my_pins_path, data: { controller: \"frame\", action: \"turbo:morph@document->frame#reload\" } %>\n  <% end %>\n\n  <button class=\"tray__toggle\" data-action=\"dialog#toggle keydown.p@document->hotkey#click\" data-controller=\"hotkey\" aria-label=\"Toggle pins stack\" aria-haspopup=\"true\">\n    <div class=\"tray__toggle-btn txt-uppercase btn btn--reversed txt-x-small center full-width\">\n      <%= icon_tag \"pinned\" %>\n      <span class=\"tray__toggle-text\">Pinned <kbd class=\"hide-on-touch\">P</kbd></span>\n    </div>\n  </button>\n</section>\n"
  },
  {
    "path": "app/views/my/pins/index.html.erb",
    "content": "<% @page_title = \"Pinned\" %>\n\n<% content_for :header do %>\n  <div class=\"header__actions header__actions--start\">\n    <%= back_link_to \"Home\", root_path, \"keydown.left@document->hotkey#click keydown.esc@document->hotkey#click\" %>\n  </div>\n\n  <h1 class=\"header__title\" data-bridge--title-target=\"header\"><%= @page_title %></h1>\n<% end %>\n\n<div class=\"pins-list panel panel--wide flex center borderless unpad flex-column gap-half margin-block-start\">\n  <%= turbo_frame_tag \"pins\" do %>\n    <%= render partial: \"my/pins/pin\", collection: @pins %>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/my/pins/index.json.jbuilder",
    "content": "json.array! @pins do |pin|\n  json.partial! \"cards/card\", card: pin.card\nend\n"
  },
  {
    "path": "app/views/notifications/_notification.html.erb",
    "content": "<% cache notification do %>\n  <%# Helper Dependency Updated: avatar_image_tag 2025-12-15 %>\n  <%= notification_tag notification do %>\n    <%= render \"notifications/notification/header\", notification: notification do %>\n      <%= notification_toggle_read_button(notification, url: notification_reading_path(notification)) %>\n    <% end %>\n    <%= render \"notifications/notification/body\", notification: notification %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/notifications/_notification.json.jbuilder",
    "content": "json.cache! notification do\n  json.(notification, :id, :unread_count)\n  json.read notification.read?\n  json.read_at notification.read_at&.utc\n  json.created_at notification.created_at.utc\n  json.source_type notification.source_type.underscore\n\n  json.partial! \"notifications/notification/#{notification.source_type.underscore}/body\", notification: notification\n\n  json.creator notification.creator, partial: \"users/user\", as: :user\n\n  json.card do\n    json.(notification.card, :id, :number, :title, :status)\n    json.board_name notification.card.board.name\n    json.closed notification.card.closed?\n    json.postponed notification.card.postponed?\n    json.url card_url(notification.card)\n    json.column notification.card.column, partial: \"columns/column\", as: :column if notification.card.column\n  end\n\n  json.url notification_url(notification)\nend\n"
  },
  {
    "path": "app/views/notifications/_tray.html.erb",
    "content": "<%= turbo_stream_from Current.user, :notifications %>\n\n<section class=\"tray tray--notifications\" data-controller=\"dialog badge\" data-badge-unread-class=\"unread\" data-action=\"turbo:render#update\" data-dialog-sizing-value=\"false\">\n  <dialog class=\"tray__dialog\"\n      data-controller=\"navigable-list\"\n      data-dialog-target=\"dialog\"\n      data-navigable-list-actionable-items-value=\"true\"\n      data-navigable-list-reverse-navigation-value=\"true\"\n      data-action=\"keydown->navigable-list#navigate dialog:show@document->navigable-list#reset turbo-visit->navigable-list#reset keydown.esc->dialog#close:stop click@document->dialog#closeOnClickOutside\">\n    <%= turbo_frame_tag \"notifications\", src: tray_notifications_path, refresh: \"morph\", data: {\n          controller: \"frame-reloader\",\n          action: \"focus@window->frame-reloader#reload\" } %>\n\n    <div class=\"tray__item tray__item--hat txt-x-small gap-half\">\n      <div data-navigable-list-target=\"item\" class=\"full-width\">\n        <%= link_to notifications_settings_path,\n              class: \"btn borderless tray__notification-settings\",\n              title: \"Notification Settings\",\n              data: { action: \"dialog#close\" } do %>\n          <%= icon_tag \"settings\" %>\n          <span>Settings</span>\n        <% end %>\n      </div>\n\n      <div data-navigable-list-target=\"item\" class=\"full-width\">\n        <%= link_to notifications_path, class: \"btn borderless flex-item-grow position-relative overflow-ellipsis\", data: { action: \"click->dialog#close\" } do %>\n          <%= icon_tag \"bell\" %>\n          <span class=\"tray__new-notifications\">See more new items</span>\n          <span class=\"tray__old-notifications\">See older items</span>\n        <% end %>\n      </div>\n\n      <%= button_to bulk_reading_path(from_tray: true),\n            class: \"btn borderless tray__clear-notifications\",\n            title: \"Mark all notifications as read\",\n            data: { action: \"dialog#close badge#clear\", turbo_frame: \"notifications\" },\n            form: { class: \"full-width\", data: { navigable_list_target: \"item\" } } do %>\n        <%= icon_tag \"check\" %>\n        <span>Clear all</span>\n      <% end %>\n    </div>\n  </dialog>\n\n  <button class=\"tray__toggle\" data-action=\"dialog#toggle keydown.n@document->hotkey#click\" data-controller=\"hotkey\" aria-label=\"Toggle notifications stack\" aria-haspopup=\"true\">\n    <div class=\"tray__toggle-btn txt-uppercase btn btn--reversed txt-x-small center full-width\">\n      <%= icon_tag \"bell\" %>\n      <span class=\"tray__toggle-text\">Notifications <kbd class=\"hide-on-touch\">N</kbd></span>\n    </div>\n  </button>\n</section>\n"
  },
  {
    "path": "app/views/notifications/index/_read_notifications.html.erb",
    "content": "<% if page.records.any? %>\n  <section class=\"notifications-list notifications-list--read panel panel--wide center borderless unpad flex flex-column gap-half margin-block-start\">\n    <h2 class=\"txt-medium margin-block-start-double margin-block-end-half txt-uppercase translucent\">Previously seen</h2>\n\n    <div id=\"notifications_list_read\" contents>\n      <%= render partial: \"notifications/notification\", collection: page.records, cached: true %>\n    </div>\n  </section>\n<% end %>\n"
  },
  {
    "path": "app/views/notifications/index/_unread_notifications.html.erb",
    "content": "<section class=\"notifications-list panel panel--wide center borderless unpad flex flex-column gap-half\">\n  <% if unread.any? %>\n    <div class=\"flex align-center justify-space-between margin-block-start margin-block-end-half\">\n      <h2 class=\"txt-medium txt-uppercase txt-alert\">New for you</h2>\n      <%= button_to \"Mark all as read\", bulk_reading_path, class: \"btn txt-small\", form: { data: { turbo: false } }, data: { action: \"badge#clear\" } %>\n    </div>\n  <% else %>\n    <div class=\"notifications-list__blank-slate blank-slate align-self-center\">\n      Nothing new for you\n    </div>\n  <% end %>\n\n  <div id=\"notifications_list\" contents>\n    <%= render partial: \"notifications/notification\", collection: unread, cached: true %>\n  </div>\n\n  <% if unread.any? %>\n    <% total_unread_count = Current.user.notifications.unread.count %>\n    <% if total_unread_count > NotificationsController::MAX_UNREAD_NOTIFICATIONS %>\n      <div class=\"fill-highlight txt-x-small border-radius pad-block-half pad-inline\">\n        Showing the <%= NotificationsController::MAX_UNREAD_NOTIFICATIONS %> most recent (<%= total_unread_count - NotificationsController::MAX_UNREAD_NOTIFICATIONS %> are hidden)\n      </div>\n    <% end %>\n  <% end %>\n</section>\n"
  },
  {
    "path": "app/views/notifications/index.html.erb",
    "content": "<% @page_title = \"Notifications\" %>\n<% @hide_footer_frames = true %>\n\n<% content_for :header do %>\n  <div class=\"header__actions header__actions--start\">\n    <%= back_link_to \"Home\", root_path, \"keydown.left@document->hotkey#click keydown.esc@document->hotkey#click\" %>\n  </div>\n\n  <h1 class=\"header__title\" data-bridge--title-target=\"header\"><%= @page_title %></h1>\n\n  <div class=\"header__actions header__actions--end\">\n    <%= link_to notifications_settings_path, class: \"btn btn--circle-mobile\", data: { bridge__overflow_menu_target: \"item\", bridge_title: \"Notification settings\" } do %>\n      <%= icon_tag \"settings\" %> <span class=\"for-screen-reader\">Notification settings</span>\n    <% end %>\n  </div>\n<% end %>\n\n<div data-controller=\"badge navigable-list\" data-badge-unread-class=\"unread\" data-action=\"keydown@document->navigable-list#navigate\" data-navigable-list-actionable-items-value=\"true\"\n      data-navigable-list-focus-on-selection-value=\"false\">\n  <%= render \"notifications/index/unread_notifications\", unread: @unread if @unread %>\n  <%= render \"notifications/index/read_notifications\", page: @page %>\n</div>\n\n<%= notifications_next_page_link(@page) if @page.records.any? %>\n"
  },
  {
    "path": "app/views/notifications/index.json.jbuilder",
    "content": "json.array! (@unread || []) + @page.records, partial: \"notifications/notification\", as: :notification, cached: true\n"
  },
  {
    "path": "app/views/notifications/index.turbo_stream.erb",
    "content": "<%= turbo_stream.append :notifications_list_read, partial: \"notifications/notification\", collection: @page.records %>\n<%= turbo_stream.replace :next_page, notifications_next_page_link(@page) %>\n"
  },
  {
    "path": "app/views/notifications/notification/_body.html.erb",
    "content": "<div class=\"card__body\">\n  <div class=\"avatar txt-x-small\">\n    <%= avatar_image_tag notification.creator %>\n  </div>\n\n  <h3 class=\"flex flex-column min-width flex-item-grow font-weight-normal\">\n    <%= render \"notifications/notification/#{notification.source_type.underscore}/body\", notification: notification %>\n  </h3>\n</div>\n"
  },
  {
    "path": "app/views/notifications/notification/_header.html.erb",
    "content": "<header class=\"card__header\">\n  <div class=\"card__board\">\n  <span class=\"card__id\">\n    <span class=\"for-screen-reader\">Card number</span>\n    <%= notification.card.number %>\n  </span>\n    <span class=\"card__board-name\">\n    <span class=\"overflow-ellipsis\"><%= notification.card.board.name %></span>\n  </span>\n  </div>\n\n  <div class=\"card__notification-meta overflow-ellipsis flex-item-grow flex-item-no-shrink txt-align-end\">\n    <span class=\"card__creator\"><%= notification.creator.familiar_name %></span>\n  </div>\n\n  <div class=\"card__notification-meta overflow-ellipsis flex-item-no-shrink\">\n    <span class=\"card__timestamp\"><%= local_datetime_tag(notification.created_at, style: :timeordate) %></span>\n  </div>\n\n  <div class=\"card__notification-meta flex-item-no-shrink\">\n    <%= yield %>\n  </div>\n</header>\n"
  },
  {
    "path": "app/views/notifications/notification/event/_body.html.erb",
    "content": "<% event = notification.source %>\n\n<div class=\"card__title overflow-ellipsis\">\n  <%= event_notification_title(event) %>\n</div>\n\n<div class=\"card__notification-body overflow-ellipsis\">\n  <%= event_notification_body(event) %>\n</div>\n"
  },
  {
    "path": "app/views/notifications/notification/event/_body.json.jbuilder",
    "content": "json.title event_notification_title(notification.source)\njson.body event_notification_body(notification.source)\n"
  },
  {
    "path": "app/views/notifications/notification/mention/_body.html.erb",
    "content": "<% mention = notification.source %>\n\n<strong class=\"card__title overflow-ellipsis\"><mark class=\"card__notification-mentioner\"><%= mention.mentioner.first_name %> @mentioned you</mark></strong>\n<div class=\"card__notification-body overflow-ellipsis\">\n  <%= mention.source.mentionable_content.truncate(200) %>\n</div>\n"
  },
  {
    "path": "app/views/notifications/notification/mention/_body.json.jbuilder",
    "content": "mention = notification.source\n\njson.title \"#{mention.mentioner.first_name} @mentioned you\"\njson.body mention.source.mentionable_content.truncate(200)\n"
  },
  {
    "path": "app/views/notifications/readings/create.turbo_stream.erb",
    "content": "<%= turbo_stream.remove @notification %>\n<%= turbo_stream.prepend :notifications_list_read, partial: \"notifications/notification\", locals: { notification: @notification } %>\n"
  },
  {
    "path": "app/views/notifications/readings/destroy.turbo_stream.erb",
    "content": "<%= turbo_stream.remove @notification %>\n<%= turbo_stream.prepend :notifications_list, partial: \"notifications/notification\", locals: { notification: @notification } %>\n"
  },
  {
    "path": "app/views/notifications/settings/_board.html.erb",
    "content": "<%= turbo_frame_tag board, :involvement do %>\n  <div class=\"flex align-center gap\">\n    <h2 class=\"txt-medium overflow-ellipsis\">\n      <%= board.name %>\n    </h2>\n\n    <hr class=\"separator--horizontal flex-item-grow\" style=\"--border-color: var(--color-ink-medium); --border-style: dashed\" aria-hidden=\"true\">\n\n    <label class=\"flex-item-no-shrink txt-small\">\n      <%= access_involvement_advance_button(board, user, show_watchers: false, icon_only: true) %>\n    </label>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/notifications/settings/_browser.html.erb",
    "content": "<% unless (platform.safari? || platform.chrome?) && platform.ios? %>\n  <div class=\"notifications-help\" data-notifications-target=\"details\">\n    <% case\n      when platform.firefox? && platform.android? %>\n        <h3>Turn on notifications for <%= platform.browser.capitalize %>.</h3>\n        <ol>\n          <li>Tap <em><%= icon_tag \"lock\", alt: \"the View site information button\" %></em> in the address bar.</li>\n          <li>Tap <em>Notification</em> to change to <em>Allowed</em>.</li>\n        </ol>\n      <% when platform.edge? && platform.desktop? %>\n        <h3>Turn on notifications for this website.</h3>\n        <ol>\n          <li>Click <em><%= icon_tag \"lock\", alt: \"the View site information button\" %></em> left of the address bar.</li>\n          <li>Under <em>Permissions for this site &gt; Notifications</em>, choose <em>Allow</em>.</li>\n        </ol>\n        <h3>Turn on notifications for <%= platform.browser.capitalize %>.</h3>\n        <ol>\n          <% if platform.windows? %>\n            <li>Click <em>Start</em>, then <em>Settings</em>.</li>\n            <li>Go to <em>System &gt; Notification</em>.</li>\n            <li>Click <em><%= icon_tag \"switch\", alt: \"the switch\" %></em> <em>ON</em> for <%= platform.browser.capitalize %>.</li>\n          <% else %>\n            <li>Click <em aria-label=\"the Apple menu\"></em> in the top left.</li>\n            <li>Click <em>System Settings…</em></li>\n            <li>Click <em>Notifications</em>.</li>\n            <li>Click <em><%= platform.browser.capitalize %></em>.</li>\n            <li>Click <em><%= icon_tag \"switch\", alt: \"the switch\" %></em> to <em>Allow notifications</em>.</li>\n          <% end %>\n        </ol>\n      <% when platform.firefox? && platform.desktop? %>\n        <h3>Turn on notifications for this website.</h3>\n        <ol>\n          <li>Click <em><%= platform.browser.capitalize %></em> in the top left.</li>\n          <li>Click <em>Settings…</em></li>\n          <li>Click <em>Privacy & Security</em> in the sidebar.</li>\n          <li>Scroll down to <em>Permissions</em>.</li>\n          <li>Click <em>Settings</em> next to <em>Notifications</em>.</li>\n          <li>Select <em>Allow</em> next to <em><%= root_url %></em>.</li>\n        </ol>\n\n        <h3>Turn on notifications for <%= platform.browser.capitalize %>.</h3>\n        <ol>\n          <% if platform.windows? %>\n            <li>Click <em>Start</em>, then <em>Settings</em>.</li>\n            <li>Go to <em>System &gt; Notification</em>.</li>\n            <li>Click <em><%= icon_tag \"switch\", alt: \"the toggle button\" %></em> <em>ON</em> for <%= platform.browser.capitalize %>.</li>\n          <% else %>\n            <li>Click <em aria-label=\"the Apple menu\"></em> in the top left.</li>\n            <li>Click <em>System Settings…</em></li>\n            <li>Click <em>Notifications</em>.</li>\n            <li>Click <em><%= platform.browser.capitalize %></em>.</li>\n            <li>Click <em><%= icon_tag \"switch\", alt: \"the switch\" %></em> to <em>Allow notifications</em>.</li>\n          <% end %>\n        </ol>\n      <% when platform.chrome? && platform.desktop? %>\n        <h3>Turn on notifications for this website.</h3>\n        <ol>\n          <li>Click the <em><%= icon_tag \"sliders\", alt: \"View site information\" %></em> icon in the address bar.</li>\n          <li>Click <em>Site Settings</em>.</li>\n          <li>Ensure notifications are <em>Allowed</em>.</li>\n        </ol>\n\n        <h3>Turn on notifications for <%= platform.browser.capitalize %>.</h3>\n        <ol>\n          <% if platform.windows? %>\n            <li>Click <em>Start</em>, then <em>Settings</em>.</li>\n            <li>Go to <em>System &gt; Notification</em>.</li>\n            <li>Click <em><%= icon_tag \"switch\", alt: \"the switch\" %></em> <em>ON</em> for <%= platform.browser.capitalize %>.</li>\n          <% else %>\n            <li>Click <em aria-label=\"the Apple menu\"></em> in the top left.</li>\n            <li>Click <em>System Settings…</em></li>\n            <li>Click <em>Notifications</em>.</li>\n            <li>Click <em><%= platform.browser == \"Chrome\" ? \"Google Chrome\" : platform.browser.capitalize %></em>.</li>\n            <li>Click <em><%= icon_tag \"switch\", alt: \"the switch\" %></em> to <em>Allow notifications</em>.</li>\n          <% end %>\n        </ol>\n      <% when platform.chrome? && platform.android? %>\n        <h3>Turn on notifications for <%= platform.browser.capitalize %>.</h3>\n        <ol>\n          <li>Tap the <em><%= icon_tag \"menu-dots-vertical\", alt: \"More options\" %></em> menu button.</li>\n          <li>Tap <em>Settings</em>.</li>\n          <li>Tap <em>Notifications</em>.</li>\n          <li>Tap <em><%= icon_tag \"switch\", alt: \"the switch\" %></em> to <em>Allow <%= platform.browser.capitalize %> notifications</em>.</li>\n          <li>Tap <em><%= icon_tag \"switch\", alt: \"the switch\" %></em> next to <em>Web apps</em>.</li>\n          <li>Tap <em><%= icon_tag \"bell-alert\", alt: \"the notification bell\" %></em> and select <em>Allow</em>.</li>\n        </ol>\n      <% when platform.safari? && platform.desktop? %>\n        <h3>Turn on notifications for this website.</h3>\n        <ol>\n          <li>Click <em aria-label=\"the Apple menu\"></em> in the top left.</li>\n          <li>Click <em>System Settings…</em></li>\n          <li>Click <em>Notifications</em>.</li>\n          <li>Click <em><%= request.base_url %></em> in the list.</li>\n          <li>Click <em><%= icon_tag \"switch\", alt: \"the switch\" %></em> to <em>Allow notifications</em>.</li>\n        </ol>\n        <h3>Turn on notifications for <%= platform.browser.capitalize %>.</h3>\n        <ol>\n          <li>Click <em><%= platform.browser.capitalize %></em> in the top left.</li>\n          <li>Click <em>Settings…</em></li>\n          <li>Click the <em>Websites</em> tab.</li>\n          <li>Click <em>Notifications</em> in the sidebar.</li>\n          <li>Click <em><%= request.base_url %></em> in the list.</li>\n          <li>Select <em>Allow</em>.</li>\n        </ol>\n      <% else %>\n        <p>Ensure notifications are enabled for <em><%= root_url %></em> in your web browser settings.</p>\n    <% end %>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/notifications/settings/_email.html.erb",
    "content": "<section class=\"settings__section\">\n  <heading>\n    <h2 class=\"divider\">Email Notifications</h2>\n    <div>Get a single email with all your notifications every few hours, daily, or weekly.</div>\n  </heading>\n\n  <%= form_with model: settings, url: notifications_settings_path,\n      method: :patch, local: true, data: { controller: \"form\" } do |form| %>\n    <div class=\"margin-block-end-half\"><strong><%= form.label :bundle_email_frequency, \"Email me about new notifications...\" %></strong></div>\n    <%= form.select :bundle_email_frequency, bundle_email_frequency_options_for(settings), {}, class: \"input input--select txt-align-center\", data: { action: \"change->form#submit\" } %>\n  <% end %>\n</section>\n"
  },
  {
    "path": "app/views/notifications/settings/_install.html.erb",
    "content": "<% unless (platform.chrome? && !platform.ios?) || (platform.firefox? && !platform.android?) %>\n  <div class=\"notifications-help pwa__instructions hide-in-pwa\">\n    <h3><%= platform.safari? && platform.desktop? ? \"…or install\" : \"Install \" -%> Fizzy as a web app.</h3>\n    <% case\n      when platform.edge? %>\n        <ol>\n          <li>Click <em><%= icon_tag \"install-edge\", alt: \"the app available - install Fizzy button\" %></em>in the address bar.</li>\n          <li>Click <em>Install</em>.</li>\n        </ol>\n      <% when platform.chrome? && platform.android? %>\n        <ol>\n          <li>Tap the <em><%= icon_tag \"menu-dots-vertical\", alt: \"More options\" %></em> menu button.</li>\n          <li>Tap <em>Install app</em> in the menu.</li>\n        </ol>\n      <% when platform.firefox? && platform.android? %>\n        <ol>\n          <li>Tap the <em><%= icon_tag \"menu-dots-vertical\", alt: \"More options\" %></em> menu button.</li>\n          <li>Tap <em>Install</em> in the menu.</li>\n        </ol>\n      <% when platform.safari? && platform.desktop? %>\n        <ol>\n          <li>Click <em>File</em> in the top left.</li>\n          <li>Click <em>Add to Dock…</em></li>\n        </ol>\n      <% when (platform.safari? || platform.chrome?) && platform.ios? %>\n        <p>To receive push notifications in <%= platform.browser.capitalize %> for <%= platform.operating_system %>, you must first install Fizzy as a web app.</p>\n        <ol>\n          <li>Tap <em><%= icon_tag \"share\", alt: \"the share button\" %></em></li>\n          <li>Tap <em>Add to Home Screen</em>.</li>\n        </ol>\n      <% else %>\n        <p>Some platforms require you to install Fizzy as a web app to receive push notifications.</p>\n    <% end %>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/notifications/settings/_push_notifications.html.erb",
    "content": "<section class=\"notifications__status settings__section panel fill-shade center hide-on-native\" data-controller=\"notifications\" data-notifications-subscriptions-url-value=\"<%= user_push_subscriptions_path(Current.user) %>\" data-notifications-enabled-class=\"notifications--on\">\n  <heading>\n    <h2 class=\"txt-medium\">\n      Push notifications are\n      <span class=\"notifications__on-message\">ON</span>\n      <span class=\"notifications__off-message\">OFF</span>\n    </h2>\n  </heading>\n\n  <div class=\"margin-block-start-half\" data-notifications-target=\"subscribeButton\" hidden>\n    <button class=\"btn txt-small\" data-action=\"notifications#attemptToSubscribe\">\n      <%= icon_tag \"bell\" %>\n      <span>Turn ON push notifications on this device</span>\n    </button>\n  </div>\n\n  <details class=\"margin-block-start-half\" data-notifications-target=\"explainer\">\n    <summary class=\"btn txt-x-small\">\n      <%= icon_tag \"lifebuoy\" %>\n      <span class=\"notifications__off-message\">Help me fix this</span>\n      <span class=\"notifications__on-message\">Not receiving notifications?</span>\n    </summary>\n\n    <div class=\"notifications-help__explainer fill-white margin-block-start border-radius border txt-align-start\">\n      <p class=\"margin-none-block-start notifications__on-message\">When push notifications aren’t working, this can usually be fixed by checking your notification settings to make sure they’re allowed.</p>\n      <%= render partial: \"notifications/settings/browser\" %>\n      <%= render partial: \"notifications/settings/system\" %>\n      <%= render partial: \"notifications/settings/install\" %>\n    </div>\n  </details>\n</section>\n"
  },
  {
    "path": "app/views/notifications/settings/_system.html.erb",
    "content": "<div class=\"notifications-help hide-in-browser\" data-notifications-target=\"details\">\n  <h3>Check your <%= platform.operating_system %> settings</h3>\n  <% case\n    when platform.firefox? && platform.android? %>\n      <ol>\n        <li>Tap the <em><%= icon_tag \"menu-dots-vertical.svg\", alt: \"More options\" %></em> menu button.</li>\n        <li>Tap <em>Settings</em>.</li>\n        <li>Tap <em>Notifications</em>.</li>\n        <li>Tap <em><%= icon_tag \"switch\", alt: \"the toggle button\" %></em> to <em>Allow <%= platform.browser.capitalize %> notifications</em>.</li>\n      </ol>\n    <% when platform.edge? && platform.desktop? %>\n      <ol>\n        <li>Click <em>Start</em>, then <em>Settings</em>.</li>\n        <li>Go to <em>System &gt; Notification</em>.</li>\n        <li>Click <em><%= icon_tag \"switch\", alt: \"the toggle button\" %></em> <em>ON</em> for Fizzy.</li>\n      </ol>\n    <% when (platform.firefox? || platform.chrome?) && platform.desktop? %>\n      <ol>\n        <% if platform.windows? %>\n          <li>Click <em>Start</em>, then <em>Settings</em>.</li>\n          <li>Go to <em>System &gt; Notification</em>.</li>\n          <li>Click <em><%= icon_tag \"switch\", alt: \"the toggle button\" %></em> <em>ON</em> for Fizzy.</li>\n        <% else %>\n          <li>Click <em aria-label=\"the Apple menu\"></em> in the top left.</li>\n          <li>Click <em>System Settings…</em></li>\n          <li>Click <em>Notifications</em>.</li>\n          <li>Click <em>Fizzy</em>.</li>\n          <li>Click <em><%= icon_tag \"switch\", alt: \"the allow notifications switch\" %></em> to <em>Allow notifications</em>.</li>\n        <% end %>\n      </ol>\n    <% when platform.safari? && platform.desktop? %>\n      <ol>\n        <li>Click <em aria-label=\"the Apple menu\"></em> in the top left.</li>\n        <li>Click <em>System Settings…</em></li>\n        <li>Click <em>Notifications</em>.</li>\n        <li>Click <em>Fizzy</em>.</li>\n        <li>Click <em><%= icon_tag \"switch\", alt: \"the allow notifications switch\" %></em> to <em>Allow notifications</em>.</li>\n      </ol>\n    <% when (platform.safari? || platform.chrome?) && platform.ios? %>\n      <ol>\n        <li>Open the <em><%= icon_tag \"gear\", aria: { hidden: \"true\" } %></em> Settings app.</li>\n        <li>Scroll to and tap <em>Fizzy</em>.</li>\n        <li>Tap <em>Notifications</em>.</li>\n        <li>Tap <em><%= icon_tag \"switch\", alt: \"the allow notifications switch button\" %></em> to <em>Allow Notifications</em>.</li>\n      </ol>\n    <% when platform.chrome? && platform.android? %>\n      <ol>\n        <li>Open the <em><%= icon_tag \"gear\", aria: { hidden: \"true\" } %></em> Settings app.</li>\n        <li>Tap <em>Notifications</em>.</li>\n        <li>Tap <em>App notifications</em>.</li>\n        <li>Scroll to <em>Fizzy</em>.</li>\n        <li>Tap <em><%= icon_tag \"switch\", alt: \"the switch\" %></em> to <em>Allow Notifications</em>.</li>\n      </ol>\n    <% else %>\n      <p>Ensure notifications are allowed for <%= platform.browser.capitalize %> in your system settings.</p>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/notifications/settings/show.html.erb",
    "content": "<% @page_title = \"Notification Settings\" %>\n\n<% content_for :header do %>\n  <h1 class=\"header__title\" data-bridge--title-target=\"header\"><%= @page_title %></h1>\n<% end %>\n\n<section class=\"settings margin-block-start-half\">\n  <div class=\"settings__panel settings__panel--users panel shadow\">\n    <section class=\"settings__section\">\n      <h2 class=\"divider\">Boards</h2>\n      <div class=\"settings__scrollable-list flex flex-column gap-half\">\n        <%= render partial: \"notifications/settings/board\", collection: @boards, locals: { user: Current.user } %>\n      </div>\n    </section>\n  </div>\n\n  <div class=\"settings__panel panel shadow\">\n    <%= render \"notifications/settings/push_notifications\" %>\n    <%= render \"notifications/settings/native_devices\" if Fizzy.saas? %>\n    <%= render \"notifications/settings/email\", settings: @settings %>\n  </div>\n</section>\n"
  },
  {
    "path": "app/views/notifications/settings/show.json.jbuilder",
    "content": "json.bundle_email_frequency Current.user.settings.bundle_email_frequency\n"
  },
  {
    "path": "app/views/notifications/trays/show.html.erb",
    "content": "<%= turbo_frame_tag \"notifications\" do %>\n  <%= render partial: \"notifications/notification\", collection: @notifications, cached: true %>\n<% end %>\n"
  },
  {
    "path": "app/views/notifications/trays/show.json.jbuilder",
    "content": "json.array! @notifications, partial: \"notifications/notification\", as: :notification\n"
  },
  {
    "path": "app/views/notifications/unsubscribes/new.html.erb",
    "content": "<p>Unsubscribing from all email notifications as <%= @user.name %>…</p>\n\n<div class=\"push_double--top centered delayed-fade-in\">\n  <%= auto_submit_form_with model: @user, url: notifications_unsubscribe_path(access_token: params[:access_token]), method: :post do |form| %>\n    <%= form.submit \"Unsubscribe now\", class: \"btn\" %>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/notifications/unsubscribes/show.html.erb",
    "content": "<div class=\"panel margin-block-double center shadow\">\n  <h1 class=\"font-black txt-x-large margin-none-block-end\">You’re unsubscribed</h1>\n  <p class=\"margin-block-start-half\">Thanks, <%= @user.first_name %>! Fizzy won’t send you any more email notifications. If you change your mind, you can turn them back on in <%= link_to \"Notification Settings\", notifications_settings_path %>.</p>\n\n  <%= link_to root_path, class: \"btn btn--link margin-block-start\" do %>\n    <%= icon_tag \"arrow-left\" %>\n    <span>Back to Fizzy</span>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/prompts/boards/users/_user.html.erb",
    "content": "<lexxy-prompt-item search=\"<%= \"#{user.name} #{user.initials} #{\"me\" if user == Current.user}\" %>\" sgid=\"<%= user.attachable_sgid %>\">\n  <template type=\"menu\">\n    <%= avatar_image_tag user %>\n    <%= user.name %>\n  </template>\n  <template type=\"editor\">\n    <%= avatar_image_tag user %>\n    <%= user.first_name %>\n  </template>\n</lexxy-prompt-item>\n"
  },
  {
    "path": "app/views/prompts/boards/users/index.html.erb",
    "content": "<%= render partial: \"prompts/boards/users/user\", collection: @users %>\n"
  },
  {
    "path": "app/views/prompts/cards/_card.html.erb",
    "content": "<lexxy-prompt-item>\n  <template type=\"menu\">\n    #<%= card.number %> <%= card.title %>\n  </template>\n  <template type=\"editor\">\n    <%= link_to \"##{card.number} #{card.title}\", card_path(card) %>\n  </template>\n</lexxy-prompt-item>\n"
  },
  {
    "path": "app/views/prompts/cards/index.html.erb",
    "content": "<%= render partial: \"prompts/cards/card\", collection: @cards %>\n"
  },
  {
    "path": "app/views/prompts/commands/_command.html.erb",
    "content": "<% command, description, editor_version = command %>\n\n<lexxy-prompt-item search=\"<%= description %> <%= command %>\">\n  <template type=\"menu\">\n    <code><%= command %></code> <%= description %>\n  </template>\n  <template type=\"editor\">\n    <%= editor_version.gsub(/ +$/, \"&nbsp;\").html_safe %>\n  </template>\n</lexxy-prompt-item>\n"
  },
  {
    "path": "app/views/prompts/commands/index.html.erb",
    "content": "<%= render partial: \"prompts/commands/command\", collection: @commands %>\n"
  },
  {
    "path": "app/views/prompts/tags/_tag.html.erb",
    "content": "<lexxy-prompt-item search=\"<%= tag.title %>\" sgid=\"<%= tag.attachable_sgid %>\">\n  <template type=\"menu\">\n    #<%= tag.title %>\n  </template>\n  <template type=\"editor\">\n    #<%= tag.title %>\n  </template>\n</lexxy-prompt-item>\n"
  },
  {
    "path": "app/views/prompts/tags/index.html.erb",
    "content": "<%= render partial: \"prompts/tags/tag\", collection: @tags %>\n"
  },
  {
    "path": "app/views/prompts/users/index.html.erb",
    "content": "<%= render partial: \"prompts/boards/users/user\", collection: @users %>\n"
  },
  {
    "path": "app/views/public/_footer.html.erb",
    "content": "<div class=\"txt-align-center center margin-block-double txt-subtle txt-small\">\n  <%= render \"layouts/shared/colophon\" %>\n</div>\n"
  },
  {
    "path": "app/views/public/boards/card_previews/index.turbo_stream.erb",
    "content": "<%= turbo_stream.remove \"#{params[:target]}-load-page-#{@page.number}\" %>\n\n<%= turbo_stream.append params[:target] do %>\n  <%= render partial: \"cards/display/public_preview\",\n        board: @page.records, as: :card, locals: { draggable: true }, cached: true %>\n\n  <% unless @page.last? %>\n    <%= public_board_cards_next_page_link @board, params[:target], page: @page, fetch_on_visible: params[:target] == \"closed-cards\" %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/public/boards/columns/closeds/show.html.erb",
    "content": "<% @page_title = \"Column: Done\" %>\n\n<% content_for :header do %>\n  <div class=\"header__actions header__actions--start\">\n    <%= back_link_to @board.name, published_board_url(@board), \"keydown.left@document->hotkey#click keydown.esc@document->hotkey#click\" %>\n  </div>\n\n  <h1 class=\"header__title divider divider--fade full-width\" data-bridge--title-target=\"header\">\n    <span class=\"overflow-ellipsis\"><%= @page_title %></span>\n  </h1>\n<% end %>\n\n<section class=\"cards cards--grid\">\n  <%= turbo_frame_tag :closed_column do %>\n    <div class=\"cards__list hide-scrollbar\">\n      <% if @page.used? %>\n        <%= with_automatic_pagination :closed_column, @page do %>\n          <%= render \"cards/display/public_previews\", cards: @page.records %>\n        <% end %>\n      <% else %>\n        <div class=\"blank-slate\">No cards here</div>\n      <% end %>\n    </div>\n  <% end %>\n</section>\n\n<% content_for :footer do %>\n  <%= render \"public/footer\" %>\n<% end %>\n"
  },
  {
    "path": "app/views/public/boards/columns/not_nows/show.html.erb",
    "content": "<% @page_title = \"Column: Not now\" %>\n\n<% content_for :header do %>\n  <div class=\"header__actions header__actions--start\">\n    <%= back_link_to @board.name, published_board_url(@board), \"keydown.left@document->hotkey#click keydown.esc@document->hotkey#click\" %>\n  </div>\n\n  <h1 class=\"header__title divider divider--fade full-width\" data-bridge--title-target=\"header\">\n    <span class=\"overflow-ellipsis\"><%= @page_title %></span>\n  </h1>\n<% end %>\n\n<section class=\"cards cards--grid\">\n  <%= turbo_frame_tag :not_now_column do %>\n    <div class=\"cards__list hide-scrollbar\">\n      <% if @page.used? %>\n        <%= with_automatic_pagination :not_now_column, @page do %>\n          <%= render \"cards/display/public_previews\", cards: @page.records %>\n        <% end %>\n      <% else %>\n        <div class=\"blank-slate\">No cards here</div>\n      <% end %>\n    </div>\n  <% end %>\n</section>\n\n<% content_for :footer do %>\n  <%= render \"public/footer\" %>\n<% end %>\n"
  },
  {
    "path": "app/views/public/boards/columns/show.html.erb",
    "content": "<% @page_title = \"Column: #{ @column.name }\" %>\n\n<% content_for :header do %>\n  <div class=\"header__actions header__actions--start\">\n    <%= back_link_to @column.board.name, published_board_url(@column.board), \"keydown.left@document->hotkey#click keydown.esc@document->hotkey#click\" %>\n  </div>\n\n  <h1 class=\"header__title divider divider--fade full-width\" data-bridge--title-target=\"header\">\n    <span class=\"overflow-ellipsis\"><%= @page_title %></span>\n  </h1>\n<% end %>\n\n<section class=\"cards cards--grid\">\n  <%= turbo_frame_tag @column, :cards do %>\n    <div class=\"cards__list hide-scrollbar\">\n      <% if @page.used? %>\n        <%= with_automatic_pagination dom_id(@column, :cards), @page do %>\n          <%= render \"cards/display/public_previews\", cards: @page.records %>\n        <% end %>\n      <% else %>\n        <div class=\"blank-slate\">No cards here</div>\n      <% end %>\n    </div>\n  <% end %>\n</section>\n\n<% content_for :footer do %>\n  <%= render \"public/footer\" %>\n<% end %>\n\n"
  },
  {
    "path": "app/views/public/boards/columns/streams/show.html.erb",
    "content": "<% @page_title = \"Column: Maybe?\" %>\n\n<% content_for :header do %>\n  <div class=\"header__actions header__actions--start\">\n    <%= back_link_to @board.name, published_board_url(@board), \"keydown.left@document->hotkey#click keydown.esc@document->hotkey#click\" %>\n  </div>\n\n  <h1 class=\"header__title divider divider--fade full-width\" data-bridge--title-target=\"header\">\n    <span class=\"overflow-ellipsis\"><%= @page_title %></span>\n  </h1>\n<% end %>\n\n<section class=\"cards cards--grid\">\n  <%= turbo_frame_tag :stream_column do %>\n    <div class=\"cards__list hide-scrollbar\">\n      <% if @page.used? %>\n        <%= with_automatic_pagination :stream_column, @page do %>\n          <%= render \"cards/display/public_previews\", cards: @page.records %>\n        <% end %>\n      <% else %>\n        <div class=\"blank-slate\">No cards here</div>\n      <% end %>\n    </div>\n  <% end %>\n</section>\n\n<% content_for :footer do %>\n  <%= render \"public/footer\" %>\n<% end %>\n"
  },
  {
    "path": "app/views/public/boards/show/_closed.html.erb",
    "content": "<section id=\"closed-cards\" class=\"cards cards--closed is-collapsed\" style=\"--card-color: var(--color-card-complete);\"\n  data-collapsible-columns-target=\"column\"\n  data-action=\"turbo:before-morph-attribute->collapsible-columns#preventToggle\"\n>\n\n  <div class=\"cards__transition-container\">\n    <header class=\"cards__header\">\n      <%= render \"boards/show/expander\", title: \"Done\", count: board.cards.closed.published.count, column_id: \"closed-cards\" %>\n\n      <%= link_to public_board_columns_closed_url(board.publication.key), class: \"cards__maximize-button btn btn--circle txt-x-small borderless\", data: { turbo_frame: \"_top\" } do %>\n        <%= icon_tag \"grid\", class: \"translucent\" %>\n        <span class=\"for-screen-reader\">Maximize column</span>\n      <% end %>\n    </header>\n\n    <%= column_frame_tag :closed_column, src: public_board_columns_closed_path(board.publication.key) %>\n  </div>\n</section>\n"
  },
  {
    "path": "app/views/public/boards/show/_column.html.erb",
    "content": "<section id=\"<%= dom_id(column) %>\"\n  class=\"cards cards--doing is-collapsed\" style=\"--card-color: <%= column.color %>;\"\n  data-collapsible-columns-target=\"column\"\n  data-controller=\"clicker\"\n  data-action=\"turbo:before-morph-attribute->collapsible-columns#preventToggle\"\n>\n  <div class=\"cards__transition-container\">\n    <header class=\"cards__header\">\n      <%= render \"boards/show/expander\", title: column.name, count: column.cards.active.count, column_id: dom_id(column) %>\n\n      <%= link_to public_board_column_url(column.board.publication.key, column), class: \"cards__maximize-button btn btn--circle txt-x-small borderless\", data: { turbo_frame: \"_top\" } do %>\n        <%= icon_tag \"grid\", class: \"translucent\" %>\n        <span class=\"for-screen-reader\">Maximize column</span>\n      <% end %>\n    </header>\n\n    <%= column_frame_tag dom_id(column, :cards), src: public_board_column_path(column.board.publication.key, column) %>\n  </div>\n</section>\n"
  },
  {
    "path": "app/views/public/boards/show/_columns.html.erb",
    "content": "<%= turbo_frame_tag :cards_container do %>\n  <div class=\"card-columns hide-scrollbar\"\n    data-controller=\"collapsible-columns\"\n    data-collapsible-columns-board-value=\"<%= board.id %>\"\n    data-collapsible-columns-collapsed-class=\"is-collapsed\"\n    data-collapsible-columns-expanded-class=\"is-expanded\"\n    data-collapsible-columns-no-transitions-class=\"no-transitions\"\n    data-collapsible-columns-title-not-visible-class=\"is-off-screen\">\n\n    <div class=\"card-columns__left\">\n      <%= render \"public/boards/show/not_now\", board: board %>\n    </div>\n\n    <%= render \"public/boards/show/stream\", board: board, page: page %>\n\n    <div class=\"card-columns__right\">\n      <%= render partial: \"public/boards/show/column\", collection: board.columns, cached: true %>\n      <%= render \"public/boards/show/closed\", board: board %>\n    </div>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/public/boards/show/_not_now.html.erb",
    "content": "<section id=\"not-now\" class=\"cards cards--on-deck is-collapsed\" style=\"--card-color: var(--color-card-complete);\"\n  data-collapsible-columns-target=\"column\"\n  data-action=\"turbo:before-morph-attribute->collapsible-columns#preventToggle\"\n>\n\n  <div class=\"cards__transition-container\">\n    <header class=\"cards__header\">\n      <%= render \"boards/show/expander\", title: \"Not Now\", count: board.cards.postponed.count, column_id: \"not-now\" %>\n\n      <%= link_to public_board_columns_not_now_url(board.publication.key), class: \"cards__maximize-button btn btn--circle txt-x-small borderless\", data: { turbo_frame: \"_top\" } do %>\n        <%= icon_tag \"grid\", class: \"translucent\" %>\n        <span class=\"for-screen-reader\">Maximize column</span>\n      <% end %>\n    </header>\n\n    <%= column_frame_tag :not_now_column, src: public_board_columns_not_now_path(board.publication.key) %>\n  </div>\n</section>\n"
  },
  {
    "path": "app/views/public/boards/show/_stream.html.erb",
    "content": "<section id=\"maybe\"\n  class=\"cards cards--maybe is-expanded\"\n  data-column-name=\"Maybe?\"\n  data-collapsible-columns-target=\"column maybeColumn\"\n  data-action=\"turbo:before-morph-attribute->collapsible-columns#preventToggle\">\n  <div class=\"cards__transition-container\">\n    <header class=\"cards__header\">\n      <div hidden class=\"cards__expander\">\n        <h2 class=\"cards__expander-title\" data-collapsible-columns-target=\"title\">Maybe?</h2>\n      </div>\n\n      <%# render \"boards/show/expander\", title: \"Maybe?\", count: column.cards.active.count, column_id: dom_id(column) %>\n      <%= render \"boards/show/expander\", title: \"Maybe?\", count: 2, column_id: \"maybe\" %>\n\n      <%= link_to public_board_columns_stream_url(board.publication.key), class: \"cards__maximize-button btn btn--circle txt-x-small borderless\", data: { turbo_frame: \"_top\" } do %>\n        <%= icon_tag \"grid\", class: \"translucent\" %>\n        <span class=\"for-screen-reader\">Maximize column</span>\n      <% end %>\n    </header>\n\n    <%= column_frame_tag :stream_column, src: public_board_columns_stream_path(board.publication.key) %>\n  </div>\n</section>\n"
  },
  {
    "path": "app/views/public/boards/show.html.erb",
    "content": "<% @page_title = @board.name %>\n<% @body_class = \"contained-scrolling\" %>\n\n<% content_for :head do %>\n  <%= tag.meta property: \"og:title\", content: \"#{@board.name} | #{Current.account.name}\" %>\n  <%= tag.meta property: \"og:description\", content: format_excerpt(@board&.public_description, length: 200) %>\n  <%= tag.meta property: \"og:image\", content: \"#{request.base_url}/opengraph.png\" %>\n  <%= tag.meta property: \"og:url\", content: published_board_url(@board) %>\n\n  <%= tag.meta property: \"twitter:title\", content: \"#{@board.name} | #{Current.account.name}\" %>\n  <%= tag.meta property: \"twitter:description\", content: format_excerpt(@board&.public_description, length: 200) %>\n  <%= tag.meta property: \"twitter:image\", content: \"#{request.base_url}/opengraph.png\" %>\n  <%= tag.meta property: \"twitter:card\", content: \"summary_large_image\" %>\n<% end %>\n\n<% content_for :header do %>\n  <h1 class=\"header__title divider divider--fade full-width\" data-bridge--title-target=\"header\">\n    <span class=\"overflow-ellipsis\"><%= @page_title %></span>\n  </h1>\n<% end %>\n\n<% if @board.public_description.present? %>\n  <div class=\"card__board-public-description lexxy-content txt-align-center center margin-block-end\" data-controller=\"syntax-highlight\">\n    <%= @board.public_description %>\n  </div>\n<% end %>\n\n<%= render \"public/boards/show/columns\", page: @page, board: @board %>\n\n<% content_for :footer do %>\n  <%= render \"public/footer\" %>\n<% end %>\n"
  },
  {
    "path": "app/views/public/cards/show/_content.html.erb",
    "content": "<div class=\"card__content\">\n  <h1 class=\"card__title flex align-start gap-half\">\n    <%= tag.span card_html_title(card), class: \"card__title-link\" %>\n  </h1>\n  <div class=\"card__description lexxy-content\" data-controller=\"syntax-highlight\">\n    <%= card.description %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/public/cards/show/_steps.html.erb",
    "content": "<ol class=\"steps txt-small margin-block\">\n  <% card.steps.each do |step| %>\n    <li class=\"step\">\n      <%= check_box_tag :completed, { class: \"step__checkbox\", disabled: true, checked: step.completed? } %>\n      <%= tag.span step.content, class: \"step__content\" %>\n    </li> \n  <% end %>\n</ol>"
  },
  {
    "path": "app/views/public/cards/show.html.erb",
    "content": "<% @page_title = @card.title %>\n\n<% content_for :head do %>\n  <%= tag.meta property: \"og:title\", content: \"#{@card.title} | #{@card.board.name}\" %>\n  <%= tag.meta property: \"og:description\", content: format_excerpt(@card&.description, length: 200) %>\n  <%= tag.meta property: \"og:image\", content: @card.image.attached? ? \"#{request.base_url}#{url_for(@card.image)}\" : \"#{request.base_url}/app-icon.png\" %>\n  <%= tag.meta property: \"og:url\", content: published_card_url(@card) %>\n\n  <%= tag.meta property: \"twitter:title\", content: \"#{@card.title} | #{@card.board.name}\" %>\n  <%= tag.meta property: \"twitter:description\", content: format_excerpt(@card&.description, length: 200) %>\n  <%= tag.meta property: \"twitter:image\", content: @card.image.attached? ? \"#{request.base_url}#{url_for(@card.image)}\" : \"#{request.base_url}/app-icon.png\" %>\n  <%= tag.meta property: \"twitter:card\", content: \"summary_large_image\" %>\n<% end %>\n\n<% content_for :header do %>\n  <div class=\"header__actions header__actions--start\">\n    <%= back_link_to @card.board.name, published_board_url(@card.board), \"keydown.left@document->hotkey#click keydown.esc@document->hotkey#click\" %>\n  </div>\n<% end %>\n\n<section id=\"<%= dom_id(@card, :card_container) %>\"\n  class=\"card-perma card-perma--public\" data-controller=\"lightbox\" style=\"--card-color: <%= @card.color %>;\">\n  <div class=\"card-perma__bg\">\n    <%= card_article_tag @card, class: \"card\" do %>\n      <header class=\"card__header\">\n        <%= render \"cards/display/preview/board\", card: @card %>\n        <%= render \"cards/display/preview/tags\", card: @card %>\n      </header>\n\n      <div class=\"card__body justify-space-between\">\n        <%= render \"public/cards/show/content\", card: @card %>\n        <%= render \"cards/display/public_preview/columns\", card: @card if @card.open? %>\n        <%= render \"cards/display/common/stamp\", card: @card %>\n      </div>\n\n      <%= render \"public/cards/show/steps\", card: @card %>\n\n      <footer class=\"card__footer\">\n        <%= render \"cards/display/public_preview/meta\", card: @card %>\n        <%= render \"cards/display/perma/background\", card: @card %>\n      </footer>\n    <% end %>\n  </div>\n\n  <%= render \"layouts/lightbox\" %>\n</section>\n\n<% content_for :footer do %>\n  <%= render \"public/footer\" %>\n<% end %>\n"
  },
  {
    "path": "app/views/pwa/manifest.json.erb",
    "content": "{\n  \"name\": <%= [ \"Fizzy\", Rails.env.production? ? nil : Rails.env ].compact.join(\" - \").to_json.html_safe %>,\n  \"icons\": [\n    {\n      \"src\": \"/app-icon-192.png\",\n      \"type\": \"image/png\",\n      \"sizes\": \"192x192\"\n    },\n    {\n      \"src\": \"/app-icon.png\",\n      \"type\": \"image/png\",\n      \"sizes\": \"512x512\"\n    },\n    {\n      \"src\": \"/app-icon-192-maskable.png\",\n      \"type\": \"image/png\",\n      \"sizes\": \"192x192\",\n      \"purpose\": \"maskable\"\n    }\n  ],\n  \"start_url\": \"/\",\n  \"display\": \"standalone\",\n  \"scope\": \"/\",\n  \"description\": \"Card-up the biggest issues in your projects\",\n  \"categories\": [\"bugs\", \"business\", \"productivity\"],\n  \"shortcuts\": [\n    {\n      \"name\": \"Notifications\",\n      \"description\": \"Catch up on recent notifications\",\n      \"url\": \"<%= notifications_path %>\",\n      \"icons\": [{ \"src\": \"<%= image_url(\"bell.svg\") %>\", \"sizes\": \"any\" }]\n    },\n    {\n      \"name\": \"Latest Activity\",\n      \"description\": \"See what’s new\",\n      \"url\": \"<%= root_path %>\",\n      \"icons\": [{ \"src\": \"<%= image_url(\"activity.svg\") %>\", \"sizes\": \"any\" }]\n    }\n  ]\n}\n"
  },
  {
    "path": "app/views/pwa/service_worker.js.erb",
    "content": "importScripts(\"<%= javascript_url(\"turbo-offline-umd.min\") %>\")\n\n// Documents - top-level navigation requests only\n// We need `cache: \"no-cache\"` here to work around\n// an annoying Safari PWA bug that predates offline mode\nTurboOffline.addRule({\n  match: (request) => request.destination === \"document\",\n  except: /\\/(edit|pin|watch|new)$/,\n  handler: TurboOffline.handlers.networkFirst({\n    cacheName: \"main\",\n    maxAge: 60 * 60 * 24 * 3,\n    networkTimeout: 3,\n    fetchOptions: { cache: \"no-cache\" },\n    maxEntrySize: 1024 * 1024\n  })\n})\n\nTurboOffline.addRule({\n  match: /\\/assets\\/.+[-\\.][0-9a-f]+\\.(js|css|svg|png|jpg|webp|woff2?|ico)$/,\n  handler: TurboOffline.handlers.cacheFirst({\n    cacheName: \"assets\",\n    maxAge: 60 * 60 * 24 * 7,\n    maxEntrySize: 1024 * 1024\n  })\n})\n\nTurboOffline.addRule({\n  match: /\\/(boards|cards|users)\\//,\n  except: /\\/(edit|pin|watch|new)$/,\n  handler: TurboOffline.handlers.networkFirst({\n    cacheName: \"main\",\n    maxAge: 60 * 60 * 24 * 3,\n    networkTimeout: 2,\n    maxEntrySize: 1024 * 1024\n  })\n})\n\nTurboOffline.addRule({\n  match: /\\/rails\\/active_storage\\//,\n  handler: TurboOffline.handlers.networkFirst({\n    cacheName: \"storage\",\n    maxAge: 60 * 60 * 24 * 7,\n    networkTimeout: 2,\n    maxEntrySize: 2 * 1024 * 1024, // 2MB covers about 95% of all Fizzy blobs\n    maxEntries: 500,\n    fetchOptions: { mode: \"cors\" }\n  })\n})\n\n// Everything else\nTurboOffline.addRule({\n  except: /\\/(service-worker\\.js|edit|pin|watch|new)$/,\n  handler: TurboOffline.handlers.networkFirst({\n    cacheName: \"main\",\n    maxAge: 60 * 60 * 24,\n    networkTimeout: 3,\n    maxEntrySize: 1024 * 1024\n  })\n})\n\nself.addEventListener(\"activate\", (event) => {\n  event.waitUntil(self.clients.claim())\n})\n\nTurboOffline.start()\n\n\nself.addEventListener(\"push\", (event) => {\n  const data = event.data.json()\n  event.waitUntil(Promise.all([ showNotification(data), updateBadgeCount(data.options) ]))\n})\n\nasync function showNotification({ title, options }) {\n  return self.registration.showNotification(title, options)\n}\n\nasync function updateBadgeCount({ data: { badge } }) {\n  return self.navigator.setAppBadge?.(badge || 0)\n}\n\nself.addEventListener(\"notificationclick\", (event) => {\n  event.notification.close()\n\n  const url = new URL(event.notification.data.url, self.location.origin).href\n  event.waitUntil(openURL(url))\n})\n\nasync function openURL(url) {\n  const clients = await self.clients.matchAll({ type: \"window\" })\n  const focused = clients.find((client) => client.focused)\n\n  if (focused) {\n    await focused.navigate(url)\n  } else {\n    await self.clients.openWindow(url)\n  }\n}\n"
  },
  {
    "path": "app/views/reactions/_menu.html.erb",
    "content": "<div class=\"reaction__menu\" data-controller=\"dialog\" data-action=\"keydown.esc->dialog#close:stop click@document->dialog#closeOnClickOutside\">\n  <button class=\"reaction__menu-btn btn btn--circle borderless\" data-action=\"click->dialog#open:stop\" type=\"button\">\n    <%= icon_tag \"reaction\" %>\n  </button>\n\n  <dialog class=\"reaction__popup popup panel fill-white shadow\" data-dialog-target=\"dialog\">\n    <div class=\"reaction__emoji-list\">\n      <% EmojiHelper::REACTIONS.each do |character, title| %>\n        <%= tag.button character, title: title, class: \"reaction__emoji-btn btn btn--circle borderless hide-focus-ring\", type: \"button\", data: { action: \"reaction-emoji#insertEmoji dialog#close\", emoji: character } %>\n      <% end %>\n    </div>\n  </dialog>\n</div>\n"
  },
  {
    "path": "app/views/reactions/_reaction.html.erb",
    "content": "<div id=\"<%= dom_id(reaction) %>\"\n      class=\"reaction\"\n      data-controller=\"reaction-delete\"\n      data-reaction-delete-reacter-id-value=\"<%= reaction.reacter.id %>\"\n      data-controller=\"reaction-delete\"\n      data-reaction-delete-perform-class=\"reaction--deleting\"\n      data-reaction-delete-reveal-class=\"expanded\"\n      data-reaction-delete-deleteable-class=\"reaction--deleteable\"\n      data-reaction-delete-reacter-id-value=\"<%= reaction.reacter.id %>\">\n  <figure class=\"reaction__avatar margin-none flex-item-no-shrink\">\n    <%= avatar_tag reaction.reacter, aria: { label: \"#{reaction.reacter.name} reacted #{reaction.content}\" } %>\n  </figure>\n\n  <%= tag.span reaction.content, role: \"button\",\n        class: [ \"txt-small\", { \"txt-medium\": reaction.all_emoji? } ],\n        data: { action: \"click->reaction-delete#reveal keydown.enter->reaction-delete#reveal:prevent\", reaction_delete_target: \"content\" } %>\n\n  <%= button_to polymorphic_path([ *reaction_path_prefix_for(reaction.reactable), reaction ]),\n        method: :delete,\n        class: \"reaction__delete btn btn--negative flex-item-justify-end\",\n        data: { action: \"reaction-delete#perform\", reaction_delete_target: \"button\" } do %>\n    <%= icon_tag \"trash\" %>\n    <span class=\"for-screen-reader\">Delete this reaction</span>\n  <% end %>\n</div>\n\n<span id=\"delete_reaction_accessible_label\" class=\"for-screen-reader\">Press enter to delete this reaction</span>\n"
  },
  {
    "path": "app/views/reactions/_reaction.json.jbuilder",
    "content": "json.cache! reaction do\n  json.(reaction, :id, :content)\n  json.reacter reaction.reacter, partial: \"users/user\", as: :user\n  json.url polymorphic_url([ *reaction_path_prefix_for(reaction.reactable), reaction ])\nend\n"
  },
  {
    "path": "app/views/reactions/_reactions.html.erb",
    "content": "<%= turbo_frame_tag reactable, :reacting do %>\n  <div class=\"reactions\">\n    <div id=\"<%= dom_id(reactable, :reactions) %>\" class=\"reactions__list\">\n      <%= render partial: \"reactions/reaction\", collection: reactable.reactions %>\n    </div>\n\n    <%= turbo_frame_tag reactable, :new_reaction do %>\n      <%= link_to new_polymorphic_path([ *reaction_path_prefix_for(reactable), :reaction ]), role: \"button\",\n            class: \"reactions__trigger btn btn--circle\", action: \"soft-keyboard#open\",\n            data: { turbo_frame: dom_id(reactable, :new_reaction), action: \"dialog#close\" } do %>\n        <%= image_tag \"boost-color.svg\", aria: { hidden: true } %>\n        <span class=\"for-screen-reader\">Add your own reaction</span>\n      <% end %>\n    <% end %>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/reactions/create.turbo_stream.erb",
    "content": "<%= turbo_stream.replace([ @reactable, :reacting ]) do %>\n  <%= render \"reactions/reactions\", reactable: @reactable.reload %>\n<% end %>\n"
  },
  {
    "path": "app/views/reactions/destroy.turbo_stream.erb",
    "content": "<%= turbo_stream.remove @reaction %>\n"
  },
  {
    "path": "app/views/reactions/index.html.erb",
    "content": "<%= render \"reactions/reactions\", reactable: @reactable %>\n"
  },
  {
    "path": "app/views/reactions/index.json.jbuilder",
    "content": "json.array! @reactable.reactions.ordered, partial: \"reactions/reaction\", as: :reaction\n"
  },
  {
    "path": "app/views/reactions/new.html.erb",
    "content": "<%= turbo_frame_tag @reactable, :new_reaction do %>\n  <%= form_with model: [ *reaction_path_prefix_for(@reactable), Reaction.new ],\n        class: \"reaction reaction__form expanded\",\n        html: { aria: { label: \"New reaction\" } },\n        data: { controller: \"form reaction-emoji\", turbo_frame: dom_id(@reactable, :reacting), action: \"keydown.esc->form#cancel submit->form#preventEmptySubmit submit->form#preventComposingSubmit\" } do |form| %>\n    <label class=\"reaction__form-label flex gap\" style=\"--column-gap: 0.4ch;\">\n      <figure class=\"reaction__avatar margin-none flex-item-no-shrink\">\n        <%= avatar_tag Current.user %>\n      </figure>\n\n      <%= form.text_field :content, autofocus: true, autocomplete: \"off\", autocorrect: \"off\", maxlength: 16,\n            pattern: /\\S+.*/, class: \"input reaction__input txt-small\", data: { form_target: \"input\", reaction_emoji_target: \"input\", action: \"compositionstart->form#compositionStart compositionend->form#compositionEnd\" }, aria: { label: \"Add a reaction\" } %>\n    </label>\n\n    <%= render \"reactions/menu\" %>\n\n    <%= form.button class: \"reaction__submit-btn btn btn--circle borderless\", type: \"submit\", data: { form_target: \"submit\" } do %>\n      <%= icon_tag \"check-circle\" %> <span class=\"for-screen-reader\">Submit</span>\n    <% end %>\n\n    <%= link_to polymorphic_path([ *reaction_path_prefix_for(@reactable), :reactions ]), role: \"button\",\n          data: { turbo_frame: dom_id(@reactable, :reacting), form_target: \"cancel\" }, class: \"reaction__cancel-btn btn btn--circle borderless\" do %>\n      <%= icon_tag \"close-circle\" %> <span class=\"for-screen-reader\">Cancel</span>\n    <% end %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/reactions/show.json.jbuilder",
    "content": "json.partial! \"reactions/reaction\", reaction: @reaction\n"
  },
  {
    "path": "app/views/searches/_form.html.erb",
    "content": "<%= form_with url: search_path, method: :get,\n  class: \"search__form flex align-center justify-center gap-half\",\n  data: {\n    controller: \"search-form\",\n    action: \"search-form:reset->bar#reset\",\n    bar_target: \"form\",\n    turbo_action: defined?(turbo_action) ? turbo_action : nil,\n    turbo_frame: defined?(target_turbo_frame) ? target_turbo_frame : nil } do |form| %>\n  <%= form.label :q, \"Search Fizzy\", class: \"font-weight-black txt-nowrap\" %>\n  <%= text_field_tag :q, query_terms,\n        class: \"search__input input\",\n        type: \"search\",\n        placeholder: \"Find something…\",\n        autocomplete: \"off\",\n        autofocus: true,\n        data: {\n          search_form_target: \"searchInput\",\n          bar_target: \"searchInput\",\n          action: \"keydown.enter->bar#showModalAndSubmit:prevent keydown.esc->bar#reset\" } %>\n  <button hidden>Search</button>\n  <button class=\"search__reset btn btn--circle borderless\" data-action=\"search-form#clearInput\">\n    <%= icon_tag \"close\" %>\n    <span class=\"for-screen-reader\">Close search</span>\n  </button>\n<% end %>\n"
  },
  {
    "path": "app/views/searches/_result.html.erb",
    "content": "<li>\n  <%= link_to result.source, class: \"search__result\", data: { turbo_frame: \"_top\", action: \"bar#reset\" } do %>\n    <div>\n      <h3 class=\"search__title txt--medium margin-none\">\n        # <%= result.card.number %> <%= result.card_title %>\n      </h3>\n\n      <% if result.comment.present? %>\n        <div class=\"search__excerpt search__excerpt--comment\">\n          <%= avatar_preview_tag result.card.creator %>\n          <div><%= result.comment_body %></div>\n        </div>\n      <% elsif result.card_id.present? %>\n        <div class=\"search__excerpt\"><%= result.card_description %></div>\n      <% end %>\n    </div>\n    <div class=\"overflow-ellipsis translucent txt-ink\">\n      <%= result.card.board.name %> · <%= local_datetime_tag(result.created_at, style: :timeordate) %>\n    </div>\n  <% end %>\n</li>\n"
  },
  {
    "path": "app/views/searches/_results.html.erb",
    "content": "<section class=\"search\">\n  <div class=\"search__results\">\n    <% if !page.used? && query %>\n      <div class=\"search__blank-slate blank-slate\">\n        No matches\n      </div>\n    <% end %>\n\n    <ul class=\"search__list\">\n      <%= with_automatic_pagination :filtered_search_results, page do %>\n        <%= render partial: \"searches/result\", collection: page.records %>\n      <% end %>\n    </ul>\n  </div>\n</section>\n"
  },
  {
    "path": "app/views/searches/show.html.erb",
    "content": "<% @page_title = @query ? \"Search results for \\\"#{@query}\\\"\" : \"Search\" %>\n\n<% content_for :header do %>\n  <div class=\"header__actions header__actions--start\">\n    <%= back_link_to \"Home\", root_path, \"keydown.left@document->hotkey#click keydown.esc@document->hotkey#click\" %>\n  </div>\n\n  <h1 class=\"header__title\" data-bridge--title-target=\"header\"><%= @page_title %></h1>\n<% end %>\n\n<div class=\"search-perma margin-block-start\">\n  <%= render \"form\", query_terms: @query, turbo_action: \"advance\", target_turbo_frame: \"bar_content\" %>\n  <%= turbo_frame_tag \"bar_content\" do %>\n    <% if @card %>\n      <%= auto_submit_form_with url: card_path(@card), method: :get, data: { turbo_action: \"advance\", turbo_frame: \"_top\", search_redirect: true } %>\n    <% else %>\n      <%= render \"results\", page: @page, query: @query %>\n    <% end %>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/searches/show.json.jbuilder",
    "content": "json.array! @page.records, partial: \"cards/card\", as: :card\n"
  },
  {
    "path": "app/views/sessions/_footer.html.erb",
    "content": "<div class=\"pad-inline txt-align-center center margin-block-double txt-subtle txt-small\">\n  <%= render \"layouts/shared/colophon\" %>.\n  Need help? <%= mail_to \"support@fizzy.do\", \"Send us an email\", class: \"txt-link\" %>.\n</div>\n"
  },
  {
    "path": "app/views/sessions/magic_links/show.html.erb",
    "content": "<% @page_title = \"Check your email\" %>\n\n<div class=\"panel panel--centered flex flex-column gap-half <%= \"shake\" if flash[:alert] || flash[:shake] %>\">\n  <header>\n    <h1 class=\"txt-x-large font-weight-black margin-none\"><%= @page_title %></h1>\n    <p class=\"margin-none-block-start txt-medium txt-balance\">\n      Then enter the verification code included in the email below:\n    </p>\n  </header>\n\n  <%= form_with url: session_magic_link_path, method: :post, html: { data: { controller: \"magic-link clear-offline-cache\", action: \"submit->clear-offline-cache#clearCache\" } } do |form| %>\n    <%= form.text_field :code, required: true, class: \"input center txt-align-enter txt-large txt-uppercase\",\n        autofocus: true, autocorrect: \"off\", autocapitalize: \"off\", spellcheck: \"false\", \"data-1p-ignore\": true,\n        autocomplete: \"one-time-code\", maxlength: \"6\", placeholder: \"••••••\", value: params[:code],\n        data: { magic_link_target: \"input\", action: \"keydown.enter->magic-link#submitOnEnter paste->magic-link#submitOnPaste\" } %>\n  <% end %>\n\n  <p class=\"txt-small txt-balance\">The code sent to <strong><%= email_address_pending_authentication %></strong> will work for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>.</p>\n</div>\n\n<% if Rails.env.development? && flash[:magic_link_code].present? %>\n  <div class=\"flex align-center justify-center gap fill-shade border-radius pad margin-block-start full-width position-relative\" style=\"animation: slide-up-fade-in 400ms ease-out both; max-width: 42ch; overflow: hidden;\">\n    <span class=\"txt-uppercase translucent txt-tight-lines\">Psst, here's your code:</span>\n    <code class=\"txt-large font-weight-black txt-tight-lines\"><%= flash[:magic_link_code] %></code>\n    <span class=\"txt-xx-small txt-uppercase font-weight-black txt-align-center flex align-center justify-center\" style=\"position: absolute; top: 0; bottom: 0; left: 0; background: var(--color-ink-lighter); padding: 0 0.5em; writing-mode: vertical-rl; transform: rotate(180deg); line-height: 1;\">DEV</span>\n  </div>\n<% end %>\n\n<% content_for :footer do %>\n  <%= render \"sessions/footer\" %>\n<% end %>\n"
  },
  {
    "path": "app/views/sessions/menus/show.html.erb",
    "content": "<% @page_title = \"Choose an account\" %>\n\n<% cache [ Current.identity, @accounts ] do %>\n  <section\n    class=\"panel panel--centered flex flex-column gap\"\n    style=\"--popup-icon-size: 24px; --popup-item-padding-inline: 0.5rem;\"\n  >\n    <% if @accounts.any? %>\n      <h1 class=\"txt-x-large font-weight-black txt-tight-lines margin-none\">\n        Your Fizzy accounts\n      </h1>\n      <menu class=\"popup__list pad border-radius border\" style=\"--block-space: var(--inline-space);\">\n        <% @accounts.each do |account| %>\n          <li class=\"popup__item txt-medium\">\n            <%= icon_tag \"marker\", class: \"popup__icon\" %>\n            <%= link_to landing_path(script_name: account.slug), class: \"btn overflow-ellipsis fill-transparent popup__btn\" do %>\n              <strong class=\"overflow-ellipsis\"><%= account.name %></strong>\n            <% end %>\n          </li>\n        <% end %>\n      </menu>\n    <% else %>\n      <h1 class=\"txt-x-large font-weight-black margin-none\">Hmm...</h1>\n      <p class=\"margin-none-block-start\">You don’t have any Fizzy accounts.</p>\n    <% end %>\n\n    <%= link_to new_signup_path, class: \"btn center txt-small margin-block-start\", data: { turbo_prefetch: false } do %>\n      <span>Sign up for a new Fizzy account</span>\n    <% end %>\n  </section>\n<% end %>\n\n<% content_for :footer do %>\n  <%= render \"sessions/footer\" %>\n<% end %>\n"
  },
  {
    "path": "app/views/sessions/new.html.erb",
    "content": "<% @page_title = \"Enter your email\" %>\n\n<% content_for :head do %>\n  <%= passkey_request_options_meta_tag(@request_options) %>\n<% end %>\n\n<div class=\"panel panel--centered flex flex-column gap-half\">\n  <h1 class=\"txt-x-large font-weight-black margin-block-end\">Get into Fizzy</h1>\n\n  <%= form_with url: session_path, class: \"flex flex-column gap-half txt-medium\" do |form| %>\n    <div class=\"flex align-center gap\">\n      <label class=\"flex align-center gap input input--actor\">\n        <%= form.email_field :email_address, required: true, class: \"input txt-large full-width\", autofocus: true, autocomplete: \"username webauthn\", placeholder: \"Enter your email address…\" %>\n      </label>\n    </div>\n\n    <% if Account.accepting_signups? %>\n      <p><strong>New here?</strong> <%= link_to \"Sign up\", new_signup_path %> to create an account. <strong>Already have an account?</strong> Enter your email and we'll get you signed in.</p>\n    <% else %>\n      <p>Enter your email and we'll get you signed in.</p>\n    <% end %>\n\n    <button type=\"submit\" id=\"log_in\" class=\"btn btn--link center txt-medium\">\n      <span>Let's go</span>\n      <%= icon_tag \"arrow-right\" %>\n    </button>\n  <% end %>\n\n  <%= passkey_sign_in_button \"Sign in with a passkey\", session_passkey_path, mediation: \"conditional\", class: \"btn btn--link center txt-medium\", hidden: true %>\n\n</div>\n\n<% content_for :footer do %>\n  <%= render \"sessions/footer\" %>\n<% end %>\n"
  },
  {
    "path": "app/views/sessions/starts/new.html.erb",
    "content": "<% @hide_footer_frames = true %>\n<% @page_title = \"Signing in...\" %>\n\n<div class=\"panel panel--centered flex flex-column gap-half\">\n  <header>\n    <h1 class=\"txt-x-large font-weight-black margin-none\">\n      <%= @page_title %>\n    </h1>\n    <p class=\"margin-none\">Just a sec while we sign you in with <%= Current.account.name %>.</p>\n  </header>\n\n  <%= form_with url: session_start_path, method: :post, data: { controller: \"form auto-submit\" } do |form| %>\n    <%= form.button \"Sign in\", type: \"submit\", class: \"btn btn-primary\", data: { form_target: \"submit\", turbo_submits_with: \"Signing in...\" } %>\n  <% end %>\n</div>\n\n<% content_for :footer do %>\n  <%= render \"sessions/footer\" %>\n<% end %>\n"
  },
  {
    "path": "app/views/sessions/transfers/show.html.erb",
    "content": "<%= auto_submit_form_with method: :put %>\n"
  },
  {
    "path": "app/views/signups/completions/new.html.erb",
    "content": "<% @page_title = \"Complete your sign-up\" %>\n\n<div class=\"panel panel--centered flex flex-column gap-half <%= \"shake\" if flash[:alert] %>\">\n  <h1 class=\"txt-x-large font-weight-black margin-block-end\"><%= @page_title %></h1>\n\n  <%= form_with model: @signup, url: signup_completion_path, scope: \"signup\", class: \"flex flex-column gap\", data: { controller: \"form\" } do |form| %>\n    <%= form.text_field :full_name, class: \"input txt-large\", autocomplete: \"name\", placeholder: \"Enter your full name…\", autofocus: true, required: true, maxlength: 240 %>\n\n    <p>You're one step away. Just enter your name to get your own Fizzy account.</p>\n\n    <% if @signup.errors.any? %>\n      <div class=\"margin-block-half txt-negative txt-small\" style=\"text-align: left;\">\n        <p class=\"margin-block-none font-weight-bold\">Your changes couldn't be saved:</p>\n        <ul class=\"margin-block-none\">\n          <% @signup.errors.full_messages.each do |message| %>\n            <li><%= message %></li>\n          <% end %>\n        </ul>\n      </div>\n    <% end %>\n\n    <button type=\"submit\" class=\"btn btn--link center\" data-form-target=\"submit\">\n      <span>Continue</span>\n      <%= icon_tag \"arrow-right\" %>\n    </button>\n  <% end %>\n\n  <p>\n    <%= link_to new_account_import_path, class: \"btn btn--plain txt-link center txt-small\", data: { turbo_prefetch: false } do %>\n      <span>Or import a Fizzy account</span>\n    <% end %>\n  </p>\n</div>\n\n<% content_for :footer do %>\n  <%= render \"sessions/footer\" %>\n<% end %>\n"
  },
  {
    "path": "app/views/signups/new.html.erb",
    "content": "<% @page_title = \"Signup for Fizzy\" %>\n\n<div class=\"panel panel--centered flex flex-column gap-half\">\n  <h1 class=\"txt-x-large font-weight-black margin-block-end\">Sign up</h1>\n\n  <%= form_with model: @signup, url: signup_path, scope: \"signup\", class: \"flex flex-column gap\", data: { turbo: false, controller: \"form\" } do |form| %>\n    <div class=\"flex align-center gap\">\n      <label class=\"flex align-center gap input input--actor\">\n        <%= form.email_field :email_address, required: true, class: \"input txt-large full-width\", autofocus: true, autocomplete: \"username\", placeholder: \"Enter your email address…\" %>\n      </label>\n    </div>\n\n    <p>Enter your email to create an account.</p>\n\n    <button type=\"submit\" id=\"log_in\" class=\"btn btn--link center txt-medium\">\n      <span>Let’s go</span>\n      <%= icon_tag \"arrow-right\" %>\n    </button>\n  <% end %>\n</div>\n\n<% content_for :footer do %>\n  <%= render \"sessions/footer\" %>\n<% end %>\n"
  },
  {
    "path": "app/views/tags/_tag.json.jbuilder",
    "content": "json.cache! tag do\n  json.(tag, :id, :title)\n  json.created_at tag.created_at.utc\n  json.url cards_url(tag_ids: [ tag ])\nend\n"
  },
  {
    "path": "app/views/tags/index.html.erb",
    "content": "<section class=\"panel borderless center margin pad\">\n  <h1>All tags</h1>\n  <ul class=\"txt-align-start\">\n    <% @tags.each do |tag| %>\n      <li class=\"flex align-start justify-space-between gap pad\" style=\"border-block-end: 1px solid var(--color-ink-lighter)\">\n        <span>\n          <strong class=\"txt-nowrap\"><%= tag.title %></strong>\n        </span>\n\n        <span>\n          <%= tag.cards.pluck(:title).to_sentence %>\n        </span>\n\n        <%= button_to tag_path(tag), class: \"btn txt-small\", method: :delete, data: { turbo_confirm: \"Are you sure?\" } do %>\n          <%= icon_tag \"remove\" %>\n          <span class=\"for-screen-reader\">Delete</span>\n        <% end %>\n      </li>\n    <% end %>\n  </ul>\n</section>\n"
  },
  {
    "path": "app/views/tags/index.json.jbuilder",
    "content": "json.array! @page.records, partial: \"tags/tag\", as: :tag\n"
  },
  {
    "path": "app/views/user_mailer/email_change_confirmation.html.erb",
    "content": "<p class=\"subtitle\">Confirm your email address change</p>\n\n<%= link_to \"Yes use this email address\", user_email_address_confirmation_url(script_name: @user.account.slug, user_id: @user.id, email_address_token: @token), class: \"btn\" %>\n\n<p class=\"margin-block-start-double\">If you didn’t request this change, you can ignore this email. Your email address WILL NOT be changed unless you hit the button.</p>\n\n<p class=\"footer\">Need help? <%= mail_to \"support@fizzy.do\", \"Send us an email\"%>.\n</p>\n"
  },
  {
    "path": "app/views/users/_access_tokens.html.erb",
    "content": "<section class=\"settings__section flex flex-column align-center\">\n  <header>\n    <h2 class=\"divider\">Developer</h2>\n    <div>Manage <%= link_to \"personal access tokens\", my_access_tokens_path, class: \"btn btn--plain txt-link\" %> used with the Fizzy developer API.</div>\n  </header>\n</section>\n"
  },
  {
    "path": "app/views/users/_activity_timeline.html.erb",
    "content": "<div class=\"events margin-block-double\" id=\"activity\" data-controller=\"pagination\" data-pagination-paginate-on-intersection-value=\"true\">\n  <%= day_timeline_pagination_frame_tag day_timeline do %>\n    <%= render \"events/day\", day_timeline: day_timeline %>\n\n    <% if day_timeline.next_day %>\n      <%= link_to \"Load more…\", user_path(user, day: day_timeline.next_day.strftime(\"%Y-%m-%d\"), **filter.as_params),\n            class: \"day-timeline-pagination-link\", data: { frame: day_timeline_pagination_frame_id_for(day_timeline.next_day), pagination_target: \"paginationLink\" } %>\n    <% end %>\n  <% end %>\n</div>\n\n\n\n"
  },
  {
    "path": "app/views/users/_attachable.html.erb",
    "content": "<%= avatar_image_tag user %>\n<%= user.first_name %>\n"
  },
  {
    "path": "app/views/users/_data_export.html.erb",
    "content": "<section class=\"settings__section\">\n  <header>\n    <h2 class=\"divider\">Export your data</h2>\n    <div>Download an archive of your Fizzy data.</div>\n  </header>\n\n  <div data-controller=\"dialog\" data-dialog-modal-value=\"true\" data-action=\"keydown.esc->dialog#close\">\n    <button type=\"button\" class=\"btn\" data-action=\"dialog#open\">Begin export...</button>\n\n    <dialog class=\"dialog panel panel--wide shadow\" data-dialog-target=\"dialog\" style=\"--panel-size: 48ch;\">\n      <h2 class=\"txt-large\">Export your data</h2>\n      <p>This will generate a ZIP archive of all cards you have access to.</p>\n      <p>We’ll email you a link to download the file when it’s ready. The link will expire after 24 hours.</p>\n\n      <div class=\"flex gap justify-center\">\n        <%= button_to \"Start export\", user_data_exports_path(@user), method: :post, class: \"btn btn--link\", form: { data: { action: \"submit->dialog#close\" } } %>\n        <button type=\"button\" class=\"btn\" data-action=\"dialog#close\">Cancel</button>\n      </div>\n    </dialog>\n  </div>\n</section>\n"
  },
  {
    "path": "app/views/users/_theme.html.erb",
    "content": "<section class=\"settings__section\">\n  <header>\n    <h2 class=\"divider\">Appearance</h2>\n  </header>\n\n  <div class=\"theme-switcher flex gap max-width justify-center txt-small margin-block-start-half\">\n    <label class=\"btn theme-switcher__btn\">\n      <%= icon_tag \"sun\" %>\n      <span class=\"overflow-ellipsis\">Always light</span>\n      <input type=\"radio\" name=\"theme\" data-action=\"click->theme#setLight\" data-theme-target=\"lightButton\">\n    </label>\n\n    <label class=\"btn theme-switcher__btn\" >\n      <%= icon_tag \"moon\" %>\n      <span class=\"overflow-ellipsis\">Always dark</span>\n      <input type=\"radio\" name=\"theme\" data-action=\"click->theme#setDark\" data-theme-target=\"darkButton\">\n    </label>\n\n    <label class=\"btn theme-switcher__btn\">\n      <%= icon_tag \"monitor\" %>\n      <span class=\"overflow-ellipsis\">Same as OS</span>\n      <input type=\"radio\" name=\"theme\" data-action=\"click->theme#setAuto\" data-theme-target=\"autoButton\">\n    </label>\n  </div>\n</section>\n"
  },
  {
    "path": "app/views/users/_transfer.html.erb",
    "content": "<section class=\"settings__section\">\n  <% url = session_transfer_url(user.identity.transfer_id, script_name: nil) %>\n\n  <header>\n    <h2 class=\"divider\">Devices</h2>\n    <p class=\"margin-none\" id=\"session_transfer_label\">Link to automatically log in on another device.</p>\n  </header>\n\n  <input type=\"text\" class=\"input fill-white\" id=\"session_transfer_url\" value=\"<%= url %>\" aria-labelledby=\"session_transfer_label\" readonly>\n\n  <div class=\"flex justify-center gap\">\n    <div data-controller=\"dialog\" data-dialog-modal-value=\"true\" class=\"flex-inline\">\n      <%= tag.button class: \"btn\", data: { action: \"dialog#open\", controller: \"tooltip\" } do %>\n        <%= icon_tag \"qr-code\" %>\n        <span class=\"for-screen-reader\">Display auto-login QR code</span>\n      <% end %>\n\n      <dialog class=\"dialog panel shadow\" data-dialog-target=\"dialog\" style=\"--panel-size: 50ch;\">\n        <p class=\"margin-none-block-start txt-balance\">\n          <strong>Scan this code to instantly log in on another device:</strong>\n        </p>\n\n        <%= qr_code_image(url) %>\n\n        <form method=\"dialog\" class=\"margin-block-start flex justify-center\">\n          <button class=\"btn\">\n            <span>Done</span>\n          </button>\n        </form>\n      </dialog>\n    </div>\n\n    <%= button_to_copy_to_clipboard(url) do %>\n      <%= icon_tag \"copy-paste\" %>\n      <span class=\"for-screen-reader\">Copy auto-login link</span>\n    <% end %>\n  </div>\n</section>\n"
  },
  {
    "path": "app/views/users/_user.json.jbuilder",
    "content": "json.cache! user do\n  json.(user, :id, :name, :role, :active)\n\n  json.email_address user.identity&.email_address\n  json.created_at user.created_at.utc\n\n  json.url user_url(user)\n  json.avatar_url user_avatar_url(user)\nend\n"
  },
  {
    "path": "app/views/users/avatars/show.svg.erb",
    "content": "<svg version=\"1.1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\"\n  viewBox=\"0 0 512 512\" class=\"avatar\" aria-hidden=\"true\">\n  <defs>\n    <clipPath id=\"porthole\">\n      <circle cx=\"50%\" cy=\"50%\" r=\"50%\" />\n    </clipPath>\n  </defs>\n\n  <g>\n    <rect width=\"100%\" height=\"100%\" rx=\"50\" fill=\"<%= avatar_background_color(@user) %>\" />\n\n    <text x=\"50%\" y=\"50%\" fill=\"#FFFFFF\"\n      text-anchor=\"middle\" dy=\"0.35em\"\n      <%=raw 'textLength=\"85%\" lengthAdjust=\"spacingAndGlyphs\"' if @user.initials.size >= 3 %>\n      font-family=\"-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica, Arial, sans-serif\"\n      font-size=\"230\"\n      font-weight=\"800\"\n      letter-spacing=\"-5\">\n      <%= @user.initials %>\n    </text>\n  </g>\n</svg>\n"
  },
  {
    "path": "app/views/users/data_exports/show.html.erb",
    "content": "<% if @export.present? %>\n  <% @page_title = \"Download Export\" %>\n<% else %>\n  <% @page_title = \"Download Expired\" %>\n<% end %>\n\n<% content_for :header do %>\n  <div class=\"header__actions header__actions--start\">\n    <%= back_link_to @user.name, user_path(@user), \"keydown.left@document->hotkey#click keydown.esc@document->hotkey#click\" %>\n  </div>\n<% end %>\n\n<div class=\"panel panel--wide shadow center flex flex-column align-center gap\">\n  <h2 class=\"txt-large font-weight-black margin-none\"><%= @page_title %></h2>\n\n  <% if @export.present? %>\n    <div>Your export is ready. The download should start automatically.</div>\n\n    <%= link_to \"Download your data\", rails_blob_path(@export.file, disposition: \"attachment\"),\n          id: \"download-link\",\n          class: \"btn btn--link\",\n          data: { turbo: false, controller: \"auto-click\" } %>\n  <% else %>\n    <div>That download link has expired. You'll need to <%= link_to \"request a new export\", user_path(@user), class: \"txt-link\" %>.</div>\n  <% end %>\n\n</div>\n"
  },
  {
    "path": "app/views/users/edit.html.erb",
    "content": "<% @page_title = \"Edit your profile\" %>\n\n<% content_for :header do %>\n  <div class=\"header__actions header__actions--start\">\n    <%= back_link_to \"profile\", user_path(@user), \"keydown.left@document->hotkey#click keydown.esc@document->hotkey#click\" %>\n  </div>\n<% end %>\n\n<div class=\"panel shadow center\" style=\"--panel-size: 45ch;\">\n  <div class=\"flex flex-column gap txt-medium\">\n    <%= form_with model: @user, method: :patch, class: \"flex flex-column gap\", data: { controller: \"form upload-preview\" } do |form| %>\n      <div class=\"align-center center avatar__form gap\">\n        <span class=\"btn btn--placeholder txt-small\"></span>\n\n        <label class=\"avatar btn btn--circle input--file txt-xx-large center fill-white\">\n          <%= image_tag user_avatar_path(@user), aria: { hidden: \"true\" }, class: \"avatar\", size: 128, data: { upload_preview_target: \"image\" } %>\n          <%= form.file_field :avatar, id: \"file\", class: \"input\", accept: User::Avatar::ALLOWED_AVATAR_CONTENT_TYPES.join(\",\"),\n                data: { upload_preview_target: \"input\", action: \"upload-preview#previewImage\" } %>\n          <span class=\"for-screen-reader\">Profile avatar for <%= @user.name %></span>\n        </label>\n\n        <% if @user.avatar.attached? %>\n          <%= tag.button type: :submit, form: \"avatar-delete-form\", class: \"btn btn--negative txt-small\", data: { turbo_confirm: \"Are you sure you want to remove your avatar? This can't be undone.\" } do %>\n            <%= icon_tag \"minus\" %>\n            <span class=\"for-screen-reader\">Delete avatar</span>\n          <% end %>\n        <% end %>\n      </div>\n\n      <div class=\"flex align-center gap\">\n        <label class=\"flex align-center gap input input--actor\">\n          <%= form.text_field :name, class: \"input full-width\", autocomplete: \"name\", placeholder: \"Name\", autofocus: true, required: true, data: { \"1p-ignore\": true, action: \"keydown.esc@document->form#cancel\" } %>\n        </label>\n      </div>\n      <div class=\"flex align-center gap\">\n        <div class=\"flex align-center gap input input--actor\">\n          <%= form.email_field :email_address, class: \"input full-width\", autocomplete: \"username\", placeholder: \"Email address\", required: true, readonly: true, value: @user.identity.email_address %>\n          <%= link_to \"Change email\", new_user_email_address_path(user_id: Current.user.id), class: \"btn btn--plain txt-link txt-small txt-nowrap\" %>\n        </div>\n      </div>\n\n      <% if @user.errors.any? %>\n        <div class=\"margin-block-half txt-negative txt-small\" style=\"text-align: left;\">\n          <p class=\"margin-block-none font-weight-bold\">Your changes couldn't be saved:</p>\n          <ul class=\"margin-block-none\">\n            <% @user.errors.full_messages.each do |message| %>\n              <li><%= message %></li>\n            <% end %>\n          </ul>\n        </div>\n      <% end %>\n\n      <button type=\"submit\" id=\"log_in\" class=\"btn btn--reversed center\" data-form-target=\"submit\">\n        <span>Save changes</span>\n      </button>\n\n      <%= link_to \"Cancel and go back\", user_path(@user), data: { form_target: \"cancel\", turbo_frame: \"_top\" }, hidden: true %>\n    <% end %>\n\n    <%= form_with url: user_avatar_url(@user), method: :delete, id: \"avatar-delete-form\" %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/users/email_addresses/confirmations/invalid_token.html.erb",
    "content": "<% @page_title = \"Link expired\" %>\n\n<div class=\"panel panel--centered center flex flex-column gap-half\" style=\"--panel-size: 50ch;\">\n  <h1 class=\"txt-x-large font-weight-black margin-none\">\n    <%= @page_title %>\n  </h1>\n  <p class=\"margin-none-block-start margin-block-end-half\">\n    That email confirmation link is no longer valid—they expire after 30 minutes. You’ll have to try again.\n  </p>\n\n  <%= link_to \"Change my email address\", new_user_email_address_path(user_id: @user, script_name: @user.account.slug), class: \"btn btn--link center\" %>\n\n  <p class=\"txt-small txt-subtle\">\n    If you get stuck, <%= mail_to \"support@fizzy.do\", \"send us an email\" %> and we’ll get you back on track.\n  </p>\n</div>\n"
  },
  {
    "path": "app/views/users/email_addresses/confirmations/show.html.erb",
    "content": "<% @page_title = \"Confirm email change\" %>\n\n<div class=\"panel panel--centered center flex flex-column gap-half\" style=\"--panel-size: 45ch;\">\n  <header>\n    <h1 class=\"txt-x-large font-weight-black margin-none\">\n      <%= @page_title %>\n    </h1>\n    <p class=\"margin-none-block-start margin-block-end-half\">Just a sec while we confirm your new email address.</p>\n  </header>\n\n  <%= form_with url: user_email_address_confirmation_path(user_id: @user.id), method: :post, data: { controller: \"form auto-submit\" } do |form| %>\n    <%= form.hidden_field :email_address_token, value: params[:email_address_token] %>\n\n    <button type=\"submit\" class=\"btn btn--link center\" data-form-target=\"submit\">\n      <span>Done</span>\n    </button>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/users/email_addresses/create.html.erb",
    "content": "<% @page_title = \"Confirm your new email address\" %>\n\n<% content_for :header do %>\n  <div class=\"header__actions header__actions--start\">\n    <%= back_link_to \"My profile\", edit_user_path(@user, script_name: @user.account.slug), \"keydown.left@document->hotkey#click keydown.esc@document->hotkey#click\" %>\n  </div>\n<% end %>\n\n<div class=\"panel panel--centered flex flex-column gap-half\">\n  <h1 class=\"txt-x-large font-weight-black margin-none\">Check your email</h1>\n  <p class=\"margin-none\">We just sent an email to <strong><%= params[:email_address] %></strong></p>\n  <p class=\"margin-none-block-start margin-block-end-half\">Hit the link in the email to confirm this is the email address you want to use with Fizzy going forward.</p>\n\n  <%= link_to \"Done\", edit_user_path(@user, script_name: @user.account.slug), class: \"btn btn--link center\" %>\n</div>\n"
  },
  {
    "path": "app/views/users/email_addresses/new.html.erb",
    "content": "<% @page_title = \"Change your email\" %>\n\n<% content_for :header do %>\n  <div class=\"header__actions header__actions--start\">\n    <%= back_link_to \"My profile\", edit_user_path(@user, script_name: @user.account.slug), \"keydown.left@document->hotkey#click keydown.esc@document->hotkey#click\" %>\n  </div>\n<% end %>\n\n<div class=\"panel panel--centered flex flex-column gap-half\">\n  <h1 class=\"txt-x-large font-weight-black margin-none\">\n    <%= @page_title %>\n  </h1>\n\n  <%= form_with url: user_email_addresses_path(user_id: @user.id), method: :post, class: \"flex flex-column gap-half\", data: { controller: \"form\", turbo: false } do |form| %>\n    <div class=\"flex align-center gap-half\">\n      <label class=\"flex align-center gap input input--actor\">\n        <%= form.email_field :email_address, class: \"input full-width\", autocomplete: \"email\", placeholder: \"New email address\", autofocus: true, required: true %>\n      </label>\n    </div>\n\n    <p class=\"margin-none-block-start margin-block-end-half\">Enter your new email address, then check your&nbsp;email to confirm the change.</p>\n\n    <button type=\"submit\" class=\"btn btn--link center\" data-form-target=\"submit\">\n      <span>Continue</span>\n      <%= icon_tag \"arrow-right\" %>\n    </button>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/users/events/show.html.erb",
    "content": "<turbo-frame id=\"user_events\">\n  <h1 class=\"font-weight-black txt-large margin-block-double\"><%= \"What #{Current.user == @user ? \"have you\" : \"has #{@user.first_name}\"} been up to?\" %></h1>\n\n  <div class=\"events margin-block-double\" id=\"activity\" data-controller=\"pagination\" data-pagination-paginate-on-intersection-value=\"true\">\n    <%= day_timeline_pagination_frame_tag @day_timeline do %>\n      <%= render \"events/day\", day_timeline: @day_timeline %>\n\n      <% if @day_timeline.next_day %>\n        <%= link_to \"Load more…\", user_events_path(@user, day: @day_timeline.next_day.strftime(\"%Y-%m-%d\"), **@filter.as_params),\n              class: \"day-timeline-pagination-link\", data: { frame: day_timeline_pagination_frame_id_for(@day_timeline.next_day), pagination_target: \"paginationLink\" } %>\n      <% end %>\n    <% end %>\n  </div>\n</turbo-frame>\n"
  },
  {
    "path": "app/views/users/index.json.jbuilder",
    "content": "json.array! @page.records, partial: \"users/user\", as: :user\n"
  },
  {
    "path": "app/views/users/joins/new.html.erb",
    "content": "<% @page_title = \"Great! Now enter your name\" %>\n\n<div class=\"panel panel--centered flex flex-column gap-half\">\n  <h1 class=\"txt-large font-weight-black margin-none\"><%= @page_title %></h1>\n\n  <%= form_with scope: \"user\", url: users_joins_path, class: \"flex flex-column gap txt-medium\", data: { controller: \"form\" } do |form| %>\n    <div class=\"flex align-center gap\">\n      <label class=\"flex align-center gap input input--actor\">\n        <%= form.text_field :name, class: \"input full-width\", autocomplete: \"name\", placeholder: \"Full name\", autofocus: true, required: true, data: { \"1p-ignore\": true } %>\n      </label>\n    </div>\n\n    <button type=\"submit\" id=\"log_in\" class=\"btn btn--link center\" data-form-target=\"submit\">\n      <span>Continue</span>\n      <%= icon_tag \"arrow-right\" %>\n    </button>\n  <% end %>\n</div>\n\n<% content_for :footer do %>\n  <%= render \"sessions/footer\" %>\n<% end %>\n"
  },
  {
    "path": "app/views/users/show.html.erb",
    "content": "<% @page_title = @user.name %>\n<% me_or_you = Current.user == @user ? \"me\" : @user.first_name %>\n\n<div class=\"settings settings--profile\">\n  <div class=\"settings__panel panel shadow txt-align-center\">\n    <div class=\"flex flex-column gap position-relative\">\n      <% if Current.user == @user %>\n        <%= link_to edit_user_path(@user), class: \"user-edit-link btn\", data: { controller: \"tooltip\" } do %>\n          <%= icon_tag \"pencil\" %>\n          <span class=\"for-screen-reader\">Edit profile</span>\n        <% end %>\n      <% end %>\n\n      <div class=\"avatar txt-xx-large center fill-white btn--circle hide-focus-ring\">\n        <%= image_tag user_avatar_path(@user), alt: \"Profile avatar for #{@user.name}\" %>\n      </div>\n\n      <div class=\"flex flex-column gap-half margin-block-end\">\n        <h1 class=\"txt-x-large margin-none\"><%= @user.name %></h1>\n        <div class=\"txt-medium\">\n          <% if !@user.active? %>\n            <%= @user.name %> is no longer on this account\n          <% elsif !@user.verified? %>\n            Unverified\n            <div class=\"txt-small txt-tinted\">A sign-in code has been sent to this email address, but the user has not yet logged in to confirm their identity.</div>\n          <% else %>\n            <%= mail_to @user.identity.email_address %>\n          <% end %>\n        </div>\n      </div>\n\n      <% if @user.verified? %>\n        <div class=\"flex-inline center justify-center flex-wrap gap\">\n          <%= link_to \"Which cards are assigned to #{me_or_you}?\",\n                cards_path(assignee_ids: [ @user.id ], sorted_by: \"newest\"), class: \"btn btn--link\", data: { turbo_frame: \"_top\" } %>\n          <%= link_to \"Which cards were added by #{me_or_you}?\",\n                cards_path(creator_ids: [ @user.id ], sorted_by: \"newest\"), class: \"btn btn--link\", data: { turbo_frame: \"_top\" } %>\n        </div>\n      <% end %>\n\n      <% if Current.user == @user %>\n        <hr class=\"separator--horizontal full-width flex-item-grow margin-block-start-double\" style=\"--border-color: var(--color-ink-light);\" aria-hidden=\"true\">\n\n        <%= link_to my_passkeys_path, class: \"btn txt-x-small center\" do %>\n          <%= icon_tag \"authentication\" %>\n          <span>Manage passkeys</span>\n        <% end %>\n\n        <%= button_to session_path(script_name: nil), method: :delete, class: \"btn txt-x-small center\", form: { data: { turbo: false, controller: \"clear-offline-cache\", action: \"submit->clear-offline-cache#clearCache\" } } do %>\n          <span>Sign out of Fizzy on this device</span>\n        <% end %>\n      <% end %>\n    </div>\n  </div>\n\n  <% if Current.user == @user %>\n    <div class=\"settings__panel panel shadow hide-on-native\">\n      <%= render \"users/theme\" %>\n      <%= render \"users/transfer\", user: @user %>\n      <%= render \"users/access_tokens\" %>\n      <%= render \"users/data_export\" %>\n    </div>\n  <% end %>\n</div>\n\n<% if @user.verified? %>\n  <%= turbo_frame_tag \"user_events\", src: user_events_path(@user) %>\n<% end %>\n"
  },
  {
    "path": "app/views/users/show.json.jbuilder",
    "content": "json.partial! \"users/user\", user: @user\n"
  },
  {
    "path": "app/views/users/verifications/new.html.erb",
    "content": "<%= auto_submit_form_with url: users_verifications_path, method: :post %>\n"
  },
  {
    "path": "app/views/webhooks/_delivery.html.erb",
    "content": "<%= tag.li id: dom_id(delivery), class: token_list(delivery.succeeded? && \"delivery--succeeded\") do %>\n  <strong>\n    <%= delivery.state %>\n  </strong>\n  <div class=\"txt-subtle\">\n    <%= time_tag delivery.created_at, time_ago_in_words(delivery.created_at) %>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/webhooks/_webhook.html.erb",
    "content": "<li class=\"list-style-none margin-none flex align-center gap full-width\">\n  <%= link_to webhook, class: \"txt-ink flex gap-half align-center min-width txt-medium\" do %>\n    <strong class=\"overflow-ellipsis\"><%= webhook.name %></strong>\n  <% end %>\n\n  <hr class=\"separator--horizontal flex-item-grow\" style=\"--border-color: var(--color-ink-medium); --border-style: dashed\" aria-hidden=\"true\">\n\n  <%= button_to webhook, method: :delete, class: \"btn btn--circle btn--negative txt-xx-small flex-item-no-shrink\",\n        data: { turbo_confirm: \"Are you sure you want to permanently remove this webhook?\" } do %>\n    <%= icon_tag \"minus\" %>\n    <span class=\"for-screen-reader\">Remove <%= webhook.name %></span>\n  <% end %>\n</li>\n"
  },
  {
    "path": "app/views/webhooks/_webhook.json.jbuilder",
    "content": "json.cache! [ webhook, webhook.board ] do\n  json.(webhook, :id, :name, :active, :signing_secret, :subscribed_actions)\n  json.payload_url webhook.url\n  json.created_at webhook.created_at.utc\n  json.url board_webhook_url(webhook.board, webhook)\n\n  json.board webhook.board, partial: \"boards/board\", as: :board\nend\n"
  },
  {
    "path": "app/views/webhooks/edit.html.erb",
    "content": "<% @page_title = @webhook.name %>\n\n<% content_for :header do %>\n  <div class=\"header__actions header__actions--start\">\n    <%= back_link_to @page_title, @webhook, \"keydown.left@document->hotkey#click keydown.esc@document->hotkey#click\" %>\n  </div>\n<% end %>\n\n<article class=\"panel panel--wide center txt-align-start\" style=\"view-transition-name: <%= dom_id(@webhook) %>\">\n  <%= form_with model: @webhook, url: @webhook, method: :put, data: { controller: \"form\" }, html: { class: \"flex flex-column gap\" } do |form| %>\n    <h2 class=\"txt-large margin-none\">\n      <%= form.text_field :name, required: true, autofocus: true, class: \"input full-width\", placeholder: \"Name your Webhook…\", data: { action: \"keydown.esc@document->form#cancel\" } %>\n    </h2>\n\n    <div class=\"flex flex-column gap-half\">\n      <%= form.label :actions do %>\n        <strong>Events</strong><br>\n        <p class=\"margin-none txt-x-small txt-subtle\">Trigger a call to the Payload URL when:</p>\n      <% end %>\n      <%= render \"webhooks/form/actions\", form: form %>\n    </div>\n\n    <%= form.button type: :submit, class: \"btn btn--link center txt-medium\" do %>\n      <span>Save Changes</span>\n    <% end %>\n\n    <%= link_to \"Go back\", board_webhooks_path, data: { form_target: \"cancel\" }, hidden: true %>\n  <% end %>\n</article>\n"
  },
  {
    "path": "app/views/webhooks/event.html.erb",
    "content": "<%= @event.description_for(Current.user).to_plain_text %>\n<% if @event.eventable %>\n<%= link_to \"↗︎\", polymorphic_url(@event.eventable) %>\n<% end %>\n"
  },
  {
    "path": "app/views/webhooks/event.json.jbuilder",
    "content": "json.cache! @event do\n  json.(@event, :id, :action)\n  json.created_at @event.created_at.utc\n\n  json.eventable do\n    case @event.eventable\n    when Card then json.partial! \"cards/card\", card: @event.eventable\n    when Comment then json.partial! \"cards/comments/comment\", comment: @event.eventable\n    end\n  end\n\n  json.board @event.board, partial: \"boards/board\", as: :board\n  json.creator @event.creator, partial: \"users/user\", as: :user\nend\n"
  },
  {
    "path": "app/views/webhooks/form/_actions.html.erb",
    "content": "<div data-controller=\"toggle-class\">\n  <div class=\"flex align-center gap-half margin-block-end-half\">\n    <%= button_tag \"Enable all\", type: \"button\", class: \"btn btn--plain txt-x-small txt-link font-weight-normal\", data: { action: \"click->toggle-class#checkAll\" } %>\n    <span class=\"txt-subtle\">&middot;</span>\n    <%= button_tag \"Disable all\", type: \"button\", class: \"btn btn--plain txt-x-small txt-link font-weight-normal\", data: { action: \"click->toggle-class#checkNone\" } %>\n  </div>\n  <ul class=\"flex flex-column align-start gap-half margin-none unpad\">\n    <%= form.collection_check_boxes \\\n          :subscribed_actions,\n          webhook_action_options,\n          :first,\n          :last do |item| %>\n      <li class=\"list-style-none margin-none\">\n        <label class=\"toggler__visible-when-off flex align-center gap cursor-pointer\">\n          <span class=\"switch txt-x-small flex-item-no-shrink\">\n            <%= item.check_box(class: \"switch__input\", data: { toggle_class_target: \"checkbox\" }) %>\n            <span class=\"switch__btn round\"></span>\n          </span>\n          <span class=\"min-width\"><%= item.text %></span>\n        </label>\n      </li>\n    <% end %>\n  </ul>\n</div>\n"
  },
  {
    "path": "app/views/webhooks/index.html.erb",
    "content": "<% @page_title = \"Webhooks\" %>\n\n<% content_for :header do %>\n  <div class=\"header__actions header__actions--start\">\n    <%= link_back_to_board(@board) %>\n  </div>\n\n  <h1 class=\"header__title\" data-bridge--title-target=\"header\"><%= @page_title %></h1>\n<% end %>\n\n<%= tag.section class: \"panel panel--wide shadow center webhooks\" do %>\n  <% if @page.records.any? %>\n    <ul class=\"flex flex-column align-start gap margin-none-block-start unpad webhooks-list\">\n      <%= render partial: \"webhooks/webhook\", collection: @page.records %>\n    </ul>\n  <% else %>\n    <p>Webhooks can notify another application when something happens in this Fizzy board. You’ll choose which events to subscribe to and provide a URL to receive the data.</p>\n    <p>For example, you could create a webhook that posts to a Campfire chat in Basecamp when new cards are added to Fizzy.</p>\n  <% end %>\n\n  <%= link_to new_board_webhook_path, class: \"btn btn--link\" do %>\n    <%= icon_tag \"add\" %>\n    <span>Set up a new webhook</span>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/webhooks/index.json.jbuilder",
    "content": "json.array! @page.records, partial: \"webhooks/webhook\", as: :webhook\n"
  },
  {
    "path": "app/views/webhooks/new.html.erb",
    "content": "<% @page_title = \"Set up a Webhook\" %>\n\n<% content_for :header do %>\n  <div class=\"header__actions header__actions--start\">\n    <%= back_link_to \"Webhooks\", board_webhooks_path, \"keydown.left@document->hotkey#click keydown.esc@document->hotkey#click\" %>\n  </div>\n\n  <h1 class=\"header__title\" data-bridge--title-target=\"header\"><%= @page_title %></h1>\n<% end %>\n\n<article class=\"panel panel--wide center txt-align-start\" style=\"view-transition-name: <%= dom_id(@webhook) %>\">\n  <%= form_with model: @webhook, url: board_webhooks_path, data: { controller: \"form\" }, html: { class: \"flex flex-column gap\" } do |form| %>\n    <h2 class=\"txt-large margin-none\">\n      <%= form.text_field :name, required: true, autofocus: true, class: \"input\", placeholder: \"Name this Webhook…\", data: { action: \"keydown.esc@document->form#cancel\" } %>\n    </h2>\n\n    <div class=\"flex flex-column gap-half\">\n      <%= form.label :url do %>\n        <strong>Payload URL</strong><br>\n        <p class=\"margin-none txt-x-small txt-subtle\">This is the URL for the app that will receive payloads from Fizzy.</p>\n      <% end %>\n      <%= form.url_field :url,\n            required: true,\n            pattern: \"https?://.*\",\n            title: \"Must be an http:// or https:// URL\",\n            class: \"input\",\n            placeholder: \"https://example.com\",\n            data: { action: \"keydown.esc@document->form#cancel\" } %>\n    </div>\n\n    <div class=\"flex flex-column gap-half\">\n      <%= form.label :actions do %>\n        <strong>Events</strong><br>\n        <p class=\"margin-none txt-x-small txt-subtle\">Trigger a call to the Payload URL when:</p>\n      <% end %>\n      <%= render \"webhooks/form/actions\", form: form %>\n    </div>\n\n    <%= form.button type: :submit, class: \"btn btn--link center txt-medium\" do %>\n      <span>Create Webhook</span>\n    <% end %>\n\n    <%= link_to \"Go back\", board_webhooks_path, data: { form_target: \"cancel\" }, hidden: true %>\n  <% end %>\n</article>\n"
  },
  {
    "path": "app/views/webhooks/show.html.erb",
    "content": "<% @page_title = @webhook.name %>\n\n<% content_for :header do %>\n  <div class=\"header__actions header__actions--start\">\n    <div class=\"header__actions header__actions--start\">\n      <%= back_link_to \"Webhooks\", board_webhooks_path, \"keydown.left@document->hotkey#click keydown.esc@document->hotkey#click\" %>\n    </div>\n  </div>\n\n  <div class=\"header__actions header__actions--end\">\n    <%= link_to edit_board_webhook_path(@webhook.board_id, @webhook), class: \"btn\" do %>\n      <%= icon_tag \"pencil\" %>\n      <span class=\"for-screen-reader\">Edit</span>\n    <% end %>\n  </div>\n<% end %>\n\n<div class=\"panel panel--wide shadow center flex flex-column gap txt-align-start\">\n  <header>\n    <h1 class=\"txt-x-large margin-none\"><%= @webhook.name %></h1>\n    <span class=\"txt-medium txt-break\"><%= @webhook.url %></span>\n  </header>\n  <% unless @webhook.active? %>\n    <div>\n      <%= button_to \"Reactivate\", board_webhook_activation_path(@webhook.board_id, @webhook), method: :post %>\n    </div>\n  <% end %>\n\n  <div>\n    <h2 class=\"margin-none txt-uppercase txt-medium\">Secret</h2>\n    <strong><code><%= @webhook.signing_secret %></code></strong>\n    <p class=\"txt-small txt-subtle margin-none\">\n      We'll send a <code>X-Webhook-Signature</code> header with each request.\n      You can generate a <code>HMAC</code> using <code>SHA256</code> of the request body with this secret\n      to verify that the request came from us.\n    </p>\n  </div>\n\n    <div class=\"flex flex-column\">\n      <h2 class=\"margin-none txt-uppercase txt-medium\">Subscribed to</h2>\n      <% if @webhook.subscribed_actions.empty? %>\n        <p class=\"margin-none txt-subtle\">This Webhook isn't subscribed to any events. It will never trigger.</p>\n      <% else %>\n        <ul class=\"margin-none unpad-block\">\n          <% @webhook.subscribed_actions.each do |action| %>\n            <li><%= webhook_action_label(action) %></li>\n          <% end %>\n        </ul>\n      <% end %>\n    </div>\n\n    <div class=\"flex flex-column\">\n      <h2 class=\"margin-none txt-uppercase txt-medium\">Deliveries</h2>\n      <% if @webhook.deliveries.empty? %>\n        <p class=\"margin-none txt-subtle\">This Webhook hasn't been triggered yet</p>\n      <% else %>\n        <ul class=\"margin-none unpad-block\">\n          <%= render partial: \"webhooks/delivery\", collection: @webhook.deliveries.ordered.limit(20), as: :delivery %>\n        </ul>\n      <% end %>\n    </div>\n</div>\n"
  },
  {
    "path": "app/views/webhooks/show.json.jbuilder",
    "content": "json.partial! \"webhooks/webhook\", webhook: @webhook\n"
  },
  {
    "path": "bin/brakeman",
    "content": "#!/usr/bin/env ruby\n# frozen_string_literal: true\n\n#\n# This file was generated by Bundler.\n#\n# The application 'brakeman' is installed as part of a gem, and\n# this file is here to facilitate running it.\n#\n\nENV[\"BUNDLE_GEMFILE\"] ||= File.expand_path(\"../Gemfile\", __dir__)\n\nbundle_binstub = File.expand_path(\"bundle\", __dir__)\n\nif File.file?(bundle_binstub)\n  if File.read(bundle_binstub, 300).include?(\"This file was generated by Bundler\")\n    load(bundle_binstub)\n  else\n    abort(\"Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.\nReplace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.\")\n  end\nend\n\nrequire \"rubygems\"\nrequire \"bundler/setup\"\n\nload Gem.bin_path(\"brakeman\", \"brakeman\")\n"
  },
  {
    "path": "bin/bundle-both",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\necho\ngum style --foreground 135 --bold \"▸ OSS: Gemfile\"\ngum style --foreground 240 \"bundle $*\"\nBUNDLE_GEMFILE=Gemfile bundle \"$@\"\n\necho\ngum style --foreground 135 --bold \"▸ SaaS: Gemfile.saas\"\ngum style --foreground 240 \"bundle $*\"\nBUNDLE_GEMFILE=Gemfile.saas bundle \"$@\"\n"
  },
  {
    "path": "bin/bundle-drift",
    "content": "#!/usr/bin/env ruby\n# Checks that Gemfile.lock and Gemfile.saas.lock are in sync for shared dependencies.\n# Since Gemfile.saas evals Gemfile, shared gems should have identical versions.\n#\n# Usage:\n#   bin/bundle-drift [check]   # check for drift (default subcommand)\n#   bin/bundle-drift correct   # restore alignment (Gemfile.saas.lock is authoritative)\n#   bin/bundle-drift forward   # push Gemfile.lock changes into Gemfile.saas.lock\nrequire \"bundler\"\nrequire \"fileutils\"\n\nGEMFILE_LOCK = \"Gemfile.lock\"\nGEMFILE_SAAS_LOCK = \"Gemfile.saas.lock\"\n\nclass GemfileDriftChecker\n  def initialize\n    @oss_lockfile = parse_lockfile(GEMFILE_LOCK)\n    @saas_lockfile = parse_lockfile(GEMFILE_SAAS_LOCK)\n  end\n\n  def check\n    find_drift.tap do\n      report it\n    end\n  end\n\n  private\n    def parse_lockfile(path)\n      Bundler::LockfileParser.new(File.read(path))\n    end\n\n    def find_drift\n      oss_specs, saas_specs = specs_hash(@oss_lockfile), specs_hash(@saas_lockfile)\n      shared_gems = oss_specs.keys & saas_specs.keys\n\n      shared_gems.filter_map do |name|\n        oss_version, saas_version = oss_specs[name], saas_specs[name]\n        if oss_version != saas_version\n          { name: name, oss: oss_version, saas: saas_version }\n        end\n      end.sort_by { |d| d[:name] }\n    end\n\n    def specs_hash(lockfile)\n      lockfile.specs.to_h { |spec| [ spec.name, spec.version.to_s ] }\n    end\n\n    def report(drift)\n      if drift.empty?\n        puts \"✓ Gemfile.lock and Gemfile.saas.lock are in sync\"\n      else\n        puts \"✗ Gemfile lock files have drifted!\\n\\n\"\n\n        name_width = [ drift.map { |d| d[:name].length }.max, \"Gem\".length ].max\n        oss_width = [ drift.map { |d| d[:oss].length }.max, \"Gemfile.lock\".length ].max\n        saas_width = [ drift.map { |d| d[:saas].length }.max, \"Gemfile.saas.lock\".length ].max\n\n        puts \"  #{\"Gem\".ljust(name_width)}  #{\"Gemfile.lock\".ljust(oss_width)}  Gemfile.saas.lock\"\n        puts \"  #{\"-\" * name_width}  #{\"-\" * oss_width}  #{\"-\" * saas_width}\"\n\n        drift.each do |d|\n          puts \"  #{d[:name].ljust(name_width)}  #{d[:oss].ljust(oss_width)}  #{d[:saas]}\"\n        end\n\n        puts \"\\nRun 'bin/bundle-drift correct' to restore alignment.\"\n      end\n    end\nend\n\nclass GemfileDriftCorrector\n  def correct\n    drift = GemfileDriftChecker.new.check\n    return puts \"\\nNothing to correct.\" if drift.empty?\n\n    puts \"\\nRestoring alignment (Gemfile.saas.lock is authoritative)...\\n\\n\"\n\n    # Save original for diff\n    original_content = File.read(GEMFILE_LOCK)\n\n    # Seed Gemfile.lock with Gemfile.saas.lock - Bundler will use these as version hints\n    FileUtils.cp(GEMFILE_SAAS_LOCK, GEMFILE_LOCK)\n\n    # Re-lock: Bundler prunes SaaS-only gems while preserving shared versions\n    puts \"▸ Re-locking Gemfile (seeded from Gemfile.saas.lock)\"\n    unless system(\"BUNDLE_GEMFILE=Gemfile bundle lock\")\n      File.write(GEMFILE_LOCK, original_content)\n      abort(\"Failed to lock Gemfile. Restored original.\")\n    end\n\n    puts \"\\n▸ Verifying alignment\"\n    new_drift = GemfileDriftChecker.new.check\n\n    if new_drift.empty?\n      puts \"\\n✓ Lock files are now in sync\"\n      show_diff(original_content, File.read(GEMFILE_LOCK))\n    else\n      puts \"\\n✗ Lock files still have drift after correction.\"\n      puts \"  Bundler couldn't resolve to matching versions.\"\n      puts \"  Restoring original Gemfile.lock.\"\n      File.write(GEMFILE_LOCK, original_content)\n      exit 1\n    end\n  end\n\n  private\n    def show_diff(original, corrected)\n      require \"tempfile\"\n\n      Tempfile.create(\"gemfile-lock-original\") do |f|\n        f.write(original)\n        f.flush\n\n        diff = `diff -u #{f.path} #{GEMFILE_LOCK} 2>/dev/null`\n        unless diff.empty?\n          puts \"\\nChanges made to Gemfile.lock:\"\n          puts diff\n        end\n      end\n    end\nend\n\nclass GemfileDriftForwarder\n  def forward\n    drift = GemfileDriftChecker.new.check\n    return puts \"\\nNothing to forward.\" if drift.empty?\n\n    puts \"\\nForwarding Gemfile.lock versions into Gemfile.saas.lock...\\n\\n\"\n\n    original_content = File.read(GEMFILE_SAAS_LOCK)\n    patched_content = original_content.dup\n\n    drift.each do |d|\n      puts \"  #{d[:name]} (#{d[:saas]}) → #{d[:name]} (#{d[:oss]})\"\n      patched_content.gsub!(/#{Regexp.escape(d[:name])} \\(#{Regexp.escape(d[:saas])}([^)]*)\\)/) do\n        \"#{d[:name]} (#{d[:oss]}#{$1})\"\n      end\n    end\n\n    File.write(GEMFILE_SAAS_LOCK, patched_content)\n\n    puts \"\\n▸ Verifying alignment\"\n    new_drift = GemfileDriftChecker.new.check\n\n    if new_drift.empty?\n      puts \"\\n✓ Lock files are now in sync\"\n      show_diff(original_content, patched_content)\n    else\n      puts \"\\n✗ Lock files still have drift after forwarding.\"\n      puts \"  Restoring original Gemfile.saas.lock.\"\n      File.write(GEMFILE_SAAS_LOCK, original_content)\n      exit 1\n    end\n  end\n\n  private\n    def show_diff(original, patched)\n      require \"tempfile\"\n\n      Tempfile.create(\"gemfile-saas-lock-original\") do |f|\n        f.write(original)\n        f.flush\n\n        diff = `diff -u #{f.path} #{GEMFILE_SAAS_LOCK} 2>/dev/null`\n        unless diff.empty?\n          puts \"\\nChanges made to Gemfile.saas.lock:\"\n          puts diff\n        end\n      end\n    end\nend\n\ncase command = ARGV[0] || \"check\"\nwhen \"check\"\n  exit 1 unless GemfileDriftChecker.new.check.empty?\nwhen \"correct\"\n  GemfileDriftCorrector.new.correct\nwhen \"forward\"\n  GemfileDriftForwarder.new.forward\nelse\n  abort \"Usage: bin/bundle-drift [check|correct|forward]\"\nend\n"
  },
  {
    "path": "bin/bundler-audit",
    "content": "#!/usr/bin/env ruby\nrequire_relative \"../config/boot\"\nrequire \"bundler/audit/cli\"\n\nARGV.concat %w[ --config config/bundler-audit.yml ] if ARGV.empty? || ARGV.include?(\"check\")\nBundler::Audit::CLI.start\n"
  },
  {
    "path": "bin/ci",
    "content": "#!/usr/bin/env ruby\nrequire_relative \"../config/boot\"\nrequire \"active_support/continuous_integration\"\n\nCI = ActiveSupport::ContinuousIntegration\nrequire_relative \"../config/ci.rb\"\n"
  },
  {
    "path": "bin/dev",
    "content": "#!/usr/bin/env sh\n\nPORT=3006\nUSE_TAILSCALE=0\nUSE_PUSH=0\n\nfor arg in \"$@\"; do\n  case $arg in\n    --tailscale) USE_TAILSCALE=1 ;;\n    --push) USE_PUSH=1 ;;\n  esac\ndone\n\nif [ \"$USE_PUSH\" = \"1\" ]; then\n  if [ ! -f tmp/saas.txt ]; then\n    echo \"Enabling SaaS mode for push notifications...\"\n    ./bin/rails saas:enable\n  fi\n  echo \"Loading push credentials from 1Password...\"\n  if ! eval \"$(BUNDLE_GEMFILE=Gemfile.saas bundle exec push-dev)\"; then\n    echo \"Error: failed to load push credentials. Are you signed into 1Password?\" >&2\n    exit 1\n  fi\n  if [ -z \"$APNS_ENCRYPTION_KEY_B64\" ] || [ -z \"$APNS_KEY_ID\" ]; then\n    echo \"Error: Push credentials not set. Missing APNS_ENCRYPTION_KEY_B64 or APNS_KEY_ID.\" >&2\n    exit 1\n  fi\nfi\n\nif [ ! -f tmp/solid-queue.txt ]; then\n  export SOLID_QUEUE_IN_PUMA=false\nfi\n\nif [ -f tmp/oss-config.txt ]; then\n  export OSS_CONFIG=1\nfi\n\nif [ \"$USE_TAILSCALE\" = \"1\" ]; then\n  if ! command -v tailscale >/dev/null 2>&1; then\n    echo \"Error: tailscale not found\" >&2\n    exit 1\n  fi\n\n  TS_STATUS=$(tailscale status --self --json 2>/dev/null)\n  if [ $? -ne 0 ]; then\n    echo \"Error: tailscale not logged in\" >&2\n    exit 1\n  fi\n\n  TS_HOSTNAME=$(echo \"$TS_STATUS\" | jq -r '.Self.DNSName | rtrimstr(\".\")')\n  TS_PORT=\"4$PORT\"\n\n  stop_tailscale() { tailscale serve --https=$TS_PORT off >/dev/null 2>&1; }\n  trap stop_tailscale EXIT INT TERM\n\n  tailscale serve --bg --https=$TS_PORT localhost:$PORT >/dev/null 2>&1\n  if ! tailscale serve status --json 2>/dev/null | jq -e \".TCP.\\\"$TS_PORT\\\"\" >/dev/null 2>&1; then\n    echo \"Error: tailscale serve failed. On Linux, run once: sudo tailscale set --operator=\\$USER\" >&2\n    exit 1\n  fi\n  echo \"Login with david@example.com to: https://$TS_HOSTNAME:$TS_PORT/\"\nelse\n  echo \"Login with david@example.com to: http://fizzy.localhost:$PORT/\"\nfi\n\n./bin/rails server -b 0.0.0.0 -p $PORT\n"
  },
  {
    "path": "bin/docker-entrypoint",
    "content": "#!/bin/bash -e\n\n# If running the rails server then create or migrate existing database\nif [ \"${1}\" == \"./bin/thrust\" ] && [ \"${2}\" == \"./bin/rails\" ] && [ \"${3}\" == \"server\" ]; then\n  MIGRATE=1 ./bin/rails db:prepare\nfi\n\nexec \"${@}\"\n"
  },
  {
    "path": "bin/gitleaks-audit",
    "content": "#!/usr/bin/env bash\n\nif ! which gitleaks > /dev/null 2>&1 ; then\n  echo \"gitleaks is not installed, please install it first\" 1>&2\n  exit 1\nfi\n\nmkdir -p tmp\nif ! gitleaks dir --redact=50 --report-path tmp/gitleaks-report.json ; then\n  echo \"gitleaks found potential secrets, please check tmp/gitleaks-report.json\" 1>&2\n  exit 1\nfi\n\nexit 0\n"
  },
  {
    "path": "bin/importmap",
    "content": "#!/usr/bin/env ruby\n\nrequire_relative '../config/application'\nrequire 'importmap/commands'\n"
  },
  {
    "path": "bin/jobs",
    "content": "#!/usr/bin/env ruby\n\nrequire_relative \"../config/environment\"\nrequire \"solid_queue/cli\"\n\nSolidQueue::Cli.start(ARGV)\n"
  },
  {
    "path": "bin/kamal",
    "content": "#!/usr/bin/env ruby\n# frozen_string_literal: true\n\n#\n# This file was generated by Bundler.\n#\n# The application 'kamal' is installed as part of a gem, and\n# this file is here to facilitate running it.\n#\n\nENV[\"BUNDLE_GEMFILE\"] ||= File.expand_path(\"../Gemfile\", __dir__)\n\nbundle_binstub = File.expand_path(\"bundle\", __dir__)\n\nif File.file?(bundle_binstub)\n  if File.read(bundle_binstub, 300).include?(\"This file was generated by Bundler\")\n    load(bundle_binstub)\n  else\n    abort(\"Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.\nReplace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.\")\n  end\nend\n\nrequire \"rubygems\"\n\nrequire_relative \"../lib/fizzy\"\nFizzy.configure_bundle\n\nrequire \"bundler/setup\"\n\nif Fizzy.saas?\n  gem_path = Gem::Specification.find_by_name(\"fizzy-saas\").gem_dir\n  deploy_config = File.join(gem_path, \"config\", \"deploy.yml\")\n\n  unless ARGV.include?(\"-c\") || ARGV.include?(\"--config-file\")\n    if ARGV.empty? || ARGV.first.start_with?(\"-\")\n      ARGV.unshift(\"-c\", deploy_config)\n    else\n      ARGV.insert(1, \"-c\", deploy_config)\n    end\n  end\nend\n\nload Gem.bin_path(\"kamal\", \"kamal\")\n"
  },
  {
    "path": "bin/minio-setup",
    "content": "#!/usr/bin/env ruby\n#\n#  Make sure the bucket we rely upon in development exists.\n#  Configuration and credentials are in config/storage.yml\n#\nrequire_relative \"../config/environment\"\nrequire \"aws-sdk-s3\"\n\n# Load storage configuration\nstorage_config = YAML.load(ERB.new(File.read(\"config/storage.yml\")).result)\nminio_config = storage_config[\"devminio\"]\n\nbucket_name = minio_config[\"bucket\"]\nendpoint = minio_config[\"endpoint\"]\naccess_key = minio_config[\"access_key_id\"]\nsecret_key = minio_config[\"secret_access_key\"]\nregion = minio_config[\"region\"]\n\n# Create S3 client\ns3_client = Aws::S3::Client.new(\n  endpoint: endpoint,\n  access_key_id: access_key,\n  secret_access_key: secret_key,\n  region: region,\n  force_path_style: minio_config[\"force_path_style\"]\n)\n\n# Check if bucket exists\nbegin\n  s3_client.head_bucket(bucket: bucket_name)\n  puts \"Bucket '#{bucket_name}' already exists\"\nrescue Aws::S3::Errors::NotFound\n  # Create the bucket\n  puts \"Creating bucket '#{bucket_name}'...\"\n  s3_client.create_bucket(bucket: bucket_name)\n  puts \"Successfully created bucket '#{bucket_name}'\"\nrescue => e\n  puts \"Error checking/creating bucket: #{e.message}\"\n  exit 1\nend\n"
  },
  {
    "path": "bin/notify_dash_of_deployment",
    "content": "#!/bin/sh\n\n# Example usage: bin/notify_dash_of_deployment $MESSAGE $KAMAL_VERSION $KAMAL_PERFORMER $CURRENT_BRANCH $KAMAL_DESTINATION $KAMAL_RUNTIME\n\nif [ -n \"$DASH_BASIC_AUTH_SECRET\" ]; then\n  jq -n -c --arg message \"${1}\" \\\n    --arg commit \"${2}\" \\\n    --arg author \"${3}\" \\\n    --arg branch \"${4}\" \\\n    --arg env \"${5}\" \\\n    --arg duration \"${6}\" \\\n    '{\n      \"application\":\"fizzy\",\n      \"rails_env\": $env,\n      \"branch\": $branch,\n      \"deployer\": $author,\n      \"message\": $message,\n      \"rev\": $commit,\n      \"duration\": $duration\n      }' |\n    curl --user \"$DASH_BASIC_AUTH_SECRET\" -H \"Content-Type: application/json\" --data-binary @- \\\n      https://dash.37signals.com/deploy\nelse\n  echo \"WARNING: Dash deploy notification not sent.\"\nfi\n"
  },
  {
    "path": "bin/rails",
    "content": "#!/usr/bin/env ruby\nAPP_PATH = File.expand_path(\"../config/application\", __dir__)\n\nrequire_relative \"../lib/fizzy\"\nFizzy.configure_bundle\n\nrequire_relative \"../config/boot\"\n\nrequire \"rails/commands\"\n\nif Fizzy.saas? && ENV[\"RAILS_ENV\"] == \"test\"\n  # the app is not loaded by rails/commands if there are additional arguments to \"rails test\"\n  require APP_PATH\n  Fizzy::Saas.append_test_paths\nend\n"
  },
  {
    "path": "bin/rake",
    "content": "#!/usr/bin/env ruby\nrequire_relative '../config/boot'\nrequire 'rake'\nRake.application.run\n"
  },
  {
    "path": "bin/rubocop",
    "content": "#!/usr/bin/env ruby\n# frozen_string_literal: true\n\n#\n# This file was generated by Bundler.\n#\n# The application 'rubocop' is installed as part of a gem, and\n# this file is here to facilitate running it.\n#\n\nENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)\n\nbundle_binstub = File.expand_path('bundle', __dir__)\n\nif File.file?(bundle_binstub)\n  if File.read(bundle_binstub, 300).include?('This file was generated by Bundler')\n    load(bundle_binstub)\n  else\n    abort(\"Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.\nReplace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.\")\n  end\nend\n\nrequire 'rubygems'\nrequire 'bundler/setup'\n\nload Gem.bin_path('rubocop', 'rubocop')\n"
  },
  {
    "path": "bin/setup",
    "content": "#!/usr/bin/env bash\nset -eo pipefail\n\n# Prefer app executables\napp_root=\"$(\n  cd \"$(dirname \"$0\")/..\"\n  pwd\n)\"\nexport PATH=\"$app_root/bin:$PATH\"\n\nif [ -e tmp/saas.txt ]; then\n  export SAAS=1\nfi\n\nif [ -n \"$SAAS\" ]; then\n  export BUNDLE_GEMFILE=\"Gemfile.saas\"\nfi\n\n# Install gum if needed\nif ! command -v gum &>/dev/null; then\n  echo\n  echo \"▸ Installing gum\"\n  if command -v pacman &>/dev/null; then\n    sudo pacman -S --noconfirm gum\n  elif command -v brew &>/dev/null; then\n    brew install gum\n  else\n    echo \"Please install gum: https://github.com/charmbracelet/gum\"\n    exit 1\n  fi\n  echo\nfi\n\n# Install mise if needed\nif ! command -v mise &>/dev/null; then\n  echo\n  echo \"▸ Installing mise\"\n  if command -v pacman &>/dev/null; then\n    sudo pacman -S --noconfirm mise\n  elif command -v brew &>/dev/null; then\n    brew install mise\n  else\n    echo \"Please install mise: https://mise.jdx.dev/installing-mise.html#installation-methods\"\n    exit 1\n  fi\n  echo\nfi\n\n# Install gh if needed\nif ! command -v gh &>/dev/null; then\n  echo\n  echo \"▸ Installing GitHub CLI\"\n  if command -v pacman &>/dev/null; then\n    sudo pacman -S --noconfirm github-cli\n  elif command -v brew &>/dev/null; then\n    brew install gh\n  else\n    echo \"Please install GitHub CLI: https://github.com/cli/cli#installation\"\n    exit 1\n  fi\n  echo\nfi\n\nstep() {\n  local step_name=\"$1\"\n  shift\n\n  gum style --foreground 135 --bold \"▸ $step_name\"\n  gum style --foreground 240 \"$*\"\n\n  \"$@\"\n\n  local exit_code=$?\n  echo\n  return $exit_code\n}\n\nneeds_seeding() {\n  if [ \"$CI\" != \"\" ]; then\n    return 1\n  fi\n\n  has_data=$(bin/rails runner \"pp Account.all.any?\" 2>/dev/null)\n  if [ \"$has_data\" = \"true\" ] ; then\n    return 1\n  else\n    return 0\n  fi\n}\n\noss_mysql_setup() {\n  if ! nc -z localhost 3306 2>/dev/null; then\n    if docker ps -aq -f name=fizzy-mysql | grep -q .; then\n      step \"Starting MySQL\" docker start fizzy-mysql\n    else\n      step \"Setting up MySQL\" bash -c '\n        docker pull mysql:8.4\n        docker run -d \\\n          --name fizzy-mysql \\\n          -e MYSQL_ALLOW_EMPTY_PASSWORD=yes \\\n          -p 3306:3306 \\\n          mysql:8.4\n        echo \"MySQL is starting… (it may take a few seconds)\"\n      '\n    fi\n  fi\n}\n\necho\ngum style --foreground 153 \"   ˚ ∘             ∘ ˚   \"\ngum style --foreground 111 --bold \"  ∘˚˳°∘°  𝒻𝒾𝓏𝓏𝓎  °∘°˳˚∘  \"\necho\n\nstep \"Installing Ruby\" mise install --yes\neval \"$(mise hook-env -s bash)\"\n\nif which pacman >/dev/null 2>&1; then\n  packages=(imagemagick mariadb-libs openslide libvips libheif libwebp libjxl libraw poppler-glib libcgif ffmpeg rav1e svt-av1 gitleaks)\n  if ! pacman -Q \"${packages[@]}\" >/dev/null 2>&1; then\n    step \"Installing packages\" sudo pacman -S --noconfirm --needed \"${packages[@]}\"\n  fi\nelif which brew >/dev/null 2>&1; then\n  packages=(imagemagick openslide vips gitleaks)\n  missing_packages=()\n  for pkg in \"${packages[@]}\"; do\n    if ! brew list \"$pkg\" &>/dev/null; then\n      missing_packages+=(\"$pkg\")\n    fi\n  done\n  if [ ${#missing_packages[@]} -gt 0 ]; then\n    step \"Installing packages\" brew install \"${missing_packages[@]}\"\n  fi\nfi\n\nbundle config set --local auto_install true\nstep \"Installing RubyGems\" bundle install\n\nif [ -n \"$SAAS\" ]; then\n  source \"$app_root/saas/bin/setup\"\nelse\n  if [ \"$DATABASE_ADAPTER\" = \"mysql\" ]; then\n    oss_mysql_setup\n  fi\nfi\n\nif [[ $* == *--reset* ]]; then\n  step \"Resetting the database\" rails db:reset\nelse\n  step \"Preparing the database\" rails db:prepare\n\n  if needs_seeding; then\n    step \"Seeding the database\" rails db:seed\n  fi\nfi\n\nstep \"Cleaning up logs and tempfiles\" rails log:clear tmp:clear\n\ngum style --foreground 46 \"✓ Done (${SECONDS} sec)\"\n"
  },
  {
    "path": "bin/thrust",
    "content": "#!/usr/bin/env ruby\n# frozen_string_literal: true\n\n#\n# This file was generated by Bundler.\n#\n# The application 'thrust' is installed as part of a gem, and\n# this file is here to facilitate running it.\n#\n\nENV[\"BUNDLE_GEMFILE\"] ||= File.expand_path(\"../Gemfile\", __dir__)\n\nbundle_binstub = File.expand_path(\"bundle\", __dir__)\n\nif File.file?(bundle_binstub)\n  if File.read(bundle_binstub, 300).include?(\"This file was generated by Bundler\")\n    load(bundle_binstub)\n  else\n    abort(\"Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.\nReplace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.\")\n  end\nend\n\nrequire \"rubygems\"\nrequire \"bundler/setup\"\n\nload Gem.bin_path(\"thruster\", \"thrust\")\n"
  },
  {
    "path": "config/application.rb",
    "content": "require_relative \"boot\"\nrequire \"rails/all\"\nrequire_relative \"../lib/fizzy\"\nrequire_relative \"../lib/action_pack/railtie\"\n\nBundler.require(*Rails.groups)\n\nmodule Fizzy\n  class Application < Rails::Application\n    # Initialize configuration defaults for originally generated Rails version.\n    config.load_defaults 8.1\n\n    # Include the `lib` directory in autoload paths. Use the `ignore:` option\n    # to list subdirectories that don't contain `.rb` files or that shouldn't\n    # be reloaded or eager loaded.\n    config.autoload_lib ignore: %w[ assets tasks rails_ext ]\n\n    # Enable debug mode for Rails event logging so we get SQL query logs.\n    # This was made necessary by the change in https://github.com/rails/rails/pull/55900\n    config.after_initialize do\n      Rails.event.debug_mode = true\n    end\n\n    # Use UUID primary keys for all new tables\n    config.generators do |g|\n      g.orm :active_record, primary_key_type: :uuid\n    end\n\n    config.action_pack.passkey.draw_routes = false\n    config.action_pack.passkey.challenge_url = -> { my_passkey_challenge_path(script_name: \"\") }\n\n    config.mission_control.jobs.http_basic_auth_enabled = false\n  end\nend\n"
  },
  {
    "path": "config/boot.rb",
    "content": "ENV[\"BUNDLE_GEMFILE\"] ||= File.expand_path(\"../Gemfile\", __dir__)\n\nrequire \"bundler/setup\" # Set up gems listed in the Gemfile.\nrequire \"bootsnap/setup\" # Speed up boot time by caching expensive operations.\n"
  },
  {
    "path": "config/brakeman.ignore",
    "content": "{\n  \"ignored_warnings\": [\n    {\n      \"warning_type\": \"SQL Injection\",\n      \"warning_code\": 0,\n      \"fingerprint\": \"4ea2e6d704b817af1d896dcdf148a89da8b9e428b3327497631ef8d9ed587307\",\n      \"check_name\": \"SQL\",\n      \"message\": \"Possible SQL injection\",\n      \"file\": \"app/models/card/entropic.rb\",\n      \"line\": 19,\n      \"link\": \"https://brakemanscanner.org/docs/warning_types/sql_injection/\",\n      \"code\": \"active.joins(:board => :account).left_outer_joins(:board => :entropy).joins(\\\"LEFT OUTER JOIN entropies AS account_entropies ON account_entropies.account_id = accounts.id AND account_entropies.container_type = 'Account' AND account_entropies.container_id = accounts.id\\\").where(\\\"last_active_at > #{connection.date_subtract(\\\"?\\\", \\\"COALESCE(entropies.auto_postpone_period, account_entropies.auto_postpone_period)\\\")}\\\", Time.now)\",\n      \"render_path\": null,\n      \"location\": {\n        \"type\": \"method\",\n        \"class\": \"Card::Entropic\",\n        \"method\": null\n      },\n      \"user_input\": \"connection.date_subtract(\\\"?\\\", \\\"COALESCE(entropies.auto_postpone_period, account_entropies.auto_postpone_period)\\\")\",\n      \"confidence\": \"Weak\",\n      \"cwe_id\": [\n        89\n      ],\n      \"note\": \"No user input allowed\"\n    },\n    {\n      \"warning_type\": \"Dangerous Send\",\n      \"warning_code\": 23,\n      \"fingerprint\": \"746ab8227e04231f0002e099e2f670bcc2b8927af85cdc69fab1440002d5c2a6\",\n      \"check_name\": \"Send\",\n      \"message\": \"User controlled method execution\",\n      \"file\": \"app/controllers/events/day_timeline/columns_controller.rb\",\n      \"line\": 19,\n      \"link\": \"https://brakemanscanner.org/docs/warning_types/dangerous_send/\",\n      \"code\": \"Current.user.timeline_for(day, :filter => ((Current.user.filters.find(params[:filter_id]) or Current.user.filters.from_params(filter_params)))).public_send(\\\"#{params[:id]}_column\\\")\",\n      \"render_path\": null,\n      \"location\": {\n        \"type\": \"method\",\n        \"class\": \"Events::DayTimeline::ColumnsController\",\n        \"method\": \"set_column\"\n      },\n      \"user_input\": \"params[:id]\",\n      \"confidence\": \"High\",\n      \"cwe_id\": [\n        77\n      ],\n      \"note\": \"\"\n    },\n    {\n      \"warning_type\": \"SSL Verification Bypass\",\n      \"warning_code\": 71,\n      \"fingerprint\": \"8e566e1a52e48940c840541ea4e33f41a39dc0f2a97eb83c52e76f9582667a3c\",\n      \"check_name\": \"SSLVerify\",\n      \"message\": \"SSL certificate verification was bypassed\",\n      \"file\": \"app/models/zip_file/remote_io.rb\",\n      \"line\": 41,\n      \"link\": \"https://brakemanscanner.org/docs/warning_types/ssl_verification_bypass/\",\n      \"code\": \"Net::HTTP.new(@uri.hostname, @uri.port).verify_mode = OpenSSL::SSL::VERIFY_NONE\",\n      \"render_path\": null,\n      \"location\": {\n        \"type\": \"method\",\n        \"class\": \"ZipFile::RemoteIO\",\n        \"method\": \"with_http\"\n      },\n      \"user_input\": null,\n      \"confidence\": \"High\",\n      \"cwe_id\": [\n        295\n      ],\n      \"note\": \"Required for internal PureStorage self-signed certificates. Only used for trusted internal storage URLs.\"\n    },\n    {\n      \"warning_type\": \"Mass Assignment\",\n      \"warning_code\": 70,\n      \"fingerprint\": \"ac0fa1970dea215e2f47a5676ffd63d8728951e361da12a53dd424bf6dc46f2a\",\n      \"check_name\": \"MassAssignment\",\n      \"message\": \"Specify exact keys allowed for mass assignment instead of using `permit!` which allows any keys\",\n      \"file\": \"app/helpers/pagination_helper.rb\",\n      \"line\": 14,\n      \"link\": \"https://brakemanscanner.org/docs/warning_types/mass_assignment/\",\n      \"code\": \"params.permit!\",\n      \"render_path\": null,\n      \"location\": {\n        \"type\": \"method\",\n        \"class\": \"PaginationHelper\",\n        \"method\": \"pagination_link\"\n      },\n      \"user_input\": null,\n      \"confidence\": \"Medium\",\n      \"cwe_id\": [\n        915\n      ],\n      \"note\": \"\"\n    },\n    {\n      \"warning_type\": \"Remote Code Execution\",\n      \"warning_code\": 24,\n      \"fingerprint\": \"f13f9f972c3f026ab4509a66ac284b8b7c1ba6191a3c4c89d2e9fb4584478f6d\",\n      \"check_name\": \"UnsafeReflection\",\n      \"message\": \"Unsafe reflection method `safe_constantize` called on model attribute\",\n      \"file\": \"app/models/notifier.rb\",\n      \"line\": 8,\n      \"link\": \"https://brakemanscanner.org/docs/warning_types/remote_code_execution/\",\n      \"code\": \"\\\"Notifier::#{Event.eventable.class}EventNotifier\\\".safe_constantize\",\n      \"render_path\": null,\n      \"location\": {\n        \"type\": \"method\",\n        \"class\": \"Notifier\",\n        \"method\": \"s(:self).for\"\n      },\n      \"user_input\": \"Event.eventable.class\",\n      \"confidence\": \"Medium\",\n      \"cwe_id\": [\n        470\n      ],\n      \"note\": \"\"\n    },\n    {\n      \"warning_type\": \"SQL Injection\",\n      \"warning_code\": 0,\n      \"fingerprint\": \"bbd8fe3cbbc6b393df770d3142d6fa46e2b9441b21f48a52175211acaae4efad\",\n      \"check_name\": \"SQL\",\n      \"message\": \"Possible SQL injection\",\n      \"file\": \"app/models/card/entropic.rb\",\n      \"line\": 10,\n      \"link\": \"https://brakemanscanner.org/docs/warning_types/sql_injection/\",\n      \"code\": \"active.joins(:board => :account).left_outer_joins(:board => :entropy).joins(\\\"LEFT OUTER JOIN entropies AS account_entropies ON account_entropies.account_id = accounts.id AND account_entropies.container_type = 'Account' AND account_entropies.container_id = accounts.id\\\").where(\\\"last_active_at <= #{connection.date_subtract(\\\"?\\\", \\\"COALESCE(entropies.auto_postpone_period, account_entropies.auto_postpone_period)\\\")}\\\", Time.now)\",\n      \"render_path\": null,\n      \"location\": {\n        \"type\": \"method\",\n        \"class\": \"Card::Entropic\",\n        \"method\": null\n      },\n      \"user_input\": \"connection.date_subtract(\\\"?\\\", \\\"COALESCE(entropies.auto_postpone_period, account_entropies.auto_postpone_period)\\\")\",\n      \"confidence\": \"Weak\",\n      \"cwe_id\": [\n        89\n      ],\n      \"note\": \"No user input allowed\"\n    }\n  ],\n  \"brakeman_version\": \"8.0.1\"\n}\n"
  },
  {
    "path": "config/cable.yml",
    "content": "cable: &cable\n  adapter: solid_cable\n  connects_to:\n    database:\n      writing: cable\n      reading: cable\n  polling_interval: 0.1.seconds\n  message_retention: 1.day\n\ndevelopment: *cable\ntest:\n  adapter: test\nbeta: *cable\nstaging: *cable\nproduction: *cable\n"
  },
  {
    "path": "config/cache.yml",
    "content": "default_options: &default_options\n  store_options:\n    max_age: <%= 60.days.to_i %>\n    namespace: <%= \"#{Rails.env}#{\"-#{ENV[\"CACHE_NAMESPACE\"]}\" if ENV[\"CACHE_NAMESPACE\"]}\" %>\n\ndefault_connection: &default_connection\n  database: cache\n\ndefault: &default\n  <<: *default_connection\n  <<: *default_options\n\ndevelopment: *default\ntest: *default_options\nbeta: *default\nstaging: *default\nproduction: *default\n"
  },
  {
    "path": "config/ci.rb",
    "content": "# Run using bin/ci\n\nrequire_relative \"../lib/fizzy\"\n\nOSS_ENV = \"SAAS=false BUNDLE_GEMFILE=Gemfile\"\nSAAS_ENV = \"SAAS=true BUNDLE_GEMFILE=Gemfile.saas\"\nSYSTEM_TEST_ENV = \"PARALLEL_WORKERS=1\" # system tests can't run reliably in parallel\n\nCI.run do\n  step \"Setup\", \"bin/setup --skip-server\"\n\n  step \"Style: Ruby\", \"bin/rubocop\"\n\n  step \"Gemfile: Drift check\", \"bin/bundle-drift check\"\n  step \"Security: Gem audit\", \"bin/bundler-audit check --update\"\n  step \"Security: Importmap audit\", \"bin/importmap audit\"\n  step \"Security: Brakeman audit\", \"bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error\"\n  step \"Security: Gitleaks audit\", \"bin/gitleaks-audit\"\n\n  if Fizzy.saas?\n    step \"Tests: SaaS\",          \"#{SAAS_ENV} bin/rails test\"\n    step \"Tests: SaaS System\",   \"#{SAAS_ENV} #{SYSTEM_TEST_ENV} bin/rails test:system\"\n    step \"Tests: OSS\",           \"#{OSS_ENV} bin/rails test\"\n    step \"Tests: OSS System\",    \"#{OSS_ENV} #{SYSTEM_TEST_ENV} bin/rails test:system\"\n  else\n    step \"Tests: SQLite\",        \"#{OSS_ENV} bin/rails test\"\n    step \"Tests: SQLite System\", \"#{OSS_ENV} #{SYSTEM_TEST_ENV} bin/rails test:system\"\n  end\n\n  if success?\n    step \"Signoff: All systems go. Ready for merge and deploy.\", \"gh signoff\"\n  else\n    failure \"Signoff: CI failed. Do not merge or deploy.\", \"Fix the issues and try again.\"\n  end\nend\n"
  },
  {
    "path": "config/database.mysql.yml",
    "content": "default: &default\n  adapter: trilogy\n  host: <%= ENV.fetch(\"MYSQL_HOST\", \"127.0.0.1\") %>\n  port: <%= ENV.fetch(\"MYSQL_PORT\", \"3306\") %>\n  username: <%= ENV.fetch(\"MYSQL_USER\", \"root\") %>\n  password: <%= ENV[\"MYSQL_PASSWORD\"] %>\n  pool: 50\n  ssl_mode: <%= ENV[\"MYSQL_SSL_MODE\"] %>\n  timeout: 5000\n\ndevelopment:\n  primary:\n    <<: *default\n    database: fizzy_development\n  cable:\n    <<: *default\n    database: fizzy_development_cable\n    migrations_paths: db/cable_migrate\n\ntest:\n  primary:\n    <<: *default\n    database: fizzy_test\n  cable:\n    <<: *default\n    database: fizzy_test_cable\n    migrations_paths: db/cable_migrate\n\nproduction:\n  primary:\n    <<: *default\n    database: fizzy_production\n  cable:\n    <<: *default\n    database: fizzy_production_cable\n    migrations_paths: db/cable_migrate\n  queue:\n    <<: *default\n    database: fizzy_production_queue\n    migrations_paths: db/queue_migrate\n  cache:\n    <<: *default\n    database: fizzy_production_cache\n    migrations_paths: db/cache_migrate\n"
  },
  {
    "path": "config/database.sqlite.yml",
    "content": "default: &default\n  adapter: sqlite3\n  pool: 5\n  timeout: 5000\n\ndevelopment:\n  primary:\n    <<: *default\n    database: storage/development.sqlite3\n    schema_dump: schema_sqlite.rb\n  cable:\n    <<: *default\n    database: storage/development_cable.sqlite3\n    migrations_paths: db/cable_migrate\n\ntest:\n  primary:\n    <<: *default\n    database: storage/test.sqlite3\n    schema_dump: schema_sqlite.rb\n  cable:\n    <<: *default\n    database: storage/test_cable.sqlite3\n    migrations_paths: db/cable_migrate\n\nproduction:\n  primary:\n    <<: *default\n    database: storage/production.sqlite3\n    schema_dump: schema_sqlite.rb\n  cable:\n    <<: *default\n    database: storage/production_cable.sqlite3\n    migrations_paths: db/cable_migrate\n  cache:\n    <<: *default\n    database: storage/production_cache.sqlite3\n    migrations_paths: db/cache_migrate\n  queue:\n    <<: *default\n    database: storage/production_queue.sqlite3\n    migrations_paths: db/queue_migrate\n"
  },
  {
    "path": "config/database.yml",
    "content": "<%\n  config_path = if Fizzy.saas?\n    gem_path = Rails.root.join(\"saas\").to_s\n    File.join(gem_path, \"config\", \"database.yml\")\n  else\n    File.join(\"config\", \"database.#{Fizzy.db_adapter}.yml\")\n  end\n%>\n<%= ERB.new(File.read(config_path)).result %>\n"
  },
  {
    "path": "config/deploy.yml",
    "content": "# Name of this app\nservice: fizzy\nimage: fizzy\n\n\n#-- About your deployment --#\n\n# Where to deploy fizzy\nservers:\n  web:\n    - fizzy.example.com  # Set your server name here\n\n# How you connect to your server\nssh:\n  user: root  # If you use a different username to SSH to your server, specify it here\n\n# Automatic SSL\nproxy:\n  ssl: true                # Set this to false if you *don't* want SSL\n  host: fizzy.example.com  # Set your server name here to use automatic SSL\n\n# Your application configuration (secrets come from .kamal/secrets).\nenv:\n  secret:\n    - SECRET_KEY_BASE\n    - VAPID_PUBLIC_KEY\n    - VAPID_PRIVATE_KEY\n    - SMTP_USERNAME\n    - SMTP_PASSWORD\n  clear:\n    BASE_URL: https://fizzy.example.com # The public URL of your Fizzy instance\n    MAILER_FROM_ADDRESS: support@example.com # The email \"from\" address that Fizzy sends email from\n    SMTP_ADDRESS: mail.example.com # The SMTP server you'll use to send email\n    MULTI_TENANT: false # Set to true to allow multiple accounts to sign up\n    SOLID_QUEUE_IN_PUMA: true # Run background jobs in the app container\n\n\n#-- General configuration --#\n\n# Use a local registry to deploy\nregistry:\n  server: localhost:5555\n\n# Handy aliases for interacting with your deployment. For eaxmple: `bin/kamal console` will connect to a\n# Rails console in production.\naliases:\n  console: app exec --interactive --reuse \"bin/rails console\"\n  shell: app exec --interactive --reuse \"bash\"\n  logs: app logs -f\n  dbc: app exec --interactive --reuse \"bin/rails dbconsole --include-password\"\n\n# Use a persistent storage volume for sqlite database files and local Active Storage files.\n# Recommended to change this to a mounted volume path that is backed up off server.\nvolumes:\n  - \"fizzy_storage:/rails/storage\"\n\n# Bridge fingerprinted assets, like JS and CSS, between versions to avoid\n# hitting 404 on in-flight requests. Combines all files from new and old\n# version inside the asset_path.\nasset_path: /rails/public/assets\n\n# Configure the image builder.\nbuilder:\n  arch: amd64\n"
  },
  {
    "path": "config/environment.rb",
    "content": "# Load the Rails application.\nrequire_relative \"application\"\n\n# Initialize the Rails application.\nRails.application.initialize!\n"
  },
  {
    "path": "config/environments/beta.rb",
    "content": "require_relative \"production\"\n"
  },
  {
    "path": "config/environments/development.rb",
    "content": "require \"active_support/core_ext/integer/time\"\n\nRails.application.configure do\n  # Settings specified here will take precedence over those in config/application.rb.\n\n  # In the development environment your application's code is reloaded any time\n  # it changes. This slows down response time but is perfect for development\n  # since you don't have to restart the web server when you make code changes.\n  config.enable_reloading = true\n\n  # Do not eager load code on boot.\n  config.eager_load = false\n\n  # Show full error reports.\n  config.consider_all_requests_local = true\n\n  # Enable server timing\n  config.server_timing = true\n\n  # Enable/disable caching. By default caching is disabled.\n  # Run rails dev:cache to toggle caching.\n  if Rails.root.join(\"tmp/caching-dev.txt\").exist?\n    config.action_controller.perform_caching = true\n    config.action_controller.enable_fragment_cache_logging = true\n\n    config.cache_store = :memory_store\n    config.public_file_server.headers = { \"Cache-Control\" => \"public, max-age=#{2.days.to_i}\" }\n  else\n    config.action_controller.perform_caching = false\n\n    config.cache_store = :null_store\n  end\n\n  # Store uploaded files on the local file system (see config/storage.yml for options).\n  if Rails.root.join(\"tmp/minio-dev.txt\").exist?\n    config.active_storage.service = :devminio\n    config.x.content_security_policy.connect_src = \"http://minio.localhost:39000\"\n    config.x.content_security_policy.img_src = \"http://minio.localhost:39000\"\n  else\n    config.active_storage.service = :local\n  end\n\n  # Don't care if the mailer can't send.\n  config.action_mailer.raise_delivery_errors = false\n\n  config.action_mailer.perform_caching = false\n\n  # Print deprecation notices to the Rails logger.\n  config.active_support.deprecation = :log\n\n  # Raise exceptions for disallowed deprecations.\n  config.active_support.disallowed_deprecation = :raise\n\n  # Tell Active Support which deprecation messages to disallow.\n  config.active_support.disallowed_deprecation_warnings = []\n\n  # Raise an error on page load if there are pending migrations.\n  config.active_record.migration_error = :page_load\n\n  # Highlight code that triggered database queries in logs.\n  config.active_record.verbose_query_logs = true\n\n  # Highlight code that enqueued background job in logs.\n  config.active_job.verbose_enqueue_logs = true\n\n  # Raises error for missing translations.\n  # config.i18n.raise_on_missing_translations = true\n\n  # Annotate rendered view with file names.\n  config.action_view.annotate_rendered_view_with_filenames = true\n\n  # Uncomment if you wish to allow Action Cable access from any origin.\n  # config.action_cable.disable_request_forgery_protection = true\n\n  # Raise error when a before_action's only/except options reference missing actions\n  config.action_controller.raise_on_missing_callback_actions = true\n\n  # Prepend all log lines with the following tags.\n  config.log_tags = [ :request_id ]\n\n  if Rails.root.join(\"tmp/email-dev.txt\").exist?\n    config.action_mailer.delivery_method = :letter_opener\n    config.action_mailer.perform_deliveries = true\n  else\n    config.action_mailer.raise_delivery_errors = false\n  end\n\n  config.hosts = [\n    \"fizzy.localhost\",\n    \"localhost\",\n    \"127.0.0.1\",\n    /fizzy-\\d+/,   # review apps: fizzy-123, fizzy-456:3000\n    /.*\\.ts\\.net/, # tailscale serve: hostname.tail1234.ts.net\n    /.*\\.nip\\.io/  # nip.io for mobile apps\n  ]\n\n  # Canonical host for mailer URLs (emails always link here, not personal Tailscale URLs)\n  config.action_mailer.default_url_options = { host: \"#{config.hosts.first}:3006\" }\nend\n"
  },
  {
    "path": "config/environments/production.rb",
    "content": "require \"active_support/core_ext/integer/time\"\n\nRails.application.configure do\n  # Settings specified here will take precedence over those in config/application.rb.\n\n  # Email provider Settings\n  #\n  # SMTP setting can be configured via environment variables.\n  # For other configuration options, consult the Action Mailer documentation.\n  if smtp_address = ENV[\"SMTP_ADDRESS\"].presence\n    config.action_mailer.delivery_method = :smtp\n    config.action_mailer.smtp_settings = {\n      address: smtp_address,\n      port: ENV.fetch(\"SMTP_PORT\", ENV[\"SMTP_TLS\"] == \"true\" ? \"465\" : \"587\").to_i,\n      domain: ENV.fetch(\"SMTP_DOMAIN\", nil),\n      user_name: ENV.fetch(\"SMTP_USERNAME\", nil),\n      password: ENV.fetch(\"SMTP_PASSWORD\", nil),\n      authentication: ENV.fetch(\"SMTP_AUTHENTICATION\", \"plain\"),\n      tls: ENV[\"SMTP_TLS\"] == \"true\",\n      openssl_verify_mode: ENV[\"SMTP_SSL_VERIFY_MODE\"]\n    }\n  end\n\n  # Base URL for links in emails and other external references.\n  # Set BASE_URL to your instance's public URL (e.g., https://fizzy.example.com)\n  if base_url = ENV[\"BASE_URL\"].presence\n    uri = URI.parse(base_url)\n    url_options = { host: uri.host, protocol: uri.scheme }\n    url_options[:port] = uri.port if uri.port != uri.default_port\n\n    routes.default_url_options = url_options\n    config.action_mailer.default_url_options = url_options\n  end\n\n  # Code is not reloaded between requests.\n  config.enable_reloading = false\n\n  # Eager load code on boot. This eager loads most of Rails and\n  # your application in memory, allowing both threaded web servers\n  # and those relying on copy on write to perform better.\n  # Rake tasks automatically ignore this option for performance.\n  config.eager_load = true\n\n  # Full error reports are disabled and caching is turned on.\n  config.consider_all_requests_local = false\n  config.action_controller.perform_caching = true\n\n  # Ensures that a master key has been made available in ENV[\"RAILS_MASTER_KEY\"], config/master.key, or an environment\n  # key such as config/credentials/production.key. This key is used to decrypt credentials (and other encrypted files).\n  # config.require_master_key = true\n\n  config.public_file_server.enabled = true\n  config.public_file_server.headers = {\n    \"Cache-Control\" => \"public, max-age=#{5.minutes.to_i}\"\n  }\n\n  # Select Active Storage service via env var; default to local disk.\n  # Don't overwrite if it's already been set (e.g. by fizzy-saas)\n  if config.active_storage.service.blank?\n    config.active_storage.service = ENV.fetch(\"ACTIVE_STORAGE_SERVICE\", \"local\").to_sym\n  end\n\n  # Enable serving of images, stylesheets, and JavaScripts from an asset server.\n  # config.asset_host = \"http://assets.example.com\"\n\n  # Specifies the header that your server uses for sending files.\n  # config.action_dispatch.x_sendfile_header = \"X-Sendfile\" # for Apache\n  # config.action_dispatch.x_sendfile_header = \"X-Accel-Redirect\" # for NGINX\n\n  # Mount Action Cable outside main process or domain.\n  # config.action_cable.mount_path = nil\n  # config.action_cable.url = \"wss://example.com/cable\"\n  # config.action_cable.allowed_request_origins = [ \"http://example.com\", /http:\\/\\/example.*/ ]\n\n  # Set DISABLE_SSL=true to disable all SSL options, rather than specify each individually\n  ssl_enabled = \"true\" unless ENV[\"DISABLE_SSL\"] == \"true\"\n\n  # Assume all access to the app is happening through a SSL-terminating reverse proxy.\n  # Can be used together with config.force_ssl for Strict-Transport-Security and secure cookies.\n  config.assume_ssl = ENV.fetch(\"ASSUME_SSL\", ssl_enabled) == \"true\"\n\n  # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.\n  config.force_ssl = ENV.fetch(\"FORCE_SSL\", ssl_enabled) == \"true\"\n\n  # Log to STDOUT by default\n  config.logger = ActiveSupport::Logger.new(STDOUT)\n                                       .tap  { |logger| logger.formatter = ::Logger::Formatter.new }\n                                       .then { |logger| ActiveSupport::TaggedLogging.new(logger) }\n\n  # Prepend all log lines with the following tags.\n  config.log_tags = [ :request_id ]\n\n  # \"info\" includes generic and useful information about system operation, but avoids logging too much\n  # information to avoid inadvertent exposure of personally identifiable information (PII). If you\n  # want to log everything, set the level to \"debug\".\n  config.log_level = ENV.fetch(\"RAILS_LOG_LEVEL\", \"info\")\n\n  # Use a different cache store in production.\n  config.cache_store = :solid_cache_store\n\n  # Use a real queuing backend for Active Job (and separate queues per environment).\n  config.active_job.queue_adapter = :solid_queue\n  config.solid_queue.connects_to = { database: { writing: :queue, reading: :queue } }\n  # config.active_job.queue_name_prefix = \"fizzy_production\"\n\n  config.action_mailer.perform_caching = false\n\n  # Ignore bad email addresses and do not raise email delivery errors.\n  # Set this to true and configure the email server for immediate delivery to raise delivery errors.\n  # config.action_mailer.raise_delivery_errors = false\n\n  # Enable locale fallbacks for I18n (makes lookups for any locale fall back to\n  # the I18n.default_locale when a translation cannot be found).\n  config.i18n.fallbacks = true\n\n  # Don't log any deprecations.\n  config.active_support.report_deprecations = false\n\n  # Do not dump schema after migrations.\n  config.active_record.dump_schema_after_migration = false\n\n  # Skip DNS rebinding protection for the default health check endpoint.\n  # config.host_authorization = { exclude: ->(request) { request.path == \"/up\" } }\nend\n"
  },
  {
    "path": "config/environments/staging.rb",
    "content": "require_relative \"production\"\n"
  },
  {
    "path": "config/environments/test.rb",
    "content": "require \"active_support/core_ext/integer/time\"\n\n# The test environment is used exclusively to run your application's\n# test suite. You never need to work with it otherwise. Remember that\n# your test database is \"scratch space\" for the test suite and is wiped\n# and recreated between test runs. Don't rely on the data there!\n\nRails.application.configure do\n  # Settings specified here will take precedence over those in config/application.rb.\n\n  # While tests run files are not watched, reloading is not necessary.\n  config.enable_reloading = false\n\n  # Eager loading loads your entire application. When running a single test locally,\n  # this is usually not necessary, and can slow down your test suite. However, it's\n  # recommended that you enable it in continuous integration systems to ensure eager\n  # loading is working properly before deploying your code.\n  config.eager_load = ENV[\"CI\"].present?\n\n  # Configure public file server for tests with Cache-Control for performance.\n  config.public_file_server.enabled = true\n  config.public_file_server.headers = {\n    \"Cache-Control\" => \"public, max-age=#{1.hour.to_i}\"\n  }\n\n  # Show full error reports and disable caching.\n  config.consider_all_requests_local = true\n  config.action_controller.perform_caching = false\n  config.cache_store = :null_store\n\n  # Render exception templates for rescuable exceptions and raise for other exceptions.\n  config.action_dispatch.show_exceptions = :rescuable\n\n  # Disable request forgery protection in test environment.\n  config.action_controller.allow_forgery_protection = false\n\n  # Store uploaded files on the local file system in a temporary directory.\n  config.active_storage.service = :test\n\n  config.action_mailer.perform_caching = false\n\n  # Tell Action Mailer not to deliver emails to the real world.\n  # The :test delivery method accumulates sent emails in the\n  # ActionMailer::Base.deliveries array.\n  config.action_mailer.delivery_method = :test\n\n  # Set host to be used by links generated in mailer templates.\n  config.action_mailer.default_url_options = { host: \"example.com\" }\n\n  # Print deprecation notices to the stderr.\n  config.active_support.deprecation = :stderr\n\n  # Raise exceptions for disallowed deprecations.\n  config.active_support.disallowed_deprecation = :raise\n\n  # Tell Active Support which deprecation messages to disallow.\n  config.active_support.disallowed_deprecation_warnings = []\n\n  # Raises error for missing translations.\n  # config.i18n.raise_on_missing_translations = true\n\n  # Annotate rendered view with file names.\n  # config.action_view.annotate_rendered_view_with_filenames = true\n\n  # Raise error when a before_action's only/except options reference missing actions\n  config.action_controller.raise_on_missing_callback_actions = true\n\n  # Load test helpers\n  config.autoload_paths += %w[ test/test_helpers ]\n\n  # Enable multi-tenant mode for tests\n  config.x.multi_tenant.enabled = true\nend\n"
  },
  {
    "path": "config/importmap.rb",
    "content": "# Pin npm packages by running ./bin/importmap\n\npin \"application\"\npin \"@hotwired/turbo-rails\", to: \"turbo.min.js\"\npin \"@hotwired/turbo/offline\", to: \"turbo-offline.min.js\"\npin \"@hotwired/stimulus\", to: \"stimulus.min.js\"\npin \"@hotwired/stimulus-loading\", to: \"stimulus-loading.js\"\npin \"@hotwired/hotwire-native-bridge\", to: \"@hotwired--hotwire-native-bridge.js\"\npin \"@rails/request.js\", to: \"@rails--request.js\" # @0.0.13\n\npin_all_from \"app/javascript/controllers\", under: \"controllers\"\npin_all_from \"app/javascript/helpers\", under: \"helpers\"\npin_all_from \"app/javascript/lib\", under: \"lib\"\npin_all_from \"app/javascript/initializers\", under: \"initializers\"\npin_all_from \"app/javascript/bridge/initializers\", under: \"bridge/initializers\"\npin_all_from \"app/javascript/bridge/helpers\", under: \"bridge/helpers\"\npin_all_from \"app/javascript/bridge/controllers/bridge\", under: \"controllers/bridge\", to: \"bridge/controllers/bridge\"\npin \"lexxy\"\npin \"@rails/activestorage\", to: \"activestorage.esm.js\"\npin \"@rails/actiontext\", to: \"actiontext.esm.js\"\n"
  },
  {
    "path": "config/initializers/action_text.rb",
    "content": "module ActionText\n  module Extensions\n    module RichText\n      extend ActiveSupport::Concern\n\n      included do\n        # This overrides the default :embeds association!\n        has_many_attached :embeds do |attachable|\n          ::Attachments::VARIANTS.each do |variant_name, variant_options|\n            attachable.variant variant_name, **variant_options, process: :immediately\n          end\n        end\n      end\n\n      # Delegate storage tracking to the parent record (Card, Comment, Board, etc.)\n      def storage_tracked_record\n        record.try(:storage_tracked_record)\n      end\n\n      def accessible_to?(user)\n        record.try(:accessible_to?, user) || record.try(:publicly_accessible?)\n      end\n\n      def publicly_accessible?\n        record.try(:publicly_accessible?)\n      end\n    end\n  end\nend\n\nActiveSupport.on_load(:action_text_rich_text) do\n  include ActionText::Extensions::RichText\nend\n"
  },
  {
    "path": "config/initializers/active_job.rb",
    "content": "# frozen_string_literal: true\n\n# inspired from code in ActiveRecord::Tenanted\nmodule FizzyActiveJobExtensions\n  extend ActiveSupport::Concern\n\n  prepended do\n    attr_reader :account\n    self.enqueue_after_transaction_commit = true\n  end\n\n  def initialize(...)\n    super\n    @account = Current.account\n  end\n\n  def serialize\n    super.merge({ \"account\" => @account&.to_gid })\n  end\n\n  def deserialize(job_data)\n    super\n    if _account = job_data.fetch(\"account\", nil)\n      @account = GlobalID::Locator.locate(_account)\n    end\n  end\n\n  def perform_now\n    if account.present?\n      Current.with_account(account) { super }\n    else\n      super\n    end\n  end\nend\n\nActiveSupport.on_load(:active_job) do\n  prepend FizzyActiveJobExtensions\nend\n"
  },
  {
    "path": "config/initializers/active_storage.rb",
    "content": "ActiveSupport.on_load(:active_storage_attachment) do\n  include Storage::AttachmentTracking\nend\n\nActiveSupport.on_load(:active_storage_blob) do\n  ActiveStorage::DiskController.after_action only: :show do\n    expires_in 5.minutes, public: true\n  end\nend\n\nActiveSupport.on_load(:action_text_content) do\n  # Install our extensions after ActionText::Engine's\n  ActiveSupport.on_load(:active_storage_blob) do\n    # Ensure all <action-text-attachment>s have a \"url\" attribute that's a relative\n    # path (for portability across host name changes, beta environments, etc).\n    def to_rich_text_attributes(*)\n      super.merge url: Rails.application.routes.url_helpers.polymorphic_url(self, only_path: true)\n    end\n  end\nend\n\n# Don't configure replica connections for ActiveStorage::Record.\n# When ActiveStorage uses `connects_to`, it creates a separate connection pool\n# from ApplicationRecord. This causes after_commit callbacks to fire in\n# non-deterministic order - the Attachment's create_variants callback can fire\n# before the User model's upload callback, causing FileNotFoundError when\n# using `process: :immediately` for variants.\n# See: https://github.com/rails/rails/issues/53694\nActiveSupport.on_load(:active_storage_record) do\n  configure_replica_connections\nend\n\nmodule ActiveStorageControllerExtensions\n  extend ActiveSupport::Concern\n\n  included do\n    before_action do\n      # Add script_name so that Disk Service will generate correct URLs for uploads\n      ActiveStorage::Current.url_options = {\n        protocol: request.protocol,\n        host: request.host,\n        port: request.port,\n        script_name: request.script_name\n      }\n    end\n  end\nend\n\nmodule ActiveStorageDirectUploadsControllerExtensions\n  extend ActiveSupport::Concern\n\n  included do\n    include Authentication\n    include Authorization\n    skip_forgery_protection if: :authenticate_by_bearer_token\n  end\nend\n\nRails.application.config.to_prepare do\n  ActiveStorage::BaseController.include ActiveStorageControllerExtensions\n  ActiveStorage::DirectUploadsController.include ActiveStorageDirectUploadsControllerExtensions\nend\n"
  },
  {
    "path": "config/initializers/active_storage_no_reuse.rb",
    "content": "# Enforce storage ledger integrity by preventing blob reuse in tracked contexts.\n#\n# Two invariants:\n# 1. Account match: blob.account_id == record.account_id (multi-tenant safety)\n# 2. No reuse within tracked contexts: a blob can only have one tracked attachment\n#\n# With per-attachment reconcile, blob reuse inside an account wouldn't break correctness -\n# ledger would still count each attach, and reconcile would agree. However, we intentionally\n# forbid reuse (except templates) as a product/control decision:\n# - Simpler mental model (one blob = one attachment)\n# - Prevents accidental quota manipulation via direct blob_id reuse\n# - Cleaner audit trail in ledger entries\n#\n# Scope note: The no-reuse validation only blocks reuse when the *new* attachment is tracked\n# AND only checks *existing* attachments in Storage::TRACKED_RECORD_TYPES. A blob first\n# attached to an untracked type (avatar/export) could theoretically be reused in a tracked\n# context. This is acceptable - user-accessible blob IDs from untracked contexts are\n# basically nonexistent in practice.\n#\n# Exception: ActionText embeds are allowed to reuse blobs to support copy/paste.\n\nActiveSupport.on_load(:active_storage_attachment) do\n  validate :blob_account_matches_record, on: :create\n  validate :no_tracked_blob_reuse, on: :create\n\n  private\n    # Multi-tenant safety: blob must belong to same account as record\n    # NOTE: Skips validation if record.account is nil. This is a theoretical bypass\n    # if someone attaches before account assignment, but our flows assign account\n    # before attachment. Global/unaccounted attachments (Identity/User avatars, exports)\n    # bypass tenancy checks via try(:account) returning nil - this is intentional as\n    # these classes don't participate in storage tracking.\n    def blob_account_matches_record\n      if record&.try(:account).present? && !whitelisted_for_cross_account?\n        unless blob&.account_id == record.account.id\n          errors.add(:blob_id, \"blob account must match record account\")\n        end\n      end\n    end\n\n    # Ledger integrity: blob can only have one tracked attachment\n    def no_tracked_blob_reuse\n      tracked_record = record&.try(:storage_tracked_record)\n      if tracked_record.present? &&\n          !whitelisted_for_cross_account? &&\n          !(record_type == \"ActionText::RichText\" && name == \"embeds\")\n\n        # Check for existing attachment of this blob in tracked contexts\n        # Uses Storage::TRACKED_RECORD_TYPES constant to stay generic\n        existing = ActiveStorage::Attachment\n          .where(blob_id: blob_id)\n          .where(record_type: Storage::TRACKED_RECORD_TYPES)\n          .where.not(id: id)\n          .exists?\n\n        if existing\n          errors.add(:blob_id, \"cannot reuse blob in tracked storage context\")\n        end\n      end\n    end\n\n    def whitelisted_for_cross_account?\n      # Only template account blobs can be reused cross-tenant.\n      # When TEMPLATE_ACCOUNT_ID is nil, no exemptions are granted.\n      Storage::TEMPLATE_ACCOUNT_ID.present? && blob&.account_id == Storage::TEMPLATE_ACCOUNT_ID\n    end\nend\n"
  },
  {
    "path": "config/initializers/active_storage_purge_on_last_attachment.rb",
    "content": "# Fizzy-specific override: ActiveStorage's default purge path uses `delete`,\n# which skips attachment callbacks. We need `destroy` so storage ledger detaches\n# are recorded and reused blobs (ActionText embeds) aren't purged until the\n# last attachment is gone. Keep this local to Fizzy; it's not a Rails default.\nmodule ActiveStorage\n  module PurgeOnLastAttachment\n    def purge\n      @purge_mode = :purge\n      destroy\n      purge_blob_if_last(:purge) if destroyed?\n    ensure\n      @purge_mode = nil\n    end\n\n    def purge_later\n      @purge_mode = :purge_later\n      destroy\n      purge_blob_if_last(:purge_later) if destroyed?\n    ensure\n      @purge_mode = nil\n    end\n\n    private\n      def purge_dependent_blob_later\n        if (record.nil? || dependent == :purge_later) && !@purge_mode\n          purge_blob_if_last(:purge_later)\n        end\n      end\n\n      def purge_blob_if_last(mode)\n        if blob && !blob.attachments.exists?\n          mode == :purge ? blob.purge : blob.purge_later\n        end\n      end\n  end\nend\n\nActiveSupport.on_load(:active_storage_attachment) do\n  prepend ActiveStorage::PurgeOnLastAttachment\nend\n"
  },
  {
    "path": "config/initializers/assets.rb",
    "content": "# Be sure to restart your server when you modify this file.\n\n# Version of your assets, change this if you want to expire all your assets.\nRails.application.config.assets.version = \"1.0\"\n\n# Add additional assets to the asset load path.\n# Rails.application.config.assets.paths << Emoji.images_path\n"
  },
  {
    "path": "config/initializers/autotuner.rb",
    "content": "# Enable autotuner. Alternatively, call Autotuner.sample_ratio= with a value\n# between 0 and 1.0 to sample on a portion of instances.\nAutotuner.enabled = true\n\n# This callback is called whenever a suggestion is provided by this gem.\n# Log to structured logging for query/analysis in Loki. This is called\n# once per autotuner heuristic.\nAutotuner.reporter = proc do |heuristic_report|\n  report = heuristic_report.to_s\n\n  Rails.logger.info \"GCAUTOTUNE: #{report}\"\n\n  RailsStructuredLogging.instrument_script \"autotuner\" do\n    Rails.logger.info report.to_s\n  end if defined? RailsStructuredLogging\nend\n"
  },
  {
    "path": "config/initializers/content_security_policy.rb",
    "content": "# Be sure to restart your server when you modify this file.\n\n# Define an application-wide Content Security Policy.\n# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy\n#\n# Directives are configurable via environment variables with fallback to config.x\n# settings. This allows fizzy-saas (or other deployments) to extend the base policy\n# without duplicating it.\n#\n# ENV vars (space-separated sources):\n#   CSP_DEFAULT_SRC, CSP_SCRIPT_SRC, CSP_STYLE_SRC, CSP_CONNECT_SRC, CSP_FRAME_SRC,\n#   CSP_IMG_SRC, CSP_FONT_SRC, CSP_MEDIA_SRC, CSP_WORKER_SRC, CSP_FRAME_ANCESTORS,\n#   CSP_FORM_ACTION, CSP_REPORT_URI, CSP_REPORT_ONLY, DISABLE_CSP\n#\n# config.x.content_security_policy.* (string, space-separated string, or array):\n#   script_src, style_src, connect_src, frame_src, img_src, font_src, media_src,\n#   worker_src, frame_ancestors, form_action, report_uri, report_only\n\nRails.application.configure do\n  # Helper to get additional CSP sources from ENV or config.x.\n  # Supports: nil, string, space-separated string, or array.\n  sources = ->(directive) do\n    env_key = \"CSP_#{directive.to_s.upcase}\"\n    value = if ENV.key?(env_key)\n      ENV[env_key]\n    else\n      config.x.content_security_policy.send(directive)\n    end\n\n    case value\n    when nil then []\n    when Array then value\n    when String then value.split\n    else []\n    end\n  end\n\n  # Report URI and report-only mode\n  report_uri = ENV.fetch(\"CSP_REPORT_URI\") { config.x.content_security_policy.report_uri }\n  report_only =\n    if ENV.key?(\"CSP_REPORT_ONLY\")\n      ENV[\"CSP_REPORT_ONLY\"] == \"true\"\n    else\n      config.x.content_security_policy.report_only\n    end\n\n  # Generate nonces for importmap and inline scripts\n  config.content_security_policy_nonce_generator = ->(request) { SecureRandom.base64(16) }\n  config.content_security_policy_nonce_directives = %w[ script-src ]\n\n  config.content_security_policy do |policy|\n    policy.default_src :self, *sources.(:default_src)\n    policy.script_src :self, *sources.(:script_src)\n    policy.connect_src :self, *sources.(:connect_src)\n    policy.frame_src :self, *sources.(:frame_src)\n\n    # Don't fight user tools: permit inline styles, data:/https: sources, and\n    # blob: workers for accessibility extensions, privacy tools, and custom fonts.\n    policy.style_src :self, :unsafe_inline, *sources.(:style_src)\n    policy.img_src :self, \"blob:\", \"data:\", \"https:\", *sources.(:img_src)\n    policy.font_src :self, \"data:\", \"https:\", *sources.(:font_src)\n    policy.media_src :self, \"blob:\", \"data:\", \"https:\", *sources.(:media_src)\n    policy.worker_src :self, \"blob:\", *sources.(:worker_src)\n\n    # Security-critical defaults (not configurable)\n    policy.object_src :none\n    policy.base_uri :none\n\n    policy.form_action :self, *sources.(:form_action)\n    policy.frame_ancestors :self, *sources.(:frame_ancestors)\n\n    # Specify URI for violation reports (e.g., Sentry CSP endpoint)\n    policy.report_uri report_uri if report_uri\n  end\n\n  # Report violations without enforcing the policy.\n  config.content_security_policy_report_only = report_only\nend unless ENV[\"DISABLE_CSP\"]\n"
  },
  {
    "path": "config/initializers/database_role_logging.rb",
    "content": "require_relative \"extensions\"\n\nclass DatabaseRoleLogger\n  def initialize(app)\n    @app = app\n  end\n\n  def call(env)\n    Rails.logger.tagged(ActiveRecord::Base.current_role.to_s) do\n      @app.call(env)\n    end\n  end\nend\n\nif ActiveRecord::Base.replica_configured?\n  Rails.application.config.middleware.insert_after ActiveRecord::Middleware::DatabaseSelector, DatabaseRoleLogger\nend\n"
  },
  {
    "path": "config/initializers/error_context.rb",
    "content": "# Lazily add identity and account context to error reports.\n# Only evaluated when an error is actually reported.\nRails.error.add_middleware ->(error, context:, **) do\n  context.merge \\\n    identity_id: Current.identity&.id,\n    account_id: Current.account&.external_account_id\nend\n"
  },
  {
    "path": "config/initializers/extensions.rb",
    "content": "Dir[\"#{Rails.root}/lib/rails_ext/*\"].each { |path| require \"rails_ext/#{File.basename(path)}\" }\n"
  },
  {
    "path": "config/initializers/filter_parameter_logging.rb",
    "content": "# Be sure to restart your server when you modify this file.\n\n# Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file.\n# Use this to limit dissemination of sensitive information.\n# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors.\nRails.application.config.filter_parameters += %i[\n  passw secret token _key crypt salt certificate otp ssn\n]\n"
  },
  {
    "path": "config/initializers/inflections.rb",
    "content": "# Be sure to restart your server when you modify this file.\n\n# Add new inflection rules using the following format. Inflections\n# are locale specific, and you may define rules for as many different\n# locales as you wish. All of these examples are active by default:\n# ActiveSupport::Inflector.inflections(:en) do |inflect|\n#   inflect.plural /^(ox)$/i, \"\\\\1en\"\n#   inflect.singular /^(ox)en/i, \"\\\\1\"\n#   inflect.irregular \"person\", \"people\"\n#   inflect.uncountable %w( fish sheep )\n# end\n\n# These inflection rules are supported but not enabled by default:\nActiveSupport::Inflector.inflections(:en) do |inflect|\n  inflect.acronym \"SQLite\"\n  inflect.acronym \"IO\"\n\n  inflect.singular \"quotas\", \"quota\"\n  inflect.plural \"quota\", \"quotas\"\nend\n"
  },
  {
    "path": "config/initializers/mission_control.rb",
    "content": "Rails.application.config.before_initialize do\n  MissionControl::Jobs.base_controller_class = \"AdminController\"\n  MissionControl::Jobs.show_console_help = false\nend\n"
  },
  {
    "path": "config/initializers/multi_db.rb",
    "content": "require \"deployment\"\nrequire_relative \"extensions\"\n\nif ActiveRecord::Base.replica_configured?\n  Rails.application.configure do\n    config.active_record.database_selector = { delay: 0.seconds }\n    config.active_record.database_resolver = Deployment::DatabaseResolver\n    config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session\n  end\nend\n"
  },
  {
    "path": "config/initializers/multi_tenant.rb",
    "content": "Rails.application.configure do\n  config.after_initialize do\n    Account.multi_tenant = ENV[\"MULTI_TENANT\"] == \"true\" || config.x.multi_tenant.enabled == true\n  end\nend\n"
  },
  {
    "path": "config/initializers/passkeys.rb",
    "content": "Rails.application.config.to_prepare do\n  ActionPack::Passkey.prepend ActionPackPasskeyInferNameFromAaguid\nend\n"
  },
  {
    "path": "config/initializers/permissions_policy.rb",
    "content": "# Be sure to restart your server when you modify this file.\n\n# Define an application-wide HTTP permissions policy. For further\n# information see: https://developers.google.com/web/updates/2018/06/feature-policy\n\n# Rails.application.config.permissions_policy do |policy|\n#   policy.camera      :none\n#   policy.gyroscope   :none\n#   policy.microphone  :none\n#   policy.usb         :none\n#   policy.fullscreen  :self\n#   policy.payment     :self, \"https://secure.example.com\"\n# end\n"
  },
  {
    "path": "config/initializers/push_notifications.rb",
    "content": "Rails.application.config.to_prepare do\n  Notification.register_push_target(:web)\nend\n"
  },
  {
    "path": "config/initializers/rack_mini_profiler.rb",
    "content": "if defined?(Rack::MiniProfiler)\n  Rack::MiniProfiler.config.tap do |config|\n    config.position = \"top-right\"\n    config.enable_hotwire_turbo_drive_support = true\n    config.pre_authorize_cb = ->(_env) { !Rails.env.test? && File.exist?(Rails.root.join(\"tmp/rack-mini-profiler-dev.txt\")) }\n  end\nend\n"
  },
  {
    "path": "config/initializers/sanitization.rb",
    "content": "Rails.application.config.after_initialize do\n  Rails::HTML5::SafeListSanitizer.allowed_tags.merge(%w[ s table tr td th thead tbody details summary video source ])\n  Rails::HTML5::SafeListSanitizer.allowed_attributes.merge(%w[ data-turbo-frame data-lightbox-target data-lightbox-caption-value controls type width ])\n\n  # ugh, see https://github.com/rails/rails/issues/54478 which I need to fix upstream --mike\n  ActionText::ContentHelper.allowed_tags = Rails::HTML5::SafeListSanitizer.allowed_tags.to_a + [ ActionText::Attachment.tag_name, \"figure\", \"figcaption\" ] + ActionText::ContentHelper.allowed_tags.to_a\n  ActionText::ContentHelper.allowed_attributes = Rails::HTML5::SafeListSanitizer.allowed_attributes.to_a + ActionText::Attachment::ATTRIBUTES + ActionText::ContentHelper.allowed_attributes.to_a\nend\n"
  },
  {
    "path": "config/initializers/sqlite_schema_dumper.rb",
    "content": "# Fix for SQLite FTS5 virtual table schema dumping\n# Rails has a bug where it doesn't handle FTS5 content= and content_rowid= options\n\nmodule SQLiteFTS5SchemaDumperFix\n  # Override the virtual_tables method to handle FTS5 syntax properly\n  def virtual_tables(stream)\n    # Query sqlite_master for all virtual tables\n    virtual_table_sqls = @connection.select_rows(\n      \"SELECT name, sql FROM sqlite_master WHERE type='table' AND sql LIKE 'CREATE VIRTUAL TABLE%'\"\n    )\n\n    virtual_table_sqls.each do |table_name, sql|\n      # Just output the raw SQL since create_virtual_table doesn't handle our syntax\n      stream.puts \"  execute #{sql.inspect}\"\n      stream.puts\n    end\n  end\nend\n\nActiveSupport.on_load(:active_record_sqlite3adapter) do\n  ActiveRecord::ConnectionAdapters::SQLite3::SchemaDumper.prepend(SQLiteFTS5SchemaDumperFix)\nend\n"
  },
  {
    "path": "config/initializers/table_definition_column_limits.rb",
    "content": "# Apply MySQL-compatible column limits when defining tables.\n#\n# For string columns: defaults to 255 (MySQL's VARCHAR default)\n#\n# For text columns: converts MySQL's `size:` option to equivalent limits:\n#   - (blank/default): 65,535 (TEXT)\n#   - size: :tiny: 255 (TINYTEXT)\n#   - size: :medium: 16,777,215 (MEDIUMTEXT)\n#   - size: :long: 4,294,967,295 (LONGTEXT)\n#\n\nmodule TableDefinitionColumnLimits\n  TEXT_SIZE_TO_LIMIT = {\n    tiny: 255,\n    medium: 16_777_215,\n    long: 4_294_967_295\n  }.freeze\n\n  TEXT_DEFAULT_LIMIT = 65_535\n  STRING_DEFAULT_LIMIT = 255\n\n  def column(name, type, **options)\n    if type == :string\n      options[:limit] ||= STRING_DEFAULT_LIMIT\n    end\n\n    if type == :text || type == :binary\n      if options.key?(:size)\n        size = options.delete(:size)\n        options[:limit] ||= TEXT_SIZE_TO_LIMIT.fetch(size) do\n          raise ArgumentError, \"Unknown text size: #{size.inspect}. Use :tiny, :medium, or :long\"\n        end\n      elsif type == :text\n        options[:limit] ||= TEXT_DEFAULT_LIMIT\n      end\n    end\n\n    super\n  end\nend\n\n# For SQLite: append inline CHECK constraints to enforce string/text length limits.\n# since SQLite doesn't natively enforce VARCHAR/TEXT length limits.\nmodule SQLiteColumnLimitCheckConstraints\n  def add_column_options!(sql, options)\n    super\n\n    column = options[:column]\n    if column && column.limit && %i[string text].include?(column.type)\n      check_expr = if column.type == :string\n        # VARCHAR limits are in characters\n        %(length(\"#{column.name}\") <= #{column.limit})\n      else\n        # TEXT limits are in bytes\n        %(length(CAST(\"#{column.name}\" AS BLOB)) <= #{column.limit})\n      end\n      sql << \" CHECK(#{check_expr})\"\n    end\n\n    sql\n  end\nend\n\nActiveSupport.on_load(:active_record) do\n  ActiveRecord::ConnectionAdapters::TableDefinition.prepend(TableDefinitionColumnLimits)\nend\n\nActiveSupport.on_load(:active_record_sqlite3adapter) do\n  ActiveRecord::ConnectionAdapters::SQLite3::SchemaCreation.prepend(SQLiteColumnLimitCheckConstraints)\nend\n"
  },
  {
    "path": "config/initializers/tenanting/account_slug.rb",
    "content": "module AccountSlug\n  PATTERN = /(\\d+)/\n  PATH_INFO_MATCH = /\\A(\\/#{AccountSlug::PATTERN})/\n\n  class Extractor\n    def initialize(app)\n      @app = app\n    end\n\n    # We're using account id prefixes in the URL path. Rather than namespace\n    # all our routes, we're \"mounting\" the Rails app at this URL prefix.\n    def call(env)\n      request = ActionDispatch::Request.new(env)\n\n      # $1, $2, $' == script_name, slug, path_info\n      if request.script_name && request.script_name =~ PATH_INFO_MATCH\n        # Likely due to restarting the action cable connection after upgrade\n        env[\"fizzy.external_account_id\"] = AccountSlug.decode($2)\n      elsif request.path_info =~ PATH_INFO_MATCH\n        # Yanks the prefix off PATH_INFO and move it to SCRIPT_NAME\n        request.engine_script_name = request.script_name = $1\n        request.path_info   = $'.empty? ? \"/\" : $'\n\n        # Stash the account's Queenbee ID.\n        env[\"fizzy.external_account_id\"] = AccountSlug.decode($2)\n      end\n\n      if env[\"fizzy.external_account_id\"]\n        account = Account.find_by(external_account_id: env[\"fizzy.external_account_id\"])\n        Current.with_account(account) do\n          @app.call env\n        end\n      else\n        Current.without_account do\n          @app.call env\n        end\n      end\n    end\n  end\n\n  def self.decode(slug) slug.to_i end\n  def self.encode(id) id.to_s end\nend\n\nRails.application.config.middleware.insert_after Rack::TempfileReaper, AccountSlug::Extractor\n"
  },
  {
    "path": "config/initializers/tenanting/turbo.rb",
    "content": "module TurboStreamsJobExtensions\n  extend ActiveSupport::Concern\n\n  class_methods do\n    def render_format(format, **rendering)\n      if Current.account.present?\n        ApplicationController.renderer.new(script_name: Current.account.slug).render(formats: [ format ], **rendering)\n      else\n        super\n      end\n    end\n  end\nend\n\nRails.application.config.after_initialize do\n  Turbo::StreamsChannel.prepend TurboStreamsJobExtensions\nend\n"
  },
  {
    "path": "config/initializers/uuid_framework_models.rb",
    "content": "# Inject account associations into Rails framework models\nRails.application.config.to_prepare do\n  ActionText::RichText.belongs_to :account, default: -> { record.account }\n\n  ActiveStorage::Attachment.belongs_to :account, default: -> { record.account }\n\n  ActiveStorage::Blob.belongs_to :account, default: -> { Current.account }\n\n  ActiveStorage::VariantRecord.belongs_to :account, default: -> { blob.account }\nend\n"
  },
  {
    "path": "config/initializers/uuid_primary_keys.rb",
    "content": "# Automatically use UUID type for all binary(16) columns and generate defaults\n\nmodule UuidPrimaryKeyDefault\n  def load_schema!\n    define_uuid_primary_key_pending_default\n    super\n  end\n\n  private\n    def define_uuid_primary_key_pending_default\n      if uuid_primary_key?\n        pending_attribute_modifications << PendingUuidDefault.new(primary_key)\n      end\n    rescue ActiveRecord::StatementInvalid\n      # Table doesn't exist yet\n    end\n\n    def uuid_primary_key?\n      table_name && primary_key && schema_cache.columns_hash(table_name)[primary_key]&.type == :uuid\n    end\n\n    PendingUuidDefault = Struct.new(:name) do\n      def apply_to(attribute_set)\n        attribute_set[name] = attribute_set[name].with_user_default(-> { ActiveRecord::Type::Uuid.generate })\n      end\n    end\nend\n\nmodule MysqlUuidAdapter\n  extend ActiveSupport::Concern\n\n  # Override lookup_cast_type to recognize binary(16) as UUID type\n  def lookup_cast_type(sql_type)\n    if sql_type == \"binary(16)\"\n      ActiveRecord::Type.lookup(:uuid, adapter: :trilogy)\n    else\n      super\n    end\n  end\n\n  # Override fetch_type_metadata to preserve UUID type and limit\n  def fetch_type_metadata(sql_type, extra = \"\")\n    if sql_type == \"binary(16)\"\n      simple_type = ActiveRecord::ConnectionAdapters::SqlTypeMetadata.new(\n        sql_type: sql_type,\n        type: :uuid,\n        limit: 16\n      )\n      ActiveRecord::ConnectionAdapters::MySQL::TypeMetadata.new(simple_type, extra: extra)\n    else\n      super\n    end\n  end\n\n  class_methods do\n    def native_database_types\n      @native_database_types_with_uuid ||= super.merge(uuid: { name: \"binary\", limit: 16 })\n    end\n  end\nend\n\nmodule SqliteUuidAdapter\n  extend ActiveSupport::Concern\n\n  # Override lookup_cast_type to recognize BLOB as UUID type\n  def lookup_cast_type(sql_type)\n    if sql_type == \"blob(16)\"\n      ActiveRecord::Type.lookup(:uuid, adapter: :sqlite3)\n    else\n      super\n    end\n  end\n\n  # Override fetch_type_metadata to preserve UUID type and limit\n  def fetch_type_metadata(sql_type)\n    if sql_type == \"blob(16)\"\n      ActiveRecord::ConnectionAdapters::SqlTypeMetadata.new(\n        sql_type: sql_type,\n        type: :uuid,\n        limit: 16\n      )\n    else\n      super\n    end\n  end\n\n  class_methods do\n    def native_database_types\n      @native_database_types_with_uuid ||= super.merge(uuid: { name: \"blob\", limit: 16 })\n    end\n  end\nend\n\nmodule SchemaDumperUuidType\n  # Map binary(16) and blob(16) columns to :uuid type in schema.rb\n  def schema_type(column)\n    if column.sql_type == \"binary(16)\" || column.sql_type == \"blob(16)\"\n      :uuid\n    else\n      super\n    end\n  end\nend\n\nmodule TableDefinitionUuidSupport\n  def uuid(name, **options)\n    column(name, :uuid, **options)\n  end\nend\n\nActiveSupport.on_load(:active_record) do\n  ActiveRecord::Base.singleton_class.prepend(UuidPrimaryKeyDefault)\n  ActiveRecord::ConnectionAdapters::TableDefinition.prepend(TableDefinitionUuidSupport)\nend\n\nActiveSupport.on_load(:active_record_trilogyadapter) do\n  ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter.prepend(MysqlUuidAdapter)\n  ActiveRecord::ConnectionAdapters::MySQL::SchemaDumper.prepend(SchemaDumperUuidType)\nend\n\nActiveSupport.on_load(:active_record_sqlite3adapter) do\n  ActiveRecord::ConnectionAdapters::SQLite3Adapter.prepend(SqliteUuidAdapter)\n  ActiveRecord::ConnectionAdapters::SQLite3::SchemaDumper.prepend(SchemaDumperUuidType)\nend\n"
  },
  {
    "path": "config/initializers/vapid.rb",
    "content": "Rails.application.configure do\n  config.x.vapid.private_key = ENV[\"VAPID_PRIVATE_KEY\"]\n  config.x.vapid.public_key = ENV[\"VAPID_PUBLIC_KEY\"]\nend\n"
  },
  {
    "path": "config/initializers/vips.rb",
    "content": "raise LoadError, \"Please install libvips\" unless defined?(Vips::LIBRARY_VERSION)\n\n# Disable Openslide to prevent sqlite segfault in forked parallel workers\n# Requires libvips 8.13+\nVips.block \"VipsForeignLoadOpenslide\", true if Vips.respond_to?(:block)\n\n# Limit libvips to 4 threads for each thread pool. Default is #CPUs.\nVips.concurrency_set 4\n\n# Limit libvips caches to reduce memory pressure.\n#\n# Do not disable entirely since libvips relies on some caching internally.\n# (When we disabled caches, we hit a ton of JPEG out of order read errors.)\nVips.cache_set_max 10               # Default 100\nVips.cache_set_max_mem 10.megabytes # Default 100MB\nVips.cache_set_max_files 10         # Default 100\n"
  },
  {
    "path": "config/initializers/web_push.rb",
    "content": "require \"web-push\"\nrequire \"web_push/pool\"\nrequire \"web_push/notification\"\n\nRails.application.configure do\n  config.x.web_push_pool = WebPush::Pool.new(\n    invalid_subscription_handler: ->(subscription_id) do\n      Rails.application.executor.wrap do\n        Rails.logger.info \"Destroying push subscription: #{subscription_id}\"\n        Push::Subscription.find_by(id: subscription_id)&.destroy\n      end\n    end\n  )\n\n  at_exit { config.x.web_push_pool.shutdown }\nend\n\nmodule WebPush::PersistentRequest\n  def perform\n    endpoint_ip = @options[:endpoint_ip]\n\n    if endpoint_ip\n      http = Net::HTTP.new(uri.host, uri.port)\n      http.ipaddr = endpoint_ip\n      http.use_ssl = true\n      http.ssl_timeout = @options[:ssl_timeout] unless @options[:ssl_timeout].nil?\n      http.open_timeout = @options[:open_timeout] unless @options[:open_timeout].nil?\n      http.read_timeout = @options[:read_timeout] unless @options[:read_timeout].nil?\n    elsif @options[:connection]\n      http = @options[:connection]\n    else\n      http = Net::HTTP.new(uri.host, uri.port, *proxy_options)\n      http.use_ssl = true\n      http.ssl_timeout = @options[:ssl_timeout] unless @options[:ssl_timeout].nil?\n      http.open_timeout = @options[:open_timeout] unless @options[:open_timeout].nil?\n      http.read_timeout = @options[:read_timeout] unless @options[:read_timeout].nil?\n    end\n\n    req = Net::HTTP::Post.new(uri.request_uri, headers)\n    req.body = body\n\n    if http.is_a?(Net::HTTP::Persistent)\n      response = http.request uri, req\n    else\n      resp = http.request(req)\n      verify_response(resp)\n    end\n\n    resp\n  end\nend\n\nWebPush::Request.prepend WebPush::PersistentRequest\n"
  },
  {
    "path": "config/locales/en.yml",
    "content": "# Files in the config/locales directory are used for internationalization and\n# are automatically loaded by Rails. If you want to use locales other than\n# English, add the necessary files in this directory.\n#\n# To use the locales, use `I18n.t`:\n#\n#     I18n.t \"hello\"\n#\n# In views, this is aliased to just `t`:\n#\n#     <%= t(\"hello\") %>\n#\n# To use a different locale, set it with `I18n.locale`:\n#\n#     I18n.locale = :es\n#\n# This would use the information in config/locales/es.yml.\n#\n# To learn more about the API, please read the Rails Internationalization guide\n# at https://guides.rubyonrails.org/i18n.html.\n#\n# Be aware that YAML interprets the following case-insensitive strings as\n# booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings\n# must be quoted to be interpreted as strings. For example:\n#\n#     en:\n#       \"yes\": yup\n#       enabled: \"ON\"\n\nen:\n  hello: \"Hello world\"\n"
  },
  {
    "path": "config/passkey_aaguids.yml",
    "content": "shared:\n  apple_passwords:\n    name: Apple Passwords\n    icon:\n      light: passkeys/apple_passwords_light.svg\n      dark: passkeys/apple_passwords_dark.svg\n    aaguids:\n      - dd4ec289-e01d-41c9-bb89-70fa845d4bf2\n      - fbfc3007-154e-4ecc-8c0b-6e020557d7bd\n\n  google_password_manager:\n    name: Google Password Manager\n    icon:\n      light: passkeys/google_password_manager_light.svg\n      dark: passkeys/google_password_manager_dark.svg\n    aaguids:\n      - ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4\n\n  windows_hello:\n    name: Windows Hello\n    icon:\n      light: passkeys/windows_hello_light.svg\n      dark: passkeys/windows_hello_dark.svg\n    aaguids:\n      - 08987058-cadc-4b81-b6e1-30de50dcbe96\n      - 6028b017-b1d4-4c02-b4b3-afcdafc96bb2\n      - 9ddd1817-af5a-4672-a2b9-3e3dd95000a9\n\n  1password:\n    name: 1Password\n    icon:\n      light: passkeys/1password_light.svg\n      dark: passkeys/1password_dark.svg\n    aaguids:\n      - bada5566-a7aa-401f-bd96-45619a55120d\n\n  bitwarden:\n    name: Bitwarden\n    icon:\n      light: passkeys/bitwarden_light.svg\n      dark: passkeys/bitwarden_dark.svg\n    aaguids:\n      - d548826e-79b4-db40-a3d8-11116f7e8349\n\n  samsung_pass:\n    name: Samsung Pass\n    icon:\n      light: passkeys/samsung_pass_light.svg\n      dark: passkeys/samsung_pass_dark.svg\n    aaguids:\n      - 53414d53-554e-4700-0000-000000000000\n\n  lastpass:\n    name: LastPass\n    icon:\n      light: passkeys/lastpass_light.svg\n      dark: passkeys/lastpass_dark.svg\n    aaguids:\n      - b78a0a55-6ef8-d246-a042-ba0f6d55050c\n\n  dashlane:\n    name: Dashlane\n    icon:\n      light: passkeys/dashlane_light.svg\n      dark: passkeys/dashlane_dark.svg\n    aaguids:\n      - 531126d6-e717-415c-9320-3d9aa6981239\n\n  yubikey:\n    name: YubiKey\n    icon:\n      light: passkeys/yubikey_light.svg\n      dark: passkeys/yubikey_dark.svg\n    aaguids:\n      - 19083c3d-8383-4b18-bc03-8f1c9ab2fd1b\n      - 1ac71f64-468d-4fe0-bef1-0e5f2f551f18\n      - 20ac7a17-c814-4833-93fe-539f0d5e3389\n      - 24673149-6c86-42e7-98d9-433fb5b73296\n      - 2fc0579f-8113-47ea-b116-bb5a8db9202a\n      - 3124e301-f14e-4e38-876d-fbeeb090e7bf\n      - 34744913-4f57-4e6e-a527-e9ec3c4b94e6\n      - 34f5766d-1536-4a24-9033-0e294e510fb0\n      - 3a662962-c6d4-4023-bebb-98ae92e78e20\n      - 3aa78eb1-ddd8-46a8-a821-8f8ec57a7bd5\n      - 3b24bf49-1d45-4484-a917-13175df0867b\n      - 4599062e-6926-4fe7-9566-9e8fb1aedaa0\n      - 4fc84f16-2545-4e53-b8fc-7bf4d7282a10\n      - 57f7de54-c807-4eab-b1c6-1c9be7984e92\n      - 58276709-bb4b-4bb3-baf1-60eea99282a7\n      - 5b0e46ba-db02-44ac-b979-ca9b84f5e335\n      - 62e54e98-c209-4df3-b692-de71bb6a8528\n      - 662ef48a-95e2-4aaa-a6c1-5b9c40375824\n      - 6ab56fad-881f-4a43-acb2-0be065924522\n      - 6ec5cff2-a0f9-4169-945b-f33b563f7b99\n      - 73bb0cd4-e502-49b8-9c6f-b59445bf720b\n      - 7409272d-1ff9-4e10-9fc9-ac0019c124fd\n      - 79f3c8ba-9e35-484b-8f47-53a5a0f5c630\n      - 7b96457d-e3cd-432b-9ceb-c9fdd7ef7432\n      - 7d1351a6-e097-4852-b8bf-c9ac5c9ce4a3\n      - 83c47309-aabb-4108-8470-8be838b573cb\n      - 85203421-48f9-4355-9bc8-8a53846e5083\n      - 8c39ee86-7f9a-4a95-9ba3-f6b097e5c2ee\n      - 905b4cb4-ed6f-4da9-92fc-45e0d4e9b5c7\n      - 90636e1f-ef82-43bf-bdcf-5255f139d12f\n      - 97e6a830-c952-4740-95fc-7c78dc97ce47\n      - 9e66c661-e428-452a-a8fb-51f7ed088acf\n      - 9eb7eabc-9db5-49a1-b6c3-555a802093f4\n      - a02167b9-ae71-4ac7-9a07-06432ebb6f1c\n      - a25342c0-3cdc-4414-8e46-f4807fca511c\n      - ad08c78a-4e41-49b9-86a2-ac15b06899e2\n      - b2c1a50b-dad8-4dc7-ba4d-0ce9597904bc\n      - b90e7dc1-316e-4fee-a25a-56a666a670fe\n      - c1f9a0bc-1dd2-404a-b27f-8e29047a43fd\n      - c5ef55ff-ad9a-4b9f-b580-adebafe026d0\n      - cb69481e-8ff7-4039-93ec-0a2729a154a8\n      - ce6bf97f-9f69-4ba7-9032-97adc6ca5cf1\n      - d2fbd093-ee62-488d-9dad-1e36389f8826\n      - d7781e5d-e353-46aa-afe2-3ca49f13332a\n      - d8522d9f-575b-4866-88a9-ba99fa02f35b\n      - dd86a2da-86a0-4cbe-b462-4bd31f57bc6f\n      - ee882879-721c-4913-9775-3dfcce97072a\n      - fa2b99dc-9e39-4257-8f92-4a30d23c4118\n      - fcc0118f-cd45-435b-8da1-9782b2da0715\n      - ff4dac45-ede8-4ec2-aced-cf66103f4335\n"
  },
  {
    "path": "config/puma.rb",
    "content": "# Specifies the `port` that Puma will listen on to receive requests; default is 3000.\nport ENV.fetch(\"PORT\", 3000)\n\n# Specifies the `pidfile` that Puma will use.\npidfile ENV.fetch(\"PIDFILE\", \"tmp/pids/server.pid\")\n\n# Allow puma to be restarted by `bin/rails restart` command.\nplugin :tmp_restart\n\n# Run Solid Queue with Puma by default.\n# Disabled when running fizzy-saas or via SOLID_QUEUE_IN_PUMA=false.\nunless Fizzy.saas? || ENV[\"SOLID_QUEUE_IN_PUMA\"] == \"false\"\n  plugin :solid_queue\nend\n\n# Expose Prometheus metrics at http://0.0.0.0:9394/metrics (SaaS only).\n# In dev, overridden to http://127.0.0.1:9306/metrics in .mise.toml.\nif Fizzy.saas?\n  control_uri = Rails.env.local? ? \"unix://tmp/pumactl.sock\" : \"auto\"\n  activate_control_app control_uri, no_token: true\n  plugin :yabeda\n  plugin :yabeda_prometheus\nend\n\nif !Rails.env.local?\n  # Because we expect fewer I/O waits than Rails apps that connect to the\n  # database over the network, let's start with a baseline config of 1\n  # worker per CPU, 1 thread per worker and tune it from there.\n  #\n  # https://edgeguides.rubyonrails.org/tuning_performance_for_deployment.html#puma\n  workers Integer(ENV.fetch(\"WEB_CONCURRENCY\") { Concurrent.physical_processor_count })\n  threads 1, 1\n\n  # Tell the Ruby VM that we're finished booting up.\n  #\n  # Now's the time to tidy the heap (GC, compact, free empty, malloc_trim, etc)\n  # for optimal copy-on-write efficiency.\n  before_fork do\n    Process.warmup\n  end\n\n  # Defer major GC (full marking phase) until after request handling,\n  # and perform major GC deferred during request handling.\n  before_worker_boot do\n    GC.config(rgengc_allow_full_mark: false)\n  end\n\n  out_of_band do\n    GC.start if GC.latest_gc_info(:need_major_by)\n  end\nend\n"
  },
  {
    "path": "config/queue.yml",
    "content": "default: &default\n  dispatchers:\n    - polling_interval: 1\n      batch_size: 500\n  workers:\n    - queues: [ \"default\", \"solid_queue_recurring\", \"backend\", \"webhooks\", \"*\" ]\n      threads: 3\n      processes: <%= Integer(ENV.fetch(\"JOB_CONCURRENCY\") { Concurrent.physical_processor_count }) %>\n      polling_interval: 0.1\n\ndevelopment: *default\ntest: *default\nbeta: *default\nstaging: *default\nproduction: *default\n"
  },
  {
    "path": "config/recurring.yml",
    "content": "<% require_relative \"../lib/fizzy\" %>\n\nproduction: &production\n  # Application functionality: notifications and summaries\n  deliver_bundled_notifications:\n    command: \"Notification::Bundle.deliver_all_later\"\n    schedule: every 30 minutes\n\n  # Application cleanup\n  auto_postpone_all_due:\n    command: \"Card.auto_postpone_all_due\"\n    schedule: every hour at minute 50\n  delete_unused_tags:\n    class: DeleteUnusedTagsJob\n    schedule: every day at 04:02\n\n  # Operations cleanup and backups\n  clear_solid_queue_finished_jobs:\n    command: \"SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)\"\n    schedule: every hour at minute 12\n  cleanup_webhook_deliveries:\n    command: \"Webhook::Delivery.cleanup\"\n    schedule: every 15 minutes\n  cleanup_magic_links:\n    command: \"MagicLink.cleanup\"\n    schedule: every 4 hours\n  cleanup_exports:\n    command: \"Export.cleanup\"\n    schedule: every hour at minute 20\n  cleanup_imports:\n    command: \"Account::Import.cleanup\"\n    schedule: every hour at minute 25\n  incineration:\n    class: \"Account::IncinerateDueJob\"\n    schedule: every 8 hours at minute 16\n\n<% if Fizzy.saas? %>\n  # Metrics\n  yabeda_actioncable:\n    command: \"Yabeda::ActionCable.measure\"\n    schedule: every 60 seconds\n<% end %>\n\nbeta: &beta\n  # Only Solid Queue maintenance\n  clear_solid_queue_finished_jobs:\n    command: \"SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)\"\n    schedule: every hour at minute 12\n\nstaging: *beta\ndevelopment: *production\n"
  },
  {
    "path": "config/routes.rb",
    "content": "Rails.application.routes.draw do\n  root \"events#index\"\n\n  namespace :account do\n    resource :cancellation, only: [ :create ]\n    resource :entropy\n    resource :join_code\n    resource :settings\n    resources :exports, only: [ :create, :show ]\n    resources :imports, only: [ :new, :create, :show ]\n  end\n\n  resources :users do\n    scope module: :users do\n      resource :avatar\n      resource :role\n      resource :events\n      resources :push_subscriptions\n\n      resources :email_addresses, param: :token do\n        resource :confirmation, module: :email_addresses\n      end\n\n      resources :data_exports, only: [ :create, :show ]\n    end\n  end\n\n  resources :boards do\n    scope module: :boards do\n      resource :subscriptions\n      resource :involvement\n      resource :publication\n      resource :entropy\n\n      namespace :columns do\n        resource :not_now\n        resource :stream\n        resource :closed\n      end\n\n      resources :columns\n    end\n\n    resources :cards, only: :create\n\n    resources :webhooks do\n      scope module: :webhooks do\n        resource :activation, only: :create\n      end\n    end\n  end\n\n  resources :columns, only: [] do\n    resource :left_position, module: :columns\n    resource :right_position, module: :columns\n  end\n\n  namespace :columns do\n    resources :cards do\n      scope module: :cards do\n        namespace :drops do\n          resource :not_now\n          resource :stream\n          resource :closure\n          resource :column\n        end\n      end\n    end\n  end\n\n  namespace :cards do\n    resources :previews\n  end\n\n  resources :cards do\n    scope module: :cards do\n      resource :draft, only: :show\n      resource :board\n      resource :closure\n      resource :column\n      resource :goldness\n      resource :image\n      resource :not_now\n      resource :pin\n      resource :publish\n      resource :reading\n      resource :triage\n      resource :watch\n      resource :reading\n\n      resources :reactions\n\n      resources :assignments\n      resource :self_assignment, only: :create\n      resources :steps\n      resources :taggings\n\n      resources :comments do\n        resources :reactions, module: :comments\n      end\n    end\n  end\n\n  resources :tags, only: :index\n\n  namespace :notifications do\n    resource :settings\n    resource :unsubscribe\n  end\n\n  resources :notifications do\n    scope module: :notifications do\n      get \"tray\", to: \"trays#show\", on: :collection\n\n      resource :reading\n      collection do\n        resource :bulk_reading, only: :create\n      end\n    end\n  end\n\n  resource :search\n  namespace :searches do\n    resources :queries\n  end\n\n  resources :filters do\n    scope module: :filters do\n      collection do\n        resource :settings_refresh, only: :create\n      end\n    end\n  end\n\n  resources :events, only: :index\n  namespace :events do\n    resources :days\n    namespace :day_timeline do\n      resources :columns, only: :show\n    end\n  end\n\n  resources :qr_codes\n\n  get \"join/:code\", to: \"join_codes#new\", as: :join\n  post \"join/:code\", to: \"join_codes#create\"\n\n  namespace :users do\n    resources :joins\n    resources :verifications, only: %i[ new create ]\n  end\n\n  resource :session do\n    scope module: :sessions do\n      resources :transfers\n      resource :magic_link\n      resource :menu\n      resource :passkey, only: :create\n    end\n  end\n\n  get \"/signup\", to: redirect(\"/signup/new\")\n\n  resource :signup, only: %i[ new create ] do\n    collection do\n      scope module: :signups, as: :signup do\n        resource :completion, only: %i[ new create ]\n      end\n    end\n  end\n\n  resource :landing\n\n  namespace :my do\n    resource :passkey_challenge, only: :create\n    resource :identity, only: :show\n    resources :access_tokens\n    resources :passkeys, except: %i[ show new ]\n    resources :pins\n    resource :timezone\n    resource :menu\n  end\n\n  namespace :prompts do\n    resources :cards\n    resources :tags\n    resources :users\n\n    resources :boards do\n      scope module: :boards do\n        resources :users\n      end\n    end\n  end\n\n  namespace :public do\n    resources :boards do\n      scope module: :boards do\n        namespace :columns do\n          resource :not_now, only: :show\n          resource :stream, only: :show\n          resource :closed, only: :show\n        end\n\n        resources :columns, only: :show\n      end\n\n      resources :cards, only: :show\n    end\n  end\n\n  direct :published_board do |board, options|\n    route_for :public_board, board.publication.key\n  end\n\n  direct :published_card do |card, options|\n    route_for :public_board_card, card.board.publication.key, card\n  end\n\n  resolve \"Comment\" do |comment, options|\n    options[:anchor] = ActionView::RecordIdentifier.dom_id(comment)\n    route_for :card, comment.card, options\n  end\n\n  resolve \"Mention\" do |mention, options|\n    polymorphic_url(mention.source, options)\n  end\n\n  resolve \"Notification\" do |notification, options|\n    polymorphic_url(notification.notifiable_target, options)\n  end\n\n  resolve \"Event\" do |event, options|\n    polymorphic_url(event.eventable, options)\n  end\n\n  resolve \"Webhook\" do |webhook, options|\n    route_for :board_webhook, webhook.board, webhook, options\n  end\n\n  # Support for legacy URLs\n  get \"/collections/:collection_id/cards/:id\", to: redirect { |params, request| \"#{request.script_name}/cards/#{params[:id]}\" }\n  get \"/collections/:id\", to: redirect { |params, request| \"#{request.script_name}/boards/#{params[:id]}\" }\n  get \"/public/collections/:id\", to: redirect { |params, request| \"#{request.script_name}/public/boards/#{params[:id]}\" }\n\n  get \"up\", to: \"rails/health#show\", as: :rails_health_check\n  get \"manifest\" => \"rails/pwa#manifest\", as: :pwa_manifest\n  get \"service-worker\" => \"pwa#service_worker\"\n\n  # Mobile clients\n  get \"client_configurations/(:platform)_v(:version)\" => \"client_configurations#show\",\n    platform: /android|ios/, version: /\\d+/\n\n  namespace :admin do\n    mount MissionControl::Jobs::Engine, at: \"/jobs\"\n  end\nend\n"
  },
  {
    "path": "config/storage.oss.yml",
    "content": "test:\n  service: Disk\n  root: <%= Rails.root.join(\"tmp/storage/files\") %>\n\nlocal:\n  service: Disk\n  root: <%= Rails.root.join(\"storage\", Rails.env, \"files\") %>\n\ndevminio:\n  service: S3\n  bucket: fizzy-dev-activestorage\n  endpoint: \"http://minio.localhost:39000\"\n  force_path_style: true\n  request_checksum_calculation: when_required # default is when_supported with CRC64NVME checksum which FlashBlade doesn't support\n  response_checksum_validation: when_required # default is when_supported with CRC64NVME checksum which FlashBlade doesn't support\n  region: us-east-1 # default region required for signer\n  access_key_id: minioadmin\n  secret_access_key: minioadmin\n\ns3:\n  service: S3\n  access_key_id: <%= ENV[\"S3_ACCESS_KEY_ID\"] %>\n  bucket: <%= ENV[\"S3_BUCKET\"] || \"fizzy-#{Rails.env}-activestorage\" %>\n  endpoint: <%= ENV[\"S3_ENDPOINT\"] %>\n  force_path_style: <%= ENV[\"S3_FORCE_PATH_STYLE\"] == \"true\" %>\n  region: <%= ENV.fetch(\"S3_REGION\", \"us-east-1\") %>\n  request_checksum_calculation: <%= ENV.fetch(\"S3_REQUEST_CHECKSUM_CALCULATION\", \"when_supported\") %>\n  response_checksum_validation: <%= ENV.fetch(\"S3_RESPONSE_CHECKSUM_VALIDATION\", \"when_supported\") %>\n  secret_access_key: <%= ENV[\"S3_SECRET_ACCESS_KEY\"] %>\n"
  },
  {
    "path": "config/storage.yml",
    "content": "<%\n  config_path = if Fizzy.saas?\n    gem_path = Gem::Specification.find_by_name(\"fizzy-saas\").gem_dir\n    File.join(gem_path, \"config\", \"storage.yml\")\n  else\n    File.join(__dir__, \"storage.oss.yml\")\n  end\n%>\n<%= ERB.new(File.read(config_path)).result %>\n"
  },
  {
    "path": "config.ru",
    "content": "# This file is used by Rack-based servers to start the application.\n\nrequire_relative 'config/environment'\n\nuse Autotuner::RackPlugin\n\nrun Rails.application\nRails.application.load_server\n"
  },
  {
    "path": "db/cable_schema.rb",
    "content": "# This file is auto-generated from the current state of the database. Instead\n# of editing this file, please use the migrations feature of Active Record to\n# incrementally modify your database, and then regenerate this schema definition.\n#\n# This file is the source Rails uses to define your schema when running `bin/rails\n# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to\n# be faster and is potentially less error prone than running all of your\n# migrations from scratch. Old migrations may fail to apply correctly if those\n# migrations use external dependencies or application code.\n#\n# It's strongly recommended that you check this file into your version control system.\n\nActiveRecord::Schema[8.2].define(version: 1) do\n  create_table \"solid_cable_messages\", charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.binary \"channel\", limit: 1024, null: false\n    t.bigint \"channel_hash\", null: false\n    t.datetime \"created_at\", null: false\n    t.binary \"payload\", size: :long, null: false\n    t.index [\"channel\"], name: \"index_solid_cable_messages_on_channel\"\n    t.index [\"channel_hash\"], name: \"index_solid_cable_messages_on_channel_hash\"\n    t.index [\"created_at\"], name: \"index_solid_cable_messages_on_created_at\"\n  end\nend\n"
  },
  {
    "path": "db/cache_schema.rb",
    "content": "# This file is auto-generated from the current state of the database. Instead\n# of editing this file, please use the migrations feature of Active Record to\n# incrementally modify your database, and then regenerate this schema definition.\n#\n# This file is the source Rails uses to define your schema when running `bin/rails\n# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to\n# be faster and is potentially less error prone than running all of your\n# migrations from scratch. Old migrations may fail to apply correctly if those\n# migrations use external dependencies or application code.\n#\n# It's strongly recommended that you check this file into your version control system.\n\nActiveRecord::Schema[8.2].define(version: 1) do\n  create_table \"solid_cache_entries\", charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.integer \"byte_size\", null: false\n    t.datetime \"created_at\", null: false\n    t.binary \"key\", limit: 1024, null: false\n    t.bigint \"key_hash\", null: false\n    t.binary \"value\", size: :long, null: false\n    t.index [\"byte_size\"], name: \"index_solid_cache_entries_on_byte_size\"\n    t.index [\"key_hash\", \"byte_size\"], name: \"index_solid_cache_entries_on_key_hash_and_byte_size\"\n    t.index [\"key_hash\"], name: \"index_solid_cache_entries_on_key_hash\", unique: true\n  end\nend\n"
  },
  {
    "path": "db/migrate/20251111122540_initial_schema.rb",
    "content": "class InitialSchema < ActiveRecord::Migration[8.2]\n  def change\n    create_table \"accesses\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.datetime \"accessed_at\"\n      t.uuid \"board_id\", null: false\n      t.datetime \"created_at\", null: false\n      t.string \"involvement\", limit: 255, default: \"access_only\", null: false\n      t.datetime \"updated_at\", null: false\n      t.uuid \"user_id\", null: false\n      t.index [\"accessed_at\"], name: \"index_accesses_on_accessed_at\", order: :desc\n      t.index [\"board_id\", \"user_id\"], name: \"index_accesses_on_board_id_and_user_id\", unique: true\n      t.index [\"board_id\"], name: \"index_accesses_on_board_id\"\n      t.index [\"user_id\"], name: \"index_accesses_on_user_id\"\n    end\n\n    create_table \"account_join_codes\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.uuid \"account_id\"\n      t.string \"code\", limit: 255, null: false\n      t.datetime \"created_at\", null: false\n      t.datetime \"updated_at\", null: false\n      t.integer \"usage_count\", default: 0, null: false\n      t.integer \"usage_limit\", default: 10, null: false\n      t.index [\"code\"], name: \"index_account_join_codes_on_code\", unique: true\n    end\n\n    create_table \"accounts\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.datetime \"created_at\", null: false\n      t.integer \"external_account_id\"\n      t.string \"name\", limit: 255, null: false\n      t.datetime \"updated_at\", null: false\n      t.index [\"external_account_id\"], name: \"index_accounts_on_external_account_id\", unique: true\n    end\n\n    create_table \"action_text_rich_texts\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.text \"body\", size: :long\n      t.datetime \"created_at\", null: false\n      t.string \"name\", limit: 255, null: false\n      t.uuid \"record_id\", null: false\n      t.string \"record_type\", limit: 255, null: false\n      t.datetime \"updated_at\", null: false\n      t.index [\"record_type\", \"record_id\", \"name\"], name: \"index_action_text_rich_texts_uniqueness\", unique: true\n    end\n\n    create_table \"active_storage_attachments\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.uuid \"blob_id\", null: false\n      t.datetime \"created_at\", null: false\n      t.string \"name\", limit: 255, null: false\n      t.uuid \"record_id\", null: false\n      t.string \"record_type\", limit: 255, null: false\n      t.index [\"blob_id\"], name: \"index_active_storage_attachments_on_blob_id\"\n      t.index [\"record_type\", \"record_id\", \"name\", \"blob_id\"], name: \"index_active_storage_attachments_uniqueness\", unique: true\n    end\n\n    create_table \"active_storage_blobs\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.bigint \"byte_size\", null: false\n      t.string \"checksum\", limit: 255\n      t.string \"content_type\", limit: 255\n      t.datetime \"created_at\", null: false\n      t.string \"filename\", limit: 255, null: false\n      t.string \"key\", limit: 255, null: false\n      t.text \"metadata\"\n      t.string \"service_name\", limit: 255, null: false\n      t.index [\"key\"], name: \"index_active_storage_blobs_on_key\", unique: true\n    end\n\n    create_table \"active_storage_variant_records\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.uuid \"blob_id\", null: false\n      t.string \"variation_digest\", limit: 255, null: false\n      t.index [\"blob_id\", \"variation_digest\"], name: \"index_active_storage_variant_records_uniqueness\", unique: true\n    end\n\n    create_table \"assignees_filters\", id: false, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.uuid \"assignee_id\", null: false\n      t.uuid \"filter_id\", null: false\n      t.index [\"assignee_id\"], name: \"index_assignees_filters_on_assignee_id\"\n      t.index [\"filter_id\"], name: \"index_assignees_filters_on_filter_id\"\n    end\n\n    create_table \"assigners_filters\", id: false, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.uuid \"assigner_id\", null: false\n      t.uuid \"filter_id\", null: false\n      t.index [\"assigner_id\"], name: \"index_assigners_filters_on_assigner_id\"\n      t.index [\"filter_id\"], name: \"index_assigners_filters_on_filter_id\"\n    end\n\n    create_table \"assignments\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.uuid \"assignee_id\", null: false\n      t.uuid \"assigner_id\", null: false\n      t.uuid \"card_id\", null: false\n      t.datetime \"created_at\", null: false\n      t.datetime \"updated_at\", null: false\n      t.index [\"assignee_id\", \"card_id\"], name: \"index_assignments_on_assignee_id_and_card_id\", unique: true\n      t.index [\"card_id\"], name: \"index_assignments_on_card_id\"\n    end\n\n    create_table \"board_publications\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.uuid \"board_id\", null: false\n      t.datetime \"created_at\", null: false\n      t.string \"key\", limit: 255\n      t.datetime \"updated_at\", null: false\n      t.index [\"board_id\"], name: \"index_board_publications_on_board_id\"\n      t.index [\"key\"], name: \"index_board_publications_on_key\", unique: true\n    end\n\n    create_table \"boards\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.uuid \"account_id\"\n      t.boolean \"all_access\", default: false, null: false\n      t.datetime \"created_at\", null: false\n      t.uuid \"creator_id\", null: false\n      t.string \"name\", limit: 255, null: false\n      t.datetime \"updated_at\", null: false\n      t.index [\"creator_id\"], name: \"index_boards_on_creator_id\"\n    end\n\n    create_table \"boards_filters\", id: false, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.uuid \"board_id\", null: false\n      t.uuid \"filter_id\", null: false\n      t.index [\"board_id\"], name: \"index_boards_filters_on_board_id\"\n      t.index [\"filter_id\"], name: \"index_boards_filters_on_filter_id\"\n    end\n\n    create_table \"card_activity_spikes\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.uuid \"card_id\", null: false\n      t.datetime \"created_at\", null: false\n      t.datetime \"updated_at\", null: false\n      t.index [\"card_id\"], name: \"index_card_activity_spikes_on_card_id\"\n    end\n\n    create_table \"card_engagements\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.uuid \"card_id\"\n      t.datetime \"created_at\", null: false\n      t.string \"status\", limit: 255, default: \"doing\", null: false\n      t.datetime \"updated_at\", null: false\n      t.index [\"card_id\"], name: \"index_card_engagements_on_card_id\"\n      t.index [\"status\"], name: \"index_card_engagements_on_status\"\n    end\n\n    create_table \"card_goldnesses\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.uuid \"card_id\", null: false\n      t.datetime \"created_at\", null: false\n      t.datetime \"updated_at\", null: false\n      t.index [\"card_id\"], name: \"index_card_goldnesses_on_card_id\", unique: true\n    end\n\n    create_table \"card_not_nows\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.uuid \"card_id\", null: false\n      t.datetime \"created_at\", null: false\n      t.datetime \"updated_at\", null: false\n      t.uuid \"user_id\"\n      t.index [\"card_id\"], name: \"index_card_not_nows_on_card_id\", unique: true\n      t.index [\"user_id\"], name: \"index_card_not_nows_on_user_id\"\n    end\n\n    create_table \"cards\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.uuid \"account_id\"\n      t.uuid \"board_id\", null: false\n      t.uuid \"column_id\"\n      t.datetime \"created_at\", null: false\n      t.uuid \"creator_id\", null: false\n      t.date \"due_on\"\n      t.datetime \"last_active_at\", null: false\n      t.string \"status\", limit: 255, default: \"drafted\", null: false\n      t.string \"title\", limit: 255\n      t.datetime \"updated_at\", null: false\n      t.index [\"board_id\"], name: \"index_cards_on_board_id\"\n      t.index [\"column_id\"], name: \"index_cards_on_column_id\"\n      t.index [\"last_active_at\", \"status\"], name: \"index_cards_on_last_active_at_and_status\"\n    end\n\n    create_table \"closers_filters\", id: false, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.uuid \"closer_id\", null: false\n      t.uuid \"filter_id\", null: false\n      t.index [\"closer_id\"], name: \"index_closers_filters_on_closer_id\"\n      t.index [\"filter_id\"], name: \"index_closers_filters_on_filter_id\"\n    end\n\n    create_table \"closures\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.uuid \"card_id\", null: false\n      t.datetime \"created_at\", null: false\n      t.datetime \"updated_at\", null: false\n      t.uuid \"user_id\"\n      t.index [\"card_id\", \"created_at\"], name: \"index_closures_on_card_id_and_created_at\"\n      t.index [\"card_id\"], name: \"index_closures_on_card_id\", unique: true\n      t.index [\"user_id\"], name: \"index_closures_on_user_id\"\n    end\n\n    create_table \"columns\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.uuid \"account_id\"\n      t.uuid \"board_id\", null: false\n      t.string \"color\", limit: 255, null: false\n      t.datetime \"created_at\", null: false\n      t.string \"name\", limit: 255, null: false\n      t.integer \"position\", default: 0, null: false\n      t.datetime \"updated_at\", null: false\n      t.index [\"board_id\", \"position\"], name: \"index_columns_on_board_id_and_position\"\n      t.index [\"board_id\"], name: \"index_columns_on_board_id\"\n    end\n\n    create_table \"comments\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.uuid \"account_id\"\n      t.uuid \"card_id\", null: false\n      t.datetime \"created_at\", null: false\n      t.uuid \"creator_id\", null: false\n      t.datetime \"updated_at\", null: false\n      t.index [\"card_id\"], name: \"index_comments_on_card_id\"\n    end\n\n    create_table \"creators_filters\", id: false, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.uuid \"creator_id\", null: false\n      t.uuid \"filter_id\", null: false\n      t.index [\"creator_id\"], name: \"index_creators_filters_on_creator_id\"\n      t.index [\"filter_id\"], name: \"index_creators_filters_on_filter_id\"\n    end\n\n    create_table \"entropies\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.bigint \"auto_postpone_period\", default: 2592000, null: false\n      t.uuid \"container_id\", null: false\n      t.string \"container_type\", limit: 255, null: false\n      t.datetime \"created_at\", null: false\n      t.datetime \"updated_at\", null: false\n      t.index [\"container_type\", \"container_id\", \"auto_postpone_period\"], name: \"idx_on_container_type_container_id_auto_postpone_pe_3d79b50517\"\n      t.index [\"container_type\", \"container_id\"], name: \"index_entropy_configurations_on_container\", unique: true\n    end\n\n    create_table \"events\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.uuid \"account_id\"\n      t.string \"action\", limit: 255, null: false\n      t.uuid \"board_id\", null: false\n      t.datetime \"created_at\", null: false\n      t.uuid \"creator_id\", null: false\n      t.uuid \"eventable_id\", null: false\n      t.string \"eventable_type\", limit: 255, null: false\n      t.json \"particulars\", default: -> { \"(json_object())\" }\n      t.datetime \"updated_at\", null: false\n      t.index [\"action\"], name: \"index_events_on_summary_id_and_action\"\n      t.index [\"board_id\", \"action\", \"created_at\"], name: \"index_events_on_board_id_and_action_and_created_at\"\n      t.index [\"board_id\"], name: \"index_events_on_board_id\"\n      t.index [\"creator_id\"], name: \"index_events_on_creator_id\"\n      t.index [\"eventable_type\", \"eventable_id\"], name: \"index_events_on_eventable\"\n    end\n\n    create_table \"filters\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.uuid \"account_id\"\n      t.datetime \"created_at\", null: false\n      t.uuid \"creator_id\", null: false\n      t.json \"fields\", default: -> { \"(json_object())\" }, null: false\n      t.string \"params_digest\", limit: 255, null: false\n      t.datetime \"updated_at\", null: false\n      t.index [\"creator_id\", \"params_digest\"], name: \"index_filters_on_creator_id_and_params_digest\", unique: true\n    end\n\n    create_table \"filters_tags\", id: false, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.uuid \"filter_id\", null: false\n      t.uuid \"tag_id\", null: false\n      t.index [\"filter_id\"], name: \"index_filters_tags_on_filter_id\"\n      t.index [\"tag_id\"], name: \"index_filters_tags_on_tag_id\"\n    end\n\n    create_table \"identities\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.datetime \"created_at\", null: false\n      t.string \"email_address\", limit: 255, null: false\n      t.datetime \"updated_at\", null: false\n      t.index [\"email_address\"], name: \"index_identities_on_email_address\", unique: true\n    end\n\n    create_table \"magic_links\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.string \"code\", limit: 255, null: false\n      t.datetime \"created_at\", null: false\n      t.datetime \"expires_at\", null: false\n      t.uuid \"identity_id\"\n      t.datetime \"updated_at\", null: false\n      t.index [\"code\"], name: \"index_magic_links_on_code\", unique: true\n      t.index [\"expires_at\"], name: \"index_magic_links_on_expires_at\"\n      t.index [\"identity_id\"], name: \"index_magic_links_on_identity_id\"\n    end\n\n    create_table \"memberships\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.datetime \"created_at\", null: false\n      t.uuid \"identity_id\", null: false\n      t.string \"join_code\", limit: 255\n      t.string \"tenant\", limit: 255, null: false\n      t.datetime \"updated_at\", null: false\n      t.index [\"identity_id\"], name: \"index_memberships_on_identity_id\"\n      t.index [\"tenant\", \"identity_id\"], name: \"index_memberships_on_tenant_and_identity_id\", unique: true\n      t.index [\"tenant\"], name: \"index_memberships_on_user_tenant_and_user_id\"\n    end\n\n    create_table \"mentions\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.datetime \"created_at\", null: false\n      t.uuid \"mentionee_id\", null: false\n      t.uuid \"mentioner_id\", null: false\n      t.uuid \"source_id\", null: false\n      t.string \"source_type\", limit: 255, null: false\n      t.datetime \"updated_at\", null: false\n      t.index [\"mentionee_id\"], name: \"index_mentions_on_mentionee_id\"\n      t.index [\"mentioner_id\"], name: \"index_mentions_on_mentioner_id\"\n      t.index [\"source_type\", \"source_id\"], name: \"index_mentions_on_source\"\n    end\n\n    create_table \"notification_bundles\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.uuid \"account_id\"\n      t.datetime \"created_at\", null: false\n      t.datetime \"ends_at\", null: false\n      t.datetime \"starts_at\", null: false\n      t.integer \"status\", default: 0, null: false\n      t.datetime \"updated_at\", null: false\n      t.uuid \"user_id\", null: false\n      t.index [\"ends_at\", \"status\"], name: \"index_notification_bundles_on_ends_at_and_status\"\n      t.index [\"user_id\", \"starts_at\", \"ends_at\"], name: \"idx_on_user_id_starts_at_ends_at_7eae5d3ac5\"\n      t.index [\"user_id\", \"status\"], name: \"index_notification_bundles_on_user_id_and_status\"\n    end\n\n    create_table \"notifications\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.uuid \"account_id\"\n      t.datetime \"created_at\", null: false\n      t.uuid \"creator_id\"\n      t.datetime \"read_at\"\n      t.uuid \"source_id\", null: false\n      t.string \"source_type\", limit: 255, null: false\n      t.datetime \"updated_at\", null: false\n      t.uuid \"user_id\", null: false\n      t.index [\"creator_id\"], name: \"index_notifications_on_creator_id\"\n      t.index [\"source_type\", \"source_id\"], name: \"index_notifications_on_source\"\n      t.index [\"user_id\", \"read_at\", \"created_at\"], name: \"index_notifications_on_user_id_and_read_at_and_created_at\", order: { read_at: :desc, created_at: :desc }\n      t.index [\"user_id\"], name: \"index_notifications_on_user_id\"\n    end\n\n    create_table \"pins\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.uuid \"card_id\", null: false\n      t.datetime \"created_at\", null: false\n      t.datetime \"updated_at\", null: false\n      t.uuid \"user_id\", null: false\n      t.index [\"card_id\", \"user_id\"], name: \"index_pins_on_card_id_and_user_id\", unique: true\n      t.index [\"card_id\"], name: \"index_pins_on_card_id\"\n      t.index [\"user_id\"], name: \"index_pins_on_user_id\"\n    end\n\n    create_table \"push_subscriptions\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.uuid \"account_id\"\n      t.string \"auth_key\", limit: 255\n      t.datetime \"created_at\", null: false\n      t.text \"endpoint\"\n      t.string \"p256dh_key\", limit: 255\n      t.datetime \"updated_at\", null: false\n      t.string \"user_agent\", limit: 255\n      t.uuid \"user_id\", null: false\n      t.index [\"endpoint\", \"p256dh_key\", \"auth_key\"], name: \"idx_on_endpoint_p256dh_key_auth_key_7553014576\"\n      t.index [\"endpoint\"], name: \"index_push_subscriptions_on_endpoint\"\n      t.index [\"user_agent\"], name: \"index_push_subscriptions_on_user_agent\"\n      t.index [\"user_id\", \"endpoint\"], name: \"index_push_subscriptions_on_user_id_and_endpoint\", unique: true\n      t.index [\"user_id\"], name: \"index_push_subscriptions_on_user_id\"\n    end\n\n    create_table \"reactions\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.uuid \"comment_id\", null: false\n      t.string \"content\", limit: 16, null: false\n      t.datetime \"created_at\", null: false\n      t.uuid \"reacter_id\", null: false\n      t.datetime \"updated_at\", null: false\n      t.index [\"comment_id\"], name: \"index_reactions_on_comment_id\"\n      t.index [\"reacter_id\"], name: \"index_reactions_on_reacter_id\"\n    end\n\n    create_table \"search_queries\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.datetime \"created_at\", null: false\n      t.string \"terms\", limit: 2000, null: false\n      t.datetime \"updated_at\", null: false\n      t.uuid \"user_id\", null: false\n      t.index [\"user_id\", \"terms\"], name: \"index_search_queries_on_user_id_and_terms\", length: { terms: 255 }\n      t.index [\"user_id\", \"updated_at\"], name: \"index_search_queries_on_user_id_and_updated_at\", unique: true\n      t.index [\"user_id\"], name: \"index_search_queries_on_user_id\"\n    end\n\n    create_table \"search_results\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.datetime \"created_at\", null: false\n      t.datetime \"updated_at\", null: false\n    end\n\n    create_table \"sessions\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.datetime \"created_at\", null: false\n      t.uuid \"identity_id\", null: false\n      t.string \"ip_address\", limit: 255\n      t.datetime \"updated_at\", null: false\n      t.string \"user_agent\", limit: 255\n      t.index [\"identity_id\"], name: \"index_sessions_on_identity_id\"\n    end\n\n    create_table \"steps\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.uuid \"account_id\"\n      t.uuid \"card_id\", null: false\n      t.boolean \"completed\", default: false, null: false\n      t.text \"content\", null: false\n      t.datetime \"created_at\", null: false\n      t.datetime \"updated_at\", null: false\n      t.index [\"card_id\", \"completed\"], name: \"index_steps_on_card_id_and_completed\"\n      t.index [\"card_id\"], name: \"index_steps_on_card_id\"\n    end\n\n    create_table \"taggings\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.uuid \"card_id\", null: false\n      t.datetime \"created_at\", null: false\n      t.uuid \"tag_id\", null: false\n      t.datetime \"updated_at\", null: false\n      t.index [\"card_id\", \"tag_id\"], name: \"index_taggings_on_card_id_and_tag_id\", unique: true\n      t.index [\"tag_id\"], name: \"index_taggings_on_tag_id\"\n    end\n\n    create_table \"tags\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.uuid \"account_id\"\n      t.datetime \"created_at\", null: false\n      t.string \"title\", limit: 255\n      t.datetime \"updated_at\", null: false\n      t.index [\"title\"], name: \"index_tags_on_title\", unique: true\n    end\n\n    create_table \"user_settings\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.integer \"bundle_email_frequency\", default: 0, null: false\n      t.datetime \"created_at\", null: false\n      t.string \"timezone_name\", limit: 255\n      t.datetime \"updated_at\", null: false\n      t.uuid \"user_id\", null: false\n      t.index [\"user_id\", \"bundle_email_frequency\"], name: \"index_user_settings_on_user_id_and_bundle_email_frequency\"\n      t.index [\"user_id\"], name: \"index_user_settings_on_user_id\"\n    end\n\n    create_table \"users\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.uuid \"account_id\"\n      t.boolean \"active\", default: true, null: false\n      t.datetime \"created_at\", null: false\n      t.uuid \"membership_id\"\n      t.string \"name\", limit: 255, null: false\n      t.string \"role\", limit: 255, default: \"member\", null: false\n      t.datetime \"updated_at\", null: false\n      t.index [\"membership_id\"], name: \"index_users_on_membership_id\"\n      t.index [\"role\"], name: \"index_users_on_role\"\n    end\n\n    create_table \"watches\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.uuid \"card_id\", null: false\n      t.datetime \"created_at\", null: false\n      t.datetime \"updated_at\", null: false\n      t.uuid \"user_id\", null: false\n      t.boolean \"watching\", default: true, null: false\n      t.index [\"card_id\"], name: \"index_watches_on_card_id\"\n      t.index [\"user_id\", \"card_id\"], name: \"index_watches_on_user_id_and_card_id\"\n      t.index [\"user_id\"], name: \"index_watches_on_user_id\"\n    end\n\n    create_table \"webhook_delinquency_trackers\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.integer \"consecutive_failures_count\", default: 0\n      t.datetime \"created_at\", null: false\n      t.datetime \"first_failure_at\"\n      t.datetime \"updated_at\", null: false\n      t.uuid \"webhook_id\", null: false\n      t.index [\"webhook_id\"], name: \"index_webhook_delinquency_trackers_on_webhook_id\"\n    end\n\n    create_table \"webhook_deliveries\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.datetime \"created_at\", null: false\n      t.uuid \"event_id\", null: false\n      t.text \"request\"\n      t.text \"response\"\n      t.string \"state\", limit: 255, null: false\n      t.datetime \"updated_at\", null: false\n      t.uuid \"webhook_id\", null: false\n      t.index [\"event_id\"], name: \"index_webhook_deliveries_on_event_id\"\n      t.index [\"webhook_id\"], name: \"index_webhook_deliveries_on_webhook_id\"\n    end\n\n    create_table \"webhooks\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n      t.uuid \"account_id\"\n      t.boolean \"active\", default: true, null: false\n      t.uuid \"board_id\", null: false\n      t.datetime \"created_at\", null: false\n      t.string \"name\", limit: 255\n      t.string \"signing_secret\", limit: 255, null: false\n      t.text \"subscribed_actions\"\n      t.datetime \"updated_at\", null: false\n      t.text \"url\", null: false\n      t.index [\"board_id\"], name: \"index_webhooks_on_board_id\"\n      t.index [\"subscribed_actions\"], name: \"index_webhooks_on_subscribed_actions\", length: 255\n    end\n\n    add_foreign_key \"active_storage_attachments\", \"active_storage_blobs\", column: \"blob_id\"\n    add_foreign_key \"active_storage_variant_records\", \"active_storage_blobs\", column: \"blob_id\"\n    add_foreign_key \"board_publications\", \"boards\"\n    add_foreign_key \"card_activity_spikes\", \"cards\"\n    add_foreign_key \"card_goldnesses\", \"cards\"\n    add_foreign_key \"card_not_nows\", \"cards\"\n    add_foreign_key \"card_not_nows\", \"users\"\n    add_foreign_key \"cards\", \"columns\"\n    add_foreign_key \"closures\", \"cards\"\n    add_foreign_key \"closures\", \"users\"\n    add_foreign_key \"columns\", \"boards\"\n    add_foreign_key \"comments\", \"cards\"\n    add_foreign_key \"events\", \"boards\"\n    add_foreign_key \"magic_links\", \"identities\"\n    add_foreign_key \"memberships\", \"identities\"\n    add_foreign_key \"mentions\", \"users\", column: \"mentionee_id\"\n    add_foreign_key \"mentions\", \"users\", column: \"mentioner_id\"\n    add_foreign_key \"notification_bundles\", \"users\"\n    add_foreign_key \"notifications\", \"users\"\n    add_foreign_key \"notifications\", \"users\", column: \"creator_id\"\n    add_foreign_key \"pins\", \"cards\"\n    add_foreign_key \"pins\", \"users\"\n    add_foreign_key \"push_subscriptions\", \"users\"\n    add_foreign_key \"search_queries\", \"users\"\n    add_foreign_key \"sessions\", \"identities\"\n    add_foreign_key \"steps\", \"cards\"\n    add_foreign_key \"taggings\", \"cards\"\n    add_foreign_key \"taggings\", \"tags\"\n    add_foreign_key \"user_settings\", \"users\"\n    add_foreign_key \"watches\", \"cards\"\n    add_foreign_key \"watches\", \"users\"\n    add_foreign_key \"webhook_delinquency_trackers\", \"webhooks\"\n    add_foreign_key \"webhook_deliveries\", \"events\"\n    add_foreign_key \"webhook_deliveries\", \"webhooks\"\n    add_foreign_key \"webhooks\", \"boards\"\n  end\nend\n"
  },
  {
    "path": "db/migrate/20251111153019_add_number_to_cards.rb",
    "content": "class AddNumberToCards < ActiveRecord::Migration[8.2]\n  def change\n    add_column :cards, :number, :bigint, null: false\n    add_column :accounts, :cards_count, :bigint, default: 0, null: false\n    add_index :cards, [:account_id, :number], unique: true\n  end\nend\n"
  },
  {
    "path": "db/migrate/20251112093037_create_search_indices.rb",
    "content": "class CreateSearchIndices < ActiveRecord::Migration[8.2]\n  def up\n    # Skip for SQLite - it doesn't use these tables\n    return if connection.adapter_name == \"SQLite\"\n\n    16.times do |i|\n      create_table \"search_index_#{i}\".to_sym, id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\" do |t|\n        t.string :searchable_type, null: false\n        t.uuid :searchable_id, null: false\n        t.uuid :card_id, null: false\n        t.uuid :board_id, null: false\n        t.string :title\n        t.text :content\n        t.datetime :created_at, null: false\n\n        t.index [:searchable_type, :searchable_id], unique: true, name: \"idx_si#{i}_type_id\"\n        t.index [:content, :title], type: :fulltext, name: \"idx_si#{i}_fulltext\"\n      end\n    end\n  end\n\n  def down\n    # Skip for SQLite - it doesn't use these tables\n    return if connection.adapter_name == \"SQLite\"\n\n    16.times do |i|\n      drop_table \"search_index_#{i}\".to_sym\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20251112184932_remove_join_code_from_memberships.rb",
    "content": "class RemoveJoinCodeFromMemberships < ActiveRecord::Migration[8.2]\n  def change\n    remove_column :memberships, :join_code, :string\n  end\nend\n"
  },
  {
    "path": "db/migrate/20251113111501_drop_memberships.rb",
    "content": "class DropMemberships < ActiveRecord::Migration[8.2]\n  def change\n    add_reference :users, :identity, type: :uuid, null: true, foreign_key: true\n    remove_column :users, :membership_id, :bigint\n    drop_table :memberships\n  end\nend\n"
  },
  {
    "path": "db/migrate/20251113160907_add_missing_account_id_columns.rb",
    "content": "class AddMissingAccountIdColumns < ActiveRecord::Migration[8.2]\n  MISSING_TABLES= %w[\n    accesses\n    assignments\n    board_publications\n    card_activity_spikes\n    card_engagements\n    card_goldnesses\n    card_not_nows\n    closures\n    entropies\n    mentions\n    pins\n    reactions\n    search_queries\n    taggings\n    user_settings\n    watches\n    webhook_delinquency_trackers\n    webhook_deliveries\n\n    action_text_rich_texts\n    active_storage_attachments\n    active_storage_blobs\n    active_storage_variant_records\n  ]\n\n  NOT_REQUIRED_TABLES = %w[\n    account_join_codes\n    boards\n    cards\n    columns\n    comments\n    events\n    filters\n    notification_bundles\n    notifications\n    push_subscriptions\n    steps\n    tags\n    users\n    webhooks\n  ]\n\n  def change\n    MISSING_TABLES.each do |table|\n      add_column table, \"account_id\", :uuid, null: false\n    end\n\n    NOT_REQUIRED_TABLES.each do |table|\n      change_column table, \"account_id\", :uuid, null: false\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20251113163145_ensure_account_id_index.rb",
    "content": "class EnsureAccountIdIndex < ActiveRecord::Migration[8.2]\n  def change\n    remove_index :accesses, :accessed_at\n    add_index :accesses, [:account_id, :accessed_at]\n\n    remove_index :account_join_codes, :code\n    add_index :account_join_codes, [:account_id, :code], unique: true\n\n    add_index :assignments, :account_id\n\n    remove_index :board_publications, :key\n    add_index :board_publications, [:account_id, :key]\n\n    add_index :boards, :account_id\n\n    add_index :card_activity_spikes, :account_id\n\n    remove_index :card_engagements, :status\n    add_index :card_engagements, [:account_id, :status]\n\n    add_index :card_goldnesses, :account_id\n\n    add_index :card_not_nows, :account_id\n\n    remove_index :cards, [:last_active_at, :status]\n    add_index :cards, [:account_id, :last_active_at, :status]\n\n    add_index :closures, :account_id\n\n    add_index :columns, :account_id\n\n    add_index :comments, :account_id\n\n    add_index :entropies, :account_id\n\n    remove_index :events, :action\n    add_index :events, [:account_id, :action]\n\n    add_index :filters, :account_id\n\n    add_index :mentions, :account_id\n\n    add_index :notification_bundles, :account_id\n\n    add_index :notifications, :account_id\n\n    add_index :pins, :account_id\n\n    add_index :push_subscriptions, :account_id\n    remove_index :push_subscriptions, :endpoint # duplicative because we always query [user_id, endpoint]\n    remove_index :push_subscriptions, [:endpoint, :p256dh_key, :auth_key] # duplicative and not necessary\n    remove_index :push_subscriptions, :user_agent # not necessary\n    remove_index :push_subscriptions, :user_id # duplicative of [user_id, endpoint]\n\n    add_index :reactions, :account_id\n\n    add_index :search_queries, :account_id\n\n    add_index :steps, :account_id\n\n    add_index :taggings, :account_id\n\n    remove_index :tags, :title\n    add_index :tags, [:account_id, :title], unique: true\n\n    add_index :user_settings, :account_id\n\n    remove_index :users, :role\n    add_index :users, [:account_id, :role]\n\n    add_index :watches, :account_id\n\n    add_index :webhook_delinquency_trackers, :account_id\n\n    add_index :webhook_deliveries, :account_id\n\n    # For webhooks, I'm making an additional change to collapse board_id and subscribed_actions into\n    # a single index, since Triggerable only queries for `subscribed_actions` in conjunction with\n    # `board_id`.\n    add_index :webhooks, :account_id\n    remove_index :webhooks, :subscribed_actions\n    add_index :webhooks, [:board_id, :subscribed_actions], length: { subscribed_actions: 255 }\n    remove_index :webhooks, :board_id\n\n    # Rails models\n    add_index :action_text_rich_texts, :account_id\n    add_index :active_storage_attachments, :account_id\n    add_index :active_storage_blobs, :account_id\n    add_index :active_storage_variant_records, :account_id\n  end\nend\n"
  },
  {
    "path": "db/migrate/20251113190256_create_search_record_shards.rb",
    "content": "class CreateSearchRecordShards < ActiveRecord::Migration[8.2]\n  SHARD_COUNT = 16\n\n  def change\n    # Skip for SQLite - it uses a single search_records table instead\n    return if connection.adapter_name == \"SQLite\"\n\n    # Create 16 sharded search_records tables\n    SHARD_COUNT.times do |shard_id|\n      create_table \"search_records_#{shard_id}\", id: :uuid do |t|\n        t.uuid :account_id, null: false\n        t.string :searchable_type, null: false\n        t.uuid :searchable_id, null: false\n        t.uuid :card_id, null: false\n        t.uuid :board_id, null: false\n        t.string :title\n        t.text :content\n        t.datetime :created_at, null: false\n\n        t.index [:searchable_type, :searchable_id], unique: true\n        t.index :account_id\n        t.index [:content, :title], type: :fulltext\n      end\n    end\n\n    # Drop old search_index tables\n    SHARD_COUNT.times do |shard_id|\n      drop_table \"search_index_#{shard_id}\", if_exists: true do |t|\n        t.string :searchable_type, null: false\n        t.uuid :searchable_id, null: false\n        t.uuid :card_id, null: false\n        t.uuid :board_id, null: false\n        t.string :title\n        t.text :content\n        t.datetime :created_at, null: false\n\n        t.index [:searchable_type, :searchable_id], unique: true, name: \"idx_si#{shard_id}_type_id\"\n        t.index [:content, :title], type: :fulltext, name: \"idx_si#{shard_id}_fulltext\"\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20251114084325_drop_search_results.rb",
    "content": "class DropSearchResults < ActiveRecord::Migration[8.2]\n  def change\n    drop_table :search_results do |t|\n      t.timestamps\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20251114183203_ensure_an_identit_can_only_have_one_user_in_an_account.rb",
    "content": "class EnsureAnIdentitCanOnlyHaveOneUserInAnAccount < ActiveRecord::Migration[8.2]\n  def change\n    add_index :users, [:account_id, :identity_id], unique: true\n  end\nend\n"
  },
  {
    "path": "db/migrate/20251117190817_change_endpoint_to_text_in_push_subscriptions.rb",
    "content": "class ChangeEndpointToTextInPushSubscriptions < ActiveRecord::Migration[8.2]\n  def change\n    # Remove foreign key first, then the index\n    remove_foreign_key :push_subscriptions, :users\n    remove_index :push_subscriptions, column: [:user_id, :endpoint]\n\n    # Change the column type\n    change_column :push_subscriptions, :endpoint, :text\n\n    # Re-add the index and foreign key\n    add_index :push_subscriptions, [:user_id, :endpoint], unique: true, length: { endpoint: 255 }\n    add_foreign_key :push_subscriptions, :users\n  end\nend\n"
  },
  {
    "path": "db/migrate/20251117192434_change_external_account_id_to_bigint_in_accounts.rb",
    "content": "class ChangeExternalAccountIdToBigintInAccounts < ActiveRecord::Migration[8.2]\n  def change\n    change_column :accounts, :external_account_id, :bigint\n  end\nend\n"
  },
  {
    "path": "db/migrate/20251117202517_change_usage_limit_to_bigint_in_account_join_codes.rb",
    "content": "class ChangeUsageLimitToBigintInAccountJoinCodes < ActiveRecord::Migration[8.2]\n  def change\n    change_column :account_join_codes, :usage_count, :bigint\n    change_column :account_join_codes, :usage_limit, :bigint\n  end\nend\n"
  },
  {
    "path": "db/migrate/20251120110206_add_search_records.rb",
    "content": "class AddSearchRecords < ActiveRecord::Migration[8.2]\n  def up\n    return unless connection.adapter_name == \"SQLite\"\n\n    # Create regular table with integer primary key for FTS5 rowid compatibility\n    create_table :search_records do |t|\n      t.uuid :account_id, null: false\n      t.string :searchable_type, limit: 255, null: false\n      t.uuid :searchable_id, null: false\n      t.uuid :card_id, null: false\n      t.uuid :board_id, null: false\n      t.string :title, limit: 255\n      t.text :content\n      t.datetime :created_at, null: false\n\n      t.index [:searchable_type, :searchable_id], unique: true\n      t.index :account_id\n    end\n\n    # Create FTS5 virtual table using Porter stemmer\n    # No triggers needed - Searchable concern handles sync via callbacks\n    execute <<-SQL\n      CREATE VIRTUAL TABLE search_records_fts USING fts5(\n        title,\n        content,\n        tokenize='porter'\n      )\n    SQL\n  end\n\n  def down\n    return unless connection.adapter_name == \"SQLite\"\n\n    execute \"DROP TABLE IF EXISTS search_records_fts\"\n    drop_table :search_records, if_exists: true\n  end\nend\n"
  },
  {
    "path": "db/migrate/20251120194700_remove_all_foreign_key_constraints.rb",
    "content": "class RemoveAllForeignKeyConstraints < ActiveRecord::Migration[8.2]\n  def change\n    remove_foreign_key \"active_storage_attachments\", \"active_storage_blobs\", column: \"blob_id\" rescue nil\n    remove_foreign_key \"active_storage_variant_records\", \"active_storage_blobs\", column: \"blob_id\" rescue nil\n    remove_foreign_key \"board_publications\", \"boards\" rescue nil\n    remove_foreign_key \"card_activity_spikes\", \"cards\" rescue nil\n    remove_foreign_key \"card_goldnesses\", \"cards\" rescue nil\n    remove_foreign_key \"card_not_nows\", \"cards\" rescue nil\n    remove_foreign_key \"card_not_nows\", \"users\" rescue nil\n    remove_foreign_key \"cards\", \"columns\" rescue nil\n    remove_foreign_key \"closures\", \"cards\" rescue nil\n    remove_foreign_key \"closures\", \"users\" rescue nil\n    remove_foreign_key \"columns\", \"boards\" rescue nil\n    remove_foreign_key \"comments\", \"cards\" rescue nil\n    remove_foreign_key \"events\", \"boards\" rescue nil\n    remove_foreign_key \"magic_links\", \"identities\" rescue nil\n    remove_foreign_key \"mentions\", \"users\", column: \"mentionee_id\" rescue nil\n    remove_foreign_key \"mentions\", \"users\", column: \"mentioner_id\" rescue nil\n    remove_foreign_key \"notification_bundles\", \"users\" rescue nil\n    remove_foreign_key \"notifications\", \"users\" rescue nil\n    remove_foreign_key \"notifications\", \"users\", column: \"creator_id\" rescue nil\n    remove_foreign_key \"pins\", \"cards\" rescue nil\n    remove_foreign_key \"pins\", \"users\" rescue nil\n    remove_foreign_key \"push_subscriptions\", \"users\" rescue nil\n    remove_foreign_key \"search_queries\", \"users\" rescue nil\n    remove_foreign_key \"sessions\", \"identities\" rescue nil\n    remove_foreign_key \"steps\", \"cards\" rescue nil\n    remove_foreign_key \"taggings\", \"cards\" rescue nil\n    remove_foreign_key \"taggings\", \"tags\" rescue nil\n    remove_foreign_key \"user_settings\", \"users\" rescue nil\n    remove_foreign_key \"users\", \"identities\" rescue nil\n    remove_foreign_key \"watches\", \"cards\" rescue nil\n    remove_foreign_key \"watches\", \"users\" rescue nil\n    remove_foreign_key \"webhook_delinquency_trackers\", \"webhooks\" rescue nil\n    remove_foreign_key \"webhook_deliveries\", \"events\" rescue nil\n    remove_foreign_key \"webhook_deliveries\", \"webhooks\" rescue nil\n    remove_foreign_key \"webhooks\", \"boards\" rescue nil\n  end\nend\n"
  },
  {
    "path": "db/migrate/20251120203100_add_unique_index_to_card_activity_spikes_on_card_id.rb",
    "content": "class AddUniqueIndexToCardActivitySpikesOnCardId < ActiveRecord::Migration[8.2]\n  def change\n    if ActiveRecord::Base.connection.adapter_name != \"SQLite\"\n      reversible do |dir|\n        dir.up do\n          execute <<-SQL\n            DELETE s1 FROM card_activity_spikes s1\n            INNER JOIN card_activity_spikes s2\n            WHERE s1.card_id = s2.card_id\n            AND s1.updated_at < s2.updated_at\n          SQL\n        end\n      end\n    end\n\n    remove_index :card_activity_spikes, :card_id\n    add_index :card_activity_spikes, :card_id, unique: true\n  end\nend\n"
  },
  {
    "path": "db/migrate/20251121092508_add_account_key_to_search_records.rb",
    "content": "class AddAccountKeyToSearchRecords < ActiveRecord::Migration[8.2]\n  def up\n    return if ActiveRecord::Base.connection.adapter_name == \"SQLite\"\n\n    16.times do |shard_id|\n      table_name = \"search_records_#{shard_id}\"\n\n      add_column table_name, :account_key, :string, null: false, default: \"\"\n      add_index table_name, [:account_key, :content, :title], type: :fulltext\n    end\n  end\n\n  def down\n    return if ActiveRecord::Base.connection.adapter_name == \"SQLite\"\n\n    16.times do |shard_id|\n      table_name = \"search_records_#{shard_id}\"\n\n      remove_index table_name, column: [:account_key, :content, :title], type: :fulltext\n      remove_column table_name, :account_key\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20251121112416_remove_old_fulltext_indexes_from_search_records.rb",
    "content": "class RemoveOldFulltextIndexesFromSearchRecords < ActiveRecord::Migration[8.2]\n  def up\n    return if ActiveRecord::Base.connection.adapter_name == \"SQLite\"\n\n    (0..15).each do |shard|\n      remove_index \"search_records_#{shard}\", name: \"index_search_records_#{shard}_on_content_and_title\"\n    end\n  end\n\n  def down\n    return if ActiveRecord::Base.connection.adapter_name == \"SQLite\"\n\n    (0..15).each do |shard|\n      add_index \"search_records_#{shard}\", [ :content, :title ], type: :fulltext, name: \"index_search_records_#{shard}_on_content_and_title\"\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20251125110629_increase_user_agent_length.rb",
    "content": "class IncreaseUserAgentLength < ActiveRecord::Migration[8.2]\n  def change\n    change_column :sessions, :user_agent, :string, limit: 4096\n    change_column :push_subscriptions, :user_agent, :string, limit: 4096\n  end\nend\n"
  },
  {
    "path": "db/migrate/20251125130010_add_a_staff_flag_to_identities.rb",
    "content": "class AddAStaffFlagToIdentities < ActiveRecord::Migration[8.2]\n  def change\n    add_column :identities, :staff, :boolean, null: false, default: false\n  end\nend\n"
  },
  {
    "path": "db/migrate/20251127000001_create_account_external_id_sequences.rb",
    "content": "class CreateAccountExternalIdSequences < ActiveRecord::Migration[8.0]\n  def change\n    create_table :account_external_id_sequences, id: :uuid do |t|\n      t.bigint :value, null: false, default: 0\n\n      t.index :value, unique: true\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20251129110120_add_purpose_to_magic_links.rb",
    "content": "class AddPurposeToMagicLinks < ActiveRecord::Migration[8.2]\n  def change\n    add_column :magic_links, :purpose, :integer, null: true\n\n    execute <<-SQL\n      UPDATE magic_links SET purpose = 0\n    SQL\n\n    change_column_null :magic_links, :purpose, false\n  end\nend\n"
  },
  {
    "path": "db/migrate/20251129175717_promote_first_admin_to_owner.rb",
    "content": "class PromoteFirstAdminToOwner < ActiveRecord::Migration[8.2]\n  def up\n    Account.find_each do |account|\n      next if account.users.exists?(role: :owner)\n\n      first_admin = account.users.where(role: :admin).order(:created_at).first\n      first_admin&.update!(role: :owner)\n    end\n  end\n\n  def down\n    User.where(role: :owner).update_all(role: :admin)\n  end\nend\n"
  },
  {
    "path": "db/migrate/20251201100607_create_account_exports.rb",
    "content": "class CreateAccountExports < ActiveRecord::Migration[8.2]\n  def change\n    create_table :account_exports, id: :uuid do |t|\n      t.uuid :account_id, null: false\n      t.uuid :user_id, null: false\n      t.string :status, default: \"pending\", null: false\n      t.datetime :completed_at\n      t.timestamps\n\n      t.index :account_id\n      t.index :user_id\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20251201132341_create_identity_access_tokens.rb",
    "content": "class CreateIdentityAccessTokens < ActiveRecord::Migration[8.2]\n  def change\n    create_table :identity_access_tokens, id: :uuid do |t|\n      t.uuid :identity_id, null: false\n      t.string :token\n      t.string :permission\n      t.text :description\n\n      t.timestamps\n\n      t.index [\"identity_id\"], name: \"index_access_token_on_identity_id\"\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20251205010536_add_verified_at_to_users.rb",
    "content": "class AddVerifiedAtToUsers < ActiveRecord::Migration[8.2]\n  def change\n    add_column :users, :verified_at, :datetime\n  end\nend\n"
  },
  {
    "path": "db/migrate/20251205205826_create_storage_tables.rb",
    "content": "class CreateStorageTables < ActiveRecord::Migration[8.0]\n  def change\n    # Storage ledger: debit/credit event stream\n    create_table :storage_entries, id: :uuid do |t|\n      t.references :account, type: :uuid, null: false\n      t.references :board, type: :uuid, null: true\n\n      t.references :recordable, type: :uuid, polymorphic: true, null: true\n\n      t.bigint :delta, null: false\n      t.string :operation, null: false\n\n      t.datetime :created_at, null: false\n    end\n\n    # Storage totals: cached snapshots\n    create_table :storage_totals, id: :uuid do |t|\n      t.references :owner, type: :uuid, polymorphic: true, null: false, index: false\n\n      t.bigint :bytes_stored, null: false, default: 0\n      t.uuid :last_entry_id  # Cursor: includes all entries <= this ID\n\n      t.timestamps\n      t.index %i[ owner_type owner_id ], unique: true\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20251210054934_add_blob_id_and_audit_context_to_storage_entries.rb",
    "content": "class AddBlobIdAndAuditContextToStorageEntries < ActiveRecord::Migration[8.2]\n  def change\n    change_table :storage_entries do |t|\n      t.references :blob, type: :uuid, foreign_key: false, index: true\n      t.references :user, type: :uuid, foreign_key: false, index: true\n      t.string :request_id, index: true\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20251219120755_drop_card_engagements.rb",
    "content": "class DropCardEngagements < ActiveRecord::Migration[8.2]\n  def up\n    drop_table :card_engagements\n  end\n\n  def down\n    create_table :card_engagements, id: :uuid do |t|\n      t.references :account, type: :uuid, null: false\n      t.references :card, type: :uuid, null: false, index: true\n      t.string :status, null: false\n      t.timestamps\n    end\n\n    add_index :card_engagements, [ :account_id, :status ]\n  end\nend\n"
  },
  {
    "path": "db/migrate/20251223000001_rename_account_exports_to_exports.rb",
    "content": "class RenameAccountExportsToExports < ActiveRecord::Migration[8.2]\n  def change\n    rename_table :account_exports, :exports\n    add_column :exports, :type, :string\n    add_index :exports, :type\n  end\nend\n"
  },
  {
    "path": "db/migrate/20251223000002_create_account_imports.rb",
    "content": "class CreateAccountImports < ActiveRecord::Migration[8.2]\n  def change\n    create_table :account_imports, id: :uuid do |t|\n      t.uuid :identity_id, null: false\n      t.uuid :account_id\n      t.string :status, default: \"pending\", null: false\n      t.datetime :completed_at\n      t.timestamps\n\n      t.index :identity_id\n      t.index :account_id\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20251224092315_create_account_cancellations.rb",
    "content": "class CreateAccountCancellations < ActiveRecord::Migration[8.2]\n  def change\n    create_table :account_cancellations, id: :uuid do |t|\n      t.uuid :account_id, null: false, index: { unique: true }\n      t.uuid :initiated_by_id, null: false\n\n      t.timestamps\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20260121155752_make_reactions_polymorphic.rb",
    "content": "class MakeReactionsPolymorphic < ActiveRecord::Migration[8.0]\n  def change\n    add_column :reactions, :reactable_type, :string\n    add_column :reactions, :reactable_id, :uuid\n\n    reversible do |dir|\n      dir.up do\n        execute <<~SQL\n          UPDATE reactions SET reactable_type = 'Comment', reactable_id = comment_id\n        SQL\n      end\n\n      dir.down do\n        execute <<~SQL\n          UPDATE reactions SET comment_id = reactable_id WHERE reactable_type = 'Comment'\n        SQL\n      end\n    end\n\n    change_column_null :reactions, :reactable_type, false\n    change_column_null :reactions, :reactable_id, false\n\n    remove_column :reactions, :comment_id, :uuid\n\n    add_index :reactions, [:reactable_type, :reactable_id]\n  end\nend\n"
  },
  {
    "path": "db/migrate/20260206104338_add_card_id_to_notifications.rb",
    "content": "class AddCardIdToNotifications < ActiveRecord::Migration[8.2]\n  def change\n    add_column :notifications, :card_id, :uuid\n    add_column :notifications, :unread_count, :integer, null: false, default: 0\n  end\nend\n"
  },
  {
    "path": "db/migrate/20260209165805_notifications_data_migration.rb",
    "content": "class NotificationsDataMigration < ActiveRecord::Migration[8.2]\n  BATCH_SIZE = 10_000\n\n  class Notification < ActiveRecord::Base\n    self.table_name = \"notifications\"\n  end\n\n  def change\n    reversible do |dir|\n      dir.up do\n        populate_card_id\n        collapse_duplicates\n      end\n    end\n\n    change_column_null :notifications, :card_id, false\n    add_index :notifications, [ :user_id, :card_id ], unique: true\n  end\n\n  private\n    def populate_card_id\n      execute(<<~SQL)\n        UPDATE notifications\n        SET card_id = (\n          SELECT CASE events.eventable_type\n            WHEN 'Card' THEN events.eventable_id\n            WHEN 'Comment' THEN (SELECT comments.card_id FROM comments WHERE comments.id = events.eventable_id)\n          END\n          FROM events\n          WHERE events.id = notifications.source_id\n        )\n        WHERE notifications.card_id IS NULL\n          AND notifications.source_type = 'Event'\n      SQL\n\n      execute(<<~SQL)\n        UPDATE notifications\n        SET card_id = (\n          SELECT CASE mentions.source_type\n            WHEN 'Card' THEN mentions.source_id\n            WHEN 'Comment' THEN (SELECT comments.card_id FROM comments WHERE comments.id = mentions.source_id)\n          END\n          FROM mentions\n          WHERE mentions.id = notifications.source_id\n        )\n        WHERE notifications.card_id IS NULL\n          AND notifications.source_type = 'Mention'\n      SQL\n    end\n\n    def collapse_duplicates\n      loop do\n        duplicates = Notification.find_by_sql(<<~SQL)\n          SELECT user_id, card_id,\n                 MAX(id) AS keep_id,\n                 COUNT(*) AS total,\n                 SUM(CASE WHEN read_at IS NULL THEN 1 ELSE 0 END) AS unread_total\n          FROM notifications\n          WHERE card_id IS NOT NULL\n          GROUP BY user_id, card_id\n          HAVING COUNT(*) > 1\n          LIMIT #{BATCH_SIZE}\n        SQL\n\n        break if duplicates.empty?\n\n        duplicates.each do |row|\n          Notification.where(user_id: row.user_id, card_id: row.card_id)\n            .where.not(id: row.keep_id)\n            .delete_all\n\n          Notification.where(id: row.keep_id)\n            .update_all(unread_count: row.unread_total.to_i)\n        end\n      end\n\n      # Set unread_count for remaining non-collapsed notifications\n      execute(<<~SQL)\n        UPDATE notifications\n        SET unread_count = CASE WHEN read_at IS NULL THEN 1 ELSE 0 END\n        WHERE unread_count = 0 AND card_id IS NOT NULL\n      SQL\n    end\nend\n"
  },
  {
    "path": "db/migrate/20260211122517_add_failure_reason_to_account_imports.rb",
    "content": "class AddFailureReasonToAccountImports < ActiveRecord::Migration[8.2]\n  def change\n    add_column :account_imports, :failure_reason, :string\n  end\nend\n"
  },
  {
    "path": "db/migrate/20260212102026_fix_notifications_ordered_index.rb",
    "content": "class FixNotificationsOrderedIndex < ActiveRecord::Migration[8.2]\n  def change\n    add_index :notifications, [ :user_id, :read_at, :updated_at ],\n      order: { read_at: :desc, updated_at: :desc },\n      name: \"index_notifications_on_user_id_and_read_at_and_updated_at\"\n    remove_index :notifications, name: \"index_notifications_on_user_id_and_read_at_and_created_at\"\n  end\nend\n"
  },
  {
    "path": "db/migrate/20260213154740_create_action_pack_passkeys.rb",
    "content": "class CreateActionPackPasskeys < ActiveRecord::Migration[8.2]\n  def change\n    create_table :action_pack_passkeys, id: :uuid do |t|\n      t.uuid :holder_id, null: false\n      t.string :holder_type, null: false\n      t.string :credential_id, null: false\n      t.binary :public_key, null: false\n      t.integer :sign_count, null: false, default: 0\n      t.string :name\n      t.text :transports\n      t.string :aaguid\n      t.boolean :backed_up\n\n      t.timestamps\n\n      t.index [ :holder_type, :holder_id ]\n      t.index :credential_id, unique: true\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20260213170100_add_created_at_index_to_webhook_deliveries.rb",
    "content": "class AddCreatedAtIndexToWebhookDeliveries < ActiveRecord::Migration[8.2]\n  def change\n    add_index :webhook_deliveries, :created_at\n  end\nend\n"
  },
  {
    "path": "db/migrate/20260218120000_restore_unique_index_on_board_publication_key.rb",
    "content": "class RestoreUniqueIndexOnBoardPublicationKey < ActiveRecord::Migration[8.2]\n  def change\n    add_index :board_publications, :key, unique: true\n    add_index :board_publications, :account_id\n    remove_index :board_publications, [:account_id, :key]\n  end\nend\n"
  },
  {
    "path": "db/queue_schema.rb",
    "content": "# This file is auto-generated from the current state of the database. Instead\n# of editing this file, please use the migrations feature of Active Record to\n# incrementally modify your database, and then regenerate this schema definition.\n#\n# This file is the source Rails uses to define your schema when running `bin/rails\n# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to\n# be faster and is potentially less error prone than running all of your\n# migrations from scratch. Old migrations may fail to apply correctly if those\n# migrations use external dependencies or application code.\n#\n# It's strongly recommended that you check this file into your version control system.\n\nActiveRecord::Schema[8.2].define(version: 1) do\n  create_table \"solid_queue_blocked_executions\", charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.string \"concurrency_key\", null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"expires_at\", null: false\n    t.bigint \"job_id\", null: false\n    t.integer \"priority\", default: 0, null: false\n    t.string \"queue_name\", null: false\n    t.index [\"concurrency_key\", \"priority\", \"job_id\"], name: \"index_solid_queue_blocked_executions_for_release\"\n    t.index [\"expires_at\", \"concurrency_key\"], name: \"index_solid_queue_blocked_executions_for_maintenance\"\n    t.index [\"job_id\"], name: \"index_solid_queue_blocked_executions_on_job_id\", unique: true\n  end\n\n  create_table \"solid_queue_claimed_executions\", charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.datetime \"created_at\", null: false\n    t.bigint \"job_id\", null: false\n    t.bigint \"process_id\"\n    t.index [\"job_id\"], name: \"index_solid_queue_claimed_executions_on_job_id\", unique: true\n    t.index [\"process_id\", \"job_id\"], name: \"index_solid_queue_claimed_executions_on_process_id_and_job_id\"\n  end\n\n  create_table \"solid_queue_failed_executions\", charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.datetime \"created_at\", null: false\n    t.text \"error\"\n    t.bigint \"job_id\", null: false\n    t.index [\"job_id\"], name: \"index_solid_queue_failed_executions_on_job_id\", unique: true\n  end\n\n  create_table \"solid_queue_jobs\", charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.string \"active_job_id\"\n    t.text \"arguments\"\n    t.string \"class_name\", null: false\n    t.string \"concurrency_key\"\n    t.datetime \"created_at\", null: false\n    t.datetime \"finished_at\"\n    t.integer \"priority\", default: 0, null: false\n    t.string \"queue_name\", null: false\n    t.datetime \"scheduled_at\"\n    t.datetime \"updated_at\", null: false\n    t.index [\"active_job_id\"], name: \"index_solid_queue_jobs_on_active_job_id\"\n    t.index [\"class_name\"], name: \"index_solid_queue_jobs_on_class_name\"\n    t.index [\"finished_at\"], name: \"index_solid_queue_jobs_on_finished_at\"\n    t.index [\"queue_name\", \"finished_at\"], name: \"index_solid_queue_jobs_for_filtering\"\n    t.index [\"scheduled_at\", \"finished_at\"], name: \"index_solid_queue_jobs_for_alerting\"\n  end\n\n  create_table \"solid_queue_pauses\", charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.datetime \"created_at\", null: false\n    t.string \"queue_name\", null: false\n    t.index [\"queue_name\"], name: \"index_solid_queue_pauses_on_queue_name\", unique: true\n  end\n\n  create_table \"solid_queue_processes\", charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.datetime \"created_at\", null: false\n    t.string \"hostname\"\n    t.string \"kind\", null: false\n    t.datetime \"last_heartbeat_at\", null: false\n    t.text \"metadata\"\n    t.string \"name\", null: false\n    t.integer \"pid\", null: false\n    t.bigint \"supervisor_id\"\n    t.index [\"last_heartbeat_at\"], name: \"index_solid_queue_processes_on_last_heartbeat_at\"\n    t.index [\"name\", \"supervisor_id\"], name: \"index_solid_queue_processes_on_name_and_supervisor_id\", unique: true\n    t.index [\"supervisor_id\"], name: \"index_solid_queue_processes_on_supervisor_id\"\n  end\n\n  create_table \"solid_queue_ready_executions\", charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.datetime \"created_at\", null: false\n    t.bigint \"job_id\", null: false\n    t.integer \"priority\", default: 0, null: false\n    t.string \"queue_name\", null: false\n    t.index [\"job_id\"], name: \"index_solid_queue_ready_executions_on_job_id\", unique: true\n    t.index [\"priority\", \"job_id\"], name: \"index_solid_queue_poll_all\"\n    t.index [\"queue_name\", \"priority\", \"job_id\"], name: \"index_solid_queue_poll_by_queue\"\n  end\n\n  create_table \"solid_queue_recurring_executions\", charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.datetime \"created_at\", null: false\n    t.bigint \"job_id\", null: false\n    t.datetime \"run_at\", null: false\n    t.string \"task_key\", null: false\n    t.index [\"job_id\"], name: \"index_solid_queue_recurring_executions_on_job_id\", unique: true\n    t.index [\"task_key\", \"run_at\"], name: \"index_solid_queue_recurring_executions_on_task_key_and_run_at\", unique: true\n  end\n\n  create_table \"solid_queue_recurring_tasks\", charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.text \"arguments\"\n    t.string \"class_name\"\n    t.string \"command\", limit: 2048\n    t.datetime \"created_at\", null: false\n    t.text \"description\"\n    t.string \"key\", null: false\n    t.integer \"priority\", default: 0\n    t.string \"queue_name\"\n    t.string \"schedule\", null: false\n    t.boolean \"static\", default: true, null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"key\"], name: \"index_solid_queue_recurring_tasks_on_key\", unique: true\n    t.index [\"static\"], name: \"index_solid_queue_recurring_tasks_on_static\"\n  end\n\n  create_table \"solid_queue_scheduled_executions\", charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.datetime \"created_at\", null: false\n    t.bigint \"job_id\", null: false\n    t.integer \"priority\", default: 0, null: false\n    t.string \"queue_name\", null: false\n    t.datetime \"scheduled_at\", null: false\n    t.index [\"job_id\"], name: \"index_solid_queue_scheduled_executions_on_job_id\", unique: true\n    t.index [\"scheduled_at\", \"priority\", \"job_id\"], name: \"index_solid_queue_dispatch_all\"\n  end\n\n  create_table \"solid_queue_semaphores\", charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.datetime \"created_at\", null: false\n    t.datetime \"expires_at\", null: false\n    t.string \"key\", null: false\n    t.datetime \"updated_at\", null: false\n    t.integer \"value\", default: 1, null: false\n    t.index [\"expires_at\"], name: \"index_solid_queue_semaphores_on_expires_at\"\n    t.index [\"key\", \"value\"], name: \"index_solid_queue_semaphores_on_key_and_value\"\n    t.index [\"key\"], name: \"index_solid_queue_semaphores_on_key\", unique: true\n  end\n\n  add_foreign_key \"solid_queue_blocked_executions\", \"solid_queue_jobs\", column: \"job_id\", on_delete: :cascade\n  add_foreign_key \"solid_queue_claimed_executions\", \"solid_queue_jobs\", column: \"job_id\", on_delete: :cascade\n  add_foreign_key \"solid_queue_failed_executions\", \"solid_queue_jobs\", column: \"job_id\", on_delete: :cascade\n  add_foreign_key \"solid_queue_ready_executions\", \"solid_queue_jobs\", column: \"job_id\", on_delete: :cascade\n  add_foreign_key \"solid_queue_recurring_executions\", \"solid_queue_jobs\", column: \"job_id\", on_delete: :cascade\n  add_foreign_key \"solid_queue_scheduled_executions\", \"solid_queue_jobs\", column: \"job_id\", on_delete: :cascade\nend\n"
  },
  {
    "path": "db/schema.rb",
    "content": "# This file is auto-generated from the current state of the database. Instead\n# of editing this file, please use the migrations feature of Active Record to\n# incrementally modify your database, and then regenerate this schema definition.\n#\n# This file is the source Rails uses to define your schema when running `bin/rails\n# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to\n# be faster and is potentially less error prone than running all of your\n# migrations from scratch. Old migrations may fail to apply correctly if those\n# migrations use external dependencies or application code.\n#\n# It's strongly recommended that you check this file into your version control system.\n\nActiveRecord::Schema[8.2].define(version: 2026_02_18_120000) do\n  create_table \"accesses\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.datetime \"accessed_at\"\n    t.uuid \"account_id\", null: false\n    t.uuid \"board_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.string \"involvement\", default: \"access_only\", null: false\n    t.datetime \"updated_at\", null: false\n    t.uuid \"user_id\", null: false\n    t.index [\"account_id\", \"accessed_at\"], name: \"index_accesses_on_account_id_and_accessed_at\"\n    t.index [\"board_id\", \"user_id\"], name: \"index_accesses_on_board_id_and_user_id\", unique: true\n    t.index [\"board_id\"], name: \"index_accesses_on_board_id\"\n    t.index [\"user_id\"], name: \"index_accesses_on_user_id\"\n  end\n\n  create_table \"account_cancellations\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.uuid \"initiated_by_id\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"account_id\"], name: \"index_account_cancellations_on_account_id\", unique: true\n  end\n\n  create_table \"account_external_id_sequences\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.bigint \"value\", default: 0, null: false\n    t.index [\"value\"], name: \"index_account_external_id_sequences_on_value\", unique: true\n  end\n\n  create_table \"account_imports\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\"\n    t.datetime \"completed_at\"\n    t.datetime \"created_at\", null: false\n    t.string \"failure_reason\"\n    t.uuid \"identity_id\", null: false\n    t.string \"status\", default: \"pending\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"account_id\"], name: \"index_account_imports_on_account_id\"\n    t.index [\"identity_id\"], name: \"index_account_imports_on_identity_id\"\n  end\n\n  create_table \"account_join_codes\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.string \"code\", null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.bigint \"usage_count\", default: 0, null: false\n    t.bigint \"usage_limit\", default: 10, null: false\n    t.index [\"account_id\", \"code\"], name: \"index_account_join_codes_on_account_id_and_code\", unique: true\n  end\n\n  create_table \"accounts\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.bigint \"cards_count\", default: 0, null: false\n    t.datetime \"created_at\", null: false\n    t.bigint \"external_account_id\"\n    t.string \"name\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"external_account_id\"], name: \"index_accounts_on_external_account_id\", unique: true\n  end\n\n  create_table \"action_pack_passkeys\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.string \"aaguid\"\n    t.boolean \"backed_up\"\n    t.datetime \"created_at\", null: false\n    t.string \"credential_id\", null: false\n    t.uuid \"holder_id\", null: false\n    t.string \"holder_type\", null: false\n    t.string \"name\"\n    t.binary \"public_key\", null: false\n    t.integer \"sign_count\", default: 0, null: false\n    t.text \"transports\"\n    t.datetime \"updated_at\", null: false\n    t.index [\"credential_id\"], name: \"index_action_pack_passkeys_on_credential_id\", unique: true\n    t.index [\"holder_type\", \"holder_id\"], name: \"index_action_pack_passkeys_on_holder_type_and_holder_id\"\n  end\n\n  create_table \"action_text_rich_texts\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.text \"body\", size: :long\n    t.datetime \"created_at\", null: false\n    t.string \"name\", null: false\n    t.uuid \"record_id\", null: false\n    t.string \"record_type\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"account_id\"], name: \"index_action_text_rich_texts_on_account_id\"\n    t.index [\"record_type\", \"record_id\", \"name\"], name: \"index_action_text_rich_texts_uniqueness\", unique: true\n  end\n\n  create_table \"active_storage_attachments\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.uuid \"blob_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.string \"name\", null: false\n    t.uuid \"record_id\", null: false\n    t.string \"record_type\", null: false\n    t.index [\"account_id\"], name: \"index_active_storage_attachments_on_account_id\"\n    t.index [\"blob_id\"], name: \"index_active_storage_attachments_on_blob_id\"\n    t.index [\"record_type\", \"record_id\", \"name\", \"blob_id\"], name: \"index_active_storage_attachments_uniqueness\", unique: true\n  end\n\n  create_table \"active_storage_blobs\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.bigint \"byte_size\", null: false\n    t.string \"checksum\"\n    t.string \"content_type\"\n    t.datetime \"created_at\", null: false\n    t.string \"filename\", null: false\n    t.string \"key\", null: false\n    t.text \"metadata\"\n    t.string \"service_name\", null: false\n    t.index [\"account_id\"], name: \"index_active_storage_blobs_on_account_id\"\n    t.index [\"key\"], name: \"index_active_storage_blobs_on_key\", unique: true\n  end\n\n  create_table \"active_storage_variant_records\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.uuid \"blob_id\", null: false\n    t.string \"variation_digest\", null: false\n    t.index [\"account_id\"], name: \"index_active_storage_variant_records_on_account_id\"\n    t.index [\"blob_id\", \"variation_digest\"], name: \"index_active_storage_variant_records_uniqueness\", unique: true\n  end\n\n  create_table \"assignees_filters\", id: false, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"assignee_id\", null: false\n    t.uuid \"filter_id\", null: false\n    t.index [\"assignee_id\"], name: \"index_assignees_filters_on_assignee_id\"\n    t.index [\"filter_id\"], name: \"index_assignees_filters_on_filter_id\"\n  end\n\n  create_table \"assigners_filters\", id: false, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"assigner_id\", null: false\n    t.uuid \"filter_id\", null: false\n    t.index [\"assigner_id\"], name: \"index_assigners_filters_on_assigner_id\"\n    t.index [\"filter_id\"], name: \"index_assigners_filters_on_filter_id\"\n  end\n\n  create_table \"assignments\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.uuid \"assignee_id\", null: false\n    t.uuid \"assigner_id\", null: false\n    t.uuid \"card_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"account_id\"], name: \"index_assignments_on_account_id\"\n    t.index [\"assignee_id\", \"card_id\"], name: \"index_assignments_on_assignee_id_and_card_id\", unique: true\n    t.index [\"card_id\"], name: \"index_assignments_on_card_id\"\n  end\n\n  create_table \"board_publications\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.uuid \"board_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.string \"key\"\n    t.datetime \"updated_at\", null: false\n    t.index [\"account_id\"], name: \"index_board_publications_on_account_id\"\n    t.index [\"board_id\"], name: \"index_board_publications_on_board_id\"\n    t.index [\"key\"], name: \"index_board_publications_on_key\", unique: true\n  end\n\n  create_table \"boards\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.boolean \"all_access\", default: false, null: false\n    t.datetime \"created_at\", null: false\n    t.uuid \"creator_id\", null: false\n    t.string \"name\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"account_id\"], name: \"index_boards_on_account_id\"\n    t.index [\"creator_id\"], name: \"index_boards_on_creator_id\"\n  end\n\n  create_table \"boards_filters\", id: false, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"board_id\", null: false\n    t.uuid \"filter_id\", null: false\n    t.index [\"board_id\"], name: \"index_boards_filters_on_board_id\"\n    t.index [\"filter_id\"], name: \"index_boards_filters_on_filter_id\"\n  end\n\n  create_table \"card_activity_spikes\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.uuid \"card_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"account_id\"], name: \"index_card_activity_spikes_on_account_id\"\n    t.index [\"card_id\"], name: \"index_card_activity_spikes_on_card_id\", unique: true\n  end\n\n  create_table \"card_goldnesses\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.uuid \"card_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"account_id\"], name: \"index_card_goldnesses_on_account_id\"\n    t.index [\"card_id\"], name: \"index_card_goldnesses_on_card_id\", unique: true\n  end\n\n  create_table \"card_not_nows\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.uuid \"card_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.uuid \"user_id\"\n    t.index [\"account_id\"], name: \"index_card_not_nows_on_account_id\"\n    t.index [\"card_id\"], name: \"index_card_not_nows_on_card_id\", unique: true\n    t.index [\"user_id\"], name: \"index_card_not_nows_on_user_id\"\n  end\n\n  create_table \"cards\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.uuid \"board_id\", null: false\n    t.uuid \"column_id\"\n    t.datetime \"created_at\", null: false\n    t.uuid \"creator_id\", null: false\n    t.date \"due_on\"\n    t.datetime \"last_active_at\", null: false\n    t.bigint \"number\", null: false\n    t.string \"status\", default: \"drafted\", null: false\n    t.string \"title\"\n    t.datetime \"updated_at\", null: false\n    t.index [\"account_id\", \"last_active_at\", \"status\"], name: \"index_cards_on_account_id_and_last_active_at_and_status\"\n    t.index [\"account_id\", \"number\"], name: \"index_cards_on_account_id_and_number\", unique: true\n    t.index [\"board_id\"], name: \"index_cards_on_board_id\"\n    t.index [\"column_id\"], name: \"index_cards_on_column_id\"\n  end\n\n  create_table \"closers_filters\", id: false, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"closer_id\", null: false\n    t.uuid \"filter_id\", null: false\n    t.index [\"closer_id\"], name: \"index_closers_filters_on_closer_id\"\n    t.index [\"filter_id\"], name: \"index_closers_filters_on_filter_id\"\n  end\n\n  create_table \"closures\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.uuid \"card_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.uuid \"user_id\"\n    t.index [\"account_id\"], name: \"index_closures_on_account_id\"\n    t.index [\"card_id\", \"created_at\"], name: \"index_closures_on_card_id_and_created_at\"\n    t.index [\"card_id\"], name: \"index_closures_on_card_id\", unique: true\n    t.index [\"user_id\"], name: \"index_closures_on_user_id\"\n  end\n\n  create_table \"columns\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.uuid \"board_id\", null: false\n    t.string \"color\", null: false\n    t.datetime \"created_at\", null: false\n    t.string \"name\", null: false\n    t.integer \"position\", default: 0, null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"account_id\"], name: \"index_columns_on_account_id\"\n    t.index [\"board_id\", \"position\"], name: \"index_columns_on_board_id_and_position\"\n    t.index [\"board_id\"], name: \"index_columns_on_board_id\"\n  end\n\n  create_table \"comments\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.uuid \"card_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.uuid \"creator_id\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"account_id\"], name: \"index_comments_on_account_id\"\n    t.index [\"card_id\"], name: \"index_comments_on_card_id\"\n  end\n\n  create_table \"creators_filters\", id: false, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"creator_id\", null: false\n    t.uuid \"filter_id\", null: false\n    t.index [\"creator_id\"], name: \"index_creators_filters_on_creator_id\"\n    t.index [\"filter_id\"], name: \"index_creators_filters_on_filter_id\"\n  end\n\n  create_table \"entropies\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.bigint \"auto_postpone_period\", default: 2592000, null: false\n    t.uuid \"container_id\", null: false\n    t.string \"container_type\", null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"account_id\"], name: \"index_entropies_on_account_id\"\n    t.index [\"container_type\", \"container_id\", \"auto_postpone_period\"], name: \"idx_on_container_type_container_id_auto_postpone_pe_3d79b50517\"\n    t.index [\"container_type\", \"container_id\"], name: \"index_entropy_configurations_on_container\", unique: true\n  end\n\n  create_table \"events\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.string \"action\", null: false\n    t.uuid \"board_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.uuid \"creator_id\", null: false\n    t.uuid \"eventable_id\", null: false\n    t.string \"eventable_type\", null: false\n    t.json \"particulars\", default: -> { \"(json_object())\" }\n    t.datetime \"updated_at\", null: false\n    t.index [\"account_id\", \"action\"], name: \"index_events_on_account_id_and_action\"\n    t.index [\"board_id\", \"action\", \"created_at\"], name: \"index_events_on_board_id_and_action_and_created_at\"\n    t.index [\"board_id\"], name: \"index_events_on_board_id\"\n    t.index [\"creator_id\"], name: \"index_events_on_creator_id\"\n    t.index [\"eventable_type\", \"eventable_id\"], name: \"index_events_on_eventable\"\n  end\n\n  create_table \"exports\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.datetime \"completed_at\"\n    t.datetime \"created_at\", null: false\n    t.string \"status\", default: \"pending\", null: false\n    t.string \"type\"\n    t.datetime \"updated_at\", null: false\n    t.uuid \"user_id\", null: false\n    t.index [\"account_id\"], name: \"index_exports_on_account_id\"\n    t.index [\"type\"], name: \"index_exports_on_type\"\n    t.index [\"user_id\"], name: \"index_exports_on_user_id\"\n  end\n\n  create_table \"filters\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.uuid \"creator_id\", null: false\n    t.json \"fields\", default: -> { \"(json_object())\" }, null: false\n    t.string \"params_digest\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"account_id\"], name: \"index_filters_on_account_id\"\n    t.index [\"creator_id\", \"params_digest\"], name: \"index_filters_on_creator_id_and_params_digest\", unique: true\n  end\n\n  create_table \"filters_tags\", id: false, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"filter_id\", null: false\n    t.uuid \"tag_id\", null: false\n    t.index [\"filter_id\"], name: \"index_filters_tags_on_filter_id\"\n    t.index [\"tag_id\"], name: \"index_filters_tags_on_tag_id\"\n  end\n\n  create_table \"identities\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.datetime \"created_at\", null: false\n    t.string \"email_address\", null: false\n    t.boolean \"staff\", default: false, null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"email_address\"], name: \"index_identities_on_email_address\", unique: true\n  end\n\n  create_table \"identity_access_tokens\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.datetime \"created_at\", null: false\n    t.text \"description\"\n    t.uuid \"identity_id\", null: false\n    t.string \"permission\"\n    t.string \"token\"\n    t.datetime \"updated_at\", null: false\n    t.index [\"identity_id\"], name: \"index_access_token_on_identity_id\"\n  end\n\n  create_table \"magic_links\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.string \"code\", null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"expires_at\", null: false\n    t.uuid \"identity_id\"\n    t.integer \"purpose\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"code\"], name: \"index_magic_links_on_code\", unique: true\n    t.index [\"expires_at\"], name: \"index_magic_links_on_expires_at\"\n    t.index [\"identity_id\"], name: \"index_magic_links_on_identity_id\"\n  end\n\n  create_table \"mentions\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.uuid \"mentionee_id\", null: false\n    t.uuid \"mentioner_id\", null: false\n    t.uuid \"source_id\", null: false\n    t.string \"source_type\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"account_id\"], name: \"index_mentions_on_account_id\"\n    t.index [\"mentionee_id\"], name: \"index_mentions_on_mentionee_id\"\n    t.index [\"mentioner_id\"], name: \"index_mentions_on_mentioner_id\"\n    t.index [\"source_type\", \"source_id\"], name: \"index_mentions_on_source\"\n  end\n\n  create_table \"notification_bundles\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"ends_at\", null: false\n    t.datetime \"starts_at\", null: false\n    t.integer \"status\", default: 0, null: false\n    t.datetime \"updated_at\", null: false\n    t.uuid \"user_id\", null: false\n    t.index [\"account_id\"], name: \"index_notification_bundles_on_account_id\"\n    t.index [\"ends_at\", \"status\"], name: \"index_notification_bundles_on_ends_at_and_status\"\n    t.index [\"user_id\", \"starts_at\", \"ends_at\"], name: \"idx_on_user_id_starts_at_ends_at_7eae5d3ac5\"\n    t.index [\"user_id\", \"status\"], name: \"index_notification_bundles_on_user_id_and_status\"\n  end\n\n  create_table \"notifications\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.uuid \"card_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.uuid \"creator_id\"\n    t.datetime \"read_at\"\n    t.uuid \"source_id\", null: false\n    t.string \"source_type\", null: false\n    t.integer \"unread_count\", default: 0, null: false\n    t.datetime \"updated_at\", null: false\n    t.uuid \"user_id\", null: false\n    t.index [\"account_id\"], name: \"index_notifications_on_account_id\"\n    t.index [\"creator_id\"], name: \"index_notifications_on_creator_id\"\n    t.index [\"source_type\", \"source_id\"], name: \"index_notifications_on_source\"\n    t.index [\"user_id\", \"card_id\"], name: \"index_notifications_on_user_id_and_card_id\", unique: true\n    t.index [\"user_id\", \"read_at\", \"updated_at\"], name: \"index_notifications_on_user_id_and_read_at_and_updated_at\", order: { read_at: :desc, updated_at: :desc }\n    t.index [\"user_id\"], name: \"index_notifications_on_user_id\"\n  end\n\n  create_table \"pins\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.uuid \"card_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.uuid \"user_id\", null: false\n    t.index [\"account_id\"], name: \"index_pins_on_account_id\"\n    t.index [\"card_id\", \"user_id\"], name: \"index_pins_on_card_id_and_user_id\", unique: true\n    t.index [\"card_id\"], name: \"index_pins_on_card_id\"\n    t.index [\"user_id\"], name: \"index_pins_on_user_id\"\n  end\n\n  create_table \"push_subscriptions\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.string \"auth_key\"\n    t.datetime \"created_at\", null: false\n    t.text \"endpoint\"\n    t.string \"p256dh_key\"\n    t.datetime \"updated_at\", null: false\n    t.string \"user_agent\", limit: 4096\n    t.uuid \"user_id\", null: false\n    t.index [\"account_id\"], name: \"index_push_subscriptions_on_account_id\"\n    t.index [\"user_id\", \"endpoint\"], name: \"index_push_subscriptions_on_user_id_and_endpoint\", unique: true, length: { endpoint: 255 }\n  end\n\n  create_table \"reactions\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.string \"content\", limit: 16, null: false\n    t.datetime \"created_at\", null: false\n    t.uuid \"reactable_id\", null: false\n    t.string \"reactable_type\", null: false\n    t.uuid \"reacter_id\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"account_id\"], name: \"index_reactions_on_account_id\"\n    t.index [\"reactable_type\", \"reactable_id\"], name: \"index_reactions_on_reactable_type_and_reactable_id\"\n    t.index [\"reacter_id\"], name: \"index_reactions_on_reacter_id\"\n  end\n\n  create_table \"search_queries\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.string \"terms\", limit: 2000, null: false\n    t.datetime \"updated_at\", null: false\n    t.uuid \"user_id\", null: false\n    t.index [\"account_id\"], name: \"index_search_queries_on_account_id\"\n    t.index [\"user_id\", \"terms\"], name: \"index_search_queries_on_user_id_and_terms\", length: { terms: 255 }\n    t.index [\"user_id\", \"updated_at\"], name: \"index_search_queries_on_user_id_and_updated_at\", unique: true\n    t.index [\"user_id\"], name: \"index_search_queries_on_user_id\"\n  end\n\n  create_table \"search_records_0\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.string \"account_key\", default: \"\", null: false\n    t.uuid \"board_id\", null: false\n    t.uuid \"card_id\", null: false\n    t.text \"content\"\n    t.datetime \"created_at\", null: false\n    t.uuid \"searchable_id\", null: false\n    t.string \"searchable_type\", null: false\n    t.string \"title\"\n    t.index [\"account_id\"], name: \"index_search_records_0_on_account_id\"\n    t.index [\"account_key\", \"content\", \"title\"], name: \"index_search_records_0_on_account_key_and_content_and_title\", type: :fulltext\n    t.index [\"searchable_type\", \"searchable_id\"], name: \"index_search_records_0_on_searchable_type_and_searchable_id\", unique: true\n  end\n\n  create_table \"search_records_1\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.string \"account_key\", default: \"\", null: false\n    t.uuid \"board_id\", null: false\n    t.uuid \"card_id\", null: false\n    t.text \"content\"\n    t.datetime \"created_at\", null: false\n    t.uuid \"searchable_id\", null: false\n    t.string \"searchable_type\", null: false\n    t.string \"title\"\n    t.index [\"account_id\"], name: \"index_search_records_1_on_account_id\"\n    t.index [\"account_key\", \"content\", \"title\"], name: \"index_search_records_1_on_account_key_and_content_and_title\", type: :fulltext\n    t.index [\"searchable_type\", \"searchable_id\"], name: \"index_search_records_1_on_searchable_type_and_searchable_id\", unique: true\n  end\n\n  create_table \"search_records_10\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.string \"account_key\", default: \"\", null: false\n    t.uuid \"board_id\", null: false\n    t.uuid \"card_id\", null: false\n    t.text \"content\"\n    t.datetime \"created_at\", null: false\n    t.uuid \"searchable_id\", null: false\n    t.string \"searchable_type\", null: false\n    t.string \"title\"\n    t.index [\"account_id\"], name: \"index_search_records_10_on_account_id\"\n    t.index [\"account_key\", \"content\", \"title\"], name: \"index_search_records_10_on_account_key_and_content_and_title\", type: :fulltext\n    t.index [\"searchable_type\", \"searchable_id\"], name: \"index_search_records_10_on_searchable_type_and_searchable_id\", unique: true\n  end\n\n  create_table \"search_records_11\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.string \"account_key\", default: \"\", null: false\n    t.uuid \"board_id\", null: false\n    t.uuid \"card_id\", null: false\n    t.text \"content\"\n    t.datetime \"created_at\", null: false\n    t.uuid \"searchable_id\", null: false\n    t.string \"searchable_type\", null: false\n    t.string \"title\"\n    t.index [\"account_id\"], name: \"index_search_records_11_on_account_id\"\n    t.index [\"account_key\", \"content\", \"title\"], name: \"index_search_records_11_on_account_key_and_content_and_title\", type: :fulltext\n    t.index [\"searchable_type\", \"searchable_id\"], name: \"index_search_records_11_on_searchable_type_and_searchable_id\", unique: true\n  end\n\n  create_table \"search_records_12\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.string \"account_key\", default: \"\", null: false\n    t.uuid \"board_id\", null: false\n    t.uuid \"card_id\", null: false\n    t.text \"content\"\n    t.datetime \"created_at\", null: false\n    t.uuid \"searchable_id\", null: false\n    t.string \"searchable_type\", null: false\n    t.string \"title\"\n    t.index [\"account_id\"], name: \"index_search_records_12_on_account_id\"\n    t.index [\"account_key\", \"content\", \"title\"], name: \"index_search_records_12_on_account_key_and_content_and_title\", type: :fulltext\n    t.index [\"searchable_type\", \"searchable_id\"], name: \"index_search_records_12_on_searchable_type_and_searchable_id\", unique: true\n  end\n\n  create_table \"search_records_13\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.string \"account_key\", default: \"\", null: false\n    t.uuid \"board_id\", null: false\n    t.uuid \"card_id\", null: false\n    t.text \"content\"\n    t.datetime \"created_at\", null: false\n    t.uuid \"searchable_id\", null: false\n    t.string \"searchable_type\", null: false\n    t.string \"title\"\n    t.index [\"account_id\"], name: \"index_search_records_13_on_account_id\"\n    t.index [\"account_key\", \"content\", \"title\"], name: \"index_search_records_13_on_account_key_and_content_and_title\", type: :fulltext\n    t.index [\"searchable_type\", \"searchable_id\"], name: \"index_search_records_13_on_searchable_type_and_searchable_id\", unique: true\n  end\n\n  create_table \"search_records_14\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.string \"account_key\", default: \"\", null: false\n    t.uuid \"board_id\", null: false\n    t.uuid \"card_id\", null: false\n    t.text \"content\"\n    t.datetime \"created_at\", null: false\n    t.uuid \"searchable_id\", null: false\n    t.string \"searchable_type\", null: false\n    t.string \"title\"\n    t.index [\"account_id\"], name: \"index_search_records_14_on_account_id\"\n    t.index [\"account_key\", \"content\", \"title\"], name: \"index_search_records_14_on_account_key_and_content_and_title\", type: :fulltext\n    t.index [\"searchable_type\", \"searchable_id\"], name: \"index_search_records_14_on_searchable_type_and_searchable_id\", unique: true\n  end\n\n  create_table \"search_records_15\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.string \"account_key\", default: \"\", null: false\n    t.uuid \"board_id\", null: false\n    t.uuid \"card_id\", null: false\n    t.text \"content\"\n    t.datetime \"created_at\", null: false\n    t.uuid \"searchable_id\", null: false\n    t.string \"searchable_type\", null: false\n    t.string \"title\"\n    t.index [\"account_id\"], name: \"index_search_records_15_on_account_id\"\n    t.index [\"account_key\", \"content\", \"title\"], name: \"index_search_records_15_on_account_key_and_content_and_title\", type: :fulltext\n    t.index [\"searchable_type\", \"searchable_id\"], name: \"index_search_records_15_on_searchable_type_and_searchable_id\", unique: true\n  end\n\n  create_table \"search_records_2\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.string \"account_key\", default: \"\", null: false\n    t.uuid \"board_id\", null: false\n    t.uuid \"card_id\", null: false\n    t.text \"content\"\n    t.datetime \"created_at\", null: false\n    t.uuid \"searchable_id\", null: false\n    t.string \"searchable_type\", null: false\n    t.string \"title\"\n    t.index [\"account_id\"], name: \"index_search_records_2_on_account_id\"\n    t.index [\"account_key\", \"content\", \"title\"], name: \"index_search_records_2_on_account_key_and_content_and_title\", type: :fulltext\n    t.index [\"searchable_type\", \"searchable_id\"], name: \"index_search_records_2_on_searchable_type_and_searchable_id\", unique: true\n  end\n\n  create_table \"search_records_3\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.string \"account_key\", default: \"\", null: false\n    t.uuid \"board_id\", null: false\n    t.uuid \"card_id\", null: false\n    t.text \"content\"\n    t.datetime \"created_at\", null: false\n    t.uuid \"searchable_id\", null: false\n    t.string \"searchable_type\", null: false\n    t.string \"title\"\n    t.index [\"account_id\"], name: \"index_search_records_3_on_account_id\"\n    t.index [\"account_key\", \"content\", \"title\"], name: \"index_search_records_3_on_account_key_and_content_and_title\", type: :fulltext\n    t.index [\"searchable_type\", \"searchable_id\"], name: \"index_search_records_3_on_searchable_type_and_searchable_id\", unique: true\n  end\n\n  create_table \"search_records_4\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.string \"account_key\", default: \"\", null: false\n    t.uuid \"board_id\", null: false\n    t.uuid \"card_id\", null: false\n    t.text \"content\"\n    t.datetime \"created_at\", null: false\n    t.uuid \"searchable_id\", null: false\n    t.string \"searchable_type\", null: false\n    t.string \"title\"\n    t.index [\"account_id\"], name: \"index_search_records_4_on_account_id\"\n    t.index [\"account_key\", \"content\", \"title\"], name: \"index_search_records_4_on_account_key_and_content_and_title\", type: :fulltext\n    t.index [\"searchable_type\", \"searchable_id\"], name: \"index_search_records_4_on_searchable_type_and_searchable_id\", unique: true\n  end\n\n  create_table \"search_records_5\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.string \"account_key\", default: \"\", null: false\n    t.uuid \"board_id\", null: false\n    t.uuid \"card_id\", null: false\n    t.text \"content\"\n    t.datetime \"created_at\", null: false\n    t.uuid \"searchable_id\", null: false\n    t.string \"searchable_type\", null: false\n    t.string \"title\"\n    t.index [\"account_id\"], name: \"index_search_records_5_on_account_id\"\n    t.index [\"account_key\", \"content\", \"title\"], name: \"index_search_records_5_on_account_key_and_content_and_title\", type: :fulltext\n    t.index [\"searchable_type\", \"searchable_id\"], name: \"index_search_records_5_on_searchable_type_and_searchable_id\", unique: true\n  end\n\n  create_table \"search_records_6\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.string \"account_key\", default: \"\", null: false\n    t.uuid \"board_id\", null: false\n    t.uuid \"card_id\", null: false\n    t.text \"content\"\n    t.datetime \"created_at\", null: false\n    t.uuid \"searchable_id\", null: false\n    t.string \"searchable_type\", null: false\n    t.string \"title\"\n    t.index [\"account_id\"], name: \"index_search_records_6_on_account_id\"\n    t.index [\"account_key\", \"content\", \"title\"], name: \"index_search_records_6_on_account_key_and_content_and_title\", type: :fulltext\n    t.index [\"searchable_type\", \"searchable_id\"], name: \"index_search_records_6_on_searchable_type_and_searchable_id\", unique: true\n  end\n\n  create_table \"search_records_7\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.string \"account_key\", default: \"\", null: false\n    t.uuid \"board_id\", null: false\n    t.uuid \"card_id\", null: false\n    t.text \"content\"\n    t.datetime \"created_at\", null: false\n    t.uuid \"searchable_id\", null: false\n    t.string \"searchable_type\", null: false\n    t.string \"title\"\n    t.index [\"account_id\"], name: \"index_search_records_7_on_account_id\"\n    t.index [\"account_key\", \"content\", \"title\"], name: \"index_search_records_7_on_account_key_and_content_and_title\", type: :fulltext\n    t.index [\"searchable_type\", \"searchable_id\"], name: \"index_search_records_7_on_searchable_type_and_searchable_id\", unique: true\n  end\n\n  create_table \"search_records_8\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.string \"account_key\", default: \"\", null: false\n    t.uuid \"board_id\", null: false\n    t.uuid \"card_id\", null: false\n    t.text \"content\"\n    t.datetime \"created_at\", null: false\n    t.uuid \"searchable_id\", null: false\n    t.string \"searchable_type\", null: false\n    t.string \"title\"\n    t.index [\"account_id\"], name: \"index_search_records_8_on_account_id\"\n    t.index [\"account_key\", \"content\", \"title\"], name: \"index_search_records_8_on_account_key_and_content_and_title\", type: :fulltext\n    t.index [\"searchable_type\", \"searchable_id\"], name: \"index_search_records_8_on_searchable_type_and_searchable_id\", unique: true\n  end\n\n  create_table \"search_records_9\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.string \"account_key\", default: \"\", null: false\n    t.uuid \"board_id\", null: false\n    t.uuid \"card_id\", null: false\n    t.text \"content\"\n    t.datetime \"created_at\", null: false\n    t.uuid \"searchable_id\", null: false\n    t.string \"searchable_type\", null: false\n    t.string \"title\"\n    t.index [\"account_id\"], name: \"index_search_records_9_on_account_id\"\n    t.index [\"account_key\", \"content\", \"title\"], name: \"index_search_records_9_on_account_key_and_content_and_title\", type: :fulltext\n    t.index [\"searchable_type\", \"searchable_id\"], name: \"index_search_records_9_on_searchable_type_and_searchable_id\", unique: true\n  end\n\n  create_table \"sessions\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.datetime \"created_at\", null: false\n    t.uuid \"identity_id\", null: false\n    t.string \"ip_address\"\n    t.datetime \"updated_at\", null: false\n    t.string \"user_agent\", limit: 4096\n    t.index [\"identity_id\"], name: \"index_sessions_on_identity_id\"\n  end\n\n  create_table \"steps\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.uuid \"card_id\", null: false\n    t.boolean \"completed\", default: false, null: false\n    t.text \"content\", null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"account_id\"], name: \"index_steps_on_account_id\"\n    t.index [\"card_id\", \"completed\"], name: \"index_steps_on_card_id_and_completed\"\n    t.index [\"card_id\"], name: \"index_steps_on_card_id\"\n  end\n\n  create_table \"storage_entries\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.uuid \"blob_id\"\n    t.uuid \"board_id\"\n    t.datetime \"created_at\", null: false\n    t.bigint \"delta\", null: false\n    t.string \"operation\", null: false\n    t.uuid \"recordable_id\"\n    t.string \"recordable_type\"\n    t.string \"request_id\"\n    t.uuid \"user_id\"\n    t.index [\"account_id\"], name: \"index_storage_entries_on_account_id\"\n    t.index [\"blob_id\"], name: \"index_storage_entries_on_blob_id\"\n    t.index [\"board_id\"], name: \"index_storage_entries_on_board_id\"\n    t.index [\"recordable_type\", \"recordable_id\"], name: \"index_storage_entries_on_recordable\"\n    t.index [\"request_id\"], name: \"index_storage_entries_on_request_id\"\n    t.index [\"user_id\"], name: \"index_storage_entries_on_user_id\"\n  end\n\n  create_table \"storage_totals\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.bigint \"bytes_stored\", default: 0, null: false\n    t.datetime \"created_at\", null: false\n    t.uuid \"last_entry_id\"\n    t.uuid \"owner_id\", null: false\n    t.string \"owner_type\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"owner_type\", \"owner_id\"], name: \"index_storage_totals_on_owner_type_and_owner_id\", unique: true\n  end\n\n  create_table \"taggings\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.uuid \"card_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.uuid \"tag_id\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"account_id\"], name: \"index_taggings_on_account_id\"\n    t.index [\"card_id\", \"tag_id\"], name: \"index_taggings_on_card_id_and_tag_id\", unique: true\n    t.index [\"tag_id\"], name: \"index_taggings_on_tag_id\"\n  end\n\n  create_table \"tags\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.string \"title\"\n    t.datetime \"updated_at\", null: false\n    t.index [\"account_id\", \"title\"], name: \"index_tags_on_account_id_and_title\", unique: true\n  end\n\n  create_table \"user_settings\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.integer \"bundle_email_frequency\", default: 0, null: false\n    t.datetime \"created_at\", null: false\n    t.string \"timezone_name\"\n    t.datetime \"updated_at\", null: false\n    t.uuid \"user_id\", null: false\n    t.index [\"account_id\"], name: \"index_user_settings_on_account_id\"\n    t.index [\"user_id\", \"bundle_email_frequency\"], name: \"index_user_settings_on_user_id_and_bundle_email_frequency\"\n    t.index [\"user_id\"], name: \"index_user_settings_on_user_id\"\n  end\n\n  create_table \"users\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.boolean \"active\", default: true, null: false\n    t.datetime \"created_at\", null: false\n    t.uuid \"identity_id\"\n    t.string \"name\", null: false\n    t.string \"role\", default: \"member\", null: false\n    t.datetime \"updated_at\", null: false\n    t.datetime \"verified_at\"\n    t.index [\"account_id\", \"identity_id\"], name: \"index_users_on_account_id_and_identity_id\", unique: true\n    t.index [\"account_id\", \"role\"], name: \"index_users_on_account_id_and_role\"\n    t.index [\"identity_id\"], name: \"index_users_on_identity_id\"\n  end\n\n  create_table \"watches\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.uuid \"card_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.uuid \"user_id\", null: false\n    t.boolean \"watching\", default: true, null: false\n    t.index [\"account_id\"], name: \"index_watches_on_account_id\"\n    t.index [\"card_id\"], name: \"index_watches_on_card_id\"\n    t.index [\"user_id\", \"card_id\"], name: \"index_watches_on_user_id_and_card_id\"\n    t.index [\"user_id\"], name: \"index_watches_on_user_id\"\n  end\n\n  create_table \"webhook_delinquency_trackers\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.integer \"consecutive_failures_count\", default: 0\n    t.datetime \"created_at\", null: false\n    t.datetime \"first_failure_at\"\n    t.datetime \"updated_at\", null: false\n    t.uuid \"webhook_id\", null: false\n    t.index [\"account_id\"], name: \"index_webhook_delinquency_trackers_on_account_id\"\n    t.index [\"webhook_id\"], name: \"index_webhook_delinquency_trackers_on_webhook_id\"\n  end\n\n  create_table \"webhook_deliveries\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.uuid \"event_id\", null: false\n    t.text \"request\"\n    t.text \"response\"\n    t.string \"state\", null: false\n    t.datetime \"updated_at\", null: false\n    t.uuid \"webhook_id\", null: false\n    t.index [\"account_id\"], name: \"index_webhook_deliveries_on_account_id\"\n    t.index [\"created_at\"], name: \"index_webhook_deliveries_on_created_at\"\n    t.index [\"event_id\"], name: \"index_webhook_deliveries_on_event_id\"\n    t.index [\"webhook_id\"], name: \"index_webhook_deliveries_on_webhook_id\"\n  end\n\n  create_table \"webhooks\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.boolean \"active\", default: true, null: false\n    t.uuid \"board_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.string \"name\"\n    t.string \"signing_secret\", null: false\n    t.text \"subscribed_actions\"\n    t.datetime \"updated_at\", null: false\n    t.text \"url\", null: false\n    t.index [\"account_id\"], name: \"index_webhooks_on_account_id\"\n    t.index [\"board_id\", \"subscribed_actions\"], name: \"index_webhooks_on_board_id_and_subscribed_actions\", length: { subscribed_actions: 255 }\n  end\nend\n"
  },
  {
    "path": "db/schema_sqlite.rb",
    "content": "# This file is auto-generated from the current state of the database. Instead\n# of editing this file, please use the migrations feature of Active Record to\n# incrementally modify your database, and then regenerate this schema definition.\n#\n# This file is the source Rails uses to define your schema when running `bin/rails\n# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to\n# be faster and is potentially less error prone than running all of your\n# migrations from scratch. Old migrations may fail to apply correctly if those\n# migrations use external dependencies or application code.\n#\n# It's strongly recommended that you check this file into your version control system.\n\nActiveRecord::Schema[8.2].define(version: 2026_02_18_120000) do\n  create_table \"accesses\", id: :uuid, force: :cascade do |t|\n    t.datetime \"accessed_at\"\n    t.uuid \"account_id\", null: false\n    t.uuid \"board_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.string \"involvement\", limit: 255, default: \"access_only\", null: false\n    t.datetime \"updated_at\", null: false\n    t.uuid \"user_id\", null: false\n    t.index [\"account_id\", \"accessed_at\"], name: \"index_accesses_on_account_id_and_accessed_at\"\n    t.index [\"board_id\", \"user_id\"], name: \"index_accesses_on_board_id_and_user_id\", unique: true\n    t.index [\"board_id\"], name: \"index_accesses_on_board_id\"\n    t.index [\"user_id\"], name: \"index_accesses_on_user_id\"\n  end\n\n  create_table \"account_cancellations\", id: :uuid, force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.uuid \"initiated_by_id\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"account_id\"], name: \"index_account_cancellations_on_account_id\", unique: true\n  end\n\n  create_table \"account_external_id_sequences\", id: :uuid, force: :cascade do |t|\n    t.bigint \"value\", default: 0, null: false\n    t.index [\"value\"], name: \"index_account_external_id_sequences_on_value\", unique: true\n  end\n\n  create_table \"account_imports\", id: :uuid, force: :cascade do |t|\n    t.uuid \"account_id\"\n    t.datetime \"completed_at\"\n    t.datetime \"created_at\", null: false\n    t.string \"failure_reason\", limit: 255\n    t.uuid \"identity_id\", null: false\n    t.string \"status\", limit: 255, default: \"pending\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"account_id\"], name: \"index_account_imports_on_account_id\"\n    t.index [\"identity_id\"], name: \"index_account_imports_on_identity_id\"\n  end\n\n  create_table \"account_join_codes\", id: :uuid, force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.string \"code\", limit: 255, null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.bigint \"usage_count\", default: 0, null: false\n    t.bigint \"usage_limit\", default: 10, null: false\n    t.index [\"account_id\", \"code\"], name: \"index_account_join_codes_on_account_id_and_code\", unique: true\n  end\n\n  create_table \"accounts\", id: :uuid, force: :cascade do |t|\n    t.bigint \"cards_count\", default: 0, null: false\n    t.datetime \"created_at\", null: false\n    t.bigint \"external_account_id\"\n    t.string \"name\", limit: 255, null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"external_account_id\"], name: \"index_accounts_on_external_account_id\", unique: true\n  end\n\n  create_table \"action_pack_passkeys\", id: :uuid, force: :cascade do |t|\n    t.string \"aaguid\", limit: 255\n    t.boolean \"backed_up\"\n    t.datetime \"created_at\", null: false\n    t.string \"credential_id\", limit: 255, null: false\n    t.uuid \"holder_id\", null: false\n    t.string \"holder_type\", limit: 255, null: false\n    t.string \"name\", limit: 255\n    t.binary \"public_key\", null: false\n    t.integer \"sign_count\", default: 0, null: false\n    t.text \"transports\", limit: 65535\n    t.datetime \"updated_at\", null: false\n    t.index [\"credential_id\"], name: \"index_action_pack_passkeys_on_credential_id\", unique: true\n    t.index [\"holder_type\", \"holder_id\"], name: \"index_action_pack_passkeys_on_holder_type_and_holder_id\"\n  end\n\n  create_table \"action_text_rich_texts\", id: :uuid, force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.text \"body\", limit: 4294967295\n    t.datetime \"created_at\", null: false\n    t.string \"name\", limit: 255, null: false\n    t.uuid \"record_id\", null: false\n    t.string \"record_type\", limit: 255, null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"account_id\"], name: \"index_action_text_rich_texts_on_account_id\"\n    t.index [\"record_type\", \"record_id\", \"name\"], name: \"index_action_text_rich_texts_uniqueness\", unique: true\n  end\n\n  create_table \"active_storage_attachments\", id: :uuid, force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.uuid \"blob_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.string \"name\", limit: 255, null: false\n    t.uuid \"record_id\", null: false\n    t.string \"record_type\", limit: 255, null: false\n    t.index [\"account_id\"], name: \"index_active_storage_attachments_on_account_id\"\n    t.index [\"blob_id\"], name: \"index_active_storage_attachments_on_blob_id\"\n    t.index [\"record_type\", \"record_id\", \"name\", \"blob_id\"], name: \"index_active_storage_attachments_uniqueness\", unique: true\n  end\n\n  create_table \"active_storage_blobs\", id: :uuid, force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.bigint \"byte_size\", null: false\n    t.string \"checksum\", limit: 255\n    t.string \"content_type\", limit: 255\n    t.datetime \"created_at\", null: false\n    t.string \"filename\", limit: 255, null: false\n    t.string \"key\", limit: 255, null: false\n    t.text \"metadata\", limit: 65535\n    t.string \"service_name\", limit: 255, null: false\n    t.index [\"account_id\"], name: \"index_active_storage_blobs_on_account_id\"\n    t.index [\"key\"], name: \"index_active_storage_blobs_on_key\", unique: true\n  end\n\n  create_table \"active_storage_variant_records\", id: :uuid, force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.uuid \"blob_id\", null: false\n    t.string \"variation_digest\", limit: 255, null: false\n    t.index [\"account_id\"], name: \"index_active_storage_variant_records_on_account_id\"\n    t.index [\"blob_id\", \"variation_digest\"], name: \"index_active_storage_variant_records_uniqueness\", unique: true\n  end\n\n  create_table \"assignees_filters\", id: false, force: :cascade do |t|\n    t.uuid \"assignee_id\", null: false\n    t.uuid \"filter_id\", null: false\n    t.index [\"assignee_id\"], name: \"index_assignees_filters_on_assignee_id\"\n    t.index [\"filter_id\"], name: \"index_assignees_filters_on_filter_id\"\n  end\n\n  create_table \"assigners_filters\", id: false, force: :cascade do |t|\n    t.uuid \"assigner_id\", null: false\n    t.uuid \"filter_id\", null: false\n    t.index [\"assigner_id\"], name: \"index_assigners_filters_on_assigner_id\"\n    t.index [\"filter_id\"], name: \"index_assigners_filters_on_filter_id\"\n  end\n\n  create_table \"assignments\", id: :uuid, force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.uuid \"assignee_id\", null: false\n    t.uuid \"assigner_id\", null: false\n    t.uuid \"card_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"account_id\"], name: \"index_assignments_on_account_id\"\n    t.index [\"assignee_id\", \"card_id\"], name: \"index_assignments_on_assignee_id_and_card_id\", unique: true\n    t.index [\"card_id\"], name: \"index_assignments_on_card_id\"\n  end\n\n  create_table \"board_publications\", id: :uuid, force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.uuid \"board_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.string \"key\", limit: 255\n    t.datetime \"updated_at\", null: false\n    t.index [\"account_id\"], name: \"index_board_publications_on_account_id\"\n    t.index [\"board_id\"], name: \"index_board_publications_on_board_id\"\n    t.index [\"key\"], name: \"index_board_publications_on_key\", unique: true\n  end\n\n  create_table \"boards\", id: :uuid, force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.boolean \"all_access\", default: false, null: false\n    t.datetime \"created_at\", null: false\n    t.uuid \"creator_id\", null: false\n    t.string \"name\", limit: 255, null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"account_id\"], name: \"index_boards_on_account_id\"\n    t.index [\"creator_id\"], name: \"index_boards_on_creator_id\"\n  end\n\n  create_table \"boards_filters\", id: false, force: :cascade do |t|\n    t.uuid \"board_id\", null: false\n    t.uuid \"filter_id\", null: false\n    t.index [\"board_id\"], name: \"index_boards_filters_on_board_id\"\n    t.index [\"filter_id\"], name: \"index_boards_filters_on_filter_id\"\n  end\n\n  create_table \"card_activity_spikes\", id: :uuid, force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.uuid \"card_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"account_id\"], name: \"index_card_activity_spikes_on_account_id\"\n    t.index [\"card_id\"], name: \"index_card_activity_spikes_on_card_id\", unique: true\n  end\n\n  create_table \"card_goldnesses\", id: :uuid, force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.uuid \"card_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"account_id\"], name: \"index_card_goldnesses_on_account_id\"\n    t.index [\"card_id\"], name: \"index_card_goldnesses_on_card_id\", unique: true\n  end\n\n  create_table \"card_not_nows\", id: :uuid, force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.uuid \"card_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.uuid \"user_id\"\n    t.index [\"account_id\"], name: \"index_card_not_nows_on_account_id\"\n    t.index [\"card_id\"], name: \"index_card_not_nows_on_card_id\", unique: true\n    t.index [\"user_id\"], name: \"index_card_not_nows_on_user_id\"\n  end\n\n  create_table \"cards\", id: :uuid, force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.uuid \"board_id\", null: false\n    t.uuid \"column_id\"\n    t.datetime \"created_at\", null: false\n    t.uuid \"creator_id\", null: false\n    t.date \"due_on\"\n    t.datetime \"last_active_at\", null: false\n    t.bigint \"number\", null: false\n    t.string \"status\", limit: 255, default: \"drafted\", null: false\n    t.string \"title\", limit: 255\n    t.datetime \"updated_at\", null: false\n    t.index [\"account_id\", \"last_active_at\", \"status\"], name: \"index_cards_on_account_id_and_last_active_at_and_status\"\n    t.index [\"account_id\", \"number\"], name: \"index_cards_on_account_id_and_number\", unique: true\n    t.index [\"board_id\"], name: \"index_cards_on_board_id\"\n    t.index [\"column_id\"], name: \"index_cards_on_column_id\"\n  end\n\n  create_table \"closers_filters\", id: false, force: :cascade do |t|\n    t.uuid \"closer_id\", null: false\n    t.uuid \"filter_id\", null: false\n    t.index [\"closer_id\"], name: \"index_closers_filters_on_closer_id\"\n    t.index [\"filter_id\"], name: \"index_closers_filters_on_filter_id\"\n  end\n\n  create_table \"closures\", id: :uuid, force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.uuid \"card_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.uuid \"user_id\"\n    t.index [\"account_id\"], name: \"index_closures_on_account_id\"\n    t.index [\"card_id\", \"created_at\"], name: \"index_closures_on_card_id_and_created_at\"\n    t.index [\"card_id\"], name: \"index_closures_on_card_id\", unique: true\n    t.index [\"user_id\"], name: \"index_closures_on_user_id\"\n  end\n\n  create_table \"columns\", id: :uuid, force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.uuid \"board_id\", null: false\n    t.string \"color\", limit: 255, null: false\n    t.datetime \"created_at\", null: false\n    t.string \"name\", limit: 255, null: false\n    t.integer \"position\", default: 0, null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"account_id\"], name: \"index_columns_on_account_id\"\n    t.index [\"board_id\", \"position\"], name: \"index_columns_on_board_id_and_position\"\n    t.index [\"board_id\"], name: \"index_columns_on_board_id\"\n  end\n\n  create_table \"comments\", id: :uuid, force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.uuid \"card_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.uuid \"creator_id\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"account_id\"], name: \"index_comments_on_account_id\"\n    t.index [\"card_id\"], name: \"index_comments_on_card_id\"\n  end\n\n  create_table \"creators_filters\", id: false, force: :cascade do |t|\n    t.uuid \"creator_id\", null: false\n    t.uuid \"filter_id\", null: false\n    t.index [\"creator_id\"], name: \"index_creators_filters_on_creator_id\"\n    t.index [\"filter_id\"], name: \"index_creators_filters_on_filter_id\"\n  end\n\n  create_table \"entropies\", id: :uuid, force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.bigint \"auto_postpone_period\", default: 2592000, null: false\n    t.uuid \"container_id\", null: false\n    t.string \"container_type\", limit: 255, null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"account_id\"], name: \"index_entropies_on_account_id\"\n    t.index [\"container_type\", \"container_id\", \"auto_postpone_period\"], name: \"idx_on_container_type_container_id_auto_postpone_pe_3d79b50517\"\n    t.index [\"container_type\", \"container_id\"], name: \"index_entropy_configurations_on_container\", unique: true\n  end\n\n  create_table \"events\", id: :uuid, force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.string \"action\", limit: 255, null: false\n    t.uuid \"board_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.uuid \"creator_id\", null: false\n    t.uuid \"eventable_id\", null: false\n    t.string \"eventable_type\", limit: 255, null: false\n    t.json \"particulars\", default: -> { \"json_object()\" }\n    t.datetime \"updated_at\", null: false\n    t.index [\"account_id\", \"action\"], name: \"index_events_on_account_id_and_action\"\n    t.index [\"board_id\", \"action\", \"created_at\"], name: \"index_events_on_board_id_and_action_and_created_at\"\n    t.index [\"board_id\"], name: \"index_events_on_board_id\"\n    t.index [\"creator_id\"], name: \"index_events_on_creator_id\"\n    t.index [\"eventable_type\", \"eventable_id\"], name: \"index_events_on_eventable\"\n  end\n\n  create_table \"exports\", id: :uuid, force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.datetime \"completed_at\"\n    t.datetime \"created_at\", null: false\n    t.string \"status\", limit: 255, default: \"pending\", null: false\n    t.string \"type\", limit: 255\n    t.datetime \"updated_at\", null: false\n    t.uuid \"user_id\", null: false\n    t.index [\"account_id\"], name: \"index_exports_on_account_id\"\n    t.index [\"type\"], name: \"index_exports_on_type\"\n    t.index [\"user_id\"], name: \"index_exports_on_user_id\"\n  end\n\n  create_table \"filters\", id: :uuid, force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.uuid \"creator_id\", null: false\n    t.json \"fields\", default: -> { \"json_object()\" }, null: false\n    t.string \"params_digest\", limit: 255, null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"account_id\"], name: \"index_filters_on_account_id\"\n    t.index [\"creator_id\", \"params_digest\"], name: \"index_filters_on_creator_id_and_params_digest\", unique: true\n  end\n\n  create_table \"filters_tags\", id: false, force: :cascade do |t|\n    t.uuid \"filter_id\", null: false\n    t.uuid \"tag_id\", null: false\n    t.index [\"filter_id\"], name: \"index_filters_tags_on_filter_id\"\n    t.index [\"tag_id\"], name: \"index_filters_tags_on_tag_id\"\n  end\n\n  create_table \"identities\", id: :uuid, force: :cascade do |t|\n    t.datetime \"created_at\", null: false\n    t.string \"email_address\", limit: 255, null: false\n    t.boolean \"staff\", default: false, null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"email_address\"], name: \"index_identities_on_email_address\", unique: true\n  end\n\n  create_table \"identity_access_tokens\", id: :uuid, force: :cascade do |t|\n    t.datetime \"created_at\", null: false\n    t.text \"description\", limit: 65535\n    t.uuid \"identity_id\", null: false\n    t.string \"permission\", limit: 255\n    t.string \"token\", limit: 255\n    t.datetime \"updated_at\", null: false\n    t.index [\"identity_id\"], name: \"index_access_token_on_identity_id\"\n  end\n\n  create_table \"magic_links\", id: :uuid, force: :cascade do |t|\n    t.string \"code\", limit: 255, null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"expires_at\", null: false\n    t.uuid \"identity_id\"\n    t.integer \"purpose\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"code\"], name: \"index_magic_links_on_code\", unique: true\n    t.index [\"expires_at\"], name: \"index_magic_links_on_expires_at\"\n    t.index [\"identity_id\"], name: \"index_magic_links_on_identity_id\"\n  end\n\n  create_table \"mentions\", id: :uuid, force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.uuid \"mentionee_id\", null: false\n    t.uuid \"mentioner_id\", null: false\n    t.uuid \"source_id\", null: false\n    t.string \"source_type\", limit: 255, null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"account_id\"], name: \"index_mentions_on_account_id\"\n    t.index [\"mentionee_id\"], name: \"index_mentions_on_mentionee_id\"\n    t.index [\"mentioner_id\"], name: \"index_mentions_on_mentioner_id\"\n    t.index [\"source_type\", \"source_id\"], name: \"index_mentions_on_source\"\n  end\n\n  create_table \"notification_bundles\", id: :uuid, force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"ends_at\", null: false\n    t.datetime \"starts_at\", null: false\n    t.integer \"status\", default: 0, null: false\n    t.datetime \"updated_at\", null: false\n    t.uuid \"user_id\", null: false\n    t.index [\"account_id\"], name: \"index_notification_bundles_on_account_id\"\n    t.index [\"ends_at\", \"status\"], name: \"index_notification_bundles_on_ends_at_and_status\"\n    t.index [\"user_id\", \"starts_at\", \"ends_at\"], name: \"idx_on_user_id_starts_at_ends_at_7eae5d3ac5\"\n    t.index [\"user_id\", \"status\"], name: \"index_notification_bundles_on_user_id_and_status\"\n  end\n\n  create_table \"notifications\", id: :uuid, force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.uuid \"card_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.uuid \"creator_id\"\n    t.datetime \"read_at\"\n    t.uuid \"source_id\", null: false\n    t.string \"source_type\", limit: 255, null: false\n    t.integer \"unread_count\", default: 0, null: false\n    t.datetime \"updated_at\", null: false\n    t.uuid \"user_id\", null: false\n    t.index [\"account_id\"], name: \"index_notifications_on_account_id\"\n    t.index [\"creator_id\"], name: \"index_notifications_on_creator_id\"\n    t.index [\"source_type\", \"source_id\"], name: \"index_notifications_on_source\"\n    t.index [\"user_id\", \"card_id\"], name: \"index_notifications_on_user_id_and_card_id\", unique: true\n    t.index [\"user_id\", \"read_at\", \"updated_at\"], name: \"index_notifications_on_user_id_and_read_at_and_updated_at\", order: { read_at: :desc, updated_at: :desc }\n    t.index [\"user_id\"], name: \"index_notifications_on_user_id\"\n  end\n\n  create_table \"pins\", id: :uuid, force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.uuid \"card_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.uuid \"user_id\", null: false\n    t.index [\"account_id\"], name: \"index_pins_on_account_id\"\n    t.index [\"card_id\", \"user_id\"], name: \"index_pins_on_card_id_and_user_id\", unique: true\n    t.index [\"card_id\"], name: \"index_pins_on_card_id\"\n    t.index [\"user_id\"], name: \"index_pins_on_user_id\"\n  end\n\n  create_table \"push_subscriptions\", id: :uuid, force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.string \"auth_key\", limit: 255\n    t.datetime \"created_at\", null: false\n    t.text \"endpoint\", limit: 65535\n    t.string \"p256dh_key\", limit: 255\n    t.datetime \"updated_at\", null: false\n    t.string \"user_agent\", limit: 4096\n    t.uuid \"user_id\", null: false\n    t.index [\"account_id\"], name: \"index_push_subscriptions_on_account_id\"\n    t.index [\"user_id\", \"endpoint\"], name: \"index_push_subscriptions_on_user_id_and_endpoint\", unique: true\n  end\n\n  create_table \"reactions\", id: :uuid, force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.string \"content\", limit: 16, null: false\n    t.datetime \"created_at\", null: false\n    t.uuid \"reactable_id\", null: false\n    t.string \"reactable_type\", limit: 255, null: false\n    t.uuid \"reacter_id\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"account_id\"], name: \"index_reactions_on_account_id\"\n    t.index [\"reactable_type\", \"reactable_id\"], name: \"index_reactions_on_reactable_type_and_reactable_id\"\n    t.index [\"reacter_id\"], name: \"index_reactions_on_reacter_id\"\n  end\n\n  create_table \"search_queries\", id: :uuid, force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.string \"terms\", limit: 2000, null: false\n    t.datetime \"updated_at\", null: false\n    t.uuid \"user_id\", null: false\n    t.index [\"account_id\"], name: \"index_search_queries_on_account_id\"\n    t.index [\"user_id\", \"terms\"], name: \"index_search_queries_on_user_id_and_terms\"\n    t.index [\"user_id\", \"updated_at\"], name: \"index_search_queries_on_user_id_and_updated_at\", unique: true\n    t.index [\"user_id\"], name: \"index_search_queries_on_user_id\"\n  end\n\n  create_table \"search_records\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.uuid \"board_id\", null: false\n    t.uuid \"card_id\", null: false\n    t.text \"content\", limit: 65535\n    t.datetime \"created_at\", null: false\n    t.uuid \"searchable_id\", null: false\n    t.string \"searchable_type\", limit: 255, null: false\n    t.string \"title\", limit: 255\n    t.index [\"account_id\"], name: \"index_search_records_on_account_id\"\n    t.index [\"searchable_type\", \"searchable_id\"], name: \"index_search_records_on_searchable_type_and_searchable_id\", unique: true\n  end\n\n  create_table \"sessions\", id: :uuid, force: :cascade do |t|\n    t.datetime \"created_at\", null: false\n    t.uuid \"identity_id\", null: false\n    t.string \"ip_address\", limit: 255\n    t.datetime \"updated_at\", null: false\n    t.string \"user_agent\", limit: 4096\n    t.index [\"identity_id\"], name: \"index_sessions_on_identity_id\"\n  end\n\n  create_table \"steps\", id: :uuid, force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.uuid \"card_id\", null: false\n    t.boolean \"completed\", default: false, null: false\n    t.text \"content\", limit: 65535, null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"account_id\"], name: \"index_steps_on_account_id\"\n    t.index [\"card_id\", \"completed\"], name: \"index_steps_on_card_id_and_completed\"\n    t.index [\"card_id\"], name: \"index_steps_on_card_id\"\n  end\n\n  create_table \"storage_entries\", id: :uuid, force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.uuid \"blob_id\"\n    t.uuid \"board_id\"\n    t.datetime \"created_at\", null: false\n    t.bigint \"delta\", null: false\n    t.string \"operation\", limit: 255, null: false\n    t.uuid \"recordable_id\"\n    t.string \"recordable_type\", limit: 255\n    t.string \"request_id\", limit: 255\n    t.uuid \"user_id\"\n    t.index [\"account_id\"], name: \"index_storage_entries_on_account_id\"\n    t.index [\"blob_id\"], name: \"index_storage_entries_on_blob_id\"\n    t.index [\"board_id\"], name: \"index_storage_entries_on_board_id\"\n    t.index [\"recordable_type\", \"recordable_id\"], name: \"index_storage_entries_on_recordable\"\n    t.index [\"request_id\"], name: \"index_storage_entries_on_request_id\"\n    t.index [\"user_id\"], name: \"index_storage_entries_on_user_id\"\n  end\n\n  create_table \"storage_totals\", id: :uuid, force: :cascade do |t|\n    t.bigint \"bytes_stored\", default: 0, null: false\n    t.datetime \"created_at\", null: false\n    t.uuid \"last_entry_id\"\n    t.uuid \"owner_id\", null: false\n    t.string \"owner_type\", limit: 255, null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"owner_type\", \"owner_id\"], name: \"index_storage_totals_on_owner_type_and_owner_id\", unique: true\n  end\n\n  create_table \"taggings\", id: :uuid, force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.uuid \"card_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.uuid \"tag_id\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"account_id\"], name: \"index_taggings_on_account_id\"\n    t.index [\"card_id\", \"tag_id\"], name: \"index_taggings_on_card_id_and_tag_id\", unique: true\n    t.index [\"tag_id\"], name: \"index_taggings_on_tag_id\"\n  end\n\n  create_table \"tags\", id: :uuid, force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.string \"title\", limit: 255\n    t.datetime \"updated_at\", null: false\n    t.index [\"account_id\", \"title\"], name: \"index_tags_on_account_id_and_title\", unique: true\n  end\n\n  create_table \"user_settings\", id: :uuid, force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.integer \"bundle_email_frequency\", default: 0, null: false\n    t.datetime \"created_at\", null: false\n    t.string \"timezone_name\", limit: 255\n    t.datetime \"updated_at\", null: false\n    t.uuid \"user_id\", null: false\n    t.index [\"account_id\"], name: \"index_user_settings_on_account_id\"\n    t.index [\"user_id\", \"bundle_email_frequency\"], name: \"index_user_settings_on_user_id_and_bundle_email_frequency\"\n    t.index [\"user_id\"], name: \"index_user_settings_on_user_id\"\n  end\n\n  create_table \"users\", id: :uuid, force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.boolean \"active\", default: true, null: false\n    t.datetime \"created_at\", null: false\n    t.uuid \"identity_id\"\n    t.string \"name\", limit: 255, null: false\n    t.string \"role\", limit: 255, default: \"member\", null: false\n    t.datetime \"updated_at\", null: false\n    t.datetime \"verified_at\"\n    t.index [\"account_id\", \"identity_id\"], name: \"index_users_on_account_id_and_identity_id\", unique: true\n    t.index [\"account_id\", \"role\"], name: \"index_users_on_account_id_and_role\"\n    t.index [\"identity_id\"], name: \"index_users_on_identity_id\"\n  end\n\n  create_table \"watches\", id: :uuid, force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.uuid \"card_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.uuid \"user_id\", null: false\n    t.boolean \"watching\", default: true, null: false\n    t.index [\"account_id\"], name: \"index_watches_on_account_id\"\n    t.index [\"card_id\"], name: \"index_watches_on_card_id\"\n    t.index [\"user_id\", \"card_id\"], name: \"index_watches_on_user_id_and_card_id\"\n    t.index [\"user_id\"], name: \"index_watches_on_user_id\"\n  end\n\n  create_table \"webhook_delinquency_trackers\", id: :uuid, force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.integer \"consecutive_failures_count\", default: 0\n    t.datetime \"created_at\", null: false\n    t.datetime \"first_failure_at\"\n    t.datetime \"updated_at\", null: false\n    t.uuid \"webhook_id\", null: false\n    t.index [\"account_id\"], name: \"index_webhook_delinquency_trackers_on_account_id\"\n    t.index [\"webhook_id\"], name: \"index_webhook_delinquency_trackers_on_webhook_id\"\n  end\n\n  create_table \"webhook_deliveries\", id: :uuid, force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.uuid \"event_id\", null: false\n    t.text \"request\", limit: 65535\n    t.text \"response\", limit: 65535\n    t.string \"state\", limit: 255, null: false\n    t.datetime \"updated_at\", null: false\n    t.uuid \"webhook_id\", null: false\n    t.index [\"account_id\"], name: \"index_webhook_deliveries_on_account_id\"\n    t.index [\"created_at\"], name: \"index_webhook_deliveries_on_created_at\"\n    t.index [\"event_id\"], name: \"index_webhook_deliveries_on_event_id\"\n    t.index [\"webhook_id\"], name: \"index_webhook_deliveries_on_webhook_id\"\n  end\n\n  create_table \"webhooks\", id: :uuid, force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.boolean \"active\", default: true, null: false\n    t.uuid \"board_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.string \"name\", limit: 255\n    t.string \"signing_secret\", limit: 255, null: false\n    t.text \"subscribed_actions\", limit: 65535\n    t.datetime \"updated_at\", null: false\n    t.text \"url\", limit: 65535, null: false\n    t.index [\"account_id\"], name: \"index_webhooks_on_account_id\"\n    t.index [\"board_id\", \"subscribed_actions\"], name: \"index_webhooks_on_board_id_and_subscribed_actions\"\n  end\n  execute \"CREATE VIRTUAL TABLE search_records_fts USING fts5(\\n        title,\\n        content,\\n        tokenize='porter'\\n      )\"\n\nend\n"
  },
  {
    "path": "db/seeds/37signals.rb",
    "content": "create_tenant \"37signals\"\n\ndavid = find_or_create_user \"David Heinemeier Hansson\", \"david@example.com\"\njason = find_or_create_user \"Jason Fried\", \"jason@example.com\"\njz    = find_or_create_user \"Jason Zimdars\", \"jz@example.com\"\nkevin = find_or_create_user \"Kevin Mcconnell\", \"kevin@example.com\"\n\nlogin_as david\n\ncreate_board(\"Fizzy\", access_to: [ jason, jz, kevin ]).tap do |fizzy|\n  create_card(\"Prepare sign-up page\", description: \"We need to do this before the launch.\", board: fizzy)\n\n  create_card(\"Prepare sign-up page\", description: \"We need to do this before the launch.\", board: fizzy).tap do |card|\n    card.toggle_assignment(kevin)\n    if column = card.board&.columns&.sample\n      card.triage_into(column)\n    end\n  end\n\n  create_card(\"Plain text mentions\", description: \"We'll support plain text mentions first.\", board: fizzy).tap do |card|\n    card.toggle_assignment(david)\n    card.close\n  end\nend\n"
  },
  {
    "path": "db/seeds/cleanslate.rb",
    "content": "create_tenant \"cleanslate\"\n"
  },
  {
    "path": "db/seeds/honcho.rb",
    "content": "create_tenant \"Honcho\"\n\ndavid = find_or_create_user \"David Heinemeier Hansson\", \"david@example.com\"\njason = find_or_create_user \"Jason Fried\", \"jason@example.com\"\njz    = find_or_create_user \"Jason Zimdars\", \"jz@example.com\"\nkevin = find_or_create_user \"Kevin McConnell\", \"kevin@example.com\"\njorge = find_or_create_user \"Jorge Manrubia\", \"jorge@example.com\"\nmike  = find_or_create_user \"Mike Dalessio\", \"mike@example.com\"\n\nlogin_as david\n\nauthors = [ david, jason, jz, kevin, jorge, mike ]\n\ncard_titles = [\n  \"Implement authentication\",\n  \"Design landing page\",\n  \"Set up database\",\n  \"Create API endpoints\",\n  \"Write unit tests\",\n  \"Optimize performance\",\n  \"Add user profiles\",\n  \"Implement search\",\n  \"Create admin panel\",\n  \"Set up CI/CD\"\n]\n\nboards = [\n  \"Project Launch\",\n  \"Frontend Dev\",\n  \"Backend Dev\",\n  \"Design System\",\n  \"Testing Suite\"\n]\n\ntime_range = (60 .. 30.days.in_minutes)\n\nboards.each_with_index do |board_name, index|\n  create_board(board_name, access_to: authors.sample(3)).tap do |board|\n    card_titles.each do |title|\n      travel(-rand(time_range).minutes) do\n        card = create_card title,\n                           description: \"#{title} for #{board_name} phase #{index + 1}.\",\n                           board: board,\n                           creator: authors.sample\n\n        # Randomly assign to 1-2 authors\n        travel rand(0..20).minutes\n        card.toggle_assignment(authors.sample)\n\n        if rand > 0.5\n          travel rand(0..20).minutes\n          card.toggle_assignment(authors.sample)\n        end\n\n        # Randomly set card state\n        travel rand(0..20).minutes\n        case rand(3)\n        when 0\n          if column = card.board&.columns&.sample\n            card.triage_into(column)\n          end\n        when 1\n          card.close\n          # 2 remains open\n        end\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "db/seeds.rb",
    "content": "unless Rails.env.development?\n  puts \"WARN: Seeding is just for development!\"\nelse\n  require \"active_support/testing/time_helpers\"\n  include ActiveSupport::Testing::TimeHelpers\n\n  # Seed DSL\n  def seed_account(name)\n    print \"  #{name}…\"\n    elapsed = Benchmark.realtime { require_relative \"seeds/#{name}\" }\n    puts \" #{elapsed.round(2)} sec\"\n  end\n\n  def create_tenant(signal_account_name)\n    tenant_id = ActiveRecord::FixtureSet.identify signal_account_name\n    email_address = \"david@example.com\"\n    identity = Identity.find_or_create_by!(email_address: email_address, staff: true)\n\n    unless account = Account.find_by(external_account_id: tenant_id)\n      account = Account.create_with_owner(\n        account: {\n          external_account_id: tenant_id,\n          name: signal_account_name\n        },\n        owner: {\n          name: \"David Heinemeier Hansson\",\n          identity: identity\n        }\n      )\n    end\n    Current.account = account\n  end\n\n  def find_or_create_user(full_name, email_address)\n    identity = Identity.find_or_create_by!(email_address: email_address)\n    if user = identity.users.find_by(account: Current.account)\n      user\n    else\n      User.create!(name: full_name, identity: identity, account: Current.account, verified_at: Time.current)\n    end\n  end\n\n  def login_as(user)\n    Current.session = user.identity.sessions.create\n  end\n\n  def create_board(name, creator: Current.user, all_access: true, access_to: [])\n    Board.find_or_create_by!(name:, creator:, all_access:).tap { it.accesses.grant_to(access_to) }\n  end\n\n  def create_card(title, board:, description: nil, status: :published, creator: Current.user)\n    board.cards.create!(title:, description:, creator:, status:)\n  end\n\n  # Seed accounts\n  seed_account \"cleanslate\"\n  seed_account \"37signals\"\n  seed_account \"honcho\"\nend\n"
  },
  {
    "path": "docs/API.md",
    "content": "# Fizzy API\n\nFizzy has an API that allows you to integrate your application with it or to create\na bot to perform various actions for you.\n\n## Authentication\n\nThere are two ways to authenticate with the Fizzy API:\n\n1. **Personal access tokens** - Long-lived tokens for scripts and integrations\n2. **Magic link authentication** - Session-based authentication for native apps\n\n### Personal Access Tokens\n\nTo use the API you'll need an access token. To get one, go to your profile, then,\nin the API section, click on \"Personal access tokens\" and then click on\n\"Generate new access token\".\n\nGive it a description and pick what kind of permission you want the access token to have:\n- `Read`: allows reading data from your account\n- `Read + Write`: allows reading and writing data to your account on your behalf\n\nThen click on \"Generate access token\".\n\n<details>\n  <summary>Access token generation guide with screenshots</summary>\n\n  | Step | Description | Screenshot |\n  |:----:|-------------|:----------:|\n  | 1 | Go to your profile | <img width=\"400\" alt=\"Profile page with API section\" src=\"https://github.com/user-attachments/assets/49e7e12b-2952-4220-84fd-cef99b13bc04\" /> |\n  | 2 | In the API section click on \"Personal access token\" | <img width=\"400\" alt=\"Personal access tokens page\" src=\"https://github.com/user-attachments/assets/2f026ea0-416f-4fbe-a097-61313f24f180\" /> |\n  | 3 | Click on \"Generate a new access token\" | <img width=\"400\" alt=\"Generate new access token dialog\" src=\"https://github.com/user-attachments/assets/d766f047-8628-416d-8e21-b89522f6c0d9\" /> |\n  | 4 | Give it a description and assign it a permission | <img width=\"400\" alt=\"Access token created\" src=\"https://github.com/user-attachments/assets/49b8e350-d152-4946-8aad-e13260b983fd\" /> |\n</details>\n\n> [!IMPORTANT]\n> __An access token is like a password, keep it secret and do not share it with anyone.__\n> Any person or application that has your access token can perform actions on your behalf.\n\nTo authenticate a request using your access token, include it in the `Authorization` header:\n\n```bash\ncurl -H \"Authorization: Bearer put-your-access-token-here\" -H \"Accept: application/json\" https://app.fizzy.do/my/identity\n```\n\n### Magic Link Authentication\n\nFor native apps, you can authenticate users via magic links. This is a two-step process:\n\n#### 1. Request a magic link\n\nSend the user's email address to request a magic link be sent to them:\n\n```bash\ncurl -X POST \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Accept: application/json\" \\\n  -d '{\"email_address\": \"user@example.com\"}' \\\n  https://app.fizzy.do/session\n```\n\n__Response:__\n\n```\nHTTP/1.1 201 Created\nSet-Cookie: pending_authentication_token=...; HttpOnly; SameSite=Lax\n```\n\n```json\n{\n  \"pending_authentication_token\": \"eyJfcmFpbHMi...\"\n}\n```\n\nThe response includes a `pending_authentication_token` both in the JSON body and as a cookie.\nNative apps should store this token and include it as a cookie when submitting the magic link code.\n\n__Error responses:__\n\n| Status Code | Description |\n|--------|-------------|\n| `422 Unprocessable entity` | Invalid email address, if sign ups are enabled and the value isn't a valid email address |\n| `429 Too Many Requests` | Rate limit exceeded |\n\n#### 2. Submit the magic link code\n\nOnce the user receives the magic link email, they'll have a 6-character code. Submit it to complete authentication:\n\n```bash\ncurl -X POST \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Accept: application/json\" \\\n  -H \"Cookie: pending_authentication_token=eyJfcmFpbHMi...\" \\\n  -d '{\"code\": \"ABC123\"}' \\\n  https://app.fizzy.do/session/magic_link\n```\n\n__Response:__\n\n```json\n{\n  \"session_token\": \"eyJfcmFpbHMi...\"\n}\n```\n\nThe `session_token` can be used to authenticate subsequent requests by including it as a cookie:\n\n```bash\ncurl -H \"Cookie: session_token=eyJfcmFpbHMi...\" \\\n  -H \"Accept: application/json\" \\\n  https://app.fizzy.do/my/identity\n```\n\n__Error responses:__\n\n| Status Code | Description |\n|--------|-------------|\n| `401 Unauthorized` | Invalid `pending_authentication_token` or `code` |\n| `429 Too Many Requests` | Rate limit exceeded |\n\n\n#### Delete server-side session (_log out_)\n\nTo log out and destroy the server-side session:\n\n```bash\ncurl -X DELETE \\\n  -H \"Accept: application/json\" \\\n  -H \"Cookie: session_token=eyJfcmFpbHMi...\" \\\n  https://app.fizzy.do/session\n```\n\n__Response:__\n\nReturns `204 No Content` on success.\n\n#### Create an access token via the API\n\nYou can programmatically create a personal access token using either a session cookie or an existing Bearer token:\n\n```bash\ncurl -X POST \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Accept: application/json\" \\\n  -H \"Cookie: session_token=eyJfcmFpbHMi...\" \\\n  -d '{\"access_token\": {\"description\": \"Fizzy CLI\", \"permission\": \"write\"}}' \\\n  https://app.fizzy.do/1234567/my/access_tokens\n```\n\nOr with a Bearer token (must have `write` permission):\n\n```bash\ncurl -X POST \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Accept: application/json\" \\\n  -H \"Authorization: Bearer put-your-access-token-here\" \\\n  -d '{\"access_token\": {\"description\": \"Fizzy CLI\", \"permission\": \"write\"}}' \\\n  https://app.fizzy.do/1234567/my/access_tokens\n```\n\nThe `permission` field accepts `read` or `write`.\n\n__Response:__\n\n```\nHTTP/1.1 201 Created\n```\n\n```json\n{\n  \"token\": \"4f9Q6d2wXr8Kp1Ls0Vz3BnTa\",\n  \"description\": \"Fizzy CLI\",\n  \"permission\": \"write\"\n}\n```\n\nStore the `token` value securely — it won't be retrievable again. Use it as a Bearer token for subsequent API requests.\n\n## Caching\n\nMost endpoints return [ETag](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/ETag) and [Cache-Control](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control) headers. You can use these to avoid re-downloading unchanged data.\n\n### Using ETags\n\nWhen you make a request, the response includes an `ETag` header:\n\n```\nHTTP/1.1 200 OK\nETag: \"abc123\"\nCache-Control: max-age=0, private, must-revalidate\n```\n\nOn subsequent requests, include the ETag value in the `If-None-Match` header:\n\n```\nGET /1234567/cards/42.json\nIf-None-Match: \"abc123\"\n```\n\nIf the resource hasn't changed, you'll receive a `304 Not Modified` response with no body, saving bandwidth and processing time:\n\n```\nHTTP/1.1 304 Not Modified\nETag: \"abc123\"\n```\n\nIf the resource has changed, you'll receive the full response with a new ETag.\n\n__Example in Ruby:__\n\n```ruby\n# Store the ETag from the response\netag = response.headers[\"ETag\"]\n\n# On next request, send it back\nheaders = { \"If-None-Match\" => etag }\nresponse = client.get(\"/1234567/cards/42.json\", headers: headers)\n\nif response.status == 304\n  # Nothing to do, the card hasn't changed\nelse\n  # The card has changed, process the new data\nend\n```\n\n## Error Responses\n\nWhen a request fails, the API response will communicate the source of the problem through the HTTP status code.\n\n| Status Code | Description |\n|-------------|-------------|\n| `400 Bad Request` | The request was malformed or missing required parameters |\n| `401 Unauthorized` | Authentication failed or access token is invalid |\n| `403 Forbidden` | You don't have permission to perform this action |\n| `404 Not Found` | The requested resource doesn't exist or you don't have access to it |\n| `422 Unprocessable Entity` | Validation failed (see error response format above) |\n| `500 Internal Server Error` | An unexpected error occurred on the server |\n\nIf a request contains invalid data for fields, such as entering a string into a number field, in most cases the API will respond with a `500 Internal Server Error`. Clients are expected to perform some validation on their end before making a request.\n\nA validation error will produce a `422 Unprocessable Entity` response, which will sometimes be accompanied by details about the error:\n\n```json\n{\n  \"avatar\": [\"must be a JPEG, PNG, GIF, or WebP image\"]\n}\n```\n\n## Pagination\n\nAll endpoints that return a list of items are paginated. The page size can vary from endpoint to endpoint,\nand we use a dynamic page size where initial pages return fewer results than later pages.\n\nIf there are more results to fetch, the response will include a `Link` header with a `rel=\"next\"` link to the next page of results:\n\n```bash\ncurl -H \"Authorization: Bearer put-your-access-token-here\" -H \"Accept: application/json\" -v http://fizzy.localhost:3006/686465299/cards\n# ...\n< link: <http://fizzy.localhost:3006/686465299/cards?page=2>; rel=\"next\"\n# ...\n```\n\n## List parameters\n\nWhen an endpoint accepts a list of values as a parameter, you can provide multiple values by repeating the parameter name:\n\n```\n?tag_ids[]=tag1&tag_ids[]=tag2&tag_ids[]=tag3\n```\n\nList parameters always end with `[]`.\n\n## File Uploads\n\nSome endpoints accept file uploads. To upload a file, send a `multipart/form-data` request instead of JSON.\nYou can combine file uploads with other parameters in the same request.\n\n__Example using curl:__\n\n```bash\ncurl -X PUT \\\n  -H \"Authorization: Bearer put-your-access-token-here\" \\\n  -F \"user[name]=David H. Hansson\" \\\n  -F \"user[avatar]=@/path/to/avatar.jpg\" \\\n  http://fizzy.localhost:3006/686465299/users/03f5v9zjw7pz8717a4no1h8a7\n```\n\n## Rich Text Fields\n\nSome fields accept rich text content. These fields accept HTML input, which will be sanitized to remove unsafe tags and attributes.\n\n```json\n{\n  \"card\": {\n    \"title\": \"My card\",\n    \"description\": \"<p>This is <strong>bold</strong> and this is <em>italic</em>.</p><ul><li>Item 1</li><li>Item 2</li></ul>\"\n  }\n}\n```\n\n### Attaching files to rich text\n\nTo attach files (images, documents) to rich text fields, use ActionText's direct upload flow:\n\n#### 1. Create a direct upload\n\nFirst, request a direct upload URL by sending file metadata:\n\n```bash\ncurl -X POST \\\n  -H \"Authorization: Bearer put-your-access-token-here\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"blob\": {\n      \"filename\": \"screenshot.png\",\n      \"byte_size\": 12345,\n      \"checksum\": \"GQ5SqLsM7ylnji0Wgd9wNA==\",\n      \"content_type\": \"image/png\"\n    }\n  }' \\\n  https://app.fizzy.do/123456/rails/active_storage/direct_uploads\n```\n\nThe `checksum` is a Base64-encoded MD5 hash of the file content.\nThe direct upload endpoint is scoped to your account (replace `/123456` with your account slug).\n\n__Response:__\n\n```json\n{\n  \"id\": \"abc123\",\n  \"key\": \"abc123def456\",\n  \"filename\": \"screenshot.png\",\n  \"content_type\": \"image/png\",\n  \"byte_size\": 12345,\n  \"checksum\": \"GQ5SqLsM7ylnji0Wgd9wNA==\",\n  \"direct_upload\": {\n    \"url\": \"https://storage.example.com/...\",\n    \"headers\": {\n      \"Content-Type\": \"image/png\",\n      \"Content-MD5\": \"GQ5SqLsM7ylnji0Wgd9wNA==\"\n    }\n  },\n  \"signed_id\": \"eyJfcmFpbHMi...\"\n}\n```\n\n#### 2. Upload the file\n\nUpload the file directly to the provided URL with the specified headers:\n\n```bash\ncurl -X PUT \\\n  -H \"Content-Type: image/png\" \\\n  -H \"Content-MD5: GQ5SqLsM7ylnji0Wgd9wNA==\" \\\n  --data-binary @screenshot.png \\\n  \"https://storage.example.com/...\"\n```\n\n#### 3. Reference the file in rich text\n\nUse the `signed_id` from step 1 to embed the file in your rich text using an `<action-text-attachment>` tag:\n\n```json\n{\n  \"card\": {\n    \"title\": \"Card with image\",\n    \"description\": \"<p>Here's a screenshot:</p><action-text-attachment sgid=\\\"eyJfcmFpbHMi...\\\"></action-text-attachment>\"\n  }\n}\n```\n\nThe `sgid` attribute should contain the `signed_id` returned from the direct upload response.\n\n## Identity\n\nAn Identity represents a person using Fizzy.\n\n### `GET /my/identity`\n\nReturns a list of accounts the identity has access to, including the user for each account.\n\n```json\n{\n  \"accounts\": [\n    {\n      \"id\": \"03f5v9zjskhcii2r45ih3u1rq\",\n      \"name\": \"37signals\",\n      \"slug\": \"/897362094\",\n      \"created_at\": \"2025-12-05T19:36:35.377Z\",\n      \"user\": {\n        \"id\": \"03f5v9zjw7pz8717a4no1h8a7\",\n        \"name\": \"David Heinemeier Hansson\",\n        \"role\": \"owner\",\n        \"active\": true,\n        \"email_address\": \"david@example.com\",\n        \"created_at\": \"2025-12-05T19:36:35.401Z\",\n        \"url\": \"http://fizzy.localhost:3006/users/03f5v9zjw7pz8717a4no1h8a7\"\n      }\n    },\n    {\n      \"id\": \"03f5v9zpko7mmhjzwum3youpp\",\n      \"name\": \"Honcho\",\n      \"slug\": \"/686465299\",\n      \"created_at\": \"2025-12-05T19:36:36.746Z\",\n      \"user\": {\n        \"id\": \"03f5v9zppzlksuj4mxba2nbzn\",\n        \"name\": \"David Heinemeier Hansson\",\n        \"role\": \"owner\",\n        \"active\": true,\n        \"email_address\": \"david@example.com\",\n        \"created_at\": \"2025-12-05T19:36:36.783Z\",\n        \"url\": \"http://fizzy.localhost:3006/users/03f5v9zppzlksuj4mxba2nbzn\"\n      }\n    }\n  ]\n}\n```\n\n## Boards\n\nBoards are where you organize your work - they contain your cards.\n\n### `GET /:account_slug/boards`\n\nReturns a list of boards that you can access in the specified account.\n\n__Response:__\n\n```json\n[\n  {\n    \"id\": \"03f5v9zkft4hj9qq0lsn9ohcm\",\n    \"name\": \"Fizzy\",\n    \"all_access\": true,\n    \"created_at\": \"2025-12-05T19:36:35.534Z\",\n    \"auto_postpone_period_in_days\": 30,\n    \"url\": \"http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm\",\n    \"creator\": {\n      \"id\": \"03f5v9zjw7pz8717a4no1h8a7\",\n      \"name\": \"David Heinemeier Hansson\",\n      \"role\": \"owner\",\n      \"active\": true,\n      \"email_address\": \"david@example.com\",\n      \"created_at\": \"2025-12-05T19:36:35.401Z\",\n      \"url\": \"http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7\"\n    }\n  }\n]\n```\n\n### `GET /:account_slug/boards/:board_id`\n\nReturns the specified board.\n\n__Response:__\n\n```json\n{\n  \"id\": \"03f5v9zkft4hj9qq0lsn9ohcm\",\n  \"name\": \"Fizzy\",\n  \"all_access\": true,\n  \"created_at\": \"2025-12-05T19:36:35.534Z\",\n  \"auto_postpone_period_in_days\": 30,\n  \"url\": \"http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm\",\n  \"creator\": {\n    \"id\": \"03f5v9zjw7pz8717a4no1h8a7\",\n    \"name\": \"David Heinemeier Hansson\",\n    \"role\": \"owner\",\n    \"active\": true,\n    \"email_address\": \"david@example.com\",\n    \"created_at\": \"2025-12-05T19:36:35.401Z\",\n    \"url\": \"http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7\"\n  },\n  \"public_url\": \"http://fizzy.localhost:3006/897362094/public/boards/aB3dEfGhIjKlMnOp\"\n}\n```\n\nThe `public_url` field is only present when the board is published.\n\n### `POST /:account_slug/boards`\n\nCreates a new Board in the account.\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `name` | string | Yes | The name of the board |\n| `all_access` | boolean | No | Whether any user in the account can access this board. Defaults to `true` |\n| `auto_postpone_period_in_days` | integer | No | Number of days of inactivity before cards are automatically postponed (e.g. `30`) |\n| `public_description` | string | No | Rich text description shown on the public board page |\n\n__Request:__\n\n```json\n{\n  \"board\": {\n    \"name\": \"My new board\",\n  }\n}\n```\n\n__Response:__\n\nReturns `201 Created` with a `Location` header pointing to the new board:\n\n```\nHTTP/1.1 201 Created\nLocation: /897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm.json\n```\n\n### `PUT /:account_slug/boards/:board_id`\n\nUpdates a Board. Only board administrators can update a board.\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `name` | string | No | The name of the board |\n| `all_access` | boolean | No | Whether any user in the account can access this board |\n| `auto_postpone_period_in_days` | integer | No | Number of days of inactivity before cards are automatically postponed (e.g. `30`) |\n| `public_description` | string | No | Rich text description shown on the public board page |\n| `user_ids` | array | No | Array of *all* user IDs who should have access to this board (only applicable when `all_access` is `false`) |\n\n__Request:__\n\n```json\n{\n  \"board\": {\n    \"name\": \"Updated board name\",\n    \"auto_postpone_period_in_days\": 30,\n    \"public_description\": \"This is a **public** description of the board.\",\n    \"all_access\": false,\n    \"user_ids\": [\n      \"03f5v9zppzlksuj4mxba2nbzn\",\n      \"03f5v9zjw7pz8717a4no1h8a7\"\n    ]\n  }\n}\n```\n\n__Response:__\n\nReturns `204 No Content` on success.\n\n### `DELETE /:account_slug/boards/:board_id`\n\nDeletes a Board. Only board administrators can delete a board.\n\n__Response:__\n\nReturns `204 No Content` on success.\n\n## Board Publications\n\nPublishing a board makes it publicly accessible via a shareable link, without requiring authentication. Only board administrators can publish or unpublish a board.\n\n### `POST /:account_slug/boards/:board_id/publication`\n\nPublishes a board, generating a shareable public link.\n\n__Response:__\n\n```\nHTTP/1.1 201 Created\n```\n\n```json\n{\n  \"id\": \"03f5v9zkft4hj9qq0lsn9ohcm\",\n  \"name\": \"Fizzy\",\n  \"all_access\": true,\n  \"created_at\": \"2025-12-05T19:36:35.534Z\",\n  \"auto_postpone_period_in_days\": 30,\n  \"url\": \"http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm\",\n  \"creator\": {\n    \"id\": \"03f5v9zjw7pz8717a4no1h8a7\",\n    \"name\": \"David Heinemeier Hansson\",\n    \"role\": \"owner\",\n    \"active\": true,\n    \"email_address\": \"david@example.com\",\n    \"created_at\": \"2025-12-05T19:36:35.401Z\",\n    \"url\": \"http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7\"\n  },\n  \"public_url\": \"http://fizzy.localhost:3006/897362094/public/boards/aB3dEfGhIjKlMnOp\"\n}\n```\n\nIf the board is already published, the existing publication is returned.\n\n### `DELETE /:account_slug/boards/:board_id/publication`\n\nUnpublishes a board, removing public access.\n\n__Response:__\n\nReturns `204 No Content` on success.\n\n## Account\n\n### `GET /account/settings`\n\nReturns the current account.\n\n__Response:__\n\n```json\n{\n  \"id\": \"03f5v9zjvypwh0t0e2rfh0h7k\",\n  \"name\": \"37signals\",\n  \"cards_count\": 5,\n  \"created_at\": \"2025-12-05T19:36:35.401Z\",\n  \"auto_postpone_period_in_days\": 30\n}\n```\n\nThe `auto_postpone_period_in_days` is the account-level default in days (e.g. `30`). Cards are automatically moved to \"Not Now\" after this period of inactivity. Each board can override this with its own value.\n\n### `PUT /account/entropy`\n\nUpdates the account-level default auto close period. Requires admin role.\n\n__Request:__\n\n```json\n{\n  \"entropy\": {\n    \"auto_postpone_period_in_days\": 30\n  }\n}\n```\n\n__Response:__\n\nReturns the account object:\n\n```json\n{\n  \"id\": \"03f5v9zjvypwh0t0e2rfh0h7k\",\n  \"name\": \"37signals\",\n  \"cards_count\": 5,\n  \"created_at\": \"2025-12-05T19:36:35.401Z\",\n  \"auto_postpone_period_in_days\": 30\n}\n```\n\n### `PUT /:account_slug/boards/:board_id/entropy`\n\nUpdates the auto close period for a specific board. Requires board admin permission.\n\n__Request:__\n\n```json\n{\n  \"board\": {\n    \"auto_postpone_period_in_days\": 90\n  }\n}\n```\n\n__Response:__\n\nReturns the board object.\n\n## Webhooks\n\nWebhooks notify another application when something happens on a board. Only account admins can list, view, create, update, delete, or reactivate webhooks.\n\n### `GET /:account_slug/boards/:board_id/webhooks`\n\nReturns a paginated list of webhooks for a board.\n\n__Response:__\n\n```json\n[\n  {\n    \"id\": \"03f5v9zkft4hj9qq0lsn9ohcm\",\n    \"name\": \"Production API\",\n    \"payload_url\": \"https://api.example.com/webhooks\",\n    \"active\": true,\n    \"signing_secret\": \"p94Bx2HjempCdYB4DTyZkY1b\",\n    \"subscribed_actions\": [\"card_published\", \"card_assigned\", \"card_closed\"],\n    \"created_at\": \"2025-12-05T19:36:35.534Z\",\n    \"url\": \"http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcy/webhooks/03f5v9zkft4hj9qq0lsn9ohcm\",\n    \"board\": {\n      \"id\": \"03f5v9zkft4hj9qq0lsn9ohcy\",\n      \"name\": \"Fizzy\",\n      \"all_access\": true,\n      \"created_at\": \"2025-12-05T19:36:35.534Z\",\n      \"url\": \"http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcy\",\n      \"creator\": {\n        \"id\": \"03f5v9zjw7pz8717a4no1h8a7\",\n        \"name\": \"David Heinemeier Hansson\",\n        \"role\": \"owner\",\n        \"active\": true,\n        \"email_address\": \"david@example.com\",\n        \"created_at\": \"2025-12-05T19:36:35.401Z\",\n        \"url\": \"http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7\"\n      }\n    }\n  }\n]\n```\n\n### `GET /:account_slug/boards/:board_id/webhooks/:id`\n\nReturns a single webhook.\n\n__Response:__\n\nReturns the same webhook shape shown above.\n\n### `POST /:account_slug/boards/:board_id/webhooks`\n\nCreates a webhook.\n\n__Request:__\n\n```json\n{\n  \"webhook\": {\n    \"name\": \"Production API\",\n    \"url\": \"https://api.example.com/webhooks\",\n    \"subscribed_actions\": [\"card_published\", \"card_assigned\", \"card_closed\"]\n  }\n}\n```\n\n`subscribed_actions` accepts any of:\n`card_assigned`, `card_closed`, `card_postponed`, `card_auto_postponed`, `card_board_changed`, `card_published`, `card_reopened`, `card_sent_back_to_triage`, `card_triaged`, `card_unassigned`, `comment_created`\n\n__Response:__\n\n```\nHTTP/1.1 201 Created\nLocation: http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcy/webhooks/03f5v9zkft4hj9qq0lsn9ohcm.json\n```\n\nReturns the created webhook in the response body.\n\n### `PATCH /:account_slug/boards/:board_id/webhooks/:id`\n\nUpdates a webhook.\n\n__Request:__\n\n```json\n{\n  \"webhook\": {\n    \"name\": \"Production API\",\n    \"subscribed_actions\": [\"card_closed\"]\n  }\n}\n```\n\nThe `url` is immutable after creation and is ignored on update.\n\n__Response:__\n\nReturns the updated webhook.\n\n### `DELETE /:account_slug/boards/:board_id/webhooks/:id`\n\nDeletes a webhook.\n\n__Response:__\n\nReturns `204 No Content` on success.\n\n### `POST /:account_slug/boards/:board_id/webhooks/:id/activation`\n\nReactivates a deactivated webhook.\n\n__Response:__\n\n```\nHTTP/1.1 201 Created\n```\n\nReturns the reactivated webhook in the response body.\n\n## Cards\n\nCards are tasks or items of work on a board. They can be organized into columns, tagged, assigned to users, and have comments.\n\n### `GET /:account_slug/cards`\n\nReturns a paginated list of cards you have access to. Results can be filtered using query parameters.\n\n__Query Parameters:__\n\n| Parameter | Description |\n|-----------|-------------|\n| `board_ids[]` | Filter by board ID(s) |\n| `tag_ids[]` | Filter by tag ID(s) |\n| `assignee_ids[]` | Filter by assignee user ID(s) |\n| `creator_ids[]` | Filter by card creator ID(s) |\n| `closer_ids[]` | Filter by user ID(s) who closed the cards |\n| `card_ids[]` | Filter to specific card ID(s) |\n| `indexed_by` | Filter by: `all` (default), `closed`, `not_now`, `stalled`, `postponing_soon`, `golden` |\n| `sorted_by` | Sort order: `latest` (default), `newest`, `oldest` |\n| `assignment_status` | Filter by assignment status: `unassigned` |\n| `creation` | Filter by creation date: `today`, `yesterday`, `thisweek`, `lastweek`, `thismonth`, `lastmonth`, `thisyear`, `lastyear` |\n| `closure` | Filter by closure date: `today`, `yesterday`, `thisweek`, `lastweek`, `thismonth`, `lastmonth`, `thisyear`, `lastyear` |\n| `terms[]` | Search terms to filter cards |\n\n__Response:__\n\n```json\n[\n  {\n    \"id\": \"03f5vaeq985jlvwv3arl4srq2\",\n    \"number\": 1,\n    \"title\": \"First!\",\n    \"status\": \"published\",\n    \"description\": \"Hello, World!\",\n    \"description_html\": \"<div class=\\\"action-text-content\\\"><p>Hello, World!</p></div>\",\n    \"image_url\": null,\n    \"has_attachments\": false,\n    \"tags\": [\"programming\"],\n    \"golden\": false,\n    \"last_active_at\": \"2025-12-05T19:38:48.553Z\",\n    \"created_at\": \"2025-12-05T19:38:48.540Z\",\n    \"url\": \"http://fizzy.localhost:3006/897362094/cards/4\",\n    \"board\": {\n      \"id\": \"03f5v9zkft4hj9qq0lsn9ohcm\",\n      \"name\": \"Fizzy\",\n      \"all_access\": true,\n      \"created_at\": \"2025-12-05T19:36:35.534Z\",\n      \"auto_postpone_period_in_days\": 30,\n      \"url\": \"http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm\",\n      \"creator\": {\n        \"id\": \"03f5v9zjw7pz8717a4no1h8a7\",\n        \"name\": \"David Heinemeier Hansson\",\n        \"role\": \"owner\",\n        \"active\": true,\n        \"email_address\": \"david@example.com\",\n        \"created_at\": \"2025-12-05T19:36:35.401Z\",\n        \"url\": \"http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7\"\n      }\n    },\n    \"creator\": {\n      \"id\": \"03f5v9zjw7pz8717a4no1h8a7\",\n      \"name\": \"David Heinemeier Hansson\",\n      \"role\": \"owner\",\n      \"active\": true,\n      \"email_address\": \"david@example.com\",\n      \"created_at\": \"2025-12-05T19:36:35.401Z\",\n      \"url\": \"http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7\"\n    },\n    \"comments_url\": \"http://fizzy.localhost:3006/897362094/cards/4/comments\",\n    \"reactions_url\": \"http://fizzy.localhost:3006/897362094/cards/4/reactions\"\n  },\n]\n```\n\n### `GET /:account_slug/cards/:card_number`\n\nReturns a specific card by its number.\n\n__Response:__\n\n```json\n{\n  \"id\": \"03f5vaeq985jlvwv3arl4srq2\",\n  \"number\": 1,\n  \"title\": \"First!\",\n  \"status\": \"published\",\n  \"description\": \"Hello, World!\",\n  \"description_html\": \"<div class=\\\"action-text-content\\\"><p>Hello, World!</p></div>\",\n  \"image_url\": null,\n  \"has_attachments\": false,\n  \"tags\": [\"programming\"],\n  \"closed\": false,\n  \"golden\": false,\n  \"last_active_at\": \"2025-12-05T19:38:48.553Z\",\n  \"created_at\": \"2025-12-05T19:38:48.540Z\",\n  \"url\": \"http://fizzy.localhost:3006/897362094/cards/4\",\n  \"board\": {\n    \"id\": \"03f5v9zkft4hj9qq0lsn9ohcm\",\n    \"name\": \"Fizzy\",\n    \"all_access\": true,\n    \"created_at\": \"2025-12-05T19:36:35.534Z\",\n    \"auto_postpone_period_in_days\": 30,\n    \"url\": \"http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm\",\n    \"creator\": {\n      \"id\": \"03f5v9zjw7pz8717a4no1h8a7\",\n      \"name\": \"David Heinemeier Hansson\",\n      \"role\": \"owner\",\n      \"active\": true,\n      \"email_address\": \"david@example.com\",\n      \"created_at\": \"2025-12-05T19:36:35.401Z\",\n      \"url\": \"http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7\"\n    }\n  },\n  \"column\": {\n    \"id\": \"03f5v9zkft4hj9qq0lsn9ohcn\",\n    \"name\": \"In Progress\",\n    \"color\": {\n      \"name\": \"Lime\",\n      \"value\": \"var(--color-card-4)\"\n    },\n    \"created_at\": \"2025-12-05T19:36:35.534Z\"\n  },\n  \"creator\": {\n    \"id\": \"03f5v9zjw7pz8717a4no1h8a7\",\n    \"name\": \"David Heinemeier Hansson\",\n    \"role\": \"owner\",\n    \"active\": true,\n    \"email_address\": \"david@example.com\",\n    \"created_at\": \"2025-12-05T19:36:35.401Z\",\n    \"url\": \"http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7\"\n  },\n  \"comments_url\": \"http://fizzy.localhost:3006/897362094/cards/4/comments\",\n  \"reactions_url\": \"http://fizzy.localhost:3006/897362094/cards/4/reactions\",\n  \"steps\": [\n    {\n      \"id\": \"03f8huu0sog76g3s975963b5e\",\n      \"content\": \"This is the first step\",\n      \"completed\": false\n    },\n    {\n      \"id\": \"03f8huu0sog76g3s975969734\",\n      \"content\": \"This is the second step\",\n      \"completed\": false\n    }\n  ]\n}\n```\n\n> **Note:** The `closed` field indicates whether the card is in the \"Done\" state. The `column` field is only present when the card has been triaged into a column; cards in \"Maybe?\", \"Not Now\" or \"Done\" will not have this field.\n\n### `POST /:account_slug/boards/:board_id/cards`\n\nCreates a new card in a board.\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `title` | string | Yes | The title of the card |\n| `description` | string | No | Rich text description of the card |\n| `status` | string | No | Initial status: `published` (default), `drafted` |\n| `image` | file | No | Header image for the card |\n| `tag_ids` | array | No | Array of tag IDs to apply to the card |\n| `created_at` | datetime | No | Override creation timestamp (ISO 8601 format) |\n| `last_active_at` | datetime | No | Override last activity timestamp (ISO 8601 format) |\n\n__Request:__\n\n```json\n{\n  \"card\": {\n    \"title\": \"Add dark mode support\",\n    \"description\": \"We need to add dark mode to the app\"\n  }\n}\n```\n\n__Response:__\n\nReturns `201 Created` with a `Location` header pointing to the new card.\n\n### `PUT /:account_slug/cards/:card_number`\n\nUpdates a card.\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `title` | string | No | The title of the card |\n| `description` | string | No | Rich text description of the card |\n| `status` | string | No | Card status: `drafted`, `published` |\n| `image` | file | No | Header image for the card |\n| `tag_ids` | array | No | Array of tag IDs to apply to the card |\n| `last_active_at` | datetime | No | Override last activity timestamp (ISO 8601 format) |\n\n__Request:__\n\n```json\n{\n  \"card\": {\n    \"title\": \"Add dark mode support (Updated)\"\n  }\n}\n```\n\n__Response:__\n\nReturns the updated card.\n\n### `DELETE /:account_slug/cards/:card_number`\n\nDeletes a card. Only the card creator or board administrators can delete cards.\n\n__Response:__\n\nReturns `204 No Content` on success.\n\n### `DELETE /:account_slug/cards/:card_number/image`\n\nRemoves the header image from a card.\n\n__Response:__\n\nReturns `204 No Content` on success.\n\n### `POST /:account_slug/cards/:card_number/closure`\n\nCloses a card.\n\n__Response:__\n\nReturns `204 No Content` on success.\n\n### `DELETE /:account_slug/cards/:card_number/closure`\n\nReopens a closed card.\n\n__Response:__\n\nReturns `204 No Content` on success.\n\n### `POST /:account_slug/cards/:card_number/not_now`\n\nMoves a card to \"Not Now\" status.\n\n__Response:__\n\nReturns `204 No Content` on success.\n\n### `POST /:account_slug/cards/:card_number/triage`\n\nMoves a card into a column.\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `column_id` | string | Yes | The ID of the column to move the card into |\n\n__Response:__\n\nReturns `204 No Content` on success.\n\n### `DELETE /:account_slug/cards/:card_number/triage`\n\nSends a card back to triage.\n\n__Response:__\n\nReturns `204 No Content` on success.\n\n### `POST /:account_slug/cards/:card_number/taggings`\n\nToggles a tag on or off for a card. If the tag doesn't exist, it will be created.\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `tag_title` | string | Yes | The title of the tag (leading `#` is stripped) |\n\n__Response:__\n\nReturns `204 No Content` on success.\n\n### `POST /:account_slug/cards/:card_number/assignments`\n\nToggles assignment of a user to/from a card.\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `assignee_id` | string | Yes | The ID of the user to assign/unassign |\n\n__Response:__\n\nReturns `204 No Content` on success.\n\n### `POST /:account_slug/cards/:card_number/watch`\n\nSubscribes the current user to notifications for this card.\n\n__Response:__\n\nReturns `204 No Content` on success.\n\n### `DELETE /:account_slug/cards/:card_number/watch`\n\nUnsubscribes the current user from notifications for this card.\n\n__Response:__\n\nReturns `204 No Content` on success.\n\n### `POST /:account_slug/cards/:card_number/goldness`\n\nMarks a card as golden.\n\n__Response:__\n\nReturns `204 No Content` on success.\n\n### `DELETE /:account_slug/cards/:card_number/goldness`\n\nRemoves golden status from a card.\n\n__Response:__\n\nReturns `204 No Content` on success.\n\n## Pins\n\nPins let users keep quick access to important cards.\n\n### `POST /:account_slug/cards/:card_number/pin`\n\nPins a card for the current user.\n\n__Response:__\n\nReturns `204 No Content` on success.\n\n### `DELETE /:account_slug/cards/:card_number/pin`\n\nUnpins a card for the current user.\n\n__Response:__\n\nReturns `204 No Content` on success.\n\n### `GET /my/pins`\n\nReturns the current user's pinned cards. This endpoint is not paginated and returns up to 100 cards.\n\n__Response:__\n\n```json\n[\n  {\n    \"id\": \"03f5vaeq985jlvwv3arl4srq2\",\n    \"number\": 1,\n    \"title\": \"First!\",\n    \"status\": \"published\",\n    \"description\": \"Hello, World!\",\n    \"description_html\": \"<div class=\\\"action-text-content\\\"><p>Hello, World!</p></div>\",\n    \"image_url\": null,\n    \"has_attachments\": false,\n    \"tags\": [\"programming\"],\n    \"golden\": false,\n    \"last_active_at\": \"2025-12-05T19:38:48.553Z\",\n    \"created_at\": \"2025-12-05T19:38:48.540Z\",\n    \"url\": \"http://fizzy.localhost:3006/897362094/cards/4\",\n    \"board\": {\n      \"id\": \"03f5v9zkft4hj9qq0lsn9ohcm\",\n      \"name\": \"Fizzy\",\n      \"all_access\": true,\n      \"created_at\": \"2025-12-05T19:36:35.534Z\",\n      \"auto_postpone_period_in_days\": 30,\n      \"url\": \"http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm\",\n      \"creator\": {\n        \"id\": \"03f5v9zjw7pz8717a4no1h8a7\",\n        \"name\": \"David Heinemeier Hansson\",\n        \"role\": \"owner\",\n        \"active\": true,\n        \"email_address\": \"david@example.com\",\n        \"created_at\": \"2025-12-05T19:36:35.401Z\",\n        \"url\": \"http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7\",\n        \"avatar_url\": \"http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7/avatar\"\n      }\n    },\n    \"creator\": {\n      \"id\": \"03f5v9zjw7pz8717a4no1h8a7\",\n      \"name\": \"David Heinemeier Hansson\",\n      \"role\": \"owner\",\n      \"active\": true,\n      \"email_address\": \"david@example.com\",\n      \"created_at\": \"2025-12-05T19:36:35.401Z\",\n      \"url\": \"http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7\",\n      \"avatar_url\": \"http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7/avatar\"\n    },\n    \"comments_url\": \"http://fizzy.localhost:3006/897362094/cards/4/comments\"\n  }\n]\n```\n\n## Comments\n\nComments are attached to cards and support rich text.\n\n### `GET /:account_slug/cards/:card_number/comments`\n\nReturns a paginated list of comments on a card, sorted chronologically (oldest first).\n\n__Response:__\n\n```json\n[\n  {\n    \"id\": \"03f5v9zo9qlcwwpyc0ascnikz\",\n    \"created_at\": \"2025-12-05T19:36:35.534Z\",\n    \"updated_at\": \"2025-12-05T19:36:35.534Z\",\n    \"body\": {\n      \"plain_text\": \"This looks great!\",\n      \"html\": \"<div class=\\\"action-text-content\\\">This looks great!</div>\"\n    },\n    \"creator\": {\n      \"id\": \"03f5v9zjw7pz8717a4no1h8a7\",\n      \"name\": \"David Heinemeier Hansson\",\n      \"role\": \"owner\",\n      \"active\": true,\n      \"email_address\": \"david@example.com\",\n      \"created_at\": \"2025-12-05T19:36:35.401Z\",\n      \"url\": \"http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7\"\n    },\n    \"card\": {\n      \"id\": \"03f5v9zo9qlcwwpyc0ascnikz\",\n      \"url\": \"http://fizzy.localhost:3006/897362094/cards/03f5v9zo9qlcwwpyc0ascnikz\"\n    },\n    \"reactions_url\": \"http://fizzy.localhost:3006/897362094/cards/3/comments/03f5v9zo9qlcwwpyc0ascnikz/reactions\",\n    \"url\": \"http://fizzy.localhost:3006/897362094/cards/3/comments/03f5v9zo9qlcwwpyc0ascnikz\"\n  }\n]\n```\n\n### `GET /:account_slug/cards/:card_number/comments/:comment_id`\n\nReturns a specific comment.\n\n__Response:__\n\n```json\n{\n  \"id\": \"03f5v9zo9qlcwwpyc0ascnikz\",\n  \"created_at\": \"2025-12-05T19:36:35.534Z\",\n  \"updated_at\": \"2025-12-05T19:36:35.534Z\",\n  \"body\": {\n    \"plain_text\": \"This looks great!\",\n    \"html\": \"<div class=\\\"action-text-content\\\">This looks great!</div>\"\n  },\n  \"creator\": {\n    \"id\": \"03f5v9zjw7pz8717a4no1h8a7\",\n    \"name\": \"David Heinemeier Hansson\",\n    \"role\": \"owner\",\n    \"active\": true,\n    \"email_address\": \"david@example.com\",\n    \"created_at\": \"2025-12-05T19:36:35.401Z\",\n    \"url\": \"http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7\"\n  },\n  \"card\": {\n    \"id\": \"03f5v9zo9qlcwwpyc0ascnikz\",\n    \"url\": \"http://fizzy.localhost:3006/897362094/cards/03f5v9zo9qlcwwpyc0ascnikz\"\n  },\n  \"reactions_url\": \"http://fizzy.localhost:3006/897362094/cards/3/comments/03f5v9zo9qlcwwpyc0ascnikz/reactions\",\n  \"url\": \"http://fizzy.localhost:3006/897362094/cards/3/comments/03f5v9zo9qlcwwpyc0ascnikz\"\n}\n```\n\n### `POST /:account_slug/cards/:card_number/comments`\n\nCreates a new comment on a card.\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `body` | string | Yes | The comment body (supports rich text) |\n| `created_at` | datetime | No | Override creation timestamp (ISO 8601 format) |\n\n__Request:__\n\n```json\n{\n  \"comment\": {\n    \"body\": \"This looks great!\"\n  }\n}\n```\n\n__Response:__\n\nReturns `201 Created` with a `Location` header pointing to the new comment.\n\n### `PUT /:account_slug/cards/:card_number/comments/:comment_id`\n\nUpdates a comment. Only the comment creator can update their comments.\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `body` | string | Yes | The updated comment body |\n\n__Request:__\n\n```json\n{\n  \"comment\": {\n    \"body\": \"This looks even better now!\"\n  }\n}\n```\n\n__Response:__\n\nReturns the updated comment.\n\n### `DELETE /:account_slug/cards/:card_number/comments/:comment_id`\n\nDeletes a comment. Only the comment creator can delete their comments.\n\n__Response:__\n\nReturns `204 No Content` on success.\n\n## Card Reactions (Boosts)\n\nCard reactions (also called \"boosts\") let users add short responses directly to cards. These are limited to 16 characters.\n\n### `GET /:account_slug/cards/:card_number/reactions`\n\nReturns a list of reactions on a card.\n\n__Response:__\n\n```json\n[\n  {\n    \"id\": \"03f5v9zo9qlcwwpyc0ascnikz\",\n    \"content\": \"👍\",\n    \"reacter\": {\n      \"id\": \"03f5v9zjw7pz8717a4no1h8a7\",\n      \"name\": \"David Heinemeier Hansson\",\n      \"role\": \"owner\",\n      \"active\": true,\n      \"email_address\": \"david@example.com\",\n      \"created_at\": \"2025-12-05T19:36:35.401Z\",\n      \"url\": \"http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7\"\n    },\n    \"url\": \"http://fizzy.localhost:3006/897362094/cards/3/reactions/03f5v9zo9qlcwwpyc0ascnikz\"\n  }\n]\n```\n\n### `POST /:account_slug/cards/:card_number/reactions`\n\nAdds a reaction (boost) to a card.\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `content` | string | Yes | The reaction text (max 16 characters) |\n\n__Request:__\n\n```json\n{\n  \"reaction\": {\n    \"content\": \"Great 👍\"\n  }\n}\n```\n\n__Response:__\n\nReturns `201 Created` on success.\n\n### `DELETE /:account_slug/cards/:card_number/reactions/:reaction_id`\n\nRemoves your reaction from a card. Only the reaction creator can remove their own reactions.\n\n__Response:__\n\nReturns `204 No Content` on success.\n\n## Comment Reactions\n\nReactions are short (16-character max) responses to comments.\n\n### `GET /:account_slug/cards/:card_number/comments/:comment_id/reactions`\n\nReturns a list of reactions on a comment.\n\n__Response:__\n\n```json\n[\n  {\n    \"id\": \"03f5v9zo9qlcwwpyc0ascnikz\",\n    \"content\": \"👍\",\n    \"reacter\": {\n      \"id\": \"03f5v9zjw7pz8717a4no1h8a7\",\n      \"name\": \"David Heinemeier Hansson\",\n      \"role\": \"owner\",\n      \"active\": true,\n      \"email_address\": \"david@example.com\",\n      \"created_at\": \"2025-12-05T19:36:35.401Z\",\n      \"url\": \"http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7\"\n    },\n    \"url\": \"http://fizzy.localhost:3006/897362094/cards/3/comments/03f5v9zo9qlcwwpyc0ascnikz/reactions/03f5v9zo9qlcwwpyc0ascnikz\"\n  }\n]\n```\n\n### `POST /:account_slug/cards/:card_number/comments/:comment_id/reactions`\n\nAdds a reaction to a comment.\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `content` | string | Yes | The reaction text |\n\n__Request:__\n\n```json\n{\n  \"reaction\": {\n    \"content\": \"Great 👍\"\n  }\n}\n```\n\n__Response:__\n\nReturns `201 Created` on success.\n\n### `DELETE /:account_slug/cards/:card_number/comments/:comment_id/reactions/:reaction_id`\n\nRemoves your reaction from a comment.\n\n__Response:__\n\nReturns `204 No Content` on success.\n\n## Steps\n\nSteps are to-do items on a card.\n\n### `GET /:account_slug/cards/:card_number/steps/:step_id`\n\nReturns a specific step.\n\n__Response:__\n\n```json\n{\n  \"id\": \"03f5v9zo9qlcwwpyc0ascnikz\",\n  \"content\": \"Write tests\",\n  \"completed\": false\n}\n```\n\n### `POST /:account_slug/cards/:card_number/steps`\n\nCreates a new step on a card.\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `content` | string | Yes | The step text |\n| `completed` | boolean | No | Whether the step is completed (default: `false`) |\n\n__Request:__\n\n```json\n{\n  \"step\": {\n    \"content\": \"Write tests\"\n  }\n}\n```\n\n__Response:__\n\nReturns `201 Created` with a `Location` header pointing to the new step.\n\n### `PUT /:account_slug/cards/:card_number/steps/:step_id`\n\nUpdates a step.\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `content` | string | No | The step text |\n| `completed` | boolean | No | Whether the step is completed |\n\n__Request:__\n\n```json\n{\n  \"step\": {\n    \"completed\": true\n  }\n}\n```\n\n__Response:__\n\nReturns the updated step.\n\n### `DELETE /:account_slug/cards/:card_number/steps/:step_id`\n\nDeletes a step.\n\n__Response:__\n\nReturns `204 No Content` on success.\n\n## Tags\n\nTags are labels that can be applied to cards for organization and filtering.\n\n### `GET /:account_slug/tags`\n\nReturns a list of all tags in the account, sorted alphabetically.\n\n__Response:__\n\n```json\n[\n  {\n    \"id\": \"03f5v9zo9qlcwwpyc0ascnikz\",\n    \"title\": \"bug\",\n    \"created_at\": \"2025-12-05T19:36:35.534Z\",\n    \"url\": \"http://fizzy.localhost:3006/897362094/cards?tag_ids[]=03f5v9zo9qlcwwpyc0ascnikz\"\n  },\n  {\n    \"id\": \"03f5v9zo9qlcwwpyc0ascnilz\",\n    \"title\": \"feature\",\n    \"created_at\": \"2025-12-05T19:36:35.534Z\",\n    \"url\": \"http://fizzy.localhost:3006/897362094/cards?tag_ids[]=03f5v9zo9qlcwwpyc0ascnilz\"\n  }\n]\n```\n\n## Columns\n\nColumns represent stages in a workflow on a board. Cards move through columns as they progress.\n\n### `GET /:account_slug/boards/:board_id/columns`\n\nReturns a list of columns on a board, sorted by position.\n\n__Response:__\n\n```json\n[\n  {\n    \"id\": \"03f5v9zkft4hj9qq0lsn9ohcm\",\n    \"name\": \"Recording\",\n    \"color\": \"var(--color-card-default)\",\n    \"created_at\": \"2025-12-05T19:36:35.534Z\"\n  },\n  {\n    \"id\": \"03f5v9zkft4hj9qq0lsn9ohcn\",\n    \"name\": \"Published\",\n    \"color\": \"var(--color-card-4)\",\n    \"created_at\": \"2025-12-05T19:36:35.534Z\"\n  }\n]\n```\n\n### `GET /:account_slug/boards/:board_id/columns/:column_id`\n\nReturns the specified column.\n\n__Response:__\n\n```json\n{\n  \"id\": \"03f5v9zkft4hj9qq0lsn9ohcm\",\n  \"name\": \"In Progress\",\n  \"color\": \"var(--color-card-default)\",\n  \"created_at\": \"2025-12-05T19:36:35.534Z\"\n}\n```\n\n### `POST /:account_slug/boards/:board_id/columns`\n\nCreates a new column on the board.\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `name` | string | Yes | The name of the column |\n| `color` | string | No | The column color. One of: `var(--color-card-default)` (Blue), `var(--color-card-1)` (Gray), `var(--color-card-2)` (Tan), `var(--color-card-3)` (Yellow), `var(--color-card-4)` (Lime), `var(--color-card-5)` (Aqua), `var(--color-card-6)` (Violet), `var(--color-card-7)` (Purple), `var(--color-card-8)` (Pink) |\n\n__Request:__\n\n```json\n{\n  \"column\": {\n    \"name\": \"In Progress\",\n    \"color\": \"var(--color-card-4)\"\n  }\n}\n```\n\n__Response:__\n\nReturns `201 Created` with a `Location` header pointing to the new column.\n\n### `PUT /:account_slug/boards/:board_id/columns/:column_id`\n\nUpdates a column.\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `name` | string | No | The name of the column |\n| `color` | string | No | The column color |\n\n__Request:__\n\n```json\n{\n  \"column\": {\n    \"name\": \"Done\"\n  }\n}\n```\n\n__Response:__\n\nReturns `204 No Content` on success.\n\n### `DELETE /:account_slug/boards/:board_id/columns/:column_id`\n\nDeletes a column.\n\n__Response:__\n\nReturns `204 No Content` on success.\n\n## Users\n\nUsers represent people who have access to an account.\n\n### `GET /:account_slug/users`\n\nReturns a list of active users in the account.\n\n__Response:__\n\n```json\n[\n  {\n    \"id\": \"03f5v9zjw7pz8717a4no1h8a7\",\n    \"name\": \"David Heinemeier Hansson\",\n    \"role\": \"owner\",\n    \"active\": true,\n    \"email_address\": \"david@example.com\",\n    \"created_at\": \"2025-12-05T19:36:35.401Z\",\n    \"url\": \"http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7\"\n  },\n  {\n    \"id\": \"03f5v9zjysoy0fqs9yg0ei3hq\",\n    \"name\": \"Jason Fried\",\n    \"role\": \"member\",\n    \"active\": true,\n    \"email_address\": \"jason@example.com\",\n    \"created_at\": \"2025-12-05T19:36:35.419Z\",\n    \"url\": \"http://fizzy.localhost:3006/897362094/users/03f5v9zjysoy0fqs9yg0ei3hq\"\n  },\n  {\n    \"id\": \"03f5v9zk1dtqduod5bkhv3k8m\",\n    \"name\": \"Jason Zimdars\",\n    \"role\": \"member\",\n    \"active\": true,\n    \"email_address\": \"jz@example.com\",\n    \"created_at\": \"2025-12-05T19:36:35.435Z\",\n    \"url\": \"http://fizzy.localhost:3006/897362094/users/03f5v9zk1dtqduod5bkhv3k8m\"\n  },\n  {\n    \"id\": \"03f5v9zk3nw9ja92e7s4h2wbe\",\n    \"name\": \"Kevin Mcconnell\",\n    \"role\": \"member\",\n    \"active\": true,\n    \"email_address\": \"kevin@example.com\",\n    \"created_at\": \"2025-12-05T19:36:35.451Z\",\n    \"url\": \"http://fizzy.localhost:3006/897362094/users/03f5v9zk3nw9ja92e7s4h2wbe\"\n  }\n]\n```\n\n### `GET /:account_slug/users/:user_id`\n\nReturns the specified user.\n\n__Response:__\n\n```json\n{\n  \"id\": \"03f5v9zjw7pz8717a4no1h8a7\",\n  \"name\": \"David Heinemeier Hansson\",\n  \"role\": \"owner\",\n  \"active\": true,\n  \"email_address\": \"david@example.com\",\n  \"created_at\": \"2025-12-05T19:36:35.401Z\",\n  \"url\": \"http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7\"\n}\n```\n\n### `PUT /:account_slug/users/:user_id`\n\nUpdates a user. You can only update users you have permission to change.\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `name` | string | No | The user's display name |\n| `avatar` | file | No | The user's avatar image |\n\n__Request:__\n\n```json\n{\n  \"user\": {\n    \"name\": \"David H. Hansson\"\n  }\n}\n```\n\n__Response:__\n\nReturns `204 No Content` on success.\n\n### `DELETE /:account_slug/users/:user_id`\n\nDeactivates a user. You can only deactivate users you have permission to change.\n\n__Response:__\n\nReturns `204 No Content` on success.\n\n## Notifications\n\nNotifications inform users about events that happened in the account, such as comments, assignments, and card updates.\n\n### `GET /:account_slug/notifications`\n\nReturns a list of notifications for the current user. Unread notifications are returned first, followed by read notifications.\n\n__Response:__\n\n```json\n[\n  {\n    \"id\": \"03f5va03bpuvkcjemcxl73ho2\",\n    \"read\": false,\n    \"read_at\": null,\n    \"created_at\": \"2025-11-19T04:03:58.000Z\",\n    \"title\": \"Plain text mentions\",\n    \"body\": \"Assigned to self\",\n    \"creator\": {\n      \"id\": \"03f5v9zjw7pz8717a4no1h8a7\",\n      \"name\": \"David Heinemeier Hansson\",\n      \"role\": \"owner\",\n      \"active\": true,\n      \"email_address\": \"david@example.com\",\n      \"created_at\": \"2025-12-05T19:36:35.401Z\",\n      \"url\": \"http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7\"\n    },\n    \"card\": {\n      \"id\": \"03f5v9zo9qlcwwpyc0ascnikz\",\n      \"title\": \"Plain text mentions\",\n      \"status\": \"published\",\n      \"url\": \"http://fizzy.localhost:3006/897362094/cards/3\"\n    },\n    \"url\": \"http://fizzy.localhost:3006/897362094/notifications/03f5va03bpuvkcjemcxl73ho2\"\n  }\n]\n```\n\n### `POST /:account_slug/notifications/:notification_id/reading`\n\nMarks a notification as read.\n\n__Response:__\n\nReturns `204 No Content` on success.\n\n### `DELETE /:account_slug/notifications/:notification_id/reading`\n\nMarks a notification as unread.\n\n__Response:__\n\nReturns `204 No Content` on success.\n\n### `POST /:account_slug/notifications/bulk_reading`\n\nMarks all unread notifications as read.\n\n__Response:__\n\nReturns `204 No Content` on success.\n"
  },
  {
    "path": "docs/development.md",
    "content": "## Development\n\n### Setting up\n\nFirst, get everything installed and configured with:\n\n```sh\nbin/setup\nbin/setup --reset # Reset the database and seed it\n```\n\nAnd then run the development server:\n\n```sh\nbin/dev\n```\n\nYou'll be able to access the app in development at http://fizzy.localhost:3006.\n\nTo login, enter `david@example.com` and grab the verification code from the browser console to sign in.\n\n### Web Push Notifications\n\nFizzy uses VAPID (Voluntary Application Server Identification) keys to send browser push notifications. For notifications to work in development you'll need to generate a key pair and set these environment variables:\n\n- `VAPID_PRIVATE_KEY`\n- `VAPID_PUBLIC_KEY`\n\nGenerate them with the `web-push` gem:\n\n```ruby\nvapid_key = WebPush.generate_key\n\nputs \"VAPID_PRIVATE_KEY=#{vapid_key.private_key}\"\nputs \"VAPID_PUBLIC_KEY=#{vapid_key.public_key}\"\n```\n\n### Running tests\n\nFor fast feedback loops, unit tests can be run with:\n\n```sh\nbin/rails test\n```\n\nThe full continuous integration tests can be run with:\n\n```sh\nbin/ci\n```\n\n### Database configuration\n\nFizzy works with SQLite by default and supports MySQL too. You can switch adapters with the `DATABASE_ADAPTER` environment variable. For example, to develop locally against MySQL:\n\n```sh\nDATABASE_ADAPTER=mysql bin/setup --reset\nDATABASE_ADAPTER=mysql bin/ci\n```\n\nThe remote CI pipeline will run tests against both SQLite and MySQL.\n\n### Outbound Emails\n\nYou can view email previews at http://fizzy.localhost:3006/rails/mailers.\n\nYou can enable or disable [`letter_opener`](https://github.com/ryanb/letter_opener) to open sent emails automatically with:\n\n```sh\nbin/rails dev:email\n```\n\nUnder the hood, this will create or remove `tmp/email-dev.txt`.\n\n## SaaS gem\n\n37signals bundles Fizzy with [`fizzy-saas`](https://github.com/basecamp/fizzy/tree/main/saas), a companion gem that links Fizzy with our billing system and contains our production setup.\n\nThis gem depends on some private git repositories and it is not meant to be used by third parties. But we hope it can serve as inspiration for anyone wanting to run fizzy on their own infrastructure.\n\n"
  },
  {
    "path": "docs/docker-deployment.md",
    "content": "## Deploying with Docker\n\nWe provide pre-built Docker images that can be used to run Fizzy on your own server.\n\nIf you don't need to change the source code, and just want the out-of-the-box Fizzy experience, this can be a great way to get started.\n\nYou'll find the latest version of Fizzy's Docker image at `ghcr.io/basecamp/fizzy:main`.\nTo run it you'll need three things: a machine that runs Docker; a mounted volume (so that your database is stored somewhere that is kept around between restarts); and some environment variables for configuration.\n\n### Mounting a storage volume\n\nThe standard Fizzy setup keeps all of its storage inside the path `/rails/storage`.\nBy default Docker containers don't persist storage between runs, so you'll want to mount a persistent volume into that location.\n\nThe simplest way to do this is with the `--volume` flag with `docker run`. For example:\n\n```sh\ndocker run --volume fizzy:/rails/storage ghcr.io/basecamp/fizzy:main\n```\n\nThat will create a named volume (called `fizzy`) and mount it into the correct path.\nDocker will manage where that volume is actually stored on your server.\n\nYou can also specify the data location yourself, mount a network drive, and more.\nCheck the Docker documentation to find out more about what's available.\n\n### Configuring with environment variables\n\nTo configure your Fizzy installation, you can use environment variables.\nFizzy has several of them.\nMany of these are optional, but at a minimum you'll want to configure your secret key, your SSL domain, and your SMTP email settings.\n\n#### Secret Key Base\n\nVarious features inside Fizzy rely on cryptography to work (such as secure links).\nTo set this up, you need to provide a secret value that will be used as the basis of those secrets.\nThis value can be anything, but it should be unguessable, and specific to your instance.\n\nYou can use any long random string for this, or you can have the Fizzy codebase generate one for you by running:\n\n```sh\nbin/rails secret\n```\n\nOnce you have one, set it in the `SECRET_KEY_BASE` environment variable:\n\n```sh\ndocker run --env SECRET_KEY_BASE=abcdefabcdef ...\n```\n\n#### SSL\n\nIf you want the Fizzy container to handle its own SSL automatically, you just need to specify the domain name that you're running it on.\nYou can do that with the `TLS_DOMAIN` environment variable.\nNote that if you're using SSL, you'll want to allow traffic on ports 80 and 443.\nSo if you were running on `fizzy.example.com` you could enable SSL like this:\n\n```sh\ndocker run --publish 80:80 --publish 443:443 --env TLS_DOMAIN=fizzy.example.com ...\n```\n\nIf you are terminating SSL in some other proxy in front of Fizzy, then you don't need to set `TLS_DOMAIN`, and can just publish port 80:\n```sh\ndocker run --publish 80:80 ...\n```\n\nIf you aren't using SSL at all (for example, if you want to run it locally on your laptop) then you should specify `DISABLE_SSL=true` instead:\n\n```sh\ndocker run --publish 80:80 --env DISABLE_SSL=true ...\n```\n\n#### SMTP Email\n\nFizzy needs to be able to send email for its sign up/sign in flow, and for its regular summary emails.\nThe easiest way to set this up is to use a 3rd-party email provider (such as Postmark, Sendgrid, and so on).\nIf email is not configured, you can still sign in by finding the 6-character verification code in your Docker container's logs.\n\nYou can then plug all your SMTP settings from that provider into Fizzy via the following environment variables:\n\n- `MAILER_FROM_ADDRESS` - the \"from\" address that Fizzy should use to send email\n- `SMTP_ADDRESS` - the address of the SMTP server you'll send through\n- `SMTP_PORT` - the port number (defaults to 465 when `SMTP_TLS` is set, 587 otherwise)\n- `SMTP_USERNAME`/`SMTP_PASSWORD` - the credentials for logging in to the SMTP server\n\nLess commonly, you might also need to set some of the following:\n\n- `SMTP_TLS` - set to `true` only for servers requiring implicit TLS (SMTPS on port 465); STARTTLS is used automatically by default so most servers don't need this\n- `SMTP_DOMAIN` - the domain name advertised to the server when connecting\n- `SMTP_AUTHENTICATION` - if you need an authentication method other than the default `plain`\n- `SMTP_SSL_VERIFY_MODE` - set to `none` to skip certificate verification (for self-signed certs)\n\nYou can find out more about all these settings in the [Rails Action Mailer documentation](https://guides.rubyonrails.org/action_mailer_basics.html#action-mailer-configuration).\n\n#### Base URL\n\nFizzy needs to know the public URL of your instance so it can generate correct links in certain situations (like when sending emails).\nSet `BASE_URL` to the full URL where your Fizzy instance is accessible:\n\n```sh\ndocker run --env BASE_URL=https://fizzy.example.com ...\n```\n\n#### VAPID keys\n\nFizzy can also send Web Push notifications.\nTo do this it needs a VAPID key pair.\n\nYou can create your own keys by starting a development console with:\n\n```sh\nbin/rails c\n```\n\nAnd then run the following to create the keypair:\n\n```ruby\nvapid_key = WebPush.generate_key\n\nputs \"VAPID_PRIVATE_KEY=#{vapid_key.private_key}\"\nputs \"VAPID_PUBLIC_KEY=#{vapid_key.public_key}\"\n```\n\nSet those in the `VAPID_PRIVATE_KEY` and `VAPID_PUBLIC_KEY` environment variables.\n\n#### S3 storage (optional)\n\nIf you'd prefer that uploaded files were stored in an S3 bucket rather than in your mounted volume, you can set that up.\n\nFirst set `ACTIVE_STORAGE_SERVICE` to `s3`.\nThen set the following as appropriate for your S3 bucket:\n\n- `S3_BUCKET`\n- `S3_REGION`\n- `S3_ACCESS_KEY_ID`\n- `S3_SECRET_ACCESS_KEY`\n- `CSP_CONNECT_SRC`\n\nIf you're using a provider other than AWS, you will also need some of the following:\n\n- `S3_ENDPOINT`\n- `S3_FORCE_PATH_STYLE`\n- `S3_REQUEST_CHECKSUM_CALCULATION`\n- `S3_RESPONSE_CHECKSUM_VALIDATION`\n\n#### Multi-tenant mode\n\nBy default, when you run the Fizzy Docker image you'll be limited to creating a single account (although that account can have as many users as you like).\nThis is for convenience: typically when you self-host you'll be running a single account, so in this mode new account signups are automatically disabled as soon as you've created your first account.\n\nIf you do want to allow multiple accounts to be created in your instance, set `MULTI_TENANT=true`\n\n## Example\n\nHere's an example of a `docker-compose.yml` that you could use to run Fizzy via `docker compose up`\n\n```yaml\nservices:\n  web:\n    image: ghcr.io/basecamp/fizzy:main\n    restart: unless-stopped\n    ports:\n      - \"80:80\"\n      - \"443:443\"\n    environment:\n      - SECRET_KEY_BASE=abcdefabcdef\n      - TLS_DOMAIN=fizzy.example.com\n      - BASE_URL=https://fizzy.example.com\n      - MAILER_FROM_ADDRESS=fizzy@example.com\n      - SMTP_ADDRESS=mail.example.com\n      - SMTP_USERNAME=user\n      - SMTP_PASSWORD=pass\n      - VAPID_PRIVATE_KEY=myvapidprivatekey\n      - VAPID_PUBLIC_KEY=myvapidpublickey\n    volumes:\n      - fizzy:/rails/storage\n\nvolumes:\n  fizzy:\n```\n"
  },
  {
    "path": "docs/kamal-deployment.md",
    "content": "## Deploying Fizzy with Kamal\n\nIf you'd like to run Fizzy on your own server while having the freedom to easily make changes to its code, we recommend deploying it with [Kamal](https://kamal-deploy.org/).\nKamal makes it easy to set up a bare server, copy the application to it, and manage the configuration settings that it uses.\n\n(Kamal is also what we use to deploy Fizzy at 37signals. If you're curious about what our deployment configuration looks like, you can find it inside [`fizzy-saas`](https://github.com/basecamp/fizzy-saas).)\n\nThis repo contains a starter deployment file that you can modify for your own specific use. That file lives at [config/deploy.yml](config/deploy.yml), which is the default place where Kamal will look for it.\n\nThe steps to configure your very own Fizzy are:\n\n1. Fork the repo\n2. Initialize Kamal by running `kamal init`. This command generates the `.kamal` directory along with the required configuration files, including `.kamal/secrets`.\n3. Edit a few things in config/deploy.yml and .kamal/secrets\n4. Run `kamal setup` to do your first deploy.\n\nWe'll go through each of these in turn.\n\n### Fork the repo\n\nTo make it easy to customise Fizzy's settings for your own instance, you should start by creating your own GitHub fork of the repo.\nThat allows you to commit your changes, and track them over time.\nYou can always re-sync your fork to pick up new changes from the main repo over time.\n\nOnce you've got your fork ready, run `bin/setup` from within it, to make sure everything is installed.\n\n### Editing the configuration\n\nThe config/deploy.yml has been mostly set up for you, but you'll need to fill out some sections that are specific to your instance.\nTo get started, the parts you need to change are all in the \"About your deployment\" section.\nWe've added comments to that file to highlight what each setting needs to be, but the main ones are:\n\n- `servers/web`: Enter the hostname of the server you're deploying to here. This should be an address that you can access via `ssh`.\n- `ssh/user`: If you access your server a `root` you can leave this alone; if you use a different user, set it here.\n- `proxy/ssl` and `proxy/host`: Kamal can set up SSL certificates for you automatically. To enable that, set the hostname again as `host`. If you don't want SSL for some reason, you can set `ssl: false` to turn it off.\n- `env/clear/BASE_URL`: The public URL of your Fizzy instance (e.g., `https://fizzy.example.com`). Used when generating links.\n- `env/clear/MAILER_FROM_ADDRESS`: This is the email address that Fizzy will send emails from. It should usually be an address from the same domain where you're running Fizzy.\n- `env/clear/SMTP_ADDRESS`: The address of an SMTP server that you can send email through. You can use a 3rd-party service for this, like Sendgrid or Postmark, in which case their documentation will tell you what to use for this.\n- `env/clear/MULTI_TENANT`: Set to `true` if you want to allow multiple accounts to sign up on your server (by default, Fizzy will allow you to create a single account).\n\nFizzy also requires a few environment variables to be set up, some of which contain secrets.\nThe simplest way to do this is to put them in a file called `.kamal/secrets`.\nBecause this file will contain secret credentials, it's important that you DON'T CHECK THIS FILE INTO YOUR REPO! You can add the filename to `.gitignore` to ensure you don't commit this file accidentally.\n\nIf you use a password manager like 1Password, you can also opt to keep your secrets there instead.\nRefer to the [Kamal documentation](https://kamal-deploy.org/docs/configuration/environment-variables/#secrets) for more information about how to do that.\n\nTo store your secrets, create the file `.kamal/secrets` and enter something like the following:\n\n```ini\nSECRET_KEY_BASE=12345\nVAPID_PUBLIC_KEY=something\nVAPID_PRIVATE_KEY=somethingelse\nSMTP_USERNAME=email-provider-username\nSMTP_PASSWORD=email-provider-password\n```\n\nThe values you enter here will be specific to you, and you can get or create them as follows:\n\n- `SECRET_KEY_BASE` should be a long, random secret. You can run `bin/rails secret` to create a suitable value for this.\n- `SMTP_USERNAME` & `SMTP_PASSWORD` should be valid credentials for your SMTP server. If you're using a 3rd-party service here, consult their documentation for what to use.\n- `VAPID_PUBLIC_KEY` & `VAPID_PRIVATE_KEY` are a pair of credentials that are used for sending notifications. You can create your own keys by starting a development console with:\n\n  ```sh\n  bin/rails c\n  ```\n\n  And then run the following to create a new pair of keys:\n\n  ```ruby\n  vapid_key = WebPush.generate_key\n\n  puts \"VAPID_PRIVATE_KEY=#{vapid_key.private_key}\"\n  puts \"VAPID_PUBLIC_KEY=#{vapid_key.public_key}\"\n  ```\n\nOnce you've made all those changes, commit them to your fork so they're saved.\n\n### Deploy Fizzy!\n\nYou can now do your first deploy by running:\n\n```sh\nbin/kamal setup\n```\n\nThis will set up Docker (if needed), build your Fizzy app container, configure it, and start it running.\n\nAfter the first deploy is done, any subsequent steps won't need to do that initial setup. So for future deploys you can just run:\n\n```sh\nbin/kamal deploy\n```\n\n### Configuring file storage (Active Storage)\n\nProduction uses the local disk service by default. To use any other service defined in `config/storage.yml`, set `ACTIVE_STORAGE_SERVICE`.\n\nTo use the included `s3` service, set:\n\n- `ACTIVE_STORAGE_SERVICE=s3`\n- `S3_ACCESS_KEY_ID`\n- `S3_BUCKET` (defaults to `fizzy-#{Rails.env}-activestorage`)\n- `S3_REGION` (defaults to `us-east-1`)\n- `S3_SECRET_ACCESS_KEY`\n- `CSP_CONNECT_SRC`\n\nOptional for S3-compatible endpoints:\n\n- `S3_ENDPOINT`\n- `S3_FORCE_PATH_STYLE=true`\n- `S3_REQUEST_CHECKSUM_CALCULATION` (defaults to `when_supported`)\n- `S3_RESPONSE_CHECKSUM_VALIDATION` (defaults to `when_supported`)\n\n"
  },
  {
    "path": "lib/action_pack/passkey/challenges_controller.rb",
    "content": "# = Action Pack Passkey Challenges Controller\n#\n# Generates fresh WebAuthn challenges for passkey ceremonies. The companion\n# JavaScript calls this endpoint before initiating a registration or\n# authentication ceremony so that the challenge is issued just-in-time rather\n# than embedded in the initial page load.\n#\n# The generated challenge is stored in an encrypted, HTTP-only, same-site\n# cookie and simultaneously returned in the JSON response body. The cookie is\n# consumed by ActionPack::Passkey::Request on the subsequent form submission.\n#\n# == Route\n#\n# By default mounted at +/rails/action_pack/passkey/challenge+ (configurable\n# via +config.action_pack.passkey.routes_prefix+).\n#\nclass ActionPack::Passkey::ChallengesController < ActionController::Base\n  COOKIE_NAME = :action_pack_passkey_challenge\n\n  include ActionPack::Passkey::Request\n\n  # Generates a fresh challenge, stores it in an encrypted cookie, and returns\n  # it as JSON. The cookie is consumed on the next passkey form submission.\n  def create\n    challenge = create_passkey_challenge\n\n    cookies.encrypted[COOKIE_NAME] = { value: challenge, httponly: true, same_site: :strict, secure: !request.local? && request.ssl? }\n    render json: { challenge: challenge }\n  end\n\n  private\n    def create_passkey_challenge\n      ActionPack::WebAuthn::PublicKeyCredential::Options.new(\n        challenge_expiration: Rails.configuration.action_pack.web_authn.request_challenge_expiration\n      ).challenge\n    end\nend\n"
  },
  {
    "path": "lib/action_pack/passkey/form_helper.rb",
    "content": "# View helpers for rendering passkey forms and meta tags.\n#\n# Include this module in your helper or ApplicationHelper to get access to:\n#\n# - +passkey_creation_options_meta_tag+ / +passkey_request_options_meta_tag+ — render a <meta>\n#   tag containing the JSON-serialized WebAuthn options for the browser credential API.\n# - +passkey_creation_button+ — render a form with hidden fields for the registration ceremony.\n# - +passkey_sign_in_button+ — render a form with hidden fields for the authentication\n#   ceremony.\nmodule ActionPack::Passkey::FormHelper\n  # Renders +<meta>+ tags containing JSON-serialized creation options and the challenge endpoint\n  # URL for the WebAuthn registration ceremony. The companion JavaScript reads these tags to call\n  # +navigator.credentials.create()+.\n  def passkey_creation_options_meta_tag(creation_options, challenge_url: nil)\n    passkey_challenge_url_meta_tag(challenge_url: challenge_url) +\n      tag.meta(name: \"passkey-creation-options\", content: creation_options.to_json)\n  end\n\n  # Renders +<meta>+ tags containing JSON-serialized request options and the challenge endpoint\n  # URL for the WebAuthn authentication ceremony. The companion JavaScript reads these tags to\n  # call +navigator.credentials.get()+.\n  def passkey_request_options_meta_tag(request_options, challenge_url: nil)\n    passkey_challenge_url_meta_tag(challenge_url: challenge_url) +\n      tag.meta(name: \"passkey-request-options\", content: request_options.to_json)\n  end\n\n  # Renders a form with hidden fields for the passkey registration ceremony. The form POSTs to\n  # +url+ and includes hidden fields for +client_data_json+, +attestation_object+, and\n  # +transports+ — populated by the Stimulus controller after the browser credential API\n  # resolves. Accepts a +label+ string or a block for button content.\n  #\n  # Options:\n  # - +param+: the form parameter namespace (default: +:passkey+)\n  # - +form+: additional HTML attributes for the +<form>+ tag\n  # - All other options are passed to the +<button>+ tag\n  def passkey_creation_button(name = nil, url = nil, param: :passkey, form: {}, **options, &block)\n    url, name = name, block ? capture(&block) : nil if block_given?\n    form_options = form.reverse_merge(method: :post, action: url, class: \"button_to\")\n\n    tag.form(**form_options) do\n      hidden_field_tag(:authenticity_token, form_authenticity_token) +\n        hidden_field_tag(\"#{param}[client_data_json]\", nil, id: nil, data: { passkey_field: \"client_data_json\" }) +\n        hidden_field_tag(\"#{param}[attestation_object]\", nil, id: nil, data: { passkey_field: \"attestation_object\" }) +\n        hidden_field_tag(\"#{param}[transports][]\", nil, id: nil, data: { passkey_field: \"transports\" }) +\n        tag.button(name, type: :button, data: { passkey: \"create\" }, **options)\n    end\n  end\n\n  # Renders a form with hidden fields for the passkey authentication ceremony. The form POSTs to\n  # +url+ and includes hidden fields for +id+, +client_data_json+, +authenticator_data+, and\n  # +signature+\n  # Accepts a +label+ string or a block for button content.\n  #\n  # Options:\n  # - +param+: the form parameter namespace (default: +:passkey+)\n  # - +mediation+: WebAuthn mediation hint (e.g. +\"conditional\"+ for autofill-assisted sign in)\n  # - +form+: additional HTML attributes for the +<form>+ tag\n  # - All other options are passed to the +<button>+ tag\n  def passkey_sign_in_button(name = nil, url = nil, param: :passkey, mediation: nil, form: {}, **options, &block)\n    url, name = name, block ? capture(&block) : nil if block_given?\n    form_data = {}\n    form_data[:passkey_mediation] = mediation if mediation\n    form_options = form.reverse_merge(method: :post, action: url, class: \"button_to\", data: form_data)\n\n    tag.form(**form_options) do\n      hidden_field_tag(:authenticity_token, form_authenticity_token) +\n        hidden_field_tag(\"#{param}[id]\", nil, id: nil, data: { passkey_field: \"id\" }) +\n        hidden_field_tag(\"#{param}[client_data_json]\", nil, id: nil, data: { passkey_field: \"client_data_json\" }) +\n        hidden_field_tag(\"#{param}[authenticator_data]\", nil, id: nil, data: { passkey_field: \"authenticator_data\" }) +\n        hidden_field_tag(\"#{param}[signature]\", nil, id: nil, data: { passkey_field: \"signature\" }) +\n        tag.button(name, type: :button, data: { passkey: \"sign_in\" }, **options)\n    end\n  end\n\n  private\n    def passkey_challenge_url_meta_tag(challenge_url: nil)\n      tag.meta(name: \"passkey-challenge-url\", content: challenge_url || default_passkey_challenge_url)\n    end\n\n    def default_passkey_challenge_url\n      if challenge_url = Rails.configuration.action_pack.passkey.challenge_url\n        instance_exec(&challenge_url)\n      else\n        passkey_challenge_path\n      end\n    end\nend\n"
  },
  {
    "path": "lib/action_pack/passkey/holder.rb",
    "content": "# Adds passkey support to an Active Record model (the \"holder\" of passkeys).\n#\n# == Usage\n#\n#   class User < ApplicationRecord\n#     has_passkeys name: :email_address, display_name: :name\n#   end\n#\n# This sets up a polymorphic +has_many :passkeys+ association and defines two methods on the\n# model that supply holder-specific options for the WebAuthn ceremonies:\n#\n# - +passkey_creation_options+ — merged into ActionPack::Passkey.creation_options\n# - +passkey_request_options+ — merged into ActionPack::Passkey.request_options\n#\n# == Options\n#\n# +has_passkeys+ accepts keyword arguments that map to WebAuthn creation or request option\n# fields. Values can be symbols (sent to the record), procs (evaluated in the record's context),\n# or plain values:\n#\n# [+name+]\n#   A human-readable account identifier (typically an email or username) shown by the\n#   authenticator when the user selects a passkey. Maps to the WebAuthn +user.name+ field.\n#\n# [+display_name+]\n#   A friendly label for the user (typically their full name) shown by the authenticator\n#   during passkey registration. Maps to the WebAuthn +user.displayName+ field.\n#\n#   has_passkeys name: :email, display_name: :name\n#\n# For more complex configuration, pass a block that receives a ActionPack::Passkey::Holder::Config:\n#\n#   has_passkeys do |config|\n#     config.creation_options { { name: email, display_name: name } }\n#     config.request_options  { { user_verification: \"required\" } }\n#   end\nmodule ActionPack::Passkey::Holder\n  extend ActiveSupport::Concern\n\n  class_methods do\n    # Declares that this model can hold passkeys. Sets up a polymorphic +has_many+ association\n    # and defines +passkey_creation_options+ and +passkey_request_options+ instance methods used\n    # by ActionPack::Passkey to build ceremony options.\n    #\n    # Keyword arguments matching CreationOptions or RequestOptions fields are extracted and\n    # turned into holder-scoped option procs automatically. An optional block yields a Config\n    # for more complex setup.\n    def has_passkeys(**options, &block)\n      config = Config.new(**options)\n      block&.call(config)\n\n      has_many config.association_name,\n        as: :holder,\n        dependent: config.dependent,\n        class_name: \"ActionPack::Passkey\"\n\n      define_method(:passkey_creation_options) do\n        {\n          id: id,\n          exclude_credentials: public_send(config.association_name)\n        }.merge(config.evaluate_creation_options(self))\n      end\n\n      define_method(:passkey_request_options) do\n        { credentials: public_send(config.association_name) }.merge(config.evaluate_request_options(self))\n      end\n    end\n  end\n\n  # Configuration object yielded by +has_passkeys+ when a block is given. Allows setting\n  # custom association options and ceremony option blocks.\n  class Config\n    attr_accessor :association_name, :dependent\n\n    def initialize(**options)\n      @association_name = options.delete(:association_name) || :passkeys\n      @dependent = options.delete(:dependent) || :destroy\n\n      if creation_opts = extract_options_for(ActionPack::WebAuthn::PublicKeyCredential::CreationOptions, options)\n        @creation_options = options_to_proc(creation_opts)\n      end\n\n      if request_opts = extract_options_for(ActionPack::WebAuthn::PublicKeyCredential::RequestOptions, options)\n        @request_options = options_to_proc(request_opts)\n      end\n    end\n\n    # Sets a block to evaluate in the holder's context to produce additional request options.\n    #\n    #   config.request_options { { user_verification: \"required\" } }\n    def request_options(&block)\n      @request_options = block\n    end\n\n    # Sets a block to evaluate in the holder's context to produce additional creation options.\n    #\n    #   config.creation_options { { name: email, display_name: name } }\n    def creation_options(&block)\n      @creation_options = block\n    end\n\n    # Evaluates the request options block (if any) in the context of the given +record+. Called\n    # internally by the +passkey_request_options+ method defined on the holder.\n    def evaluate_request_options(record)\n      if @request_options\n        record.instance_exec(&@request_options)\n      else\n        {}\n      end\n    end\n\n    # Evaluates the creation options block (if any) in the context of the given +record+. Called\n    # internally by the +passkey_creation_options+ method defined on the holder.\n    def evaluate_creation_options(record)\n      if @creation_options\n        record.instance_exec(&@creation_options)\n      else\n        {}\n      end\n    end\n\n    private\n      def extract_options_for(klass, options)\n        keys = klass.attribute_names.map(&:to_sym)\n\n        extracted = options.slice(*keys)\n        options.except!(*keys)\n        extracted if extracted.any?\n      end\n\n      def options_to_proc(options)\n        proc do\n          options.transform_values do |value|\n            case value\n            when Symbol then send(value)\n            when Proc then instance_exec(&value)\n            else value\n            end\n          end\n        end\n      end\n  end\nend\n"
  },
  {
    "path": "lib/action_pack/passkey/request.rb",
    "content": "# = Action Pack Passkey Request\n#\n# Controller concern that sets up the WebAuthn request context and provides\n# helper methods for passkey registration and authentication. Include this\n# in any controller that handles passkey form submissions.\n#\n# == Registration example\n#\n#   class PasskeysController < ApplicationController\n#     include ActionPack::Passkey::Request\n#\n#     def new\n#       @creation_options = passkey_creation_options(holder: Current.user)\n#     end\n#\n#     def create\n#       @passkey = ActionPack::Passkey.register(\n#         passkey_creation_params, holder: Current.user\n#       )\n#       redirect_to settings_path\n#     end\n#   end\n#\n# == Authentication example\n#\n#   class SessionsController < ApplicationController\n#     include ActionPack::Passkey::Request\n#\n#     def new\n#       @request_options = passkey_request_options\n#     end\n#\n#     def create\n#       if passkey = ActionPack::Passkey.authenticate(passkey_request_params)\n#         sign_in passkey.holder\n#         redirect_to root_path\n#       else\n#         redirect_to new_session_path, alert: \"Authentication failed\"\n#       end\n#     end\n#   end\n#\n# == Before Action\n#\n# Automatically populates +ActionPack::WebAuthn::Current+ with the request\n# host, origin, and challenge (read from the encrypted cookie set by\n# ChallengesController). The cookie is deleted after being read to prevent\n# replay.\n#\nmodule ActionPack::Passkey::Request\n  extend ActiveSupport::Concern\n\n  included do\n    before_action do\n      ActionPack::WebAuthn::Current.host = request.host\n      ActionPack::WebAuthn::Current.origin = request.base_url\n      ActionPack::WebAuthn::Current.challenge = cookies.encrypted[ActionPack::Passkey::ChallengesController::COOKIE_NAME]\n      cookies.delete(ActionPack::Passkey::ChallengesController::COOKIE_NAME)\n    end\n  end\n\n  # Returns strong parameters for the passkey registration ceremony.\n  def passkey_creation_params(param: :passkey)\n    params.expect(param => [ :client_data_json, :attestation_object, transports: [] ])\n  end\n\n  # Returns strong parameters for the passkey authentication ceremony.\n  def passkey_request_params(param: :passkey)\n    params.expect(param => [ :id, :client_data_json, :authenticator_data, :signature ])\n  end\n\n  # Returns RequestOptions for the authentication ceremony.\n  def passkey_request_options(**options)\n    ActionPack::Passkey.request_options(**options)\n  end\n\n  # Returns CreationOptions for the registration ceremony.\n  def passkey_creation_options(**options)\n    ActionPack::Passkey.creation_options(**options)\n  end\nend\n"
  },
  {
    "path": "lib/action_pack/passkey.rb",
    "content": "# ActionPack::Passkey provides WebAuthn passkey registration and authentication backed by Active Record.\n#\n# Passkeys are scoped to a polymorphic +holder+ (typically a User or Identity) and store the\n# credential ID, public key, sign count, and transport hints needed for the WebAuthn ceremonies.\n#\n# == Registration\n#\n# Generate options for the browser's +navigator.credentials.create()+ call, then register the\n# response:\n#\n#   options = ActionPack::Passkey.creation_options(holder: current_user)\n#   # Pass options to the browser\n#\n#   passkey = ActionPack::Passkey.register(params[:passkey], holder: current_user)\n#\n# == Authentication\n#\n# Generate options for the browser's +navigator.credentials.get()+ call, then authenticate the\n# response:\n#\n#   options = ActionPack::Passkey.request_options\n#   # Pass options to the browser\n#\n#   passkey = ActionPack::Passkey.authenticate(params[:passkey])\n#\n# == Holder integration\n#\n# Call +has_passkeys+ in your model to set up the association and configure ceremony options\n# per-holder. See ActionPack::Passkey::Holder for details.\nclass ActionPack::Passkey < Rails.configuration.action_pack.passkey.parent_class_name.constantize\n  self.table_name = \"action_pack_passkeys\"\n  belongs_to :holder, polymorphic: true\n  serialize :transports, coder: JSON, type: Array, default: []\n\n  class << self\n    # Returns a CreationOptions object for the given +holder+, suitable for passing to the\n    # browser's +navigator.credentials.create()+ call. Merges global defaults from the Rails\n    # configuration, holder-specific options from +holder.passkey_creation_options+, and any\n    # additional +options+ overrides.\n    def creation_options(holder:, **options)\n      ActionPack::WebAuthn::PublicKeyCredential.creation_options(\n        **Rails.configuration.action_pack.web_authn.default_creation_options.to_h,\n        **holder.passkey_creation_options.to_h,\n        **options\n      )\n    end\n\n    # Verifies the attestation response from the browser and persists a new passkey record.\n    # The +passkey+ hash should contain +client_data_json+, +attestation_object+, and +transports+\n    # as submitted by the registration form. The +challenge+ defaults to\n    # +ActionPack::WebAuthn::Current.challenge+, which is automatically populated from the session\n    # by ActionPack::Passkey::Request. Any additional +attributes+ (e.g. +holder+) are passed\n    # through to +create!+.\n    #\n    # Raises ActionPack::WebAuthn::InvalidResponseError if the attestation is invalid.\n    def register(passkey, challenge: ActionPack::WebAuthn::Current.challenge, **attributes)\n      credential = ActionPack::WebAuthn::PublicKeyCredential.register(passkey, challenge: challenge)\n\n      create!(**credential.to_h, **attributes)\n    end\n\n    # Returns a RequestOptions object suitable for passing to the browser's\n    # +navigator.credentials.get()+ call. When a +holder+ is provided, their existing credentials\n    # are included so the browser can offer them for selection. Merges global defaults, holder\n    # options, and any additional +options+ overrides.\n    def request_options(holder: nil, **options)\n      ActionPack::WebAuthn::PublicKeyCredential.request_options(\n        **Rails.configuration.action_pack.web_authn.default_request_options.to_h,\n        **holder&.passkey_request_options.to_h,\n        **options\n      )\n    end\n\n    # Looks up a passkey by credential ID and verifies the assertion response from the browser.\n    # Returns the authenticated Passkey record, or +nil+ if the credential is not found or\n    # verification fails.\n    def authenticate(passkey, challenge: ActionPack::WebAuthn::Current.challenge)\n      find_by(credential_id: passkey[:id])&.authenticate(passkey, challenge: challenge)\n    end\n  end\n\n  # Verifies the assertion response against this passkey's stored credential and updates the\n  # +sign_count+ and +backed_up+ attributes. Returns +self+ on success, or +nil+ if the\n  # response is invalid.\n  def authenticate(passkey, challenge: ActionPack::WebAuthn::Current.challenge)\n    credential = to_public_key_credential\n    credential.authenticate(passkey, challenge: challenge)\n    update!(sign_count: credential.sign_count, backed_up: credential.backed_up)\n    self\n  rescue ActionPack::WebAuthn::InvalidResponseError\n    nil\n  end\n\n  # Returns an ActionPack::WebAuthn::PublicKeyCredential initialized from this record's stored\n  # credential data.\n  def to_public_key_credential\n    ActionPack::WebAuthn::PublicKeyCredential.new(\n      id: credential_id,\n      public_key: public_key,\n      sign_count: sign_count,\n      transports: transports\n    )\n  end\nend\n"
  },
  {
    "path": "lib/action_pack/railtie.rb",
    "content": "require_relative \"web_authn\"\n\n# = Action Pack Railtie\n#\n# Integrates the WebAuthn and Passkey subsystems into the Rails application.\n# Configures default options for WebAuthn ceremonies and sets up the passkey\n# challenge endpoint route.\n#\n# == Configuration\n#\n#   # config/application.rb or config/initializers/passkeys.rb\n#   config.action_pack.web_authn.default_creation_options = { attestation: :none }\n#   config.action_pack.web_authn.default_request_options  = { user_verification: :required }\n#   config.action_pack.web_authn.creation_challenge_expiration = 10.minutes\n#   config.action_pack.web_authn.request_challenge_expiration = 5.minutes\n#\n#   config.action_pack.passkey.routes_prefix = \"/rails/action_pack/passkey\"\n#   config.action_pack.passkey.draw_routes   = true\n#\nclass ActionPack::Railtie < Rails::Railtie\n  config.action_pack = ActiveSupport::OrderedOptions.new unless config.respond_to?(:action_pack)\n\n  config.action_pack.web_authn = ActiveSupport::OrderedOptions.new\n  config.action_pack.web_authn.default_request_options = {}\n  config.action_pack.web_authn.default_creation_options = {}\n  config.action_pack.web_authn.creation_challenge_expiration = 10.minutes\n  config.action_pack.web_authn.request_challenge_expiration = 5.minutes\n\n  config.action_pack.passkey = ActiveSupport::OrderedOptions.new\n  config.action_pack.passkey.parent_class_name = \"ApplicationRecord\"\n  config.action_pack.passkey.routes_prefix = \"/rails/action_pack/passkey\"\n  config.action_pack.passkey.draw_routes = true\n  config.action_pack.passkey.challenge_url = nil\n\n  initializer \"action_pack.passkey.routes\" do |app|\n    passkey_config = config.action_pack.passkey\n\n    app.routes.prepend do\n      if passkey_config.draw_routes\n        scope passkey_config.routes_prefix, as: :passkey do\n          post \"/challenge\" => \"action_pack/passkey/challenges#create\", as: :challenge\n        end\n      end\n    end\n  end\n\n  initializer \"action_pack.passkey.holder\" do\n    ActiveSupport.on_load(:active_record) do\n      # We need this shim because Holder is namespaced under Passkey, which is an ActiveRecord\n      # and can't be required before ActiveRecord is loaded.\n      def self.has_passkeys(**options, &block)\n        include ActionPack::Passkey::Holder\n        has_passkeys(**options, &block)\n      end\n    end\n  end\n\n  initializer \"action_pack.passkey.form_helper\" do\n    ActiveSupport.on_load(:action_view) do\n      require_relative \"passkey/form_helper\"\n      include ActionPack::Passkey::FormHelper\n    end\n  end\n\n  initializer \"action_pack.passkey.request\" do\n    ActiveSupport.on_load(:action_controller) do\n      require_relative \"passkey/request\"\n    end\n  end\nend\n"
  },
  {
    "path": "lib/action_pack/web_authn/authenticator/assertion_response.rb",
    "content": "# = Action Pack WebAuthn Assertion Response\n#\n# Handles the authenticator response from a WebAuthn authentication ceremony.\n# When a user authenticates with an existing credential, the authenticator\n# returns an assertion response containing a signature that proves possession\n# of the private key.\n#\n# == Usage\n#\n#   # Look up the credential by ID\n#   credential = user.credentials.find_by!(\n#     credential_id: params[:id]\n#   )\n#\n#   response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(\n#     client_data_json: params[:response][:clientDataJSON],\n#     authenticator_data: params[:response][:authenticatorData],\n#     signature: params[:response][:signature],\n#     credential: credential.to_public_key_credential,\n#     challenge: ActionPack::WebAuthn::Current.challenge,\n#     origin: \"https://example.com\"\n#   )\n#\n#   response.validate!\n#\n# == Validation\n#\n# In addition to the base Response validations, this class verifies:\n#\n# * The client data type is \"webauthn.get\"\n# * The signature is valid for the credential's public key\n#\nclass ActionPack::WebAuthn::Authenticator::AssertionResponse < ActionPack::WebAuthn::Authenticator::Response\n  attr_reader :credential, :authenticator_data, :signature\n\n  validate :client_data_type_must_be_get\n  validate :signature_must_be_valid\n  validate :sign_count_must_increase\n\n  def initialize(credential:, authenticator_data:, signature:, **attributes)\n    super(**attributes)\n    @credential = credential\n    @signature = signature\n    @signature = Base64.urlsafe_decode64(@signature) unless @signature.encoding == Encoding::BINARY\n    @authenticator_data = ActionPack::WebAuthn::Authenticator::Data.wrap(authenticator_data)\n  rescue ArgumentError\n    raise ActionPack::WebAuthn::InvalidResponseError, \"Invalid base64 encoding in signature\"\n  end\n\n  private\n    def client_data_type_must_be_get\n      unless client_data[\"type\"] == \"webauthn.get\"\n        errors.add(:base, \"Client data type is not webauthn.get\")\n      end\n    end\n\n    def signature_must_be_valid\n      client_data_hash = Digest::SHA256.digest(client_data_json)\n      signed_data = authenticator_data.bytes.pack(\"C*\") + client_data_hash\n      digest = credential.public_key.oid == \"ED25519\" ? nil : \"SHA256\"\n\n      unless credential.public_key.verify(digest, signature, signed_data)\n        errors.add(:base, \"Invalid signature\")\n      end\n    rescue OpenSSL::PKey::PKeyError\n      errors.add(:base, \"Invalid signature\")\n    end\n\n    def sign_count_must_increase\n      unless sign_count_increased?\n        errors.add(:base, \"Sign count did not increase\")\n      end\n    end\n\n    def sign_count_increased?\n      if authenticator_data.sign_count.zero? && credential.sign_count.zero?\n        # Some authenticators always return 0 for the sign count, even after multiple authentications.\n        # In that case, we have to check that both the stored and returned sign counts are 0,\n        # which indicates that the authenticator is likely not updating the sign count.\n        true\n      else\n        authenticator_data.sign_count > credential.sign_count\n      end\n    end\nend\n"
  },
  {
    "path": "lib/action_pack/web_authn/authenticator/attestation.rb",
    "content": "# = Action Pack WebAuthn Attestation\n#\n# Decodes and represents the attestation object returned by an authenticator\n# during registration. The attestation object is CBOR-encoded and contains\n# the authenticator data along with an optional attestation statement.\n#\n# == Usage\n#\n#   attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(\n#     attestation_object_bytes\n#   )\n#\n#   attestation.credential_id  # => \"abc123...\"\n#   attestation.public_key     # => OpenSSL::PKey::EC\n#   attestation.sign_count     # => 0\n#\n# == Attributes\n#\n# [+authenticator_data+]\n#   The parsed Data containing credential information.\n#\n# [+format+]\n#   The attestation statement format (e.g., \"none\", \"packed\", \"fido-u2f\").\n#\n# [+attestation_statement+]\n#   The attestation statement, which may contain a signature from the\n#   authenticator manufacturer. Empty for \"none\" format.\n#\n# == Delegated Methods\n#\n# The following methods are delegated to +authenticator_data+:\n#\n# * +credential_id+ - Base64URL-encoded credential identifier\n# * +public_key+ - OpenSSL public key object\n# * +public_key_bytes+ - Raw COSE key bytes\n# * +sign_count+ - Signature counter for replay detection\n#\nclass ActionPack::WebAuthn::Authenticator::Attestation\n  attr_reader :authenticator_data, :format, :attestation_statement\n\n  delegate :credential_id, :public_key, :public_key_bytes, :sign_count, :aaguid, :backed_up?, to: :authenticator_data\n\n  # Wraps raw attestation data into an Attestation instance. Accepts an\n  # existing Attestation object (returned as-is), a Base64URL-encoded string,\n  # or raw binary.\n  def self.wrap(data)\n    if data.is_a?(self)\n      data\n    else\n      data = Base64.urlsafe_decode64(data) unless data.encoding == Encoding::BINARY\n      decode(data)\n    end\n  rescue ArgumentError\n    raise ActionPack::WebAuthn::InvalidResponseError, \"Invalid base64 encoding in attestation object\"\n  end\n\n  # Decodes a CBOR-encoded attestation object into an Attestation instance.\n  def self.decode(bytes)\n    cbor = ActionPack::WebAuthn::CborDecoder.decode(bytes)\n\n    new(\n      authenticator_data: ActionPack::WebAuthn::Authenticator::Data.decode(cbor[\"authData\"]),\n      format: cbor[\"fmt\"],\n      attestation_statement: cbor[\"attStmt\"]\n    )\n  end\n\n  def initialize(authenticator_data:, format:, attestation_statement:)\n    @authenticator_data = authenticator_data\n    @format = format\n    @attestation_statement = attestation_statement\n  end\nend\n"
  },
  {
    "path": "lib/action_pack/web_authn/authenticator/attestation_response.rb",
    "content": "# = Action Pack WebAuthn Attestation Response\n#\n# Handles the authenticator response from a WebAuthn registration ceremony.\n# When a user registers a new credential, the authenticator returns an\n# attestation response containing the new public key and credential ID.\n#\n# == Usage\n#\n#   response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(\n#     client_data_json: params[:response][:clientDataJSON],\n#     attestation_object: params[:response][:attestationObject],\n#     challenge: ActionPack::WebAuthn::Current.challenge,\n#     origin: \"https://example.com\"\n#   )\n#\n#   response.validate!\n#\n#   # Store the credential\n#   credential_id = response.attestation.credential_id\n#   public_key = response.attestation.public_key\n#\n# == Validation\n#\n# In addition to the base Response validations, this class verifies:\n#\n# * The client data type is \"webauthn.create\"\n# * The attestation format has a registered verifier\n# * The attestation statement passes format-specific verification\n#\nclass ActionPack::WebAuthn::Authenticator::AttestationResponse < ActionPack::WebAuthn::Authenticator::Response\n  attr_reader :attestation_object\n\n  validate :client_data_type_must_be_create\n  validate :attestation_must_be_valid\n\n  def initialize(attestation_object:, **attributes)\n    super(**attributes)\n    @attestation_object = attestation_object\n  end\n\n  # Returns the decoded Attestation object, lazily parsed from the raw\n  # attestation object bytes.\n  def attestation\n    @attestation ||= ActionPack::WebAuthn::Authenticator::Attestation.wrap(attestation_object)\n  end\n\n  # Returns the authenticator data extracted from the attestation object.\n  def authenticator_data\n    attestation.authenticator_data\n  end\n\n  private\n    def client_data_type_must_be_create\n      unless client_data[\"type\"] == \"webauthn.create\"\n        errors.add(:base, \"Client data type is not webauthn.create\")\n      end\n    end\n\n    def attestation_must_be_valid\n      verifier = ActionPack::WebAuthn.attestation_verifiers[attestation.format]\n\n      if verifier\n        verifier.verify!(attestation, client_data_json: client_data_json)\n      else\n        errors.add(:base, \"Unsupported attestation format: #{attestation.format}\")\n      end\n    end\nend\n"
  },
  {
    "path": "lib/action_pack/web_authn/authenticator/attestation_verifiers/none.rb",
    "content": "# = Action Pack WebAuthn None Attestation Verifier\n#\n# Verifies attestation responses with the \"none\" format, which indicates the\n# authenticator did not provide any attestation statement. This is the default\n# format used by most consumer authenticators.\n#\n# == Implementing Custom Verifiers\n#\n# To support other attestation formats (e.g., \"packed\", \"fido-u2f\"), implement\n# a class with the same +verify!+ interface and register it:\n#\n#   ActionPack::WebAuthn.register_attestation_verifier(\"packed\", MyPackedVerifier.new)\n#\n# The +verify!+ method receives the decoded +Attestation+ object and the raw\n# +client_data_json+ bytes. Raise +InvalidResponseError+ if verification fails.\n#\nclass ActionPack::WebAuthn::Authenticator::AttestationVerifiers::None\n  def verify!(attestation, client_data_json:)\n    if attestation.attestation_statement.present?\n      raise ActionPack::WebAuthn::InvalidResponseError,\n        \"Attestation statement must be empty for 'none' format\"\n    end\n  end\nend\n"
  },
  {
    "path": "lib/action_pack/web_authn/authenticator/data.rb",
    "content": "# = Action Pack WebAuthn Authenticator Data\n#\n# Decodes and represents the authenticator data structure from WebAuthn\n# responses. This binary format contains information about the authenticator\n# and, during registration, the newly created credential.\n#\n# == Structure\n#\n# The authenticator data consists of:\n#\n# * RP ID Hash (32 bytes) - SHA-256 hash of the relying party ID\n# * Flags (1 byte) - Bit flags for user presence, verification, etc.\n# * Sign Count (4 bytes) - Signature counter for replay detection\n# * Attested Credential Data (variable) - Present only during registration\n#\n# == Usage\n#\n#   data = ActionPack::WebAuthn::Authenticator::Data.decode(bytes)\n#\n#   data.user_present?   # => true\n#   data.user_verified?  # => true\n#   data.sign_count      # => 42\n#   data.credential_id   # => \"abc123...\" (registration only)\n#   data.public_key      # => OpenSSL::PKey::EC (registration only)\n#\n# == Flags\n#\n# [+user_present?+]\n#   Returns true if the user performed a test of user presence (e.g., touched\n#   the authenticator).\n#\n# [+user_verified?+]\n#   Returns true if the user was verified through biometrics, PIN, or other\n#   method. This is stronger than mere presence.\n#\n# [+backup_eligible?+]\n#   Returns true if the credential can be backed up (e.g., synced passkeys\n#   from Apple, Google, or Microsoft). Indicates multi-device credential support.\n#\n# [+backed_up?+]\n#   Returns true if the credential is currently backed up to cloud storage.\n#   Useful for risk assessment—backed-up credentials may be accessible from\n#   multiple devices.\n#\nclass ActionPack::WebAuthn::Authenticator::Data\n  # Segment lengths\n  RELYING_PARTY_ID_HASH_LENGTH = 32\n  FLAGS_LENGTH = 1\n  SIGN_COUNT_LENGTH = 4\n  AAGUID_LENGTH = 16\n  CREDENTIAL_ID_LENGTH_BYTES = 2\n\n  # Flags\n  USER_PRESENT_FLAG = 0x01\n  USER_VERIFIED_FLAG = 0x04\n  BACKUP_ELIGIBLE_FLAG = 0x08\n  BACKUP_STATE_FLAG = 0x10\n  ATTESTED_CREDENTIAL_DATA_FLAG = 0x40\n\n  attr_reader :bytes, :relying_party_id_hash, :flags, :sign_count, :aaguid, :credential_id, :public_key_bytes\n\n  class << self\n    # Wraps raw authenticator data into a Data instance. Accepts an existing\n    # Data object (returned as-is), a Base64URL-encoded string, or raw binary.\n    def wrap(data)\n      if data.is_a?(self)\n        data\n      else\n        data = Base64.urlsafe_decode64(data) unless data.encoding == Encoding::BINARY\n        decode(data)\n      end\n    rescue ArgumentError\n      raise ActionPack::WebAuthn::InvalidResponseError, \"Invalid base64 encoding in authenticator data\"\n    end\n\n    # Decodes raw authenticator data bytes into a Data instance, parsing the\n    # RP ID hash, flags, sign count, and (if present) attested credential data.\n    def decode(bytes)\n      bytes = bytes.bytes if bytes.is_a?(String)\n\n      minimum_length = RELYING_PARTY_ID_HASH_LENGTH + FLAGS_LENGTH + SIGN_COUNT_LENGTH\n      if bytes.length < minimum_length\n        raise ActionPack::WebAuthn::InvalidResponseError, \"Authenticator data is too short\"\n      end\n\n      position = 0\n\n      relying_party_id_hash = bytes[position, RELYING_PARTY_ID_HASH_LENGTH].pack(\"C*\")\n      position += RELYING_PARTY_ID_HASH_LENGTH\n\n      flags = bytes[position]\n      position += FLAGS_LENGTH\n\n      sign_count = bytes[position, SIGN_COUNT_LENGTH].pack(\"C*\").unpack1(\"N\")\n      position += SIGN_COUNT_LENGTH\n\n      aaguid = nil\n      credential_id = nil\n      public_key_bytes = nil\n\n      if flags & ATTESTED_CREDENTIAL_DATA_FLAG != 0\n        if bytes.length < position + AAGUID_LENGTH + CREDENTIAL_ID_LENGTH_BYTES\n          raise ActionPack::WebAuthn::InvalidResponseError, \"Authenticator data is too short for attested credential data\"\n        end\n\n        aaguid_bytes = bytes[position, AAGUID_LENGTH].pack(\"C*\")\n        aaguid = aaguid_bytes.unpack(\"H8H4H4H4H12\").join(\"-\")\n        position += AAGUID_LENGTH\n\n        credential_id_length = bytes[position, CREDENTIAL_ID_LENGTH_BYTES].pack(\"C*\").unpack1(\"n\")\n        position += CREDENTIAL_ID_LENGTH_BYTES\n\n        if bytes.length < position + credential_id_length + 1\n          raise ActionPack::WebAuthn::InvalidResponseError, \"Authenticator data is too short for credential ID and public key\"\n        end\n\n        credential_id = Base64.urlsafe_encode64(bytes[position, credential_id_length].pack(\"C*\"), padding: false)\n        position += credential_id_length\n\n        public_key_bytes = bytes[position..].pack(\"C*\")\n      end\n\n      new(\n        bytes: bytes,\n        relying_party_id_hash: relying_party_id_hash,\n        flags: flags,\n        sign_count: sign_count,\n        aaguid: aaguid,\n        credential_id: credential_id,\n        public_key_bytes: public_key_bytes\n      )\n    end\n  end\n\n  def initialize(bytes:, relying_party_id_hash:, flags:, sign_count:, aaguid: nil, credential_id:, public_key_bytes:)\n    @bytes = bytes\n    @relying_party_id_hash = relying_party_id_hash\n    @flags = flags\n    @sign_count = sign_count\n    @aaguid = aaguid\n    @credential_id = credential_id\n    @public_key_bytes = public_key_bytes\n  end\n\n  # Returns true if the user performed a test of presence (e.g., touched the\n  # authenticator).\n  def user_present?\n    flags & USER_PRESENT_FLAG != 0\n  end\n\n  # Returns true if the user was verified via biometrics, PIN, or similar.\n  def user_verified?\n    flags & USER_VERIFIED_FLAG != 0\n  end\n\n  # Returns true if the credential is eligible for backup (e.g., synced passkey).\n  # This indicates the authenticator supports multi-device credentials.\n  def backup_eligible?\n    flags & BACKUP_ELIGIBLE_FLAG != 0\n  end\n\n  # Returns true if the credential is currently backed up to cloud storage.\n  # Only meaningful when +backup_eligible?+ is true.\n  def backed_up?\n    flags & BACKUP_STATE_FLAG != 0\n  end\n\n  # Decodes the COSE public key bytes into an OpenSSL key object.\n  # Returns +nil+ when no attested credential data is present (authentication\n  # responses).\n  def public_key\n    @public_key ||= ActionPack::WebAuthn::CoseKey.decode(public_key_bytes).to_openssl_key if public_key_bytes\n  end\nend\n"
  },
  {
    "path": "lib/action_pack/web_authn/authenticator/response.rb",
    "content": "# = Action Pack WebAuthn Authenticator Response\n#\n# Abstract base class for WebAuthn authenticator responses. Provides common\n# validation logic for both registration (attestation) and authentication\n# (assertion) ceremonies.\n#\n# This class should not be instantiated directly. Use AttestationResponse for\n# registration or AssertionResponse for authentication.\n#\n# == Validation\n#\n# The +validate!+ method performs security checks required by the WebAuthn\n# specification:\n#\n# * Challenge verification - ensures the response matches the server-generated challenge\n# * Origin verification - ensures the response comes from the expected origin\n# * User verification - optionally requires biometric or PIN verification\n#\n# == Example\n#\n#   response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(\n#     client_data_json: client_data_json,\n#     authenticator_data: authenticator_data,\n#     signature: signature,\n#     credential: credential,\n#     challenge: ActionPack::WebAuthn::Current.challenge,\n#     origin: \"https://example.com\",\n#     user_verification: :required\n#   )\n#\n#   response.validate!\n#\nclass ActionPack::WebAuthn::Authenticator::Response\n  include ActiveModel::Validations\n\n  attr_reader :client_data_json\n  attr_accessor :challenge, :origin, :user_verification\n\n  validate :challenge_must_match\n  validate :challenge_must_not_be_expired\n  validate :origin_must_match\n  validate :must_not_be_cross_origin\n  validate :must_not_have_token_binding\n  validate :relying_party_id_must_match\n  validate :user_must_be_present\n  validate :user_must_be_verified_when_required\n\n  def initialize(client_data_json:, challenge: nil, origin: nil, user_verification: :preferred)\n    @client_data_json = client_data_json\n    @challenge = challenge\n    @origin = origin\n    @user_verification = user_verification.to_sym\n  end\n\n  def validate!\n    super\n  rescue ActiveModel::ValidationError\n    raise ActionPack::WebAuthn::InvalidResponseError, errors.full_messages.join(\", \")\n  end\n\n  # Returns the RelyingParty used for RP ID validation.\n  def relying_party\n    ActionPack::WebAuthn.relying_party\n  end\n\n  # Parses the client data JSON string into a Hash. Raises\n  # +InvalidResponseError+ if the JSON is malformed.\n  def client_data\n    @client_data ||= JSON.parse(client_data_json)\n  rescue JSON::ParserError\n    raise ActionPack::WebAuthn::InvalidResponseError, \"Client data is not valid JSON\"\n  end\n\n  def authenticator_data\n    nil\n  end\n\n  private\n    def challenge_must_match\n      if challenge.blank?\n        errors.add(:base, \"Challenge missing\")\n      elsif client_data[\"challenge\"].blank?\n        errors.add(:base, \"Challenge missing in client data\")\n      elsif !ActiveSupport::SecurityUtils.secure_compare(challenge.to_s, client_data[\"challenge\"].to_s)\n        errors.add(:base, \"Challenge does not match\")\n      end\n    end\n\n    def challenge_must_not_be_expired\n      return if errors.any? || challenge.blank?\n\n      signed_message = Base64.urlsafe_decode64(challenge)\n\n      unless ActionPack::WebAuthn.challenge_verifier.verified(signed_message)\n        errors.add(:base, \"Challenge has expired\")\n      end\n    rescue ArgumentError\n      errors.add(:base, \"Challenge is invalid\")\n    end\n\n    def origin_must_match\n      if origin.blank?\n        errors.add(:base, \"Origin missing\")\n      elsif client_data[\"origin\"].blank?\n        errors.add(:base, \"Origin missing in client data\")\n      elsif !ActiveSupport::SecurityUtils.secure_compare(origin.to_s, client_data[\"origin\"].to_s)\n        errors.add(:base, \"Origin does not match\")\n      end\n    end\n\n    def must_not_be_cross_origin\n      if client_data[\"crossOrigin\"] == true\n        errors.add(:base, \"Cross-origin requests are not supported\")\n      end\n    end\n\n    def must_not_have_token_binding\n      if client_data.dig(\"tokenBinding\", \"status\") == \"present\"\n        errors.add(:base, \"Token binding is not supported\")\n      end\n    end\n\n    def relying_party_id_must_match\n      unless ActiveSupport::SecurityUtils.secure_compare(\n        Digest::SHA256.digest(relying_party.id),\n        authenticator_data&.relying_party_id_hash || \"\"\n      )\n        errors.add(:base, \"Relying party ID does not match\")\n      end\n    end\n\n    def user_must_be_present\n      unless authenticator_data&.user_present?\n        errors.add(:base, \"User presence is required\")\n      end\n    end\n\n    def user_must_be_verified_when_required\n      if user_verification == :required && !authenticator_data&.user_verified?\n        errors.add(:base, \"User verification is required\")\n      end\n    end\nend\n"
  },
  {
    "path": "lib/action_pack/web_authn/cbor_decoder.rb",
    "content": "# = Action Pack WebAuthn CBOR Decoder\n#\n# Decodes Concise Binary Object Representation (CBOR) data as specified in\n# RFC 8949[https://tools.ietf.org/html/rfc8949]. CBOR is a binary data format\n# used by WebAuthn for encoding authenticator data and attestation objects.\n#\n# == Usage\n#\n# The decoder accepts either a binary string or an array of bytes:\n#\n#   # From binary string\n#   ActionPack::WebAuthn::CborDecoder.decode(\"\\x83\\x01\\x02\\x03\")\n#   # => [1, 2, 3]\n#\n#   # From byte array\n#   ActionPack::WebAuthn::CborDecoder.decode([0x83, 0x01, 0x02, 0x03])\n#   # => [1, 2, 3]\n#\n# == Supported Types\n#\n# The decoder supports the following CBOR types:\n#\n# [Integers]\n#   Unsigned (major type 0) and negative (major type 1) integers of any size.\n#\n# [Byte strings]\n#   Binary data (major type 2), returned as ASCII-8BIT encoded strings.\n#\n# [Text strings]\n#   UTF-8 text (major type 3), returned as UTF-8 encoded strings.\n#\n# [Arrays]\n#   Ordered collections (major type 4) of any CBOR values.\n#\n# [Maps]\n#   Key-value pairs (major type 5) with any CBOR values as keys and values.\n#\n# [Floats]\n#   IEEE 754 half (16-bit), single (32-bit), and double (64-bit) precision.\n#\n# [Simple values]\n#   +false+, +true+, +null+, and +undefined+ (both decoded as +nil+).\n#\n# [Indefinite length]\n#   Streaming byte strings, text strings, arrays, and maps.\n#\n# Tags (major type 6) are recognized but their semantic meaning is ignored;\n# the tagged value is returned directly.\n#\n# == Errors\n#\n# Raises +InvalidCborError+ when encountering malformed or unsupported CBOR data.\nclass ActionPack::WebAuthn::CborDecoder\n  # Major types\n  UNSIGNED_INTEGER_TYPE = 0\n  NEGATIVE_INTEGER_TYPE = 1\n  BYTE_STRING_TYPE = 2\n  TEXT_STRING_TYPE = 3\n  ARRAY_TYPE = 4\n  MAP_TYPE = 5\n  TAG_TYPE = 6\n  FLOAT_OR_SIMPLE_TYPE = 7\n\n  # Additional information values\n  SIMPLE_VALUE_RANGE = 0..23\n  SINGLE_BYTE_VALUE_FOLLOWS = 24\n  TWO_BYTE_VALUE_FOLLOWS = 25\n  FOUR_BYTE_VALUE_FOLLOWS = 26\n  EIGHT_BYTE_VALUE_FOLLOWS = 27\n  RESERVED_VALUE_RANGE = 28..30\n  INDEFINITE_LENGTH_MAJOR_TYPE = 31\n\n  # Simple values\n  SIMPLE_FALSE_VALUE = 20\n  SIMPLE_TRUE_VALUE = 21\n  SIMPLE_NULL_VALUE = 22\n  SIMPLE_UNDEFINED_VALUE = 23\n\n  # Flow control\n  BREAK_CODE = 0xFF\n\n  # Limits\n  MAX_DEPTH = 16\n  MAX_SIZE = 10.megabytes\n\n  # Tags\n  POSITIVE_BIGNUM_TAG = 2\n  NEGATIVE_BIGNUM_TAG = 3\n\n  class << self\n    # Decodes a CBOR-encoded byte sequence into a Ruby object.\n    #\n    #   ActionPack::WebAuthn::CborDecoder.decode(\"\\xa2\\x61a\\x01\\x61b\\x02\")\n    #   # => {\"a\" => 1, \"b\" => 2}\n    def decode(bytes, **args)\n      bytes = bytes.bytes if bytes.respond_to?(:bytes)\n      new(bytes, **args).decode\n    end\n  end\n\n  def initialize(bytes, max_depth: MAX_DEPTH, max_size: MAX_SIZE) # :nodoc:\n    raise ActionPack::WebAuthn::InvalidCborError, \"Input exceeds maximum size\" if bytes.length > max_size\n\n    @bytes = bytes\n    @max_depth = max_depth\n    @position = 0\n    @depth = 0\n  end\n\n  # Decodes the next CBOR data item from the byte sequence.\n  def decode\n    raise ActionPack::WebAuthn::InvalidCborError, \"Unexpected end of input\" if @position >= @bytes.length\n    raise ActionPack::WebAuthn::InvalidCborError, \"Maximum nesting depth exceeded\" if @depth >= @max_depth\n\n    @depth += 1\n\n    result = case major_type\n    when UNSIGNED_INTEGER_TYPE then decode_unsigned_integer\n    when NEGATIVE_INTEGER_TYPE then decode_negative_integer\n    when BYTE_STRING_TYPE then decode_byte_string\n    when TEXT_STRING_TYPE then decode_text_string\n    when ARRAY_TYPE then decode_array\n    when MAP_TYPE then decode_map\n    when TAG_TYPE then decode_tag\n    when FLOAT_OR_SIMPLE_TYPE then decode_float_or_simple\n    end\n\n    @depth -= 1\n    result\n  end\n\n  private\n    def major_type\n      peek >> 5\n    end\n\n    def peek\n      @bytes[@position]\n    end\n\n    def decode_unsigned_integer\n      read_argument\n    end\n\n    def decode_negative_integer\n      -1 - read_argument\n    end\n\n    def decode_byte_string\n      if indefinite_length?\n        String.new(encoding: Encoding::ASCII_8BIT).tap { |str| str << decode_byte_string until break_code? }\n      else\n        read_bytes(read_argument).pack(\"C*\")\n      end\n    end\n\n    def decode_text_string\n      if indefinite_length?\n        String.new(encoding: Encoding::UTF_8).tap { |str| str << decode_text_string until break_code? }\n      else\n        read_bytes(read_argument).pack(\"C*\").force_encoding(Encoding::UTF_8)\n      end\n    end\n\n    def decode_array\n      if indefinite_length?\n        Array.new.tap { |arr| arr << decode until break_code? }\n      else\n        Array.new(read_argument) { decode }\n      end\n    end\n\n    def decode_map\n      if indefinite_length?\n        Hash.new.tap { |hash| hash[decode] = decode until break_code? }\n      else\n        Hash.new.tap do |hash|\n          read_argument.times do\n            hash[decode] = decode\n          end\n        end\n      end\n    end\n\n    def decode_float_or_simple\n      case info = additional_info\n      when SIMPLE_FALSE_VALUE then false\n      when SIMPLE_TRUE_VALUE then true\n      when SIMPLE_NULL_VALUE, SIMPLE_UNDEFINED_VALUE then nil\n      when TWO_BYTE_VALUE_FOLLOWS then decode_half_float\n      when FOUR_BYTE_VALUE_FOLLOWS then read_bytes(4).pack(\"C*\").unpack1(\"g\")\n      when EIGHT_BYTE_VALUE_FOLLOWS then read_bytes(8).pack(\"C*\").unpack1(\"G\")\n      else\n        raise ActionPack::WebAuthn::InvalidCborError, \"Invalid simple value: #{info}\"\n      end\n    end\n\n    def decode_tag\n      tag = read_argument\n      value = decode\n\n      case tag\n      when POSITIVE_BIGNUM_TAG then value.bytes.inject(0) { |n, b| (n << 8) | b }\n      when NEGATIVE_BIGNUM_TAG then -1 - value.bytes.inject(0) { |n, b| (n << 8) | b }\n      else value\n      end\n    end\n\n    def decode_half_float\n      half = read_bytes(2).pack(\"C*\").unpack1(\"n\")\n\n      sign = (half >> 15) & 0x1\n      exponent = (half >> 10) & 0x1F\n      mantissa = half & 0x3FF\n\n      value = if exponent == 0\n        Math.ldexp(mantissa, -24)\n      elsif exponent == 31\n        mantissa == 0 ? Float::INFINITY : Float::NAN\n      else\n        Math.ldexp(mantissa + 1024, exponent - 25)\n      end\n\n      sign == 1 ? -value : value\n    end\n\n    def read_argument\n      case info = additional_info\n      when SIMPLE_VALUE_RANGE then info\n      when SINGLE_BYTE_VALUE_FOLLOWS then read_byte\n      when TWO_BYTE_VALUE_FOLLOWS then read_bytes(2).pack(\"C*\").unpack1(\"n\")\n      when FOUR_BYTE_VALUE_FOLLOWS then read_bytes(4).pack(\"C*\").unpack1(\"N\")\n      when EIGHT_BYTE_VALUE_FOLLOWS then read_bytes(8).pack(\"C*\").unpack1(\"Q>\")\n      when RESERVED_VALUE_RANGE\n        raise ActionPack::WebAuthn::InvalidCborError, \"Reserved additional info: #{info}\"\n      else\n        raise ActionPack::WebAuthn::InvalidCborError, \"Invalid additional info: #{info}\"\n      end\n    end\n\n    def additional_info(consume: true)\n      byte = consume ? read_byte : peek\n      byte & 0b00011111\n    end\n\n    def indefinite_length?\n      read_byte if additional_info(consume: false) == INDEFINITE_LENGTH_MAJOR_TYPE\n    end\n\n    def break_code?\n      read_byte if peek == BREAK_CODE\n    end\n\n    def read_bytes(length)\n      raise ActionPack::WebAuthn::InvalidCborError, \"Unexpected end of input\" if @position + length > @bytes.length\n\n      bytes = @bytes[@position, length]\n      @position += length\n      bytes\n    end\n\n    def read_byte\n      raise ActionPack::WebAuthn::InvalidCborError, \"Unexpected end of input\" if @position >= @bytes.length\n\n      byte = @bytes[@position]\n      @position += 1\n      byte\n    end\nend\n"
  },
  {
    "path": "lib/action_pack/web_authn/cose_key.rb",
    "content": "# = Action Pack WebAuthn COSE Key\n#\n# Parses COSE (CBOR Object Signing and Encryption) public keys as specified in\n# RFC 9053[https://datatracker.ietf.org/doc/html/rfc9053]. WebAuthn authenticators\n# return public keys in COSE format, which must be converted to a standard format\n# for signature verification.\n#\n# == Usage\n#\n#   # Decode a COSE key from CBOR bytes (e.g., from authenticator data)\n#   cose_key = ActionPack::WebAuthn::CoseKey.decode(cbor_bytes)\n#\n#   # Convert to OpenSSL key for signature verification\n#   openssl_key = cose_key.to_openssl_key\n#   openssl_key.verify(\"SHA256\", signature, signed_data)\n#\n# == Supported Algorithms\n#\n# [ES256]\n#   ECDSA with P-256 curve and SHA-256. The most common algorithm for WebAuthn.\n#\n# [EdDSA]\n#   EdDSA with Ed25519 curve. Increasingly supported by modern authenticators.\n#\n# [RS256]\n#   RSASSA-PKCS1-v1_5 with SHA-256. Used by some security keys and platforms.\n#\n# == Attributes\n#\n# [+key_type+]\n#   The COSE key type (1 for OKP, 2 for EC2, 3 for RSA).\n#\n# [+algorithm+]\n#   The COSE algorithm identifier (-7 for ES256, -8 for EdDSA, -257 for RS256).\n#\n# [+parameters+]\n#   The full COSE key parameters map, including curve and coordinate data.\nclass ActionPack::WebAuthn::CoseKey\n  P256_COORDINATE_LENGTH = 32\n  MINIMUM_RSA_KEY_BITS = 2048\n\n  # COSE key labels\n  KEY_TYPE_LABEL = 1\n  ALGORITHM_LABEL = 3\n  EC2_CURVE_LABEL = -1\n  EC2_X_LABEL = -2\n  EC2_Y_LABEL = -3\n  RSA_N_LABEL = -1\n  RSA_E_LABEL = -2\n  OKP_CURVE_LABEL = -1\n  OKP_X_LABEL = -2\n\n  # COSE key types\n  OKP = 1\n  EC2 = 2\n  RSA = 3\n\n  # COSE algorithms\n  ES256 = -7\n  EDDSA = -8\n  RS256 = -257\n\n  # COSE EC2 curves\n  P256 = 1\n\n  # COSE OKP curves\n  ED25519 = 6\n\n  # OpenSSL types\n  UNCOMPRESSED_POINT_MARKER = 0x04\n\n  attr_reader :key_type, :algorithm, :parameters\n\n  class << self\n    # Decodes a COSE key from CBOR-encoded bytes.\n    #\n    #   cose_key = ActionPack::WebAuthn::CoseKey.decode(cbor_bytes)\n    #   cose_key.algorithm # => -7 (ES256)\n    def decode(bytes)\n      data = ActionPack::WebAuthn::CborDecoder.decode(bytes)\n      new(\n        key_type: data[KEY_TYPE_LABEL],\n        algorithm: data[ALGORITHM_LABEL],\n        parameters: data\n      )\n    end\n  end\n\n  def initialize(key_type:, algorithm:, parameters:) # :nodoc:\n    @key_type = key_type\n    @algorithm = algorithm\n    @parameters = parameters\n  end\n\n  # Converts the COSE key to an OpenSSL public key object.\n  #\n  # Returns an +OpenSSL::PKey::EC+ for EC2 keys, +OpenSSL::PKey::RSA+ for\n  # RSA keys, or an Ed25519 key for OKP keys, suitable for use with\n  # +OpenSSL::PKey#verify+.\n  #\n  # Raises +UnsupportedKeyTypeError+ if the key type, algorithm, or curve\n  # is not supported.\n  def to_openssl_key\n    case [ key_type, algorithm ]\n    when [ EC2, ES256 ] then build_ec2_es256_key\n    when [ OKP, EDDSA ] then build_okp_eddsa_key\n    when [ RSA, RS256 ] then build_rsa_rs256_key\n    else raise ActionPack::WebAuthn::UnsupportedKeyTypeError, \"Unsupported COSE key type/algorithm: #{key_type}/#{algorithm}\"\n    end\n  end\n\n  private\n    def build_ec2_es256_key\n      curve = parameters[EC2_CURVE_LABEL]\n      raise ActionPack::WebAuthn::UnsupportedKeyTypeError, \"Unsupported EC curve: #{curve}\" unless curve == P256\n\n      x = parameters[EC2_X_LABEL]\n      y = parameters[EC2_Y_LABEL]\n      raise ActionPack::WebAuthn::InvalidKeyError, \"Missing EC2 key coordinates\" if x.nil? || y.nil?\n      raise ActionPack::WebAuthn::InvalidKeyError, \"Invalid EC2 coordinate length\" unless x.bytesize == P256_COORDINATE_LENGTH && y.bytesize == P256_COORDINATE_LENGTH\n\n      # Uncompressed point format: 0x04 || x || y\n      public_key_bytes = [ UNCOMPRESSED_POINT_MARKER, *x.bytes, *y.bytes ].pack(\"C*\")\n\n      asn1 = OpenSSL::ASN1::Sequence([\n        OpenSSL::ASN1::Sequence([\n          OpenSSL::ASN1::ObjectId(\"id-ecPublicKey\"),\n          OpenSSL::ASN1::ObjectId(\"prime256v1\")\n        ]),\n        OpenSSL::ASN1::BitString(public_key_bytes)\n      ])\n\n      OpenSSL::PKey::EC.new(asn1.to_der)\n    rescue OpenSSL::PKey::PKeyError => error\n      raise ActionPack::WebAuthn::InvalidKeyError, \"Invalid EC2 key: #{error.message}\"\n    end\n\n    def build_okp_eddsa_key\n      curve = parameters[OKP_CURVE_LABEL]\n      raise ActionPack::WebAuthn::UnsupportedKeyTypeError, \"Unsupported OKP curve: #{curve}\" unless curve == ED25519\n\n      x = parameters[OKP_X_LABEL]\n      raise ActionPack::WebAuthn::InvalidKeyError, \"Missing OKP key coordinate\" if x.nil?\n\n      asn1 = OpenSSL::ASN1::Sequence([\n        OpenSSL::ASN1::Sequence([\n          OpenSSL::ASN1::ObjectId(\"ED25519\")\n        ]),\n        OpenSSL::ASN1::BitString(x)\n      ])\n\n      OpenSSL::PKey.read(asn1.to_der)\n    rescue OpenSSL::PKey::PKeyError => error\n      raise ActionPack::WebAuthn::InvalidKeyError, \"Invalid OKP key: #{error.message}\"\n    end\n\n    def build_rsa_rs256_key\n      n_bytes = parameters[RSA_N_LABEL]\n      e_bytes = parameters[RSA_E_LABEL]\n      raise ActionPack::WebAuthn::InvalidKeyError, \"Missing RSA key parameters\" if n_bytes.nil? || e_bytes.nil?\n      raise ActionPack::WebAuthn::InvalidKeyError, \"RSA key must be at least #{MINIMUM_RSA_KEY_BITS} bits\" if n_bytes.bytesize * 8 < MINIMUM_RSA_KEY_BITS\n\n      n = OpenSSL::BN.new(n_bytes, 2)\n      e = OpenSSL::BN.new(e_bytes, 2)\n\n      asn1 = OpenSSL::ASN1::Sequence([\n        OpenSSL::ASN1::Sequence([\n          OpenSSL::ASN1::ObjectId(\"rsaEncryption\"),\n          OpenSSL::ASN1::Null.new(nil)\n        ]),\n        OpenSSL::ASN1::BitString(\n          OpenSSL::ASN1::Sequence([\n            OpenSSL::ASN1::Integer(n),\n            OpenSSL::ASN1::Integer(e)\n          ]).to_der\n        )\n      ])\n\n      OpenSSL::PKey::RSA.new(asn1.to_der)\n    rescue OpenSSL::PKey::PKeyError => error\n      raise ActionPack::WebAuthn::InvalidKeyError, \"Invalid RSA key: #{error.message}\"\n    end\nend\n"
  },
  {
    "path": "lib/action_pack/web_authn/current.rb",
    "content": "# = Action Pack WebAuthn Current Attributes\n#\n# Thread-isolated request-scoped attributes for WebAuthn ceremonies. These are\n# set automatically by ActionPack::Passkey::Request at the start of each\n# request and consumed by the registration/authentication flows.\n#\n# == Attributes\n#\n# [+host+]\n#   The relying party identifier (typically +request.host+). Used as the\n#   default RelyingParty ID.\n#\n# [+origin+]\n#   The expected origin (typically +request.base_url+). Validated against the\n#   +origin+ field in the authenticator's client data.\n#\n# [+challenge+]\n#   The Base64URL-encoded challenge read from the encrypted cookie. Validated\n#   against the +challenge+ field in the authenticator's client data.\n#\nclass ActionPack::WebAuthn::Current < ActiveSupport::CurrentAttributes\n  attribute :host, :origin, :challenge\nend\n"
  },
  {
    "path": "lib/action_pack/web_authn/public_key_credential/creation_options.rb",
    "content": "# = Action Pack WebAuthn Public Key Credential Creation Options\n#\n# Generates options for the WebAuthn registration ceremony (creating a new\n# credential). These options are passed to +navigator.credentials.create()+ in\n# the browser to prompt the user to register an authenticator.\n#\n# == Usage\n#\n#   options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(\n#     id: current_user.id,\n#     name: current_user.email,\n#     display_name: current_user.name\n#   )\n#\n#   # In your controller, return as JSON for the JavaScript WebAuthn API\n#   render json: { publicKey: options.as_json }\n#\n# == Attributes\n#\n# [+id+]\n#   A unique identifier for the user account. Will be Base64URL-encoded in the\n#   output. This should be an opaque identifier (like a primary key), not\n#   personally identifiable information.\n#\n# [+name+]\n#   A human-readable identifier for the user account, typically an email\n#   address or username. Displayed by the authenticator.\n#\n# [+display_name+]\n#   A human-friendly name for the user, typically their full name. Displayed\n#   by the authenticator during registration.\n#\n# [+relying_party+]\n#   The relying party (your application) configuration. Defaults to\n#   +ActionPack::WebAuthn.relying_party+.\n#\n# == Supported Algorithms\n#\n# By default, supports ES256 (ECDSA with P-256 and SHA-256), EdDSA\n# (Ed25519), and RS256 (RSASSA-PKCS1-v1_5 with SHA-256), which cover\n# the vast majority of authenticators.\nclass ActionPack::WebAuthn::PublicKeyCredential::CreationOptions < ActionPack::WebAuthn::PublicKeyCredential::Options\n  ES256 = { type: \"public-key\", alg: -7 }.freeze\n  EDDSA = { type: \"public-key\", alg: -8 }.freeze\n  RS256 = { type: \"public-key\", alg: -257 }.freeze\n  RESIDENT_KEY_OPTIONS = %i[ preferred required discouraged ].freeze\n  ATTESTATION_PREFERENCES = %i[ none indirect direct enterprise ].freeze\n\n  attribute :id\n  attribute :name\n  attribute :display_name\n  attribute :resident_key, default: :required\n  attribute :exclude_credentials, default: -> { [] }\n  attribute :attestation, default: :none\n  attribute :challenge_expiration, default: -> { Rails.configuration.action_pack.web_authn.creation_challenge_expiration }\n\n  validates :id, :name, :display_name, presence: true\n  validates :resident_key, inclusion: { in: RESIDENT_KEY_OPTIONS }\n  validates :attestation, inclusion: { in: ATTESTATION_PREFERENCES }\n\n  def initialize(attributes = {})\n    super\n    self.resident_key = resident_key.to_sym\n    self.attestation = attestation.to_sym\n    validate!\n  end\n\n  # Returns a Hash suitable for JSON serialization and passing to the\n  # WebAuthn JavaScript API.\n  def as_json(*)\n    json = {\n      challenge: challenge,\n      rp: relying_party.as_json,\n      user: {\n        id: Base64.urlsafe_encode64(id.to_s, padding: false),\n        name: name,\n        displayName: display_name\n      },\n      pubKeyCredParams: [\n        ES256,\n        EDDSA,\n        RS256\n      ],\n      authenticatorSelection: {\n        residentKey: resident_key.to_s,\n        requireResidentKey: resident_key == :required,\n        userVerification: user_verification.to_s\n      }\n    }\n\n    if exclude_credentials.any?\n      json[:excludeCredentials] = exclude_credentials.map { |credential| exclude_credential_json(credential) }\n    end\n\n    if attestation != :none\n      json[:attestation] = attestation.to_s\n    end\n\n    json\n  end\n\n  private\n    def exclude_credential_json(credential)\n      hash = { type: \"public-key\", id: credential.id }\n      hash[:transports] = credential.transports if credential.transports.any?\n      hash\n    end\nend\n"
  },
  {
    "path": "lib/action_pack/web_authn/public_key_credential/options.rb",
    "content": "# = Action Pack WebAuthn Public Key Credential Options\n#\n# Abstract base class for WebAuthn ceremony options. Provides shared\n# attributes and challenge generation for both CreationOptions (registration)\n# and RequestOptions (authentication).\n#\n# This class should not be instantiated directly. Use CreationOptions or\n# RequestOptions instead.\n#\n# == Challenge Generation\n#\n# Each options object generates a signed, expiring challenge via\n# +ActionPack::WebAuthn.challenge_verifier+. The challenge is Base64URL-encoded\n# and includes an embedded timestamp so the server can reject stale challenges.\n#\n# == Attributes\n#\n# [+user_verification+]\n#   Controls whether user verification (biometrics/PIN) is required. One of\n#   +:required+, +:preferred+, or +:discouraged+. Defaults to +:preferred+.\n#\n# [+relying_party+]\n#   The RelyingParty configuration. Defaults to +ActionPack::WebAuthn.relying_party+.\n#\n# [+challenge_expiration+]\n#   How long the challenge remains valid. Defaults vary by ceremony type\n#   (configured in the Railtie).\n#\nclass ActionPack::WebAuthn::PublicKeyCredential::Options\n  include ActiveModel::API\n  include ActiveModel::Attributes\n\n  CHALLENGE_LENGTH = 32\n  USER_VERIFICATION_OPTIONS = %i[ required preferred discouraged ].freeze\n\n  attribute :user_verification, default: :preferred\n  attribute :relying_party, default: -> { ActionPack::WebAuthn.relying_party }\n  attribute :challenge_expiration\n\n  validates :user_verification, inclusion: { in: USER_VERIFICATION_OPTIONS }\n\n  def initialize(attributes = {})\n    super\n    self.user_verification = user_verification.to_sym\n  end\n\n  # Validates the options, raising +InvalidOptionsError+ if any are invalid.\n  def validate!\n    super\n  rescue ActiveModel::ValidationError\n    raise ActionPack::WebAuthn::InvalidOptionsError, errors.full_messages.to_sentence\n  end\n\n  # Returns a human-readable representation of the options.\n  def inspect\n    attributes_string = attributes.map { |name, value| \"#{name}: #{value.inspect}\" }.join(\", \")\n    \"#<#{self.class.name} #{attributes_string}>\"\n  end\n\n  # Returns a Base64URL-encoded signed challenge containing a random nonce and\n  # an embedded timestamp. The challenge is generated once and memoized for the\n  # lifetime of this object.\n  #\n  # The timestamp allows the server to reject stale challenges. The expiration\n  # window is configurable per-ceremony via\n  # +config.action_pack.web_authn.creation_challenge_expiration+ and\n  # +config.action_pack.web_authn.request_challenge_expiration+, or per-instance\n  # via the +challenge_expiration+ attribute.\n  def challenge\n    @challenge ||= Base64.urlsafe_encode64(\n      ActionPack::WebAuthn.challenge_verifier.generate(\n        Base64.strict_encode64(SecureRandom.random_bytes(CHALLENGE_LENGTH)),\n        expires_in: challenge_expiration\n      ),\n      padding: false\n    )\n  end\nend\n"
  },
  {
    "path": "lib/action_pack/web_authn/public_key_credential/request_options.rb",
    "content": "# = Action Pack WebAuthn Public Key Credential Request Options\n#\n# Generates options for the WebAuthn authentication ceremony (using an existing\n# credential). These options are passed to +navigator.credentials.get()+ in\n# the browser to prompt the user to authenticate with a registered authenticator.\n#\n# == Usage\n#\n#   options = ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new(\n#     credentials: current_user.webauthn_credentials\n#   )\n#\n#   # In your controller, return as JSON for the JavaScript WebAuthn API\n#   render json: { publicKey: options.as_json }\n#\n# == Attributes\n#\n# [+credentials+]\n#   A collection of credential records for the user. Each credential must\n#   respond to +id+ returning the Base64URL-encoded credential ID, and\n#   +transports+ returning an array of transport strings.\n#\n# [+relying_party+]\n#   The relying party (your application) configuration. Defaults to\n#   +ActionPack::WebAuthn.relying_party+.\nclass ActionPack::WebAuthn::PublicKeyCredential::RequestOptions < ActionPack::WebAuthn::PublicKeyCredential::Options\n  attribute :credentials, default: -> { [] }\n  attribute :challenge_expiration, default: -> { Rails.configuration.action_pack.web_authn.request_challenge_expiration }\n\n  def initialize(attributes = {})\n    super\n    validate!\n  end\n\n  # Returns a Hash suitable for JSON serialization and passing to the\n  # WebAuthn JavaScript API.\n  def as_json(*)\n    {\n      challenge: challenge,\n      rpId: relying_party.id,\n      allowCredentials: credentials.map { |credential| allow_credential_json(credential) },\n      userVerification: user_verification.to_s\n    }\n  end\n\n  private\n    def allow_credential_json(credential)\n      hash = { type: \"public-key\", id: credential.id }\n      hash[:transports] = credential.transports if credential.transports.any?\n      hash\n    end\nend\n"
  },
  {
    "path": "lib/action_pack/web_authn/public_key_credential.rb",
    "content": "# = Action Pack WebAuthn Public Key Credential\n#\n# Represents a WebAuthn public key credential and orchestrates the registration\n# and authentication ceremonies. During registration (+.register+), it verifies\n# the attestation response and returns a new credential. During authentication\n# (+#authenticate+), it verifies the assertion response against the stored\n# public key.\n#\n# == Registration\n#\n#   credential = ActionPack::WebAuthn::PublicKeyCredential.register(\n#     params[:passkey],\n#     challenge: ActionPack::WebAuthn::Current.challenge,\n#     origin: ActionPack::WebAuthn::Current.origin\n#   )\n#\n#   credential.id         # => Base64URL-encoded credential ID\n#   credential.public_key # => OpenSSL::PKey::EC\n#   credential.sign_count # => 0\n#\n# == Authentication\n#\n#   credential = ActionPack::WebAuthn::PublicKeyCredential.new(\n#     id: stored_credential_id,\n#     public_key: stored_public_key,\n#     sign_count: stored_sign_count\n#   )\n#\n#   credential.authenticate(params[:passkey])\n#\n# == Ceremony Options\n#\n# Use +.creation_options+ and +.request_options+ to generate the JSON options\n# passed to the browser's +navigator.credentials.create()+ and\n# +navigator.credentials.get()+ calls.\n#\n# == Attributes\n#\n# [+id+]\n#   The Base64URL-encoded credential identifier.\n#\n# [+public_key+]\n#   The OpenSSL public key for signature verification.\n#\n# [+sign_count+]\n#   The signature counter, used for replay detection.\n#\n# [+aaguid+]\n#   The authenticator attestation GUID (set during registration).\n#\n# [+backed_up+]\n#   Whether the credential is backed up to cloud storage (synced passkey).\n#\n# [+transports+]\n#   Transport hints (e.g., \"internal\", \"usb\", \"ble\", \"nfc\").\n#\nclass ActionPack::WebAuthn::PublicKeyCredential\n  attr_reader :id, :public_key, :sign_count, :aaguid, :backed_up, :transports\n\n  class << self\n    # Returns a RequestOptions object for the authentication ceremony.\n    # Credentials responding to +to_public_key_credential+ are automatically\n    # transformed.\n    def request_options(**attributes)\n      attributes[:credentials] = transform_credentials(attributes[:credentials]) if attributes[:credentials]\n\n      ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new(**attributes)\n    end\n\n    # Returns a CreationOptions object for the registration ceremony.\n    # Credentials in +exclude_credentials+ responding to\n    # +to_public_key_credential+ are automatically transformed.\n    def creation_options(**attributes)\n      attributes[:exclude_credentials] = transform_credentials(attributes[:exclude_credentials]) if attributes[:exclude_credentials]\n\n      ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(**attributes)\n    end\n\n    # Verifies an attestation response from the browser and returns a new\n    # PublicKeyCredential with the registered credential data.\n    #\n    # Raises +InvalidResponseError+ if the attestation is invalid.\n    def register(params, challenge: ActionPack::WebAuthn::Current.challenge, origin: ActionPack::WebAuthn::Current.origin)\n      response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(\n        client_data_json: params[:client_data_json],\n        attestation_object: params[:attestation_object],\n        challenge: challenge,\n        origin: origin\n      )\n\n      response.validate!\n\n      new(\n        id: response.attestation.credential_id,\n        public_key: response.attestation.public_key,\n        sign_count: response.attestation.sign_count,\n        aaguid: response.attestation.aaguid,\n        backed_up: response.attestation.backed_up?,\n        transports: Array(params[:transports])\n      )\n    end\n\n    private\n      def transform_credentials(credentials)\n        Array(credentials).map do |credential|\n          if credential.respond_to?(:to_public_key_credential)\n            credential.to_public_key_credential\n          else\n            credential\n          end\n        end\n      end\n  end\n\n  def initialize(id:, public_key:, sign_count:, aaguid: nil, backed_up: nil, transports: [])\n    @id = id\n    @public_key = public_key\n    @public_key = OpenSSL::PKey.read(public_key) unless public_key.is_a?(OpenSSL::PKey::PKey)\n    @sign_count = sign_count\n    @aaguid = aaguid\n    @backed_up = backed_up\n    @transports = transports\n  end\n\n  # Verifies an assertion response against this credential's public key.\n  # Updates +sign_count+ and +backed_up+ on success.\n  #\n  # Raises +InvalidResponseError+ if the assertion is invalid.\n  def authenticate(params, challenge: ActionPack::WebAuthn::Current.challenge, origin: ActionPack::WebAuthn::Current.origin)\n    response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(\n      client_data_json: params[:client_data_json],\n      authenticator_data: params[:authenticator_data],\n      signature: params[:signature],\n      credential: self,\n      challenge: challenge,\n      origin: origin\n    )\n\n    response.validate!\n\n    @sign_count = response.authenticator_data.sign_count\n    @backed_up = response.authenticator_data.backed_up?\n  end\n\n  # Returns a Hash of the credential data suitable for persisting.\n  def to_h\n    {\n      credential_id: id,\n      public_key: public_key.to_der,\n      sign_count: sign_count,\n      aaguid: aaguid,\n      backed_up: backed_up,\n      transports: transports\n    }\n  end\nend\n"
  },
  {
    "path": "lib/action_pack/web_authn/relying_party.rb",
    "content": "# = Action Pack WebAuthn Relying Party\n#\n# Represents the relying party (your application) in WebAuthn ceremonies. The\n# relying party identity is sent to authenticators during registration and\n# authentication to scope credentials to your application.\n#\n# == Usage\n#\n#   # Using defaults (host from Current, name from Rails application)\n#   relying_party = ActionPack::WebAuthn::RelyingParty.new\n#\n#   # With explicit values\n#   relying_party = ActionPack::WebAuthn::RelyingParty.new(\n#     id: \"example.com\",\n#     name: \"Example Application\"\n#   )\n#\n# == Attributes\n#\n# [+id+]\n#   The relying party identifier, typically the application's domain name\n#   (e.g., \"example.com\"). This must match the origin's effective domain\n#   or be a registrable domain suffix of it. Credentials are scoped to this\n#   identifier. Defaults to +ActionPack::WebAuthn::Current.host+.\n#\n# [+name+]\n#   A human-readable name for your application, displayed by authenticators\n#   during ceremonies. Defaults to +Rails.application.name+.\nclass ActionPack::WebAuthn::RelyingParty\n  attr_reader :id, :name\n\n  # Creates a new relying party configuration.\n  #\n  # ==== Options\n  #\n  # [+:id+]\n  #   Optional. The relying party identifier (domain).\n  #\n  # [+:name+]\n  #   Optional. The application display name.\n  def initialize(id: ActionPack::WebAuthn::Current.host, name: Rails.application.name)\n    @id = id\n    @name = name\n  end\n\n  # Returns a Hash suitable for JSON serialization.\n  def as_json(*)\n    { id: id, name: name }\n  end\nend\n"
  },
  {
    "path": "lib/action_pack/web_authn.rb",
    "content": "# = Action Pack WebAuthn\n#\n# Provides a pure-Ruby implementation of the WebAuthn (Web Authentication)\n# specification for passkey registration and authentication. This module\n# is the top-level namespace for all WebAuthn components and provides\n# shared utilities used across ceremonies.\n#\n# == Components\n#\n# [ActionPack::WebAuthn::RelyingParty]\n#   Identifies your application to authenticators.\n#\n# [ActionPack::WebAuthn::PublicKeyCredential]\n#   Orchestrates registration and authentication ceremonies.\n#\n# [ActionPack::WebAuthn::Authenticator]\n#   Parses and validates authenticator responses.\n#\n# [ActionPack::WebAuthn::CborDecoder]\n#   Decodes CBOR-encoded data from authenticators.\n#\n# [ActionPack::WebAuthn::CoseKey]\n#   Parses COSE public keys into OpenSSL key objects.\n#\n# == Extending Attestation Formats\n#\n# By default only the \"none\" attestation format is supported. Register\n# additional verifiers with:\n#\n#   ActionPack::WebAuthn.register_attestation_verifier(\"packed\", MyPackedVerifier.new)\n#\nmodule ActionPack::WebAuthn\n  class InvalidResponseError < StandardError; end\n  class InvalidCborError < StandardError; end\n  class InvalidKeyError < StandardError; end\n  class UnsupportedKeyTypeError < StandardError; end\n  class InvalidOptionsError < StandardError; end\n\n  class << self\n    # Returns a new RelyingParty configured from the current request context.\n    def relying_party\n      RelyingParty.new\n    end\n\n    # Returns the MessageVerifier used to sign and verify WebAuthn challenges.\n    def challenge_verifier\n      Rails.application.message_verifier(\"action_pack.webauthn.challenge\")\n    end\n\n    # Returns the registry of attestation format verifiers, keyed by format\n    # string (e.g., \"none\", \"packed\"). Only \"none\" is registered by default.\n    def attestation_verifiers\n      @attestation_verifiers ||= {\n        \"none\" => Authenticator::AttestationVerifiers::None.new\n      }\n    end\n\n    # Registers a custom attestation verifier for the given +format+.\n    # The +verifier+ must respond to +verify!(attestation, client_data_json:)+.\n    def register_attestation_verifier(format, verifier)\n      attestation_verifiers[format.to_s] = verifier\n    end\n  end\nend\n"
  },
  {
    "path": "lib/assets/.keep",
    "content": ""
  },
  {
    "path": "lib/auto_link_scrubber.rb",
    "content": "# Loofah scrubber to auto-link URLs and email addresses in HTML text nodes.\n#\n# This scrubber does not perform HTML sanitization; it's assumed that the input is already sanitized\n# (for example, ActionText rich text).\nclass AutoLinkScrubber < Loofah::Scrubber\n  EXCLUDED_ELEMENTS = %w[a figcaption pre code].freeze\n\n  # This regexp is similar to URI::MailTo::EMAIL_REGEXP but uses \\b word boundaries instead of \\A/\\z\n  # anchors, allowing it to match email addresses embedded within longer strings.\n  #\n  # It's named EMAIL_AUTOLINK_REGEXP (not EMAIL_REGEXP) to avoid confusing Brakeman's imprecise\n  # constant lookup, which otherwise assumes Identity's email validation uses this \\b-anchored pattern.\n  # See https://github.com/presidentbeef/brakeman/pull/1981\n  EMAIL_AUTOLINK_REGEXP = /\\b[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\\b/\n  URL_REGEXP = URI::DEFAULT_PARSER.make_regexp(%w[http https])\n  AUTOLINK_REGEXP = /(?<url>#{URL_REGEXP})|(?<email>#{EMAIL_AUTOLINK_REGEXP})/\n\n  TRAILING_PUNCTUATION = %(.?,:!;\"'<>)\n  TRAILING_PUNCTUATION_REGEXP = /[#{Regexp.escape(TRAILING_PUNCTUATION)}]+\\z/\n\n  MAX_TEXT_NODE_LENGTH = 10_000\n\n  def initialize\n    @direction = :top_down\n  end\n\n  def scrub(node)\n    return Loofah::Scrubber::STOP if EXCLUDED_ELEMENTS.include?(node.name)\n\n    if node.text?\n      replacement = autolink_text_node(node)\n      node.replace(replacement) if replacement\n    end\n\n    Loofah::Scrubber::CONTINUE\n  end\n\n  private\n    def autolink_text_node(node)\n      text = node.text\n      links = find_links(text)\n\n      return nil if links.empty?\n\n      doc = node.document\n      nodes = Nokogiri::XML::NodeSet.new(doc)\n      pos = 0\n\n      links.each do |link|\n        nodes << doc.create_text_node(text[pos...link[:start]]) if link[:start] > pos\n        nodes << doc.create_element(\"a\", link[:text], href: link[:href], rel: \"noopener noreferrer\")\n        pos = link[:start] + link[:length]\n      end\n      nodes << doc.create_text_node(text[pos..]) if pos < text.length\n\n      nodes\n    end\n\n    def find_links(text)\n      return [] if text.length > MAX_TEXT_NODE_LENGTH\n\n      links = []\n\n      text.scan(AUTOLINK_REGEXP) do\n        match_data = Regexp.last_match\n        start_pos = match_data.begin(0)\n\n        if match_data[:url]\n          url = clean_url(match_data[:url])\n          links << { start: start_pos, length: url.length, text: url, href: url }\n        else\n          email = match_data[:email]\n          links << { start: start_pos, length: email.length, text: email, href: \"mailto:#{email}\" }\n        end\n      end\n\n      links\n    rescue Regexp::TimeoutError => error\n      Sentry.capture_exception error if Fizzy.saas?\n      []\n    end\n\n    def clean_url(url)\n      url.sub(TRAILING_PUNCTUATION_REGEXP, \"\")\n    end\nend\n"
  },
  {
    "path": "lib/deployment/database_resolver.rb",
    "content": "module Deployment\n  class DatabaseResolver < ActiveRecord::Middleware::DatabaseSelector::Resolver\n    def self.in_primary_datacenter?\n      ENV[\"PRIMARY_DATACENTER\"].present? || Rails.env.local?\n    end\n\n    def reading_request?(request)\n      # Disables writes (and so primary-DB stickiness) in non-primary datacenters\n      super || !DatabaseResolver.in_primary_datacenter?\n    end\n\n    private\n      def read_from_primary?\n        # Only the primary datacenter can read from the primary database, non-primary DCs have local DB replicas\n        super && DatabaseResolver.in_primary_datacenter?\n      end\n  end\nend\n"
  },
  {
    "path": "lib/deployment.rb",
    "content": "require_relative \"deployment/database_resolver.rb\"\n"
  },
  {
    "path": "lib/fizzy.rb",
    "content": "module Fizzy\n  class << self\n    def saas?\n      return @saas if defined?(@saas)\n      @saas = !!(((ENV[\"SAAS\"] || File.exist?(File.expand_path(\"../tmp/saas.txt\", __dir__))) && ENV[\"SAAS\"] != \"false\"))\n    end\n\n    def db_adapter\n      @db_adapter ||= DbAdapter.new ENV.fetch(\"DATABASE_ADAPTER\", saas? ? \"mysql\" : \"sqlite\")\n    end\n\n    def configure_bundle\n      if saas?\n        ENV[\"BUNDLE_GEMFILE\"] = \"Gemfile.saas\"\n      end\n    end\n  end\n\n  class DbAdapter\n    def initialize(name)\n      @name = name.to_s\n    end\n\n    def to_s\n      @name\n    end\n\n    # Not using inquiry so that it works before Rails env loads.\n    def sqlite?\n      @name == \"sqlite\"\n    end\n  end\nend\n"
  },
  {
    "path": "lib/rails_ext/action_mailer_mail_delivery_job.rb",
    "content": "Rails.application.config.to_prepare do\n  ActionMailer::MailDeliveryJob.include SmtpDeliveryErrorHandling\nend\n"
  },
  {
    "path": "lib/rails_ext/action_pack_passkey_infer_name_from_aaguid.rb",
    "content": "module ActionPackPasskeyInferNameFromAaguid\n  extend ActiveSupport::Concern\n\n  class_methods do\n    def register(...)\n      super(...).tap do |credential|\n        credential.update!(name: credential.authenticator.name) if credential.authenticator && credential.name.blank?\n      end\n    end\n  end\n\n  def authenticator\n    Passkey::Authenticator.find_by_aaguid(aaguid)\n  end\nend\n"
  },
  {
    "path": "lib/rails_ext/active_record_date_arithmetic.rb",
    "content": "# frozen_string_literal: true\n\n# Adds database-agnostic date arithmetic methods to ActiveRecord adapters.\n# This allows code to perform date calculations without checking which database adapter is in use.\n#\n# Usage:\n#   connection.date_subtract(\"created_at\", \"3600\")\n#   # MySQL/Trilogy: \"DATE_SUB(created_at, INTERVAL 3600 SECOND)\"\n#   # SQLite: \"datetime(created_at, '-' || (3600) || ' seconds')\"\n\n# Module for MySQL-based adapters (Trilogy, Mysql2, etc.)\nmodule MysqlDateArithmetic\n  # Generates SQL for subtracting seconds from a date/time column in MySQL.\n  #\n  # @param date_column [String] The date/time column or expression\n  # @param seconds_expression [String] SQL expression that evaluates to number of seconds\n  # @return [String] MySQL DATE_SUB expression\n  #\n  # Example:\n  #   date_subtract(\"last_active_at\", \"COALESCE(auto_postpone_period, 3600)\")\n  #   # => \"DATE_SUB(last_active_at, INTERVAL COALESCE(auto_postpone_period, 3600) SECOND)\"\n  def date_subtract(date_column, seconds_expression)\n    \"DATE_SUB(#{date_column}, INTERVAL #{seconds_expression} SECOND)\"\n  end\nend\n\n# Module for SQLite adapter\nmodule SqliteDateArithmetic\n  # Generates SQL for subtracting seconds from a date/time column in SQLite.\n  #\n  # @param date_column [String] The date/time column or expression\n  # @param seconds_expression [String] SQL expression that evaluates to number of seconds\n  # @return [String] SQLite datetime expression\n  #\n  # Example:\n  #   date_subtract(\"last_active_at\", \"COALESCE(auto_postpone_period, 3600)\")\n  #   # => \"datetime(last_active_at, '-' || (COALESCE(auto_postpone_period, 3600)) || ' seconds')\"\n  def date_subtract(date_column, seconds_expression)\n    \"datetime(#{date_column}, '-' || (#{seconds_expression}) || ' seconds')\"\n  end\nend\n\nActiveSupport.on_load(:active_record) do\n  # Prepend MySQL date arithmetic to AbstractMysqlAdapter (covers Trilogy, Mysql2, etc.)\n  if defined?(ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter)\n    ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter.prepend(MysqlDateArithmetic)\n  end\n\n  # Prepend SQLite date arithmetic to SQLite3Adapter\n  if defined?(ActiveRecord::ConnectionAdapters::SQLite3Adapter)\n    ActiveRecord::ConnectionAdapters::SQLite3Adapter.prepend(SqliteDateArithmetic)\n  end\nend\n"
  },
  {
    "path": "lib/rails_ext/active_record_replica_support.rb",
    "content": "# frozen_string_literal: true\n\n# Adds a helper method to check if replica database connections are configured\n# and automatically configures read/write splitting when replicas are available.\n#\n# Usage:\n#   class ApplicationRecord < ActiveRecord::Base\n#     configure_replica_connections\n#   end\nmodule ActiveRecordReplicaSupport\n  extend ActiveSupport::Concern\n\n  class_methods do\n    # Automatically configures connects_to for read/write splitting if a replica\n    # database is configured for the current environment. This is a no-op if no\n    # replica configuration exists.\n    #\n    # Example:\n    #   class ApplicationRecord < ActiveRecord::Base\n    #     configure_replica_connections\n    #   end\n    def configure_replica_connections\n      if replica_configured?\n        connects_to database: { writing: :primary, reading: :replica }\n      end\n    end\n\n    # Returns true if a replica database configuration exists for the current\n    # environment. This allows different database adapters to opt in or out of\n    # read/write splitting based on their database.yml configuration.\n    #\n    # Example:\n    #   ApplicationRecord.replica_configured? # => true for MySQL, false for SQLite\n    def replica_configured?\n      configurations.find_db_config(\"replica\").present?\n    end\n\n    # Execute block using read replica if available, otherwise use primary.\n    #\n    # Example:\n    #   ApplicationRecord.with_reading_role { User.count }\n    def with_reading_role(&block)\n      if replica_configured?\n        connected_to(role: :reading, &block)\n      else\n        yield\n      end\n    end\n  end\nend\n\nActiveSupport.on_load(:active_record) do\n  include ActiveRecordReplicaSupport\nend\n"
  },
  {
    "path": "lib/rails_ext/active_record_uuid_type.rb",
    "content": "# Custom UUID attribute type for MySQL binary storage with base36 string representation\nmodule ActiveRecord\n  module Type\n    class Uuid < Binary\n      BASE36_LENGTH = 25 # 36^25 > 2^128\n\n      class << self\n        def generate\n          uuid = SecureRandom.uuid_v7\n          hex = uuid.delete(\"-\")\n          hex_to_base36(hex)\n        end\n\n        def hex_to_base36(hex)\n          hex.to_i(16).to_s(36).rjust(BASE36_LENGTH, \"0\")\n        end\n\n        def base36_to_hex(base36)\n          base36.to_s.to_i(36).to_s(16).rjust(32, \"0\")\n        end\n      end\n\n      def serialize(value)\n        return unless value\n\n        binary = Uuid.base36_to_hex(value).scan(/../).map(&:hex).pack(\"C*\")\n        super(binary)\n      end\n\n      def deserialize(value)\n        return unless value\n\n        hex = value.to_s.unpack1(\"H*\")\n        Uuid.hex_to_base36(hex)\n      end\n\n      def cast(value)\n        value\n      end\n    end\n  end\nend\n\n# Register the UUID type for Trilogy (MySQL) and SQLite3 adapters\nActiveRecord::Type.register(:uuid, ActiveRecord::Type::Uuid, adapter: :trilogy)\nActiveRecord::Type.register(:uuid, ActiveRecord::Type::Uuid, adapter: :sqlite3)\n"
  },
  {
    "path": "lib/rails_ext/active_storage_analyze_job_skip_detached.rb",
    "content": "# Skip analysis for blobs whose attachments have already been destroyed.\n#   e.g. when a user uploads a file but deletes it before the analysis runs.\n# Avoids `Aws::S3::Errors::NoSuchKey` when an upload is deleted before AnalyzeJob runs.\nmodule ActiveStorageAnalyzeJobSkipDetached\n  def perform(blob)\n    return unless blob.attachments.exists?\n\n    super\n  end\nend\n\nActiveSupport.on_load :active_storage_blob do\n  ActiveStorage::AnalyzeJob.prepend ActiveStorageAnalyzeJobSkipDetached\nend\n"
  },
  {
    "path": "lib/rails_ext/active_storage_analyze_job_suppress_broadcasts.rb",
    "content": "# Avoid page refreshes from Active Storage analyzing blobs when these are attached.\n#\n# A better option would be to disable touching with +touch_attachment_records+ but\n# there is currently a bug https://github.com/rails/rails/issues/55144\nmodule ActiveStorageAnalyzeJobSuppressBroadcasts\n  def perform(blob)\n    Board.suppressing_turbo_broadcasts do\n      Card.suppressing_turbo_broadcasts do\n        super\n      end\n    end\n  end\nend\n\nActiveSupport.on_load :active_storage_blob do\n  ActiveStorage::AnalyzeJob.prepend ActiveStorageAnalyzeJobSuppressBroadcasts\nend\n"
  },
  {
    "path": "lib/rails_ext/active_storage_authorization.rb",
    "content": "ActiveSupport.on_load :active_storage_blob do\n  def accessible_to?(user)\n    attachments.includes(:record).any? { |attachment| attachment.accessible_to?(user) } || attachments.none?\n  end\n\n  def publicly_accessible?\n    attachments.includes(:record).any? { |attachment| attachment.publicly_accessible? }\n  end\nend\n\nActiveSupport.on_load :active_storage_attachment do\n  def accessible_to?(user)\n    record.try(:accessible_to?, user)\n  end\n\n  def publicly_accessible?\n    record.try(:publicly_accessible?)\n  end\nend\n\nRails.application.config.to_prepare do\n  module ActiveStorage::Authorize\n    extend ActiveSupport::Concern\n\n    include Authentication\n\n    included do\n      # Ensure require_authentication runs after set_blob.\n      skip_before_action :require_authentication\n      before_action :require_authentication, :ensure_accessible, unless: :publicly_accessible_blob?\n    end\n\n    private\n      def publicly_accessible_blob?\n        @blob.publicly_accessible?\n      end\n\n      def ensure_accessible\n        unless @blob.accessible_to?(Current.user)\n          head :forbidden\n        end\n      end\n  end\n\n  ActiveStorage::Blobs::RedirectController.include ActiveStorage::Authorize\n  ActiveStorage::Blobs::ProxyController.include ActiveStorage::Authorize\n  ActiveStorage::Representations::RedirectController.include ActiveStorage::Authorize\n  ActiveStorage::Representations::ProxyController.include ActiveStorage::Authorize\nend\n"
  },
  {
    "path": "lib/rails_ext/active_storage_blob_service_url_for_direct_upload_expiry.rb",
    "content": "#\n#  see https://github.com/basecamp/haystack/pull/7862\n#\nmodule ActiveStorage\n  mattr_accessor :service_urls_for_direct_uploads_expire_in, default: 48.hours\nend\n\nmodule ActiveStorageBlobServiceUrlForDirectUploadExpiry\n  # Override default expires_in to accommodate long upload URL expiry\n  # without having to lengthen download URL expiry.\n  #\n  # Accounts for Cloudflare only proxying slow client uploads once they're\n  # fully buffered, long after the URL expired.\n  #\n  # 48 hours covers a 10GB upload at 0.5Mbps.\n  def service_url_for_direct_upload(expires_in: ActiveStorage.service_urls_for_direct_uploads_expire_in)\n    super\n  end\nend\n\nActiveSupport.on_load :active_storage_blob do\n  prepend ::ActiveStorageBlobServiceUrlForDirectUploadExpiry\nend\n"
  },
  {
    "path": "lib/rails_ext/active_support_array_conversions.rb",
    "content": "module ChoiceSentenceArrayConversion\n  def to_choice_sentence\n    to_sentence two_words_connector: \" or \", last_word_connector: \", or \"\n  end\nend\n\nArray.include ChoiceSentenceArrayConversion\n"
  },
  {
    "path": "lib/rails_ext/prepend_order.rb",
    "content": "module ActiveRecordRelationPrependOrder\n  extend ActiveSupport::Concern\n\n  included do\n    def prepend_order(*args)\n      new_orders = args.flatten.map { |arg| arg.is_a?(String) ? arg : arg.to_sql }\n\n      spawn.tap do |relation|\n        relation.order_values = new_orders + order_values\n      end\n    end\n  end\nend\n\nActiveRecord::Relation.include(ActiveRecordRelationPrependOrder)\nActiveRecord::AssociationRelation.include(ActiveRecordRelationPrependOrder)\n"
  },
  {
    "path": "lib/rails_ext/string.rb",
    "content": "class String\n  def all_emoji?\n    self.match?(/\\A(\\p{Emoji_Presentation}|\\p{Extended_Pictographic}|\\uFE0F)+\\z/u)\n  end\nend\n"
  },
  {
    "path": "lib/tasks/dev.rake",
    "content": "namespace :dev do\n  desc \"Toggle using Letter Opener to preview emails\"\n  task :email do\n    file_path = Rails.root.join(\"tmp\", \"email-dev.txt\")\n\n    if File.exist?(file_path)\n      File.delete(file_path)\n      puts \"Letter Opener turned off\"\n    else\n      FileUtils.touch(file_path)\n      puts \"Letter Opener turned on\"\n    end\n\n    %x(bin/rails restart)\n  end\nend\n"
  },
  {
    "path": "lib/tasks/saas.rake",
    "content": "namespace :saas do\n  SAAS_FILE_PATH = \"tmp/saas.txt\"\n\n  desc \"Enable SaaS mode\"\n  task enable: :environment do\n    file_path = Rails.root.join(SAAS_FILE_PATH)\n    FileUtils.mkdir_p(File.dirname(file_path))\n    FileUtils.touch(file_path)\n    puts \"SaaS mode enabled (#{file_path} created)\"\n  end\n\n  desc \"Disable SaaS mode\"\n  task disable: :environment do\n    file_path = Rails.root.join(SAAS_FILE_PATH)\n    FileUtils.rm_f(file_path)\n    puts \"SaaS mode disabled (#{file_path} removed)\"\n  end\nend\n"
  },
  {
    "path": "lib/tasks/search.rake",
    "content": "namespace :search do\n  desc \"Reindex all cards and comments in the search index\"\n  task reindex: :environment do\n    puts \"Clearing search records...\"\n    if ActiveRecord::Base.connection.adapter_name == \"SQLite\"\n      ActiveRecord::Base.connection.execute(\"DELETE FROM search_records\")\n      ActiveRecord::Base.connection.execute(\"DELETE FROM search_records_fts\")\n    else\n      Search::Record::Trilogy::SHARD_COUNT.times do |shard_id|\n        ActiveRecord::Base.connection.execute(\"DELETE FROM search_records_#{shard_id}\")\n      end\n    end\n\n    puts \"Reindexing cards...\"\n    Card.includes(:rich_text_description).find_each(&:reindex)\n\n    puts \"Reindexing comments...\"\n    Comment.includes(:rich_text_body, :card).find_each(&:reindex)\n\n    puts \"Done! Reindexed #{Card.count} cards and #{Comment.count} comments.\"\n  end\nend\n"
  },
  {
    "path": "lib/web_push/notification.rb",
    "content": "class WebPush::Notification\n  def initialize(title:, body:, url:, badge:, endpoint:, endpoint_ip:, p256dh_key:, auth_key:)\n    @title, @body, @url, @badge = title, body, url, badge\n    @endpoint, @endpoint_ip, @p256dh_key, @auth_key = endpoint, endpoint_ip, p256dh_key, auth_key\n  end\n\n  def deliver(connection: nil)\n    WebPush.payload_send \\\n      message: encoded_message,\n      endpoint: @endpoint, endpoint_ip: @endpoint_ip, p256dh: @p256dh_key, auth: @auth_key,\n      vapid: vapid_identification,\n      connection: connection,\n      urgency: \"high\"\n  end\n\n  private\n    def vapid_identification\n      { subject: \"mailto:support@fizzy.do\" }.merge \\\n        Rails.configuration.x.vapid.symbolize_keys\n    end\n\n    def encoded_message\n      JSON.generate title: @title, options: { body: @body, icon: icon_path, data: { url: @url, path: @url, badge: @badge } }\n    end\n\n    def icon_path\n      \"/apple-touch-icon.png\"\n    end\nend\n"
  },
  {
    "path": "lib/web_push/pool.rb",
    "content": "# This is in lib so we can use it in a thread pool without the Rails executor\nclass WebPush::Pool\n  attr_reader :delivery_pool, :invalidation_pool, :connection, :invalid_subscription_handler\n\n  def initialize(invalid_subscription_handler:)\n    @delivery_pool = Concurrent::ThreadPoolExecutor.new(max_threads: 50, queue_size: 10000)\n    @invalidation_pool = Concurrent::FixedThreadPool.new(1)\n    @connection = Net::HTTP::Persistent.new(name: \"web_push\", pool_size: 150)\n    @invalid_subscription_handler = invalid_subscription_handler\n  end\n\n  def queue(payload, subscriptions)\n    subscriptions.find_each do |subscription|\n      deliver_later(payload, subscription)\n    end\n  end\n\n  def shutdown\n    connection.shutdown\n    shutdown_pool(delivery_pool)\n    shutdown_pool(invalidation_pool)\n  end\n\n  private\n    def deliver_later(payload, subscription)\n      # Ensure any AR operations happen before we post to the thread pool\n      notification = subscription.notification(**payload)\n      subscription_id = subscription.id\n\n      delivery_pool.post do\n        deliver(notification, subscription_id)\n      rescue Exception => e\n        Rails.logger.error \"Error in WebPush::Pool.deliver: #{e.class} #{e.message}\"\n      end\n    rescue Concurrent::RejectedExecutionError\n    end\n\n    def deliver(notification, id)\n      notification.deliver(connection: connection)\n    rescue WebPush::ExpiredSubscription, OpenSSL::OpenSSLError => ex\n      invalidate_subscription_later(id) if invalid_subscription_handler\n    end\n\n    def invalidate_subscription_later(id)\n      invalidation_pool.post do\n        invalid_subscription_handler.call(id)\n      rescue Exception => e\n        Rails.logger.error \"Error in WebPush::Pool.invalid_subscription_handler: #{e.class} #{e.message}\"\n      end\n    end\n\n    def shutdown_pool(pool)\n      pool.shutdown\n      pool.kill unless pool.wait_for_termination(1)\n    end\nend\n"
  },
  {
    "path": "log/.keep",
    "content": ""
  },
  {
    "path": "public/400.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <title>400 Bad Request</title>\n    <meta charset=\"utf-8\">\n    <meta name=\"viewport\" content=\"initial-scale=1, width=device-width\">\n    <meta name=\"robots\" content=\"noindex, nofollow\">\n    <link rel=\"stylesheet\" href=\"/error.css\">\n  </head>\n  <body>\n    <hgroup class=\"error-page__stamp\">\n      <h1>400</h1>\n      <h2>Bad request</h2>\n      <hr />\n      <div>Your request could not be understood by the server.</div>\n    </hgroup>\n\n    <a href=\"/\">&larr; Back home</a>\n  </body>\n</html>\n\n"
  },
  {
    "path": "public/404.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <title>404 Not Found</title>\n    <meta charset=\"utf-8\">\n    <meta name=\"viewport\" content=\"initial-scale=1, width=device-width\">\n    <meta name=\"robots\" content=\"noindex, nofollow\">\n    <link rel=\"stylesheet\" href=\"/error.css\">\n  </head>\n  <body>\n    <hgroup class=\"error-page__stamp\">\n      <h1>404</h1>\n      <h2>Sorry, that page doesn’t exist!</h2>\n      <hr />\n      <div>You may have mistyped the address or the page may have moved.</div>\n    </hgroup>\n\n    <a href=\"/\">&larr; Back home</a>\n  </body>\n</html>\n\n"
  },
  {
    "path": "public/406-unsupported-browser.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <title>406 Unsupported Browser</title>\n    <meta charset=\"utf-8\">\n    <meta name=\"viewport\" content=\"initial-scale=1, width=device-width\">\n    <meta name=\"robots\" content=\"noindex, nofollow\">\n    <link rel=\"stylesheet\" href=\"/error.css\">\n  </head>\n  <body>\n    <hgroup class=\"error-page__stamp\">\n      <h1>406</h1>\n      <h2>You need to upgrade your browser</h2>\n      <hr />\n      <div>This application requires a modern browser. Please upgrade to the latest version.</div>\n    </hgroup>\n\n    <a href=\"/\">&larr; Back home</a>\n  </body>\n</html>\n\n"
  },
  {
    "path": "public/422.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <title>422 Unprocessable Entity</title>\n    <meta charset=\"utf-8\">\n    <meta name=\"viewport\" content=\"initial-scale=1, width=device-width\">\n    <meta name=\"robots\" content=\"noindex, nofollow\">\n    <link rel=\"stylesheet\" href=\"/error.css\">\n  </head>\n  <body>\n    <hgroup class=\"error-page__stamp\">\n      <h1>422</h1>\n      <h2>Unprocessable entity</h2>\n      <hr />\n      <div>The server understands the request but was unable to process it.</div>\n    </hgroup>\n\n    <a href=\"/\">&larr; Back home</a>\n  </body>\n</html>\n\n"
  },
  {
    "path": "public/500.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <title>500 Internal Server Error</title>\n    <meta charset=\"utf-8\">\n    <meta name=\"viewport\" content=\"initial-scale=1, width=device-width\">\n    <meta name=\"robots\" content=\"noindex, nofollow\">\n    <link rel=\"stylesheet\" href=\"/error.css\">\n  </head>\n  <body>\n    <hgroup class=\"error-page__stamp\">\n      <h1>500</h1>\n      <h2>Internal server error</h2>\n      <hr />\n      <div>Something went wrong on our end; please try again later.</div>\n    </hgroup>\n\n    <a href=\"/\">&larr; Back home</a>\n  </body>\n</html>\n\n"
  },
  {
    "path": "public/error.css",
    "content": ":root {\n  --color-canvas: white;\n  --color-ink: oklch(26% 0.05 264);\n  --color-accent: oklch(57.02% 0.1895 260.46);\n\n  --text-large: 6rem;\n  --text-medium: 2rem;\n  --text-normal: 1rem;\n\n  @media (prefers-color-scheme: dark) {\n    --color-canvas: oklch(20% 0.0195 232.58);\n    --color-ink: oklch(96.02% 0.0034 260);\n    --color-accent: oklch(74% 0.1293 256);\n  }\n}\n\n*, *::before, *::after {\n  box-sizing: border-box;\n  margin: 0;\n}\n\nhtml {\n  font-size: 1.5rem;\n  overflow: hidden;\n}\n\nbody {\n  -moz-osx-font-smoothing: grayscale;\n  -webkit-font-smoothing: antialiased;\n  -webkit-text-size-adjust: none;\n  align-items: center;\n  background-color: var(--color-canvas);\n  color: var(--color-ink);\n  display: flex;\n  flex-direction: column;\n  font-family: system-ui;\n  font-size: var(--text-normal);\n  font-style: normal;\n  font-weight: 500;\n  gap: 2rem;\n  line-height: 1.375;\n  min-height: 100vh;\n  overflow: hidden;\n  padding-inline: 1rem;\n  justify-content: center;\n  text-align: center;\n  text-rendering: optimizeLegibility;\n  text-size-adjust: none;\n\n  /* Cool wavy striped lines */\n  &:before {\n    --mask:\n    radial-gradient(6px at 50% calc(100% + 3px), #0000 calc(99% - 2px), #000 calc(101% - 2px) 99%, #0000 101%) calc(50% - 8px) calc(50% - 3px + .5px)/16px 6px ,\n    radial-gradient(6px at 50% -3px, #0000 calc(99% - 2px), #000 calc(101% - 2px) 99%, #0000 101%) 50% calc(50% + 3px)/16px 6px ;\n    -webkit-mask: var(--mask);\n    mask: var(--mask);\n    background: var(--color-accent);\n    content: \"\";\n    inset: 0;\n    opacity: 0.15;\n    position: absolute;\n    z-index: -1;\n  }\n}\n\n.error-page__stamp {\n  --padding: 1rem 2rem;\n  --stroke-width: 0.25rem;\n\n  align-items: center;\n  background-color: color-mix(in oklch, var(--color-canvas), transparent 50%);\n  background-color: var(--color-canvas);\n  border-radius: calc(var(--stroke-width) * 2);\n  border: calc(var(--stroke-width) * 1) dashed var(--color-accent);\n  color: var(--color-accent);\n  display: flex;\n  flex-direction: column;\n  justify-content: center;\n  line-height: 1.25;\n  max-inline-size: 36ch;\n  rotate: -5deg;\n  padding: var(--padding);\n  position: relative;\n}\n\nh1 {\n  font-size: var(--text-large);\n  font-weight: 900;\n  line-height: 1;\n  position: relative;\n  text-transform: uppercase;\n\n  @supports (-webkit-text-stroke: 1px black) {\n    -webkit-text-stroke: var(--stroke-width) var(--color-accent);\n    -webkit-text-fill-color: transparent;\n  }\n}\n\nh2 {\n  font-size: var(--text-normal);\n  font-weight: inherit;\n}\n\nhr {\n  inline-size: 100%;\n  border: 0;\n  border-block-start: calc(var(--stroke-width) / 2) solid currentcolor;\n  color: currentcolor;\n  margin: 1ch auto;\n}\n\na {\n  color: inherit;\n  line-height: 2rem;\n  display: inline-block;\n  padding-inline: 1ch;\n  border-radius: 99rem;\n  text-decoration: none;\n\n  span {\n    text-decoration: underline;\n    text-underline-offset: 0.25ch;\n  }\n\n  &:hover {\n    color: var(--color-accent);\n  }\n}\n"
  },
  {
    "path": "public/robots.txt",
    "content": "User-Agent: *\nDisallow: /\n"
  },
  {
    "path": "saas/.kamal/hooks/post-deploy",
    "content": "#!/usr/bin/env bash\n\nMESSAGE=\"$KAMAL_PERFORMER deployed $KAMAL_SERVICE_VERSION to $KAMAL_DESTINATION in $KAMAL_RUNTIME seconds\"\nCURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)\n\nbin/notify_dash_of_deployment \"$MESSAGE\" $KAMAL_VERSION $KAMAL_PERFORMER $CURRENT_BRANCH $KAMAL_DESTINATION $KAMAL_RUNTIME\n\nif [[ $CURRENT_BRANCH == \"main\" && $KAMAL_DESTINATION == \"production\" ]]; then\n  gh release create $KAMAL_SERVICE_VERSION --target $KAMAL_VERSION --generate-notes 2> /dev/null || true\n\n  RELEASE_URL=$(gh release view $KAMAL_SERVICE_VERSION --json url,body --jq .url)\n  RELEASE_BODY=$(gh release view $KAMAL_SERVICE_VERSION --json url,body --jq .body)\n\n  saas/bin/broadcast_to_bc \"$MESSAGE \"$'\\n'\"$RELEASE_URL \"$'\\n'\"$RELEASE_BODY\"\nelse\n  saas/bin/broadcast_to_bc \"$MESSAGE\"\nfi\n"
  },
  {
    "path": "saas/.kamal/hooks/pre-connect",
    "content": "#!/usr/bin/env bash\n\n# Validate hostnames are FQDNs ending in -int.37signals.com\nif command -v yq >/dev/null 2>&1; then\n  declare -A SUGGESTIONS\n  while IFS= read -r host; do\n    if [[ ! $host =~ -int\\.37signals\\.com$ ]]; then\n      if [[ $host =~ -4[0-9]{2}$ ]]; then\n        SUGGESTIONS[\"$host\"]=\"$host.df-ams-int.37signals.com\"\n      elif [[ $host =~ -1[0-9]{2}$ ]]; then\n        SUGGESTIONS[\"$host\"]=\"$host.df-iad-int.37signals.com\"\n      else\n        SUGGESTIONS[\"$host\"]=\"$host.sc-chi-int.37signals.com\"\n      fi\n    fi\n  done < <(bin/kamal config -d \"${KAMAL_DESTINATION:-production}\" 2>/dev/null | yq -r '.\":hosts\"[]')\n\n  if [ ${#SUGGESTIONS[@]} -gt 0 ]; then\n    echo \"Unqualified hostnames found in config/deploy.${KAMAL_DESTINATION:-production}.yml:\" >&2\n    echo \"\" >&2\n    echo \"Update to use fully-qualified hostnames:\" >&2\n    for host in \"${!SUGGESTIONS[@]}\"; do\n      echo \"  $host → ${SUGGESTIONS[$host]}\" >&2\n    done\n    exit 1\n  fi\nfi\n\n# Verify Tailscale connection and SSH authentication before deploying.\ntailscale_cmd() {\n  if command -v tailscale >/dev/null 2>&1; then\n    tailscale \"$@\"\n  elif [ -f \"/Applications/Tailscale.app/Contents/MacOS/Tailscale\" ]; then\n    env TAILSCALE_BE_CLI=1 /Applications/Tailscale.app/Contents/MacOS/Tailscale \"$@\"\n  else\n    return 1\n  fi\n}\n\non_tailscale() {\n  tailscale_cmd status --json 2>/dev/null | jq -e '.Self.Online' >/dev/null 2>&1\n}\n\n# Check Tailscale connection\nif ! on_tailscale; then\n  echo \"\" >&2\n  echo \"You must be connected to Tailscale to deploy.\" >&2\n  echo \"\" >&2\n  echo \"→ Connect to Tailscale and try again\" >&2\n  echo \"\" >&2\n  exit 1\nfi\n\n# Verify SSH access\necho \"Deploying via Tailscale. Verifying SSH access…\" >&2\n\nTEST_HOST=\"fizzy-app-101.df-iad-int.37signals.com\"\n\nSSH_OUTPUT=$(ssh -o ConnectTimeout=5 \"app@$TEST_HOST\" true 2>&1)\nSSH_EXIT=$?\n\necho \"$SSH_OUTPUT\" >&2\n\nif echo \"$SSH_OUTPUT\" | grep -q \"Permission denied\"; then\n  GITHUB_USER=$(gh api user 2>/dev/null | jq -r '.login // \"unknown\"')\n  GITHUB_KEYS_URL=\"https://github.com/${GITHUB_USER}.keys\"\n\n  echo \"\" >&2\n  echo \"ERROR: SSH authentication failed\" >&2\n  echo \"\" >&2\n  echo \"You must deploy with an SSH key that's on your GitHub account.\" >&2\n  echo \"\" >&2\n  echo \"→ Verify your public key is at $GITHUB_KEYS_URL\" >&2\n  echo \"  Add it at https://github.com/settings/keys if not\" >&2\n  echo \"\" >&2\n  echo \"Note that SSH keys are pulled from GitHub every 5 minutes, so if you've\" >&2\n  echo \"just added a new key to GitHub, try again in five.\" >&2\n  echo \"\" >&2\n  exit 1\nfi\n\nexit $SSH_EXIT\n"
  },
  {
    "path": "saas/.kamal/secrets.beta",
    "content": "SECRETS=$(kamal secrets fetch --adapter 1password --account 23QPQDKZC5BKBIIG7UGT5GR5RM --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Beta/RAILS_MASTER_KEY Beta/MYSQL_ALTER_PASSWORD Beta/MYSQL_ALTER_USER Beta/MYSQL_APP_PASSWORD Beta/MYSQL_APP_USER Beta/MYSQL_READONLY_PASSWORD Beta/MYSQL_READONLY_USER Beta/SECRET_KEY_BASE Beta/VAPID_PUBLIC_KEY Beta/VAPID_PRIVATE_KEY Beta/ACTIVE_STORAGE_ACCESS_KEY_ID Beta/ACTIVE_STORAGE_SECRET_ACCESS_KEY Beta/QUEENBEE_API_TOKEN Beta/SIGNAL_ID_SECRET Beta/SENTRY_DSN Beta/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Beta/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Beta/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Beta/STRIPE_MONTHLY_V1_PRICE_ID Beta/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Beta/STRIPE_SECRET_KEY Beta/STRIPE_WEBHOOK_SECRET Beta/APNS_KEY_ID Beta/APNS_ENCRYPTION_KEY_B64 Beta/FCM_ENCRYPTION_KEY_B64)\n\nGITHUB_TOKEN=$(gh config get -h github.com oauth_token)\nBASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS)\nDASH_BASIC_AUTH_SECRET=$(kamal secrets extract DASH_BASIC_AUTH_SECRET $SECRETS)\nRAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY $SECRETS)\nMYSQL_ALTER_PASSWORD=$(kamal secrets extract MYSQL_ALTER_PASSWORD $SECRETS)\nMYSQL_ALTER_USER=$(kamal secrets extract MYSQL_ALTER_USER $SECRETS)\nMYSQL_APP_PASSWORD=$(kamal secrets extract MYSQL_APP_PASSWORD $SECRETS)\nMYSQL_APP_USER=$(kamal secrets extract MYSQL_APP_USER $SECRETS)\nMYSQL_READONLY_PASSWORD=$(kamal secrets extract MYSQL_READONLY_PASSWORD $SECRETS)\nMYSQL_READONLY_USER=$(kamal secrets extract MYSQL_READONLY_USER $SECRETS)\nSECRET_KEY_BASE=$(kamal secrets extract SECRET_KEY_BASE $SECRETS)\nVAPID_PUBLIC_KEY=$(kamal secrets extract VAPID_PUBLIC_KEY $SECRETS)\nVAPID_PRIVATE_KEY=$(kamal secrets extract VAPID_PRIVATE_KEY $SECRETS)\nACTIVE_STORAGE_ACCESS_KEY_ID=$(kamal secrets extract ACTIVE_STORAGE_ACCESS_KEY_ID $SECRETS)\nACTIVE_STORAGE_SECRET_ACCESS_KEY=$(kamal secrets extract ACTIVE_STORAGE_SECRET_ACCESS_KEY $SECRETS)\nQUEENBEE_API_TOKEN=$(kamal secrets extract QUEENBEE_API_TOKEN $SECRETS)\nSIGNAL_ID_SECRET=$(kamal secrets extract SIGNAL_ID_SECRET $SECRETS)\nSENTRY_DSN=$(kamal secrets extract SENTRY_DSN $SECRETS)\nACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY $SECRETS)\nACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY $SECRETS)\nACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT $SECRETS)\nSTRIPE_MONTHLY_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_V1_PRICE_ID $SECRETS)\nSTRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID $SECRETS)\nSTRIPE_SECRET_KEY=$(kamal secrets extract STRIPE_SECRET_KEY $SECRETS)\nSTRIPE_WEBHOOK_SECRET=$(kamal secrets extract STRIPE_WEBHOOK_SECRET $SECRETS)\nAPNS_KEY_ID=$(kamal secrets extract APNS_KEY_ID $SECRETS)\nAPNS_ENCRYPTION_KEY_B64=$(kamal secrets extract APNS_ENCRYPTION_KEY_B64 $SECRETS)\nFCM_ENCRYPTION_KEY_B64=$(kamal secrets extract FCM_ENCRYPTION_KEY_B64 $SECRETS)\n"
  },
  {
    "path": "saas/.kamal/secrets.production",
    "content": "SECRETS=$(kamal secrets fetch --adapter 1password --account 23QPQDKZC5BKBIIG7UGT5GR5RM --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Production/RAILS_MASTER_KEY Production/MYSQL_ALTER_PASSWORD Production/MYSQL_ALTER_USER Production/MYSQL_APP_PASSWORD Production/MYSQL_APP_USER Production/MYSQL_READONLY_PASSWORD Production/MYSQL_READONLY_USER Production/SECRET_KEY_BASE Production/VAPID_PUBLIC_KEY Production/VAPID_PRIVATE_KEY Production/ACTIVE_STORAGE_ACCESS_KEY_ID Production/ACTIVE_STORAGE_SECRET_ACCESS_KEY Production/QUEENBEE_API_TOKEN Production/SIGNAL_ID_SECRET Production/SENTRY_DSN Production/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Production/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Production/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Production/STRIPE_MONTHLY_V1_PRICE_ID Production/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Production/STRIPE_SECRET_KEY Production/STRIPE_WEBHOOK_SECRET Production/APNS_KEY_ID Production/APNS_ENCRYPTION_KEY_B64 Production/FCM_ENCRYPTION_KEY_B64)\n\nGITHUB_TOKEN=$(gh config get -h github.com oauth_token)\nBASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS)\nDASH_BASIC_AUTH_SECRET=$(kamal secrets extract DASH_BASIC_AUTH_SECRET $SECRETS)\nRAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY $SECRETS)\nMYSQL_ALTER_PASSWORD=$(kamal secrets extract MYSQL_ALTER_PASSWORD $SECRETS)\nMYSQL_ALTER_USER=$(kamal secrets extract MYSQL_ALTER_USER $SECRETS)\nMYSQL_APP_PASSWORD=$(kamal secrets extract MYSQL_APP_PASSWORD $SECRETS)\nMYSQL_APP_USER=$(kamal secrets extract MYSQL_APP_USER $SECRETS)\nMYSQL_READONLY_PASSWORD=$(kamal secrets extract MYSQL_READONLY_PASSWORD $SECRETS)\nMYSQL_READONLY_USER=$(kamal secrets extract MYSQL_READONLY_USER $SECRETS)\nSECRET_KEY_BASE=$(kamal secrets extract SECRET_KEY_BASE $SECRETS)\nVAPID_PUBLIC_KEY=$(kamal secrets extract VAPID_PUBLIC_KEY $SECRETS)\nVAPID_PRIVATE_KEY=$(kamal secrets extract VAPID_PRIVATE_KEY $SECRETS)\nACTIVE_STORAGE_ACCESS_KEY_ID=$(kamal secrets extract ACTIVE_STORAGE_ACCESS_KEY_ID $SECRETS)\nACTIVE_STORAGE_SECRET_ACCESS_KEY=$(kamal secrets extract ACTIVE_STORAGE_SECRET_ACCESS_KEY $SECRETS)\nQUEENBEE_API_TOKEN=$(kamal secrets extract QUEENBEE_API_TOKEN $SECRETS)\nSIGNAL_ID_SECRET=$(kamal secrets extract SIGNAL_ID_SECRET $SECRETS)\nSENTRY_DSN=$(kamal secrets extract SENTRY_DSN $SECRETS)\nACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY $SECRETS)\nACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY $SECRETS)\nACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT $SECRETS)\nSTRIPE_MONTHLY_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_V1_PRICE_ID $SECRETS)\nSTRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID $SECRETS)\nSTRIPE_SECRET_KEY=$(kamal secrets extract STRIPE_SECRET_KEY $SECRETS)\nSTRIPE_WEBHOOK_SECRET=$(kamal secrets extract STRIPE_WEBHOOK_SECRET $SECRETS)\nAPNS_KEY_ID=$(kamal secrets extract APNS_KEY_ID $SECRETS)\nAPNS_ENCRYPTION_KEY_B64=$(kamal secrets extract APNS_ENCRYPTION_KEY_B64 $SECRETS)\nFCM_ENCRYPTION_KEY_B64=$(kamal secrets extract FCM_ENCRYPTION_KEY_B64 $SECRETS)\n"
  },
  {
    "path": "saas/.kamal/secrets.staging",
    "content": "SECRETS=$(kamal secrets fetch --adapter 1password --account 23QPQDKZC5BKBIIG7UGT5GR5RM --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Staging/RAILS_MASTER_KEY Staging/MYSQL_ALTER_PASSWORD Staging/MYSQL_ALTER_USER Staging/MYSQL_APP_PASSWORD Staging/MYSQL_APP_USER Staging/MYSQL_READONLY_PASSWORD Staging/MYSQL_READONLY_USER Staging/SECRET_KEY_BASE Staging/VAPID_PUBLIC_KEY Staging/VAPID_PRIVATE_KEY Staging/ACTIVE_STORAGE_ACCESS_KEY_ID Staging/ACTIVE_STORAGE_SECRET_ACCESS_KEY Staging/QUEENBEE_API_TOKEN Staging/SIGNAL_ID_SECRET Staging/SENTRY_DSN Staging/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Staging/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Staging/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Staging/STRIPE_MONTHLY_V1_PRICE_ID Staging/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Staging/STRIPE_SECRET_KEY Staging/STRIPE_WEBHOOK_SECRET)\n\nGITHUB_TOKEN=$(gh config get -h github.com oauth_token)\nBASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS)\nDASH_BASIC_AUTH_SECRET=$(kamal secrets extract DASH_BASIC_AUTH_SECRET $SECRETS)\nRAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY $SECRETS)\nMYSQL_ALTER_PASSWORD=$(kamal secrets extract MYSQL_ALTER_PASSWORD $SECRETS)\nMYSQL_ALTER_USER=$(kamal secrets extract MYSQL_ALTER_USER $SECRETS)\nMYSQL_APP_PASSWORD=$(kamal secrets extract MYSQL_APP_PASSWORD $SECRETS)\nMYSQL_APP_USER=$(kamal secrets extract MYSQL_APP_USER $SECRETS)\nMYSQL_READONLY_PASSWORD=$(kamal secrets extract MYSQL_READONLY_PASSWORD $SECRETS)\nMYSQL_READONLY_USER=$(kamal secrets extract MYSQL_READONLY_USER $SECRETS)\nSECRET_KEY_BASE=$(kamal secrets extract SECRET_KEY_BASE $SECRETS)\nVAPID_PUBLIC_KEY=$(kamal secrets extract VAPID_PUBLIC_KEY $SECRETS)\nVAPID_PRIVATE_KEY=$(kamal secrets extract VAPID_PRIVATE_KEY $SECRETS)\nACTIVE_STORAGE_ACCESS_KEY_ID=$(kamal secrets extract ACTIVE_STORAGE_ACCESS_KEY_ID $SECRETS)\nACTIVE_STORAGE_SECRET_ACCESS_KEY=$(kamal secrets extract ACTIVE_STORAGE_SECRET_ACCESS_KEY $SECRETS)\nQUEENBEE_API_TOKEN=$(kamal secrets extract QUEENBEE_API_TOKEN $SECRETS)\nSIGNAL_ID_SECRET=$(kamal secrets extract SIGNAL_ID_SECRET $SECRETS)\nSENTRY_DSN=$(kamal secrets extract SENTRY_DSN $SECRETS)\nACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY $SECRETS)\nACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY $SECRETS)\nACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT $SECRETS)\nSTRIPE_MONTHLY_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_V1_PRICE_ID $SECRETS)\nSTRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID $SECRETS)\nSTRIPE_SECRET_KEY=$(kamal secrets extract STRIPE_SECRET_KEY $SECRETS)\nSTRIPE_WEBHOOK_SECRET=$(kamal secrets extract STRIPE_WEBHOOK_SECRET $SECRETS)\n"
  },
  {
    "path": "saas/Dockerfile",
    "content": "# Make sure RUBY_VERSION matches the Ruby version in .ruby-version\nARG RUBY_VERSION=3.4.7\nFROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base\n\n# Rails app lives here\nWORKDIR /rails\n\n# Set production environment\nENV RAILS_ENV=\"production\" \\\n    BUNDLE_DEPLOYMENT=\"1\" \\\n    SAAS=\"1\" \\\n    BUNDLE_PATH=\"/usr/local/bundle\" \\\n    BUNDLE_GEMFILE=\"Gemfile.saas\" \\\n    BUNDLE_WITHOUT=\"development\"\n\n\n# Throw-away build stage to reduce size of final image\nFROM base AS build\n\n# Install packages needed to build gems\nRUN apt-get update -qq && \\\n    apt-get install -y --no-install-recommends -y build-essential pkg-config git libvips libyaml-dev libssl-dev && \\\n    rm -rf /var/lib/apt/lists /var/cache/apt/archives\n\n# Install application gems\nCOPY Gemfile Gemfile.lock Gemfile.saas Gemfile.saas.lock .ruby-version ./\nCOPY lib/fizzy.rb ./lib/fizzy.rb\nCOPY saas/fizzy-saas.gemspec ./saas/\nCOPY saas/lib/fizzy/saas/version.rb ./saas/lib/fizzy/saas/\nRUN --mount=type=secret,id=GITHUB_TOKEN --mount=type=cache,id=fizzy-permabundle-${RUBY_VERSION},sharing=locked,target=/permabundle \\\n    gem install bundler && \\\n    BUNDLE_PATH=/permabundle BUNDLE_GITHUB__COM=\"$(cat /run/secrets/GITHUB_TOKEN):x-oauth-basic\" bundle install && \\\n    cp -a /permabundle/. \"$BUNDLE_PATH\"/ && \\\n    bundle clean --force && \\\n    rm -rf \"$BUNDLE_PATH\"/ruby/*/bundler/gems/*/.git && \\\n    find \"$BUNDLE_PATH\" -type f \\( -name '*.gem' -o -iname '*.a' -o -iname '*.o' -o -iname '*.h' -o -iname '*.c' -o -iname '*.hpp' -o -iname '*.cpp' \\) -delete && \\\n    bundle exec bootsnap precompile --gemfile\n\n# Copy application code\nCOPY . .\n\n# Precompile bootsnap code for faster boot times\nRUN bundle exec bootsnap precompile app/ lib/\n\n# Precompiling assets for production\nRUN SECRET_KEY_BASE_DUMMY=1 \\\n    ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=1 \\\n    ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=1 \\\n    ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=1 \\\n    ./bin/rails assets:precompile\n\n# Final stage for app image\nFROM base\n\n# Install packages needed for deployment\nRUN apt-get update -qq && \\\n    apt-get install --no-install-recommends -y curl libsqlite3-0 libvips build-essential ffmpeg groff libreoffice-writer libreoffice-impress libreoffice-calc mupdf-tools sqlite3 libjemalloc-dev && \\\n    rm -rf /var/lib/apt/lists /var/cache/apt/archives\n\n# Copy built artifacts: gems, application\nCOPY --from=build /usr/local/bundle /usr/local/bundle\nCOPY --from=build /rails /rails\n\n# Run and own only the runtime files as a non-root user for security\nRUN useradd rails --create-home --shell /bin/bash && \\\n    chown -R rails:rails db log storage tmp\nUSER rails:rails\n\n# Entrypoint prepares the database.\nENTRYPOINT [\"/rails/bin/docker-entrypoint\"]\n\n# Ruby GC tuning values pulled from Autotuner recommendations\nENV RUBY_GC_HEAP_0_INIT_SLOTS=692636 \\\n    RUBY_GC_HEAP_1_INIT_SLOTS=175943 \\\n    RUBY_GC_HEAP_2_INIT_SLOTS=148807 \\\n    RUBY_GC_HEAP_3_INIT_SLOTS=9169 \\\n    RUBY_GC_HEAP_4_INIT_SLOTS=3054 \\\n    RUBY_GC_MALLOC_LIMIT=67108864 \\\n    RUBY_GC_MALLOC_LIMIT_MAX=134217728 \\\n    LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2\n\n# Start the server by default, this can be overwritten at runtime\nEXPOSE 80 443 9394\nCMD [\"./bin/thrust\", \"./bin/rails\", \"server\"]\n"
  },
  {
    "path": "saas/LICENSE.md",
    "content": "# O'Saasy License Agreement\n\nCopyright © 2025, 37signals LLC.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\n1. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n2. No licensee or downstream recipient may use the Software (including any modified or derivative versions) to directly compete with the original Licensor by offering it to third parties as a hosted, managed, or Software-as-a-Service (SaaS) product or cloud service where the primary value of the service is the functionality of the Software itself.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n"
  },
  {
    "path": "saas/README.md",
    "content": "This is a Rails engine that [37signals](https://37signals.com/) bundles with [Fizzy](https://github.com/basecamp/fizzy) to offer the hosted version at https://fizzy.do.\n\n## Development\n\nTo make Fizzy run in SaaS mode, run this in the terminal:\n\n```ruby\nbin/rails saas:enable\n```\n\nTo go back to open source mode:\n\n```ruby\nbin/rails saas:disable\n```\n\nThen you can do [Fizzy development as usual](https://github.com/basecamp/fizzy).\n\n## How to update Fizzy\n\nAfter making changes to this gem, you need to update Fizzy to pick up the changes:\n\n```ruby\nBUNDLE_GEMFILE=Gemfile.saas bundle update --conservative fizzy-saas\n```\n\n## Working with Stripe\n\nThe first time, you need to:\n\n1. Install Stripe CLI: https://stripe.com/docs/stripe-cli\n2. Run `stripe login` and authorize the environment `37signals Development`\n\nThen, for working on the Stripe integration locally, you need to run this script to start the tunneling and set the environment variables:\n\n```sh\neval \"$(BUNDLE_GEMFILE=Gemfile.saas bundle exec stripe-dev)\"\nbin/dev # You need to start the dev server in the same terminal session\n```\n\nThis will ask for your 1password authorization to read and set the environment variables that Stripe needs.\n\n### Stripe environments\n\n* [Development](https://dashboard.stripe.com/acct_1SdTFtRus34tgjsJ/test/dashboard)\n* [Staging](https://dashboard.stripe.com/acct_1SdTbuRvb8txnPBR/test/dashboard)\n* [Production](https://dashboard.stripe.com/acct_1SNy97RwChFE4it8/dashboard)\n\n## Working with Push Notifications\n\nTo test native push notifications (APNs and FCM) locally, start the dev server with the `--push` flag:\n\n```sh\nbin/dev --push\n```\n\nThis will ask for your 1Password authorization to fetch the push credentials. Note that this loads the **production** APNs and FCM credentials into your environment.\n\n## Environments\n\nFizzy is deployed with [Kamal](https://kamal-deploy.org/). You'll need to have the 1Password CLI set up in order to access the secrets that are used when deploying. Provided you have that, it should be as simple as `bin/kamal deploy` to the correct environment.\n\n## Handbook\n\nSee the [Fizzy handbook](https://handbooks.37signals.works/18/fizzy) for runbooks and more.\n\n### Production\n\n- https://app.fizzy.do/\n\nThis environment uses a FlashBlade bucket for blob storage.\n\n### Beta\n\nBeta is primarily intended for testing product features. It uses the same production database and Active Storage configuration.\n\nThere are 4 beta environments:\n\n- https://beta1.fizzy-beta.com\n- https://beta2.fizzy-beta.com\n- https://beta3.fizzy-beta.com\n- https://beta4.fizzy-beta.com\n\nDeploy with: `bin/kamal deploy -d beta1` (or `-d beta2`, `-d beta3`, `-d beta4`)\n\n### Staging\n\nStaging is primarily intended for testing infrastructure changes. It uses production-like but separate database and Active Storage configurations.\n\n- https://app.fizzy-staging.com/\n\n## License\n\nfizzy-saas is released under the [O'Saasy License](LICENSE.md).\n"
  },
  {
    "path": "saas/Rakefile",
    "content": "require \"bundler/setup\"\n\nAPP_RAKEFILE = File.expand_path(\"test/dummy/Rakefile\", __dir__)\nload \"rails/tasks/engine.rake\"\n\nload \"rails/tasks/statistics.rake\"\n\nrequire \"bundler/gem_tasks\"\n"
  },
  {
    "path": "saas/app/assets/images/fizzy/saas/.keep",
    "content": ""
  },
  {
    "path": "saas/app/controllers/admin/audits_controller.rb",
    "content": "class Admin::AuditsController < ::AdminController\n  private\n    # Extend Fizzy's authentication to support auditor bearer tokens.\n    def require_authentication\n      authenticate_by_audit_bearer_token || super\n    end\n\n    def authenticate_by_audit_bearer_token\n      if auditor = auditor_from_bearer_token\n        Current.identity = auditor\n      end\n    end\n\n    def find_current_auditor\n      Current.identity\n    end\nend\n"
  },
  {
    "path": "saas/app/controllers/admin/stats_controller.rb",
    "content": "class Admin::StatsController < AdminController\n  layout \"public\"\n\n  def show\n    @accounts_total = Account.count\n    @accounts_last_7_days = Account.where(created_at: 7.days.ago..).count\n    @accounts_last_24_hours = Account.where(created_at: 24.hours.ago..).count\n\n    @identities_total = Identity.count\n    @identities_last_7_days = Identity.where(created_at: 7.days.ago..).count\n    @identities_last_24_hours = Identity.where(created_at: 24.hours.ago..).count\n\n    @top_accounts = Account\n      .where(\"cards_count > 0\")\n      .order(cards_count: :desc)\n      .limit(20)\n\n    @recent_accounts = Account.order(created_at: :desc).limit(10)\n  end\nend\n"
  },
  {
    "path": "saas/app/controllers/concerns/card/storage_limited/commenting.rb",
    "content": "module Card::StorageLimited::Commenting\n  extend ActiveSupport::Concern\n\n  included do\n    include Card::StorageLimited\n\n    before_action :ensure_within_storage_limit, only: :create\n  end\nend\n"
  },
  {
    "path": "saas/app/controllers/concerns/card/storage_limited/creation.rb",
    "content": "module Card::StorageLimited::Creation\n  extend ActiveSupport::Concern\n\n  included do\n    include Card::StorageLimited\n\n    before_action :ensure_within_storage_limit, only: :create, if: -> { request.format.json? }\n  end\nend\n"
  },
  {
    "path": "saas/app/controllers/concerns/card/storage_limited/publishing.rb",
    "content": "module Card::StorageLimited::Publishing\n  extend ActiveSupport::Concern\n\n  included do\n    include Card::StorageLimited\n\n    before_action :ensure_within_storage_limit, only: :create\n  end\nend\n"
  },
  {
    "path": "saas/app/controllers/concerns/card/storage_limited.rb",
    "content": "module Card::StorageLimited\n  extend ActiveSupport::Concern\n\n  private\n    def ensure_within_storage_limit\n      head :forbidden if Current.account.exceeding_storage_limit? && !Current.identity.staff?\n    end\nend\n"
  },
  {
    "path": "saas/app/controllers/my/devices_controller.rb",
    "content": "class My::DevicesController < ApplicationController\n  disallow_account_scope\n  before_action :set_device, only: :destroy\n\n  layout \"public\"\n\n  def index\n    @devices = Current.identity.devices.order(created_at: :desc)\n  end\n\n  def create\n    ApplicationPushDevice.register(session: Current.session, **device_params)\n    head :created\n  end\n\n  def destroy\n    @device.destroy\n    respond_to do |format|\n      format.html { redirect_to saas.my_devices_path, notice: \"Device removed\" }\n      format.json { head :no_content }\n    end\n  end\n\n  private\n    def set_device\n      @device = Current.identity.devices.find_by(token: params[:id]) || Current.identity.devices.find(params[:id])\n    end\n\n    def device_params\n      params.require([ :token, :platform ])\n      params.permit(:token, :platform, :name).to_h.symbolize_keys\n    end\nend\n"
  },
  {
    "path": "saas/app/jobs/application_push_notification_job.rb",
    "content": "class ApplicationPushNotificationJob < ActionPushNative::NotificationJob\nend\n"
  },
  {
    "path": "saas/app/models/account/storage_exception.rb",
    "content": "class Account::StorageException < SaasRecord\n  belongs_to :account\n\n  validates :bytes_allowed, presence: true, numericality: { greater_than: 0 }\nend\n"
  },
  {
    "path": "saas/app/models/account/storage_limited.rb",
    "content": "module Account::StorageLimited\n  extend ActiveSupport::Concern\n\n  DEFAULT_STORAGE_LIMIT = 1.gigabyte\n  NEAR_STORAGE_LIMIT_THRESHOLD = 500.megabytes\n\n  included do\n    has_one :storage_exception\n  end\n\n  def storage_limit\n    storage_exception&.bytes_allowed || DEFAULT_STORAGE_LIMIT\n  end\n\n  def exceeding_storage_limit?\n    bytes_used > storage_limit\n  end\n\n  def nearing_storage_limit?\n    !exceeding_storage_limit? && bytes_used > storage_limit - NEAR_STORAGE_LIMIT_THRESHOLD\n  end\n\n  def add_storage_exception(bytes)\n    if storage_exception\n      storage_exception.update!(bytes_allowed: bytes)\n    else\n      create_storage_exception!(bytes_allowed: bytes)\n    end\n  end\nend\n"
  },
  {
    "path": "saas/app/models/application_push_device.rb",
    "content": "class ApplicationPushDevice < ActionPushNative::Device\n  belongs_to :session, optional: true\n\n  def self.register(session:, token:, platform:, name: nil)\n    session.identity.devices.find_or_initialize_by(token: token).tap do |device|\n      device.update!(session: session, platform: platform.downcase, name: name)\n    end\n  end\nend\n"
  },
  {
    "path": "saas/app/models/application_push_notification.rb",
    "content": "class ApplicationPushNotification < ActionPushNative::Notification\n  queue_as :default\n  self.enabled = Fizzy.saas? && (!Rails.env.local? || ENV[\"ENABLE_NATIVE_PUSH\"] == \"true\")\nend\n"
  },
  {
    "path": "saas/app/models/identity/devices.rb",
    "content": "module Identity::Devices\n  extend ActiveSupport::Concern\n\n  included do\n    has_many :devices, class_name: \"ApplicationPushDevice\", as: :owner, dependent: :destroy\n  end\nend\n"
  },
  {
    "path": "saas/app/models/notification/push_target/native.rb",
    "content": "class Notification::PushTarget::Native < Notification::PushTarget\n  def process\n    if devices.any?\n      native_notification.deliver_later_to(devices)\n    end\n  end\n\n  private\n    def devices\n      @devices ||= notification.identity.devices\n    end\n\n    def payload\n      @payload ||= notification.payload\n    end\n\n    def native_notification\n      ApplicationPushNotification\n        .with_apple(\n          aps: {\n            category: payload.category,\n            \"mutable-content\": 1,\n            \"interruption-level\": interruption_level\n          }\n        )\n        .with_google(\n          android: { notification: nil }\n        )\n        .with_data(\n          title: payload.title,\n          body: payload.body,\n          url: payload.url,\n          base_url: payload.base_url,\n          account_id: notification.account.id,\n          account_slug: notification.account.slug,\n          avatar_url: payload.avatar_url,\n          card_id: card&.id,\n          card_title: card&.title,\n          creator_id: notification.creator.id,\n          creator_name: notification.creator.name,\n          creator_familiar_name: notification.creator.familiar_name,\n          creator_initials: notification.creator.initials,\n          creator_avatar_color: notification.creator.avatar_background_color,\n          category: payload.category\n        )\n        .new(\n          title: payload.title,\n          body: payload.body,\n          badge: notification.user.notifications.unread.count,\n          sound: \"default\",\n          thread_id: card&.id,\n          high_priority: payload.high_priority?\n        )\n    end\n\n    def interruption_level\n      payload.high_priority? ? \"time-sensitive\" : \"active\"\n    end\nend\n"
  },
  {
    "path": "saas/app/models/saas_record.rb",
    "content": "class SaasRecord < ActiveRecord::Base\n  self.abstract_class = true\n\n  connects_to database: { writing: :saas, reading: :saas }\nend\n"
  },
  {
    "path": "saas/app/models/session/devices.rb",
    "content": "module Session::Devices\n  extend ActiveSupport::Concern\n\n  included do\n    has_many :devices, class_name: \"ApplicationPushDevice\", dependent: :destroy\n  end\nend\n"
  },
  {
    "path": "saas/app/models/subscription.rb",
    "content": "class Subscription < Queenbee::Subscription\n  SHORT_NAMES = %w[ FreeV1 ]\n\n  def self.short_name\n    name.demodulize\n  end\n\n  class FreeV1 < Subscription\n    property :proper_name,  \"Free Subscription\"\n    property :price,        0\n    property :frequency,    \"yearly\"\n  end\nend\n"
  },
  {
    "path": "saas/app/views/admin/stats/show.html.erb",
    "content": "<% @page_title = \"Account Statistics\" %>\n\n<% content_for :header do %>\n  <h1 class=\"header__title\"><%= @page_title %></h1>\n<% end %>\n\n<section class=\"settings\">\n  <div class=\"settings__panel panel shadow\">\n    <div class=\"flex flex-column gap margin-block-half\">\n      <div class=\"flex flex-column gap-half\">\n        <header>\n          <h2 class=\"divider txt-medium margin-block-start\">Accounts Created</h2>\n        </header>\n        <div class=\"flex gap-half\">\n          <div class=\"flex flex-column gap-quarter flex-item-grow\">\n            <div class=\"txt-x-small\">Total</div>\n            <div class=\"txt-large\">\n              <strong><%= @accounts_total %></strong>\n            </div>\n          </div>\n          <div class=\"flex flex-column gap-quarter flex-item-grow\">\n            <div class=\"txt-x-small\">7 days</div>\n            <div class=\"txt-large\">\n              <strong><%= @accounts_last_7_days %></strong>\n            </div>\n          </div>\n          <div class=\"flex flex-column gap-quarter flex-item-grow\">\n            <div class=\"txt-x-small\">24 hours</div>\n            <div class=\"txt-large\">\n              <strong><%= @accounts_last_24_hours %></strong>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <div class=\"flex flex-column gap-half\">\n        <header>\n          <h2 class=\"divider txt-medium margin-block-start\">Identities Created</h2>\n        </header>\n        <div class=\"flex gap-half\">\n          <div class=\"flex flex-column gap-quarter flex-item-grow\">\n            <div class=\"txt-x-small\">Total</div>\n            <div class=\"txt-large\">\n              <strong><%= @identities_total %></strong>\n            </div>\n          </div>\n          <div class=\"flex flex-column gap-quarter flex-item-grow\">\n            <div class=\"txt-x-small\">7 days</div>\n            <div class=\"txt-large\">\n              <strong><%= @identities_last_7_days %></strong>\n            </div>\n          </div>\n          <div class=\"flex flex-column gap-quarter flex-item-grow\">\n            <div class=\"txt-x-small\">24 hours</div>\n            <div class=\"txt-large\">\n              <strong><%= @identities_last_24_hours %></strong>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n\n  <div class=\"settings__panel panel shadow\">\n    <header>\n      <h2 class=\"divider txt-medium margin-block-start\">\n        10 Most Recent Signups\n      </h2>\n    </header>\n\n    <ul class=\"margin-block-half\">\n      <% @recent_accounts.each do |account| %>\n        <% admin_user = account.users.owner.first %>\n        <li class=\"flex align-start gap-half margin-block-start-half\">\n          <div\n            class=\"flex-item-grow min-width overflow-ellipsis\"\n            style=\"text-align: left\"\n          >\n            <strong><%= account.name %></strong>\n            <br>\n            <span class=\"txt-x-small txt-ink-medium\">\n              #<%= account.external_account_id %> •\n              <%= admin_user&.identity&.email_address || \"No admin\" %>\n            </span>\n          </div>\n\n          <div class=\"txt-medium txt-ink-medium\" style=\"text-align: right\">\n            <%= time_ago_in_words(account.created_at) %> ago\n          </div>\n        </li>\n      <% end %>\n    </ul>\n  </div>\n\n  <div class=\"settings__panel panel shadow\">\n    <header>\n      <h2 class=\"divider txt-medium margin-block-start\">\n        Top 20 Accounts by Card Count\n      </h2>\n    </header>\n\n    <ul class=\"margin-block-half\">\n      <% @top_accounts.each do |account| %>\n        <% admin_user = account.users.owner.first %>\n        <li class=\"flex align-start gap-half margin-block-start-half\">\n          <div\n            class=\"flex-item-grow min-width overflow-ellipsis\"\n            style=\"text-align: left\"\n          >\n            <strong><%= account.name %></strong>\n            <br>\n            <span class=\"txt-x-small txt-ink-medium\">\n              #<%= account.external_account_id %> •\n              <%= admin_user&.identity&.email_address || \"No admin\" %>\n            </span>\n          </div>\n\n          <div class=\"txt-medium\">\n            <strong><%= number_with_delimiter(account.cards_count) %></strong>\n            <span class=\"txt-x-small txt-ink-medium\">cards</span>\n          </div>\n        </li>\n      <% end %>\n    </ul>\n  </div>\n</section>\n"
  },
  {
    "path": "saas/app/views/cards/comments/saas/_new.html.erb",
    "content": "<% if Current.account.exceeding_storage_limit? && !Current.identity.staff? %>\n  <%= render \"cards/comments/saas/storage_limit_exceeded\" %>\n<% else %>\n  <%= render \"cards/comments/new\", card: card %>\n<% end %>\n"
  },
  {
    "path": "saas/app/views/cards/comments/saas/_storage_limit_exceeded.html.erb",
    "content": "<div class=\"comment-by-system comment-by-system--account-limit\">\n  <div class=\"comment align-start full-width\">\n    <div class=\"comment__content flex flex-column flex-item-grow full-width\">\n      <div class=\"comment__body lexxy-content\">\n        <div class=\"action-text-content\">\n          <strong>This account has used all the included free storage (<%= number_to_human_size(Current.account.storage_limit) %>).</strong>\n          <div><%= link_to \"Self-host Fizzy\", \"https://github.com/basecamp/fizzy\", target: \"_blank\" %> for unlimited storage.</div>\n        </div>\n      </div>\n    </div>\n  </div>\n</div>\n"
  },
  {
    "path": "saas/app/views/cards/container/footer/saas/_create.html.erb",
    "content": "<% if Current.account.exceeding_storage_limit? && !Current.identity.staff? %>\n  <%= render \"cards/container/footer/saas/storage_limit_exceeded\" %>\n<% else %>\n  <%= render \"cards/container/footer/create\", card: card %>\n<% end %>\n"
  },
  {
    "path": "saas/app/views/cards/container/footer/saas/_storage_limit_exceeded.html.erb",
    "content": "<div class=\"card-perma__notch card-perma__notch--bottom\">\n  <div class=\"card-perma__account-limit-message\">\n    <strong>This account has used all the included free storage (<%= number_to_human_size(Current.account.storage_limit) %>).</strong>\n    <div>\n      <%= link_to \"Self-host Fizzy\", \"https://github.com/basecamp/fizzy\", target: \"_blank\" %> for unlimited storage.\n    </div>\n  </div>\n</div>\n"
  },
  {
    "path": "saas/app/views/cards/container/footer/saas/_storage_limit_notice.html.erb",
    "content": "<% if Current.account.nearing_storage_limit? && !Current.identity.staff? %>\n  <div class=\"txt-small\">\n    This account has used <strong><%= number_to_human_size(Current.account.bytes_used) %></strong> of <strong><%= number_to_human_size(Current.account.storage_limit) %></strong> storage.\n    <div><%= link_to \"Self-host Fizzy\", \"https://github.com/basecamp/fizzy\", class: \"txt-current txt-underline\", target: \"_blank\" %> for unlimited storage.</div>\n  </div>\n<% end %>\n"
  },
  {
    "path": "saas/app/views/layouts/fizzy/saas/application.html.erb",
    "content": "<!DOCTYPE html>\n<html>\n<head>\n  <title>Fizzy saas</title>\n  <%= csrf_meta_tags %>\n  <%= csp_meta_tag %>\n\n  <%= yield :head %>\n\n  <%= stylesheet_link_tag    \"fizzy/saas/application\", media: \"all\" %>\n</head>\n<body>\n\n<%= yield %>\n\n</body>\n</html>\n"
  },
  {
    "path": "saas/app/views/my/devices/index.html.erb",
    "content": "<% @page_title = \"Registered devices\" %>\n\n<section class=\"panel panel--centered flex flex-column gap\" style=\"--panel-size: auto;\">\n  <% if @devices.any? %>\n    <h1 class=\"txt-x-large font-weight-black txt-tight-lines margin-none\">\n      Registered devices\n    </h1>\n    <ul class=\"flex flex-column gap list-style-none full-width pad border-radius border\" style=\"--inline-space: var(--block-space);\">\n      <% @devices.each do |device| %>\n        <li class=\"flex align-center gap txt-medium txt-align-start\">\n          <div class=\"flex-1 flex flex-column\">\n            <span>\n              <strong><%= device.name || \"Unnamed device\" %></strong>\n              (<%= device.platform == \"apple\" ? \"iOS\" : \"Android\" %>)\n            </span>\n            <span class=\"txt-subtle txt-x-small\">\n              Added <%= local_datetime_tag(device.created_at, style: :daysago) %>\n            </span>\n          </div>\n          <%= button_to saas.my_device_path(device), method: :delete, class: \"btn btn--circle-mobile txt-small\", data: { confirm: \"Remove this device?\" }, form: { class: \"flex-item-no-shrink\" } do %>\n            <%= icon_tag \"trash\", class: \"icon--mobile-only\" %>\n            <span class=\"overflow-ellipsis\">Remove</span>\n          <% end %>\n        </li>\n      <% end %>\n    </ul>\n  <% else %>\n    <h1 class=\"txt-x-large font-weight-black margin-none\">No devices registered</h1>\n    <p class=\"margin-none-block-start\">Mobile devices registered with the mobile app will receive push notifications and will be listed here.</p>\n  <% end %>\n\n  <%= link_to session_menu_path(script_name: nil), class: \"btn center txt-small margin-block-start hide-on-native\", data: { turbo_prefetch: false } do %>\n    <span>Your Fizzy accounts</span>\n  <% end %>\n</section>\n"
  },
  {
    "path": "saas/app/views/notifications/settings/_native_devices.html.erb",
    "content": "<div class=\"native-devices margin-block-start\">\n  <h3 class=\"txt-small txt-uppercase divider\">Mobile Devices</h3>\n\n  <% if Current.identity.devices.any? %>\n    <p class=\"txt-small\">\n      You have <%= pluralize(Current.identity.devices.count, \"mobile device\") %> registered for push notifications.\n    </p>\n    <%= link_to \"Manage devices\", saas.my_devices_path, class: \"btn txt-small\" %>\n  <% else %>\n    <p class=\"txt-small color-secondary\">\n      No mobile devices registered. Install the iOS or Android app to receive push notifications on your phone.\n    </p>\n  <% end %>\n</div>\n"
  },
  {
    "path": "saas/app/views/signup/completions/new.html.erb",
    "content": "<% @page_title = \"Complete your sign-up\" %>\n\n<div class=\"panel panel--centered flex flex-column gap-half <%= \"shake\" if flash[:alert] %>\">\n  <h1 class=\"txt-x-large font-weight-black margin-block-end\"><%= @page_title %></h1>\n\n  <%= form_with model: @signup, url: saas.signup_completion_path, scope: \"signup\", class: \"flex flex-column gap\", data: { controller: \"form\" } do |form| %>\n    <%= form.text_field :full_name, class: \"input txt-large\", autocomplete: \"name\", placeholder: \"Enter your full name…\", autofocus: true, required: true %>\n\n    <p>You’re one step away. Just enter your name to get your own Fizzy account.</p>\n\n    <% if @signup.errors.any? %>\n      <div class=\"margin-block-half\">\n        <ul class=\"margin-block-none txt-negative txt-small\">\n          <% @signup.errors.full_messages.each do |message| %>\n            <li><%= message %></li>\n          <% end %>\n        </ul>\n      </div>\n    <% end %>\n\n    <button type=\"submit\" class=\"btn btn--link center\" data-form-target=\"submit\">\n      <span>Continue</span>\n      <%= icon_tag \"arrow-right\" %>\n    </button>\n  <% end %>\n</div>\n\n<% content_for :footer do %>\n  <%= render \"sessions/footer\" %>\n<% end %>\n"
  },
  {
    "path": "saas/app/views/signup/new.html.erb",
    "content": "<% @page_title = \"Sign up for Fizzy\" %>\n\n<div class=\"panel panel--centered flex flex-column gap-half <%= \"shake\" if flash[:alert] %>\">\n  <h1 class=\"txt-xx-large margin-block-end-double\">Sign up</h1>\n\n  <%= form_with model: @signup, url: saas.signup_path, scope: \"signup\", class: \"flex flex-column gap\", data: { turbo: false, controller: \"form\" } do |form| %>\n    <%= form.email_field :email_address, class: \"input\", autocomplete: \"username\", placeholder: \"Email address\", required: true %>\n\n    <% if @signup.errors.any? %>\n      <div class=\"margin-block-half\">\n        <ul class=\"margin-block-none txt-negative txt-small\">\n          <% @signup.errors.full_messages.each do |message| %>\n            <li><%= message %></li>\n          <% end %>\n        </ul>\n      </div>\n    <% end %>\n\n    <button type=\"submit\" class=\"btn btn--link center\" data-form-target=\"submit\">\n      <span>Continue</span>\n      <%= icon_tag \"arrow-right\" %>\n    </button>\n  <% end %>\n</div>\n\n<% content_for :footer do %>\n  <%= render \"sessions/footer\" %>\n<% end %>\n"
  },
  {
    "path": "saas/bin/broadcast_to_bc",
    "content": "#!/usr/bin/env bash\n\nurl=$(op read \"op://Deploy/Deploy Chatbot/url\" --account 23QPQDKZC5BKBIIG7UGT5GR5RM)\ncurl -s -d content=\"[Fizzy] ${1}\" \"${url}\"\n"
  },
  {
    "path": "saas/bin/setup",
    "content": "#!/usr/bin/env bash\nset -eo pipefail\n\n# SaaS-specific setup tasks\n# This script is called from the main Fizzy bin/setup when SAAS is enabled\n\nif [ -e tmp/minio-dev.txt ]; then\n  step \"Starting Docker services\" bash -c \"[ -d ~/Work/basecamp/docker-dev ] && git -C ~/Work/basecamp/docker-dev pull || gh repo clone basecamp/docker-dev ~/Work/basecamp/docker-dev && ~/Work/basecamp/docker-dev/setup minio\"\n  step \"Configuring MinIO\" bin/minio-setup\nfi\n\nstep \"Starting mysql\" bash -c \"[ -d ~/Work/basecamp/docker-dev ] && git -C ~/Work/basecamp/docker-dev pull || gh repo clone basecamp/docker-dev ~/Work/basecamp/docker-dev && ~/Work/basecamp/docker-dev/setup mysql80\"\n"
  },
  {
    "path": "saas/config/database.yml",
    "content": "<%\n  if ENV[\"MIGRATE\"].present?\n    mysql_app_user_key = \"MYSQL_ALTER_USER\"\n    mysql_app_password_key = \"MYSQL_ALTER_PASSWORD\"\n    max_execution_time_ms = 0 # No limit\n  else\n    mysql_app_user_key = \"MYSQL_APP_USER\"\n    mysql_app_password_key = \"MYSQL_APP_PASSWORD\"\n    max_execution_time_ms = 5_000\n  end\n\n  mysql_app_user = ENV[mysql_app_user_key]\n  mysql_app_password = ENV[mysql_app_password_key]\n\n  gem_path = Gem::Specification.find_by_name(\"fizzy-saas\").gem_dir\n%>\n\ndefault: &default\n  adapter: trilogy\n  host: <%= ENV.fetch \"FIZZY_DB_HOST\", \"127.0.0.1\" %>\n  port: <%= ENV.fetch \"FIZZY_DB_PORT\", 3306 %>\n  pool: 50\n  timeout: 5000\n  variables:\n    transaction_isolation: READ-COMMITTED\n    max_execution_time: <%= max_execution_time_ms %>\n\ndevelopment:\n  primary:\n    <<: *default\n    database: fizzy_development\n    port: <%= ENV.fetch \"FIZZY_DB_PORT\", 33380 %>\n  replica:\n    <<: *default\n    database: fizzy_development\n    port: <%= ENV.fetch \"FIZZY_DB_PORT\", 33380 %>\n    replica: true\n  cable:\n    <<: *default\n    database: development_cable\n    port: <%= ENV.fetch \"FIZZY_DB_PORT\", 33380 %>\n    migrations_paths: db/cable_migrate\n  cache:\n    <<: *default\n    database: development_cache\n    port: <%= ENV.fetch \"FIZZY_DB_PORT\", 33380 %>\n    migrations_paths: db/cache_migrate\n  queue:\n    <<: *default\n    database: development_queue\n    port: <%= ENV.fetch \"FIZZY_DB_PORT\", 33380 %>\n    migrations_paths: db/queue_migrate\n  saas:\n    <<: *default\n    database: fizzy_saas_development\n    port: <%= ENV.fetch \"FIZZY_DB_PORT\", 33380 %>\n    migrations_paths: <%= File.join(gem_path, \"db\", \"migrate\") %>\n    schema_dump: <%= File.join(gem_path, \"db\", \"saas_schema.rb\") %>\n\n# Warning: The database defined as \"test\" will be erased and\n# re-generated from your development database when you run \"rake\".\n# Do not set this db to the same as development or production.\ntest:\n  primary:\n    <<: *default\n    database: fizzy_test\n    port: <%= ENV.fetch \"FIZZY_DB_PORT\", 33380 %>\n  replica:\n    <<: *default\n    database: fizzy_test\n    port: <%= ENV.fetch \"FIZZY_DB_PORT\", 33380 %>\n    replica: true\n  saas:\n    <<: *default\n    database: fizzy_saas_test\n    port: <%= ENV.fetch \"FIZZY_DB_PORT\", 33380 %>\n    migrations_paths: <%= File.join(gem_path, \"db\", \"migrate\") %>\n    schema_dump: <%= File.join(gem_path, \"db\", \"saas_schema.rb\") %>\n\nproduction: &production\n  primary:\n    <<: *default\n    database: fizzy_production\n    host: <%= ENV[\"MYSQL_DATABASE_HOST\"] %>\n    username: <%= mysql_app_user %>\n    password: <%= mysql_app_password %>\n  replica:\n    <<: *default\n    database: fizzy_production\n    host: <%= ENV[\"MYSQL_DATABASE_REPLICA_HOST\"] %>\n    username: <%= ENV[\"MYSQL_READONLY_USER\"] %>\n    password: <%= ENV[\"MYSQL_READONLY_PASSWORD\"] %>\n    replica: true\n  cable:\n    <<: *default\n    database: fizzy_solidcable_production\n    host: <%= ENV[\"MYSQL_SOLID_CABLE_HOST\"] %>\n    username: <%= mysql_app_user %>\n    password: <%= mysql_app_password %>\n    migrations_paths: db/cable_migrate\n  queue:\n    <<: *default\n    database: fizzy_solidqueue_production\n    host: <%= ENV[\"MYSQL_SOLID_QUEUE_HOST\"] %>\n    username: <%= mysql_app_user %>\n    password: <%= mysql_app_password %>\n    migrations_paths: db/queue_migrate\n  cache:\n    <<: *default\n    database: fizzy_solidcache_production\n    host: <%= ENV[\"MYSQL_SOLID_CACHE_HOST\"] %>\n    username: <%= mysql_app_user %>\n    password: <%= mysql_app_password %>\n    migrations_paths: db/cache_migrate\n  saas:\n    <<: *default\n    database: fizzy_saas_production\n    host: <%= ENV[\"MYSQL_DATABASE_HOST\"] %>\n    username: <%= mysql_app_user %>\n    password: <%= mysql_app_password %>\n    migrations_paths: <%= File.join(gem_path, \"db\", \"migrate\") %>\n    schema_dump: <%= File.join(gem_path, \"db\", \"saas_schema.rb\") %>\n\nbeta: *production\nstaging: *production\n"
  },
  {
    "path": "saas/config/deploy.beta.yml",
    "content": "<%\n  raise \"The BETA_NUMBER environment variable must be given\" unless ENV[\"BETA_NUMBER\"]\n  @data = {\n    \"1\" => {\n      \"hosts\" => {\n        \"web\" => [\"fizzy-beta-app-101.df-iad-int.37signals.com\"],\n        \"jobs\" => [\"fizzy-beta-jobs-101.df-iad-int.37signals.com\"],\n        \"lb\" => \"fizzy-beta-lb-101.df-iad-int.37signals.com\"\n      },\n      \"dbs\" => {\n        \"solidqueue\" => \"fizzy-beta-solidqueue-db-101.df-iad-int.37signals.com\"\n      }\n    },\n    \"2\" => {\n      \"hosts\" => {\n        \"web\" => [\"fizzy-beta-app-102.df-iad-int.37signals.com\"],\n        \"jobs\" => [\"fizzy-beta-jobs-102.df-iad-int.37signals.com\"],\n        \"lb\" => \"fizzy-beta-lb-102.df-iad-int.37signals.com\"\n      },\n      \"dbs\" => {\n        \"solidqueue\" => \"fizzy-beta-solidqueue-db-102.df-iad-int.37signals.com\"\n      }\n    },\n    \"3\" => {\n      \"hosts\" => {\n        \"web\" => [\"fizzy-beta-app-103.df-iad-int.37signals.com\"],\n        \"jobs\" => [\"fizzy-beta-jobs-103.df-iad-int.37signals.com\"],\n        \"lb\" => \"fizzy-beta-lb-103.df-iad-int.37signals.com\"\n      },\n      \"dbs\" => {\n        \"solidqueue\" => \"fizzy-beta-solidqueue-db-103.df-iad-int.37signals.com\"\n      }\n    },\n    \"4\" => {\n      \"hosts\" => {\n        \"web\" => [\"fizzy-beta-app-104.df-iad-int.37signals.com\"],\n        \"jobs\" => [\"fizzy-beta-jobs-104.df-iad-int.37signals.com\"],\n        \"lb\" => \"fizzy-beta-lb-104.df-iad-int.37signals.com\"\n      },\n      \"dbs\" => {\n        \"solidqueue\" => \"fizzy-beta-solidqueue-db-104.df-iad-int.37signals.com\"\n      }\n    }\n  }\n  @beta_number = ENV[\"BETA_NUMBER\"]\n  raise \"Beta #{@beta_number} doesn't appear to be defined\" unless @data[@beta_number]\n\n  @web_hosts = @data[@beta_number][\"hosts\"][\"web\"]\n  @job_hosts = @data[@beta_number][\"hosts\"][\"jobs\"]\n  @lb_host = @data[@beta_number][\"hosts\"][\"lb\"]\n  @solidqueue_db = @data[@beta_number][\"dbs\"][\"solidqueue\"]\n%>\n\nretain_containers: 1\n\nservers:\n  web:\n    hosts:\n      - <%= @web_hosts[0] %>: df_iad\n    labels:\n      otel_scrape_enabled: true\n\n  jobs:\n    hosts:\n      - <%= @job_hosts[0] %>: df_iad\n    labels:\n      otel_scrape_enabled: true\n\nproxy:\n  ssl: false\n\nssh:\n  user: app\n\nenv:\n  clear:\n    APP_FQDN: beta<%= @beta_number %>.fizzy-beta.com\n    CACHE_NAMESPACE: <%= @beta_number %>\n    RAILS_ENV: beta\n    RAILS_LOG_LEVEL: fatal # suppress unstructured log lines\n    MYSQL_DATABASE_HOST: fizzy-mysql-primary\n    MYSQL_DATABASE_REPLICA_HOST: fizzy-mysql-replica\n    MYSQL_SOLID_CABLE_HOST: fizzy-mysql-primary\n    MYSQL_SOLID_QUEUE_HOST: <%= @solidqueue_db %>\n    MYSQL_SOLID_CACHE_HOST: fizzy-beta-solidcache-db-101.df-iad-int.37signals.com\n  secret:\n    - RAILS_MASTER_KEY\n    - MYSQL_ALTER_PASSWORD\n    - MYSQL_ALTER_USER\n    - MYSQL_APP_PASSWORD\n    - MYSQL_APP_USER\n    - MYSQL_READONLY_PASSWORD\n    - MYSQL_READONLY_USER\n    - SECRET_KEY_BASE\n    - VAPID_PUBLIC_KEY\n    - VAPID_PRIVATE_KEY\n    - ACTIVE_STORAGE_ACCESS_KEY_ID\n    - ACTIVE_STORAGE_SECRET_ACCESS_KEY\n    - QUEENBEE_API_TOKEN\n    - SIGNAL_ID_SECRET\n    - SENTRY_DSN\n    - ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY\n    - ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY\n    - ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT\n    - STRIPE_MONTHLY_V1_PRICE_ID\n    - STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID\n    - STRIPE_SECRET_KEY\n    - STRIPE_WEBHOOK_SECRET\n    - APNS_KEY_ID\n    - APNS_ENCRYPTION_KEY_B64\n    - FCM_ENCRYPTION_KEY_B64\n  tags:\n    df_iad:\n      PRIMARY_DATACENTER: true\n\naccessories:\n  load-balancer:\n    image: basecamp/kamal-proxy:lb\n    host: <%= @lb_host %>\n    labels:\n      otel_role: load-balancer\n      otel_service: fizzy-load-balancer\n      otel_scrape_enabled: true\n    options:\n      publish:\n        - 80:80\n        - 443:443\n    volumes:\n      - load-balancer:/home/kamal-proxy/.config/kamal-proxy\n"
  },
  {
    "path": "saas/config/deploy.beta1.yml",
    "content": "<% ENV[\"BETA_NUMBER\"] = \"1\" %>\n<%= ERB.new(File.read(File.join(Gem::Specification.find_by_name(\"fizzy-saas\").gem_dir, \"config\", \"deploy.beta.yml\")), trim_mode: 2).result %>\n"
  },
  {
    "path": "saas/config/deploy.beta2.yml",
    "content": "<% ENV[\"BETA_NUMBER\"] = \"2\" %>\n<%= ERB.new(File.read(File.join(Gem::Specification.find_by_name(\"fizzy-saas\").gem_dir, \"config\", \"deploy.beta.yml\")), trim_mode: 2).result %>\n"
  },
  {
    "path": "saas/config/deploy.beta3.yml",
    "content": "<% ENV[\"BETA_NUMBER\"] = \"3\" %>\n<%= ERB.new(File.read(File.join(Gem::Specification.find_by_name(\"fizzy-saas\").gem_dir, \"config\", \"deploy.beta.yml\")), trim_mode: 2).result %>\n"
  },
  {
    "path": "saas/config/deploy.beta4.yml",
    "content": "<% ENV[\"BETA_NUMBER\"] = \"4\" %>\n<%= ERB.new(File.read(File.join(Gem::Specification.find_by_name(\"fizzy-saas\").gem_dir, \"config\", \"deploy.beta.yml\")), trim_mode: 2).result %>\n"
  },
  {
    "path": "saas/config/deploy.production.yml",
    "content": "retain_containers: 2\n\nservers:\n  web:\n    hosts:\n      - fizzy-app-01.sc-chi-int.37signals.com: sc_chi\n      - fizzy-app-02.sc-chi-int.37signals.com: sc_chi\n      - fizzy-app-101.df-iad-int.37signals.com: df_iad\n      - fizzy-app-102.df-iad-int.37signals.com: df_iad\n      - fizzy-app-401.df-ams-int.37signals.com: df_ams\n      - fizzy-app-402.df-ams-int.37signals.com: df_ams\n    labels:\n      otel_scrape_enabled: true\n  jobs:\n    hosts:\n      - fizzy-jobs-101.df-iad-int.37signals.com: df_iad\n      - fizzy-jobs-102.df-iad-int.37signals.com: df_iad\n    labels:\n      otel_scrape_enabled: true\n\nproxy:\n  ssl: false\n\nssh:\n  user: app\n\nenv:\n  clear:\n    RAILS_ENV: production\n    RAILS_LOG_LEVEL: fatal # suppress unstructured log lines\n    MYSQL_DATABASE_HOST: fizzy-mysql-primary\n    MYSQL_DATABASE_REPLICA_HOST: fizzy-mysql-replica\n    MYSQL_SOLID_CABLE_HOST: fizzy-mysql-primary\n    MYSQL_SOLID_QUEUE_HOST: fizzy-mysql-primary\n  secret:\n    - RAILS_MASTER_KEY\n    - MYSQL_ALTER_PASSWORD\n    - MYSQL_ALTER_USER\n    - MYSQL_APP_PASSWORD\n    - MYSQL_APP_USER\n    - MYSQL_READONLY_PASSWORD\n    - MYSQL_READONLY_USER\n    - SECRET_KEY_BASE\n    - VAPID_PUBLIC_KEY\n    - VAPID_PRIVATE_KEY\n    - ACTIVE_STORAGE_ACCESS_KEY_ID\n    - ACTIVE_STORAGE_SECRET_ACCESS_KEY\n    - QUEENBEE_API_TOKEN\n    - SIGNAL_ID_SECRET\n    - SENTRY_DSN\n    - ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY\n    - ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY\n    - ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT\n    - STRIPE_MONTHLY_V1_PRICE_ID\n    - STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID\n    - STRIPE_SECRET_KEY\n    - STRIPE_WEBHOOK_SECRET\n    - APNS_KEY_ID\n    - APNS_ENCRYPTION_KEY_B64\n    - FCM_ENCRYPTION_KEY_B64\n  tags:\n    sc_chi:\n      MYSQL_SOLID_CACHE_HOST: fizzy-solidcache-db-01.sc-chi-int.37signals.com\n    df_iad:\n      MYSQL_SOLID_CACHE_HOST: fizzy-solidcache-db-101.df-iad-int.37signals.com\n      PRIMARY_DATACENTER: true\n    df_ams:\n      MYSQL_SOLID_CACHE_HOST: fizzy-solidcache-db-401.df-ams-int.37signals.com\n\n\naccessories:\n  load-balancer:\n    image: basecamp/kamal-proxy:lb\n    hosts:\n      - fizzy-lb-101.df-iad-int.37signals.com\n      - fizzy-lb-102.df-iad-int.37signals.com\n      - fizzy-lb-01.sc-chi-int.37signals.com\n      - fizzy-lb-02.sc-chi-int.37signals.com\n      - fizzy-lb-401.df-ams-int.37signals.com\n      - fizzy-lb-402.df-ams-int.37signals.com\n    labels:\n      otel_role: load-balancer\n      otel_service: fizzy-load-balancer\n      otel_scrape_enabled: true\n    options:\n      publish:\n        - 80:80\n        - 443:443\n      # NFS mount for certificates\n      # See https://3.basecamp.com/2914079/buckets/37331921/todos/9180260061\n      mount: type=volume,src=certificates,dst=/certificates,volume-driver=local,volume-opt=type=nfs,volume-opt=device=:/fizzy-production-certificates,\"volume-opt=o=addr=purestorage.sc-chi-int.37signals.com,nfsvers=3,rw,noatime,nconnect=8,soft,timeo=30,retrans=2\"\n    volumes:\n      - load-balancer:/home/kamal-proxy/.config/kamal-proxy\n"
  },
  {
    "path": "saas/config/deploy.staging.yml",
    "content": "retain_containers: 1\n\nservers:\n  web:\n    hosts:\n      - fizzy-staging-app-101.df-iad-int.37signals.com: df_iad\n      - fizzy-staging-app-102.df-iad-int.37signals.com: df_iad\n      - fizzy-staging-app-01.sc-chi-int.37signals.com: sc_chi\n      - fizzy-staging-app-02.sc-chi-int.37signals.com: sc_chi\n      - fizzy-staging-app-401.df-ams-int.37signals.com: df_ams\n      - fizzy-staging-app-402.df-ams-int.37signals.com: df_ams\n    labels:\n      otel_scrape_enabled: true\n  jobs:\n    hosts:\n      - fizzy-staging-jobs-101.df-iad-int.37signals.com: df_iad\n      - fizzy-staging-jobs-102.df-iad-int.37signals.com: df_iad\n    labels:\n      otel_scrape_enabled: true\n\nproxy:\n  ssl: false\n\nssh:\n  user: app\n\nenv:\n  clear:\n    RAILS_ENV: staging\n    RAILS_LOG_LEVEL: fatal # suppress unstructured log lines\n    MYSQL_DATABASE_HOST: fizzy-staging-mysql-primary\n    MYSQL_DATABASE_REPLICA_HOST: fizzy-staging-mysql-replica\n    MYSQL_SOLID_CABLE_HOST: fizzy-staging-mysql-primary\n    MYSQL_SOLID_QUEUE_HOST: fizzy-staging-mysql-primary\n  secret:\n    - RAILS_MASTER_KEY\n    - MYSQL_ALTER_PASSWORD\n    - MYSQL_ALTER_USER\n    - MYSQL_APP_PASSWORD\n    - MYSQL_APP_USER\n    - MYSQL_READONLY_PASSWORD\n    - MYSQL_READONLY_USER\n    - SECRET_KEY_BASE\n    - VAPID_PUBLIC_KEY\n    - VAPID_PRIVATE_KEY\n    - ACTIVE_STORAGE_ACCESS_KEY_ID\n    - ACTIVE_STORAGE_SECRET_ACCESS_KEY\n    - QUEENBEE_API_TOKEN\n    - SIGNAL_ID_SECRET\n    - SENTRY_DSN\n    - ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY\n    - ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY\n    - ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT\n    - STRIPE_MONTHLY_V1_PRICE_ID\n    - STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID\n    - STRIPE_SECRET_KEY\n    - STRIPE_WEBHOOK_SECRET\n  tags:\n    sc_chi:\n      MYSQL_SOLID_CACHE_HOST: fizzy-staging-solidcache-db-01.sc-chi-int.37signals.com\n    df_iad:\n      MYSQL_SOLID_CACHE_HOST: fizzy-staging-solidcache-db-101.df-iad-int.37signals.com\n      PRIMARY_DATACENTER: true\n    df_ams:\n      MYSQL_SOLID_CACHE_HOST: fizzy-staging-solidcache-db-401.df-ams-int.37signals.com\n\n\naccessories:\n  load-balancer:\n    image: basecamp/kamal-proxy:lb\n    hosts:\n      - fizzy-staging-lb-01.sc-chi-int.37signals.com\n      - fizzy-staging-lb-101.df-iad-int.37signals.com\n      - fizzy-staging-lb-401.df-ams-int.37signals.com\n    labels:\n      otel_role: load-balancer\n      otel_service: fizzy-load-balancer\n      otel_scrape_enabled: true\n    options:\n      publish:\n        - 80:80\n        - 443:443\n      # NFS mount for certificates\n      # See https://3.basecamp.com/2914079/buckets/37331921/todos/9180260061\n      mount: type=volume,src=certificates,dst=/certificates,volume-driver=local,volume-opt=type=nfs,volume-opt=device=:/fizzy-staging-certificates,\"volume-opt=o=addr=purestorage.sc-chi-int.37signals.com,nfsvers=3,rw,noatime,nconnect=8,soft,timeo=30,retrans=2\"\n    volumes:\n      - load-balancer:/home/kamal-proxy/.config/kamal-proxy\n"
  },
  {
    "path": "saas/config/deploy.yml",
    "content": "service: fizzy\nimage: basecamp/fizzy\nasset_path: /rails/public/assets\nhooks_path: <%= File.join(Gem::Specification.find_by_name(\"fizzy-saas\").gem_dir, \".kamal\", \"hooks\") %>\nsecrets_path: <%= File.join(Gem::Specification.find_by_name(\"fizzy-saas\").gem_dir, \".kamal/secrets\") %>\n\nservers:\n  jobs:\n    cmd: bin/jobs\n\nvolumes:\n  - fizzy:/rails/storage\n\nproxy:\n  ssl: true\n\nregistry:\n  server: registry.37signals.com\n  username: robot$harbor-bot\n  password:\n    - BASECAMP_REGISTRY_PASSWORD\n\nbuilder:\n  arch: amd64\n  dockerfile: <%= File.join(Gem::Specification.find_by_name(\"fizzy-saas\").gem_dir, \"Dockerfile\") %>\n  secrets:\n    - GITHUB_TOKEN\n  remote: ssh://app@docker-builder-102\n  local: <%= ENV.fetch(\"KAMAL_BUILDER_LOCAL\", \"true\") %>\n\naliases:\n  console: app exec -i --reuse -e CONSOLE_USER:<%= ENV[\"USER\"] %> \"bin/rails console\"\n  ssh: app exec -i --reuse -e CONSOLE_USER:<%= ENV[\"USER\"] %> /bin/bash\n"
  },
  {
    "path": "saas/config/environments/beta.rb",
    "content": "require_relative \"production\"\n\nRails.application.configure do\n  config.action_mailer.smtp_settings[:domain] = ENV.fetch(\"APP_FQDN\", \"fizzy-beta.com\")\n  config.action_mailer.smtp_settings[:address] = \"smtp-outbound-staging\"\n  config.action_mailer.default_url_options     = { host: ENV.fetch(\"APP_FQDN\", \"fizzy-beta.com\"), protocol: \"https\" }\n  config.action_controller.default_url_options = { host: ENV.fetch(\"APP_FQDN\", \"fizzy-beta.com\"), protocol: \"https\" }\nend\n"
  },
  {
    "path": "saas/config/environments/development.rb",
    "content": "Rails.application.configure do\n  # SaaS version of Fizzy is multi-tenanted\n  config.x.multi_tenant.enabled = true\n\n  if Rails.root.join(\"tmp/structured-logging.txt\").exist?\n    config.structured_logging.logger = ActiveSupport::Logger.new(\"log/structured-development.log\")\n  end\n\n  if Rails.root.join(\"tmp/solid-queue.txt\").exist?\n    config.active_job.queue_adapter = :solid_queue\n    config.solid_queue.connects_to = { database: { writing: :queue, reading: :queue } }\n  end\nend\n"
  },
  {
    "path": "saas/config/environments/production.rb",
    "content": "Rails.application.configure do\n  config.active_storage.service = :purestorage\n\n  # Enable structured logging\n  config.structured_logging.logger = ActiveSupport::Logger.new(STDOUT)\n\n  config.action_controller.default_url_options = { host: \"app.fizzy.do\", protocol: \"https\" }\n  config.action_mailer.default_url_options     = { host: \"app.fizzy.do\", protocol: \"https\" }\n  config.action_mailer.smtp_settings = { domain: \"app.fizzy.do\", address: \"smtp-outbound\", port: 25, enable_starttls_auto: false }\n\n  # SaaS version of Fizzy is multi-tenanted\n  config.x.multi_tenant.enabled = true\n\n  # Content Security Policy\n  config.x.content_security_policy.report_only = false\n  config.x.content_security_policy.report_uri = \"https://o33603.ingest.us.sentry.io/api/4510481339187200/security/?sentry_key=9f126ba30d5f703451a13a2929bb5a10\" # gitleaks:allow (public DSN for CSP reports)\n  config.x.content_security_policy.script_src = \"https://challenges.cloudflare.com\"\n  config.x.content_security_policy.frame_src = \"https://challenges.cloudflare.com\"\n  config.x.content_security_policy.connect_src = \"https://storage.basecamp.com\"\nend\n"
  },
  {
    "path": "saas/config/environments/staging.rb",
    "content": "require_relative \"production\"\n\nRails.application.configure do\n  config.action_mailer.smtp_settings[:domain] = \"app.fizzy-staging.com\"\n  config.action_mailer.smtp_settings[:address] = \"smtp-outbound-staging\"\n  config.action_mailer.default_url_options     = { host: \"app.fizzy-staging.com\", protocol: \"https\" }\n  config.action_controller.default_url_options = { host: \"app.fizzy-staging.com\", protocol: \"https\" }\nend\n"
  },
  {
    "path": "saas/config/push.yml",
    "content": "shared:\n  apple:\n    key_id: <%= ENV[\"APNS_KEY_ID\"] %>\n    encryption_key: <%= Base64.decode64(ENV[\"APNS_ENCRYPTION_KEY_B64\"] || \"\").dump %>\n    team_id: <%= ENV[\"APNS_TEAM_ID\"] || \"2WNYUYRS7G\" %>\n    topic: <%= ENV[\"APNS_TOPIC\"] || \"do.fizzy.app.ios\" %>\n    connect_to_development_server: <%= Rails.env.local? %>\n  google:\n    encryption_key: <%= Base64.decode64(ENV[\"FCM_ENCRYPTION_KEY_B64\"] || \"\").dump %>\n    project_id: fizzy-a148c\n"
  },
  {
    "path": "saas/config/routes.rb",
    "content": "Fizzy::Saas::Engine.routes.draw do\n  Queenbee.routes(self)\n\n  namespace :my do\n    resources :devices, only: [ :index, :create, :destroy ]\n  end\n\n  namespace :admin do\n    mount Audits1984::Engine, at: \"/console\"\n    get \"stats\", to: \"stats#show\"\n  end\nend\n"
  },
  {
    "path": "saas/config/storage.yml",
    "content": "test:\n  service: Disk\n  root: <%= Rails.root.join(\"tmp/storage/files\") %>\n\nlocal:\n  service: Disk\n  root: <%= Rails.root.join(\"storage\", Rails.env, \"files\") %>\n\ndevminio:\n  service: S3\n  bucket: fizzy-dev-activestorage\n  endpoint: \"http://minio.localhost:39000\"\n  force_path_style: true\n  request_checksum_calculation: when_required # default is when_supported with CRC64NVME checksum which FlashBlade doesn't support\n  response_checksum_validation: when_required # default is when_supported with CRC64NVME checksum which FlashBlade doesn't support\n  region: us-east-1 # default region required for signer\n  access_key_id: minioadmin\n  secret_access_key: minioadmin\n\n# We have \"development\", \"staging\", and \"production\" buckets configured. Note that we don't have a\n# \"beta\" bucket. (As of 2025-06-01.)\n<% pure_env = Rails.env.beta? ? \"production\" : Rails.env %>\npurestorage:\n  service: S3\n  bucket: fizzy-<%= pure_env %>-activestorage\n  endpoint: \"https://storage.basecamp.com\"\n  ssl_verify_peer: false # FIXME: using self-signed cert internally\n  force_path_style: true\n  request_checksum_calculation: when_required # default is when_supported with CRC64NVME checksum which FlashBlade doesn't support\n  response_checksum_validation: when_required # default is when_supported with CRC64NVME checksum which FlashBlade doesn't support\n  region: us-east-1 # default region required for signer\n  access_key_id: <%= ENV[\"ACTIVE_STORAGE_ACCESS_KEY_ID\"] %>\n  secret_access_key: <%= ENV[\"ACTIVE_STORAGE_SECRET_ACCESS_KEY\"] %>\n"
  },
  {
    "path": "saas/db/migrate/20251202200249_create_console1984_tables.console1984.rb",
    "content": "# This migration comes from console1984 (originally 20210517203931)\nclass CreateConsole1984Tables < ActiveRecord::Migration[7.0]\n  def change\n    create_table :console1984_sessions do |t|\n      t.text :reason\n      t.references :user, null: false, index: false\n      t.timestamps\n\n      t.index :created_at\n      t.index [ :user_id, :created_at ]\n    end\n\n    create_table :console1984_users do |t|\n      t.string :username, null: false\n      t.timestamps\n\n      t.index [:username]\n    end\n\n    create_table :console1984_commands do |t|\n      t.text :statements\n      t.references :sensitive_access\n      t.references :session, null: false, index: false\n      t.timestamps\n\n      t.index [ :session_id, :created_at, :sensitive_access_id ], name: \"on_session_and_sensitive_chronologically\"\n    end\n\n    create_table :console1984_sensitive_accesses do |t|\n      t.text :justification\n      t.references :session, null: false\n\n      t.timestamps\n    end\n  end\nend\n"
  },
  {
    "path": "saas/db/migrate/20251202205753_create_auditing_tables.audits1984.rb",
    "content": "# This migration comes from audits1984 (originally 20210810092639)\nclass CreateAuditingTables < ActiveRecord::Migration[7.0]\n  def change\n    create_table :audits1984_audits do |t|\n      t.integer :status, default: 0, null: false\n      t.text :notes\n      t.references :session, null: false\n      t.uuid :auditor_id, null: false\n\n      t.timestamps\n    end\n  end\nend\n"
  },
  {
    "path": "saas/db/migrate/20251203144630_create_account_subscriptions.rb",
    "content": "class CreateAccountSubscriptions < ActiveRecord::Migration[8.2]\n  def change\n    create_table :account_subscriptions, id: :uuid do |t|\n      t.references :account, null: false, type: :uuid, index: true\n      t.string :plan_key\n      t.string :stripe_customer_id, null: false, index: { unique: true }\n      t.string :stripe_subscription_id, index: { unique: true }\n      t.string :status\n      t.datetime :current_period_end\n      t.datetime :cancel_at\n\n      t.timestamps\n    end\n  end\nend\n"
  },
  {
    "path": "saas/db/migrate/20251215140000_create_account_overridden_limits.rb",
    "content": "class CreateAccountOverriddenLimits < ActiveRecord::Migration[8.2]\n  def change\n    create_table :account_overridden_limits, id: :uuid do |t|\n      t.references :account, null: false, type: :uuid, index: { unique: true }\n      t.integer :card_count\n\n      t.timestamps\n    end\n  end\nend\n"
  },
  {
    "path": "saas/db/migrate/20251215160000_create_account_billing_waivers.rb",
    "content": "class CreateAccountBillingWaivers < ActiveRecord::Migration[8.2]\n  def change\n    create_table :account_billing_waivers, id: :uuid do |t|\n      t.references :account, null: false, type: :uuid, index: { unique: true }\n\n      t.timestamps\n    end\n  end\nend\n"
  },
  {
    "path": "saas/db/migrate/20251215170000_add_next_amount_due_in_cents_to_account_subscriptions.rb",
    "content": "class AddNextAmountDueInCentsToAccountSubscriptions < ActiveRecord::Migration[8.2]\n  def change\n    add_column :account_subscriptions, :next_amount_due_in_cents, :integer\n  end\nend\n"
  },
  {
    "path": "saas/db/migrate/20251216000000_add_bytes_used_to_account_overridden_limits.rb",
    "content": "class AddBytesUsedToAccountOverriddenLimits < ActiveRecord::Migration[8.2]\n  def change\n    add_column :account_overridden_limits, :bytes_used, :bigint\n  end\nend\n"
  },
  {
    "path": "saas/db/migrate/20260114203313_create_action_push_native_devices.rb",
    "content": "class CreateActionPushNativeDevices < ActiveRecord::Migration[8.0]\n  def change\n    create_table :action_push_native_devices do |t|\n      t.string :name\n      t.string :platform, null: false\n      t.string :token, null: false\n      t.belongs_to :owner, polymorphic: true, type: :uuid, index: false\n      t.belongs_to :session, type: :uuid\n\n      t.timestamps\n    end\n\n    add_index :action_push_native_devices, [ :owner_type, :owner_id, :token ], unique: true\n  end\nend\n"
  },
  {
    "path": "saas/db/migrate/20260126230838_create_auditor_tokens.audits1984.rb",
    "content": "# This migration comes from audits1984 (originally 20260126000000)\nclass CreateAuditorTokens < ActiveRecord::Migration[7.0]\n  def change\n    create_table :audits1984_auditor_tokens do |t|\n      t.uuid :auditor_id, null: false, index: { unique: true }\n      t.string :token_digest, null: false\n      t.datetime :expires_at, null: false\n\n      t.timestamps\n\n      t.index :token_digest, unique: true\n    end\n  end\nend\n"
  },
  {
    "path": "saas/db/migrate/20260317000000_drop_billing_tables.rb",
    "content": "class DropBillingTables < ActiveRecord::Migration[8.2]\n  def change\n    drop_table :account_subscriptions\n    drop_table :account_overridden_limits\n    drop_table :account_billing_waivers\n  end\nend\n"
  },
  {
    "path": "saas/db/migrate/20260319142914_create_account_storage_exceptions.rb",
    "content": "class CreateAccountStorageExceptions < ActiveRecord::Migration[8.2]\n  def change\n    create_table :account_storage_exceptions, id: :uuid do |t|\n      t.references :account, null: false, type: :uuid, index: { unique: true }\n      t.bigint :bytes_allowed, null: false\n\n      t.timestamps\n    end\n  end\nend\n"
  },
  {
    "path": "saas/db/saas_schema.rb",
    "content": "# This file is auto-generated from the current state of the database. Instead\n# of editing this file, please use the migrations feature of Active Record to\n# incrementally modify your database, and then regenerate this schema definition.\n#\n# This file is the source Rails uses to define your schema when running `bin/rails\n# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to\n# be faster and is potentially less error prone than running all of your\n# migrations from scratch. Old migrations may fail to apply correctly if those\n# migrations use external dependencies or application code.\n#\n# It's strongly recommended that you check this file into your version control system.\n\nActiveRecord::Schema[8.2].define(version: 2026_03_19_142914) do\n  create_table \"account_storage_exceptions\", id: :uuid, charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.bigint \"bytes_allowed\", null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"account_id\"], name: \"index_account_storage_exceptions_on_account_id\", unique: true\n  end\n\n  create_table \"action_push_native_devices\", charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.datetime \"created_at\", null: false\n    t.string \"name\"\n    t.uuid \"owner_id\"\n    t.string \"owner_type\"\n    t.string \"platform\", null: false\n    t.uuid \"session_id\"\n    t.string \"token\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"owner_type\", \"owner_id\", \"token\"], name: \"idx_on_owner_type_owner_id_token_95a4008c64\", unique: true\n    t.index [\"session_id\"], name: \"index_action_push_native_devices_on_session_id\"\n  end\n\n  create_table \"audits1984_auditor_tokens\", charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"auditor_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"expires_at\", null: false\n    t.string \"token_digest\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"auditor_id\"], name: \"index_audits1984_auditor_tokens_on_auditor_id\", unique: true\n    t.index [\"token_digest\"], name: \"index_audits1984_auditor_tokens_on_token_digest\", unique: true\n  end\n\n  create_table \"audits1984_audits\", charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.uuid \"auditor_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.text \"notes\"\n    t.bigint \"session_id\", null: false\n    t.integer \"status\", default: 0, null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"session_id\"], name: \"index_audits1984_audits_on_session_id\"\n  end\n\n  create_table \"console1984_commands\", charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.datetime \"created_at\", null: false\n    t.bigint \"sensitive_access_id\"\n    t.bigint \"session_id\", null: false\n    t.text \"statements\"\n    t.datetime \"updated_at\", null: false\n    t.index [\"sensitive_access_id\"], name: \"index_console1984_commands_on_sensitive_access_id\"\n    t.index [\"session_id\", \"created_at\", \"sensitive_access_id\"], name: \"on_session_and_sensitive_chronologically\"\n  end\n\n  create_table \"console1984_sensitive_accesses\", charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.datetime \"created_at\", null: false\n    t.text \"justification\"\n    t.bigint \"session_id\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"session_id\"], name: \"index_console1984_sensitive_accesses_on_session_id\"\n  end\n\n  create_table \"console1984_sessions\", charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.datetime \"created_at\", null: false\n    t.text \"reason\"\n    t.datetime \"updated_at\", null: false\n    t.bigint \"user_id\", null: false\n    t.index [\"created_at\"], name: \"index_console1984_sessions_on_created_at\"\n    t.index [\"user_id\", \"created_at\"], name: \"index_console1984_sessions_on_user_id_and_created_at\"\n  end\n\n  create_table \"console1984_users\", charset: \"utf8mb4\", collation: \"utf8mb4_0900_ai_ci\", force: :cascade do |t|\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.string \"username\", null: false\n    t.index [\"username\"], name: \"index_console1984_users_on_username\"\n  end\nend\n"
  },
  {
    "path": "saas/exe/push-dev",
    "content": "#!/usr/bin/env ruby\n#\n# Fetches push notification credentials from 1Password for development.\n# Uses the same 1Password items as production (Deploy/Fizzy Production).\n#\n# Usage: eval \"$(bundle exec push-dev)\"\n\nOP_ACCOUNT = \"23QPQDKZC5BKBIIG7UGT5GR5RM\"\nOP_VAULT = \"Deploy\"\nOP_ITEM = \"Fizzy\"\n\ndef op_read(field)\n  `op read \"op://#{OP_VAULT}/#{OP_ITEM}/Production/#{field}\" --account #{OP_ACCOUNT} 2>/dev/null`.strip\nend\n\napns_key_id = op_read(\"APNS_KEY_ID\")\napns_encryption_key_b64 = op_read(\"APNS_ENCRYPTION_KEY_B64\")\nfcm_encryption_key_b64 = op_read(\"FCM_ENCRYPTION_KEY_B64\")\n\nif apns_key_id.empty? || apns_encryption_key_b64.empty?\n  warn \"Error: Could not fetch APNs credentials from 1Password\"\n  warn \"Make sure you're signed in: op signin --account #{OP_ACCOUNT}\"\n  exit 1\nend\n\nif fcm_encryption_key_b64.empty?\n  warn \"Warning: Could not fetch FCM credentials from 1Password\"\n  warn \"Android push notifications will not work\"\nend\n\nputs %Q(export APNS_KEY_ID=\"#{apns_key_id}\")\nputs %Q(export APNS_TEAM_ID=\"#{apns_team_id}\")\nputs %Q(export APNS_ENCRYPTION_KEY_B64=\"#{apns_encryption_key_b64}\")\nputs %Q(export FCM_ENCRYPTION_KEY_B64=\"#{fcm_encryption_key_b64}\")\nputs %Q(export ENABLE_NATIVE_PUSH=\"true\")\n\nwarn \"\"\nwarn \"Push notification credentials loaded for development\"\nwarn \"  APNs Key ID: #{apns_key_id}\"\nwarn \"  APNs: #{apns_encryption_key_b64.empty? ? \"not configured\" : \"configured\"}\"\nwarn \"  FCM: #{fcm_encryption_key_b64.empty? ? \"not configured\" : \"configured\"}\"\nwarn \"  Native push: enabled\"\n"
  },
  {
    "path": "saas/fizzy-saas.gemspec",
    "content": "require_relative \"lib/fizzy/saas/version\"\n\nGem::Specification.new do |spec|\n  spec.name        = \"fizzy-saas\"\n  spec.version     = Fizzy::Saas::VERSION\n  spec.authors     = [ \"Mike Dalessio\" ]\n  spec.email       = [ \"mike@37signals.com\" ]\n  spec.homepage    = \"https://github.com/basecamp/fizzy-saas\"\n  spec.summary     = \"37signals SaaS companion for Fizzy\"\n  spec.description = \"Rails engine that bundles with Fizzy to offer the hosted version at https://app.fizzy.do\"\n  spec.license     = \"O'Saasy\"\n\n  # Prevent pushing this gem to RubyGems.org. To allow pushes either set the \"allowed_push_host\"\n  # to allow pushing to a single host or delete this section to allow pushing to any host.\n  spec.metadata[\"allowed_push_host\"] = \"https://rubygems.org\"\n\n  spec.metadata[\"homepage_uri\"] = spec.homepage\n  spec.metadata[\"source_code_uri\"] = \"https://github.com/basecamp/fizzy-saas\"\n\n  spec.files = Dir.chdir(File.expand_path(__dir__)) do\n    Dir[\"{app,config,db,lib,exe}/**/*\", \"test/fixtures/**/*\", \"LICENSE.md\", \"Rakefile\", \"README.md\"]\n  end\n\n  spec.bindir = \"exe\"\n  spec.executables = [ \"push-dev\", \"stripe-dev\" ]\n\n  spec.add_dependency \"rails\", \">= 8.1.0.beta1\"\n  spec.add_dependency \"queenbee\"\n  spec.add_dependency \"rails_structured_logging\"\n  spec.add_dependency \"sentry-ruby\"\n  spec.add_dependency \"sentry-rails\"\n  spec.add_dependency \"yabeda\"\n  spec.add_dependency \"yabeda-actioncable\"\n  spec.add_dependency \"yabeda-activejob\"\n  spec.add_dependency \"yabeda-gc\"\n  spec.add_dependency \"yabeda-http_requests\"\n  spec.add_dependency \"yabeda-prometheus-mmap\"\n  spec.add_dependency \"yabeda-puma-plugin\"\n  spec.add_dependency \"yabeda-rails\", \">= 0.10\"\n  spec.add_dependency \"prometheus-client-mmap\", \"~> 1.4.0\"\n  spec.add_dependency \"console1984\"\n  spec.add_dependency \"audits1984\"\nend\n"
  },
  {
    "path": "saas/lib/fizzy/saas/authorization.rb",
    "content": "module Fizzy\n  module Saas\n    module Authorization\n      module Controller\n        extend ActiveSupport::Concern\n\n        included do\n          before_action :ensure_only_employees_can_access_non_production_remote_environments, if: :authenticated?\n        end\n\n        private\n          def ensure_only_employees_can_access_non_production_remote_environments\n            head :forbidden if Rails.env.staging? && !Current.identity.employee?\n          end\n      end\n\n      module Identity\n        extend ActiveSupport::Concern\n\n        EMPLOYEE_DOMAINS = [ \"@37signals.com\", \"@basecamp.com\" ].freeze\n\n        def employee?\n          email_address.end_with?(*EMPLOYEE_DOMAINS)\n        end\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "saas/lib/fizzy/saas/engine.rb",
    "content": "require_relative \"transaction_pinning\"\nrequire_relative \"true_client_ip\"\nrequire_relative \"signup\"\nrequire_relative \"authorization\"\nrequire_relative \"gvl_instrumentation\"\nrequire_relative \"../../rails_ext/active_record_tasks_database_tasks.rb\"\n\nmodule Fizzy\n  module Saas\n    class Engine < ::Rails::Engine\n      # moved from config/initializers/queenbee.rb\n      Queenbee.host_app = Fizzy\n\n      # Configure ActionPushNative to use the saas database\n      ActiveSupport.on_load(:action_push_native_record) do\n        connects_to database: { writing: :saas, reading: :saas }\n      end\n\n      initializer \"fizzy_saas.assets\" do |app|\n        app.config.assets.paths << root.join(\"app/assets/stylesheets\")\n      end\n\n      initializer \"fizzy_saas.public_files\" do |app|\n        app.middleware.insert_after ActionDispatch::Static, ActionDispatch::Static, root.join(\"public\").to_s,\n          headers: app.config.public_file_server.headers\n      end\n\n      initializer \"fizzy_saas.push_config\", after: \"action_push_native.config\" do |app|\n        app.paths[\"config/push\"].unshift(root.join(\"config/push.yml\").to_s)\n      end\n\n      initializer \"fizzy.saas.mount\" do |app|\n        app.routes.append do\n          mount Fizzy::Saas::Engine => \"/\", as: \"saas\"\n        end\n      end\n\n      initializer \"fizzy_saas.transaction_pinning\" do |app|\n        app.config.middleware.insert_after(ActiveRecord::Middleware::DatabaseSelector, TransactionPinning::Middleware)\n      end\n\n      initializer \"fizzy_saas.true_client_ip\" do |app|\n        app.config.middleware.insert_before ActionDispatch::RemoteIp, TrackTrueClientIp\n      end\n\n      initializer \"fizzy_saas.gvl_instrumentation\" do |app|\n        app.config.middleware.insert_before(Rack::Runtime, GvlInstrumentation)\n      end\n\n      initializer \"fizzy_saas.solid_queue\" do\n        SolidQueue.on_start do\n          Process.warmup\n          Yabeda::Prometheus::Exporter.start_metrics_server!\n        end\n      end\n\n      initializer \"fizzy_saas.logging.session\" do |app|\n        ActiveSupport.on_load(:action_controller_base) do\n          before_action do\n            if Current.identity.present?\n              logger.struct(authentication: { identity: { id: Current.identity.id } })\n            end\n\n            if Current.account.present?\n              logger.struct(account: { queenbee_id: Current.account.external_account_id })\n            end\n          end\n        end\n      end\n\n      # Load test mocks automatically in test environment\n      initializer \"fizzy_saas.test_mocks\", after: :load_config_initializers do\n        if Rails.env.test?\n          require_relative \"testing\"\n        end\n      end\n\n      initializer \"fizzy_saas.sentry\" do\n        if !Rails.env.local? && ENV[\"SKIP_TELEMETRY\"].blank?\n          Sentry.init do |config|\n            config.dsn = ENV[\"SENTRY_DSN\"]\n            config.breadcrumbs_logger = %i[ active_support_logger http_logger ]\n            config.send_default_pii = false\n            config.release = ENV[\"KAMAL_VERSION\"]\n            config.excluded_exceptions += [ \"ActiveRecord::ConcurrentMigrationError\" ]\n\n            # Receive Rails.error.report and retry_on/discard_on report: true\n            config.rails.register_error_subscriber = true\n          end\n        end\n      end\n\n      initializer \"fizzy_saas.yabeda\" do\n        require \"prometheus/client/support/puma\"\n\n        Prometheus::Client.configuration.logger = Rails.logger\n        Prometheus::Client.configuration.pid_provider = Prometheus::Client::Support::Puma.method(:worker_pid_provider)\n        Yabeda::Rails.config.controller_name_case = :camel\n        Yabeda::Rails.config.ignore_actions = %w[\n          Rails::HealthController#show\n        ]\n\n        Yabeda::ActiveJob.install!\n\n        require \"yabeda/solid_queue\"\n        Yabeda::SolidQueue.install!\n\n        Yabeda::ActionCable.configure do |config|\n          config.channel_class_name = \"ActionCable::Channel::Base\"\n        end\n\n        require \"yabeda/gvl\"\n        Yabeda::GVL.install!\n\n        require_relative \"metrics\"\n      end\n\n      config.before_initialize do\n        config.console1984.protected_environments = %i[ production beta staging ]\n        config.console1984.ask_for_username_if_empty = true\n        config.console1984.base_record_class = \"::SaasRecord\"\n        config.console1984.incinerate_after = 60.days\n\n        config.audits1984.base_controller_class = \"::Admin::AuditsController\"\n        config.audits1984.auditor_class = \"::Identity\"\n        config.audits1984.auditor_name_attribute = :email_address\n\n        if config.console1984.protected_environments.include?(Rails.env.to_sym)\n          config.active_record.encryption.primary_key = ENV.fetch(\"ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY\")\n          config.active_record.encryption.deterministic_key = ENV.fetch(\"ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY\")\n          config.active_record.encryption.key_derivation_salt = ENV.fetch(\"ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT\")\n        end\n      end\n\n      config.to_prepare do\n        ::Account.include Account::StorageLimited\n        ::Identity.include Authorization::Identity, Identity::Devices\n        ::Session.include Session::Devices\n        ::Signup.prepend Signup\n        ApplicationController.include Authorization::Controller\n        CardsController.include(Card::StorageLimited::Creation)\n        Cards::CommentsController.include(Card::StorageLimited::Commenting)\n        Cards::PublishesController.include(Card::StorageLimited::Publishing)\n\n        Notification.register_push_target(:native)\n\n        Queenbee::Subscription.short_names = Subscription::SHORT_NAMES\n\n        # Default to local dev QB token if not set\n        Queenbee::ApiToken.token = ENV.fetch(\"QUEENBEE_API_TOKEN\") { \"69a4cfb8705913e6323f7b4c0c0cff9bd8df37da532f4375b85e9655b8100bb023591b48d308205092aa0a04dd28cb6c62d6798364a6f44cc1e675814eb148a1\" } # gitleaks:allow development-only token\n\n        Subscription::SHORT_NAMES.each do |short_name|\n          const_name = \"#{short_name}Subscription\"\n          ::Object.send(:remove_const, const_name) if ::Object.const_defined?(const_name)\n          ::Object.const_set const_name, Subscription.const_get(short_name, false)\n        end\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "saas/lib/fizzy/saas/gvl_instrumentation.rb",
    "content": "module Fizzy\n  module Saas\n    class GvlInstrumentation\n      def initialize(app)\n        @app = app\n      end\n\n      def call(env)\n        GVLTools::LocalTimer.enable\n        before = GVLTools::LocalTimer.monotonic_time\n        result = @app.call(env)\n        gvl_wait_ns = GVLTools::LocalTimer.monotonic_time - before\n        Yabeda.gvl.request_wait.measure({}, gvl_wait_ns / 1_000_000_000.0)\n        result\n      ensure\n        GVLTools::LocalTimer.disable\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "saas/lib/fizzy/saas/metrics.rb",
    "content": "Yabeda.configure do\n  SHORT_HISTOGRAM_BUCKETS = [ 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5 ]\n\n  group :fizzy do\n    counter :replica_stale,\n      comment: \"Number of requests served from a stale replica\"\n\n    histogram :replica_wait,\n      unit: :seconds,\n      comment: \"Time spent waiting for replica to catch up with transaction\",\n      buckets: SHORT_HISTOGRAM_BUCKETS\n  end\nend\n"
  },
  {
    "path": "saas/lib/fizzy/saas/signup.rb",
    "content": "module Fizzy\n  module Saas\n    module Signup\n      extend ActiveSupport::Concern\n\n      included do\n        attr_reader :queenbee_account\n      end\n\n      private\n        def create_tenant\n          @queenbee_account = Queenbee::Remote::Account.create!(queenbee_account_attributes)\n          @queenbee_account.id.to_s\n        end\n\n        def handle_account_creation_error(error)\n          @queenbee_account&.cancel\n        end\n\n        def queenbee_account_attributes\n          {}.tap do |attributes|\n            attributes[:product_name]   = \"fizzy\"\n            attributes[:name]           = generate_account_name\n            attributes[:owner_name]     = full_name\n            attributes[:owner_email]    = email_address\n\n            attributes[:trial]          = true\n            attributes[:subscription]   = subscription_attributes\n            attributes[:remote_request] = request_attributes\n\n            # # TODO: Terms of Service\n            # attributes[:terms_of_service] = true\n\n            # We've confirmed the email\n            attributes[:auto_allow]     = true\n\n            # Tell Queenbee to skip the request to create a local account. We've created it ourselves.\n            attributes[:skip_remote]    = true\n          end\n        end\n    end\n  end\nend\n"
  },
  {
    "path": "saas/lib/fizzy/saas/testing.rb",
    "content": "require \"queenbee/testing/mocks\"\n\nQueenbee::Remote::Account.class_eval do\n  # because we use the account ID as the tenant name, we need it to be unique in each test to avoid\n  # parallelized tests clobbering each other.\n  def next_id\n    super + Random.rand(1000000)\n  end\nend\n\n# Add engine fixtures to the test fixture paths\nmodule Fizzy::Saas::EngineFixtures\n  def included(base)\n    super\n    engine_fixtures = Fizzy::Saas::Engine.root.join(\"test\", \"fixtures\").to_s\n    base.fixture_paths << engine_fixtures unless base.fixture_paths.include?(engine_fixtures)\n  end\nend\n\nActiveRecord::TestFixtures.singleton_class.prepend(Fizzy::Saas::EngineFixtures)\n"
  },
  {
    "path": "saas/lib/fizzy/saas/transaction_pinning.rb",
    "content": "module TransactionPinning\n  class Middleware\n    SESSION_KEY = :last_txn\n    DEFAULT_MAX_WAIT = 0.25\n\n    def initialize(app)\n      @app = app\n      @timeout = Rails.application.config.x.transaction_pinning&.timeout&.to_f || DEFAULT_MAX_WAIT\n    end\n\n    def call(env)\n      request = ActionDispatch::Request.new(env)\n      replica_metrics = {}\n\n      if ApplicationRecord.current_role == :reading\n        wait_for_replica_catchup(request, replica_metrics)\n      end\n\n      status, headers, body = @app.call(env)\n      headers.merge!(replica_metrics.transform_values(&:to_s))\n\n      if ApplicationRecord.current_role == :writing\n        capture_transaction_id(request)\n      end\n\n      [ status, headers, body ]\n    end\n\n    private\n      def wait_for_replica_catchup(request, replica_metrics)\n        if last_txn = request.session[SESSION_KEY].presence\n          has_transaction = tracking_replica_wait_time(replica_metrics) do\n            replica_has_transaction(last_txn)\n          end\n\n          unless has_transaction\n            Yabeda.fizzy.replica_stale.increment\n            replica_metrics[\"X-Replica-Stale\"] = true\n          end\n        end\n      end\n\n      def capture_transaction_id(request)\n        request.session[SESSION_KEY] = ApplicationRecord.connection.show_variable(\"global.gtid_executed\")\n      end\n\n      def replica_has_transaction(txn)\n        sql = ApplicationRecord.sanitize_sql_array([ \"SELECT WAIT_FOR_EXECUTED_GTID_SET(?, ?)\", txn, @timeout ])\n        ApplicationRecord.connection.select_value(sql) == 0\n      rescue => e\n        Sentry.capture_exception(e, extra: { gtid: txn })\n        true # Treat as if we're up to date, since we don't know\n      end\n\n      def tracking_replica_wait_time(replica_metrics)\n        started_at = Time.current\n\n        Yabeda.fizzy.replica_wait.measure do\n          yield\n        end.tap do\n          replica_metrics[\"X-Replica-Wait\"] = Time.current - started_at\n        end\n      end\n  end\nend\n"
  },
  {
    "path": "saas/lib/fizzy/saas/true_client_ip.rb",
    "content": "#\n#  Cloudflare sets a True-Client-IP header, which for most 37signals apps gets copied to\n#  X-Forwarded-For by an iRule on the F5 load balancers:\n#\n#  https://github.com/basecamp/f5-tf/blob/1543f7bfa3961a79e397f80cf041d75567f1b2f8/ams-base/iRules/manage_x_forwarded.tcl\n#\n#  However, for Fizzy the F5s are configured to do passthrough, so the header value isn't being\n#  copied for us. Let's do that bit of work here, before Rails' RemoteIp middleware.\n#\nclass TrackTrueClientIp\n  def initialize(app)\n    @app = app\n  end\n\n  def call(env)\n    if env[\"HTTP_TRUE_CLIENT_IP\"].present?\n      env[\"HTTP_X_FORWARDED_FOR\"] = env[\"HTTP_TRUE_CLIENT_IP\"]\n    end\n\n    @app.call(env)\n  end\nend\n"
  },
  {
    "path": "saas/lib/fizzy/saas/version.rb",
    "content": "module Fizzy\n  module Saas\n    VERSION = \"0.1.0\"\n  end\nend\n"
  },
  {
    "path": "saas/lib/fizzy/saas.rb",
    "content": "require \"fizzy/saas/version\"\nrequire \"fizzy/saas/engine\"\n\nmodule Fizzy\n  module Saas\n    def self.append_test_paths\n      engine_test_path = Engine.root.join(\"test\")\n      ENV[\"DEFAULT_TEST\"] = \"{#{engine_test_path},test}/**/*_test.rb\"\n      ENV[\"DEFAULT_TEST_EXCLUDE\"] = \"{#{engine_test_path},test}/{system,dummy,fixtures}/**/*_test.rb\"\n    end\n  end\nend\n"
  },
  {
    "path": "saas/lib/rails_ext/active_record_tasks_database_tasks.rb",
    "content": "module ActiveRecordTasksDatabaseTasksExtension\n  extend ActiveSupport::Concern\n\n  class_methods do\n    # proposed upstream in https://github.com/rails/rails/pull/56290\n    def schema_dump_path(db_config, format = db_config.schema_format)\n      return ENV[\"SCHEMA\"] if ENV[\"SCHEMA\"]\n\n      filename = db_config.schema_dump(format)\n      return unless filename\n\n      if Pathname.new(filename).absolute?\n        filename\n      else\n        super\n      end\n    end\n  end\nend\n\nActiveSupport.on_load(:active_record) do\n  ActiveRecord::Tasks::DatabaseTasks.include(ActiveRecordTasksDatabaseTasksExtension)\nend\n"
  },
  {
    "path": "saas/lib/tasks/fizzy/saas_tasks.rake",
    "content": "require \"rake/testtask\"\n\nnamespace :test do\n  desc \"Run tests for fizzy-saas gem\"\n  Rake::TestTask.new(saas: :environment) do |t|\n    t.libs << \"test\"\n    t.test_files = FileList[Fizzy::Saas::Engine.root.join(\"test/**/*_test.rb\")]\n    t.warning = false\n  end\nend\n"
  },
  {
    "path": "saas/lib/yabeda/gvl.rb",
    "content": "module Yabeda\n  module GVL\n    WAIT_HISTOGRAM_BUCKETS = [ 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10 ]\n\n    def self.install!\n      GVLTools::GlobalTimer.enable\n      GVLTools::WaitingThreads.enable\n\n      Yabeda.configure do\n        group :gvl do\n          gauge :waiting_threads,\n            comment: \"Number of threads currently waiting to acquire the GVL\"\n\n          gauge :global_timer_total_seconds,\n            comment: \"Total time all threads spent waiting on the GVL (seconds)\"\n\n          histogram :request_wait,\n            unit: :seconds,\n            comment: \"GVL wait time experienced during a single request (seconds)\",\n            buckets: WAIT_HISTOGRAM_BUCKETS\n        end\n\n        collect do\n          gvl.waiting_threads.set({}, GVLTools::WaitingThreads.count)\n          gvl.global_timer_total_seconds.set({}, GVLTools::GlobalTimer.monotonic_time / 1_000_000_000.0)\n        end\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "saas/lib/yabeda/solid_queue.rb",
    "content": "module Yabeda\n  module SolidQueue\n    def self.install!\n      Yabeda.configure do\n        group :solid_queue\n\n        gauge :jobs_failed_count, comment: \"Number of failed jobs\"\n        gauge :jobs_unreleased_count, comment: \"Number of claimed jobs that don't belong to any process\"\n        gauge :jobs_scheduled_and_delayed_count, comment: \"Number of scheduled jobs that have over 5 minutes delay\"\n        gauge :recurring_tasks_count, comment: \"Number of recurring jobs scheduled\"\n        gauge :recurring_tasks_delayed_count, comment: \"Number of recurring jobs that haven't been enqueued within their schedule\"\n\n        collect do\n          if ::SolidQueue.supervisor?\n            solid_queue.jobs_failed_count.set({}, ::SolidQueue::FailedExecution.count)\n            solid_queue.jobs_unreleased_count.set({}, ::SolidQueue::ClaimedExecution.where(process: nil).count)\n            solid_queue.jobs_scheduled_and_delayed_count.set({}, ::SolidQueue::ScheduledExecution.where(scheduled_at: ..5.minutes.ago).count)\n            solid_queue.recurring_tasks_count.set({}, ::SolidQueue::RecurringTask.count)\n            solid_queue.recurring_tasks_delayed_count.set({}, ::SolidQueue::RecurringTask.count do |task|\n              task.last_enqueued_time.present? && (task.previous_time - task.last_enqueued_time) > 5.minutes\n            end)\n          end\n        end\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "saas/public/.well-known/apple-app-site-association",
    "content": "{\n  \"applinks\": {\n    \"details\": [\n      {\n        \"appIDs\": [\"2WNYUYRS7G.do.fizzy.app.ios\"],\n        \"components\": [\n          {\n            \"/\": \"/*\",\n            \"comment\": \"Matches all paths.\"\n          }\n        ]\n      }\n    ]\n  }\n}"
  },
  {
    "path": "saas/public/.well-known/assetlinks.json",
    "content": "[\n  {\n    \"relation\": [\"delegate_permission/common.handle_all_urls\"],\n    \"target\": {\n      \"namespace\": \"android_app\",\n      \"package_name\": \"do.fizzy.app\",\n      \"sha256_cert_fingerprints\": [\n        \"3B:17:E4:FF:F0:2E:E0:CE:D7:94:F9:9E:71:3C:A8:14:7C:FA:B7:F2:99:35:98:03:E9:E0:EB:B3:6E:12:E2:0F\",\n        \"3B:4C:33:46:FD:F4:AD:4D:FE:9E:49:1D:B1:2F:EA:B1:04:33:02:97:BB:09:39:20:D4:18:69:2D:D2:9E:B5:5C\"\n      ]\n    }\n  }\n]\n"
  },
  {
    "path": "saas/script/configure-lb-beta.sh",
    "content": "#!/usr/bin/env bash\n\nset -e\n\n# Beta 1: fizzy-beta-lb-101 -> fizzy-beta-app-101\nssh app@fizzy-beta-lb-101.df-iad-int.37signals.com \\\n  docker exec fizzy-load-balancer \\\n    kamal-proxy deploy fizzy \\\n      --force \\\n      --tls \\\n      --host=beta1.fizzy-beta.com \\\n      --target=fizzy-beta-app-101.df-iad-int.37signals.com\n\n# Beta 2: fizzy-beta-lb-102 -> fizzy-beta-app-102\nssh app@fizzy-beta-lb-102.df-iad-int.37signals.com \\\n  docker exec fizzy-load-balancer \\\n    kamal-proxy deploy fizzy \\\n      --force \\\n      --tls \\\n      --host=beta2.fizzy-beta.com \\\n      --target=fizzy-beta-app-102.df-iad-int.37signals.com\n\n# Beta 3: fizzy-beta-lb-103 -> fizzy-beta-app-103\nssh app@fizzy-beta-lb-103.df-iad-int.37signals.com \\\n  docker exec fizzy-load-balancer \\\n    kamal-proxy deploy fizzy \\\n      --force \\\n      --tls \\\n      --host=beta3.fizzy-beta.com \\\n      --target=fizzy-beta-app-103.df-iad-int.37signals.com\n\n# Beta 4: fizzy-beta-lb-104 -> fizzy-beta-app-104\nssh app@fizzy-beta-lb-104.df-iad-int.37signals.com \\\n  docker exec fizzy-load-balancer \\\n    kamal-proxy deploy fizzy \\\n      --force \\\n      --tls \\\n      --host=beta4.fizzy-beta.com \\\n      --target=fizzy-beta-app-104.df-iad-int.37signals.com\n"
  },
  {
    "path": "saas/script/configure-lb-production.sh",
    "content": "#!/usr/bin/env bash\n\nset -e\n\n# fizzy-lb-101.df-iad-int.37signals.com\n#\nssh app@fizzy-lb-101.df-iad-int.37signals.com \\\n  docker exec fizzy-load-balancer \\\n    kamal-proxy deploy fizzy \\\n      --force \\\n      --tls \\\n      --host=app.fizzy.do \\\n      --writer-affinity-timeout=0 \\\n      --tls-acme-cache-path=/certificates \\\n      --target=fizzy-app-101.df-iad-int.37signals.com \\\n      --target=fizzy-app-102.df-iad-int.37signals.com\n\n\n# fizzy-lb-102.df-iad-int.37signals.com\n#\nssh app@fizzy-lb-102.df-iad-int.37signals.com \\\n  docker exec fizzy-load-balancer \\\n    kamal-proxy deploy fizzy \\\n      --force \\\n      --tls \\\n      --host=app.fizzy.do \\\n      --writer-affinity-timeout=0 \\\n      --tls-acme-cache-path=/certificates \\\n      --target=fizzy-app-101.df-iad-int.37signals.com \\\n      --target=fizzy-app-102.df-iad-int.37signals.com\n\n\n# fizzy-lb-01.sc-chi-int.37signals.com\n#\nssh app@fizzy-lb-01.sc-chi-int.37signals.com \\\n  docker exec fizzy-load-balancer \\\n    kamal-proxy deploy fizzy \\\n      --force \\\n      --tls \\\n      --host=app.fizzy.do \\\n      --writer-affinity-timeout=0 \\\n      --tls-acme-cache-path=/certificates \\\n      --target=fizzy-app-101.df-iad-int.37signals.com \\\n      --target=fizzy-app-102.df-iad-int.37signals.com \\\n      --read-target=fizzy-app-01.sc-chi-int.37signals.com \\\n      --read-target=fizzy-app-02.sc-chi-int.37signals.com\n\n\n# fizzy-lb-02.sc-chi-int.37signals.com\n#\nssh app@fizzy-lb-02.sc-chi-int.37signals.com \\\n  docker exec fizzy-load-balancer \\\n    kamal-proxy deploy fizzy \\\n      --force \\\n      --tls \\\n      --host=app.fizzy.do \\\n      --writer-affinity-timeout=0 \\\n      --tls-acme-cache-path=/certificates \\\n      --target=fizzy-app-101.df-iad-int.37signals.com \\\n      --target=fizzy-app-102.df-iad-int.37signals.com \\\n      --read-target=fizzy-app-01.sc-chi-int.37signals.com \\\n      --read-target=fizzy-app-02.sc-chi-int.37signals.com\n\n\n# fizzy-lb-401.df-ams-int.37signals.com\n#\nssh app@fizzy-lb-401.df-ams-int.37signals.com \\\n  docker exec fizzy-load-balancer \\\n    kamal-proxy deploy fizzy \\\n      --force \\\n      --tls \\\n      --host=app.fizzy.do \\\n      --writer-affinity-timeout=0 \\\n      --tls-acme-cache-path=/certificates \\\n      --target=fizzy-app-101.df-iad-int.37signals.com \\\n      --target=fizzy-app-102.df-iad-int.37signals.com \\\n      --read-target=fizzy-app-401.df-ams-int.37signals.com \\\n      --read-target=fizzy-app-402.df-ams-int.37signals.com\n\n\n# fizzy-lb-402.df-ams-int.37signals.com\n#\nssh app@fizzy-lb-402.df-ams-int.37signals.com \\\n  docker exec fizzy-load-balancer \\\n    kamal-proxy deploy fizzy \\\n      --force \\\n      --tls \\\n      --host=app.fizzy.do \\\n      --writer-affinity-timeout=0 \\\n      --tls-acme-cache-path=/certificates \\\n      --target=fizzy-app-101.df-iad-int.37signals.com \\\n      --target=fizzy-app-102.df-iad-int.37signals.com \\\n      --read-target=fizzy-app-401.df-ams-int.37signals.com \\\n      --read-target=fizzy-app-402.df-ams-int.37signals.com\n"
  },
  {
    "path": "saas/script/configure-lb-staging.sh",
    "content": "#!/usr/bin/env bash\n\nset -e\n\n# fizzy-staging-lb-01.sc-chi-int.37signals.com\n#\nssh app@fizzy-staging-lb-01.sc-chi-int.37signals.com \\\n  docker exec fizzy-load-balancer \\\n    kamal-proxy deploy fizzy \\\n      --force \\\n      --tls \\\n      --host=app.fizzy-staging.com \\\n      --writer-affinity-timeout=0 \\\n      --tls-acme-cache-path=/certificates \\\n      --target=fizzy-staging-app-101.df-iad-int.37signals.com \\\n      --target=fizzy-staging-app-102.df-iad-int.37signals.com \\\n      --read-target=fizzy-staging-app-01.sc-chi-int.37signals.com \\\n      --read-target=fizzy-staging-app-02.sc-chi-int.37signals.com\n\n# fizzy-staging-lb-101.df-iad-int.37signals.com\n#\nssh app@fizzy-staging-lb-101.df-iad-int.37signals.com \\\n  docker exec fizzy-load-balancer \\\n    kamal-proxy deploy fizzy \\\n      --force \\\n      --tls \\\n      --host=app.fizzy-staging.com \\\n      --writer-affinity-timeout=0 \\\n      --tls-acme-cache-path=/certificates \\\n      --target=fizzy-staging-app-101.df-iad-int.37signals.com \\\n      --target=fizzy-staging-app-102.df-iad-int.37signals.com\n\n# fizzy-staging-lb-401.df-ams-int.37signals.com\n#\nssh app@fizzy-staging-lb-401.df-ams-int.37signals.com \\\n  docker exec fizzy-load-balancer \\\n    kamal-proxy deploy fizzy \\\n      --force \\\n      --tls \\\n      --host=app.fizzy-staging.com \\\n      --writer-affinity-timeout=0 \\\n      --tls-acme-cache-path=/certificates \\\n      --target=fizzy-staging-app-101.df-iad-int.37signals.com \\\n      --target=fizzy-staging-app-102.df-iad-int.37signals.com \\\n      --read-target=fizzy-staging-app-401.df-ams-int.37signals.com \\\n      --read-target=fizzy-staging-app-402.df-ams-int.37signals.com \n\n"
  },
  {
    "path": "saas/test/controllers/.keep",
    "content": ""
  },
  {
    "path": "saas/test/controllers/admin/audits_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Admin::AuditsControllerTest < ActionDispatch::IntegrationTest\n  # Test authentication via the Audits1984::SessionsController#index endpoint,\n  # which inherits from Admin::AuditsController through Audits1984::ApplicationController.\n\n  test \"unauthenticated access is forbidden\" do\n    untenanted do\n      get saas.admin_audits1984_path\n      assert_redirected_to new_session_path\n    end\n  end\n\n  test \"logged-in non-staff access is forbidden\" do\n    sign_in_as :jz\n\n    untenanted do\n      get saas.admin_audits1984_path\n    end\n\n    assert_response :forbidden\n  end\n\n  test \"logged-in staff access is allowed\" do\n    sign_in_as :david\n\n    untenanted do\n      get saas.admin_audits1984_path\n    end\n\n    assert_response :success\n  end\n\n  test \"invalid bearer token is forbidden\" do\n    untenanted do\n      get saas.admin_audits1984_path, headers: { \"Authorization\" => \"Bearer invalid_token\" }\n    end\n\n    assert_response :unauthorized\n  end\n\n  test \"valid bearer token is allowed\" do\n    token = Audits1984::AuditorToken.generate_for(identities(:david))\n\n    untenanted do\n      get saas.admin_audits1984_path, headers: { \"Authorization\" => \"Bearer #{token}\" }\n    end\n\n    assert_response :success\n  end\n\n  test \"expired bearer token is forbidden\" do\n    token = Audits1984::AuditorToken.generate_for(identities(:david))\n    Audits1984::AuditorToken.update_all(expires_at: 1.day.ago)\n\n    untenanted do\n      get saas.admin_audits1984_path, headers: { \"Authorization\" => \"Bearer #{token}\" }\n    end\n\n    assert_response :unauthorized\n  end\n\n  test \"bearer token for non-staff user is forbidden\" do\n    # Even with a valid token, non-staff users should be denied access.\n    # This handles the case where a user's staff privileges are revoked\n    # after a token was issued.\n    token = Audits1984::AuditorToken.generate_for(identities(:jz))\n\n    untenanted do\n      get saas.admin_audits1984_path, headers: { \"Authorization\" => \"Bearer #{token}\" }\n    end\n\n    assert_response :forbidden\n  end\nend\n"
  },
  {
    "path": "saas/test/controllers/admin/stats_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Admin::StatsControllerTest < ActionDispatch::IntegrationTest\n  test \"staff can access stats\" do\n    sign_in_as :david\n\n    untenanted do\n      get saas.admin_stats_path\n    end\n\n    assert_response :success\n  end\n\n  test \"non-staff cannot access stats\" do\n    sign_in_as :jz\n\n    untenanted do\n      get saas.admin_stats_path\n    end\n\n    assert_response :forbidden\n  end\nend\n"
  },
  {
    "path": "saas/test/controllers/card/storage_limited/commenting_test.rb",
    "content": "require \"test_helper\"\n\nclass Card::StorageLimited::CommentingTest < ActionDispatch::IntegrationTest\n  test \"cannot create comments when storage limit exceeded\" do\n    sign_in_as :david\n\n    Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1)\n    Identity.any_instance.stubs(:staff?).returns(false)\n\n    assert_no_difference -> { Comment.count } do\n      post card_comments_path(cards(:logo), script_name: accounts(:\"37s\").slug),\n        params: { comment: { body: \"Blocked comment\" } },\n        as: :turbo_stream\n    end\n\n    assert_response :forbidden\n  end\n\n  test \"can create comments when under storage limit\" do\n    sign_in_as :david\n\n    assert_difference -> { Comment.count } do\n      post card_comments_path(cards(:logo), script_name: accounts(:\"37s\").slug),\n        params: { comment: { body: \"Allowed comment\" } },\n        as: :turbo_stream\n    end\n\n    assert_response :success\n  end\n\n  test \"staff can create comments even when storage limit exceeded\" do\n    sign_in_as :david\n\n    Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1)\n\n    assert_difference -> { Comment.count } do\n      post card_comments_path(cards(:logo), script_name: accounts(:\"37s\").slug),\n        params: { comment: { body: \"Staff comment\" } },\n        as: :turbo_stream\n    end\n\n    assert_response :success\n  end\nend\n"
  },
  {
    "path": "saas/test/controllers/card/storage_limited/creation_test.rb",
    "content": "require \"test_helper\"\n\nclass Card::StorageLimited::CreationTest < ActionDispatch::IntegrationTest\n  test \"cannot create cards via JSON when storage limit exceeded\" do\n    sign_in_as :mike\n\n    Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1)\n\n    assert_no_difference -> { Card.count } do\n      post board_cards_path(boards(:miltons_wish_list), script_name: accounts(:initech).slug),\n        params: { card: { title: \"Blocked card\" } },\n        as: :json\n    end\n\n    assert_response :forbidden\n  end\n\n  test \"can create cards via HTML when storage limit exceeded since they become drafts\" do\n    sign_in_as :mike\n\n    Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1)\n    accounts(:initech).update_column(:cards_count, 100)\n    boards(:miltons_wish_list).cards.drafted.where(creator: users(:mike)).destroy_all\n\n    assert_difference -> { Card.count } do\n      post board_cards_path(boards(:miltons_wish_list), script_name: accounts(:initech).slug)\n    end\n\n    assert_response :redirect\n    assert Card.last.drafted?\n  end\n\n  test \"can create cards via JSON when under storage limit\" do\n    sign_in_as :mike\n\n    Account.any_instance.stubs(:bytes_used).returns(500.megabytes)\n    accounts(:initech).update_column(:cards_count, 100)\n\n    assert_difference -> { Card.count } do\n      post board_cards_path(boards(:miltons_wish_list), script_name: accounts(:initech).slug),\n        params: { card: { title: \"Allowed card\" } },\n        as: :json\n    end\n\n    assert_response :created\n  end\n\n  test \"staff can create cards via JSON even when storage limit exceeded\" do\n    sign_in_as :mike\n\n    Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1)\n    Identity.any_instance.stubs(:staff?).returns(true)\n    accounts(:initech).update_column(:cards_count, 100)\n\n    assert_difference -> { Card.count } do\n      post board_cards_path(boards(:miltons_wish_list), script_name: accounts(:initech).slug),\n        params: { card: { title: \"Staff card\" } },\n        as: :json\n    end\n\n    assert_response :created\n  end\nend\n"
  },
  {
    "path": "saas/test/controllers/card/storage_limited/publishing_test.rb",
    "content": "require \"test_helper\"\n\nclass Card::StorageLimited::PublishingTest < ActionDispatch::IntegrationTest\n  test \"cannot publish cards when storage limit exceeded\" do\n    sign_in_as :mike\n\n    Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1)\n\n    post card_publish_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug)\n\n    assert_response :forbidden\n    assert cards(:unfinished_thoughts).reload.drafted?\n  end\n\n  test \"can publish cards when under storage limit\" do\n    sign_in_as :mike\n\n    post card_publish_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug)\n\n    assert_response :redirect\n    assert cards(:unfinished_thoughts).reload.published?\n  end\n\n  test \"staff can publish cards even when storage limit exceeded\" do\n    sign_in_as :mike\n\n    Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1)\n    Identity.any_instance.stubs(:staff?).returns(true)\n\n    post card_publish_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug)\n\n    assert_response :redirect\n    assert cards(:unfinished_thoughts).reload.published?\n  end\nend\n"
  },
  {
    "path": "saas/test/controllers/card/storage_limited_test.rb",
    "content": "require \"test_helper\"\n\nclass Card::StorageLimitedTest < ActionDispatch::IntegrationTest\n  test \"draft card shows storage limit notice instead of create buttons when limit exceeded\" do\n    sign_in_as :mike\n\n    Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1)\n\n    get card_draft_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug)\n\n    assert_response :success\n    assert_select \".card-perma__notch\" do\n      assert_select \"strong\", text: /used all/\n      assert_select \"a[href='https://github.com/basecamp/fizzy']\", text: \"Self-host Fizzy\"\n    end\n    assert_select \".card-perma__notch-new-card-buttons\", count: 0\n  end\n\n  test \"draft card shows create buttons when under storage limit\" do\n    sign_in_as :mike\n\n    get card_draft_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug)\n\n    assert_response :success\n    assert_select \".card-perma__notch-new-card-buttons\"\n  end\n\n  test \"staff sees create buttons even when storage limit exceeded\" do\n    sign_in_as :mike\n\n    Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1)\n    Identity.any_instance.stubs(:staff?).returns(true)\n\n    get card_draft_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug)\n\n    assert_response :success\n    assert_select \".card-perma__notch-new-card-buttons\"\n  end\nend\n"
  },
  {
    "path": "saas/test/controllers/comment/storage_limited_test.rb",
    "content": "require \"test_helper\"\n\nclass Comment::StorageLimitedTest < ActionDispatch::IntegrationTest\n  test \"published card shows storage limit notice instead of comment form when limit exceeded\" do\n    sign_in_as :david\n\n    Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1)\n    Identity.any_instance.stubs(:staff?).returns(false)\n\n    get card_path(cards(:logo), script_name: accounts(:\"37s\").slug)\n\n    assert_response :success\n    assert_select \"strong\", text: /used all/\n    assert_select \"a[href='https://github.com/basecamp/fizzy']\", text: \"Self-host Fizzy\"\n    assert_select \"##{dom_id(cards(:logo), :new_comment)}\", count: 0\n  end\n\n  test \"published card shows comment form when under storage limit\" do\n    sign_in_as :david\n\n    get card_path(cards(:logo), script_name: accounts(:\"37s\").slug)\n\n    assert_response :success\n    assert_select \"##{dom_id(cards(:logo), :new_comment)}\"\n  end\n\n  test \"staff sees comment form even when storage limit exceeded\" do\n    sign_in_as :david\n\n    Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1)\n\n    get card_path(cards(:logo), script_name: accounts(:\"37s\").slug)\n\n    assert_response :success\n    assert_select \"##{dom_id(cards(:logo), :new_comment)}\"\n  end\nend\n"
  },
  {
    "path": "saas/test/controllers/my/devices_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass My::DevicesControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    @identity = identities(:david)\n    sign_in_as :david\n  end\n\n  test \"index shows identity's devices\" do\n    @identity.devices.create!(token: \"test_token_123\", platform: \"apple\", name: \"iPhone 15 Pro\")\n\n    untenanted { get saas.my_devices_path }\n\n    assert_response :success\n    assert_select \"strong\", \"iPhone 15 Pro\"\n    assert_select \"li\", /iOS/\n  end\n\n  test \"index shows empty state when no devices\" do\n    @identity.devices.delete_all\n\n    untenanted { get saas.my_devices_path }\n\n    assert_response :success\n    assert_select \"h1\", /No devices registered/\n  end\n\n  test \"show notification settings with registered devices\" do\n    @identity.devices.create!(token: \"test_token\", platform: \"apple\", name: \"iPhone 15 Pro\")\n\n    get notifications_settings_path\n\n    assert_response :success\n  end\n\n  test \"index requires authentication\" do\n    sign_out\n\n    untenanted { get saas.my_devices_path }\n\n    assert_response :redirect\n  end\n\n  test \"creates a new device via api\" do\n    token = SecureRandom.hex(32)\n\n    assert_difference -> { ApplicationPushDevice.count }, 1 do\n      untenanted do\n        post saas.my_devices_path, params: {\n          token: token,\n          platform: \"apple\",\n          name: \"iPhone 15 Pro\"\n        }, as: :json\n      end\n    end\n\n    assert_response :created\n\n    device = ApplicationPushDevice.last\n    assert_equal token, device.token\n    assert_equal \"apple\", device.platform\n    assert_equal \"iPhone 15 Pro\", device.name\n    assert_equal @identity, device.owner\n  end\n\n  test \"creates android device\" do\n    untenanted do\n      post saas.my_devices_path, params: {\n        token: SecureRandom.hex(32),\n        platform: \"google\",\n        name: \"Pixel 8\"\n      }, as: :json\n    end\n\n    assert_response :created\n\n    device = ApplicationPushDevice.last\n    assert_equal \"google\", device.platform\n  end\n\n  test \"same token can be registered by multiple identities\" do\n    shared_token = \"shared_push_token_123\"\n    other_identity = identities(:kevin)\n\n    # Other identity registers the token first\n    other_device = other_identity.devices.create!(\n      token: shared_token,\n      platform: \"apple\",\n      name: \"Kevin's iPhone\"\n    )\n\n    # Current identity registers the same token with their own device\n    assert_difference -> { ApplicationPushDevice.count }, 1 do\n      untenanted do\n        post saas.my_devices_path, params: {\n          token: shared_token,\n          platform: \"apple\",\n          name: \"David's iPhone\"\n        }, as: :json\n      end\n    end\n\n    assert_response :created\n\n    # Both identities have their own device records\n    assert_equal shared_token, other_device.reload.token\n    assert_equal other_identity, other_device.owner\n\n    davids_device = @identity.devices.last\n    assert_equal shared_token, davids_device.token\n    assert_equal @identity, davids_device.owner\n  end\n\n  test \"rejects invalid platform\" do\n    untenanted do\n      post saas.my_devices_path, params: {\n        token: SecureRandom.hex(32),\n        platform: \"windows\",\n        name: \"Surface\"\n      }, as: :json\n    end\n\n    assert_response :unprocessable_entity\n  end\n\n  test \"rejects missing token\" do\n    untenanted do\n      post saas.my_devices_path, params: {\n        platform: \"apple\",\n        name: \"iPhone\"\n      }, as: :json\n    end\n\n    assert_response :bad_request\n  end\n\n  test \"create requires authentication\" do\n    sign_out\n\n    untenanted do\n      post saas.my_devices_path, params: {\n        token: SecureRandom.hex(32),\n        platform: \"apple\"\n      }, as: :json\n    end\n\n    assert_response :redirect\n  end\n\n  test \"destroys device by id\" do\n    device = @identity.devices.create!(\n      token: \"token_to_delete\",\n      platform: \"apple\",\n      name: \"iPhone\"\n    )\n\n    assert_difference -> { ApplicationPushDevice.count }, -1 do\n      untenanted { delete saas.my_device_path(device) }\n    end\n\n    assert_redirected_to saas.my_devices_path(script_name: nil)\n    assert_not ApplicationPushDevice.exists?(device.id)\n  end\n\n  test \"returns not found when device not found by id\" do\n    assert_no_difference \"ApplicationPushDevice.count\" do\n      untenanted { delete saas.my_device_path(id: \"nonexistent\") }\n    end\n\n    assert_response :not_found\n  end\n\n  test \"returns not found for another identity's device by id\" do\n    other_identity = identities(:kevin)\n    device = other_identity.devices.create!(\n      token: \"other_identity_token\",\n      platform: \"apple\",\n      name: \"Other iPhone\"\n    )\n\n    assert_no_difference \"ApplicationPushDevice.count\" do\n      untenanted { delete saas.my_device_path(device) }\n    end\n\n    assert_response :not_found\n    assert ApplicationPushDevice.exists?(device.id)\n  end\n\n  test \"destroy by id requires authentication\" do\n    device = @identity.devices.create!(\n      token: \"my_token\",\n      platform: \"apple\",\n      name: \"iPhone\"\n    )\n\n    sign_out\n\n    untenanted { delete saas.my_device_path(device) }\n\n    assert_response :redirect\n    assert ApplicationPushDevice.exists?(device.id)\n  end\n\n  test \"destroys device by token\" do\n    device = @identity.devices.create!(\n      token: \"token_to_unregister\",\n      platform: \"apple\",\n      name: \"iPhone\"\n    )\n\n    assert_difference -> { ApplicationPushDevice.count }, -1 do\n      untenanted { delete saas.my_device_path(\"token_to_unregister\"), as: :json }\n    end\n\n    assert_response :no_content\n    assert_not ApplicationPushDevice.exists?(device.id)\n  end\n\n  test \"returns not found when device not found by token\" do\n    assert_no_difference \"ApplicationPushDevice.count\" do\n      untenanted { delete saas.my_device_path(\"nonexistent_token\"), as: :json }\n    end\n\n    assert_response :not_found\n  end\n\n  test \"returns not found for another identity's device by token\" do\n    other_identity = identities(:kevin)\n    device = other_identity.devices.create!(\n      token: \"other_identity_token\",\n      platform: \"apple\",\n      name: \"Other iPhone\"\n    )\n\n    assert_no_difference \"ApplicationPushDevice.count\" do\n      untenanted { delete saas.my_device_path(\"other_identity_token\"), as: :json }\n    end\n\n    assert_response :not_found\n    assert ApplicationPushDevice.exists?(device.id)\n  end\n\n  test \"destroy by token requires authentication\" do\n    device = @identity.devices.create!(\n      token: \"my_token\",\n      platform: \"apple\",\n      name: \"iPhone\"\n    )\n\n    sign_out\n\n    untenanted { delete saas.my_device_path(\"my_token\"), as: :json }\n\n    assert_response :redirect\n    assert ApplicationPushDevice.exists?(device.id)\n  end\nend\n"
  },
  {
    "path": "saas/test/controllers/non_production_remote_access_test.rb",
    "content": "require \"test_helper\"\n\nclass NonProductionRemoteAccessTest < ActionDispatch::IntegrationTest\n  test \"employee can access in staging environment\" do\n    assert_predicate identities(:david), :employee?\n\n    sign_in_as :david\n\n    Rails.stubs(:env).returns(ActiveSupport::EnvironmentInquirer.new(\"staging\"))\n    get cards_path\n    assert_response :success\n  end\n\n  test \"non-employee cannot access in staging environment\" do\n    identities(:jz).update!(email_address: \"david@example.com\")\n    assert_not_predicate identities(:jz), :employee?\n\n    sign_in_as :jz\n\n    Rails.stubs(:env).returns(ActiveSupport::EnvironmentInquirer.new(\"staging\"))\n    get cards_path\n    assert_response :forbidden\n  end\n\n  test \"non-employee can access in production environment\" do\n    identities(:jz).update!(email_address: \"david@example.com\")\n    assert_not_predicate identities(:jz), :employee?\n\n    sign_in_as :jz\n\n    Rails.stubs(:env).returns(ActiveSupport::EnvironmentInquirer.new(\"production\"))\n    get cards_path\n    assert_response :success\n  end\n\n  test \"non-employee can access in beta environment\" do\n    identities(:jz).update!(email_address: \"david@example.com\")\n    assert_not_predicate identities(:jz), :employee?\n\n    sign_in_as :jz\n\n    Rails.stubs(:env).returns(ActiveSupport::EnvironmentInquirer.new(\"beta\"))\n    get cards_path\n    assert_response :success\n  end\n\n  test \"non-employee can access in local environment\" do\n    identities(:jz).update!(email_address: \"david@example.com\")\n    assert_not_predicate identities(:jz), :employee?\n\n    sign_in_as :jz\n\n    get cards_path\n    assert_response :success\n  end\nend\n"
  },
  {
    "path": "saas/test/fixtures/application_push_devices.yml",
    "content": "davids_iphone:\n  name: iPhone 15 Pro\n  token: abc123def456abc123def456abc123def456abc123def456abc123def456abcd\n  platform: apple\n  owner: david (User)\n\ndavids_pixel:\n  name: Pixel 8\n  token: def456abc123def456abc123def456abc123def456abc123def456abc123defg\n  platform: google\n  owner: david (User)\n\nkevins_iphone:\n  name: iPhone 14\n  token: 789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz7890\n  platform: apple\n  owner: kevin (User)\n"
  },
  {
    "path": "saas/test/fixtures/files/.keep",
    "content": ""
  },
  {
    "path": "saas/test/helpers/.keep",
    "content": ""
  },
  {
    "path": "saas/test/integration/.keep",
    "content": ""
  },
  {
    "path": "saas/test/lib/true_client_ip_test.rb",
    "content": "require \"test_helper\"\n\nclass TrackTrueClientIpTest < ActiveSupport::TestCase\n  setup do\n    @app = ->(env) { [ 200, {}, [ \"OK\" ] ] }\n    @middleware = TrackTrueClientIp.new(@app)\n  end\n\n  test \"sets X-Forwarded-For header when True-Client-IP header is present\" do\n    env = { \"HTTP_TRUE_CLIENT_IP\" => \"123.123.123.123\" }\n    @middleware.call(env)\n    assert_equal \"123.123.123.123\", env[\"HTTP_X_FORWARDED_FOR\"]\n  end\n\n  test \"does not modify environment when True-Client-IP header is absent\" do\n    env = {}\n    @middleware.call(env)\n    assert_nil env[\"HTTP_X_FORWARDED_FOR\"]\n\n    env = { \"HTTP_X_FORWARDED_FOR\" => \"234.234.234.234\" }\n    @middleware.call(env)\n    assert_equal \"234.234.234.234\", env[\"HTTP_X_FORWARDED_FOR\"]\n  end\n\n  test \"calls the next middleware in the stack\" do\n    called = false\n    app = ->(env) { called = true; [ 200, {}, [ \"OK\" ] ] }\n    middleware = TrackTrueClientIp.new(app)\n\n    middleware.call({})\n\n    assert called\n  end\nend\n"
  },
  {
    "path": "saas/test/mailers/.keep",
    "content": ""
  },
  {
    "path": "saas/test/models/account/storage_exception_test.rb",
    "content": "require \"test_helper\"\n\nclass Account::StorageExceptionTest < ActiveSupport::TestCase\n  test \"storage limit returns default when no exception exists\" do\n    assert_equal Account::StorageLimited::DEFAULT_STORAGE_LIMIT, accounts(:initech).storage_limit\n  end\n\n  test \"storage limit returns exception value when one exists\" do\n    account = accounts(:initech)\n    account.add_storage_exception(5.gigabytes)\n\n    assert_equal 5.gigabytes, account.storage_limit\n  end\n\n  test \"add storage exception creates a new record\" do\n    account = accounts(:initech)\n\n    assert_difference -> { Account::StorageException.count } do\n      account.add_storage_exception(2.gigabytes)\n    end\n\n    assert_equal 2.gigabytes, account.storage_exception.bytes_allowed\n  end\n\n  test \"add storage exception updates existing record\" do\n    account = accounts(:initech)\n    account.add_storage_exception(2.gigabytes)\n\n    assert_no_difference -> { Account::StorageException.count } do\n      account.add_storage_exception(10.gigabytes)\n    end\n\n    assert_equal 10.gigabytes, account.storage_exception.reload.bytes_allowed\n  end\n\n  test \"exceeding storage limit respects exception\" do\n    account = accounts(:initech)\n    Account.any_instance.stubs(:bytes_used).returns(2.gigabytes)\n\n    assert account.exceeding_storage_limit?\n\n    account.add_storage_exception(5.gigabytes)\n\n    assert_not account.exceeding_storage_limit?\n  end\nend\n"
  },
  {
    "path": "saas/test/models/account/storage_limited_test.rb",
    "content": "require \"test_helper\"\n\nclass Account::StorageLimitedTest < ActiveSupport::TestCase\n  test \"exceeding storage limit when bytes used exceeds 1 GB\" do\n    Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1)\n\n    assert accounts(:initech).exceeding_storage_limit?\n  end\n\n  test \"not exceeding storage limit when bytes used equals 1 GB\" do\n    Account.any_instance.stubs(:bytes_used).returns(1.gigabyte)\n\n    assert_not accounts(:initech).exceeding_storage_limit?\n  end\n\n  test \"not exceeding storage limit when under 1 GB\" do\n    Account.any_instance.stubs(:bytes_used).returns(500.megabytes)\n\n    assert_not accounts(:initech).exceeding_storage_limit?\n  end\n\n  test \"nearing storage limit when within 500 MB of the limit\" do\n    Account.any_instance.stubs(:bytes_used).returns(600.megabytes)\n\n    assert accounts(:initech).nearing_storage_limit?\n  end\n\n  test \"not nearing storage limit when well under the threshold\" do\n    Account.any_instance.stubs(:bytes_used).returns(400.megabytes)\n\n    assert_not accounts(:initech).nearing_storage_limit?\n  end\n\n  test \"not nearing storage limit when already exceeding it\" do\n    Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1)\n\n    assert_not accounts(:initech).nearing_storage_limit?\n  end\nend\n"
  },
  {
    "path": "saas/test/models/identity_test.rb",
    "content": "require \"test_helper\"\n\nclass Fizzy::Saas::IdentityTest < ActiveSupport::TestCase\n  test \"#employee? returns true for 37signals.com domains\" do\n    identity = Identity.new(email_address: \"mike@37signals.com\")\n    assert_predicate identity, :employee?\n  end\n\n  test \"#employee? returns true for basecamp.com domains\" do\n    identity = Identity.new(email_address: \"mike@basecamp.com\")\n    assert_predicate identity, :employee?\n  end\n\n  test \"#employee? returns false for other domains\" do\n    identity = Identity.new(email_address: \"mike@example.com\")\n    assert_not_predicate identity, :employee?\n  end\nend\n"
  },
  {
    "path": "saas/test/models/notification/push_target/native_test.rb",
    "content": "require \"test_helper\"\n\nclass Notification::PushTarget::NativeTest < ActiveSupport::TestCase\n  setup do\n    @user = users(:kevin)\n    @identity = @user.identity\n    @notification = notifications(:logo_assignment_kevin)\n\n    # Ensure user has no web push subscriptions (we want to test native push independently)\n    @user.push_subscriptions.delete_all\n  end\n\n  test \"payload category returns assignment for card_assigned\" do\n    notification = notifications(:logo_assignment_kevin)\n\n    assert_equal \"assignment\", notification.payload.category\n  end\n\n  test \"payload category returns comment for comment_created\" do\n    notification = notifications(:layout_commented_kevin)\n\n    assert_equal \"comment\", notification.payload.category\n  end\n\n  test \"payload category returns mention for mentions\" do\n    notification = notifications(:logo_mentioned_david)\n\n    assert_equal \"mention\", notification.payload.category\n  end\n\n  test \"payload category returns card for other card events\" do\n    @notification.update!(source: events(:logo_published))\n\n    assert_equal \"card\", @notification.payload.category\n  end\n\n\n  test \"pushes to native devices when user has devices\" do\n    stub_push_services\n    @identity.devices.create!(token: \"test123\", platform: \"apple\", name: \"Test iPhone\")\n\n    assert_native_push_delivery(count: 1) do\n      Notification::PushTarget::Native.new(@notification).process\n    end\n  end\n\n  test \"does not push when user has no devices\" do\n    @identity.devices.delete_all\n\n    assert_no_native_push_delivery do\n      Notification::PushTarget::Native.new(@notification).process\n    end\n  end\n\n  test \"pushes to multiple devices\" do\n    stub_push_services\n    @identity.devices.delete_all\n    @identity.devices.create!(token: \"token1\", platform: \"apple\", name: \"iPhone\")\n    @identity.devices.create!(token: \"token2\", platform: \"google\", name: \"Pixel\")\n\n    assert_native_push_delivery(count: 2) do\n      Notification::PushTarget::Native.new(@notification).process\n    end\n  end\n\n  test \"native notification includes required fields\" do\n    @identity.devices.create!(token: \"test123\", platform: \"apple\", name: \"Test iPhone\")\n\n    push = Notification::PushTarget::Native.new(@notification)\n    native = push.send(:native_notification)\n\n    assert_not_nil native.title\n    assert_not_nil native.body\n    assert_equal \"default\", native.sound\n  end\n\n  test \"native notification sets thread_id from card\" do\n    @identity.devices.create!(token: \"test123\", platform: \"apple\", name: \"Test iPhone\")\n\n    push = Notification::PushTarget::Native.new(@notification)\n    native = push.send(:native_notification)\n\n    assert_equal @notification.card.id, native.thread_id\n  end\n\n  test \"native notification sets high_priority for assignments\" do\n    notification = notifications(:logo_assignment_kevin)\n    notification.user.identity.devices.create!(token: \"test123\", platform: \"apple\", name: \"Test iPhone\")\n\n    push = Notification::PushTarget::Native.new(notification)\n    native = push.send(:native_notification)\n\n    assert native.high_priority\n  end\n\n  test \"native notification sets high_priority for mentions\" do\n    notification = notifications(:logo_mentioned_david)\n    notification.user.identity.devices.create!(token: \"test123\", platform: \"apple\", name: \"Test iPhone\")\n\n    push = Notification::PushTarget::Native.new(notification)\n    native = push.send(:native_notification)\n\n    assert native.high_priority\n  end\n\n  test \"native notification sets normal priority for comments\" do\n    notification = notifications(:layout_commented_kevin)\n    @identity.devices.create!(token: \"test123\", platform: \"apple\", name: \"Test iPhone\")\n\n    push = Notification::PushTarget::Native.new(notification)\n    native = push.send(:native_notification)\n\n    assert_not native.high_priority\n  end\n\n  test \"native notification sets normal priority for other card events\" do\n    @notification.update!(source: events(:logo_published))\n    @identity.devices.create!(token: \"test123\", platform: \"apple\", name: \"Test iPhone\")\n\n    push = Notification::PushTarget::Native.new(@notification)\n    native = push.send(:native_notification)\n\n    assert_not native.high_priority\n  end\n\n  test \"native notification includes apple-specific fields\" do\n    @identity.devices.create!(token: \"test123\", platform: \"apple\", name: \"Test iPhone\")\n\n    push = Notification::PushTarget::Native.new(@notification)\n    native = push.send(:native_notification)\n\n    assert_equal 1, native.apple_data.dig(:aps, :\"mutable-content\")\n    assert_not_nil native.apple_data.dig(:aps, :category)\n  end\n\n  test \"native notification sets time-sensitive interruption level for assignments\" do\n    notification = notifications(:logo_assignment_kevin)\n    notification.user.identity.devices.create!(token: \"test123\", platform: \"apple\", name: \"Test iPhone\")\n\n    push = Notification::PushTarget::Native.new(notification)\n    native = push.send(:native_notification)\n\n    assert_equal \"time-sensitive\", native.apple_data.dig(:aps, :\"interruption-level\")\n  end\n\n  test \"native notification sets time-sensitive interruption level for mentions\" do\n    notification = notifications(:logo_mentioned_david)\n    notification.user.identity.devices.create!(token: \"test123\", platform: \"apple\", name: \"Test iPhone\")\n\n    push = Notification::PushTarget::Native.new(notification)\n    native = push.send(:native_notification)\n\n    assert_equal \"time-sensitive\", native.apple_data.dig(:aps, :\"interruption-level\")\n  end\n\n  test \"native notification sets active interruption level for comments\" do\n    notification = notifications(:layout_commented_kevin)\n    @identity.devices.create!(token: \"test123\", platform: \"apple\", name: \"Test iPhone\")\n\n    push = Notification::PushTarget::Native.new(notification)\n    native = push.send(:native_notification)\n\n    assert_equal \"active\", native.apple_data.dig(:aps, :\"interruption-level\")\n  end\n\n  test \"native notification sets active interruption level for other card events\" do\n    @notification.update!(source: events(:logo_published))\n    @identity.devices.create!(token: \"test123\", platform: \"apple\", name: \"Test iPhone\")\n\n    push = Notification::PushTarget::Native.new(@notification)\n    native = push.send(:native_notification)\n\n    assert_equal \"active\", native.apple_data.dig(:aps, :\"interruption-level\")\n  end\n\n  test \"native notification sets android notification to nil for data-only\" do\n    @identity.devices.create!(token: \"test123\", platform: \"apple\", name: \"Test iPhone\")\n\n    push = Notification::PushTarget::Native.new(@notification)\n    native = push.send(:native_notification)\n\n    assert_nil native.google_data.dig(:android, :notification)\n  end\n\n  test \"native notification includes data payload\" do\n    @identity.devices.create!(token: \"test123\", platform: \"apple\", name: \"Test iPhone\")\n\n    push = Notification::PushTarget::Native.new(@notification)\n    native = push.send(:native_notification)\n\n    assert_not_nil native.data[:url]\n    assert_equal @notification.account.id, native.data[:account_id]\n    assert_equal @notification.account.slug, native.data[:account_slug]\n    assert_equal @notification.creator.name, native.data[:creator_name]\n  end\n\n  test \"native notification includes base_url without account slug\" do\n    @identity.devices.create!(token: \"test123\", platform: \"apple\", name: \"Test iPhone\")\n\n    push = Notification::PushTarget::Native.new(@notification)\n    native = push.send(:native_notification)\n\n    assert_equal \"http://example.com\", native.data[:base_url]\n  end\n\n  private\n    def assert_native_push_delivery(count: 1, &block)\n      assert_enqueued_jobs count, only: ApplicationPushNotificationJob do\n        perform_enqueued_jobs only: Notification::PushJob, &block\n      end\n    end\n\n    def assert_no_native_push_delivery(&block)\n      assert_enqueued_jobs 0, only: ApplicationPushNotificationJob do\n        perform_enqueued_jobs only: Notification::PushJob, &block\n      end\n    end\n\n    def stub_push_services\n      ActionPushNative.stubs(:service_for).returns(stub(push: true))\n    end\nend\n"
  },
  {
    "path": "saas/test/models/session/devices_test.rb",
    "content": "require \"test_helper\"\n\nclass Session::DevicesTest < ActiveSupport::TestCase\n  setup do\n    @session = sessions(:david)\n    @identity = @session.identity\n  end\n\n  test \"destroying session destroys associated devices\" do\n    device = ApplicationPushDevice.register(\n      session: @session,\n      token: \"test_token\",\n      platform: \"apple\",\n      name: \"Test iPhone\"\n    )\n\n    assert_difference -> { ApplicationPushDevice.count }, -1 do\n      @session.destroy\n    end\n\n    assert_nil ApplicationPushDevice.find_by(id: device.id)\n  end\n\n  test \"destroying session does not destroy devices from other sessions\" do\n    other_session = sessions(:kevin)\n\n    device = ApplicationPushDevice.register(\n      session: other_session,\n      token: \"other_token\",\n      platform: \"apple\",\n      name: \"Other iPhone\"\n    )\n\n    assert_no_difference -> { ApplicationPushDevice.count } do\n      @session.destroy\n    end\n\n    assert ApplicationPushDevice.exists?(device.id)\n  end\nend\n"
  },
  {
    "path": "saas/test/models/signup_test.rb",
    "content": "require \"test_helper\"\n\nclass Fizzy::Saas::SignupTest < ActiveSupport::TestCase\n  test \"#complete creates queenbee account and uses its id as tenant\" do\n    queenbee_account = mock(\"queenbee_account\")\n    queenbee_account.stubs(:id).returns(123456)\n\n    Queenbee::Remote::Account.expects(:create!).once.returns(queenbee_account)\n    Account.any_instance.expects(:setup_customer_template).once\n\n    Current.without_account do\n      assert_changes -> { Account.count }, +1 do\n        sequence_value_before = Account::ExternalIdSequence.value\n\n        signup = Signup.new(\n          full_name: \"Kevin\",\n          identity: identities(:kevin)\n        )\n\n        assert signup.complete\n\n        assert signup.account\n        assert_equal 123456, signup.account.external_account_id\n        assert_equal sequence_value_before, Account::ExternalIdSequence.value\n      end\n    end\n  end\n\n  test \"#complete calls cancel on queenbee account when account creation fails\" do\n    queenbee_account = mock(\"queenbee_account\")\n    queenbee_account.stubs(:id).returns(789012)\n    queenbee_account.expects(:cancel).once\n\n    Queenbee::Remote::Account.expects(:create!).once.returns(queenbee_account)\n    Account.any_instance.stubs(:setup_customer_template).raises(StandardError.new(\"Account setup failed\"))\n\n    Current.without_account do\n      signup = Signup.new(\n        full_name: \"Kevin\",\n        identity: identities(:kevin)\n      )\n\n      assert_not signup.complete\n      assert_includes signup.errors[:base], \"Something went wrong, and we couldn't create your account. Please give it another try.\"\n    end\n  end\nend\n"
  },
  {
    "path": "script/create-identities.rb",
    "content": "#!/usr/bin/env ruby\n\nrequire_relative \"../config/environment\"\n\n# Loop through all tenants and create identities for users\nApplicationRecord.with_each_tenant do |tenant|\n  puts \"Processing tenant: #{tenant}\"\n\n  User.find_each do |user|\n    next if user.system?\n\n    # Use IdentityProvider to link the user's email to this tenant\n    # This will find_or_create the identity and link it to the tenant\n    IdentityProvider.link(email_address: user.email_address, to: tenant)\n\n    puts \"  ✅ Linked identity for user #{user.id} (#{user.email_address}) to tenant '#{tenant}'\"\n  end\n\n  puts \"  Completed tenant: #{tenant}\"\n  puts\nend\n\nputs \"All identities created successfully!\"\n"
  },
  {
    "path": "script/fetch-prod-db.rb",
    "content": "#!/usr/bin/env ruby\nrequire \"tmpdir\"\nrequire \"fileutils\"\nrequire \"open3\"\n\nif ARGV.size != 1\n  warn \"Usage: #{$PROGRAM_NAME} TENANT_ID\"\n  exit 1\nend\n\ntenant_id = ARGV[0]\n\n# Automatically detect the fizzy-web-production container\nputs \"→ Detecting fizzy-web-production container...\"\ncontainer_output, status = Open3.capture2(%(ssh app@fizzy-app-101 \"docker ps --format '{{.Names}}' | grep fizzy-web-production\"))\nabort(\"Failed to detect container\") unless status.success?\n\nCONTAINER = container_output.strip\nabort(\"No fizzy-web-production container found\") if CONTAINER.empty?\nputs \"→ Using container: #{CONTAINER}\"\n\nREMOTE_PATH = \"/rails/storage/tenants/production/#{tenant_id}/db/main.sqlite3.1\"\n\nDir.mktmpdir do |tmpdir|\n  local_file = File.join(tmpdir, \"main.sqlite3\")\n\n  puts \"→ Copying #{REMOTE_PATH} from container to #{local_file}\"\n  cmd = %(ssh app@fizzy-app-101 \"docker cp #{CONTAINER}:#{REMOTE_PATH} -\" | tar -xOf - > #{local_file})\n  system(cmd) or abort(\"Failed to copy database file\")\n\n  puts \"→ Running script/load-prod-db-in-dev.rb with #{local_file}\"\n  exec(\"bundle\", \"exec\", \"ruby\", \"script/load-prod-db-in-dev.rb\", local_file)\nend\n"
  },
  {
    "path": "script/fix-active-storage-links.rb",
    "content": "#!/usr/bin/env ruby\n\nrequire_relative \"../config/environment\"\nrequire \"pathname\"\nrequire \"uri\"\nrequire \"base64\"\nrequire \"json\"\n\nclass FixActiveStorage\n  attr_reader :skipped, :processed, :scope\n\n  def initialize(scope = nil)\n    @scope = scope || ActionText::RichText.all.where(\"body LIKE '%/rails/active_storage/%'\")\n    @mapping = {}\n\n    @skipped = 0\n    @processed = 0\n\n    @users = {}\n    @memberships = {}\n    @attachments = {}\n    @identities = {}\n  end\n\n  def ingest_blob_keys(db_path)\n    models = Models.new(db_path)\n\n    @mapping[models.accounts.sole.external_account_id.to_s] = models.blobs.all.index_by(&:id)\n    @attachments[models.accounts.sole.external_account_id.to_s] = models.attachments.all.index_by(&:id)\n    @users[models.accounts.sole.external_account_id.to_s] = models.users.all.index_by(&:id)\n  end\n\n  def ingest_untenanted(untenanted_db_path)\n    untenanted = Models.new(untenanted_db_path)\n\n    @memberships = untenanted.memberships.all.index_by(&:id)\n    @identities = untenanted.identities.all.index_by(&:id)\n  end\n\n  def perform\n    fix_avatars\n    fix_mentions\n    fix_attachments\n\n    pp [ @processed, @skipped ]\n  end\n\n  private\n    def fix_avatars\n      User.all.active.preload(:identity).find_each do |user|\n        tenant = user.account.external_account_id.to_s\n        email_address = user.identity.email_address\n\n        membership = @memberships.values.find { |m| m.tenant == tenant && @identities[m.identity_id]&.email_address == email_address }\n        old_user = @users[tenant]&.values&.find { |u| u.membership_id == membership&.id }\n\n        next if user.avatar.attached? || old_user.nil?\n\n        old_avatar_attachment = @attachments[tenant]&.values&.find do |attachment|\n          attachment.record_type == \"User\" && attachment.record_id == old_user.id && attachment.name == \"avatar\"\n        end\n\n        if old_avatar_attachment.nil?\n          @skipped += 1\n          next\n        end\n\n\n        old_blob = old_avatar_attachment.blob\n\n        if old_blob.nil?\n          @skipped += 1\n          next\n        end\n\n        new_blob = ActiveStorage::Blob.find_by(key: old_blob.key)\n\n        unless new_blob\n          new_blob = ActiveStorage::Blob.create!(\n            account_id: user.account_id,\n            byte_size: old_blob.byte_size,\n            checksum: old_blob.checksum,\n            content_type: old_blob.content_type,\n            created_at: old_blob.created_at,\n            filename: old_blob.filename,\n            key: old_blob.key,\n            metadata: old_blob.metadata,\n            service_name: old_blob.service_name\n          )\n        end\n\n          ActiveStorage::Attachment.find_or_create_by!(\n            account_id: user.account_id,\n            blob_id: new_blob.id,\n            name: \"avatar\",\n            record: user\n          )\n\n        @processed += 1\n      end\n    end\n\n    def fix_mentions\n      ActionText::RichText.where(\"body LIKE '%action-text-attachment%'\").find_each do |rich_text|\n        rich_text.body.send(:attachment_nodes).each do |node|\n          next unless node[\"content-type\"] == \"application/vnd.actiontext.mention\"\n\n          sgid = SignedGlobalID.parse(node[\"sgid\"], for: ActionText::Attachable::LOCATOR_NAME)\n\n          user = @users.dig(sgid.params[:tenant], sgid.model_id.to_i)\n          membership = @memberships[user&.membership_id]\n          unless membership\n            @skipped += 1\n            next\n          end\n          identity = @identities[membership&.identity_id]\n          unless identity\n            @skipped += 1\n            next\n          end\n\n          new_identity = Identity.find_by(email_address: identity.email_address)\n          new_account = Account.find_by(external_account_id: sgid.params[:tenant])\n          new_user = User.find_by(identity: new_identity, account: new_account)\n          new_sgid = new_user.attachable_sgid\n\n          node[\"sgid\"] = new_sgid.to_s\n        end\n        rich_text.save!\n      end\n    end\n\n    def fix_attachments\n      scope.find_each do |rich_text|\n        next unless rich_text.body\n\n        rich_text.body.send(:attachment_nodes).each do |node|\n          sgid = node[\"sgid\"]\n          url = node[\"url\"]\n          next if url.blank? || sgid.blank?\n\n          sgid = SignedGlobalID.parse(node[\"sgid\"], for: ActionText::Attachable::LOCATOR_NAME)\n          old_blob = @mapping.dig(sgid.params[:tenant], sgid.model_id.to_i)\n\n          # There are some old files that got lost in a previous migration\n          unless old_blob\n            @skipped += 1\n            next\n          end\n\n          new_blob = ActiveStorage::Blob.find_by(key: old_blob.key)\n\n          unless new_blob\n            new_blob = ActiveStorage::Blob.create!(\n              account_id: rich_text.account_id,\n              byte_size: old_blob.byte_size,\n              checksum: old_blob.checksum,\n              content_type: old_blob.content_type,\n              created_at: old_blob.created_at,\n              filename: old_blob.filename,\n              key: old_blob.key,\n              metadata: old_blob.metadata,\n              service_name: old_blob.service_name\n            )\n\n            ActiveStorage::Attachment.create!(\n              account_id: rich_text.account_id,\n              blob_id: new_blob.id,\n              created_at: old_blob.created_at,\n              name: \"embeds\",\n              record: rich_text\n            )\n          end\n\n          node[\"sgid\"] = new_blob.attachable_sgid\n\n          @processed += 1\n        end\n\n        rich_text.save!\n      rescue ActiveStorage::FileNotFoundError\n        @skipped += 1\n        next\n      end\n    end\nend\n\nclass Models\n  attr_reader :application_record\n\n  def initialize(db_path)\n    const_name = \"ImportBase#{db_path.hash.abs}\"\n\n    if self.class.const_defined?(const_name)\n      @application_record = self.class.const_get(const_name)\n    else\n      @application_record = Class.new(ActiveRecord::Base) do\n        self.abstract_class = true\n\n        def self.models\n          const_get(\"MODELS\")\n        end\n\n        delegate :models, to: :class\n      end\n      self.class.const_set(const_name, @application_record)\n    end\n\n    @application_record.establish_connection adapter: \"sqlite3\", database: db_path\n    @application_record.const_set(\"MODELS\", self)\n  end\n\n  def accounts\n    @accounts ||= Class.new(application_record) do\n      self.table_name = \"accounts\"\n    end\n  end\n\n  def blobs\n    models = self\n    @blobs ||= Class.new(application_record) do\n      self.table_name = \"active_storage_blobs\"\n\n      def attachments\n        models.attachments.where(blob_id: id)\n      end\n    end\n  end\n\n  def attachments\n    models = self\n    @attachments ||= Class.new(application_record) do\n      self.table_name = \"active_storage_attachments\"\n\n      def blob\n        models.blobs.find_by(id: blob_id)\n      end\n    end\n  end\n\n  def users\n    @users ||= Class.new(application_record) do\n      self.table_name = \"users\"\n    end\n  end\n\n  def identities\n    @identities ||= Class.new(application_record) do\n      self.table_name = \"identities\"\n    end\n  end\n\n  def memberships\n    @memberships ||= Class.new(application_record) do\n      self.table_name = \"memberships\"\n    end\n  end\nend\n\n# tenanted_db_paths = ARGV\ntenanted_db_paths = Dir[Rails.root.join(\"storage/tenants/production/*/db/main.sqlite3\")]\nuntenanted_db_path = Rails.root.join(\"storage/untenanted/production.sqlite3\")\n\nif tenanted_db_paths.empty?\n  $stderr.puts \"Error: at least one tenanted database path is required\"\n  $stderr.puts\n  exit 1\nend\n\nfix = FixActiveStorage.new\n\nfix.ingest_untenanted(untenanted_db_path)\n\ntenanted_db_paths.each_with_index do |db_path, _index|\n  fix.ingest_blob_keys(db_path)\nend\n\nfix.perform\n"
  },
  {
    "path": "script/import-sqlite-database.rb",
    "content": "#!/usr/bin/env ruby\n\nrequire_relative \"../config/environment\"\nrequire \"pathname\"\nrequire \"optparse\"\n\nclass AccountExistsError < StandardError; end\n\nclass Import\n  FIX_LINK_HOSTS = {\n    \"fizzy.37signals.com\" => \"app.fizzy.do\",\n    \"box-car.com\" => \"app.fizzy.do\",\n    \"app.box-car.com\" => \"app.fizzy.do\"\n  }.freeze\n\n  attr_reader :db_path, :untenanted_db_path, :skip_already_imported\n  attr_reader :account, :tenant, :mapping\n\n  def initialize(db_path, untenanted_db_path, skip_already_imported: false)\n    @db_path = Pathname(db_path)\n    @untenanted_db_path = Pathname(untenanted_db_path)\n    @skip_already_imported = skip_already_imported\n    @mapping = nil\n  end\n\n  def import_database\n    raise \"The given database file doesn't exist\" unless db_path.exist?\n\n    @mapping = {}\n\n    duration = ActiveSupport::Benchmark.realtime do\n      ApplicationRecord.transaction do\n        setup_account\n\n        ActiveRecord::Base.no_touching do\n          Current.with(account: account) do\n            begin\n              Webhook.skip_callback(:create, :after, :create_delinquency_tracker!)\n              Comment.skip_callback(:commit, :after, :watch_card_by_creator)\n              Comment.skip_callback(:commit, :after, :track_creation)\n              Mention.skip_callback(:commit, :after, :watch_source_by_mentionee)\n              Notification.skip_callback(:commit, :after, :broadcast_unread)\n              Notification.skip_callback(:create, :after, :bundle)\n              Reaction.skip_callback(:create, :after, :register_card_activity)\n              Card.skip_callback(:save, :before, :set_default_title)\n              Card.skip_callback(:update, :after, :handle_board_change)\n              ActiveStorage::Blob.skip_callback(:update, :after, :touch_attachments)\n              ActiveStorage::Blob.skip_callback(:commit, :after, :update_service_metadata)\n              ActiveStorage::Attachment.skip_callback(:commit, :after, :mirror_blob_later)\n              ActiveStorage::Attachment.skip_callback(:commit, :after, :analyze_blob_later)\n              ActiveStorage::Attachment.skip_callback(:commit, :after, :transform_variants_later)\n              ActiveStorage::Attachment.skip_callback(:commit, :after, :purge_dependent_blob_later)\n            rescue => e\n              puts \"⚠️  Warning: Could not skip some callbacks: #{e.message}\"\n            end\n\n            Event.suppress do\n              copy_users\n              copy_boards\n              copy_accesses\n              copy_columns\n              copy_cards\n              copy_steps\n              copy_comments\n              copy_mentions\n              copy_reactions\n              copy_tags\n              copy_watches\n              copy_pins\n            end\n\n            copy_events\n\n            Event.suppress do\n              copy_webhooks\n              copy_push_subscriptions\n              copy_filters\n              copy_entropies\n            end\n\n            copy_notifications\n            copy_notification_bundles\n\n            fix_links\n\n            unless Rails.env.production?\n              # Don't spam real webhooks\n              Webhook.all.update_all(active: false)\n              # Don't send emails to real users\n              User::Settings.all.update_all(bundle_email_frequency: :never)\n            end\n          end\n        end\n      end\n    end\n\n    puts \"🎉 Import complete! (#{duration.round(2)}s)\"\n  rescue AccountExistsError => e\n    raise e unless skip_already_imported\n  end\n\n  private\n    def step(start_message, completion_message)\n      puts \"⏩ #{start_message}\"\n\n      result = nil\n      duration = ActiveSupport::Benchmark.realtime do\n        result = yield\n      end\n\n      interpolations = { duration: \"#{duration.round(2)}s\" }\n      interpolations.merge!(result) if result.is_a?(Hash)\n      completion_text = completion_message % interpolations\n      puts \"✅ #{completion_text}\"\n\n      result\n    end\n\n    def generate_uuid\n      ActiveRecord::Type::Uuid.generate\n    end\n\n    def setup_account\n      step(\"Setting up account\", \"Account set up in %{duration}\") do\n        oldest_admin = import.users.order(id: :asc).admin.first\n        raise \"No admin user found in the database\" unless oldest_admin\n\n        membership = untenanted.memberships.find(oldest_admin.membership_id)\n        account = import.accounts.sole\n\n        new_identity = Identity.find_or_create_by!(email_address: membership.identity.email_address)\n\n        if Account.all.exists?(external_account_id: account.external_account_id)\n          raise AccountExistsError, \"Account already exists\"\n        else\n          @account = Account.create_with_owner(\n            account: {\n              external_account_id: account.external_account_id,\n              name: account.name.truncate(255, omission: \"\")\n            },\n            owner: {\n              name: oldest_admin.name.truncate(255, omission: \"\"),\n              identity: new_identity\n            }\n          )\n          @tenant = @account.external_account_id\n          @admin = @account.users.find_by(role: :owner)\n        end\n\n        old_join_code = import.account_join_codes.sole\n\n        attributes = {\n          usage_count: old_join_code.usage_count,\n          usage_limit: old_join_code.usage_limit\n        }\n        attributes[:code] = old_join_code.code unless Account::JoinCode.all.exists?(code: old_join_code.code)\n\n        @account.join_code.update_columns(**attributes)\n      end\n    end\n\n    def copy_users\n      step(\"Copying users\", \"Copied %{count} users in %{duration}\") do\n        mapping[:users] ||= {}\n        import.users.find_each do |old_user|\n          new_identity = nil\n\n          if old_user.membership_id && old_user.active?\n            membership = untenanted.memberships.find(old_user.membership_id)\n            new_identity = Identity.find_or_create_by!(email_address: membership.identity.email_address)\n          end\n\n          new_user = if new_identity == @admin.identity\n            @admin\n          else\n            User.create!(\n              account: account,\n              identity: new_identity,\n              name: old_user.name.truncate(255, omission: \"\"),\n              role: old_user.role,\n              active: old_user.active,\n            )\n          end\n\n          old_settings = old_user.settings\n          if old_settings\n            User::Settings.create!(\n              user: new_user,\n              bundle_email_frequency: old_settings.bundle_email_frequency,\n              timezone_name: old_settings.timezone_name\n            )\n          end\n\n          mapping[:users][old_user.id] = new_user.id\n        end\n\n        { count: mapping[:users].size }\n      end\n    end\n\n    def copy_boards\n      step(\"Copying boards\", \"Copied %{count} boards in %{duration}\") do\n        mapping[:boards] ||= {}\n        import.boards.find_each do |old_board|\n          new_board = Board.create!(\n            account_id: account.id,\n            creator_id: mapping[:users][old_board.creator_id],\n            name: old_board.name.truncate(255, omission: \"\"),\n            all_access: old_board.all_access,\n            created_at: old_board.created_at,\n            updated_at: old_board.updated_at\n          )\n\n          old_publication = old_board.publication\n          if old_publication\n            Board::Publication.create!(\n              board_id: new_board.id,\n              key: old_publication.key,\n              created_at: old_publication.created_at,\n              updated_at: old_publication.updated_at\n            )\n          end\n\n          mapping[:boards][old_board.id] = new_board.id\n        end\n\n        { count: mapping[:boards].size }\n      end\n    end\n\n    def copy_columns\n      step(\"Copying columns\", \"Copied %{count} columns in %{duration}\") do\n        mapping[:columns] ||= {}\n\n        import.columns.find_each do |old_column|\n          new_column = Column.create!(\n            account_id: account.id,\n            board_id: mapping[:boards][old_column.board_id],\n            name: old_column.name.truncate(255, omission: \"\"),\n            color: old_column.color,\n            position: old_column.position,\n            created_at: old_column.created_at,\n            updated_at: old_column.updated_at\n          )\n\n          mapping[:columns][old_column.id] = new_column.id\n        end\n\n        { count: mapping[:columns].size }\n      end\n    end\n\n    def copy_cards\n      step(\"Copying cards\", \"Copied %{count} cards in %{duration}\") do\n        mapping[:cards] ||= {}\n        account.update_columns(cards_count: import.cards.maximum(:id) || 0)\n\n        activity_spikes_to_insert = []\n        engagements_to_insert = []\n        goldnesses_to_insert = []\n        not_nows_to_insert = []\n        assignments_to_insert = []\n        closures_to_insert = []\n\n        import.cards.in_batches(of: 1000) do |batch|\n          cards_to_insert = []\n\n          batch.each do |old_card|\n            new_id = generate_uuid\n            mapping[:cards][old_card.id] = new_id\n\n            # Map old 'creating' status to 'drafted' since it's no longer a valid enum value\n            status = old_card.status == \"creating\" ? \"drafted\" : old_card.status\n\n            cards_to_insert << {\n              id: new_id,\n              number: old_card.id,\n              account_id: account.id,\n              board_id: mapping[:boards][old_card.board_id],\n              column_id: old_card.column_id ? mapping[:columns][old_card.column_id] : nil,\n              creator_id: mapping[:users][old_card.creator_id],\n              title: old_card.title,\n              status: status,\n              due_on: old_card.due_on,\n              last_active_at: old_card.last_active_at,\n              created_at: old_card.created_at,\n              updated_at: old_card.updated_at\n            }\n\n            old_activity_spike = old_card.activity_spike\n            if old_activity_spike\n              activity_spikes_to_insert << {\n                id: generate_uuid,\n                account_id: account.id,\n                card_id: new_id,\n                created_at: old_activity_spike.created_at,\n                updated_at: old_activity_spike.updated_at\n              }\n            end\n\n            old_engagement = old_card.engagement\n            if old_engagement\n              engagements_to_insert << {\n                id: generate_uuid,\n                account_id: account.id,\n                card_id: new_id,\n                status: old_engagement.status,\n                created_at: old_engagement.created_at,\n                updated_at: old_engagement.updated_at\n              }\n            end\n\n            old_goldness = old_card.goldness\n            if old_goldness\n              goldnesses_to_insert << {\n                id: generate_uuid,\n                account_id: account.id,\n                card_id: new_id,\n                created_at: old_goldness.created_at,\n                updated_at: old_goldness.updated_at\n              }\n            end\n\n            old_not_now = old_card.not_now\n            if old_not_now\n              not_nows_to_insert << {\n                id: generate_uuid,\n                account_id: account.id,\n                card_id: new_id,\n                user_id: old_not_now.user_id ? mapping[:users][old_not_now.user_id] : nil,\n                created_at: old_not_now.created_at,\n                updated_at: old_not_now.updated_at\n              }\n            end\n\n            old_card.assignments.each do |old_assignment|\n              assignments_to_insert << {\n                id: generate_uuid,\n                account_id: account.id,\n                card_id: new_id,\n                assignee_id: mapping[:users][old_assignment.assignee_id],\n                assigner_id: mapping[:users][old_assignment.assigner_id],\n                created_at: old_assignment.created_at,\n                updated_at: old_assignment.updated_at\n              }\n            end\n\n            old_closure = old_card.closure\n            if old_closure\n              closures_to_insert << {\n                id: generate_uuid,\n                account_id: account.id,\n                card_id: new_id,\n                user_id: old_closure.user_id ? mapping[:users][old_closure.user_id] : nil,\n                created_at: old_closure.created_at,\n                updated_at: old_closure.updated_at\n              }\n            end\n          end\n\n          Card.insert_all(cards_to_insert)\n        end\n\n        Card::ActivitySpike.insert_all(activity_spikes_to_insert) if activity_spikes_to_insert.any?\n        Card::Engagement.insert_all(engagements_to_insert) if engagements_to_insert.any?\n        Card::Goldness.insert_all(goldnesses_to_insert) if goldnesses_to_insert.any?\n        Card::NotNow.insert_all(not_nows_to_insert) if not_nows_to_insert.any?\n        Assignment.insert_all(assignments_to_insert) if assignments_to_insert.any?\n        Closure.insert_all(closures_to_insert) if closures_to_insert.any?\n\n        import.cards.find_each do |old_card|\n          new_card_id = mapping[:cards][old_card.id]\n          new_card = Card.find(new_card_id)\n          copy_rich_text(old_card, new_card, \"Card\", \"description\")\n          copy_attachment(old_card, new_card, \"Card\", \"image\")\n        end\n\n        { count: mapping[:cards].size }\n      end\n    end\n\n    def copy_steps\n      step(\"Copying steps\", \"Copied steps in %{duration}\") do\n        import.steps.in_batches(of: 1000) do |batch|\n          steps_to_insert = []\n\n          batch.each do |old_step|\n            steps_to_insert << {\n              id: generate_uuid,\n              account_id: account.id,\n              card_id: mapping[:cards][old_step.card_id],\n              content: old_step.content,\n              completed: old_step.completed,\n              created_at: old_step.created_at,\n              updated_at: old_step.updated_at\n            }\n          end\n\n          Step.insert_all(steps_to_insert)\n        end\n      end\n    end\n\n    def copy_comments\n      step(\"Copying comments\", \"Copied %{count} comments in %{duration}\") do\n        mapping[:comments] ||= {}\n\n        import.comments.in_batches(of: 1000) do |batch|\n          comments_to_insert = []\n\n          batch.each do |old_comment|\n            new_id = generate_uuid\n            mapping[:comments][old_comment.id] = new_id\n\n            comments_to_insert << {\n              id: new_id,\n              account_id: account.id,\n              card_id: mapping[:cards][old_comment.card_id],\n              creator_id: mapping[:users][old_comment.creator_id],\n              created_at: old_comment.created_at,\n              updated_at: old_comment.updated_at\n            }\n          end\n\n          Comment.insert_all(comments_to_insert)\n        end\n\n        import.comments.find_each do |old_comment|\n          new_comment_id = mapping[:comments][old_comment.id]\n          new_comment = Comment.find(new_comment_id)\n          copy_rich_text(old_comment, new_comment, \"Comment\", \"body\")\n        end\n\n        { count: mapping[:comments].size }\n      end\n    end\n\n    def copy_mentions\n      step(\"Copying mentions\", \"Copied %{count} mentions in %{duration}\") do\n        mapping[:mentions] ||= {}\n\n        import.mentions.find_each do |old_mention|\n          new_mention = Mention.create!(\n            source_type: old_mention.source_type,\n            source_id: mapping[old_mention.source_type.tableize.to_sym][old_mention.source_id],\n            mentioner_id: mapping[:users][old_mention.mentioner_id],\n            mentionee_id: mapping[:users][old_mention.mentionee_id],\n            created_at: old_mention.created_at,\n            updated_at: old_mention.updated_at\n          )\n\n          mapping[:mentions][old_mention.id] = new_mention.id\n        end\n\n        { count: mapping[:mentions].size }\n      end\n    end\n\n    def copy_accesses\n      step(\"Copying accesses\", \"Copied %{count} accesses in %{duration}\") do\n        mapping[:accesses] ||= {}\n\n        import.accesses.in_batches(of: 1000) do |batch|\n          accesses_to_insert = []\n\n          batch.each do |old_access|\n            new_id = generate_uuid\n            mapping[:accesses][old_access.id] = new_id\n\n            accesses_to_insert << {\n              id: new_id,\n              account_id: account.id,\n              board_id: mapping[:boards][old_access.board_id],\n              user_id: mapping[:users][old_access.user_id],\n              involvement: old_access.involvement,\n              accessed_at: old_access.accessed_at,\n              created_at: old_access.created_at,\n              updated_at: old_access.updated_at\n            }\n          end\n\n          Access.insert_all(accesses_to_insert)\n        end\n\n        { count: mapping[:accesses].size }\n      end\n    end\n\n    def copy_notifications\n      step(\"Copying notifications\", \"Copied %{count} notifications in %{duration}\") do\n        mapping[:notifications] ||= {}\n\n        import.notifications.in_batches(of: 1000) do |batch|\n          notifications_to_insert = []\n\n          batch.each do |old_notification|\n            new_id = generate_uuid\n            mapping[:notifications][old_notification.id] = new_id\n\n            notifications_to_insert << {\n              id: new_id,\n              account_id: account.id,\n              user_id: mapping[:users][old_notification.user_id],\n              creator_id: old_notification.creator_id ? mapping[:users][old_notification.creator_id] : nil,\n              source_type: old_notification.source_type,\n              source_id: mapping.fetch(old_notification.source_type.tableize.to_sym)[old_notification.source_id],\n              read_at: old_notification.read_at,\n              created_at: old_notification.created_at,\n              updated_at: old_notification.updated_at\n            }\n          end\n\n          Notification.insert_all(notifications_to_insert)\n        end\n\n        { count: mapping[:notifications].size }\n      end\n    end\n\n    def copy_notification_bundles\n      step(\"Copying notification bundles\", \"Copied %{count} notification bundles in %{duration}\") do\n        mapping[:notification_bundles] ||= {}\n\n        import.notification_bundles.in_batches(of: 1000) do |batch|\n          bundles_to_insert = []\n\n          batch.each do |old_bundle|\n            new_id = generate_uuid\n            mapping[:notification_bundles][old_bundle.id] = new_id\n\n            bundles_to_insert << {\n              id: new_id,\n              account_id: account.id,\n              user_id: mapping[:users][old_bundle.user_id],\n              status: old_bundle.status,\n              starts_at: old_bundle.starts_at,\n              ends_at: old_bundle.ends_at,\n              created_at: old_bundle.created_at,\n              updated_at: old_bundle.updated_at\n            }\n          end\n\n          Notification::Bundle.insert_all(bundles_to_insert)\n        end\n\n        { count: mapping[:notification_bundles].size }\n      end\n    end\n\n    def copy_entropies\n      step(\"Copying entropies\", \"Copied entropies in %{duration}\") do\n        import.entropies.find_each do |old_entropy|\n          container_id = case old_entropy.container_type\n          when \"Account\" then account.id\n          when \"Board\" then mapping[:boards][old_entropy.container_id]\n          when \"Card\" then mapping[:cards][old_entropy.container_id]\n          else next\n          end\n\n          Entropy.find_or_create_by!(account_id: account.id, container_type: old_entropy.container_type, container_id: container_id) do |entropy|\n            entropy.auto_postpone_period = old_entropy.auto_postpone_period || 0\n            entropy.created_at = old_entropy.created_at\n            entropy.updated_at = old_entropy.updated_at\n          end\n        end\n      end\n    end\n\n    def copy_filters\n      step(\"Copying filters\", \"Copied %{count} filters in %{duration}\") do\n        mapping[:filters] ||= {}\n\n        # First, insert all filters\n        import.filters.in_batches(of: 1000) do |batch|\n          filters_to_insert = []\n\n          batch.each do |old_filter|\n            new_id = generate_uuid\n            mapping[:filters][old_filter.id] = new_id\n\n            filters_to_insert << {\n              id: new_id,\n              account_id: account.id,\n              creator_id: mapping[:users][old_filter.creator_id],\n              params_digest: old_filter.params_digest,\n              fields: old_filter.fields,\n              created_at: old_filter.created_at,\n              updated_at: old_filter.updated_at\n            }\n          end\n\n          Filter.insert_all(filters_to_insert)\n        end\n\n        # Then, copy HABTM associations for each filter\n        import.filters.find_each do |old_filter|\n          new_filter = Filter.find(mapping[:filters][old_filter.id])\n\n          # Copy HABTM associations by finding valid mapped IDs first\n          assignee_ids = import.assignees_filters.where(filter_id: old_filter.id)\n            .filter_map { |join| mapping[:users][join.assignee_id] }\n          new_filter.assignee_ids = assignee_ids if assignee_ids.any?\n\n          creator_ids = import.creators_filters.where(filter_id: old_filter.id)\n            .filter_map { |join| mapping[:users][join.creator_id] }\n          new_filter.creator_ids = creator_ids if creator_ids.any?\n\n          closer_ids = import.closers_filters.where(filter_id: old_filter.id)\n            .filter_map { |join| mapping[:users][join.closer_id] }\n          new_filter.closer_ids = closer_ids if closer_ids.any?\n\n          board_ids = import.boards_filters.where(filter_id: old_filter.id)\n            .filter_map { |join| mapping[:boards][join.board_id] }\n          new_filter.board_ids = board_ids if board_ids.any?\n\n          tag_ids = import.filters_tags.where(filter_id: old_filter.id)\n            .filter_map { |join| mapping[:tags][join.tag_id] }\n          new_filter.tag_ids = tag_ids if tag_ids.any?\n        end\n\n        { count: mapping[:filters].size }\n      end\n    end\n\n    def copy_events\n      step(\"Copying events\", \"Copied %{count} events in %{duration}\") do\n        mapping[:events] ||= {}\n\n        import.events.in_batches(of: 1000) do |batch|\n          events_to_insert = []\n\n          batch.each do |old_event|\n            new_id = generate_uuid\n            mapping[:events][old_event.id] = new_id\n\n            events_to_insert << {\n              id: new_id,\n              account_id: account.id,\n              board_id: mapping[:boards][old_event.board_id],\n              creator_id: mapping[:users][old_event.creator_id],\n              eventable_type: old_event.eventable_type,\n              eventable_id: mapping[old_event.eventable_type.tableize.to_sym][old_event.eventable_id],\n              action: old_event.action,\n              particulars: old_event.particulars,\n              created_at: old_event.created_at,\n              updated_at: old_event.updated_at\n            }\n          end\n\n          Event.insert_all(events_to_insert)\n        end\n\n        { count: mapping[:events].size }\n      end\n    end\n\n    def copy_rich_text(old_record, new_record, record_type, name)\n      old_rich_text = import.rich_texts.find_by(record_type: record_type, record_id: old_record.id, name: name)\n      return unless old_rich_text\n\n      new_rich_text = ActionText::RichText.create!(\n        record: new_record,\n        name: name,\n        body: old_rich_text.body,\n        created_at: old_rich_text.created_at,\n        updated_at: old_rich_text.updated_at\n      )\n\n      mapping[:rich_text] ||= {}\n      mapping[:rich_text][old_rich_text.id] = new_rich_text.id\n\n      import.attachments.where(record_type: \"ActionText::RichText\", record_id: old_rich_text.id).each do |old_attachment|\n        copy_attachment(old_rich_text, new_rich_text, \"ActionText::RichText\", old_attachment.name)\n      end\n    end\n\n    def copy_attachment(old_record, new_record, record_type, name)\n      old_attachment = import.attachments.find_by(record_type: record_type, record_id: old_record.id, name: name)\n      return unless old_attachment\n\n      old_blob = import.blobs.find(old_attachment.blob_id)\n\n      new_blob = ActiveStorage::Blob.find_or_create_by!(key: old_blob.key) do |blob|\n        blob.filename = old_blob.filename\n        blob.content_type = old_blob.content_type\n        blob.metadata = old_blob.metadata\n        blob.service_name = old_blob.service_name\n        blob.byte_size = old_blob.byte_size\n        blob.checksum = old_blob.checksum\n        blob.created_at = old_blob.created_at\n      end\n\n      mapping[:blobs] ||= {}\n      mapping[:blobs][old_blob.id] = new_blob.id\n\n      # Copy variant records to prevent ActiveStorage from regenerating them\n      copy_variant_records(old_blob, new_blob)\n\n      new_attachment = ActiveStorage::Attachment.find_or_create_by!(\n        name: name,\n        record: new_record,\n        blob: new_blob,\n        created_at: old_attachment.created_at\n      )\n\n      mapping[:attachments] ||= {}\n      mapping[:attachments][old_attachment.id] = new_attachment.id\n    end\n\n    def copy_variant_records(old_blob, new_blob)\n      import.variant_records.where(blob_id: old_blob.id).each do |old_variant_record|\n        old_variant_blob = import.blobs.find_by(id: old_variant_record.id)\n        next unless old_variant_blob\n\n        new_variant_blob = ActiveStorage::Blob.find_or_create_by!(key: old_variant_blob.key) do |blob|\n          blob.filename = old_variant_blob.filename\n          blob.content_type = old_variant_blob.content_type\n          blob.metadata = old_variant_blob.metadata\n          blob.service_name = old_variant_blob.service_name\n          blob.byte_size = old_variant_blob.byte_size\n          blob.checksum = old_variant_blob.checksum\n          blob.created_at = old_variant_blob.created_at\n        end\n\n        mapping[:blobs] ||= {}\n        mapping[:blobs][old_variant_blob.id] = new_variant_blob.id\n\n        ActiveStorage::VariantRecord.find_or_create_by!(\n          id: new_variant_blob.id,\n          account_id: account.id,\n          blob_id: new_blob.id,\n          variation_digest: old_variant_record.variation_digest\n        )\n      end\n    end\n\n    def copy_reactions\n      step(\"Copying reactions\", \"Copied %{count} reactions in %{duration}\") do\n        mapping[:reactions] ||= {}\n        import.reactions.find_each do |old_reaction|\n          # Truncate content to 16 characters to match current column limit\n          content = old_reaction.content.truncate(16, omission: \"\")\n\n          new_reaction = Reaction.create!(\n            comment_id: mapping[:comments][old_reaction.comment_id],\n            reacter_id: mapping[:users][old_reaction.reacter_id],\n            content: content,\n            created_at: old_reaction.created_at,\n            updated_at: old_reaction.updated_at\n          )\n\n          mapping[:reactions][old_reaction.id] = new_reaction.id\n        end\n\n        { count: mapping[:reactions].size }\n      end\n    end\n\n    def copy_tags\n      step(\"Copying tags\", \"Copied %{tags} tags and %{taggings} taggings in %{duration}\") do\n        mapping[:tags] ||= {}\n        mapping[:taggings] ||= {}\n\n        import.tags.find_each do |old_tag|\n          new_tag = account.tags.find_or_create_by!(title: old_tag.title) do |t|\n            t.created_at = old_tag.created_at\n            t.updated_at = old_tag.updated_at\n          end\n\n          mapping[:tags][old_tag.id] = new_tag.id\n        end\n\n        import.taggings.find_each do |old_tagging|\n          new_tagging = Tagging.create!(\n            tag_id: mapping[:tags][old_tagging.tag_id],\n            card_id: mapping[:cards][old_tagging.card_id],\n            created_at: old_tagging.created_at,\n            updated_at: old_tagging.updated_at\n          )\n\n          mapping[:taggings][old_tagging.id] = new_tagging.id\n        end\n\n        { tags: mapping[:tags].size, taggings: mapping[:taggings].size }\n      end\n    end\n\n    def copy_watches\n      step(\"Copying watches\", \"Copied %{count} watches in %{duration}\") do\n        mapping[:watches] ||= {}\n\n        import.watches.in_batches(of: 1000) do |batch|\n          watches_to_insert = []\n\n          batch.each do |old_watch|\n            new_id = generate_uuid\n            mapping[:watches][old_watch.id] = new_id\n\n            watches_to_insert << {\n              id: new_id,\n              account_id: account.id,\n              user_id: mapping[:users][old_watch.user_id],\n              card_id: mapping[:cards][old_watch.card_id],\n              watching: old_watch.watching,\n              created_at: old_watch.created_at,\n              updated_at: old_watch.updated_at\n            }\n          end\n\n          Watch.insert_all(watches_to_insert)\n        end\n\n        { count: mapping[:watches].size }\n      end\n    end\n\n    def copy_pins\n      step(\"Copying pins\", \"Copied %{count} pins in %{duration}\") do\n        mapping[:pins] ||= {}\n\n        import.pins.in_batches(of: 1000) do |batch|\n          pins_to_insert = []\n\n          batch.each do |old_pin|\n            new_id = generate_uuid\n            mapping[:pins][old_pin.id] = new_id\n\n            pins_to_insert << {\n              id: new_id,\n              account_id: account.id,\n              user_id: mapping[:users][old_pin.user_id],\n              card_id: mapping[:cards][old_pin.card_id],\n              created_at: old_pin.created_at,\n              updated_at: old_pin.updated_at\n            }\n          end\n\n          Pin.insert_all(pins_to_insert)\n        end\n\n        { count: mapping[:pins].size }\n      end\n    end\n\n    def copy_webhooks\n      step(\"Copying webhooks\", \"Copied %{webhooks} webhooks and %{deliveries} deliveries in %{duration}\") do\n        mapping[:webhooks] ||= {}\n        mapping[:webhook_deliveries] ||= {}\n\n        import.webhooks.find_each do |old_webhook|\n          subscribed_actions = old_webhook.subscribed_actions\n          subscribed_actions = JSON.parse(subscribed_actions) if subscribed_actions.is_a?(String)\n\n          new_webhook = Webhook.create!(\n            account_id: account.id,\n            board_id: mapping[:boards][old_webhook.board_id],\n            name: old_webhook.name.truncate(255, omission: \"\"),\n            url: old_webhook.url,\n            signing_secret: old_webhook.signing_secret,\n            subscribed_actions: subscribed_actions,\n            active: old_webhook.active,\n            created_at: old_webhook.created_at,\n            updated_at: old_webhook.updated_at\n          )\n\n          mapping[:webhooks][old_webhook.id] = new_webhook.id\n\n          old_tracker = import.webhook_delinquency_trackers.find_by(webhook_id: old_webhook.id)\n          if old_tracker\n            Webhook::DelinquencyTracker.find_or_create_by!(webhook_id: new_webhook.id) do |tracker|\n              tracker.consecutive_failures_count = old_tracker.consecutive_failures_count\n              tracker.first_failure_at = old_tracker.first_failure_at\n              tracker.created_at = old_tracker.created_at\n              tracker.updated_at = old_tracker.updated_at\n            end\n          end\n        end\n\n        import.webhook_deliveries.find_each do |old_delivery|\n          new_delivery = Webhook::Delivery.create!(\n            webhook_id: mapping[:webhooks][old_delivery.webhook_id],\n            event_id: mapping[:events][old_delivery.event_id],\n            state: old_delivery.state,\n            request: old_delivery.request,\n            response: old_delivery.response,\n            created_at: old_delivery.created_at,\n            updated_at: old_delivery.updated_at\n          )\n\n          mapping[:webhook_deliveries][old_delivery.id] = new_delivery.id\n        end\n\n        { webhooks: mapping[:webhooks].size, deliveries: mapping[:webhook_deliveries].size }\n      end\n    end\n\n    def copy_push_subscriptions\n      step(\"Copying push subscriptions\", \"Copied %{count} push subscriptions in %{duration}\") do\n        mapping[:push_subscriptions] ||= {}\n\n        import.push_subscriptions.find_each do |old_subscription|\n          new_subscription = Push::Subscription.create!(\n            account_id: account.id,\n            user_id: mapping[:users][old_subscription.user_id],\n            endpoint: old_subscription.endpoint,\n            p256dh_key: old_subscription.p256dh_key,\n            auth_key: old_subscription.auth_key,\n            user_agent: old_subscription.user_agent,\n            created_at: old_subscription.created_at,\n            updated_at: old_subscription.updated_at\n          )\n\n          mapping[:push_subscriptions][old_subscription.id] = new_subscription.id\n        end\n\n        { count: mapping[:push_subscriptions].size }\n      end\n    end\n\n    def fix_links\n      step(\"Fixing links\", \"Fixed %{count} links in %{duration}\") do\n        mapping[:fixed_links] ||= {}\n\n        ActionText::RichText.where(id: mapping[:rich_text]&.values).find_each do |rich_text|\n          fragment = rich_text.body.fragment\n          fixed_link = false\n\n          fragment.find_all(\"a[href]\").each do |link|\n            url = link[\"href\"]\n            uri = URI.parse(url) rescue nil\n\n            if uri\n              uri.host = FIX_LINK_HOSTS[uri.host] if uri.absolute? && FIX_LINK_HOSTS.key?(uri.host)\n              params = Rails.application.routes.recognize_path(uri.path) rescue {}\n\n              if params[:controller] == \"cards\" && params[:action] == \"show\" && params[:id] && mapping[:cards][params[:id].to_i]\n                uri.path = Rails.application.routes.url_helpers.card_path(mapping[:cards][params[:id].to_i])\n              elsif params[:controller] == \"boards\" && params[:action] == \"show\" && params[:id] && mapping[:boards][params[:id].to_i]\n                uri.path = Rails.application.routes.url_helpers.board_path(mapping[:boards][params[:id].to_i])\n              end\n\n              link[\"href\"] = uri.to_s\n              mapping[:fixed_links][url] = link[\"href\"]\n              fixed_link = true\n            end\n          end\n\n          rich_text.update!(body: fragment.to_html) if fixed_link\n        end\n\n        { count: mapping[:fixed_links].size }\n      end\n    end\n\n    def import\n      @import ||= Models.new(db_path)\n    rescue => e\n      $stderr.puts e.backtrace.join(\"\\n\") if ENV[\"DEBUG\"]\n      raise \"Couldn't open the given database: #{e}\"\n    end\n\n    def untenanted\n      @untenanted ||= Models.new(untenanted_db_path)\n    rescue => e\n      $stderr.puts e.backtrace.join(\"\\n\") if ENV[\"DEBUG\"]\n      raise \"Couldn't open the given untenanted database: #{e}\"\n    end\nend\n\nclass Models\n  attr_reader :application_record\n\n  def initialize(db_path)\n    const_name = \"ImportBase#{db_path.hash.abs}\"\n\n    if self.class.const_defined?(const_name)\n      @application_record = self.class.const_get(const_name)\n    else\n      @application_record = Class.new(ActiveRecord::Base) do\n        self.abstract_class = true\n\n        def self.models\n          const_get(\"MODELS\")\n        end\n\n        delegate :models, to: :class\n      end\n      self.class.const_set(const_name, @application_record)\n    end\n\n    @application_record.establish_connection adapter: \"sqlite3\", database: db_path\n    @application_record.const_set(\"MODELS\", self)\n  end\n\n  def identities\n    @identities ||= Class.new(application_record) do\n      self.table_name = \"identities\"\n    end\n  end\n\n  def memberships\n    @memberships ||= begin\n      models = self\n      Class.new(application_record) do\n        self.table_name = \"memberships\"\n\n        def identity\n          @identity ||= models.identities.find_by(id: identity_id)\n        end\n      end\n    end\n  end\n\n  def accounts\n    @accounts ||= Class.new(application_record) do\n      self.table_name = \"accounts\"\n    end\n  end\n\n  def account_join_codes\n    @account_join_codes ||= Class.new(application_record) do\n      self.table_name = \"account_join_codes\"\n    end\n  end\n\n  def users\n    @users ||= begin\n      models = self\n      Class.new(application_record) do\n        self.table_name = \"users\"\n\n        def settings\n          @settings ||= models.user_settings.find_by(user_id: id)\n        end\n      end\n    end\n  end\n\n  def boards\n    @boards ||= begin\n      models = self\n      Class.new(application_record) do\n        self.table_name = \"boards\"\n\n        def publication\n          @publication ||= models.board_publications.find_by(board_id: id)\n        end\n      end\n    end\n  end\n\n  def columns\n    @columns ||= Class.new(application_record) do\n      self.table_name = \"columns\"\n    end\n  end\n\n  def cards\n    @cards ||= begin\n      models = self\n      Class.new(application_record) do\n        self.table_name = \"cards\"\n\n        def activity_spike\n          @activity_spike ||= models.card_activity_spikes.find_by(card_id: id)\n        end\n\n        def engagement\n          @engagement ||= models.card_engagements.find_by(card_id: id)\n        end\n\n        def goldness\n          @goldness ||= models.card_goldnesses.find_by(card_id: id)\n        end\n\n        def not_now\n          @not_now ||= models.card_not_nows.find_by(card_id: id)\n        end\n\n        def assignments\n          models.assignments.where(card_id: id)\n        end\n\n        def closure\n          @closure ||= models.closures.find_by(card_id: id)\n        end\n      end\n    end\n  end\n\n  def comments\n    @comments ||= Class.new(application_record) do\n      self.table_name = \"comments\"\n    end\n  end\n\n  def steps\n    @steps ||= Class.new(application_record) do\n      self.table_name = \"steps\"\n    end\n  end\n\n  def reactions\n    @reactions ||= Class.new(application_record) do\n      self.table_name = \"reactions\"\n    end\n  end\n\n  def tags\n    @tags ||= Class.new(application_record) do\n      self.table_name = \"tags\"\n    end\n  end\n\n  def taggings\n    @taggings ||= Class.new(application_record) do\n      self.table_name = \"taggings\"\n    end\n  end\n\n  def watches\n    @watches ||= Class.new(application_record) do\n      self.table_name = \"watches\"\n    end\n  end\n\n  def pins\n    @pins ||= Class.new(application_record) do\n      self.table_name = \"pins\"\n    end\n  end\n\n  def webhooks\n    @webhooks ||= Class.new(application_record) do\n      self.table_name = \"webhooks\"\n    end\n  end\n\n  def webhook_deliveries\n    @webhook_deliveries ||= Class.new(application_record) do\n      self.table_name = \"webhook_deliveries\"\n    end\n  end\n\n  def webhook_delinquency_trackers\n    @webhook_delinquency_trackers ||= Class.new(application_record) do\n      self.table_name = \"webhook_delinquency_trackers\"\n    end\n  end\n\n  def push_subscriptions\n    @push_subscriptions ||= Class.new(application_record) do\n      self.table_name = \"push_subscriptions\"\n    end\n  end\n\n  def assignments\n    @assignments ||= Class.new(application_record) do\n      self.table_name = \"assignments\"\n    end\n  end\n\n  def closures\n    @closures ||= Class.new(application_record) do\n      self.table_name = \"closures\"\n    end\n  end\n\n  def accesses\n    @accesses ||= Class.new(application_record) do\n      self.table_name = \"accesses\"\n    end\n  end\n\n  def events\n    @events ||= Class.new(application_record) do\n      self.table_name = \"events\"\n    end\n  end\n\n  def rich_texts\n    @rich_texts ||= Class.new(application_record) do\n      self.table_name = \"action_text_rich_texts\"\n    end\n  end\n\n  def attachments\n    @attachments ||= Class.new(application_record) do\n      self.table_name = \"active_storage_attachments\"\n    end\n  end\n\n  def blobs\n    @blobs ||= Class.new(application_record) do\n      self.table_name = \"active_storage_blobs\"\n    end\n  end\n\n  def variant_records\n    @variant_records ||= Class.new(application_record) do\n      self.table_name = \"active_storage_variant_records\"\n    end\n  end\n\n  def user_settings\n    @user_settings ||= Class.new(application_record) do\n      self.table_name = \"user_settings\"\n    end\n  end\n\n  def board_publications\n    @board_publications ||= Class.new(application_record) do\n      self.table_name = \"board_publications\"\n    end\n  end\n\n  def card_activity_spikes\n    @card_activity_spikes ||= Class.new(application_record) do\n      self.table_name = \"card_activity_spikes\"\n    end\n  end\n\n  def card_engagements\n    @card_engagements ||= Class.new(application_record) do\n      self.table_name = \"card_engagements\"\n    end\n  end\n\n  def card_goldnesses\n    @card_goldnesses ||= Class.new(application_record) do\n      self.table_name = \"card_goldnesses\"\n    end\n  end\n\n  def card_not_nows\n    @card_not_nows ||= Class.new(application_record) do\n      self.table_name = \"card_not_nows\"\n    end\n  end\n\n  def mentions\n    @mentions ||= Class.new(application_record) do\n      self.table_name = \"mentions\"\n    end\n  end\n\n  def notifications\n    @notifications ||= Class.new(application_record) do\n      self.table_name = \"notifications\"\n    end\n  end\n\n  def notification_bundles\n    @notification_bundles ||= Class.new(application_record) do\n      self.table_name = \"notification_bundles\"\n    end\n  end\n\n  def entropies\n    @entropies ||= Class.new(application_record) do\n      self.table_name = \"entropies\"\n    end\n  end\n\n  def filters\n    @filters ||= Class.new(application_record) do\n      self.table_name = \"filters\"\n    end\n  end\n\n  def assignees_filters\n    @assignees_filters ||= Class.new(application_record) do\n      self.table_name = \"assignees_filters\"\n    end\n  end\n\n  def assigners_filters\n    @assigners_filters ||= Class.new(application_record) do\n      self.table_name = \"assigners_filters\"\n    end\n  end\n\n  def boards_filters\n    @boards_filters ||= Class.new(application_record) do\n      self.table_name = \"boards_filters\"\n    end\n  end\n\n  def closers_filters\n    @closers_filters ||= Class.new(application_record) do\n      self.table_name = \"closers_filters\"\n    end\n  end\n\n  def creators_filters\n    @creators_filters ||= Class.new(application_record) do\n      self.table_name = \"creators_filters\"\n    end\n  end\n\n  def filters_tags\n    @filters_tags ||= Class.new(application_record) do\n      self.table_name = \"filters_tags\"\n    end\n  end\nend\n\noptions = {\n  skip_already_imported: false\n}\n\nparser = OptionParser.new do |parser|\n  parser.banner = \"Usage: #{$PROGRAM_NAME} [options] <tenanted_db_path>...\"\n\n  parser.on(\"--untenanted-db-path PATH\", \"Path to the untenanted database\") do |path|\n    options[:untenanted_db_path] = path\n  end\n\n  parser.on(\"--skip-already-imported\", \"Skip import if account already exists\") do\n    options[:skip_already_imported] = true\n  end\n\n  parser.on(\"-h\", \"--help\", \"Show this help message\") do\n    puts parser\n    exit\n  end\nend\n\nparser.parse!\n\nuntenanted_db_path = options[:untenanted_db_path]\ntenanted_db_paths = ARGV\n\nif untenanted_db_path.nil?\n  $stderr.puts \"Error: --untenanted-db-path is required\"\n  $stderr.puts\n  $stderr.puts parser\n  exit 1\nend\n\nif tenanted_db_paths.empty?\n  $stderr.puts \"Error: at least one tenanted database path is required\"\n  $stderr.puts\n  $stderr.puts parser\n  exit 1\nend\n\ntotal_imported = 0\n\nduration = ActiveSupport::Benchmark.realtime do\n  tenanted_db_paths.each_with_index do |db_path, index|\n    puts\n    puts \"=\"*80\n    puts \"Processing database #{index + 1}/#{tenanted_db_paths.size}: #{db_path}\"\n    puts \"=\"*80\n\n    Import.new(db_path, untenanted_db_path, skip_already_imported: options[:skip_already_imported]).import_database\n    total_imported += 1\n  end\nend\n\nputs\nputs \"=\"*80\nputs \"Summary:\"\nputs \"  Imported: #{total_imported}\"\nputs \"  Total time: #{duration.round(2)} seconds\"\nputs \"=\"*80\n"
  },
  {
    "path": "script/load-prod-db-in-dev.rb",
    "content": "#!/usr/bin/env ruby\n\nif ARGV.length != 1\n  puts \"Usage: #{$0} <dbfile>\"\n  exit 1\nend\noriginal_dbfile = ARGV[0]\n\nrequire \"securerandom\"\nidentifier = SecureRandom.hex(4)\n\n# run a process to run the migration and dump the schema cache\nProcess.fork do\n  require_relative \"../config/environment\"\n\n  unless Rails.env.local?\n    abort \"This script should only be run in a local development environment.\"\n  end\n\n  tenant = ActiveRecord::FixtureSet.identify(identifier)\n\n  config = ApplicationRecord.tenanted_root_config\n  path = config.config_adapter.path_for(config.database_for(tenant))\n  FileUtils.mkdir_p(File.dirname(path), verbose: true)\n  FileUtils.cp original_dbfile, path, verbose: true\n\n  puts \"Running migrations...\"\n  system \"bin/rails db:migrate\"\nend\nProcess.wait\n\n# now load the schema cache and do what we need to do in the database\nrequire_relative \"../config/environment\"\n\ntenant = ActiveRecord::FixtureSet.identify(identifier)\n\nApplicationRecord.with_tenant(tenant) do |tenant|\n  Current.account.destroy!\n\n  Account.create_with_owner \\\n    account: { name: \"Company #{identifier}\" },\n    owner: { name: \"Developer #{identifier}\", email_address: \"dev-#{identifier}@example.com\" }\n\n  user = User.find_by(role: :owner)\n  identity = Identity.find_or_create_by(email_address: user.email_address)\n  identity.link_to(user.tenant)\n  Board.find_each do |board|\n    board.accesses.grant_to(user)\n  end\n\n  url = Rails.application.routes.url_helpers.root_url(Rails.application.config.action_controller.default_url_options.merge(script_name: Current.account.slug))\n  puts \"\\n\\nLogin to #{url} as #{user.email_address} / secret123456\"\nend\n"
  },
  {
    "path": "script/maintenance/fix_cross_account_taggings.rb",
    "content": "#!/usr/bin/env ruby\n\nrequire_relative \"../../config/environment\"\n\ncross_account_taggings = Tagging.joins(:tag).where(\"taggings.account_id != tags.account_id\")\n\nputs \"Found #{cross_account_taggings.count} cross-account taggings to fix\"\n\ncross_account_taggings.find_each do |tagging|\n  correct_tag = tagging.account.tags.find_or_create_by!(title: tagging.tag.title)\n  tagging.update!(tag: correct_tag)\n  puts \"Fixed tagging #{tagging.id}: reassigned to tag #{correct_tag.id}\"\nend\n\nputs \"Done!\"\n"
  },
  {
    "path": "script/maintenance/remove_duplicated_search_queries.rb",
    "content": "#!/usr/bin/env ruby\n\nrequire_relative \"../config/environment\"\n\nApplicationRecord.with_each_tenant do |tenant|\n  User.find_each do |user|\n    search_queries = Set.new\n    to_delete = []\n    user.search_queries.find_each do |search_query|\n      if search_queries.include?(search_query.terms)\n        to_delete << search_query\n      end\n\n      search_queries << search_query.terms\n    end\n\n    to_delete.each(&:destroy)\n  end\nend\n"
  },
  {
    "path": "script/maintenance/remove_duplicated_tags.rb",
    "content": "#!/usr/bin/env ruby\n\nrequire_relative \"../config/environment\"\n\nApplicationRecord.with_each_tenant do |tenant|\n  Account.find_each do |account|\n    tags_grouped_by_title = account.tags.group_by { |tag| tag.title.downcase }\n\n    tags_grouped_by_title.each do |title, tags|\n      if tags.length > 1\n        to_keep, to_merge = tags.first, tags[1..]\n\n        to_merge.each do |tag_to_merge|\n          tag_to_merge.cards.each do |card|\n            to_keep.cards << card unless to_keep.cards.include?(card)\n          end\n\n          tag_to_merge.destroy\n        end\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "script/migrations/20250924-populate-identities.rb",
    "content": "#!/usr/bin/env ruby\n\nrequire_relative \"../../config/environment\"\n\nApplicationRecord.with_each_tenant do |tenant|\n  puts \"# #{tenant}\"\n  User.find_each do |user|\n    next if user.system? || !user.active?\n\n    if user.membership.present?\n      puts \"Found identity #{user.identity.id} for user #{user.id} (#{user.email_address})\"\n    else\n      memberships = Membership.where(email_address: user.email_address)\n      if memberships.empty?\n        # Create a new Identity\n        Identity.transaction do\n          identity = Identity.create!\n          user.membership = identity.memberships.create!(user_id: user.id, user_tenant: user.tenant, email_address: user.email_address, account_name: Current.account.name)\n          puts \"Created identity #{identity.id} for user #{user.id} (#{user.email_address})\"\n        end\n      else\n        # Merge this User's Membership into the existing Identity\n        identity = memberships.first.identity\n        user.membership = identity.memberships.create!(user_id: user.id, user_tenant: user.tenant, email_address: user.email_address, account_name: Current.account.name)\n        puts \"Merged membership for user #{user.id} (#{user.email_address}) into identity #{identity.id}\"\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "script/migrations/20251028-populate_membership_id_on_users.rb",
    "content": "#!/usr/bin/env ruby\n\nrequire_relative \"../../config/environment\"\n\nApplicationRecord.with_each_tenant do |tenant|\n  puts \"🏢 #{tenant}\"\n  User.find_each do |user|\n    next if user.system? || !user.active?\n\n    if user.membership.present?\n      puts \"✅ User #{user.id} has a membership\"\n    else\n      puts \"⏩ Creating membership for user #{user.id}\"\n\n      identity = Identity.find_or_create_by(email_address: user.email_address)\n      membership = identity.memberships.find_or_create_by(tenant: tenant)\n      user.update_columns(membership_id: membership.id)\n    end\n  end\nend\n"
  },
  {
    "path": "script/migrations/20251029-populate-column-positions.rb",
    "content": "#!/usr/bin/env ruby\n\nrequire_relative \"../../config/environment\"\n\nApplicationRecord.with_each_tenant do |tenant|\n  puts \"Processing tenant: #{tenant}\"\n\n  Board.find_each do |board|\n    puts \"  Processing board: #{board.name} (ID: #{board.id})\"\n\n    columns = board.columns.order(:id)\n\n    columns.each_with_index do |column, index|\n      column.update_column(:position, index)\n      puts \"    Set position #{index} for column '#{column.name}' (ID: #{column.id})\"\n    end\n  end\nend\n\nputs \"Migration completed!\"\n"
  },
  {
    "path": "script/migrations/20251205-backfill-verified-at.rb",
    "content": "#!/usr/bin/env ruby\n\nrequire_relative \"../../config/environment\"\n\nBACKFILL_TIMESTAMP = Time.parse(\"2025-12-02 12:00:00 UTC\")\n\ndef collect_verified_user_ids\n  verified_ids = Set.new\n\n  # Owners (they created the account)\n  verified_ids.merge(User.where(role: :owner).pluck(:id))\n  puts \"After owners: #{verified_ids.size} users\"\n\n  # Card creators\n  verified_ids.merge(Card.distinct.pluck(:creator_id).compact)\n  puts \"After card creators: #{verified_ids.size} users\"\n\n  # Comment creators\n  verified_ids.merge(Comment.distinct.pluck(:creator_id).compact)\n  puts \"After comment creators: #{verified_ids.size} users\"\n\n  # Board creators\n  verified_ids.merge(Board.distinct.pluck(:creator_id).compact)\n  puts \"After board creators: #{verified_ids.size} users\"\n\n  # Event creators\n  verified_ids.merge(Event.distinct.pluck(:creator_id).compact)\n  puts \"After event creators: #{verified_ids.size} users\"\n\n  # Assigners (not assignees - they could be assigned without logging in)\n  verified_ids.merge(Assignment.distinct.pluck(:assigner_id).compact)\n  puts \"After assigners: #{verified_ids.size} users\"\n\n  # Manual closers (user_id is nil for automatic closures)\n  verified_ids.merge(Closure.where.not(user_id: nil).distinct.pluck(:user_id).compact)\n  puts \"After closers: #{verified_ids.size} users\"\n\n  # Manual postponers (user_id is nil for automatic entropy postponements)\n  verified_ids.merge(Card::NotNow.where.not(user_id: nil).distinct.pluck(:user_id).compact)\n  puts \"After postponers: #{verified_ids.size} users\"\n\n  # Reactors\n  verified_ids.merge(Reaction.distinct.pluck(:reacter_id).compact)\n  puts \"After reactors: #{verified_ids.size} users\"\n\n  # Filter creators\n  verified_ids.merge(Filter.distinct.pluck(:creator_id).compact)\n  puts \"After filter creators: #{verified_ids.size} users\"\n\n  # Pinners\n  verified_ids.merge(Pin.distinct.pluck(:user_id).compact)\n  puts \"After pinners: #{verified_ids.size} users\"\n\n  # Board accessors (accessed_at is touched when viewing boards)\n  verified_ids.merge(Access.where.not(accessed_at: nil).distinct.pluck(:user_id).compact)\n  puts \"After board accessors: #{verified_ids.size} users\"\n\n  # Export requesters\n  verified_ids.merge(Account::Export.distinct.pluck(:user_id).compact)\n  puts \"After export requesters: #{verified_ids.size} users\"\n\n  # Push subscribers\n  verified_ids.merge(Push::Subscription.distinct.pluck(:user_id).compact)\n  puts \"After push subscribers: #{verified_ids.size} users\"\n\n  # Users who completed setup (name != email)\n  verified_ids.merge(\n    User.joins(:identity)\n        .where.not(\"users.name = identities.email_address\")\n        .pluck(:id)\n  )\n  puts \"After setup completers: #{verified_ids.size} users\"\n\n  # Users whose identity has at least one session\n  verified_ids.merge(\n    User.where(identity_id: Session.distinct.select(:identity_id)).pluck(:id)\n  )\n  puts \"After identity sessions: #{verified_ids.size} users\"\n\n  verified_ids\nend\n\nputs \"Collecting verified user IDs...\"\nverified_user_ids = collect_verified_user_ids\n\nputs \"\\nFiltering to unverified users only...\"\nusers_to_update = User.where(id: verified_user_ids.to_a)\n                      .where(verified_at: nil)\n                      .where(active: true)\n                      .where.not(identity_id: nil)\n                      .where.not(role: :system)\n\nupdate_count = users_to_update.count\nputs \"Found #{update_count} users to backfill\"\n\n# Report remaining unverified users (before update)\nremaining_before = User.where(verified_at: nil, active: true)\n                       .where.not(identity_id: nil)\n                       .where.not(role: :system)\n                       .count\nremaining_after = remaining_before - update_count\nputs \"\\nCurrently unverified active users: #{remaining_before}\"\nputs \"After backfill, remaining unverified: #{remaining_after}\"\nputs \"These users will need to verify on next login.\"\n\nif update_count > 0\n  puts \"\\nBackfilling verified_at...\"\n  updated = users_to_update.update_all(verified_at: BACKFILL_TIMESTAMP)\n  puts \"Updated #{updated} users\"\nend\n\nputs \"\\nDone!\"\n"
  },
  {
    "path": "script/migrations/20260123-remove-draft-cards-from-search-index.rb",
    "content": "#!/usr/bin/env ruby\n\nrequire_relative \"../../config/environment\"\n\ntotal_deleted = 0\n\nAccount.find_each do |account|\n  search_record_class = Search::Record.for(account.id)\n\n  # Find search records for draft cards (both Card and Comment searchables)\n  draft_card_ids = Card.where(account_id: account.id, status: \"drafted\").pluck(:id)\n\n  if draft_card_ids.any?\n    count = search_record_class.where(card_id: draft_card_ids).delete_all\n    if count > 0\n      puts \"#{account.name}: deleted #{count} search records for draft cards\"\n      total_deleted += count\n    end\n  end\nend\n\nputs \"Migration completed! Total deleted: #{total_deleted}\"\n"
  },
  {
    "path": "script/migrations/20260204-fix-misplaced-comment-events.rb",
    "content": "# Fix comment events that are on the wrong board after a card move.\n#\n# See https://github.com/basecamp/fizzy/pull/2486\n#\n# Usage:\n#   bin/rails runner script/migrations/20260204-fix-misplaced-comment-events.rb           # dry run\n#   bin/rails runner script/migrations/20260204-fix-misplaced-comment-events.rb --fix     # actually fix\n#\ndry_run = !ARGV.include?(\"--fix\")\n\nputs dry_run ? \"DRY RUN - no changes will be made\\n\\n\" : \"FIXING misplaced events\\n\\n\"\n\nmisplaced_events = Event\n  .where(eventable_type: \"Comment\")\n  .joins(\"INNER JOIN comments ON comments.id = events.eventable_id\")\n  .joins(\"INNER JOIN cards ON cards.id = comments.card_id\")\n  .where(\"events.board_id != cards.board_id\")\n\ntotal = misplaced_events.count\nputs \"Found #{total} misplaced comment events\\n\\n\"\n\nif total.zero?\n  puts \"Nothing to fix!\"\n  exit\nend\n\nfixed = 0\nskipped = 0\n\nmisplaced_events.find_each.with_index do |event, index|\n  comment = event.eventable\n  card = comment&.card\n  old_board = event.board\n  new_board = card&.board\n\n  puts \"[#{index + 1}/#{total}] Event #{event.id}\"\n\n  if card.nil? || new_board.nil?\n    puts \"  Skipping - orphaned data (comment or card deleted)\"\n    skipped += 1\n    puts\n    next\n  end\n\n  puts \"  Card ##{card.number}: #{card.title.truncate(40)}\"\n  puts \"  Moving from board '#{old_board&.name || 'nil'}' to '#{new_board.name}'\"\n\n  if dry_run\n    puts \"  (skipped - dry run)\"\n  else\n    event.update!(board: new_board)\n    fixed += 1\n    puts \"  Fixed!\"\n  end\n\n  puts\nend\n\nputs \"Done. #{dry_run ? \"Run with --fix to apply changes.\" : \"Fixed #{fixed} events.\"} (#{skipped} skipped)\"\n"
  },
  {
    "path": "script/migrations/backfill-storage-ledger.rb",
    "content": "#!/usr/bin/env ruby\n\n# Backfill storage ledger with attach entries for all existing attachments.\n#\n# Run locally:\n#   bin/rails runner script/migrations/backfill-storage-ledger.rb\n#\n# Run via Kamal:\n#   kamal app exec -d <stage> -p --reuse \"bin/rails runner script/migrations/backfill-storage-ledger.rb\"\n#\n# Safe to re-run: skips attachments that already have entries (by blob_id + recordable).\n#\n# OPTIONAL: If you want to enforce no-reuse for direct attachments, verify there are\n# no existing violations (ActionText embeds may legitimately reuse blobs):\n#\n#   ActiveStorage::Attachment\n#     .joins(:blob)\n#     .where(record_type: Storage::TRACKED_RECORD_TYPES)\n#     .where.not(record_type: \"ActionText::RichText\")\n#     .where.not(active_storage_blobs: { account_id: Storage::TEMPLATE_ACCOUNT_ID })\n#     .select(:blob_id)\n#     .group(:blob_id)\n#     .having(\"COUNT(*) > 1\")\n#     .count\n#   # Should return empty hash if no direct-attachment reuse exists\n#\n# If reuse exists (excluding template blobs), fix the data first.\nclass BackfillStorageLedger\n  def run\n    puts \"Backfilling storage entries…\"\n    backfill_entries\n\n    puts \"\\nMaterializing totals…\"\n    materialize_totals\n  end\n\n  private\n    def backfill_entries\n      created = 0\n      skipped = 0\n\n      ActiveStorage::Attachment.includes(:blob).find_each do |attachment|\n        record = attachment.record.try(:storage_tracked_record)\n\n        # Backfill creates one entry PER ATTACHMENT (not per blob) to match the ledger model.\n        # Storage tracking is a business abstraction at the attachment level.\n        # IMPORTANT: This assumes no historic blob reuse. Run pre-check query above first.\n        if record.nil? || Storage::Entry.exists?(blob_id: attachment.blob_id, recordable: record)\n          skipped += 1\n          next\n        end\n\n        Storage::Entry.create! \\\n          account_id: record.account.id,\n          board_id: record.board_for_storage_tracking&.id,\n          recordable_type: record.class.name,\n          recordable_id: record.id,\n          blob_id: attachment.blob_id,\n          delta: attachment.blob.byte_size,\n          operation: \"attach\"\n        created += 1\n\n        print \".\" if created % 100 == 0\n      end\n\n      puts \"\\n\\nBackfill complete!\"\n      puts \"  Entries created: #{created}\"\n      puts \"  Attachments skipped: #{skipped}\"\n    end\n\n    def materialize_totals\n      boards_materialized = 0\n      accounts_materialized = 0\n\n      Board.find_each do |board|\n        board.materialize_storage\n        boards_materialized += 1\n        print \".\" if boards_materialized % 100 == 0\n      end\n\n      Account.find_each do |account|\n        account.materialize_storage\n        accounts_materialized += 1\n      end\n\n      puts \"\\n\\nMaterialization complete!\"\n      puts \"  Boards: #{boards_materialized}\"\n      puts \"  Accounts: #{accounts_materialized}\"\n    end\nend\n\nBackfillStorageLedger.new.run\n"
  },
  {
    "path": "script/migrations/convert-absolute-attachment-urls-to-relative.rb",
    "content": "#!/usr/bin/env ruby\n\n# Convert absolute attachment URLs in rich text content to relative paths.\n# This fixes URLs that were stored with full hostnames (e.g., https://app.fizzy.do/...)\n# making them portable across beta environments and host changes.\n#\n# MUST BE RUN AFTER `decrypt!` when using ActiveRecord Encryption\n#\n# Run locally:\n#   bin/rails runner script/migrations/convert-absolute-attachment-urls-to-relative.rb --help\n#\n# Run via Kamal:\n#   kamal app exec -d <stage> -p --reuse \"bin/rails runner script/migrations/convert-absolute-attachment-urls-to-relative.rb --help\"\n#\n# Safe to re-run: won't modify already-relative URLs\n\nclass ConvertAbsoluteAttachmentUrlsToRelative\n  # Match absolute URLs pointing to Active Storage routes, keeping the account slug\n  ABSOLUTE_URL_PATTERN = %r{https?://[^/]+(/\\d+/rails/active_storage/[^\"']+)}\n\n  attr_reader :account, :dry_run\n\n  def initialize(account_id: nil, dry_run: false)\n    @account = Account.find_by(external_account_id: account_id)\n    @dry_run = dry_run\n  end\n\n  def run\n    puts \"Converting absolute attachment URLs to relative paths\"\n    puts dry_run ? \"DRY RUN MODE - no changes will be saved\" : \"LIVE MODE - changes will be saved\"\n    puts account ? \"Only account: #{account.external_account_id} - #{account.name}\" : \"For **ALL ACCOUNTS**\"\n\n    puts \"\\nPress ENTER to continue running or CTRL-C to bail...\"\n    gets\n\n    puts \"\\nRunning...\"\n\n    # Suppress SQL logs\n    Rails.event.debug_mode = false\n\n    seconds = Benchmark.realtime do\n      suppressing_turbo_broadcasts do\n        convert_urls\n      end\n    end\n\n    puts \"\\n\\n\"\n    puts \"Finished in %.2f seconds.\" % seconds\n  end\n\n  private\n    def suppressing_turbo_broadcasts\n      Board.suppressing_turbo_broadcasts do\n        Card.suppressing_turbo_broadcasts do\n          yield\n        end\n      end\n    end\n\n    def convert_urls\n      scanned = 0\n      fixed = 0\n      urls_converted = 0\n\n      action_texts_scope.find_each do |rich_text|\n        scanned += 1\n\n        body = rich_text.body\n\n        edited = false\n        conversions = 0\n\n        body.send(:attachment_nodes).each do |node|\n          url = node[\"url\"]\n          next unless url\n\n          if url.match?(ABSOLUTE_URL_PATTERN)\n            node[\"url\"] = url.gsub(ABSOLUTE_URL_PATTERN, '\\1')\n            edited = true\n            conversions += 1\n          end\n        end\n\n        if edited\n          record = rich_text.record\n          puts \" - modifying #{record.class.name} #{record.to_param} (account: #{record.account&.external_account_id}) - #{conversions} URL(s)\"\n\n          unless dry_run\n            rich_text.update! body: body.fragment.to_html\n          end\n\n          fixed += 1\n          urls_converted += conversions\n        end\n      end\n\n      puts \"\\n\\nConversion complete!\"\n      puts \"  Rich texts examined: #{scanned}\"\n      puts \"  Rich texts modified: #{fixed}\"\n      puts \"  URLs converted: #{urls_converted}\"\n    end\n\n    def action_texts_scope\n      # Only examine rich texts that have embedded attachments\n      scope = ActionText::RichText.joins(:embeds_attachments)\n      scope = scope.where(account: account) if account\n      scope\n    end\nend\n\nrequire \"optparse\"\n\noptions = { account_id: nil, dry_run: true }\n\nOptionParser.new do |opts|\n  opts.banner = \"Usage: bin/rails runner #{__FILE__} [options]\"\n\n  opts.on(\"-a\", \"--account ACCOUNT_ID\", \"Restrict to a specific account (external_account_id)\") do |id|\n    options[:account_id] = id\n  end\n\n  opts.on(\"--[no-]dry-run\", \"Run in dry-run mode (default: --dry-run)\") do |v|\n    options[:dry_run] = v\n  end\n\n  opts.on(\"-h\", \"--help\", \"Show this help message\") do\n    puts opts\n    exit\n  end\nend.parse!\n\nConvertAbsoluteAttachmentUrlsToRelative.new(**options).run\n"
  },
  {
    "path": "script/migrations/convert-relative-attachment-urls-to-absolute.rb",
    "content": "#!/usr/bin/env ruby\n\n# Rollback script: Convert relative attachment URLs back to absolute URLs.\n# Use this if you need to rollback the relative URL changes and want to\n# convert rich texts created during the rollout back to absolute URLs.\n#\n# Run locally:\n#   bin/rails runner script/migrations/convert-relative-attachment-urls-to-absolute.rb --help\n#\n# Run via Kamal:\n#   kamal app exec -d <stage> -p --reuse \"bin/rails runner script/migrations/convert-relative-attachment-urls-to-absolute.rb --help\"\n\nclass ConvertRelativeAttachmentUrlsToAbsolute\n  # Match relative URLs pointing to Active Storage routes (with account slug)\n  RELATIVE_URL_PATTERN = %r{\\A(/\\d+/rails/active_storage/[^\"']+)\\z}\n\n  attr_reader :host, :since\n\n  def initialize(host:, since:)\n    @host = host\n    @since = since\n  end\n\n  def run\n    puts \"Converting relative attachment URLs to absolute URLs\"\n    puts \"Host: #{host}\"\n    puts \"Processing rich texts created since: #{since}\"\n\n    puts \"\\nPress ENTER to continue running or CTRL-C to bail...\"\n    gets\n\n    puts \"\\nRunning...\"\n\n    seconds = Benchmark.realtime do\n      suppressing_turbo_broadcasts do\n        convert_urls\n      end\n    end\n\n    puts \"\\n\\n\"\n    puts \"Finished in %.2f seconds.\" % seconds\n  end\n\n  private\n    def suppressing_turbo_broadcasts\n      Board.suppressing_turbo_broadcasts do\n        Card.suppressing_turbo_broadcasts do\n          yield\n        end\n      end\n    end\n\n    def convert_urls\n      scanned = 0\n      fixed = 0\n      urls_converted = 0\n\n      action_texts_scope.find_each do |rich_text|\n        scanned += 1\n\n        body = rich_text.body\n\n        edited = false\n        conversions = 0\n\n        body.send(:attachment_nodes).each do |node|\n          url = node[\"url\"]\n          next unless url\n\n          if url.match?(RELATIVE_URL_PATTERN)\n            node[\"url\"] = \"#{host}#{url}\"\n            edited = true\n            conversions += 1\n          end\n        end\n\n        if edited\n          record = rich_text.record\n          puts \" - modifying #{record.class.name} #{record.to_param} (account: #{record.account&.external_account_id}) - #{conversions} URL(s)\"\n\n          rich_text.update! body: body.fragment.to_html\n\n          fixed += 1\n          urls_converted += conversions\n        end\n      end\n\n      puts \"\\n\\nConversion complete!\"\n      puts \"  Rich texts examined: #{scanned}\"\n      puts \"  Rich texts modified: #{fixed}\"\n      puts \"  URLs converted: #{urls_converted}\"\n    end\n\n    def action_texts_scope\n      ActionText::RichText.joins(:embeds_attachments).where(\"action_text_rich_texts.created_at >= ?\", since)\n    end\nend\n\nrequire \"optparse\"\nrequire \"time\"\n\noptions = {}\n\nOptionParser.new do |opts|\n  opts.banner = \"Usage: bin/rails runner #{__FILE__} [options]\"\n\n  opts.on(\"--host HOST\", \"Host to prepend (e.g., https://app.fizzy.do)\") do |host|\n    options[:host] = host\n  end\n\n  opts.on(\"--since TIME\", \"Process rich texts created since this time (ISO 8601 format)\") do |time|\n    options[:since] = Time.parse(time)\n  end\n\n  opts.on(\"-h\", \"--help\", \"Show this help message\") do\n    puts opts\n    exit\n  end\nend.parse!\n\nif options[:host].nil? || options[:since].nil?\n  puts \"Error: --host and --since are required\"\n  puts \"Example: bin/rails runner #{__FILE__} --host https://app.fizzy.do --since 2026-01-14T10:00:00Z\"\n  exit 1\nend\n\nConvertRelativeAttachmentUrlsToAbsolute.new(**options).run\n"
  },
  {
    "path": "script/migrations/copy-blobs-to-pure.rb",
    "content": "#! /usr/bin/env ruby\n\nrequire_relative \"../config/environment\"\n\ndef migrate(source_service_name, target_service_name)\n  ApplicationRecord.with_each_tenant do |tenant|\n    puts \"\\n## #{tenant}\"\n    report = { updated: 0, skipped: 0, errors: 0 }\n\n    if ActiveStorage::Blob.count == 0\n      puts \"No blobs found, skipping.\"\n      next\n    end\n\n    ActiveStorage::Blob.service = source_service = ActiveStorage::Blob.services.fetch(source_service_name)\n    target_service = ActiveStorage::Blob.services.fetch(target_service_name)\n\n    ActiveStorage::Blob.find_each do |blob|\n      if target_service.name.to_sym == blob.service_name.to_sym\n        report[:skipped] += 1\n        putc \"-\"\n      elsif target_service.exist?(blob.key)\n        report[:skipped] += 1\n        putc \"S\"\n      else\n        begin\n          blob.open do |stream|\n            target_service.upload(blob.key, stream, checksum: blob.checksum)\n          end\n          report[:updated] += 1\n          putc \".\"\n        rescue ActiveStorage::FileNotFoundError\n          report[:errors] += 1\n          putc \"E\"\n        end\n      end\n\n      # Update the service name of the blob.\n      blob.update_column :service_name, target_service_name\n    end\n\n    puts\n    pp report\n  end\nend\n\nmigrate :local, :purestorage\n"
  },
  {
    "path": "script/migrations/fill_account_closure_reasons.rb",
    "content": "#!/usr/bin/env ruby\n\nrequire_relative \"../config/environment\"\n\nApplicationRecord.with_each_tenant do |tenant|\n  Account.find_each do |account|\n    account.send(:create_default_closure_reasons)\n  end\nend\n"
  },
  {
    "path": "script/migrations/generate_comments_from_events.rb",
    "content": "#!/usr/bin/env ruby\n\nrequire_relative \"../config/environment\"\n\nApplicationRecord.with_each_tenant do |tenant|\n  Card.find_each do |card|\n    card.events.find_each do |event|\n      Card::Eventable::SystemCommenter.new(card.reload, event).comment\n    end\n  end\nend\n"
  },
  {
    "path": "script/migrations/migrate-content-to-slugged-urls.rb",
    "content": "#!/usr/bin/env ruby\n\nrequire_relative \"../config/environment\"\n\ndomains = {\n  \"production\" => \"app.fizzy.do\",\n  \"beta\" => ENV.fetch(\"APP_FQDN\", \"beta1.fizzy-beta.com\"),\n  \"staging\" => \"app.fizzy-staging.com\"\n}\n\ndef fix_attachments(rich_text)\n  if rich_text.body\n    rich_text.body.send(:attachment_nodes).each do |node|\n      sgid = SignedGlobalID.parse(node[\"sgid\"], for: ActionText::Attachable::LOCATOR_NAME)\n      if sgid\n        puts \"Fixing attachment node: #{node.to_html}\"\n        model = sgid.model_class.find(sgid.model_id)\n        node[\"sgid\"] = model.attachable_sgid\n      else\n        puts \"Skipping attachment node without valid sgid: #{node.to_html}\"\n      end\n    end\n    rich_text.save!\n  end\nend\n\nApplicationRecord.with_each_tenant do |tenant|\n  account_id = Current.account.queenbee_id\n\n  unless account_id\n    puts \"Skipping URL fixup for tenant: #{tenant}\"\n    next\n  end\n\n  puts \"\\n## Processing tenant: #{tenant}\\n\"\n\n  domain = domains[Rails.env] || domains[\"production\"]\n  regex = %r{://\\w+\\.#{domain}/}\n\n  pp [ Current.account.name, account_id, domain, regex ]\n  puts\n\n  Card.find_each do |card|\n    puts \"### Processing card #{card.id} in #{Rails.application.routes.url_helpers.board_card_path(card.board, card)}\"\n    fix_attachments(card.description)\n    card.reload\n\n    old_body = card.description.body.to_s\n    if match = regex.match(old_body)\n      puts \"URL found in card #{card.id} in #{Rails.application.routes.url_helpers.board_card_path(card.board, card)}\"\n      new_body = old_body.gsub(regex, \"://#{domain}/#{account_id}/\")\n\n      card.description.update(body: new_body) || raise(\"Failed to update card description for card #{card.id}\")\n    end\n  end\n\n  Comment.find_each do |comment|\n    puts \"### Processing comment #{comment.id} in #{Rails.application.routes.url_helpers.board_card_path(comment.card.board, comment.card)}\"\n    fix_attachments(comment.body)\n    comment.reload\n\n    old_body = comment.body.body.to_s\n    if match = regex.match(old_body)\n      puts \"URL found in comment #{comment.id} in #{Rails.application.routes.url_helpers.board_card_path(comment.card.board, comment.card)}\"\n      new_body = old_body.gsub(regex, \"://#{domain}/#{account_id}/\")\n\n      comment.body.update(body: new_body) || raise(\"Failed to update comment body for comment #{comment.id}\")\n    end\n  end\nend\n"
  },
  {
    "path": "script/migrations/migrate-disk-service-blobs.rb",
    "content": "#! /usr/bin/env ruby\n\nrequire_relative \"../config/environment\"\n\nApplicationRecord.with_each_tenant do |tenant|\n  puts \"\\n## #{tenant}\"\n  report = { updated: 0, skipped: 0 }\n\n  ActiveStorage::Blob.find_each do |blob|\n    if blob.key.start_with?(\"#{tenant}/\")\n      report[:skipped] += 1\n    else\n      blob.update_column :key, \"#{tenant}/#{blob.key}\"\n      report[:updated] += 1\n    end\n  end\n  pp report\n\n  disk_service = ActiveStorage::Blob.services.fetch(:local)\n  new_root = File.join(disk_service.root, tenant)\n  old_root = File.join(\"storage\", \"tenants\", Rails.env, tenant, \"files\")\n\n  FileUtils.mkdir_p(new_root, verbose: true) unless File.directory?(new_root)\n\n  Dir.glob(File.join(old_root, \"??\")).each_slice(20) do |blob_dirs|\n    FileUtils.mv(blob_dirs, new_root, verbose: true)\n  end\nend\n"
  },
  {
    "path": "script/migrations/migrate_to_flat_card_urls.rb",
    "content": "#!/usr/bin/env ruby\n\nrequire_relative \"../config/environment\"\n\ndef replace_url(string)\n  string.gsub(%r{/boards/\\d+/cards/(\\d+)}) do\n    \"/cards/#{$1}\"\n  end\nend\n\ndef fix_rich_text(rich_text)\n  original_html = rich_text.body_before_type_cast\n  new_html = replace_url(original_html)\n  if original_html != new_html\n    rich_text.update_columns(body: new_html)\n  end\nend\n\nApplicationRecord.with_each_tenant do\n  ActionText::RichText.where(record_type: \"Card\", name: \"description\").find_each do |rich_text|\n    fix_rich_text rich_text\n  end\n\n  ActionText::RichText.where(record_type: \"Comment\", name: \"body\").find_each do |rich_text|\n    fix_rich_text rich_text\n  end\nend\n"
  },
  {
    "path": "script/migrations/migrate_to_new_cards_url_scheme.rb",
    "content": "#!/usr/bin/env ruby\n\nrequire_relative \"../config/environment\"\n\ndef replace_url(string)\n  string.gsub(%r{/buckets/(\\d+)/bubbles/(\\d+)}) do\n    \"/boards/#{$1}/cards/#{$2}\"\n  end\nend\n\nApplicationRecord.with_each_tenant do |tenant|\n  Account.find_each do |account|\n    Comment.find_each do |comment|\n      comment.update!(body: replace_url(comment.body.content.to_s))\n    end\n  end\nend\n"
  },
  {
    "path": "script/migrations/populate_columns_from_workflow_stages.rb",
    "content": "#!/usr/bin/env ruby\n\nrequire_relative \"../../config/environment\"\n\nclass Card\n  has_one :engagement, dependent: :destroy, class_name: \"Card::Engagement\"\n\n  def doing?\n    open? && published? && engagement&.status == \"doing\"\n  end\n\n  def on_deck?\n    open? && published? && engagement&.status == \"on_deck\"\n  end\n\n  def considering?\n    open? && published? && engagement.blank?\n  end\nend\n\nApplicationRecord.with_each_tenant do |tenant|\n  puts \"Processing tenant: #{tenant}\"\n\n  Column.destroy_all\n\n  Board.find_each do |board|\n    next unless board.workflow.present?\n\n    # Map to track stage_id -> column\n    columns_by_stage = {}\n\n    # Create columns from workflow stages\n    board.workflow.stages.find_each do |stage|\n      column = board.columns.create!(\n        name: stage.name,\n        color: stage.color || Card::Colored::COLORS.first\n      )\n      columns_by_stage[stage] = column\n      puts \"Created column '#{column.name}' for board '#{board.name}'\"\n    end\n\n    # Associate cards with their corresponding columns based on stages\n    board.cards.includes(:stage).find_each do |card|\n      next if !card.doing? || card.stage.blank?\n\n      unless card.stage.workflow.boards.include?(board)\n        puts \"Corrupt data: the card with id #{card.id} has the stage #{card.stage.name} with id #{card.stage.id} that belongs to a workflow not asociated ot its board\"\n        next\n      end\n\n      stage = columns_by_stage[card.stage]\n      card.update!(column: stage)\n      puts \"Associated card ##{card.id} with column '#{stage.name}'\"\n    end\n  end\nend\n\nputs \"Migration completed!\"\n"
  },
  {
    "path": "script/migrations/renaming/content.rb",
    "content": "#!/usr/bin/env ruby\n\nrequire \"find\"\nrequire \"active_support/core_ext/string\" # for camelize\n\nRENAME_RULES = {\n  \"bubble\" => \"card\",\n  \"closed\" => \"closed\",\n  \"poppable\" => \"closeable\",\n  \"pop\" => \"closure\",\n  \"bucket\" => \"board\"\n}\n\nEXTENSIONS = %w[.rb .yml .html .js .css .erb]\nEXCLUDED_DIRS = %w[db .git script/renaming vendor/javascript]\n\n# Helper to build replacement regex patterns respecting case and separators\ndef build_patterns(from, to)\n  boundary = \"(?<=\\\\A|[^a-zA-Z0-9])#{from}(?=[^a-zA-Z0-9]|\\\\z)\"\n  camel = from.camelize\n  camel_plural = camel.pluralize\n  underscore_plural = from.pluralize.underscore\n  dasherized_plural = underscore_plural.dasherize\n\n  [\n    # Match lowercase boundary-delimited\n    [ /#{boundary}/, to ],\n    # Match capitalized version (e.g., Bubble => Card)\n    [ /(?<![a-zA-Z0-9])#{from.capitalize}(?![a-z])/, to.capitalize ],\n    # Match all-uppercase\n    [ /(?<![a-zA-Z0-9])#{from.upcase}(?![A-Z])/, to.upcase ],\n    # Match CamelCase and plural CamelCase\n    [ /(?<![a-zA-Z0-9])#{camel}(?![a-z])/, to.camelize ],\n    [ /(?<![a-zA-Z0-9])#{camel_plural}(?![a-z])/, to.camelize.pluralize ],\n    # Match lowerCamelCase\n    [ /(?<![a-zA-Z0-9])#{from.camelize(:lower)}(?![a-z])/, to.camelize(:lower) ],\n    # Match underscore and dashed plural forms (e.g. bubbles(:logo) => cards(:logo))\n    [ /(?<![a-zA-Z0-9])#{underscore_plural}(?![a-z])/, to.pluralize.underscore ],\n    [ /(?<![a-zA-Z0-9])#{dasherized_plural}(?![a-z])/, to.pluralize.underscore.dasherize ]\n  ]\nend\n\npatterns = []\nRENAME_RULES.each do |from, to|\n  patterns.concat(build_patterns(from, to))\nend\n\nFind.find(\".\") do |path|\n  next if File.directory?(path)\n  next unless EXTENSIONS.include?(File.extname(path))\n  next if EXCLUDED_DIRS.any? { |dir| path.start_with?(\"./#{dir}/\") }\n\n  content = File.read(path)\n  original_content = content.dup\n\n  patterns.each do |regex, replacement|\n    content.gsub!(regex, replacement)\n  end\n\n  if content != original_content\n    puts \"Renaming in: #{path}\"\n    File.write(path, content)\n  end\nend\n"
  },
  {
    "path": "script/migrations/renaming/files.rb",
    "content": "#!/usr/bin/env ruby\n\nrequire \"fileutils\"\nrequire \"active_support/core_ext/string\" # for camelize\n\n# Configuration\nEXCLUDED_DIRS = [ \"db\", \".git\", \"script/renaming\" ].freeze\n\nRENAMES = {\n  \"bubble\" => \"card\",\n  \"poppable\" => \"closeable\",\n  \"closed\" => \"closed\",\n  \"pop\" => \"closure\",\n  \"bucket\" => \"board\"\n}.freeze\n\nFILE_EXTENSIONS = %w[rb yml html css js jpg jpeg png gif svg erb].freeze\n\ndef excluded_path?(path)\n  EXCLUDED_DIRS.any? { |excluded| path.split(File::SEPARATOR).include?(excluded) }\nend\n\ndef rename_path(path)\n  new_path = path.dup\n\n  RENAMES.each do |from, to|\n    # Replace snake_case, kebab-case, plain, and CamelCase versions\n    patterns = [\n      [ /(?<=\\A|[^a-zA-Z0-9])#{from}(?=[^a-zA-Z0-9]|\\z)/i, to ],\n      [ from.camelize, to.camelize ],\n      [ from.camelize(:lower), to.camelize(:lower) ],\n      [ from.underscore.dasherize, to.underscore.dasherize ],\n      [ from.underscore, to.underscore ]\n    ]\n\n    patterns.each do |pattern, replacement|\n      new_path.gsub!(pattern, replacement)\n    end\n  end\n\n  new_path\nend\n\n# Rename Directories First\ndirs = Dir.glob(\"**/*/\").reject { |path| excluded_path?(path) }.sort_by { |dir| -dir.count(\"/\") }\n\nputs \"Renaming directories...\"\ndirs.each do |dir|\n  clean_dir = dir.chomp(\"/\")\n  new_dir = rename_path(clean_dir)\n\n  next if clean_dir == new_dir\n  next if File.exist?(new_dir)\n\n  puts \"Renaming dir: #{clean_dir} => #{new_dir}\"\n  FileUtils.mkdir_p(File.dirname(new_dir))\n  FileUtils.mv(clean_dir, new_dir)\nend\n\n# Rename Files\nfiles = Dir.glob(\"**/*.{#{FILE_EXTENSIONS.join(\",\")}}\").reject { |path| excluded_path?(path) }\n\nputs \"Renaming files...\"\nfiles.each do |file|\n  new_file = rename_path(file)\n\n  next if file == new_file\n  next if File.exist?(new_file)\n\n  puts \"Renaming file: #{file} => #{new_file}\"\n  FileUtils.mkdir_p(File.dirname(new_file))\n  FileUtils.mv(file, new_file)\nend\n\nputs \"Renaming complete!\"\n"
  },
  {
    "path": "script/migrations/reset_boards_ids.rb",
    "content": "#!/usr/bin/env ruby\n\nrequire_relative \"../config/environment\"\n\nApplicationRecord.with_each_tenant do |tenant|\n  id_mapping = {}\n\n  puts \"Processing tenant: #{tenant}\"\n\n  # Disable foreign key constraints\n  ApplicationRecord.connection.execute(\"PRAGMA foreign_keys = OFF;\")\n\n  begin\n    # Get all boards ordered by ID\n    boards = Board.order(:id).to_a\n\n    # Create mapping of old IDs to new IDs\n    boards.each_with_index do |board, index|\n      id_mapping[board.id] = index + 1\n    end\n\n    # Update foreign keys in related tables\n    puts \"Updating foreign keys in related tables...\"\n\n    # Update accesses table\n    Access.where.not(board_id: nil).find_each do |access|\n      if id_mapping[access.board_id]\n        access.update_column(:board_id, id_mapping[access.board_id])\n      end\n    end\n\n    # Update cards table\n    Card.where.not(board_id: nil).find_each do |card|\n      if id_mapping[card.board_id]\n        card.update_column(:board_id, id_mapping[card.board_id])\n      end\n    end\n\n    # Update boards_filters table (join table)\n    ApplicationRecord.connection.execute(\"SELECT board_id FROM boards_filters\").each do |row|\n      old_id = row[0]\n      if id_mapping[old_id]\n        ApplicationRecord.connection.execute(\"UPDATE boards_filters SET board_id = #{id_mapping[old_id]} WHERE board_id = #{old_id}\")\n      end\n    end\n\n    # Update events table\n    Event.where.not(board_id: nil).find_each do |event|\n      if id_mapping[event.board_id]\n        event.update_column(:board_id, id_mapping[event.board_id])\n      end\n    end\n\n    # Update events table (polymorphic relationship)\n    Event.where(eventable_type: \"Board\").find_each do |event|\n      if id_mapping[event.eventable_id]\n        event.update_column(:eventable_id, id_mapping[event.eventable_id])\n      end\n    end\n\n    # Update mentions table (polymorphic relationship)\n    Mention.where(source_type: \"Board\").find_each do |mention|\n      if id_mapping[mention.source_id]\n        mention.update_column(:source_id, id_mapping[mention.source_id])\n      end\n    end\n\n    # Update notifications table (polymorphic relationship)\n    Notification.where(source_type: \"Board\").find_each do |notification|\n      if id_mapping[notification.source_id]\n        notification.update_column(:source_id, id_mapping[notification.source_id])\n      end\n    end\n\n    # Update action_text_markdowns table (polymorphic relationship)\n    ActionText::RichText.where(record_type: \"Board\").find_each do |rich_text|\n      if id_mapping[rich_text.record_id]\n        rich_text.update_column(:record_id, id_mapping[rich_text.record_id])\n      end\n    end\n\n    # Update active_storage_attachments table (polymorphic relationship)\n    ActiveStorage::Attachment.where(record_type: \"Board\").find_each do |attachment|\n      if id_mapping[attachment.record_id]\n        attachment.update_column(:record_id, id_mapping[attachment.record_id])\n      end\n    end\n\n    # Reset the boards table IDs\n    puts \"Resetting board IDs...\"\n    boards.each do |board|\n      new_id = id_mapping[board.id]\n      # Use direct SQL to update the ID to avoid ActiveRecord validations\n      ApplicationRecord.connection.execute(\"UPDATE boards SET id = #{new_id} WHERE id = #{board.id}\")\n    end\n\n    # Reset the SQLite sequence for the boards table\n    ApplicationRecord.connection.execute(\"DELETE FROM sqlite_sequence WHERE name = 'boards'\")\n    max_id = Board.maximum(:id) || 0\n    ApplicationRecord.connection.execute(\"INSERT INTO sqlite_sequence (name, seq) VALUES ('boards', #{max_id})\")\n\n    puts \"Board IDs have been reset successfully!\"\n  rescue => e\n    puts \"Error: #{e.message}\"\n    puts e.backtrace\n  ensure\n    # Re-enable foreign key constraints\n    ApplicationRecord.connection.execute(\"PRAGMA foreign_keys = ON;\")\n  end\n\n  # Update links in card descriptions and comment bodies\n  Card.find_each do |card|\n    description = card.description.content.dup\n\n    description.gsub!(/boards\\/(\\d+)\\//) do |match|\n      old_id = $1.to_i\n      new_id = id_mapping[old_id]\n\n      new_id ? \"boards/#{new_id}/\" : match\n    end\n\n    if description != card.description.content\n      puts \"Updating links in card #{card.id}\"\n      card.update!(description: description)\n    end\n  end\n\n  Comment.find_each do |comment|\n    body = comment.body.content.dup\n\n    body.gsub!(/boards\\/(\\d+)\\//) do |match|\n      old_id = $1.to_i\n      new_id = id_mapping[old_id]\n      new_id ? \"boards/#{new_id}/\" : match\n    end\n\n    if body != comment.body.content\n      puts \"Updating links in comment #{comment.id}\"\n      comment.update!(body: body)\n    end\n  end\n\n  # Output the mapping of old IDs to new IDs\n  puts \"\\nMapping of old IDs to new IDs:\"\n  puts id_mapping.inspect\nend\n"
  },
  {
    "path": "script/migrations/reset_cards_ids.rb",
    "content": "#!/usr/bin/env ruby\n\nrequire_relative \"../config/environment\"\n\nApplicationRecord.with_each_tenant do |tenant|\n  id_mapping = {}\n\n  puts \"Processing tenant: #{tenant}\"\n\n  # Disable foreign key constraints\n  ApplicationRecord.connection.execute(\"PRAGMA foreign_keys = OFF;\")\n\n  begin\n    # Get all cards ordered by ID\n    cards = Card.order(:id).to_a\n\n    # Create mapping of old IDs to new IDs\n    cards.each_with_index do |card, index|\n      id_mapping[card.id] = index + 1\n    end\n\n    # Update foreign keys in related tables\n    puts \"Updating foreign keys in related tables...\"\n\n    # Update assignments table\n    Assignment.where.not(card_id: nil).find_each do |assignment|\n      if id_mapping[assignment.card_id]\n        assignment.update_column(:card_id, id_mapping[assignment.card_id])\n      end\n    end\n\n    # Update card_engagements table\n    Card::Engagement.where.not(card_id: nil).find_each do |engagement|\n      if id_mapping[engagement.card_id]\n        engagement.update_column(:card_id, id_mapping[engagement.card_id])\n      end\n    end\n\n    # Update card_goldnesses table\n    Card::Goldness.where.not(card_id: nil).find_each do |goldness|\n      if id_mapping[goldness.card_id]\n        goldness.update_column(:card_id, id_mapping[goldness.card_id])\n      end\n    end\n\n    # Update closures table\n    Closure.where.not(card_id: nil).find_each do |closure|\n      if id_mapping[closure.card_id]\n        closure.update_column(:card_id, id_mapping[closure.card_id])\n      end\n    end\n\n    # Update comments table\n    Comment.where.not(card_id: nil).find_each do |comment|\n      if id_mapping[comment.card_id]\n        comment.update_column(:card_id, id_mapping[comment.card_id])\n      end\n    end\n\n    # Update pins table\n    Pin.where.not(card_id: nil).find_each do |pin|\n      if id_mapping[pin.card_id]\n        pin.update_column(:card_id, id_mapping[pin.card_id])\n      end\n    end\n\n    # Update taggings table\n    Tagging.where.not(card_id: nil).find_each do |tagging|\n      if id_mapping[tagging.card_id]\n        tagging.update_column(:card_id, id_mapping[tagging.card_id])\n      end\n    end\n\n    # Update watches table\n    Watch.where.not(card_id: nil).find_each do |watch|\n      if id_mapping[watch.card_id]\n        watch.update_column(:card_id, id_mapping[watch.card_id])\n      end\n    end\n\n    # Update events table (polymorphic relationship)\n    Event.where(eventable_type: \"Card\").find_each do |event|\n      if id_mapping[event.eventable_id]\n        event.update_column(:eventable_id, id_mapping[event.eventable_id])\n      end\n    end\n\n    # Update mentions table (polymorphic relationship)\n    Mention.where(source_type: \"Card\").find_each do |mention|\n      if id_mapping[mention.source_id]\n        mention.update_column(:source_id, id_mapping[mention.source_id])\n      end\n    end\n\n    # Update notifications table (polymorphic relationship)\n    Notification.where(source_type: \"Card\").find_each do |notification|\n      if id_mapping[notification.source_id]\n        notification.update_column(:source_id, id_mapping[notification.source_id])\n      end\n    end\n\n    # Update action_text_markdowns table (polymorphic relationship)\n    ActionText::RichText.where(record_type: \"Card\").find_each do |rich_text|\n      if id_mapping[rich_text.record_id]\n        rich_text.update_column(:record_id, id_mapping[rich_text.record_id])\n      end\n    end\n\n    # Reset the cards table IDs\n    puts \"Resetting card IDs...\"\n    cards.each do |card|\n      new_id = id_mapping[card.id]\n      # Use direct SQL to update the ID to avoid ActiveRecord validations\n      ApplicationRecord.connection.execute(\"UPDATE cards SET id = #{new_id} WHERE id = #{card.id}\")\n    end\n\n    # Reset the SQLite sequence for the cards table\n    ApplicationRecord.connection.execute(\"DELETE FROM sqlite_sequence WHERE name = 'cards'\")\n    max_id = Card.maximum(:id) || 0\n    ApplicationRecord.connection.execute(\"INSERT INTO sqlite_sequence (name, seq) VALUES ('cards', #{max_id})\")\n\n    puts \"Card IDs have been reset successfully!\"\n  rescue => e\n    puts \"Error: #{e.message}\"\n    puts e.backtrace\n  ensure\n    # Re-enable foreign key constraints\n    ApplicationRecord.connection.execute(\"PRAGMA foreign_keys = ON;\")\n  end\n\n  Card.find_each do |card|\n    description = card.description.content.dup\n\n    description.gsub!(/cards\\/(\\d+)\\)/) do |match|\n      old_id = $1.to_i\n      new_id = id_mapping[old_id]\n\n      new_id ? \"cards/#{new_id})\" : match\n    end\n\n    if description != card.description.content\n      puts \"Updating links in card #{card.id}\"\n      card.update!(description: description)\n    end\n  end\n\n  Comment.find_each do |comment|\n    body = comment.body.content.dup\n\n    body.gsub!(/cards\\/(\\d+)\\)/) do |match|\n      old_id = $1.to_i\n      new_id = id_mapping[old_id]\n      new_id ? \"cards/#{new_id})\" : match\n    end\n\n    if body != comment.body.content\n      puts \"Updating links in comment #{comment.id}\"\n      comment.update!(body: body)\n    end\n  end\n\n  # Output the mapping of old IDs to new IDs\n  puts \"\\nMapping of old IDs to new IDs:\"\n  puts id_mapping.inspect\nend\n"
  },
  {
    "path": "script/migrations/split-sibling-paragraphs-with-p-br.rb",
    "content": "#!/usr/bin/env ruby\n\nBACKFILL_TIMESTAMP = Time.parse(\"2025-12-19 00:07:00 UTC\")\nACCOUNT_ID = nil # restrict to an account_id\n\n# Split sibling <p> tags with content by inserting <p><br</p> to replicaate previous view.\n# Run for the time range before paragraphs were not spaced\n# See https://app.fizzy.do/5986089/cards/3472\n# and https://github.com/basecamp/fizzy/pull/2107\n#\n# MUST BE RUN AFTER `decrypt!` when using ActiveRecord Encryption\n#\n# Run locally:\n#   bin/rails runner script/migrations/split-sibling-paragraphs-with-p-br.rb\n#\n# Run via Kamal:\n#   kamal app exec -d <stage> -p --reuse \"bin/rails runner script/migrations/split-sibling-paragraphs-with-p-br.rb\"\n#\n# Safe to re-run for a time range: won't re-detect unsplit paragraphs and updated_at will be outside time window\nclass SeparateSiblingParagraphs\n  attr_reader :updated_at, :account_id\n\n  def initialize(updated_at, account_id: nil)\n    @updated_at = updated_at\n    @account_id = account_id\n  end\n\n  def run\n    puts \"Separating non-blank sibling paragraphs\"\n\n    puts \"Updated at: #{updated_at}\"\n    puts account_id ? \"Only account id: #{account_id}\" : \"For **ALL ACCOUNTS**\"\n\n    puts \"\\nPress ENTER to continue running or CTRL-C to bail...\"\n    gets\n\n    puts \"\\nRunning...\"\n\n    # Suppress SQL logs\n    Rails.event.debug_mode = false\n\n    seconds = Benchmark.realtime do\n      suppressing_turbo_broadcasts do\n        separate_nonblank_paragraphs\n      end\n    end\n\n    puts \"\\n\\n\"\n    puts \"Finished splitting non-blank <p>s in %.2f seconds.\" % seconds\n  end\n\n  private\n    def suppressing_turbo_broadcasts\n      Board.suppressing_turbo_broadcasts do\n        Card.suppressing_turbo_broadcasts do\n          yield\n        end\n      end\n    end\n\n    def separate_nonblank_paragraphs\n      scanned = 0\n      fixed = 0\n      insertions = 0\n\n      action_texts_scope.find_each(**batch_options) do |rich_text|\n        next if account_id && rich_text.record.account.external_account_id != account_id\n\n        scanned += 1\n        edited = false\n\n        rich_text.body&.fragment.tap do |fragment|\n          next unless fragment\n\n          fragment.find_all(\"p + p\").each do |node|\n            unless empty_node?(node) || empty_node?(node.previous_sibling)\n              node.add_previous_sibling empty_node_markup\n              edited = true\n              insertions += 1\n            end\n          end\n\n          if edited\n            puts \" - modifying #{rich_text.record.class.name} #{rich_text.record.to_param} (account: #{rich_text.record.account.external_account_id})\" unless demo_card?(rich_text.record)\n            # allow implicit touching to invalidate caches\n            rich_text.update! body: fragment.to_html\n            fixed +=1\n          end\n        end\n      end\n\n      puts \"\\n\\Separation complete!\"\n      puts \"  Rich texts examined: #{scanned}\"\n      puts \"  Rich texts modified: #{fixed}\"\n      puts \"  Paragraphs inserted: #{insertions}\"\n      fixed\n    end\n\n    def action_texts_scope\n      ActionText::RichText.where(updated_at: updated_at)\n    end\n\n    def batch_options\n      { batch_size: 20, order: :desc }\n    end\n\n    def empty_node?(node)\n      node.to_html == empty_node_markup\n    end\n\n    def empty_node_markup\n      \"<p><br></p>\"\n    end\n\n    def demo_card?(record)\n      record.is_a?(Card) && record.number <= 8\n    end\nend\n\nSeparateSiblingParagraphs.new(..BACKFILL_TIMESTAMP, account_id: ACCOUNT_ID).run\n"
  },
  {
    "path": "script/populate.rb",
    "content": "require_relative \"../config/environment\"\nrequire \"faker\"\n\nACCOUNT = Account.find_by(name: \"cleanslate\")\nCARDS_COUNT = ARGV.first&.to_i || 10_000\nBOARDS_COUNT = ARGV.second&.to_i || 100\nTAGS_COUNT = ARGV.third&.to_i || 500\nUSERS_COUNT = ARGV.fourth&.to_i || 1000\n\nCurrent.account = ACCOUNT\nCurrent.session = ACCOUNT.users.last.identity.sessions.first\n\nputs \"Creating #{CARDS_COUNT} cards with #{TAGS_COUNT} tags across #{BOARDS_COUNT} board(s)\"\n\nBoard.suppressing_turbo_broadcasts do\n  Card.suppressing_turbo_broadcasts do\n    BOARDS_COUNT.times do\n      ACCOUNT.boards.create! name: Faker::Company.buzzword, all_access: true\n      print \".\"\n    end\n\n    CARDS_COUNT.times do\n      card = ACCOUNT.boards.take.cards.create! \\\n        title: Faker::Company.bs, description: Faker::Hacker.say_something_smart, status: :published\n\n      print \".\"\n    end\n\n    TAGS_COUNT.times do\n      ACCOUNT.cards.take.toggle_tag_with Faker::Game.title\n      print \".\"\n    end\n\n    USERS_COUNT.times do\n      ACCOUNT.users.create! name: Faker::FunnyName\n    end\n  end\nend\n"
  },
  {
    "path": "script/remove-lb-admin-production.sh",
    "content": "#!/usr/bin/env bash\n\nset -e\n\nssh app@fizzy-lb-101.df-iad-int.37signals.com \\\n  docker exec fizzy-load-balancer kamal-proxy rm fizzy-admin\n\nssh app@fizzy-lb-01.sc-chi-int.37signals.com \\\n  docker exec fizzy-load-balancer kamal-proxy rm fizzy-admin\n\nssh app@fizzy-lb-401.df-ams-int.37signals.com \\\n  docker exec fizzy-load-balancer kamal-proxy rm fizzy-admin\n"
  },
  {
    "path": "storage/.keep",
    "content": ""
  },
  {
    "path": "test/application_system_test_case.rb",
    "content": "require \"test_helper\"\n\nclass ApplicationSystemTestCase < ActionDispatch::SystemTestCase\n  browser_options = Selenium::WebDriver::Chrome::Options.new.tap do |opts|\n    opts.add_argument(\"--window-size=1200,800\")\n    opts.add_argument(\"--disable-extensions\")\n    # Disable non-foreground tabs from getting a lower process priority\n    opts.add_argument(\"--disable-renderer-backgrounding\")\n    # Normally, Chrome will treat a 'foreground' tab instead as backgrounded if the surrounding\n    # window is occluded (aka visually covered) by another window. This flag disables that.\n    opts.add_argument(\"--disable-backgrounding-occluded-windows\")\n    # Suppress all permission prompts by automatically denying them.\n    opts.add_argument(\"--deny-permission-prompts\")\n    opts.add_argument(\"--enable-automation\")\n  end\n\n  Capybara.register_driver :chrome_headless do |app|\n    browser_options.add_argument(\"--headless\")\n    Capybara::Selenium::Driver.new(app, browser: :chrome, options: browser_options)\n  end\n\n  Capybara.register_driver :chrome do |app|\n    Capybara::Selenium::Driver.new(app, browser: :chrome, options: browser_options)\n  end\n\n  if ENV[\"SYSTEM_TESTS_BROWSER\"]\n    driven_by :chrome, screen_size: [ 1200, 1000 ]\n  else\n    driven_by :chrome_headless, screen_size: [ 1200, 1000 ]\n  end\n\n  private\n    def sign_in_as(user)\n      visit session_transfer_url(user.identity.transfer_id, script_name: nil)\n      assert_current_path root_path\n    end\nend\n"
  },
  {
    "path": "test/channels/application_cable/connection_test.rb",
    "content": "require \"test_helper\"\n\nmodule ApplicationCable\n  class ConnectionTest < ActionCable::Connection::TestCase\n    setup do\n      # Use non-37s account to assess that Current.account is set correctly\n      @account = accounts(:initech)\n      @session = sessions(:mike)\n    end\n\n    test \"connects with valid session and account info\" do\n      cookies.signed[:session_token] = @session.signed_id\n\n      connect \"/cable\", env: { \"fizzy.external_account_id\" => @account.external_account_id }\n\n      assert_equal users(:mike), connection.current_user\n      assert_equal @account, Current.account\n    end\n\n    test \"rejects with invalid session token\" do\n      cookies.signed[:session_token] = \"invalid-session-id\"\n\n      assert_reject_connection do\n        connect \"/cable\", env: { \"fizzy.external_account_id\" => @account.external_account_id }\n      end\n    end\n\n    test \"rejects when account does not exist\" do\n      cookies.signed[:session_token] = @session.signed_id\n\n      assert_reject_connection do\n        connect \"/cable\", env: { \"fizzy.external_account_id\" => -1 }\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "test/controllers/account/cancellations_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Account::CancellationsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    @account = accounts(:\"37s\")\n    @user = users(:jason)\n    sign_in_as @user\n\n    if @account.respond_to?(:subscription)\n      Account.any_instance.stubs(:subscription).returns(nil)\n    end\n  end\n\n  test \"an owner can cancel the account\" do\n    assert_difference -> { Account::Cancellation.count }, 1 do\n      assert_enqueued_emails 1 do\n        post account_cancellation_url\n      end\n    end\n\n    assert_redirected_to session_menu_path(script_name: nil)\n    assert_equal \"Account deleted\", flash[:notice]\n    assert @account.reload.cancelled?\n    assert_equal @user, @account.cancellation.initiated_by\n  end\n\n  test \"non-owner cannot cancel the account\" do\n    logout_and_sign_in_as users(:david)\n\n    assert_no_difference -> { Account::Cancellation.count } do\n      post account_cancellation_url\n    end\n\n    assert_response :forbidden\n  end\n\n  test \"cancelling an account while in single-tenant mode does nothing\" do\n    with_multi_tenant_mode(false) do\n      assert_no_difference -> { Account::Cancellation.count } do\n        post account_cancellation_url\n      end\n\n      assert_not @account.reload.cancelled?\n    end\n  end\nend\n"
  },
  {
    "path": "test/controllers/accounts/entropies_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Account::EntropiesControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"update\" do\n    put account_entropy_path, params: { entropy: { auto_postpone_period_in_days: 7 } }\n\n    assert_equal 7.days, entropies(\"37s_account\").auto_postpone_period\n\n    assert_redirected_to account_settings_path\n  end\n\n  test \"update as JSON\" do\n    put account_entropy_path, params: { entropy: { auto_postpone_period_in_days: 7 } }, as: :json\n\n    assert_response :success\n    assert_equal 7.days, entropies(\"37s_account\").reload.auto_postpone_period\n    assert_equal 7, @response.parsed_body[\"auto_postpone_period_in_days\"]\n  end\n\n  test \"update requires admin\" do\n    logout_and_sign_in_as :david\n\n    put account_entropy_path, params: { entropy: { auto_postpone_period_in_days: 7 } }\n    assert_response :forbidden\n  end\n\n  test \"update rejects invalid auto_postpone_period\" do\n    original_period = entropies(\"37s_account\").auto_postpone_period\n\n    put account_entropy_path, params: { entropy: { auto_postpone_period_in_days: 1 } }\n\n    assert_response :unprocessable_entity\n    assert_equal original_period, entropies(\"37s_account\").reload.auto_postpone_period\n  end\n\n  test \"update as JSON rejects invalid auto_postpone_period\" do\n    original_period = entropies(\"37s_account\").auto_postpone_period\n\n    put account_entropy_path, params: { entropy: { auto_postpone_period_in_days: 1 } }, as: :json\n\n    assert_response :unprocessable_entity\n    assert_equal original_period, entropies(\"37s_account\").reload.auto_postpone_period\n  end\n\n  test \"update as JSON requires admin\" do\n    logout_and_sign_in_as :david\n\n    put account_entropy_path, params: { entropy: { auto_postpone_period_in_days: 7 } }, as: :json\n    assert_response :forbidden\n  end\nend\n"
  },
  {
    "path": "test/controllers/accounts/exports_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Account::ExportsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :jason\n  end\n\n  test \"create creates an export record and enqueues job\" do\n    assert_difference -> { Account::Export.count }, 1 do\n      assert_enqueued_with(job: DataExportJob) do\n        post account_exports_path\n      end\n    end\n\n    assert_redirected_to account_settings_path\n    assert_equal \"Export started. You'll receive an email when it's ready.\", flash[:notice]\n  end\n\n  test \"create associates export with current user\" do\n    post account_exports_path\n\n    export = Account::Export.last\n    assert_equal users(:jason), export.user\n    assert_equal Current.account, export.account\n    assert export.pending?\n  end\n\n  test \"create rejects request when current export limit is reached\" do\n    Account::ExportsController::CURRENT_EXPORT_LIMIT.times do\n      Account::Export.create!(account: Current.account, user: users(:jason))\n    end\n\n    assert_no_difference -> { Account::Export.count } do\n      post account_exports_path\n    end\n\n    assert_response :too_many_requests\n  end\n\n  test \"create allows request when exports are older than one day\" do\n    Account::ExportsController::CURRENT_EXPORT_LIMIT.times do\n      Account::Export.create!(account: Current.account, user: users(:jason), created_at: 2.days.ago)\n    end\n\n    assert_difference -> { Account::Export.count }, 1 do\n      post account_exports_path\n    end\n\n    assert_redirected_to account_settings_path\n  end\n\n  test \"show displays completed export with download link\" do\n    export = Account::Export.create!(account: Current.account, user: users(:jason))\n    export.build\n\n    get account_export_path(export)\n\n    assert_response :success\n    assert_select \"a#download-link\"\n  end\n\n  test \"show displays a warning if the export is missing\" do\n    get account_export_path(\"not-really-an-export\")\n\n    assert_response :success\n    assert_select \"h2\", \"Download Expired\"\n  end\n\n  test \"show does not allow access to another user's export\" do\n    export = Account::Export.create!(account: Current.account, user: users(:kevin))\n    export.build\n\n    get account_export_path(export)\n\n    assert_response :success\n    assert_select \"h2\", \"Download Expired\"\n  end\n\n  test \"create as JSON\" do\n    assert_difference -> { Account::Export.count }, 1 do\n      assert_enqueued_with(job: DataExportJob) do\n        post account_exports_path, as: :json\n      end\n    end\n\n    assert_response :created\n    body = @response.parsed_body\n    assert body[\"id\"].present?\n    assert_equal \"pending\", body[\"status\"]\n    assert_nil body[\"download_url\"]\n  end\n\n  test \"show as JSON with completed export\" do\n    export = Account::Export.create!(account: Current.account, user: users(:jason))\n    export.build\n\n    get account_export_path(export), as: :json\n    assert_response :success\n\n    body = @response.parsed_body\n    assert_equal export.id, body[\"id\"]\n    assert_equal \"completed\", body[\"status\"]\n    assert body[\"download_url\"].present?\n  end\n\n  test \"show as JSON with pending export\" do\n    export = Account::Export.create!(account: Current.account, user: users(:jason))\n\n    get account_export_path(export), as: :json\n    assert_response :success\n\n    body = @response.parsed_body\n    assert_equal \"pending\", body[\"status\"]\n    assert_nil body[\"download_url\"]\n  end\n\n  test \"show as JSON with missing export\" do\n    get account_export_path(\"nonexistent\"), as: :json\n    assert_response :not_found\n  end\n\n  test \"create is forbidden for non-admin members\" do\n    logout_and_sign_in_as :david\n\n    post account_exports_path\n\n    assert_response :forbidden\n  end\n\n  test \"show is forbidden for non-admin members\" do\n    logout_and_sign_in_as :david\n    export = Account::Export.create!(account: Current.account, user: users(:jason))\n    export.build\n\n    get account_export_path(export)\n\n    assert_response :forbidden\n  end\nend\n"
  },
  {
    "path": "test/controllers/accounts/join_codes_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Account::JoinCodesControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"reset\" do\n    get account_join_code_path\n    assert_response :success\n\n    assert_changes -> { Current.account.join_code.reload.code } do\n      delete account_join_code_path\n      assert_redirected_to account_join_code_path\n    end\n  end\n\n  test \"update\" do\n    get edit_account_join_code_path\n    assert_response :success\n\n    put account_join_code_path, params: { account_join_code: { usage_limit: 5 } }\n    assert_equal 5, Current.account.join_code.reload.usage_limit\n    assert_redirected_to account_join_code_path\n  end\n\n  test \"show as JSON\" do\n    get account_join_code_path, as: :json\n    assert_response :success\n\n    body = @response.parsed_body\n    assert body[\"code\"].present?\n    assert body.key?(\"usage_count\")\n    assert body.key?(\"usage_limit\")\n    assert body.key?(\"url\")\n    assert body.key?(\"active\")\n  end\n\n  test \"update as JSON\" do\n    put account_join_code_path, params: { account_join_code: { usage_limit: 5 } }, as: :json\n\n    assert_response :no_content\n    assert_equal 5, Current.account.join_code.reload.usage_limit\n  end\n\n  test \"update as JSON with invalid data\" do\n    huge_number = \"99999999999999999999999999999999999\"\n\n    put account_join_code_path, params: { account_join_code: { usage_limit: huge_number } }, as: :json\n\n    assert_response :unprocessable_entity\n  end\n\n  test \"destroy as JSON\" do\n    assert_changes -> { Current.account.join_code.reload.code } do\n      delete account_join_code_path, as: :json\n    end\n\n    assert_response :no_content\n  end\n\n  test \"update requires admin\" do\n    logout_and_sign_in_as :david\n\n    put account_join_code_path, params: { account_join_code: { usage_limit: 5 } }\n    assert_response :forbidden\n  end\n\n  test \"destroy requires admin\" do\n    logout_and_sign_in_as :david\n\n    delete account_join_code_path\n    assert_response :forbidden\n  end\n\n  test \"update with extremely large usage_limit\" do\n    # A number larger than bigint max (2^63 - 1 = 9223372036854775807)\n    huge_number = \"99999999999999999999999999999999999\"\n\n    put account_join_code_path, params: { account_join_code: { usage_limit: huge_number } }\n\n    assert_response :unprocessable_entity\n    assert_select \".txt-negative\", text: /cannot be larger than the population of the planet/\n  end\nend\n"
  },
  {
    "path": "test/controllers/accounts/settings_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Account::SettingsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"show\" do\n    get account_settings_path\n    assert_response :success\n  end\n\n  test \"update\" do\n    put account_settings_path, params: { account: { name: \"New Account Name\" } }\n    assert_equal \"New Account Name\", Current.account.reload.name\n    assert_redirected_to account_settings_path\n  end\n\n  test \"update as JSON\" do\n    put account_settings_path, params: { account: { name: \"New Account Name\" } }, as: :json\n\n    assert_response :no_content\n    assert_equal \"New Account Name\", Current.account.reload.name\n  end\n\n  test \"update requires admin\" do\n    logout_and_sign_in_as :david\n\n    put account_settings_path, params: { account: { name: \"New Account Name\" } }\n    assert_response :forbidden\n  end\n\n  test \"show as JSON\" do\n    get account_settings_path, as: :json\n\n    assert_response :success\n    assert_equal Current.account.name, @response.parsed_body[\"name\"]\n    assert_equal Current.account.cards_count, @response.parsed_body[\"cards_count\"]\n    assert_equal Current.account.entropy.auto_postpone_period_in_days, @response.parsed_body[\"auto_postpone_period_in_days\"]\n  end\nend\n"
  },
  {
    "path": "test/controllers/active_storage/direct_uploads_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass ActiveStorage::DirectUploadsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    @blob_params = {\n      blob: {\n        filename: \"screenshot.png\",\n        byte_size: 12345,\n        checksum: \"GQ5SqLsM7ylnji0Wgd9wNC==\",\n        content_type: \"image/png\"\n      }\n    }\n  end\n\n  test \"create\" do\n    sign_in_as :david\n\n    post rails_direct_uploads_path,\n      params: @blob_params,\n      headers: bearer_token_header(identity_access_tokens(:davids_api_token).token),\n      as: :json\n\n    assert_response :success\n    assert_includes response.parsed_body.keys, \"direct_upload\"\n  end\n\n  test \"create with valid access token\" do\n    post rails_direct_uploads_path,\n      params: @blob_params,\n      headers: bearer_token_header(identity_access_tokens(:davids_api_token).token),\n      as: :json\n\n    assert_response :success\n    assert_includes response.parsed_body.keys, \"direct_upload\"\n  end\n\n  test \"create with read-only access token\" do\n    post rails_direct_uploads_path,\n      params: @blob_params,\n      headers: bearer_token_header(identity_access_tokens(:jasons_api_token).token),\n      as: :json\n\n    assert_response :unauthorized\n  end\n\n  test \"create with invalid access token\" do\n    post rails_direct_uploads_path,\n      params: @blob_params,\n      headers: bearer_token_header(\"invalid_token\"),\n      as: :json\n\n    assert_response :unauthorized\n  end\n\n  test \"create unauthenticated\" do\n    post rails_direct_uploads_path,\n      params: @blob_params,\n      as: :json\n\n    assert_response :redirect\n  end\n\n  test \"create in another account is forbidden\" do\n    sign_in_as :david\n\n    post rails_direct_uploads_path(script_name: \"/#{ActiveRecord::FixtureSet.identify(\"initech\")}\"),\n      params: @blob_params,\n      as: :json\n\n    assert_response :forbidden\n  end\n\n  test \"create with valid access token in another account is forbidden\" do\n    post rails_direct_uploads_path(script_name: \"/#{ActiveRecord::FixtureSet.identify(\"initech\")}\"),\n      params: @blob_params,\n      headers: bearer_token_header(identity_access_tokens(:davids_api_token).token),\n      as: :json\n\n    assert_response :forbidden\n  end\n\n  private\n    def bearer_token_header(token)\n      { \"Authorization\" => \"Bearer #{token}\" }\n    end\nend\n"
  },
  {
    "path": "test/controllers/admin/mission_control_test.rb",
    "content": "require \"test_helper\"\n\nclass Admin::MissionControlTest < ActionDispatch::IntegrationTest\n  test \"staff can access mission control jobs\" do\n    sign_in_as :david\n\n    untenanted do\n      get \"/admin/jobs\"\n    end\n\n    assert_response :success\n  end\n\n  test \"non-staff cannot access mission control jobs\" do\n    sign_in_as :jz\n\n    untenanted do\n      get \"/admin/jobs\"\n    end\n\n    assert_response :forbidden\n  end\nend\n"
  },
  {
    "path": "test/controllers/allow_browser_test.rb",
    "content": "require \"test_helper\"\n\nclass AllowBrowserTest < ActionDispatch::IntegrationTest\n  test \"Baidu browser is allowed\" do\n    sign_in_as :kevin\n\n    get cards_path, headers: {\n      \"User-Agent\" => \"Mozilla/5.0 (Linux; Android 7.0; HUAWEI NXT-AL10 Build/HUAWEINXT-AL10; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/48.0.2564.116 Mobile Safari/537.36 baidubrowser/7.9.12.0 (Baidu; P1 7.0)NULL\"\n    }\n\n    assert_response :success\n  end\n\n  test \"nonsense user agent with bot in name is allowed\" do\n    sign_in_as :kevin\n\n    get cards_path, headers: {\n      \"User-Agent\" => \"TotallyFakeBot/1.0 (NonsenseBrowser; Testing)\"\n    }\n\n    assert_response :success\n  end\n\n  test \"nonsense user agent is allowed\" do\n    sign_in_as :kevin\n\n    get cards_path, headers: {\n      \"User-Agent\" => \"just some random nonsense text\"\n    }\n\n    assert_response :success\n  end\n\n  test \"old Chrome browser is rejected with 406\" do\n    sign_in_as :kevin\n\n    # Chrome 118 is below the modern threshold of Chrome 120\n    get cards_path, headers: {\n      \"User-Agent\" => \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118 Safari/537.36\"\n    }\n\n    assert_response :not_acceptable\n  end\n\n  test \"Google Image Proxy is allowed\" do\n    sign_in_as :kevin\n\n    get cards_path, headers: {\n      \"User-Agent\" => \"Mozilla/5.0 (Windows NT 5.1; rv:11.0) Gecko Firefox/11.0 (via ggpht.com GoogleImageProxy)\"\n    }\n\n    assert_response :success\n  end\n\n  test \"Facebook/Twitter bot is allowed\" do\n    sign_in_as :kevin\n\n    get cards_path, headers: {\n      \"User-Agent\" => \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_1) AppleWebKit/601.2.4 (KHTML, like Gecko) Version/9.0.1 Safari/601.2.4 facebookexternalhit/1.1 Facebot Twitterbot/1.0\"\n    }\n\n    assert_response :success\n  end\nend\n"
  },
  {
    "path": "test/controllers/api/flat_json_params_test.rb",
    "content": "require \"test_helper\"\n\nclass FlatJsonParamsTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"update user role with flat JSON\" do\n    put user_role_path(users(:david)), params: { role: \"admin\" }, as: :json\n\n    assert_response :no_content\n    assert users(:david).reload.admin?\n  end\n\n  test \"update notification settings with flat JSON\" do\n    logout_and_sign_in_as :david\n\n    assert_changes -> { users(:david).reload.settings.bundle_email_frequency }, from: \"never\", to: \"every_few_hours\" do\n      put notifications_settings_path, params: { bundle_email_frequency: \"every_few_hours\" }, as: :json\n    end\n\n    assert_response :no_content\n  end\n\n  test \"update join code with flat JSON\" do\n    put account_join_code_path, params: { usage_limit: 5 }, as: :json\n\n    assert_response :no_content\n    assert_equal 5, Current.account.join_code.reload.usage_limit\n  end\n\n  test \"update account settings with flat JSON\" do\n    put account_settings_path, params: { name: \"New Name\" }, as: :json\n\n    assert_response :no_content\n    assert_equal \"New Name\", Current.account.reload.name\n  end\n\n  test \"update board entropy with flat JSON\" do\n    board = boards(:writebook)\n\n    put board_entropy_path(board), params: { auto_postpone_period_in_days: 90 }, as: :json\n\n    assert_response :success\n    assert_equal 90.days, board.entropy.reload.auto_postpone_period\n  end\n\n  test \"update account entropy with flat JSON\" do\n    put account_entropy_path, params: { auto_postpone_period_in_days: 7 }, as: :json\n\n    assert_response :success\n    assert_equal 7.days, Current.account.entropy.reload.auto_postpone_period\n  end\n\n  test \"create push subscription with flat JSON\" do\n    stub_dns_resolution(\"142.250.185.206\")\n\n    post user_push_subscriptions_path(users(:kevin)),\n      params: { endpoint: \"https://fcm.googleapis.com/fcm/send/abc123\", p256dh_key: \"key1\", auth_key: \"key2\" },\n      as: :json\n\n    assert_response :created\n  end\n\n  test \"create card with flat JSON\" do\n    assert_difference -> { Card.count }, +1 do\n      post board_cards_path(boards(:writebook)),\n        params: { title: \"Flat card\", description: \"<p>Flat description</p>\" },\n        as: :json\n    end\n\n    assert_response :created\n    card = Card.last\n    assert_equal \"Flat card\", card.title\n    assert_equal \"Flat description\", card.description.to_plain_text\n  end\n\n  test \"update card with flat JSON\" do\n    card = cards(:logo)\n\n    put card_path(card),\n      params: { title: \"Flat update\", description: \"<p>Updated flat</p>\" },\n      as: :json\n\n    assert_response :success\n    card.reload\n    assert_equal \"Flat update\", card.title\n    assert_equal \"Updated flat\", card.description.to_plain_text\n  end\n\n  test \"create board with flat JSON\" do\n    assert_difference -> { Board.count }, +1 do\n      post boards_path, params: { name: \"Flat board\" }, as: :json\n    end\n\n    assert_response :created\n    assert_equal \"Flat board\", Board.last.name\n  end\n\n  test \"update board with flat JSON\" do\n    board = boards(:writebook)\n\n    put board_path(board),\n      params: { name: \"Flat board\", auto_postpone_period_in_days: 7, public_description: \"<p>Flat public desc</p>\" },\n      as: :json\n\n    assert_response :no_content\n    board.reload\n    assert_equal \"Flat board\", board.name\n    assert_equal 7.days, board.entropy.auto_postpone_period\n    assert_equal \"Flat public desc\", board.public_description.to_plain_text\n  end\n\n  test \"create column with flat JSON\" do\n    board = boards(:writebook)\n\n    assert_difference -> { board.columns.count }, +1 do\n      post board_columns_path(board), params: { name: \"Flat Column\" }, as: :json\n    end\n\n    assert_response :created\n    assert_equal \"Flat Column\", Column.last.name\n  end\n\n  test \"update column with flat JSON\" do\n    column = columns(:writebook_in_progress)\n\n    put board_column_path(column.board, column), params: { name: \"Flat Updated\" }, as: :json\n\n    assert_response :no_content\n    assert_equal \"Flat Updated\", column.reload.name\n  end\n\n  test \"create step with flat JSON\" do\n    card = cards(:logo)\n\n    assert_difference -> { card.steps.count }, +1 do\n      post card_steps_path(card), params: { content: \"Flat step\" }, as: :json\n    end\n\n    assert_response :created\n    assert_equal \"Flat step\", Step.last.content\n  end\n\n  test \"update step with flat JSON\" do\n    card = cards(:logo)\n    step = card.steps.create!(content: \"Original\")\n\n    put card_step_path(card, step), params: { content: \"Flat updated\" }, as: :json\n\n    assert_response :success\n    assert_equal \"Flat updated\", step.reload.content\n  end\n\n  test \"create card reaction with flat JSON\" do\n    card = cards(:logo)\n\n    assert_difference -> { card.reactions.count }, +1 do\n      post card_reactions_path(card), params: { content: \"🎉\" }, as: :json\n    end\n\n    assert_response :created\n  end\n\n  test \"create comment reaction with flat JSON\" do\n    comment = comments(:logo_agreement_kevin)\n\n    assert_difference -> { comment.reactions.count }, +1 do\n      post card_comment_reactions_path(comment.card, comment), params: { content: \"👍\" }, as: :json\n    end\n\n    assert_response :created\n  end\n\n  test \"create access token with flat JSON\" do\n    assert_difference -> { identities(:kevin).access_tokens.count }, +1 do\n      post my_access_tokens_path, params: { description: \"Flat token\", permission: \"read\" }, as: :json\n    end\n\n    assert_response :created\n    assert_equal \"Flat token\", @response.parsed_body[\"description\"]\n  end\n\n  test \"update user with flat JSON\" do\n    put user_path(users(:david)), params: { name: \"Flat Name\" }, as: :json\n\n    assert_response :no_content\n    assert_equal \"Flat Name\", users(:david).reload.name\n  end\n\n  test \"create webhook with flat JSON\" do\n    board = boards(:writebook)\n\n    assert_difference -> { Webhook.count }, +1 do\n      post board_webhooks_path(board),\n        params: { name: \"Flat Webhook\", url: \"https://example.com/flat\", subscribed_actions: [ \"card_published\" ] },\n        as: :json\n    end\n\n    assert_response :created\n    assert_equal \"Flat Webhook\", Webhook.last.name\n  end\n\n  test \"update webhook with flat JSON\" do\n    webhook = webhooks(:active)\n\n    patch board_webhook_path(webhook.board, webhook),\n      params: { name: \"Flat Updated\", subscribed_actions: [ \"card_published\" ] },\n      as: :json\n\n    assert_response :success\n    assert_equal \"Flat Updated\", webhook.reload.name\n  end\n\n  test \"create signup with flat JSON\" do\n    sign_out\n    email = \"flatjson-#{SecureRandom.hex(6)}@example.com\"\n\n    untenanted do\n      assert_difference -> { Identity.count }, +1 do\n        post signup_path, params: { email_address: email }, as: :json\n      end\n    end\n\n    assert_response :created\n  end\n\n  test \"complete signup with flat JSON\" do\n    signup = Signup.new(email_address: \"flatjson-#{SecureRandom.hex(6)}@example.com\", full_name: \"Flat User\")\n    signup.create_identity || raise(\"Failed to create identity\")\n    logout_and_sign_in_as signup.identity\n\n    untenanted do\n      assert_difference -> { Account.count }, +1 do\n        post signup_completion_path, params: { full_name: \"Flat JSON User\" }, as: :json\n      end\n    end\n\n    assert_response :created\n  end\n\n  test \"update user via join with flat JSON\" do\n    logout_and_sign_in_as :david\n\n    post users_joins_path, params: { name: \"Flat Join\" }, as: :json\n\n    assert_response :no_content\n    assert_equal \"Flat Join\", users(:david).reload.name\n  end\n\n  private\n    def stub_dns_resolution(*ips)\n      dns_mock = mock(\"dns\")\n      dns_mock.stubs(:each_address).multiple_yields(*ips)\n      Resolv::DNS.stubs(:open).yields(dns_mock)\n    end\nend\n"
  },
  {
    "path": "test/controllers/api_test.rb",
    "content": "require \"test_helper\"\n\nclass ApiTest < ActionDispatch::IntegrationTest\n  setup do\n    @davids_bearer_token = bearer_token_env(identity_access_tokens(:davids_api_token).token)\n    @jasons_bearer_token = bearer_token_env(identity_access_tokens(:jasons_api_token).token)\n  end\n\n  test \"authenticate with user credentials\" do\n    identity = identities(:david)\n\n    untenanted do\n      post session_path(format: :json), params: { email_address: identity.email_address }\n      assert_response :created\n      pending_token = @response.parsed_body[\"pending_authentication_token\"]\n      assert pending_token.present?\n\n      magic_link = MagicLink.last\n      post session_magic_link_path(format: :json), params: { code: magic_link.code, pending_authentication_token: pending_token }\n      assert_response :success\n      assert @response.parsed_body[\"session_token\"].present?\n    end\n  end\n\n  test \"logout with user credentials\" do\n    identity = identities(:david)\n\n    untenanted do\n      post session_path(format: :json), params: { email_address: identity.email_address }\n      magic_link = MagicLink.last\n\n      assert_difference -> { identity.sessions.count }, +1 do\n        post session_magic_link_path(format: :json), params: { code: magic_link.code, pending_authentication_token: @response.parsed_body[\"pending_authentication_token\"] }\n      end\n      assert cookies[:session_token].present?\n\n      assert_difference -> { identity.sessions.count }, -1 do\n        delete session_path(format: :json)\n      end\n      assert_response :no_content\n      assert_not cookies[:session_token].present?\n    end\n  end\n\n  test \"authenticate with valid access token\" do\n    get boards_path(format: :json), env: @davids_bearer_token\n    assert_response :success\n  end\n\n  test \"fail to authenticate with invalid access token\" do\n    get boards_path(format: :json), env: bearer_token_env(\"nonsense\")\n    assert_response :unauthorized\n  end\n\n  test \"changing data requires a write-endowed access token\" do\n    post boards_path(format: :json), params: { board: { name: \"My new board\" } }, env: @jasons_bearer_token\n    assert_response :unauthorized\n\n    post boards_path(format: :json), params: { board: { name: \"My new board\" } }, env: @davids_bearer_token\n    assert_response :success\n  end\n\n  private\n    def bearer_token_env(token)\n      { \"HTTP_AUTHORIZATION\" => \"Bearer #{token}\" }\n    end\nend\n"
  },
  {
    "path": "test/controllers/boards/columns/closeds_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Boards::Columns::ClosedsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"show\" do\n    get board_columns_closed_path(boards(:writebook))\n    assert_response :success\n  end\n\n  test \"show as JSON\" do\n    get board_columns_closed_path(boards(:writebook)), as: :json\n    assert_response :success\n\n    assert_kind_of Array, @response.parsed_body\n  end\nend\n"
  },
  {
    "path": "test/controllers/boards/columns/not_nows_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Boards::Columns::NotNowsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"show\" do\n    get board_columns_not_now_path(boards(:writebook))\n    assert_response :success\n  end\n\n  test \"show as JSON\" do\n    get board_columns_not_now_path(boards(:writebook)), as: :json\n    assert_response :success\n\n    assert_kind_of Array, @response.parsed_body\n  end\nend\n"
  },
  {
    "path": "test/controllers/boards/columns/streams_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Boards::Columns::StreamsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"show\" do\n    get board_columns_stream_path(boards(:writebook))\n    assert_response :success\n  end\n\n  test \"show as JSON\" do\n    get board_columns_stream_path(boards(:writebook)), as: :json\n    assert_response :success\n\n    assert_kind_of Array, @response.parsed_body\n    assert response.headers[\"X-Total-Count\"].present?, \"Expected X-Total-Count header\"\n  end\nend\n"
  },
  {
    "path": "test/controllers/boards/columns_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Boards::ColumnsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"show\" do\n    get board_column_path(boards(:writebook), columns(:writebook_in_progress))\n    assert_response :success\n  end\n\n  test \"create\" do\n    assert_difference -> { boards(:writebook).columns.count }, +1 do\n      post board_columns_path(boards(:writebook)), params: { column: { name: \"New Column\" } }, as: :turbo_stream\n      assert_response :success\n    end\n\n    assert_equal \"New Column\", boards(:writebook).columns.last.name\n  end\n\n  test \"create refreshes adjacent columns\" do\n    board = boards(:writebook)\n\n    post board_columns_path(board), params: { column: { name: \"New Column\" } }, as: :turbo_stream\n\n    new_column = board.columns.find_by!(name: \"New Column\")\n    new_column.adjacent_columns.each do |adjacent_column|\n      assert_turbo_stream action: :replace, target: dom_id(adjacent_column)\n    end\n  end\n\n  test \"update\" do\n    column = columns(:writebook_in_progress)\n\n    assert_changes -> { column.reload.name }, from: \"In progress\", to: \"Updated Name\" do\n      put board_column_path(boards(:writebook), column), params: { column: { name: \"Updated Name\" } }, as: :turbo_stream\n      assert_response :success\n    end\n  end\n\n  test \"destroy\" do\n    column = columns(:writebook_in_progress)\n    adjacent_columns = column.adjacent_columns.to_a\n\n    delete board_column_path(column.board, column), as: :turbo_stream\n\n    assert_redirected_to board_path(column.board)\n  end\n\n  test \"index as JSON\" do\n    board = boards(:writebook)\n\n    get board_columns_path(board), as: :json\n\n    assert_response :success\n    assert_equal board.columns.count, @response.parsed_body.count\n  end\n\n  test \"show as JSON\" do\n    column = columns(:writebook_in_progress)\n\n    get board_column_path(column.board, column), as: :json\n\n    assert_response :success\n    assert_equal column.id, @response.parsed_body[\"id\"]\n  end\n\n  test \"create as JSON\" do\n    board = boards(:writebook)\n\n    assert_difference -> { board.columns.count }, +1 do\n      post board_columns_path(board), params: { column: { name: \"New Column\" } }, as: :json\n    end\n\n    assert_response :created\n    assert_equal board_column_path(board, Column.last, format: :json), @response.headers[\"Location\"]\n    assert_equal \"New Column\", @response.parsed_body[\"name\"]\n  end\n\n  test \"update as JSON\" do\n    column = columns(:writebook_in_progress)\n\n    put board_column_path(column.board, column), params: { column: { name: \"Updated Name\" } }, as: :json\n\n    assert_response :no_content\n    assert_equal \"Updated Name\", column.reload.name\n  end\n\n  test \"destroy as JSON\" do\n    column = columns(:writebook_on_hold)\n\n    assert_difference -> { column.board.columns.count }, -1 do\n      delete board_column_path(column.board, column), as: :json\n    end\n\n    assert_response :no_content\n  end\nend\n"
  },
  {
    "path": "test/controllers/boards/entropies_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Boards::EntropiesControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n    @board = boards(:writebook)\n  end\n\n  test \"update\" do\n    assert_no_difference -> { Current.account.entropy.reload.auto_postpone_period } do\n      put board_entropy_path(@board, format: :turbo_stream), params: { board: { auto_postpone_period_in_days: 90 } }\n\n      assert_equal 90.days, @board.entropy.reload.auto_postpone_period\n\n      assert_turbo_stream action: :replace, target: dom_id(@board, :entropy)\n    end\n  end\n\n  test \"update as JSON\" do\n    assert_no_difference -> { Current.account.entropy.reload.auto_postpone_period } do\n      put board_entropy_path(@board), params: { board: { auto_postpone_period_in_days: 90 } }, as: :json\n\n      assert_response :success\n      assert_equal 90.days, @board.entropy.reload.auto_postpone_period\n      assert_equal 90, @response.parsed_body[\"auto_postpone_period_in_days\"]\n    end\n  end\n\n  test \"update requires board admin permission\" do\n    logout_and_sign_in_as :jz\n\n    original_period = @board.entropy.auto_postpone_period\n\n    put board_entropy_path(@board, format: :turbo_stream), params: { board: { auto_postpone_period_in_days: 7 } }\n\n    assert_response :forbidden\n    assert_equal original_period, @board.entropy.reload.auto_postpone_period\n  end\n\n  test \"update rejects invalid auto_postpone_period\" do\n    original_period = @board.entropy.auto_postpone_period\n\n    put board_entropy_path(@board, format: :turbo_stream), params: { board: { auto_postpone_period_in_days: 1 } }\n\n    assert_response :unprocessable_entity\n    assert_equal original_period, @board.entropy.reload.auto_postpone_period\n  end\n\n  test \"update as JSON rejects invalid auto_postpone_period\" do\n    original_period = @board.entropy.auto_postpone_period\n\n    put board_entropy_path(@board), params: { board: { auto_postpone_period_in_days: 1 } }, as: :json\n\n    assert_response :unprocessable_entity\n    assert_equal original_period, @board.entropy.reload.auto_postpone_period\n  end\n\n  test \"update as JSON requires board admin permission\" do\n    logout_and_sign_in_as :jz\n\n    original_period = @board.entropy.auto_postpone_period\n\n    put board_entropy_path(@board), params: { board: { auto_postpone_period_in_days: 7 } }, as: :json\n\n    assert_response :forbidden\n    assert_equal original_period, @board.entropy.reload.auto_postpone_period\n  end\nend\n"
  },
  {
    "path": "test/controllers/boards/involvements_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Boards::InvolvementsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"update\" do\n    board = boards(:writebook)\n    board.access_for(users(:kevin)).access_only!\n\n    assert_changes -> { board.access_for(users(:kevin)).involvement }, from: \"access_only\", to: \"watching\" do\n      put board_involvement_path(board, involvement: \"watching\")\n    end\n\n    assert_response :success\n  end\n\n  test \"update as JSON\" do\n    board = boards(:writebook)\n    board.access_for(users(:kevin)).access_only!\n\n    assert_changes -> { board.access_for(users(:kevin)).involvement }, from: \"access_only\", to: \"watching\" do\n      put board_involvement_path(board), params: { involvement: \"watching\" }, as: :json\n    end\n\n    assert_response :no_content\n  end\nend\n"
  },
  {
    "path": "test/controllers/boards/publications_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Boards::PublicationsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n    @board = boards(:writebook)\n  end\n\n  test \"publish a board\" do\n    assert_not @board.published?\n\n    assert_changes -> { @board.reload.published? }, from: false, to: true do\n      post board_publication_path(@board, format: :turbo_stream)\n    end\n\n    assert_turbo_stream action: :replace, target: dom_id(@board, :publication)\n  end\n\n  test \"unpublish a board\" do\n    @board.publish\n    assert @board.published?\n\n    assert_changes -> { @board.reload.published? }, from: true, to: false do\n      delete board_publication_path(@board, format: :turbo_stream)\n    end\n\n    assert_turbo_stream action: :replace, target: dom_id(@board, :publication)\n  end\n\n  test \"publish a board via JSON\" do\n    assert_not @board.published?\n\n    assert_changes -> { @board.reload.published? }, from: false, to: true do\n      post board_publication_path(@board), as: :json\n    end\n\n    assert_response :created\n    body = @response.parsed_body\n    @board.reload\n    assert_equal @board.name, body[\"name\"]\n    assert_equal published_board_url(@board), body[\"public_url\"]\n  end\n\n  test \"unpublish a board via JSON\" do\n    @board.publish\n    assert @board.published?\n\n    assert_changes -> { @board.reload.published? }, from: true, to: false do\n      delete board_publication_path(@board), as: :json\n    end\n\n    assert_response :no_content\n  end\n\n  test \"publish requires board admin permission\" do\n    logout_and_sign_in_as :jz\n\n    assert_not @board.published?\n\n    post board_publication_path(@board, format: :turbo_stream)\n\n    assert_response :forbidden\n    assert_not @board.reload.published?\n  end\n\n  test \"unpublish requires board admin permission\" do\n    logout_and_sign_in_as :jz\n\n    @board.publish\n    assert @board.published?\n\n    delete board_publication_path(@board, format: :turbo_stream)\n\n    assert_response :forbidden\n    assert @board.reload.published?\n  end\nend\n"
  },
  {
    "path": "test/controllers/boards_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass BoardsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"new\" do\n    get new_board_path\n    assert_response :success\n  end\n\n  test \"show\" do\n    get board_path(boards(:writebook))\n    assert_response :success\n  end\n\n  test \"invalidates page title cache when account updates\" do\n    get board_path(boards(:writebook))\n    etag = response.headers[\"ETag\"]\n\n    accounts(\"37s\").update!(name: \"Renamed Account\")\n\n    get board_path(boards(:writebook)), headers: { \"If-None-Match\" => etag }\n    assert_response :success\n  end\n\n  test \"create\" do\n    assert_difference -> { Board.count }, +1 do\n      post boards_path, params: { board: { name: \"Remodel Punch List\" } }\n    end\n\n    board = Board.last\n    assert_redirected_to board_path(board)\n    assert_includes board.users, users(:kevin)\n    assert_equal \"Remodel Punch List\", board.name\n  end\n\n  test \"edit\" do\n    get edit_board_path(boards(:writebook))\n    assert_response :success\n  end\n\n  test \"edit renders 11-day auto-close option last on the knob\" do\n    get edit_board_path(boards(:writebook))\n    assert_response :success\n\n    assert_select \"input[type=radio][name='board[auto_postpone_period_in_days]']\" do |options|\n      assert_equal Entropy::AUTO_POSTPONE_PERIODS_IN_DAYS.map(&:to_s), options.map { |option| option[\"value\"] }\n      assert_equal \"11\", options.last[\"value\"]\n    end\n  end\n\n  test \"update\" do\n    patch board_path(boards(:writebook)), params: {\n      board: {\n        name: \"Writebook bugs\",\n        all_access: false,\n        auto_postpone_period_in_days: 7\n      },\n      user_ids: users(:kevin, :jz).pluck(:id)\n    }\n\n    assert_redirected_to edit_board_path(boards(:writebook))\n    assert_equal \"Writebook bugs\", boards(:writebook).reload.name\n    assert_equal users(:kevin, :jz).sort, boards(:writebook).users.sort\n    assert_equal 7.days, entropies(:writebook_board).auto_postpone_period\n    assert_not boards(:writebook).all_access?\n  end\n\n  test \"update redirects to root when user removes themselves from board\" do\n    board = boards(:writebook)\n\n    patch board_path(board), params: {\n      board: { name: \"Updated name\", all_access: false },\n      user_ids: users(:david, :jz).pluck(:id)\n    }\n\n    assert_redirected_to root_path\n    assert_not board.reload.users.include?(users(:kevin))\n  end\n\n  test \"update board with granular permissions, submitting no user ids\" do\n    assert_not boards(:private).all_access?\n\n    boards(:private).users = [ users(:kevin) ]\n    boards(:private).save!\n\n    patch board_path(boards(:private)), params: {\n      board: { name: \"Renamed\" }\n    }\n\n    assert_redirected_to edit_board_path(boards(:private))\n    assert_equal \"Renamed\", boards(:private).reload.name\n    assert_equal [ users(:kevin) ], boards(:private).users\n    assert_not boards(:private).all_access?\n  end\n\n  test \"update all access\" do\n    board = Current.set(account: accounts(\"37s\"), session: sessions(:kevin), user: users(:kevin)) do\n      Board.create! name: \"New board\", all_access: false\n    end\n    assert_equal [ users(:kevin) ], board.users\n\n    patch board_path(board), params: { board: { name: \"Bugs\", all_access: true } }\n\n    assert_redirected_to edit_board_path(board)\n    assert board.reload.all_access?\n    assert_equal accounts(\"37s\").users.active.sort, board.users.sort\n  end\n\n  test \"destroy\" do\n    board = boards(:writebook)\n    delete board_path(board)\n    assert_redirected_to root_path\n    assert_raises(ActiveRecord::RecordNotFound) { board.reload }\n  end\n\n  test \"non-admin cannot change all_access on board they don't own\" do\n    logout_and_sign_in_as :jz\n\n    board = boards(:writebook)\n    original_all_access = board.all_access\n\n    patch board_path(board), params: { board: { all_access: !original_all_access } }\n\n    assert_response :forbidden\n    assert_equal original_all_access, board.reload.all_access\n  end\n\n  test \"non-admin cannot change individual user accesses on board they don't own\" do\n    logout_and_sign_in_as :jz\n\n    board = boards(:writebook)\n    original_users = board.users.sort\n\n    patch board_path(board), params: {\n      board: { name: board.name },\n      user_ids: [ users(:jz).id ]\n    }\n\n    assert_response :forbidden\n    assert_equal original_users, board.reload.users.sort\n  end\n\n  test \"non-admin cannot change board name on board they don't own\" do\n    logout_and_sign_in_as :jz\n\n    board = boards(:writebook)\n    original_name = board.name\n\n    patch board_path(board), params: {\n      board: { name: \"Hacked Board Name\" }\n    }\n\n    assert_response :forbidden\n    assert_equal original_name, board.reload.name\n  end\n\n  test \"non-admin cannot destroy board they don't own\" do\n    logout_and_sign_in_as :jz\n\n    board = boards(:writebook)\n    delete board_path(board)\n\n    assert_response :forbidden\n  end\n\n  test \"disables select all/none buttons for non-privileged user\" do\n    logout_and_sign_in_as :jz\n    assert_not users(:jz).can_administer_board?(boards(:writebook))\n\n    get edit_board_path(boards(:writebook))\n\n    assert_response :success\n    assert_select \"button[disabled]\", text: \"Select all\"\n    assert_select \"button[disabled]\", text: \"Select none\"\n  end\n\n  test \"enables select all/none buttons for privileged user\" do\n    assert users(:kevin).can_administer_board?(boards(:writebook))\n\n    get edit_board_path(boards(:writebook))\n\n    assert_response :success\n    assert_select \"button:not([disabled])\", text: \"Select all\"\n    assert_select \"button:not([disabled])\", text: \"Select none\"\n  end\n\n  test \"access toggle disabled state is cached correctly\" do\n    board = boards(:writebook)\n    david = users(:david)\n\n    with_actionview_partial_caching do\n      # privileged user\n      assert users(:kevin).can_administer_board?(board)\n\n      get edit_board_path(board)\n\n      assert_response :success\n      assert_select \"input.switch__input[name='user_ids[]'][value='#{david.id}']:not([disabled])\"\n\n      # unprivileged user\n      logout_and_sign_in_as :jz\n      assert_not users(:jz).can_administer_board?(board)\n\n      get edit_board_path(board)\n\n      assert_response :success\n      assert_select \"input.switch__input[name='user_ids[]'][value='#{david.id}'][disabled]\"\n    end\n  end\n\n  test \"index as JSON\" do\n    get boards_path, as: :json\n    assert_response :success\n    assert_equal users(:kevin).boards.count, @response.parsed_body.count\n  end\n\n  test \"index as JSON paginates and preserves recently-accessed order\" do\n    account = accounts(\"37s\")\n    kevin = users(:kevin)\n    baseline_accessed_at = 3.days.ago.change(usec: 0)\n\n    kevin.accesses.order(:id).each_with_index do |access, index|\n      access.update!(accessed_at: baseline_accessed_at + index.seconds)\n    end\n\n    200.times do |index|\n      board = Board.create!(\n        name: \"Recent board #{index}\",\n        creator: kevin,\n        account: account,\n        all_access: false\n      )\n      board.access_for(kevin).update!(accessed_at: baseline_accessed_at + (index + 1).minutes)\n    end\n\n    expected_ids = kevin.boards.ordered_by_recently_accessed.pluck(:id)\n    actual_ids = []\n    next_page = boards_path(format: :json)\n    page_count = 0\n\n    while next_page\n      get next_page, as: :json\n      assert_response :success\n\n      page_count += 1\n      actual_ids.concat(@response.parsed_body.map { |board| board[\"id\"] })\n      next_page = next_page_from_link_header(@response.headers[\"Link\"])\n    end\n\n    assert_equal expected_ids, actual_ids\n    assert_operator page_count, :>, 1\n  end\n\n  test \"show as JSON\" do\n    get board_path(boards(:writebook)), as: :json\n    assert_response :success\n    assert_equal boards(:writebook).name, @response.parsed_body[\"name\"]\n    assert_equal boards(:writebook).auto_postpone_period_in_days, @response.parsed_body[\"auto_postpone_period_in_days\"]\n  end\n\n  test \"show as JSON includes public_url when published\" do\n    board = boards(:writebook)\n    board.publish\n\n    get board_path(board), as: :json\n    assert_response :success\n    assert_equal published_board_url(board), @response.parsed_body[\"public_url\"]\n  end\n\n  test \"show as JSON excludes public_url when not published\" do\n    board = boards(:writebook)\n    assert_not board.published?\n\n    get board_path(board), as: :json\n    assert_response :success\n    assert_nil @response.parsed_body[\"public_url\"]\n  end\n\n  test \"create as JSON\" do\n    assert_difference -> { Board.count }, +1 do\n      post boards_path, params: { board: { name: \"My new board\" } }, as: :json\n    end\n\n    assert_response :created\n    assert_equal board_path(Board.last, format: :json), @response.headers[\"Location\"]\n    assert_equal \"My new board\", @response.parsed_body[\"name\"]\n  end\n\n  test \"update as JSON\" do\n    board = boards(:writebook)\n\n    put board_path(board), params: { board: { name: \"Updated Name\" } }, as: :json\n\n    assert_response :no_content\n    assert_equal \"Updated Name\", board.reload.name\n  end\n\n  test \"destroy as JSON\" do\n    board = boards(:writebook)\n\n    assert_difference -> { Board.count }, -1 do\n      delete board_path(board), as: :json\n    end\n\n    assert_response :no_content\n  end\n\n  test \"index avoids N+1 queries on creator and identity\" do\n    assert_queries_match(/FROM [`\"]users[`\"].* IN \\(/, count: 1) do\n      assert_queries_match(/FROM [`\"]identities[`\"].* IN \\(/, count: 1) do\n        get boards_path, as: :json\n        assert_response :success\n      end\n    end\n\n    json = @response.parsed_body\n    first_board = json.first\n    assert first_board[\"creator\"].present?\n    assert first_board[\"creator\"][\"email_address\"].present?\n  end\n\n  private\n    def next_page_from_link_header(link_header)\n      url = link_header&.match(/<([^>]+)>;\\s*rel=\"next\"/)&.captures&.first\n      URI.parse(url).request_uri if url\n    end\nend\n"
  },
  {
    "path": "test/controllers/cards/assignments_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Cards::AssignmentsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"new\" do\n    get new_card_assignment_path(cards(:logo))\n    assert_response :success\n  end\n\n  test \"create\" do\n    assert_changes \"cards(:logo).reload.assigned_to?(users(:david))\", from: false, to: true do\n      post card_assignments_path(cards(:logo)), params: { assignee_id: users(:david).id }, as: :turbo_stream\n      assert_meta_replaced(cards(:logo))\n    end\n\n    assert_changes \"cards(:logo).reload.assigned_to?(users(:david))\", from: true, to: false do\n      post card_assignments_path(cards(:logo)), params: { assignee_id: users(:david).id }, as: :turbo_stream\n      assert_meta_replaced(cards(:logo))\n    end\n  end\n\n  test \"create as JSON\" do\n    card = cards(:logo)\n\n    assert_not card.assigned_to?(users(:david))\n\n    post card_assignments_path(card), params: { assignee_id: users(:david).id }, as: :json\n    assert_response :no_content\n    assert card.reload.assigned_to?(users(:david))\n\n    post card_assignments_path(card), params: { assignee_id: users(:david).id }, as: :json\n    assert_response :no_content\n    assert_not card.reload.assigned_to?(users(:david))\n  end\n\n  private\n    def assert_meta_replaced(card)\n      assert_turbo_stream action: :replace, target: dom_id(card, :meta)\n    end\nend\n"
  },
  {
    "path": "test/controllers/cards/boards_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Cards::BoardsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"update changes card board\" do\n    card = cards(:logo)\n    new_board = boards(:private)\n\n    assert_not_equal new_board, card.board\n\n    assert_changes -> { card.reload.board }, from: card.board, to: new_board do\n      put card_board_path(card), params: { board_id: new_board.id }\n    end\n\n    assert_redirected_to card\n  end\n\n  test \"update as JSON\" do\n    card = cards(:logo)\n    new_board = boards(:private)\n\n    assert_not_equal new_board, card.board\n\n    put card_board_path(card), params: { board_id: new_board.id }, as: :json\n\n    assert_response :no_content\n    assert_equal new_board, card.reload.board\n  end\nend\n"
  },
  {
    "path": "test/controllers/cards/closures_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Cards::ClosuresControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"create\" do\n    card = cards(:logo)\n\n    assert_changes -> { card.reload.closed? }, from: false, to: true do\n      post card_closure_path(card), as: :turbo_stream\n      assert_card_container_rerendered(card)\n    end\n  end\n\n  test \"destroy\" do\n    card = cards(:shipping)\n\n    assert_changes -> { card.reload.closed? }, from: true, to: false do\n      delete card_closure_path(card), as: :turbo_stream\n      assert_card_container_rerendered(card)\n    end\n  end\n\n  test \"create as JSON\" do\n    card = cards(:logo)\n\n    assert_not card.closed?\n\n    post card_closure_path(card), as: :json\n\n    assert_response :no_content\n    assert card.reload.closed?\n  end\n\n  test \"destroy as JSON\" do\n    card = cards(:shipping)\n\n    assert card.closed?\n\n    delete card_closure_path(card), as: :json\n\n    assert_response :no_content\n    assert_not card.reload.closed?\n  end\nend\n"
  },
  {
    "path": "test/controllers/cards/comments/reactions_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Cards::Comments::ReactionsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :david\n    @comment = comments(:logo_agreement_jz)\n    @card = @comment.card\n  end\n\n  test \"index\" do\n    get card_comment_reactions_path(@card, @comment)\n    assert_response :success\n  end\n\n  test \"create\" do\n    assert_difference -> { @comment.reactions.count }, 1 do\n      post card_comment_reactions_path(@comment.card, @comment, format: :turbo_stream), params: { reaction: { content: \"Great work!\" } }\n      assert_turbo_stream action: :replace, target: dom_id(@comment, :reacting)\n    end\n  end\n\n  test \"destroy\" do\n    reaction = reactions(:david)\n    assert_difference -> { @comment.reactions.count }, -1 do\n      delete card_comment_reaction_path(@comment.card, @comment, reaction, format: :turbo_stream)\n      assert_turbo_stream action: :remove, target: dom_id(reaction)\n    end\n  end\n\n  test \"non-owner cannot destroy reaction\" do\n    reaction = reactions(:kevin)\n\n    assert_no_difference -> { @comment.reactions.count } do\n      delete card_comment_reaction_path(@comment.card, @comment, reaction, format: :turbo_stream)\n      assert_response :forbidden\n    end\n  end\n\n  test \"index as JSON\" do\n    get card_comment_reactions_path(@card, @comment), as: :json\n\n    assert_response :success\n    assert_equal @comment.reactions.count, @response.parsed_body.count\n  end\n\n  test \"create as JSON\" do\n    assert_difference -> { @comment.reactions.count }, 1 do\n      post card_comment_reactions_path(@card, @comment), params: { reaction: { content: \"👍\" } }, as: :json\n    end\n\n    assert_response :created\n    assert_equal \"👍\", @response.parsed_body[\"content\"]\n  end\n\n  test \"destroy as JSON\" do\n    reaction = reactions(:david)\n\n    assert_difference -> { @comment.reactions.count }, -1 do\n      delete card_comment_reaction_path(@card, @comment, reaction), as: :json\n    end\n\n    assert_response :no_content\n  end\nend\n"
  },
  {
    "path": "test/controllers/cards/comments_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Cards::CommentsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"create\" do\n    assert_difference -> { cards(:logo).comments.count }, +1 do\n      post card_comments_path(cards(:logo)), params: { comment: { body: \"Agreed.\" } }, as: :turbo_stream\n    end\n\n    assert_response :success\n  end\n\n  test \"create on draft card is forbidden\" do\n    draft_card = boards(:writebook).cards.create!(status: :drafted, creator: users(:kevin))\n\n    assert_no_difference -> { draft_card.comments.count } do\n      post card_comments_path(draft_card), params: { comment: { body: \"This should be forbidden\" } }, as: :json\n    end\n\n    assert_response :forbidden\n  end\n\n  test \"update\" do\n    put card_comment_path(cards(:logo), comments(:logo_agreement_kevin)), params: { comment: { body: \"I've changed my mind\" } }, as: :turbo_stream\n\n    assert_response :success\n    assert_action_text \"I've changed my mind\", comments(:logo_agreement_kevin).reload.body\n  end\n\n  test \"update another user's comment\" do\n    assert_no_changes -> { comments(:logo_agreement_jz).reload.body.to_s } do\n      put card_comment_path(cards(:logo), comments(:logo_agreement_jz)), params: { comment: { body: \"I've changed my mind\" } }, as: :turbo_stream\n    end\n\n    assert_response :forbidden\n  end\n\n  test \"index as JSON\" do\n    card = cards(:logo)\n\n    get card_comments_path(card), as: :json\n\n    assert_response :success\n    assert_equal card.comments.count, @response.parsed_body.count\n  end\n\n  test \"create as JSON\" do\n    card = cards(:logo)\n\n    assert_difference -> { card.comments.count }, +1 do\n      post card_comments_path(card), params: { comment: { body: \"New comment\" } }, as: :json\n    end\n\n    assert_response :created\n    assert_equal card_comment_path(card, Comment.last, format: :json), @response.headers[\"Location\"]\n    assert_equal Comment.last.id, @response.parsed_body[\"id\"]\n  end\n\n  test \"create as JSON with custom created_at\" do\n    card = cards(:logo)\n    custom_time = Time.utc(2024, 1, 15, 10, 30, 0)\n\n    assert_difference -> { card.comments.count }, +1 do\n      post card_comments_path(card), params: { comment: { body: \"Backdated comment\", created_at: custom_time } }, as: :json\n    end\n\n    assert_response :created\n    assert_equal custom_time, Comment.last.created_at\n  end\n\n  test \"show as JSON\" do\n    comment = comments(:logo_agreement_kevin)\n\n    get card_comment_path(comment.card, comment), as: :json\n\n    assert_response :success\n    assert_equal comment.id, @response.parsed_body[\"id\"]\n    assert_equal comment.card.id, @response.parsed_body.dig(\"card\", \"id\")\n    assert_equal card_url(comment.card), @response.parsed_body.dig(\"card\", \"url\")\n    assert_equal card_comment_reactions_url(comment.card, comment), @response.parsed_body[\"reactions_url\"]\n    assert_equal card_comment_url(comment.card, comment), @response.parsed_body[\"url\"]\n  end\n\n  test \"create as JSON with flat params\" do\n    card = cards(:logo)\n\n    assert_difference -> { card.comments.count }, +1 do\n      post card_comments_path(card), params: { body: \"Flat comment\" }, as: :json\n    end\n\n    assert_response :created\n    assert_equal \"Flat comment\", Comment.last.body.to_plain_text\n  end\n\n  test \"update as JSON with flat params\" do\n    comment = comments(:logo_agreement_kevin)\n\n    put card_comment_path(cards(:logo), comment), params: { body: \"Flat update\" }, as: :json\n\n    assert_response :success\n    assert_equal \"Flat update\", comment.reload.body.to_plain_text\n  end\n\n  test \"update as JSON\" do\n    comment = comments(:logo_agreement_kevin)\n\n    put card_comment_path(cards(:logo), comment), params: { comment: { body: \"Updated comment\" } }, as: :json\n\n    assert_response :success\n    assert_equal \"Updated comment\", comment.reload.body.to_plain_text\n  end\n\n  test \"destroy as JSON\" do\n    comment = comments(:logo_agreement_kevin)\n\n    delete card_comment_path(cards(:logo), comment), as: :json\n\n    assert_response :no_content\n    assert_not Comment.exists?(comment.id)\n  end\nend\n"
  },
  {
    "path": "test/controllers/cards/drafts_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Cards::DraftsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"show\" do\n    card = boards(:writebook).cards.create!(creator: users(:kevin), status: :drafted)\n\n    get card_draft_path(card)\n    assert_response :success\n  end\n\n  test \"show redirects to card when published\" do\n    card = cards(:logo)\n\n    get card_draft_path(card)\n    assert_redirected_to card\n  end\nend\n"
  },
  {
    "path": "test/controllers/cards/goldnesses_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Cards::GoldnessesControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"create\" do\n    assert_changes -> { cards(:text).reload.golden? }, from: false, to: true do\n      post card_goldness_path(cards(:text)), as: :turbo_stream\n      assert_card_container_rerendered(cards(:text))\n    end\n  end\n\n  test \"destroy\" do\n    assert_changes -> { cards(:logo).reload.golden? }, from: true, to: false do\n      delete card_goldness_path(cards(:logo)), as: :turbo_stream\n      assert_card_container_rerendered(cards(:logo))\n    end\n  end\n\n  test \"create as JSON\" do\n    card = cards(:text)\n\n    assert_not card.golden?\n\n    post card_goldness_path(card), as: :json\n\n    assert_response :no_content\n    assert card.reload.golden?\n  end\n\n  test \"destroy as JSON\" do\n    card = cards(:logo)\n\n    assert card.golden?\n\n    delete card_goldness_path(card), as: :json\n\n    assert_response :no_content\n    assert_not card.reload.golden?\n  end\nend\n"
  },
  {
    "path": "test/controllers/cards/images_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Cards::ImagesControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"destroy\" do\n    card = cards(:logo)\n    card.image.attach(io: file_fixture(\"moon.jpg\").open, filename: \"moon.jpg\")\n\n    assert card.image.attached?\n\n    delete card_image_path(card)\n\n    assert_redirected_to card\n    assert_not card.reload.image.attached?\n  end\n\n  test \"destroy as JSON\" do\n    card = cards(:logo)\n    card.image.attach(io: file_fixture(\"moon.jpg\").open, filename: \"moon.jpg\")\n\n    assert card.image.attached?\n\n    delete card_image_path(card), as: :json\n\n    assert_response :no_content\n    assert_not card.reload.image.attached?\n  end\nend\n"
  },
  {
    "path": "test/controllers/cards/not_nows_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Cards::NotNowsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"create\" do\n    card = cards(:logo)\n\n    assert_changes -> { card.reload.postponed? }, from: false, to: true do\n      post card_not_now_path(card), as: :turbo_stream\n      assert_card_container_rerendered(card)\n    end\n  end\n\n  test \"create as JSON\" do\n    card = cards(:logo)\n\n    assert_not card.postponed?\n\n    post card_not_now_path(card), as: :json\n\n    assert_response :no_content\n    assert card.reload.postponed?\n  end\nend\n"
  },
  {
    "path": "test/controllers/cards/pins_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Cards::PinsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"create\" do\n    assert_changes -> { cards(:layout).pinned_by?(users(:kevin)) }, from: false, to: true do\n      perform_enqueued_jobs do\n        assert_turbo_stream_broadcasts([ users(:kevin), :pins_tray ], count: 1) do\n          post card_pin_path(cards(:layout)), as: :turbo_stream\n        end\n      end\n    end\n\n    assert_response :success\n  end\n\n  test \"create as JSON\" do\n    card = cards(:layout)\n\n    assert_not card.pinned_by?(users(:kevin))\n\n    post card_pin_path(card), as: :json\n\n    assert_response :no_content\n    assert card.reload.pinned_by?(users(:kevin))\n  end\n\n  test \"destroy\" do\n    assert_changes -> { cards(:shipping).pinned_by?(users(:kevin)) }, from: true, to: false do\n      perform_enqueued_jobs do\n        assert_turbo_stream_broadcasts([ users(:kevin), :pins_tray ], count: 1) do\n          delete card_pin_path(cards(:shipping)), as: :turbo_stream\n        end\n      end\n    end\n\n    assert_response :success\n  end\n\n  test \"destroy as JSON\" do\n    card = cards(:shipping)\n\n    assert card.pinned_by?(users(:kevin))\n\n    delete card_pin_path(card), as: :json\n\n    assert_response :no_content\n    assert_not card.reload.pinned_by?(users(:kevin))\n  end\nend\n"
  },
  {
    "path": "test/controllers/cards/previews_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Cards::PreviewsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"index\" do\n    get cards_previews_path(format: :turbo_stream)\n\n    assert_response :success\n  end\nend\n"
  },
  {
    "path": "test/controllers/cards/publishes_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Cards::PublishesControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"create\" do\n    card = cards(:logo)\n    card.drafted!\n\n    assert_changes -> { card.reload.published? }, from: false, to: true do\n      post card_publish_path(card)\n    end\n\n    assert_redirected_to card.board\n  end\n\n  test \"create as JSON\" do\n    card = cards(:logo)\n    card.drafted!\n\n    assert_changes -> { card.reload.published? }, from: false, to: true do\n      post card_publish_path(card), as: :json\n    end\n\n    assert_response :created\n  end\n\n  test \"create and add another\" do\n    card = cards(:logo)\n    card.drafted!\n\n    assert_changes -> { card.reload.published? }, from: false, to: true do\n      assert_difference -> { Card.count }, +1 do\n        post card_publish_path(card, creation_type: \"add_another\")\n      end\n    end\n\n    new_card = Card.last\n    assert new_card.drafted?\n    assert_redirected_to card_draft_path(new_card)\n  end\nend\n"
  },
  {
    "path": "test/controllers/cards/reactions_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Cards::ReactionsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :david\n    @card = cards(:logo)\n  end\n\n  test \"index\" do\n    get card_reactions_path(@card)\n    assert_response :success\n  end\n\n  test \"new\" do\n    get new_card_reaction_path(@card)\n    assert_response :success\n  end\n\n  test \"create\" do\n    assert_difference -> { @card.reactions.count }, 1 do\n      post card_reactions_path(@card, format: :turbo_stream), params: { reaction: { content: \"Great work!\" } }\n      assert_turbo_stream action: :replace, target: dom_id(@card, :reacting)\n    end\n  end\n\n  test \"destroy\" do\n    reaction = reactions(:logo_card_david)\n    assert_difference -> { @card.reactions.count }, -1 do\n      delete card_reaction_path(@card, reaction, format: :turbo_stream)\n      assert_turbo_stream action: :remove, target: dom_id(reaction)\n    end\n  end\n\n  test \"non-owner cannot destroy reaction\" do\n    reaction = reactions(:logo_card_kevin)\n\n    assert_no_difference -> { @card.reactions.count } do\n      delete card_reaction_path(@card, reaction, format: :turbo_stream)\n      assert_response :forbidden\n    end\n  end\n\n  test \"index as JSON\" do\n    get card_reactions_path(@card), as: :json\n\n    assert_response :success\n    assert_equal @card.reactions.count, @response.parsed_body.count\n  end\n\n  test \"create as JSON\" do\n    assert_difference -> { @card.reactions.count }, 1 do\n      post card_reactions_path(@card), params: { reaction: { content: \"👍\" } }, as: :json\n    end\n\n    assert_response :created\n    assert_equal \"👍\", @response.parsed_body[\"content\"]\n  end\n\n  test \"destroy as JSON\" do\n    reaction = reactions(:logo_card_david)\n\n    assert_difference -> { @card.reactions.count }, -1 do\n      delete card_reaction_path(@card, reaction), as: :json\n    end\n\n    assert_response :no_content\n  end\nend\n"
  },
  {
    "path": "test/controllers/cards/readings_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Cards::ReadingsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"create\" do\n    freeze_time\n\n    assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do\n      assert_changes -> { accesses(:writebook_kevin).reload.accessed_at }, from: nil, to: Time.current do\n        post card_reading_url(cards(:logo)), as: :turbo_stream\n      end\n    end\n\n    assert_response :success\n  end\n\n  test \"read notification on card visit\" do\n    assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do\n      post card_reading_path(cards(:logo)), as: :turbo_stream\n    end\n\n    assert_response :success\n  end\n\n  test \"destroy\" do\n    freeze_time\n\n    notifications(:logo_assignment_kevin).read\n\n    assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: true, to: false do\n      assert_changes -> { accesses(:writebook_kevin).reload.accessed_at }, to: Time.current do\n        delete card_reading_url(cards(:logo)), as: :turbo_stream\n      end\n    end\n\n    assert_response :success\n  end\n\n  test \"create as JSON\" do\n    post card_reading_url(cards(:logo)), as: :json\n    assert_response :created\n  end\n\n  test \"destroy as JSON\" do\n    notifications(:logo_assignment_kevin).read\n\n    delete card_reading_url(cards(:logo)), as: :json\n    assert_response :no_content\n  end\n\n  test \"unread notification on destroy\" do\n    notifications(:logo_assignment_kevin).read\n\n    assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: true, to: false do\n      delete card_reading_path(cards(:logo)), as: :turbo_stream\n    end\n\n    assert_response :success\n  end\nend\n"
  },
  {
    "path": "test/controllers/cards/self_assignments_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Cards::SelfAssignmentsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"create assigns to current user\" do\n    card = cards(:layout)\n\n    assert_not card.assigned_to?(users(:kevin))\n\n    post card_self_assignment_path(card), as: :turbo_stream\n    assert_response :success\n    assert_meta_replaced(card)\n    assert card.reload.assigned_to?(users(:kevin))\n  end\n\n  test \"create toggles off when already assigned\" do\n    card = cards(:logo)\n\n    assert card.assigned_to?(users(:kevin))\n\n    post card_self_assignment_path(card), as: :turbo_stream\n    assert_response :success\n    assert_meta_replaced(card)\n    assert_not card.reload.assigned_to?(users(:kevin))\n  end\n\n  test \"create as JSON\" do\n    card = cards(:layout)\n\n    assert_not card.assigned_to?(users(:kevin))\n\n    post card_self_assignment_path(card), as: :json\n    assert_response :no_content\n    assert card.reload.assigned_to?(users(:kevin))\n  end\n\n  private\n    def assert_meta_replaced(card)\n      assert_turbo_stream action: :replace, target: dom_id(card, :meta)\n    end\nend\n"
  },
  {
    "path": "test/controllers/cards/steps_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Cards::StepsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"create\" do\n    card = cards(:logo)\n\n    assert_difference -> { card.steps.count }, +1 do\n      post card_steps_path(card), params: { step: { content: \"Research alternatives\" } }, as: :turbo_stream\n      assert_turbo_stream action: :before, target: dom_id(card, :new_step)\n    end\n\n    assert_equal \"Research alternatives\", card.steps.last.content\n  end\n\n  test \"update\" do\n    card = cards(:logo)\n    step = card.steps.create!(content: \"Original content\")\n\n    assert_changes -> { step.reload.content }, from: \"Original content\", to: \"Updated content\" do\n      put card_step_path(card, step), params: { step: { content: \"Updated content\" } }, as: :turbo_stream\n      assert_turbo_stream action: :replace, target: dom_id(step)\n    end\n  end\n\n  test \"destroy\" do\n    card = cards(:logo)\n    step = card.steps.create!(content: \"Step to delete\")\n\n    assert_difference -> { card.steps.count }, -1 do\n      delete card_step_path(card, step), as: :turbo_stream\n      assert_turbo_stream action: :remove, target: dom_id(step)\n    end\n  end\n\n  test \"toggle completion\" do\n    card = cards(:logo)\n    step = card.steps.create!(content: \"Test step\", completed: false)\n\n    # Toggle to completed\n    assert_changes -> { step.reload.completed? }, from: false, to: true do\n      put card_step_path(card, step), params: { step: { completed: \"1\" } }, as: :turbo_stream\n      assert_turbo_stream action: :replace, target: dom_id(step)\n    end\n\n    # Toggle back to incomplete\n    assert_changes -> { step.reload.completed? }, from: true, to: false do\n      put card_step_path(card, step), params: { step: { completed: \"0\" } }, as: :turbo_stream\n      assert_turbo_stream action: :replace, target: dom_id(step)\n    end\n  end\n\n  test \"index as JSON\" do\n    card = cards(:logo)\n    card.steps.create!(content: \"Step one\")\n    card.steps.create!(content: \"Step two\", completed: true)\n\n    get card_steps_path(card), as: :json\n    assert_response :success\n\n    body = @response.parsed_body\n    assert_equal 2, body.size\n    assert_equal \"Step one\", body.first[\"content\"]\n  end\n\n  test \"create as JSON\" do\n    card = cards(:logo)\n\n    assert_difference -> { card.steps.count }, +1 do\n      post card_steps_path(card), params: { step: { content: \"New step\" } }, as: :json\n    end\n\n    assert_response :created\n    assert_equal card_step_path(card, Step.last, format: :json), @response.headers[\"Location\"]\n    assert_equal \"New step\", @response.parsed_body[\"content\"]\n  end\n\n  test \"show as JSON\" do\n    card = cards(:logo)\n    step = card.steps.create!(content: \"Test step\")\n\n    get card_step_path(card, step), as: :json\n\n    assert_response :success\n    assert_equal step.id, @response.parsed_body[\"id\"]\n    assert_equal \"Test step\", @response.parsed_body[\"content\"]\n  end\n\n  test \"update as JSON\" do\n    card = cards(:logo)\n    step = card.steps.create!(content: \"Original\")\n\n    put card_step_path(card, step), params: { step: { content: \"Updated\" } }, as: :json\n\n    assert_response :success\n    assert_equal \"Updated\", step.reload.content\n    assert_equal \"Updated\", @response.parsed_body[\"content\"]\n  end\n\n  test \"destroy as JSON\" do\n    card = cards(:logo)\n    step = card.steps.create!(content: \"To delete\")\n\n    delete card_step_path(card, step), as: :json\n\n    assert_response :no_content\n    assert_not Step.exists?(step.id)\n  end\nend\n"
  },
  {
    "path": "test/controllers/cards/taggings_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Cards::TaggingsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"new\" do\n    get new_card_tagging_path(cards(:logo))\n    assert_response :success\n  end\n\n  test \"toggle tag on\" do\n    assert_changes \"cards(:logo).tagged_with?(tags(:mobile))\", from: false, to: true do\n      post card_taggings_path(cards(:logo)), params: { tag_title: tags(:mobile).title }, as: :turbo_stream\n      assert_turbo_stream action: :replace, target: dom_id(cards(:logo), :tags)\n    end\n  end\n\n  test \"toggle tag off\" do\n    assert_changes \"cards(:logo).tagged_with?(tags(:web))\", from: true, to: false do\n      post card_taggings_path(cards(:logo)), params: { tag_title: tags(:web).title }, as: :turbo_stream\n      assert_turbo_stream action: :replace, target: dom_id(cards(:logo), :tags)\n    end\n  end\n\n  test \"toggle tag on as JSON\" do\n    card = cards(:logo)\n\n    assert_not card.tagged_with?(tags(:mobile))\n\n    post card_taggings_path(card), params: { tag_title: tags(:mobile).title }, as: :json\n\n    assert_response :no_content\n    assert card.reload.tagged_with?(tags(:mobile))\n  end\n\n  test \"toggle tag off as JSON\" do\n    card = cards(:logo)\n\n    assert card.tagged_with?(tags(:web))\n\n    post card_taggings_path(card), params: { tag_title: tags(:web).title }, as: :json\n\n    assert_response :no_content\n    assert_not card.reload.tagged_with?(tags(:web))\n  end\nend\n"
  },
  {
    "path": "test/controllers/cards/triages_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Cards::TriagesControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"create\" do\n    card = cards(:logo)\n    original_column = card.column\n    column = columns(:writebook_in_progress)\n\n    assert_changes -> { card.reload.column }, from: original_column, to: column do\n      post card_triage_path(card, column_id: column.id)\n      assert_redirected_to card\n    end\n  end\n\n  test \"destroy\" do\n    card = cards(:shipping)\n\n    assert_changes -> { card.reload.column }, to: nil do\n      delete card_triage_path(card), as: :turbo_stream\n      assert_redirected_to card\n    end\n  end\n\n  test \"create as JSON\" do\n    card = cards(:logo)\n    column = columns(:writebook_in_progress)\n\n    post card_triage_path(card, column_id: column.id), as: :json\n\n    assert_response :no_content\n    assert_equal column, card.reload.column\n  end\n\n  test \"destroy as JSON\" do\n    card = cards(:shipping)\n\n    assert card.column.present?\n\n    delete card_triage_path(card), as: :json\n\n    assert_response :no_content\n    assert_nil card.reload.column\n  end\nend\n"
  },
  {
    "path": "test/controllers/cards/watches_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Cards::WatchesControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"create\" do\n    cards(:logo).unwatch_by users(:kevin)\n\n    assert_changes -> { cards(:logo).watched_by?(users(:kevin)) }, from: false, to: true do\n      post card_watch_path(cards(:logo)), as: :turbo_stream\n    end\n  end\n\n  test \"destroy\" do\n    cards(:logo).watch_by users(:kevin)\n\n    assert_changes -> { cards(:logo).watched_by?(users(:kevin)) }, from: true, to: false do\n      delete card_watch_path(cards(:logo)), as: :turbo_stream\n    end\n  end\n\n  test \"create as JSON\" do\n    card = cards(:logo)\n    card.unwatch_by users(:kevin)\n\n    assert_not card.watched_by?(users(:kevin))\n\n    post card_watch_path(card), as: :json\n\n    assert_response :no_content\n    assert card.reload.watched_by?(users(:kevin))\n  end\n\n  test \"destroy as JSON\" do\n    card = cards(:logo)\n    card.watch_by users(:kevin)\n\n    assert card.watched_by?(users(:kevin))\n\n    delete card_watch_path(card), as: :json\n\n    assert_response :no_content\n    assert_not card.reload.watched_by?(users(:kevin))\n  end\nend\n"
  },
  {
    "path": "test/controllers/cards_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass CardsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"index\" do\n    get cards_path\n    assert_response :success\n  end\n\n  test \"filtered index\" do\n    get cards_path(filters(:jz_assignments).as_params.merge(term: \"haggis\"))\n    assert_response :success\n  end\n\n  test \"create a new draft\" do\n    assert_difference -> { Card.count }, 1 do\n      post board_cards_path(boards(:writebook))\n    end\n\n    card = Card.last\n    assert_redirected_to card_draft_path(card)\n\n    assert card.drafted?\n  end\n\n  test \"create resumes existing draft if it exists\" do\n    draft = boards(:writebook).cards.create!(creator: users(:kevin), status: :drafted)\n\n    assert_no_difference -> { Card.count } do\n      post board_cards_path(boards(:writebook))\n      assert_redirected_to card_draft_path(draft)\n    end\n  end\n\n  test \"show redirects to draft when card is drafted\" do\n    card = boards(:writebook).cards.create!(creator: users(:kevin), status: :drafted)\n\n    get card_path(card)\n    assert_redirected_to card_draft_path(card)\n  end\n\n  test \"show renders assign-to-me hotkey using self assignment path\" do\n    card = cards(:logo)\n\n    get card_path(card)\n    assert_response :success\n\n    assert_select \"form[action=?] button[hidden]\", card_self_assignment_path(card), text: \"Assign to me\"\n  end\n\n  test \"show renders inline code in title\" do\n    card = cards(:logo)\n    card.update_column :title, \"Fix the `bug` in production\"\n\n    get card_path(card)\n    assert_select \".card__title-link\" do |element|\n      assert_equal \"Fix the <code>bug</code> in production\", element.inner_html\n    end\n  end\n\n  test \"edit\" do\n    get edit_card_path(cards(:logo))\n    assert_response :success\n  end\n\n  test \"edit card with invalid attachments in description\" do\n    card = cards(:logo)\n    card.update! description: <<~HTML\n      <action-text-attachment sgid=\"gid://fizzy/Card/nonexistent\" content-type=\"application/octet-stream\"></action-text-attachment>\n    HTML\n\n    get edit_card_path(card)\n    assert_response :success\n  end\n\n  test \"update\" do\n    patch card_path(cards(:logo)), as: :turbo_stream, params: {\n      card: {\n        title: \"Logo needs to change\",\n        image: fixture_file_upload(\"moon.jpg\", \"image/jpeg\"),\n        description: \"Something more in-depth\" } }\n    assert_response :success\n\n    card = cards(:logo).reload\n    assert_equal \"Logo needs to change\", card.title\n    assert_equal \"moon.jpg\", card.image.filename.to_s\n    assert_equal \"Something more in-depth\", card.description.to_plain_text.strip\n  end\n\n  test \"update draft card does not render reactions\" do\n    draft = boards(:writebook).cards.create!(creator: users(:kevin), status: :drafted)\n\n    patch card_path(draft), as: :turbo_stream, params: {\n      card: { image: fixture_file_upload(\"moon.jpg\", \"image/jpeg\") }\n    }\n    assert_response :success\n\n    assert_no_match \"reactions\", response.body, \"Draft card should not show reactions/boost button\"\n  end\n\n  test \"users can only see cards in boards they have access to\" do\n    get card_path(cards(:logo))\n    assert_response :success\n\n    boards(:writebook).update! all_access: false\n    boards(:writebook).accesses.revoke_from users(:kevin)\n\n    get card_path(cards(:logo))\n    assert_response :not_found\n  end\n\n  test \"admins can see delete button on any card\" do\n    get card_path(cards(:logo))\n    assert_response :success\n\n    assert_match \"Delete this card\", response.body\n  end\n\n  test \"card creators can see delete button on their own cards\" do\n    logout_and_sign_in_as :david\n\n    get card_path(cards(:logo))\n    assert_response :success\n\n    assert_match \"Delete this card\", response.body\n  end\n\n  test \"non-admins cannot see delete button on cards they did not create\" do\n    logout_and_sign_in_as :jz\n\n    get card_path(cards(:logo))\n    assert_response :success\n\n    assert_no_match \"Delete this card\", response.body\n  end\n\n  test \"non-admins cannot delete cards they did not create\" do\n    logout_and_sign_in_as :jz\n\n    assert_no_difference -> { Card.count } do\n      delete card_path(cards(:logo))\n    end\n\n    assert_response :forbidden\n  end\n\n  test \"card creators can delete their own cards\" do\n    logout_and_sign_in_as :david\n\n    assert_difference -> { Card.count }, -1 do\n      delete card_path(cards(:logo))\n    end\n\n    assert_redirected_to boards(:writebook)\n  end\n\n  test \"admins can delete any card\" do\n    assert_difference -> { Card.count }, -1 do\n      delete card_path(cards(:logo))\n    end\n\n    assert_redirected_to boards(:writebook)\n  end\n\n  test \"show card with comment containing malformed remote image attachment\" do\n    card = cards(:logo)\n    card.comments.create! \\\n      creator: users(:kevin),\n      body: '<action-text-attachment url=\"image.png\" content-type=\"image/*\" presentation=\"gallery\"></action-text-attachment>'\n\n    get card_path(card)\n    assert_response :success\n  end\n\n  test \"show as JSON\" do\n    card = cards(:logo)\n    card.steps.create!(content: \"First step\")\n    card.steps.create!(content: \"Second step\", completed: true)\n\n    get card_path(card), as: :json\n    assert_response :success\n\n    assert_equal card.title, @response.parsed_body[\"title\"]\n    assert_equal card.closed?, @response.parsed_body[\"closed\"]\n    assert_equal card.postponed?, @response.parsed_body[\"postponed\"]\n    assert_equal 2, @response.parsed_body[\"steps\"].size\n    assert_equal card_comments_url(card), @response.parsed_body[\"comments_url\"]\n    assert_equal card_reactions_url(card), @response.parsed_body[\"reactions_url\"]\n  end\n\n  test \"create as JSON\" do\n    assert_difference -> { Card.count }, +1 do\n      post board_cards_path(boards(:writebook)),\n        params: { card: { title: \"My new card\", description: \"Big if true\" } },\n        as: :json\n      assert_response :created\n    end\n\n    card = Card.last\n    assert_equal card_path(card, format: :json), @response.headers[\"Location\"]\n    assert_equal \"My new card\", @response.parsed_body[\"title\"]\n\n    assert_equal \"My new card\", card.title\n    assert_equal \"Big if true\", card.description.to_plain_text\n  end\n\n  test \"create as JSON with custom created_at\" do\n    custom_time = Time.utc(2024, 1, 15, 10, 30, 0)\n\n    assert_difference -> { Card.count }, +1 do\n      post board_cards_path(boards(:writebook)),\n        params: { card: { title: \"Backdated card\", created_at: custom_time } },\n        as: :json\n      assert_response :created\n    end\n\n    assert_equal custom_time, Card.last.created_at\n  end\n\n  test \"create as JSON with custom last_active_at\" do\n    created_time = Time.utc(2024, 1, 15, 10, 30, 0)\n    last_active_time = Time.utc(2024, 6, 1, 12, 0, 0)\n\n    assert_difference -> { Card.count }, +1 do\n      post board_cards_path(boards(:writebook)),\n        params: { card: { title: \"Card with activity\", created_at: created_time, last_active_at: last_active_time } },\n        as: :json\n      assert_response :created\n    end\n\n    card = Card.last\n    assert_equal created_time, card.created_at\n    assert_equal last_active_time, card.last_active_at\n  end\n\n  test \"create as JSON defaults last_active_at to created_at when not provided\" do\n    created_time = Time.utc(2024, 1, 15, 10, 30, 0)\n\n    assert_difference -> { Card.count }, +1 do\n      post board_cards_path(boards(:writebook)),\n        params: { card: { title: \"Backdated card without last_active_at\", created_at: created_time } },\n        as: :json\n      assert_response :created\n    end\n\n    card = Card.last\n    assert_equal created_time, card.created_at\n    assert_equal created_time, card.last_active_at\n  end\n\n  test \"update as JSON with custom last_active_at\" do\n    card = cards(:logo)\n    custom_time = Time.utc(2024, 3, 15, 14, 0, 0)\n\n    put card_path(card, format: :json), params: { card: { last_active_at: custom_time } }\n\n    assert_response :success\n    assert_equal custom_time, card.reload.last_active_at\n  end\n\n  test \"update as JSON can restore last_active_at after comments overwrite it\" do\n    created_time = Time.utc(2024, 1, 15, 10, 30, 0)\n    last_active_time = Time.utc(2024, 6, 1, 12, 0, 0)\n\n    # Create a card with custom timestamps (simulating import)\n    post board_cards_path(boards(:writebook)),\n      params: { card: { title: \"Imported card\", created_at: created_time, last_active_at: last_active_time } },\n      as: :json\n    assert_response :created\n\n    card = Card.last\n\n    # Adding a comment overwrites last_active_at (this is expected)\n    card.comments.create!(creator: users(:kevin), body: \"Imported comment\")\n    assert_not_equal last_active_time, card.reload.last_active_at\n\n    # After import, restore the correct last_active_at\n    put card_path(card, format: :json), params: { card: { last_active_at: last_active_time } }\n    assert_response :success\n\n    assert_equal last_active_time, card.reload.last_active_at\n  end\n\n  test \"update as JSON\" do\n    card = cards(:logo)\n\n    put card_path(card, format: :json), params: { card: { title: \"Update test\" } }\n    assert_response :success\n\n    assert_equal \"Update test\", card.reload.title\n  end\n\n  test \"delete as JSON\" do\n    card = cards(:logo)\n\n    delete card_path(card, format: :json)\n    assert_response :no_content\n\n    assert_not Card.exists?(card.id)\n  end\nend\n"
  },
  {
    "path": "test/controllers/client_configurations_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass ClientConfigurationsControllerTest < ActionDispatch::IntegrationTest\n  test \"android\" do\n    assert_ok \"/client_configurations/android_v1.json\"\n  end\n\n  test \"ios\" do\n    assert_ok \"/client_configurations/ios_v1.json\"\n  end\n\n  test \"bad platform\" do\n    assert_no_route \"/client_configurations/blackberry_v1.json\"\n  end\n\n  test \"bad version\" do\n    assert_no_route \"/client_configurations/android_va.json\"\n  end\n\n  test \"nonexistent version\" do\n    assert_missing \"/client_configurations/android_v2000.json\"\n    assert_missing \"/client_configurations/ios_v2000.json\"\n  end\n\n  private\n    def assert_ok(url, cache_control: { public: true, max_age: \"60\" })\n      get url\n      assert_response :ok\n\n      assert_kind_of Hash, response.parsed_body[\"settings\"]\n      assert_kind_of Array, response.parsed_body[\"rules\"]\n\n      assert_equal cache_control, response.cache_control\n    end\n\n    def assert_no_route(url)\n      without_action_dispatch_exception_handling do\n        assert_raises(ActionController::RoutingError) { get url }\n      end\n    end\n\n    def assert_missing(url)\n      without_action_dispatch_exception_handling do\n        assert_raises(ActionView::MissingTemplate) { get url }\n      end\n    end\nend\n"
  },
  {
    "path": "test/controllers/columns/cards/drops/closures_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Columns::Cards::Drops::ClosuresControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"create\" do\n    card = cards(:logo)\n\n    assert_changes -> { card.reload.closed? }, from: false, to: true do\n      post columns_card_drops_closure_path(card), as: :turbo_stream\n      assert_response :success\n    end\n  end\nend\n"
  },
  {
    "path": "test/controllers/columns/cards/drops/columns_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Columns::Cards::Drops::ColumnsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"create\" do\n    card = cards(:logo)\n    column = columns(:writebook_in_progress)\n\n    assert_changes -> { card.reload.column }, to: column do\n      post columns_card_drops_column_path(card, column_id: column.id), as: :turbo_stream\n      assert_response :success\n    end\n  end\nend\n"
  },
  {
    "path": "test/controllers/columns/cards/drops/not_nows_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Columns::Cards::Drops::NotNowsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"create\" do\n    card = cards(:logo)\n\n    assert_changes -> { card.reload.postponed? }, from: false, to: true do\n      post columns_card_drops_not_now_path(card), as: :turbo_stream\n      assert_response :success\n    end\n  end\nend\n"
  },
  {
    "path": "test/controllers/columns/cards/drops/streams_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Columns::Cards::Drops::StreamsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"create\" do\n    card = cards(:text)\n\n    assert_changes -> { card.reload.triaged? }, from: true, to: false do\n      post columns_card_drops_stream_path(card), as: :turbo_stream\n      assert_response :success\n    end\n  end\nend\n"
  },
  {
    "path": "test/controllers/columns/left_positions_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Columns::LeftPositionsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"move column left\" do\n    board = boards(:writebook)\n    columns = board.columns.sorted.to_a\n\n    column_a = columns[0]\n    column_b = columns[1]\n    original_position_a = column_a.position\n    original_position_b = column_b.position\n\n    post column_left_position_path(column_b), as: :turbo_stream\n    assert_response :success\n\n    assert_equal original_position_b, column_a.reload.position\n    assert_equal original_position_a, column_b.reload.position\n  end\n\n  test \"move column left as JSON\" do\n    board = boards(:writebook)\n    columns = board.columns.sorted.to_a\n\n    column_a = columns[0]\n    column_b = columns[1]\n    original_position_a = column_a.position\n    original_position_b = column_b.position\n\n    post column_left_position_path(column_b), as: :json\n    assert_response :created\n\n    assert_equal original_position_b, column_a.reload.position\n    assert_equal original_position_a, column_b.reload.position\n  end\n\n  test \"move left refreshes adjacent columns\" do\n    column = columns(:writebook_in_progress)\n\n    post column_left_position_path(column), as: :turbo_stream\n\n    column.reload.adjacent_columns.each do |adjacent_column|\n      assert_turbo_stream action: :replace, target: dom_id(adjacent_column)\n    end\n  end\n\n  test \"users can only reorder columns in boards they have access to\" do\n    column = columns(:writebook_in_progress)\n\n    post column_left_position_path(column), as: :turbo_stream\n    assert_response :success\n\n    boards(:writebook).update! all_access: false\n    boards(:writebook).accesses.revoke_from users(:kevin)\n\n    post column_left_position_path(column), as: :turbo_stream\n    assert_response :not_found\n  end\nend\n"
  },
  {
    "path": "test/controllers/columns/right_positions_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Columns::RightPositionsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"move column right\" do\n    board = boards(:writebook)\n    columns = board.columns.sorted.to_a\n\n    column_a = columns[0]\n    column_b = columns[1]\n    original_position_a = column_a.position\n    original_position_b = column_b.position\n\n    post column_right_position_path(column_a), as: :turbo_stream\n    assert_response :success\n\n    assert_equal original_position_b, column_a.reload.position\n    assert_equal original_position_a, column_b.reload.position\n  end\n\n  test \"move column right as JSON\" do\n    board = boards(:writebook)\n    columns = board.columns.sorted.to_a\n\n    column_a = columns[0]\n    column_b = columns[1]\n    original_position_a = column_a.position\n    original_position_b = column_b.position\n\n    post column_right_position_path(column_a), as: :json\n    assert_response :created\n\n    assert_equal original_position_b, column_a.reload.position\n    assert_equal original_position_a, column_b.reload.position\n  end\n\n  test \"move right refreshes adjacent columns\" do\n    column = columns(:writebook_in_progress)\n\n    post column_right_position_path(column), as: :turbo_stream\n\n    column.reload.adjacent_columns.each do |adjacent_column|\n      assert_turbo_stream action: :replace, target: dom_id(adjacent_column)\n    end\n  end\n\n  test \"users can only reorder columns in boards they have access to\" do\n    column = columns(:writebook_triage)\n\n    post column_right_position_path(column), as: :turbo_stream\n    assert_response :success\n\n    boards(:writebook).update! all_access: false\n    boards(:writebook).accesses.revoke_from users(:kevin)\n\n    post column_right_position_path(column), as: :turbo_stream\n    assert_response :not_found\n  end\nend\n"
  },
  {
    "path": "test/controllers/concerns/block_search_engine_indexing_test.rb",
    "content": "require \"test_helper\"\n\nclass BlockSearchEngineIndexingTest < ActionDispatch::IntegrationTest\n  test \"sets X-Robots-Tag header to none on authenticated requests\" do\n    sign_in_as :david\n\n    get board_path(boards(:writebook))\n    assert_response :success\n    assert_equal \"none\", response.headers[\"X-Robots-Tag\"]\n  end\n\n  test \"sets X-Robots-Tag header to none on unauthenticated requests\" do\n    untenanted do\n      get new_session_path\n    end\n\n    assert_response :success\n    assert_equal \"none\", response.headers[\"X-Robots-Tag\"]\n  end\n\n  test \"sets X-Robots-Tag header to none on public board pages\" do\n    boards(:writebook).publish\n\n    get public_board_path(boards(:writebook).publication.key)\n    assert_response :success\n    assert_equal \"none\", response.headers[\"X-Robots-Tag\"]\n  end\nend\n"
  },
  {
    "path": "test/controllers/concerns/current_timezone_test.rb",
    "content": "require \"test_helper\"\n\nclass CurrentTimezoneTest < ActionDispatch::IntegrationTest\n  test \"includes the timezone cookie in the ETag\" do\n    cookies[:timezone] = \"America/New_York\"\n    get user_avatar_path(users(:kevin))\n    etag = response.headers.fetch(\"ETag\")\n\n    get user_avatar_path(users(:kevin)), headers: { \"If-None-Match\" => etag }\n    assert_equal 304, response.status\n\n    cookies[:timezone] = \"America/Los_Angeles\"\n    get user_avatar_path(users(:kevin)), headers: { \"If-None-Match\" => etag }\n    assert_response :success\n    assert_not_equal etag, response.headers.fetch(\"ETag\")\n  end\nend\n"
  },
  {
    "path": "test/controllers/concerns/request_forgery_protection_test.rb",
    "content": "require \"test_helper\"\n\nclass RequestForgeryProtectionTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n\n    @original_allow_forgery_protection = ActionController::Base.allow_forgery_protection\n    ActionController::Base.allow_forgery_protection = true\n\n    @original_force_ssl = Rails.configuration.force_ssl\n    @original_secure_protocol = ActionDispatch::Http::URL.secure_protocol\n  end\n\n  teardown do\n    ActionController::Base.allow_forgery_protection = @original_allow_forgery_protection\n    Rails.configuration.force_ssl = @original_force_ssl\n    ActionDispatch::Http::URL.secure_protocol = @original_secure_protocol\n  end\n\n  test \"JSON request succeeds with missing Sec-Fetch-Site header\" do\n    assert_difference -> { Board.count }, +1 do\n      post boards_path,\n        params: { board: { name: \"Test Board\" } },\n        as: :json\n    end\n\n    assert_response :created\n  end\n\n  test \"HTTP request succeeds with missing Sec-Fetch-Site header when force_ssl is disabled\" do\n    Rails.configuration.force_ssl = false\n\n    assert_difference -> { Board.count }, +1 do\n      post boards_path,\n        params: { board: { name: \"Test Board\" } }\n    end\n\n    assert_response :redirect\n  end\n\n  test \"HTTP request fails with missing Sec-Fetch-Site header when force_ssl is enabled\" do\n    Rails.configuration.force_ssl = true\n    ActionDispatch::Http::URL.secure_protocol = true\n\n    assert_no_difference -> { Board.count } do\n      post boards_path,\n        params: { board: { name: \"Test Board\" } }\n    end\n\n    assert_response :unprocessable_entity\n  end\n\n  test \"HTTPS request fails with missing Sec-Fetch-Site header\" do\n    Rails.configuration.force_ssl = false\n\n    assert_no_difference -> { Board.count } do\n      post boards_path,\n        params: { board: { name: \"Test Board\" } },\n        headers: { \"X-Forwarded-Proto\" => \"https\" }\n    end\n\n    assert_response :unprocessable_entity\n  end\nend\n"
  },
  {
    "path": "test/controllers/concerns/set_platform_test.rb",
    "content": "require \"test_helper\"\n\nclass SetPlatformTest < ActionDispatch::IntegrationTest\n  DESKTOP_UA = \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\"\n  NATIVE_IOS_UA = \"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Hotwire Native iOS/1.0 bridge-components: [buttons overflow-menu]\"\n\n  test \"uses the request user agent by default\" do\n    sign_in_as :david\n\n    get board_path(boards(:writebook)), headers: { \"User-Agent\" => DESKTOP_UA }\n    assert_select \"body[data-platform='desktop web'][data-bridge-platform=''][data-bridge-components='']\"\n  end\n\n  test \"prefers x_user_agent cookie over request user agent\" do\n    sign_in_as :david\n\n    cookies[:x_user_agent] = NATIVE_IOS_UA\n    get board_path(boards(:writebook)), headers: { \"User-Agent\" => DESKTOP_UA }\n    assert_select \"body[data-platform='native ios'][data-bridge-platform='ios'][data-bridge-components='buttons overflow-menu']\"\n  end\nend\n"
  },
  {
    "path": "test/controllers/controller_authentication_test.rb",
    "content": "require \"test_helper\"\n\nclass ControllerAuthenticationTest < ActionDispatch::IntegrationTest\n  test \"access without an account slug redirects to menu\" do\n    sign_in_as :kevin\n    integration_session.default_url_options[:script_name] = \"\" # no tenant\n\n    get cards_path\n\n    assert_redirected_to session_menu_path\n  end\n\n  test \"access with an account slug but no session redirects to new session\" do\n    get cards_path\n\n    assert_redirected_to new_session_path(script_name: nil)\n  end\n\n  test \"access with an account slug and a session allows functional access\" do\n    sign_in_as :kevin\n\n    get cards_path\n\n    assert_response :success\n  end\nend\n"
  },
  {
    "path": "test/controllers/events/day_timeline/columns_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Events::DayTimeline::ColumnsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"show added column\" do\n    get events_day_timeline_column_path(\"added\")\n    assert_response :success\n    assert_select \"h1\", text: /Added/\n  end\n\n  test \"show updated column\" do\n    get events_day_timeline_column_path(\"updated\")\n    assert_response :success\n    assert_select \"h1\", text: /Updated/\n  end\n\n  test \"show closed column\" do\n    get events_day_timeline_column_path(\"closed\")\n    assert_response :success\n    assert_select \"h1\", text: /Done/\n  end\n\n  test \"show returns not found for invalid column\" do\n    get events_day_timeline_column_path(\"invalid\")\n    assert_response :not_found\n  end\nend\n"
  },
  {
    "path": "test/controllers/events_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass EventsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n    travel_to Time.utc(2025, 1, 22, 17, 30, 0)\n\n    events(:layout_assignment_jz).update!(created_at: Time.current.beginning_of_day + 8.hours)\n  end\n\n  test \"index\" do\n    get events_path\n\n    assert_select \"div.events__time-block[style='grid-area: 17/2']\" do\n      assert_select \"strong\", text: /assigned JZ to Layout is broken/\n    end\n  end\n\n  test \"index with a specific timezone\" do\n    cookies[:timezone] = \"America/New_York\"\n\n    get events_path\n\n    assert_select \"div.events__time-block[style='grid-area: 22/2']\" do\n      assert_select \"strong\", text: /assigned JZ to Layout is broken/\n    end\n  end\n\n  test \"only displays events from filtered boards\" do\n    get events_path(board_ids: [ boards(:writebook).id ])\n    assert_response :success\n\n    events_shown = css_select(\".event\").count\n    assert events_shown > 0, \"Should show some events\"\n\n    css_select(\".event\").each do |event|\n      assert_includes event.text, boards(:writebook).name\n    end\n  end\nend\n"
  },
  {
    "path": "test/controllers/filters_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass FiltersControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :david\n  end\n\n  test \"create\" do\n    assert_difference \"users(:david).filters.count\", +1 do\n      post filters_path, params: {\n        indexed_by: \"closed\",\n        assignment_status: \"unassigned\",\n        tag_ids: [ tags(:mobile).id ],\n        assignee_ids: [ users(:jz).id ],\n        board_ids: [ boards(:writebook).id ] }, as: :turbo_stream\n    end\n    assert_response :success\n\n    filter = Filter.last\n    assert_predicate filter.indexed_by, :closed?\n    assert_predicate filter.assignment_status, :unassigned?\n    assert_equal [ tags(:mobile) ], filter.tags\n    assert_equal [ users(:jz) ], filter.assignees\n    assert_equal [ boards(:writebook) ], filter.boards\n  end\n\n  test \"destroy\" do\n    filter = filters(:jz_assignments)\n    expected_params = filter.as_params\n\n    assert_difference \"users(:david).filters.count\", -1 do\n      delete filter_path(filter), as: :turbo_stream\n    end\n    assert_response :success\n  end\nend\n"
  },
  {
    "path": "test/controllers/join_codes_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass JoinCodesControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    @account = accounts(\"37s\")\n    @join_code = account_join_codes(:\"37s\")\n  end\n\n  test \"new\" do\n    get join_path(code: @join_code.code, script_name: @account.slug)\n\n    assert_response :success\n    assert_in_body \"37signals\"\n  end\n\n  test \"new with an invalid code\" do\n    get join_path(code: \"INVALID-CODE\", script_name: @account.slug)\n\n    assert_response :not_found\n  end\n\n  test \"new with an inactive code\" do\n    @join_code.update!(usage_count: @join_code.usage_limit)\n\n    get join_path(code: @join_code.code, script_name: @account.slug)\n\n    assert_response :gone\n    assert_in_body \"That code is all used up\"\n  end\n\n  test \"create\" do\n    assert_difference -> { Identity.count }, 1 do\n      assert_difference -> { User.count }, 1 do\n        post join_path(code: @join_code.code, script_name: @account.slug), params: { email_address: \"new_user@example.com\" }\n      end\n    end\n\n    assert_redirected_to session_magic_link_url(script_name: nil)\n    assert_equal new_users_verification_url(script_name: @account.slug), session[:return_to_after_authenticating]\n  end\n\n  test \"create for existing identity\" do\n    identity = identities(:jz)\n    sign_in_as :jz\n\n    assert identity.users.exists?(account: @account), \"JZ should be a member of 37s for this test\"\n    assert identity.users.find_by!(account: @account).setup?, \"JZ's user should be setup for this test\"\n\n    assert_no_difference -> { Identity.count } do\n      assert_no_difference -> { User.count } do\n        post join_path(code: @join_code.code, script_name: @account.slug), params: { email_address: identity.email_address }\n      end\n    end\n\n    assert_redirected_to landing_url(script_name: @account.slug)\n  end\n\n  test \"create for signed-in identity without a user in the account redirects to verification\" do\n    identity = identities(:mike)\n    sign_in_as :mike\n\n    assert_not identity.users.exists?(account: @account), \"Mike should not be a member of 37s for this test\"\n\n    assert_no_difference -> { Identity.count } do\n      assert_difference -> { User.count }, 1 do\n        post join_path(code: @join_code.code, script_name: @account.slug), params: { email_address: identity.email_address }\n      end\n    end\n\n    assert_redirected_to new_users_verification_url(script_name: @account.slug)\n  end\n\n  test \"create for different identity terminates existing session\" do\n    sign_in_as :kevin\n\n    assert_difference -> { Identity.count }, 1 do\n      assert_difference -> { User.count }, 1 do\n        post join_path(code: @join_code.code, script_name: @account.slug), params: { email_address: \"new_user@example.com\" }\n      end\n    end\n\n    assert_redirected_to session_magic_link_url(script_name: nil)\n    assert_not_predicate cookies[:session_token], :present?\n  end\n\n  test \"create with invalid email address\" do\n    # Avoid Sentry exceptions when attackers try to stuff invalid emails into the system\n    without_action_dispatch_exception_handling do\n      assert_no_difference -> { Identity.count } do\n        assert_no_difference -> { User.count } do\n          post join_path(code: @join_code.code, script_name: @account.slug), params: { email_address: \"not-a-valid-email\" }\n        end\n      end\n      assert_response :unprocessable_entity\n    end\n  end\n\n  test \"create is rate limited\" do\n    Rails.cache.stubs(:increment).returns(11)\n\n    post join_path(code: @join_code.code, script_name: @account.slug), params: { email_address: \"test@example.com\" }\n\n    assert_response :too_many_requests\n  end\nend\n"
  },
  {
    "path": "test/controllers/landings_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass LandingsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"redirects to the timeline when many boards\" do\n    get landing_path\n    assert_redirected_to root_path\n  end\n\n  test \"redirects to the timeline when no boards\" do\n    Board.destroy_all\n    get landing_path\n    assert_redirected_to root_path\n  end\n\n  test \"redirects to boards when only one board\" do\n    sole_board, *boards_to_delete = users(:kevin).boards.to_a\n    boards_to_delete.each(&:destroy)\n\n    get landing_path\n    assert_redirected_to board_path(sole_board)\n  end\nend\n"
  },
  {
    "path": "test/controllers/my/access_tokens_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass My::AccessTokensControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"create new token\" do\n    get my_access_tokens_path\n    assert_response :success\n\n    get new_my_access_token_path\n    assert_response :success\n\n    assert_changes -> { identities(:kevin).access_tokens.count }, +1 do\n      post my_access_tokens_path, params: { access_token: { description: \"GitHub\", permission: \"read\" } }\n      follow_redirect!\n      assert_in_body identities(:kevin).access_tokens.last.token\n    end\n  end\n\n  test \"create new token via JSON with session\" do\n    assert_difference -> { identities(:kevin).access_tokens.count }, +1 do\n      post my_access_tokens_path, params: { access_token: { description: \"Fizzy CLI\", permission: \"write\" } }, as: :json\n    end\n    assert_response :created\n    body = @response.parsed_body\n    assert body[\"id\"].present?\n    assert body[\"token\"].present?\n    assert_equal \"Fizzy CLI\", body[\"description\"]\n    assert_equal \"write\", body[\"permission\"]\n    assert body[\"created_at\"].present?\n  end\n\n  test \"create new token via JSON with bearer token\" do\n    sign_out\n    bearer_token = { \"HTTP_AUTHORIZATION\" => \"Bearer #{identity_access_tokens(:davids_api_token).token}\" }\n\n    assert_difference -> { identities(:david).access_tokens.count }, +1 do\n      post my_access_tokens_path, params: { access_token: { description: \"Fizzy CLI\", permission: \"read\" } }, env: bearer_token, as: :json\n    end\n    assert_response :created\n    body = @response.parsed_body\n    assert body[\"token\"].present?\n    assert_equal \"Fizzy CLI\", body[\"description\"]\n    assert_equal \"read\", body[\"permission\"]\n  end\n\n  test \"cannot create new token via JSON with read-only bearer token\" do\n    sign_out\n    bearer_token = { \"HTTP_AUTHORIZATION\" => \"Bearer #{identity_access_tokens(:jasons_api_token).token}\" }\n\n    assert_no_difference -> { identities(:jason).access_tokens.count } do\n      post my_access_tokens_path, params: { access_token: { description: \"Fizzy CLI\", permission: \"read\" } }, env: bearer_token, as: :json\n    end\n    assert_response :unauthorized\n  end\n\n  test \"index as JSON\" do\n    get my_access_tokens_path, as: :json\n    assert_response :success\n\n    body = @response.parsed_body\n    assert_kind_of Array, body\n  end\n\n  test \"index as JSON with bearer token and no account scope\" do\n    sign_out\n    bearer_token = { \"HTTP_AUTHORIZATION\" => \"Bearer #{identity_access_tokens(:davids_api_token).token}\" }\n\n    untenanted do\n      get my_access_tokens_path, as: :json, env: bearer_token\n    end\n\n    assert_response :success\n    assert_kind_of Array, @response.parsed_body\n  end\n\n  test \"destroy as JSON\" do\n    token = identities(:kevin).access_tokens.create!(description: \"To delete\", permission: \"read\")\n\n    assert_difference -> { identities(:kevin).access_tokens.count }, -1 do\n      delete my_access_token_path(token), as: :json\n    end\n\n    assert_response :no_content\n  end\n\n  test \"accessing new token after reveal window redirects to index\" do\n    assert_changes -> { identities(:kevin).access_tokens.count }, +1 do\n      post my_access_tokens_path, params: { access_token: { description: \"GitHub\", permission: \"read\" } }\n      travel_to 15.seconds.from_now\n      follow_redirect!\n      assert_equal \"Token is no longer visible\", flash[:alert]\n    end\n  end\nend\n"
  },
  {
    "path": "test/controllers/my/identities_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass My::IdentitiesControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"show as JSON\" do\n    identity = identities(:kevin)\n    expected_count = identity.users_with_active_accounts.count\n\n    untenanted do\n      get my_identity_path, as: :json\n      assert_response :success\n      assert_equal identity.id, @response.parsed_body[\"id\"]\n      assert_equal expected_count, @response.parsed_body[\"accounts\"].count\n    end\n  end\n\n  test \"show as JSON includes users from active accounts only\" do\n    identity = identities(:kevin)\n\n    active_account = Account.create!(external_account_id: 9999981, name: \"Active Account\")\n    cancelled_account = Account.create!(external_account_id: 9999982, name: \"Cancelled Account\")\n\n    identity.users.create!(account: active_account, name: \"Kevin\", role: :owner)\n\n    cancelling_user = identity.users.create!(account: cancelled_account, name: \"Kevin\", role: :owner)\n    cancelled_account.cancel(initiated_by: cancelling_user)\n\n    untenanted do\n      get my_identity_path, as: :json\n      assert_response :success\n\n      account_ids = @response.parsed_body[\"accounts\"].map { |account| account[\"id\"] }\n\n      assert_includes account_ids, active_account.id\n      assert_not_includes account_ids, cancelled_account.id\n    end\n  end\nend\n"
  },
  {
    "path": "test/controllers/my/menus_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass My::MenusControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n    @user = users(:kevin)\n    @account = accounts(\"37s\")\n  end\n\n  test \"show\" do\n    get my_menu_path\n    assert_response :success\n  end\n\n  test \"etag invalidates when filters change\" do\n    get my_menu_path\n    assert_response :success\n    etag = response.headers[\"ETag\"]\n\n    @user.filters.create!(\n      params_digest: Filter.digest_params({ indexed_by: :all, sorted_by: :newest }),\n      fields: { indexed_by: :all, sorted_by: :newest }\n    )\n\n    get my_menu_path, headers: { \"If-None-Match\" => etag }\n    assert_response :success\n  end\n\n  test \"etag invalidates when boards change\" do\n    get my_menu_path\n    assert_response :success\n    etag = response.headers[\"ETag\"]\n\n    @account.boards.create!(name: \"New Board\", all_access: true, creator: @user)\n\n    get my_menu_path, headers: { \"If-None-Match\" => etag }\n    assert_response :success\n  end\n\n  test \"etag invalidates when tags change\" do\n    get my_menu_path\n    assert_response :success\n    etag = response.headers[\"ETag\"]\n\n    @account.tags.create!(title: \"new-tag\")\n\n    get my_menu_path, headers: { \"If-None-Match\" => etag }\n    assert_response :success\n  end\n\n  test \"etag invalidates when users change\" do\n    get my_menu_path\n    assert_response :success\n    etag = response.headers[\"ETag\"]\n\n    @user.touch\n\n    get my_menu_path, headers: { \"If-None-Match\" => etag }\n    assert_response :success\n  end\n\n  test \"etag invalidates when account changes\" do\n    get my_menu_path\n    assert_response :success\n    etag = response.headers[\"ETag\"]\n\n    @account.update!(name: \"Renamed Account\")\n\n    get my_menu_path, headers: { \"If-None-Match\" => etag }\n    assert_response :success\n  end\n\n  test \"etag returns not modified when nothing changes\" do\n    get my_menu_path\n    assert_response :success\n    etag = response.headers[\"ETag\"]\n\n    get my_menu_path, headers: { \"If-None-Match\" => etag }\n    assert_response :not_modified\n  end\n\n  test \"show excludes cancelled accounts\" do\n    # Create another account for the same identity\n    another_account = Account.create!(external_account_id: 9999996, name: \"Cancelled Account\")\n    another_user = @user.identity.users.create!(account: another_account, name: \"Kevin\", role: \"owner\")\n\n    # Cancel the other account\n    another_account.cancel(initiated_by: another_user)\n\n    get my_menu_path\n    assert_response :success\n\n    # The response should include active account but not cancelled one\n    assert_select \"a[href*='#{@account.slug}']\"\n    assert_select \"a[href*='#{another_account.slug}']\", count: 0\n  end\nend\n"
  },
  {
    "path": "test/controllers/my/passkey_challenges_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass My::PasskeyChallengesControllerTest < ActionDispatch::IntegrationTest\n  test \"returns a fresh challenge\" do\n    untenanted do\n      post my_passkey_challenge_url\n\n      assert_response :success\n      assert_not_nil response.parsed_body[\"challenge\"]\n    end\n  end\n\n  test \"stores challenge in cookie\" do\n    untenanted do\n      post my_passkey_challenge_url\n\n      jar = ActionDispatch::Cookies::CookieJar.build(request, cookies.to_hash)\n      assert_equal response.parsed_body[\"challenge\"], jar.encrypted[ActionPack::Passkey::ChallengesController::COOKIE_NAME]\n    end\n  end\n\n  test \"returns a different challenge each time\" do\n    untenanted do\n      post my_passkey_challenge_url\n      first_challenge = response.parsed_body[\"challenge\"]\n\n      post my_passkey_challenge_url\n      second_challenge = response.parsed_body[\"challenge\"]\n\n      assert_not_equal first_challenge, second_challenge\n    end\n  end\nend\n"
  },
  {
    "path": "test/controllers/my/passkeys_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass My::PasskeysControllerTest < ActionDispatch::IntegrationTest\n  include WebauthnTestHelper\n\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"index\" do\n    get my_passkeys_path\n    assert_response :success\n  end\n\n  test \"register a passkey\" do\n    challenge = request_webauthn_challenge\n\n    assert_difference -> { identities(:kevin).passkeys.count }, 1 do\n      post my_passkeys_path, params: build_attestation_params(challenge: challenge)\n    end\n\n    passkey = identities(:kevin).passkeys.order(created_at: :desc).first\n    assert_redirected_to edit_my_passkey_path(passkey, created: true)\n    assert_equal [ \"internal\" ], passkey.transports\n  end\nend\n"
  },
  {
    "path": "test/controllers/my/pins_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass My::PinsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"index\" do\n    get my_pins_path\n\n    assert_response :success\n    assert_select \"div\", text: /#{users(:kevin).pins.first.card.title}/\n  end\n\n  test \"index as JSON\" do\n    expected_ids = users(:kevin).pins.ordered.pluck(:card_id)\n\n    get my_pins_path(format: :json)\n\n    assert_response :success\n    assert_equal expected_ids.count, @response.parsed_body.count\n    assert_equal expected_ids, @response.parsed_body.map { |card| card[\"id\"] }\n  end\nend\n"
  },
  {
    "path": "test/controllers/my/timezones_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass My::TimezonesControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"update\" do\n    time_zone = ActiveSupport::TimeZone[\"America/New_York\"]\n\n    assert_not_equal time_zone, users(:kevin).timezone\n    patch my_timezone_path, params: { timezone_name: \"America/New_York\" }\n    assert_equal time_zone, users(:kevin).reload.timezone\n  end\nend\n"
  },
  {
    "path": "test/controllers/notifications/bulk_readings_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Notifications::BulkReadingsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"create marks all notifications as read\" do\n    assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do\n      assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: false, to: true do\n        post bulk_reading_path\n      end\n    end\n  end\n\n  test \"create redirects to notifications path when not from tray\" do\n    post bulk_reading_path\n    assert_redirected_to notifications_path\n  end\n\n  test \"create returns ok when from tray\" do\n    post bulk_reading_path, params: { from_tray: true }\n    assert_response :ok\n  end\n\n  test \"create as JSON\" do\n    assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do\n      assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: false, to: true do\n        post bulk_reading_path, as: :json\n      end\n    end\n\n    assert_response :no_content\n  end\nend\n"
  },
  {
    "path": "test/controllers/notifications/readings_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Notifications::ReadingsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n    @notification = notifications(:logo_assignment_kevin)\n  end\n\n  test \"create\" do\n    assert_changes -> { @notification.reload.read? }, from: false, to: true do\n      post notification_reading_path(@notification, format: :turbo_stream)\n      assert_response :success\n    end\n  end\n\n  test \"destroy\" do\n    @notification.read\n\n    assert_changes -> { @notification.reload.read? }, from: true, to: false do\n      delete notification_reading_path(@notification, format: :turbo_stream)\n      assert_response :success\n    end\n  end\n\n  test \"create as JSON\" do\n    assert_changes -> { @notification.reload.read? }, from: false, to: true do\n      post notification_reading_path(@notification), as: :json\n      assert_response :no_content\n    end\n  end\n\n  test \"destroy as JSON\" do\n    @notification.read\n\n    assert_changes -> { @notification.reload.read? }, from: true, to: false do\n      delete notification_reading_path(@notification), as: :json\n      assert_response :no_content\n    end\n  end\nend\n"
  },
  {
    "path": "test/controllers/notifications/settings_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Notifications::SettingsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    @user = users(:david)\n\n    sign_in_as @user\n  end\n\n  test \"show\" do\n    get notifications_settings_path\n\n    assert_response :success\n  end\n\n  test \"show as JSON\" do\n    get notifications_settings_path, as: :json\n    assert_response :success\n\n    assert_equal @user.settings.bundle_email_frequency, @response.parsed_body[\"bundle_email_frequency\"]\n  end\n\n  test \"update as JSON\" do\n    assert_changes -> { @user.reload.settings.bundle_email_frequency }, from: \"never\", to: \"every_few_hours\" do\n      put notifications_settings_path, params: { user_settings: { bundle_email_frequency: \"every_few_hours\" } }, as: :json\n    end\n\n    assert_response :no_content\n  end\n\n  test \"update email frequency\" do\n    assert_changes -> { @user.reload.settings.bundle_email_frequency }, from: \"never\", to: \"every_few_hours\" do\n      put notifications_settings_path, params: { user_settings: { bundle_email_frequency: \"every_few_hours\" } }\n    end\n\n    assert_redirected_to notifications_settings_path\n    assert_equal \"Settings updated\", flash[:notice]\n  end\nend\n"
  },
  {
    "path": "test/controllers/notifications/trays_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Notifications::TraysControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"show\" do\n    get tray_notifications_path\n\n    assert_response :success\n    assert_select \"div\", text: /Layout is broken/\n  end\n\n  test \"show as JSON\" do\n    expected_ids = users(:kevin).notifications.unread.ordered.limit(100).pluck(:id)\n\n    get tray_notifications_path(format: :json)\n\n    assert_response :success\n    assert_equal expected_ids, @response.parsed_body.map { |s| s[\"id\"] }\n  end\n\n  test \"show as JSON with include_read includes read notifications\" do\n    notifications = users(:kevin).notifications\n    expected_ids = notifications.unread.ordered.limit(100).pluck(:id) +\n      notifications.read.ordered.limit(100).pluck(:id)\n\n    get tray_notifications_path(format: :json, include_read: true)\n\n    assert_response :success\n    assert_equal expected_ids, @response.parsed_body.map { |s| s[\"id\"] }\n  end\nend\n"
  },
  {
    "path": "test/controllers/notifications/unsubscribes_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Notifications::UnsubscribesControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    @user = users(:david)\n    @access_token = @user.generate_token_for(:unsubscribe)\n\n    sign_in_as @user\n  end\n\n  test \"new\" do\n    get new_notifications_unsubscribe_path(access_token: @access_token)\n    assert_response :success\n  end\n\n  test \"new with bad token\" do\n    get new_notifications_unsubscribe_path(access_token: \"bad\")\n    assert_redirected_to root_path\n  end\n\n  test \"create\" do\n    @user.reload.settings.bundle_email_every_few_hours!\n    assert_changes -> { @user.reload.settings.bundle_email_frequency }, to: \"never\" do\n      post notifications_unsubscribe_path(access_token: @access_token)\n      assert_redirected_to notifications_unsubscribe_path(access_token: @access_token)\n    end\n  end\n\n  test \"create with bad token\" do\n    assert_no_changes -> { @user.reload.settings.bundle_email_frequency } do\n      post notifications_unsubscribe_path(access_token: \"bad\")\n      assert_redirected_to root_path\n    end\n  end\n\n  test \"show\" do\n    get notifications_unsubscribe_path(access_token: @access_token)\n    assert_response :success\n  end\nend\n"
  },
  {
    "path": "test/controllers/notifications_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass NotificationsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"index as JSON\" do\n    get notifications_path, as: :json\n\n    assert_response :success\n    assert_kind_of Array, @response.parsed_body\n    assert @response.parsed_body.any? { |n| n[\"id\"] == notifications(:logo_assignment_kevin).id }\n  end\n\n  test \"index as JSON includes notification attributes\" do\n    get notifications_path, as: :json\n\n    notification = @response.parsed_body.find { |n| n[\"id\"] == notifications(:logo_assignment_kevin).id }\n\n    assert_not_nil notification[\"created_at\"]\n    assert_not_nil notification[\"card\"]\n    assert_not_nil notification[\"creator\"]\n    assert_not_nil notification[\"unread_count\"]\n    assert_not_nil notification.dig(\"creator\", \"avatar_url\")\n    assert_not_nil notification.dig(\"card\", \"number\")\n    assert_not_nil notification.dig(\"card\", \"board_name\")\n    assert_not_nil notification.dig(\"card\", \"column\")\n\n    card = notifications(:logo_assignment_kevin).card\n    assert_equal card.closed?, notification.dig(\"card\", \"closed\")\n    assert_equal card.postponed?, notification.dig(\"card\", \"postponed\")\n  end\nend\n"
  },
  {
    "path": "test/controllers/prompts/boards/users_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Prompts::Boards::UsersControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n    @board = boards(:writebook)\n  end\n\n  test \"index\" do\n    get prompts_board_users_path(@board)\n    assert_response :success\n    assert_select \"lexxy-prompt-item\", count: 3\n  end\n\n  test \"index excludes inactive users\" do\n    get prompts_board_users_path(@board)\n    assert_response :success\n    assert_select \"lexxy-prompt-item[search*='David']\", count: 1\n\n    users(:david).update!(active: false)\n\n    get prompts_board_users_path(@board)\n    assert_response :success\n    assert_select \"lexxy-prompt-item[search*='David']\", count: 0\n  end\nend\n"
  },
  {
    "path": "test/controllers/prompts/cards_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Prompts::CardsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"index\" do\n    get prompts_cards_path\n    assert_response :success\n  end\nend\n"
  },
  {
    "path": "test/controllers/prompts/tags_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Prompts::TagsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"index\" do\n    get prompts_tags_path\n    assert_response :success\n  end\nend\n"
  },
  {
    "path": "test/controllers/prompts/users_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Prompts::UsersControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"index\" do\n    get prompts_users_path\n    assert_response :success\n  end\nend\n"
  },
  {
    "path": "test/controllers/public/boards/columns/closeds_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Public::Boards::Columns::ClosedsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    boards(:writebook).publish\n  end\n\n  test \"show\" do\n    get public_board_columns_closed_path(boards(:writebook).publication.key)\n    assert_response :success\n  end\n\n  test \"show excludes draft cards\" do\n    draft_card = cards(:buy_domain)\n    draft_card.update!(status: :drafted)\n    Current.set(user: users(:david)) { draft_card.close }\n\n    get public_board_columns_closed_path(boards(:writebook).publication.key)\n    assert_response :success\n    assert_not_includes response.body, draft_card.title\n  end\nend\n"
  },
  {
    "path": "test/controllers/public/boards/columns/not_nows_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Public::Boards::Columns::NotNowsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    boards(:writebook).publish\n  end\n\n  test \"show\" do\n    get public_board_columns_not_now_path(boards(:writebook).publication.key)\n    assert_response :success\n  end\nend\n"
  },
  {
    "path": "test/controllers/public/boards/columns/streams_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Public::Boards::Columns::StreamsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    boards(:writebook).publish\n  end\n\n  test \"show\" do\n    get public_board_columns_stream_path(boards(:writebook).publication.key)\n    assert_response :success\n  end\nend\n"
  },
  {
    "path": "test/controllers/public/boards/columns_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Public::Boards::ColumnsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    boards(:writebook).publish\n  end\n\n  test \"show\" do\n    column = columns(:writebook_in_progress)\n    get public_board_column_path(boards(:writebook).publication.key, column)\n    assert_response :success\n  end\nend\n"
  },
  {
    "path": "test/controllers/public/boards_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Public::BoardsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n\n    boards(:writebook).publish\n  end\n\n  test \"show\" do\n    get published_board_path(boards(:writebook))\n    assert_response :success\n  end\n\n  test \"not found if the board is not published\" do\n    key = boards(:writebook).publication.key\n\n    boards(:writebook).unpublish\n    get public_board_path(key)\n\n    assert_response :not_found\n  end\n\n  test \"show excludes draft cards from closed count\" do\n    draft_card = cards(:buy_domain)\n    draft_card.update!(status: :drafted)\n    Current.set(user: users(:david)) { draft_card.close }\n\n    get published_board_path(boards(:writebook))\n    assert_response :success\n    assert_select \".cards--closed .cards__expander-count\", \"1\"\n  end\n\n  test \"show works without authentication\" do\n    sign_out\n    get published_board_path(boards(:writebook))\n    assert_response :success\n  end\nend\n"
  },
  {
    "path": "test/controllers/public/cards_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Public::CardsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n    @board = boards(:writebook)\n    @card = cards(:logo)\n    @board.publish\n  end\n\n  test \"show\" do\n    get public_board_card_path(@board.publication.key, @card)\n    assert_response :success\n  end\n\n  test \"not found if the board is not published\" do\n    @board.unpublish\n    get public_board_card_path(@board.publication.key, @card)\n    assert_response :not_found\n  end\n\n  test \"not found if the card is drafted\" do\n    @card.update!(status: :drafted)\n    get public_board_card_path(@board.publication.key, @card)\n    assert_response :not_found\n  end\nend\n"
  },
  {
    "path": "test/controllers/qr_codes_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass QrCodesControllerTest < ActionDispatch::IntegrationTest\n  test \"show\" do\n    join_code = account_join_codes(:\"37s\")\n    account = accounts(\"37s\")\n    url = join_url(code: join_code.code, script_name: account.slug, host: \"app.fizzy.do\")\n    signed_token = QrCodeLink.new(url).signed\n\n    get qr_code_path(signed_token)\n\n    assert_response :success\n    assert_match %r{image/svg\\+xml}, response.content_type\n    assert_includes response.body, \"<svg\"\n  end\nend\n"
  },
  {
    "path": "test/controllers/searches/queries_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Searches::QueriesControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"create\" do\n    assert_difference -> { users(:kevin).search_queries.count }, +1 do\n      post searches_queries_path, params: { q: \"layout issues\" }\n    end\n\n    assert_equal \"layout issues\", users(:kevin).search_queries.last.terms\n    assert_response :success\n  end\nend\n"
  },
  {
    "path": "test/controllers/searches_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass SearchesControllerTest < ActionDispatch::IntegrationTest\n  include SearchTestHelper\n\n  setup do\n    @board.update!(all_access: true)\n    @card = @board.cards.create!(title: \"Layout is broken\", description: \"Look at this mess.\", status: \"published\", creator: @user)\n    @comment_card = @board.cards.create!(title: \"Some card\", status: \"published\", creator: @user)\n    @comment_card.comments.create!(body: \"overflowing text issue\", creator: @user)\n    @comment2_card = @board.cards.create!(title: \"Just haggis\", description: \"More haggis\", status: \"published\", creator: @user)\n    @comment2_card.comments.create!(body: \"I love haggis\", creator: @user)\n\n    untenanted { sign_in_as @user }\n  end\n\n  test \"search\" do\n    # Search query is blank\n    get search_path(q: \"\", script_name: \"/#{@account.external_account_id}\")\n    assert @query.nil?\n\n    # Searching by card title\n    get search_path(q: \"broken\", script_name: \"/#{@account.external_account_id}\")\n    assert_select \"li .search__title\", text: /Layout is broken/\n    assert_select \"li .search__excerpt\", text: /Look at this mess/\n\n    # Searching by comment\n    get search_path(q: \"overflowing\", script_name: \"/#{@account.external_account_id}\")\n    assert_select \"li .search__title\", text: /Some card/\n    assert_select \"li .search__excerpt--comment\", text: /overflowing text issue/\n\n    # Searching for a term that appears in a card and in a comment\n    get search_path(q: \"haggis\", script_name: \"/#{@account.external_account_id}\")\n    assert_select \"li .search__title\", text: /Just haggis/, count: 2 # card title shows up in two entries\n    assert_select \"li .search__excerpt\", text: /More haggis/ # one entry for the card description\n    assert_select \"li .search__excerpt--comment\", text: /I love haggis/ # one entry for the comment\n    assert_match(/<mark class=\"circled-text\"><span><\\/span>haggis<\\/mark>/, response.body)\n\n    # Searching by card id\n    get search_path(q: @card.id, script_name: \"/#{@account.external_account_id}\")\n    assert_select \"form[data-controller='auto-submit']\"\n\n    # Searching with non-existent card id\n    get search_path(q: \"999999\", script_name: \"/#{@account.external_account_id}\")\n    assert_select \"form[data-controller='auto-submit']\", count: 0\n    assert_select \".search__blank-slate\", text: \"No matches\"\n  end\n\n  test \"search as JSON\" do\n    get search_path(q: \"broken\", script_name: \"/#{@account.external_account_id}\"), as: :json\n    assert_response :success\n\n    body = @response.parsed_body\n    assert_kind_of Array, body\n    assert_equal 1, body.size\n    assert_equal \"Layout is broken\", body.first[\"title\"]\n  end\n\n  test \"search by card ID as JSON returns array\" do\n    get search_path(q: @card.id, script_name: \"/#{@account.external_account_id}\"), as: :json\n    assert_response :success\n\n    body = @response.parsed_body\n    assert_kind_of Array, body\n    assert_equal 1, body.size\n    assert_equal @card.id, body.first[\"id\"]\n  end\n\n  test \"search as JSON deduplicates cards with multiple search hits\" do\n    get search_path(q: \"haggis\", script_name: \"/#{@account.external_account_id}\"), as: :json\n    assert_response :success\n\n    body = @response.parsed_body\n    assert_kind_of Array, body\n    assert_equal 1, body.size\n    assert_equal @comment2_card.id, body.first[\"id\"]\n  end\n\n  test \"search highlights matched terms with proper HTML marks\" do\n    @board.cards.create!(title: \"Testing search highlighting\", status: \"published\", creator: @user)\n\n    get search_path(q: \"highlighting\", script_name: \"/#{@account.external_account_id}\")\n    assert_response :success\n  end\n\n  test \"search preserves highlight marks but escapes surrounding HTML\" do\n    @board.cards.create!(\n      title: \"<b>Bold</b> testing content\",\n      status: \"published\",\n      creator: @user\n    )\n\n    get search_path(q: \"testing\", script_name: \"/#{@account.external_account_id}\")\n    assert_response :success\n\n    # Should escape <b> tags\n    assert response.body.include?(\"&lt;b&gt;\")\n    # But should preserve highlight marks around \"testing\"\n    assert_match(/<mark class=\"circled-text\"><span><\\/span>testing<\\/mark>/, response.body)\n  end\nend\n"
  },
  {
    "path": "test/controllers/sessions/magic_links_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Sessions::MagicLinksControllerTest < ActionDispatch::IntegrationTest\n  test \"show\" do\n    untenanted do\n      get session_magic_link_url\n\n      assert_response :redirect, \"Without an email address pending authentication, should redirect\"\n      assert_redirected_to new_session_path\n    end\n\n    untenanted do\n      post session_path, params: { email_address: \"test@example.com\" }\n      get session_magic_link_url\n\n      assert_response :success\n    end\n  end\n\n  test \"create with sign in code\" do\n    identity = identities(:kevin)\n    magic_link = MagicLink.create!(identity: identity)\n\n    untenanted do\n      post session_path, params: { email_address: identity.email_address }\n      post session_magic_link_url, params: { code: magic_link.code }\n\n      assert_response :redirect\n      assert cookies[:session_token].present?\n      assert_redirected_to landing_path, \"Should redirect to after authentication path\"\n      assert_not MagicLink.exists?(magic_link.id), \"The magic link should be consumed\"\n    end\n  end\n\n  test \"create with sign up code\" do\n    identity = identities(:kevin)\n    magic_link = MagicLink.create!(identity: identity, purpose: :sign_up)\n\n    untenanted do\n      post session_path, params: { email_address: identity.email_address }\n      post session_magic_link_url, params: { code: magic_link.code }\n\n      assert_response :redirect\n      assert cookies[:session_token].present?\n      assert_redirected_to new_signup_completion_path, \"Should redirect to signup completion\"\n      assert_not MagicLink.exists?(magic_link.id), \"The magic link should be consumed\"\n    end\n  end\n\n  test \"create with cross-user code\" do\n    identity = identities(:kevin)\n    other_identity = identities(:jason)\n    magic_link = MagicLink.create!(identity: other_identity)\n\n    untenanted do\n      post session_path, params: { email_address: identity.email_address }\n      post session_magic_link_url, params: { code: magic_link.code }\n\n      assert_redirected_to new_session_path\n      assert_not cookies[:session_token].present?\n    end\n  end\n\n  test \"create with invalid code\" do\n    identity = identities(:kevin)\n    magic_link = MagicLink.create!(identity: identity)\n\n    untenanted do\n      post session_magic_link_url, params: { code: \"INVALID\" }\n    end\n\n    assert_response :redirect, \"Invalid code should redirect\"\n\n    expired_link = MagicLink.create!(identity: identity)\n    expired_link.update_column(:expires_at, 1.hour.ago)\n\n    post session_magic_link_url, params: { code: expired_link.code }\n\n    assert_response :redirect, \"Expired magic link should redirect\"\n    assert MagicLink.exists?(expired_link.id), \"Expired magic link should not be consumed\"\n  end\n\n  test \"create via JSON for sign in\" do\n    identity = identities(:david)\n    magic_link = identity.send_magic_link\n\n    untenanted do\n      post session_path(format: :json), params: { email_address: identity.email_address }\n      post session_magic_link_path(format: :json), params: { code: magic_link.code }\n      assert_response :success\n      assert @response.parsed_body[\"session_token\"].present?\n      assert_equal false, @response.parsed_body[\"requires_signup_completion\"]\n    end\n  end\n\n  test \"create via JSON for sign up\" do\n    identity = identities(:david)\n    magic_link = identity.send_magic_link(for: :sign_up)\n\n    untenanted do\n      post session_path(format: :json), params: { email_address: identity.email_address }\n      post session_magic_link_path(format: :json), params: { code: magic_link.code }\n      assert_response :success\n      assert @response.parsed_body[\"session_token\"].present?\n      assert_equal true, @response.parsed_body[\"requires_signup_completion\"]\n    end\n  end\n\n  test \"create via JSON without pending_authentication_token\" do\n    identity = identities(:david)\n    magic_link = identity.send_magic_link\n\n    untenanted do\n      post session_magic_link_path(format: :json), params: { code: magic_link.code }\n      assert_response :unauthorized\n      assert_equal \"Enter your email address to sign in.\", @response.parsed_body[\"message\"]\n    end\n  end\n\n  test \"create via JSON with invalid code\" do\n    identity = identities(:david)\n\n    untenanted do\n      post session_path(format: :json), params: { email_address: identity.email_address }\n      post session_magic_link_path(format: :json), params: { code: \"INVALID\" }\n      assert_response :unauthorized\n      assert_equal \"Try another code.\", @response.parsed_body[\"message\"]\n    end\n  end\n\n  test \"create via JSON with cross-user code\" do\n    identity = identities(:david)\n    other_identity = identities(:jason)\n    magic_link = other_identity.send_magic_link\n\n    untenanted do\n      post session_path(format: :json), params: { email_address: identity.email_address }\n      post session_magic_link_path(format: :json), params: { code: magic_link.code }\n      assert_response :unauthorized\n      assert_equal \"Something went wrong. Please try again.\", @response.parsed_body[\"message\"]\n    end\n  end\n\n  test \"create via JSON with expired pending_authentication_token\" do\n    identity = identities(:david)\n    magic_link = identity.send_magic_link\n\n    untenanted do\n      travel_to 20.minutes.ago do\n        post session_path(format: :json), params: { email_address: identity.email_address }\n      end\n\n      post session_magic_link_path(format: :json), params: { code: magic_link.code }\n      assert_response :unauthorized\n      assert_equal \"Enter your email address to sign in.\", @response.parsed_body[\"message\"]\n    end\n  end\nend\n"
  },
  {
    "path": "test/controllers/sessions/menus_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Sessions::MenusControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    @identity = identities(:kevin)\n  end\n\n  test \"show with no account\" do\n    sign_in_as @identity\n    @identity.users.delete_all\n\n    untenanted do\n      get session_menu_url\n    end\n\n    assert_response :success, \"Renders an empty menu\"\n  end\n\n  test \"show with exactly one account\" do\n    sign_in_as @identity\n\n    Current.without_account do\n      @identity.users.delete_all\n      account = Account.create!(external_account_id: 9999991, name: \"Test Account\")\n      @identity.users.create!(account: account, name: \"Kevin\")\n    end\n\n    untenanted do\n      get session_menu_url\n    end\n\n    assert_response :redirect\n    assert_redirected_to root_url(script_name: \"/9999991\")\n  end\n\n  test \"show with multiple accounts\" do\n    sign_in_as @identity\n    @identity.users.delete_all\n    account1 = Account.create!(external_account_id: 9999992, name: \"37signals\")\n    account2 = Account.create!(external_account_id: 9999993, name: \"Acme\")\n    @identity.users.create!(account: account1, name: \"Kevin\")\n    @identity.users.create!(account: account2, name: \"Kevin\")\n\n    untenanted do\n      get session_menu_url\n    end\n\n    assert_response :success\n  end\n\n  test \"show excludes cancelled accounts\" do\n    sign_in_as @identity\n    @identity.users.delete_all\n    account1 = Account.create!(external_account_id: 9999994, name: \"Active Account\")\n    account2 = Account.create!(external_account_id: 9999995, name: \"Cancelled Account\")\n    user1 = @identity.users.create!(account: account1, name: \"Kevin\", role: \"owner\")\n    user2 = @identity.users.create!(account: account2, name: \"Kevin\", role: \"owner\")\n\n    # Cancel one account\n    account2.cancel(initiated_by: user2)\n\n    untenanted do\n      get session_menu_url\n    end\n\n    # Should redirect to the only active account\n    assert_response :redirect\n    assert_redirected_to root_url(script_name: account1.slug)\n  end\nend\n"
  },
  {
    "path": "test/controllers/sessions/passkeys_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest\n  include WebauthnTestHelper\n\n  setup do\n    @identity = identities(:kevin)\n\n    @credential = @identity.passkeys.create!(\n      name: \"Test Passkey\",\n      credential_id: Base64.urlsafe_encode64(SecureRandom.random_bytes(32), padding: false),\n      public_key: webauthn_private_key.public_to_der,\n      sign_count: 0,\n      transports: [ \"internal\" ]\n    )\n  end\n\n  test \"successful authentication\" do\n    untenanted do\n      challenge = request_webauthn_challenge\n\n      post session_passkey_url, params: build_assertion_params(challenge: challenge, credential: @credential)\n\n      assert_response :redirect\n      assert cookies[:session_token].present?\n      assert_redirected_to landing_path\n    end\n  end\n\n  test \"updates sign count\" do\n    untenanted do\n      challenge = request_webauthn_challenge\n\n      post session_passkey_url, params: build_assertion_params(challenge: challenge, credential: @credential, sign_count: 1)\n\n      assert_equal 1, @credential.reload.sign_count\n    end\n  end\n\n  test \"rejects invalid signature\" do\n    untenanted do\n      challenge = request_webauthn_challenge\n\n      params = build_assertion_params(challenge: challenge, credential: @credential)\n      params[:passkey][:signature] = Base64.urlsafe_encode64(\"invalid\", padding: false)\n\n      post session_passkey_url, params: params\n\n      assert_redirected_to new_session_path\n      assert_not cookies[:session_token].present?\n      assert_equal \"That passkey didn't work. Try again.\", flash[:alert]\n    end\n  end\n\n  test \"rejects unknown credential\" do\n    untenanted do\n      request_webauthn_challenge\n\n      post session_passkey_url, params: {\n        passkey: {\n          id: \"nonexistent\",\n          client_data_json: Base64.urlsafe_encode64(\"{}\", padding: false),\n          authenticator_data: Base64.urlsafe_encode64(\"x\", padding: false),\n          signature: Base64.urlsafe_encode64(\"x\", padding: false)\n        }\n      }\n\n      assert_redirected_to new_session_path\n      assert_not cookies[:session_token].present?\n    end\n  end\n\n  test \"successful authentication via JSON\" do\n    untenanted do\n      challenge = request_webauthn_challenge\n\n      post session_passkey_url(format: :json), params: build_assertion_params(challenge: challenge, credential: @credential)\n\n      assert_response :success\n      assert @response.parsed_body[\"session_token\"].present?\n    end\n  end\n\n  test \"failed authentication via JSON\" do\n    untenanted do\n      request_webauthn_challenge\n\n      post session_passkey_url(format: :json), params: {\n        passkey: {\n          id: \"nonexistent\",\n          client_data_json: Base64.urlsafe_encode64(\"{}\", padding: false),\n          authenticator_data: Base64.urlsafe_encode64(\"x\", padding: false),\n          signature: Base64.urlsafe_encode64(\"x\", padding: false)\n        }\n      }\n\n      assert_response :unauthorized\n      assert_equal \"That passkey didn't work. Try again.\", @response.parsed_body[\"message\"]\n    end\n  end\nend\n"
  },
  {
    "path": "test/controllers/sessions/transfers_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Sessions::TransfersControllerTest < ActionDispatch::IntegrationTest\n  test \"show renders when not signed in\" do\n    untenanted do\n      get session_transfer_path(\"some-token\")\n\n      assert_response :success\n    end\n  end\n\n  test \"update establishes a session when the code is valid\" do\n    identity = identities(:david)\n\n    untenanted do\n      put session_transfer_path(identity.transfer_id)\n\n      assert_redirected_to session_menu_url(script_name: nil)\n      assert parsed_cookies.signed[:session_token]\n    end\n  end\nend\n"
  },
  {
    "path": "test/controllers/sessions_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass SessionsControllerTest < ActionDispatch::IntegrationTest\n  test \"new\" do\n    untenanted do\n      get new_session_path\n    end\n\n    assert_response :success\n  end\n\n  test \"new redirects authenticated users\" do\n    sign_in_as :kevin\n\n    untenanted do\n      get new_session_path\n      assert_redirected_to root_url\n    end\n  end\n\n  test \"create\" do\n    identity = identities(:kevin)\n\n    untenanted do\n      assert_difference -> { MagicLink.count }, 1 do\n        post session_path, params: { email_address: identity.email_address }\n      end\n\n      assert_redirected_to session_magic_link_path\n      assert_nil flash[:magic_link_code]\n    end\n  end\n\n  test \"create for a new user\" do\n    untenanted do\n      assert_difference -> { MagicLink.count }, +1 do\n        assert_difference -> { Identity.count }, +1 do\n          post session_path,\n            params: { email_address: \"nonexistent-#{SecureRandom.hex(6)}@example.com\" }\n        end\n      end\n\n      assert_redirected_to session_magic_link_path\n      assert MagicLink.last.for_sign_up?\n    end\n  end\n\n  test \"create for a new user when single tenant mode already has a tenant\" do\n    with_multi_tenant_mode(false) do\n      untenanted do\n        assert_no_difference -> { MagicLink.count } do\n          assert_no_difference -> { Identity.count } do\n            post session_path,\n              params: { email_address: \"nonexistent-#{SecureRandom.hex(6)}@example.com\" }\n          end\n        end\n\n        assert_redirected_to session_magic_link_path\n      end\n    end\n  end\n\n  test \"create with invalid email address\" do\n    # Avoid Sentry exceptions when attackers try to stuff invalid emails. The browser performs form\n    # field validation that should normally prevent this from occurring, so I'm not worried about\n    # returning proper validation errors.\n    without_action_dispatch_exception_handling do\n      untenanted do\n        assert_no_difference -> { Identity.count } do\n          post session_path, params: { email_address: \"not-a-valid-email\" }\n        end\n\n        assert_response :redirect\n        assert_redirected_to new_session_path\n      end\n    end\n  end\n\n  test \"destroy\" do\n    sign_in_as :kevin\n\n    untenanted do\n      delete session_path\n\n      assert_redirected_to new_session_path\n      assert_not cookies[:session_token].present?\n    end\n  end\n\n  test \"create via JSON\" do\n    untenanted do\n      post session_path(format: :json), params: { email_address: identities(:david).email_address }\n      assert_response :created\n    end\n  end\n\n  test \"create for a new user via JSON\" do\n    new_email = \"new-user-#{SecureRandom.hex(6)}@example.com\"\n\n    untenanted do\n      assert_difference -> { Identity.count }, 1 do\n        assert_difference -> { MagicLink.count }, 1 do\n          post session_path(format: :json), params: { email_address: new_email }\n        end\n      end\n      assert_response :created\n      assert @response.parsed_body[\"pending_authentication_token\"].present?\n      assert MagicLink.last.for_sign_up?\n    end\n  end\n\n  test \"create with invalid email address via JSON\" do\n    untenanted do\n      assert_no_difference -> { Identity.count } do\n        post session_path(format: :json), params: { email_address: \"not-a-valid-email\" }\n      end\n      assert_response :unprocessable_entity\n    end\n  end\n\n  test \"destroy via JSON\" do\n    sign_in_as :kevin\n\n    untenanted do\n      delete session_path(format: :json)\n\n      assert_response :no_content\n      assert_not cookies[:session_token].present?\n    end\n  end\nend\n"
  },
  {
    "path": "test/controllers/signup/completions_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Signup::CompletionsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    @signup = Signup.new(email_address: \"newuser@example.com\", full_name: \"New User\")\n\n    @signup.create_identity || raise(\"Failed to create identity\")\n\n    sign_in_as @signup.identity\n  end\n\n  test \"new\" do\n    untenanted do\n      get new_signup_completion_path\n    end\n\n    assert_response :success\n  end\n\n  test \"create\" do\n    untenanted do\n      post signup_completion_path, params: {\n        signup: {\n          full_name: @signup.full_name\n        }\n      }\n    end\n\n    assert_response :redirect, \"Valid params should redirect\"\n  end\n\n  test \"shows welcome letter after signup\" do\n    untenanted do\n      post signup_completion_path, params: {\n        signup: {\n          full_name: @signup.full_name\n        }\n      }\n    end\n\n    assert flash[:welcome_letter]\n  end\n\n  test \"create with blank name\" do\n    untenanted do\n      post signup_completion_path, params: {\n        signup: {\n          full_name: \"\"\n        }\n      }\n    end\n\n    assert_response :unprocessable_entity\n    assert_select \".txt-negative\" do\n      assert_select \"li\", text: \"Full name can't be blank\"\n    end\n  end\n\n  test \"create via JSON\" do\n    untenanted do\n      assert_difference -> { Account.count }, +1 do\n        post signup_completion_path(format: :json), params: {\n          signup: {\n            full_name: @signup.full_name\n          }\n        }\n      end\n    end\n\n    assert_response :created\n  end\n\n  test \"create via JSON with blank name\" do\n    untenanted do\n      assert_no_difference -> { Account.count } do\n        post signup_completion_path(format: :json), params: {\n          signup: {\n            full_name: \"\"\n          }\n        }\n      end\n    end\n\n    assert_response :unprocessable_entity\n    assert_includes @response.parsed_body[\"errors\"], \"Full name can't be blank\"\n  end\nend\n"
  },
  {
    "path": "test/controllers/signups_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass SignupsControllerTest < ActionDispatch::IntegrationTest\n  test \"new\" do\n    untenanted do\n      get new_signup_path\n\n      assert_response :success\n    end\n  end\n\n  test \"new for an authenticated user\" do\n    identity = identities(:kevin)\n    sign_in_as identity\n\n    untenanted do\n      get new_signup_path\n\n      assert_redirected_to new_signup_completion_path\n    end\n  end\n\n  test \"create\" do\n    email_address = \"newuser-#{SecureRandom.hex(6)}@example.com\"\n\n    untenanted do\n      assert_difference -> { Identity.count }, +1 do\n        assert_difference -> { MagicLink.count }, +1 do\n          post signup_path, params: { signup: { email_address: email_address } }\n        end\n      end\n\n      assert_redirected_to session_magic_link_path\n    end\n  end\n\n  test \"create with invalid email address\" do\n    without_action_dispatch_exception_handling do\n      untenanted do\n        assert_no_difference -> { Identity.count } do\n          assert_no_difference -> { MagicLink.count } do\n            post signup_path, params: { signup: { email_address: \"not-a-valid-email\" } }\n          end\n        end\n\n        assert_response :unprocessable_entity\n      end\n    end\n  end\n\n  test \"create for an authenticated user\" do\n    identity = identities(:kevin)\n    sign_in_as identity\n\n    untenanted do\n      assert_no_difference -> { Identity.count } do\n        assert_no_difference -> { MagicLink.count } do\n          post signup_path,\n            params: { signup: { email_address: identity.email_address } }\n        end\n      end\n\n      assert_redirected_to new_signup_completion_path\n    end\n  end\n\n  test \"redirects to session#new when single_tenant and user exists\" do\n    users(:david)\n\n    with_multi_tenant_mode(false) do\n      untenanted do\n        get new_signup_path\n\n        assert_redirected_to new_session_url\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "test/controllers/tags_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass TagsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"index as JSON\" do\n    tags = users(:kevin).account.tags.alphabetically\n\n    get tags_path, as: :json\n    assert_response :success\n    assert_equal tags.count, @response.parsed_body.count\n    assert_equal tags.pluck(:title), @response.parsed_body.pluck(\"title\")\n  end\nend\n"
  },
  {
    "path": "test/controllers/users/avatars_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Users::AvatarsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :david\n  end\n\n  test \"show system user\" do\n    get user_avatar_path(users(:system))\n\n    assert_response :redirect\n    assert_redirected_to ActionController::Base.helpers.image_path(\"system_user.png\")\n  end\n\n  test \"show own initials without caching\" do\n    get user_avatar_path(users(:david))\n    assert_match \"image/svg+xml\", @response.content_type\n    assert @response.cache_control[:private]\n    assert_equal \"0\", @response.cache_control[:max_age]\n  end\n\n  test \"show other initials with caching\" do\n    get user_avatar_path(users(:kevin))\n    assert_match \"image/svg+xml\", @response.content_type\n    assert @response.cache_control[:private]\n    assert_equal 30.minutes.to_s, @response.cache_control[:max_age]\n  end\n\n  test \"show own image redirects to the blob url\" do\n    users(:david).avatar.attach(io: File.open(file_fixture(\"moon.jpg\")), filename: \"moon.jpg\", content_type: \"image/jpeg\")\n    assert users(:david).avatar.attached?\n\n    get user_avatar_path(users(:david))\n\n    assert_redirected_to rails_blob_url(users(:david).avatar_thumbnail, disposition: \"inline\")\n  end\n\n  test \"show other image redirects to the blob url\" do\n    users(:kevin).avatar.attach(io: File.open(file_fixture(\"moon.jpg\")), filename: \"moon.jpg\", content_type: \"image/jpeg\")\n    assert users(:kevin).avatar.attached?\n\n    get user_avatar_path(users(:kevin))\n\n    assert_redirected_to rails_blob_url(users(:kevin).avatar_thumbnail, disposition: \"inline\")\n  end\n\n  test \"delete self\" do\n    delete user_avatar_path(users(:david))\n    assert_redirected_to users(:david)\n  end\n\n  test \"delete self as JSON\" do\n    delete user_avatar_path(users(:david)), as: :json\n    assert_response :no_content\n  end\n\n  test \"unable to delete other\" do\n    delete user_avatar_path(users(:kevin))\n    assert_response :forbidden\n  end\nend\n"
  },
  {
    "path": "test/controllers/users/data_exports_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Users::DataExportsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :david\n    @user = users(:david)\n  end\n\n  test \"create creates an export record and enqueues job\" do\n    assert_difference -> { User::DataExport.count }, 1 do\n      assert_enqueued_with(job: DataExportJob) do\n        post user_data_exports_path(@user)\n      end\n    end\n\n    assert_redirected_to @user\n    assert_equal \"Export started. You'll receive an email when it's ready.\", flash[:notice]\n  end\n\n  test \"create associates export with user and account\" do\n    post user_data_exports_path(@user)\n\n    export = User::DataExport.last\n    assert_equal @user, export.user\n    assert_equal Current.account, export.account\n    assert export.pending?\n  end\n\n  test \"create rejects request when current export limit is reached\" do\n    Users::DataExportsController::CURRENT_EXPORT_LIMIT.times do\n      @user.data_exports.create!(account: Current.account)\n    end\n\n    assert_no_difference -> { User::DataExport.count } do\n      post user_data_exports_path(@user)\n    end\n\n    assert_response :too_many_requests\n  end\n\n  test \"create allows request when exports are older than one day\" do\n    Users::DataExportsController::CURRENT_EXPORT_LIMIT.times do\n      @user.data_exports.create!(account: Current.account, created_at: 2.days.ago)\n    end\n\n    assert_difference -> { User::DataExport.count }, 1 do\n      post user_data_exports_path(@user)\n    end\n\n    assert_redirected_to @user\n  end\n\n  test \"show displays completed export with download link\" do\n    export = @user.data_exports.create!(account: Current.account)\n    export.build\n\n    get user_data_export_path(@user, export)\n\n    assert_response :success\n    assert_select \"a#download-link\"\n  end\n\n  test \"show displays a warning if the export is missing\" do\n    get user_data_export_path(@user, \"not-really-an-export\")\n\n    assert_response :success\n    assert_select \"h2\", \"Download Expired\"\n  end\n\n  test \"create is forbidden for other users\" do\n    other_user = users(:kevin)\n\n    post user_data_exports_path(other_user)\n\n    assert_response :forbidden\n  end\n\n  test \"show is forbidden for other users\" do\n    other_user = users(:kevin)\n    export = other_user.data_exports.create!(account: Current.account)\n    export.build\n\n    get user_data_export_path(other_user, export)\n\n    assert_response :forbidden\n  end\nend\n"
  },
  {
    "path": "test/controllers/users/email_addresses/confirmations_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Users::EmailAddresses::ConfirmationsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    @user = users(:david)\n    @old_email = @user.identity.email_address\n    @new_email = \"newemail@example.com\"\n    @token = @user.send(:generate_email_address_change_token, to: @new_email)\n  end\n\n  test \"show\" do\n    get user_email_address_confirmation_path(user_id: @user.id, email_address_token: @token, script_name: @user.account.slug)\n    assert_response :success\n  end\n\n  test \"create\" do\n    post user_email_address_confirmation_path(user_id: @user.id, email_address_token: @token, script_name: @user.account.slug)\n\n    assert_equal @new_email, @user.reload.identity.email_address\n    assert_redirected_to edit_user_url(script_name: @user.account.slug, id: @user)\n  end\n\n  test \"create with invalid token\" do\n    post user_email_address_confirmation_path(user_id: @user.id, email_address_token: \"invalid\", script_name: @user.account.slug)\n\n    assert_equal @user.identity.email_address, @old_email\n    assert_response :unprocessable_entity\n    assert_match /Link expired/, response.body\n  end\nend\n"
  },
  {
    "path": "test/controllers/users/email_addresses_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Users::EmailAddressesControllerTest < ActionDispatch::IntegrationTest\n  include ActionMailer::TestHelper\n\n  setup do\n    sign_in_as :david\n    @user = users(:david)\n  end\n\n  test \"new\" do\n    get new_user_email_address_path(@user, script_name: @user.account.slug)\n    assert_response :success\n  end\n\n  test \"create\" do\n    assert_emails 1 do\n      post user_email_addresses_path(@user, script_name: @user.account.slug), params: { email_address: \"newemail@example.com\" }\n    end\n    assert_response :success\n  end\n\n  test \"create with existing email in same account\" do\n    existing_user = users(:kevin)\n    existing_email = existing_user.identity.email_address\n\n    post user_email_addresses_path(@user, script_name: @user.account.slug), params: { email_address: existing_email }\n    assert_redirected_to new_user_email_address_path(@user)\n    assert_equal \"You already have a user in this account with that email address\", flash[:alert]\n  end\n\n  test \"create for other user\" do\n    other_user = users(:kevin)\n\n    assert_no_emails do\n      post user_email_addresses_path(other_user, script_name: @user.account.slug), params: { email_address: \"newemail@example.com\" }\n    end\n    assert_response :not_found\n  end\nend\n"
  },
  {
    "path": "test/controllers/users/events_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Users::EventsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"show self\" do\n    get user_events_path(users(:kevin))\n    assert_in_body \"What have you been up to?\"\n  end\n\n  test \"show other\" do\n    get user_events_path(users(:david))\n    assert_in_body \"What has David been up to?\"\n  end\nend\n"
  },
  {
    "path": "test/controllers/users/joins_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Users::JoinsControllerTest < ActionDispatch::IntegrationTest\n  test \"new\" do\n    sign_in_as :david\n\n    get new_users_join_path\n    assert_response :ok\n  end\n\n  test \"create\" do\n    user = users(:david)\n    sign_in_as user\n\n    assert_no_difference -> { User.count } do\n      post users_joins_path, params: { user: { name: \"David Updated\" } }\n      assert_redirected_to landing_path\n    end\n\n    assert_equal \"David Updated\", user.reload.name\n  end\nend\n"
  },
  {
    "path": "test/controllers/users/push_subscriptions_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Users::PushSubscriptionsControllerTest < ActionDispatch::IntegrationTest\n  PUBLIC_TEST_IP = \"142.250.185.206\"\n\n  setup do\n    sign_in_as :david\n    stub_dns_resolution(PUBLIC_TEST_IP)\n  end\n\n  test \"create new push subscription\" do\n    subscription_params = { \"endpoint\" => \"https://fcm.googleapis.com/fcm/send/abc123\", \"p256dh_key\" => \"123\", \"auth_key\" => \"456\" }\n\n    post user_push_subscriptions_path(users(:david)),\n      params: { push_subscription: subscription_params }, headers: { \"HTTP_USER_AGENT\" => \"Mozilla/5.0\" }\n\n    assert_response :no_content\n\n    assert_equal subscription_params, users(:david).push_subscriptions.last.attributes.slice(\"endpoint\", \"p256dh_key\", \"auth_key\")\n    assert_equal \"Mozilla/5.0\", users(:david).push_subscriptions.last.user_agent\n  end\n\n  test \"create as JSON\" do\n    subscription_params = { \"endpoint\" => \"https://fcm.googleapis.com/fcm/send/abc123\", \"p256dh_key\" => \"123\", \"auth_key\" => \"456\" }\n\n    post user_push_subscriptions_path(users(:david)),\n      params: { push_subscription: subscription_params }, headers: { \"HTTP_USER_AGENT\" => \"Mozilla/5.0\" }, as: :json\n\n    assert_response :created\n  end\n\n  test \"create as JSON for duplicate subscription\" do\n    subscription_params = { \"endpoint\" => \"https://fcm.googleapis.com/fcm/send/abc123\", \"p256dh_key\" => \"123\", \"auth_key\" => \"456\" }\n\n    users(:david).push_subscriptions.create!(subscription_params)\n\n    assert_no_difference -> { Push::Subscription.count } do\n      post user_push_subscriptions_path(users(:david)),\n        params: { push_subscription: subscription_params }, as: :json\n    end\n\n    assert_response :created\n  end\n\n  test \"destroy as JSON\" do\n    subscription = users(:david).push_subscriptions.create!(\n      endpoint: \"https://fcm.googleapis.com/fcm/send/abc123\",\n      p256dh_key: \"123\",\n      auth_key: \"456\"\n    )\n\n    assert_difference -> { Push::Subscription.count }, -1 do\n      delete user_push_subscription_path(users(:david), subscription), as: :json\n    end\n\n    assert_response :no_content\n  end\n\n  test \"destroy a push subscription\" do\n    subscription = users(:david).push_subscriptions.create!(\n      endpoint: \"https://fcm.googleapis.com/fcm/send/abc123\",\n      p256dh_key: \"123\",\n      auth_key: \"456\"\n    )\n\n    assert_difference -> { Push::Subscription.count }, -1 do\n      delete user_push_subscription_path(users(:david), subscription)\n      assert_redirected_to user_push_subscriptions_path(users(:david))\n    end\n  end\n\n  test \"rejects subscription with non-permitted endpoint\" do\n    subscription_params = { \"endpoint\" => \"https://attacker.example.com/steal\", \"p256dh_key\" => \"123\", \"auth_key\" => \"456\" }\n\n    assert_no_difference -> { Push::Subscription.count } do\n      post user_push_subscriptions_path(users(:david)),\n        params: { push_subscription: subscription_params }\n    end\n\n    assert_response :unprocessable_entity\n  end\n\n  test \"rejects subscription with endpoint resolving to private IP\" do\n    stub_dns_resolution(\"192.168.1.1\")\n\n    subscription_params = { \"endpoint\" => \"https://fcm.googleapis.com/fcm/send/abc123\", \"p256dh_key\" => \"123\", \"auth_key\" => \"456\" }\n\n    assert_no_difference -> { Push::Subscription.count } do\n      post user_push_subscriptions_path(users(:david)),\n        params: { push_subscription: subscription_params }\n    end\n\n    assert_response :unprocessable_entity\n  end\n\n  private\n    def stub_dns_resolution(*ips)\n      dns_mock = mock(\"dns\")\n      dns_mock.stubs(:each_address).multiple_yields(*ips)\n      Resolv::DNS.stubs(:open).yields(dns_mock)\n    end\nend\n"
  },
  {
    "path": "test/controllers/users/roles_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Users::RolesControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"update\" do\n    assert_not users(:david).admin?\n\n    put user_role_path(users(:david)), params: { user: { role: \"admin\" } }\n\n    assert_redirected_to account_settings_path\n    assert users(:david).reload.admin?\n  end\n\n  test \"update as JSON\" do\n    put user_role_path(users(:david)), params: { user: { role: \"admin\" } }, as: :json\n\n    assert_response :no_content\n    assert users(:david).reload.admin?\n  end\n\n  test \"can't promote to special roles\" do\n    assert_no_changes -> { users(:david).reload.role } do\n      put user_role_path(users(:david)), params: { user: { role: \"system\" } }\n    end\n\n    assert_no_changes -> { users(:david).reload.role } do\n      put user_role_path(users(:david)), params: { user: { role: \"owner\" } }\n    end\n  end\n\n  test \"admin cannot demote the owner\" do\n    assert users(:jason).owner?\n\n    assert_no_changes -> { users(:jason).reload.role } do\n      put user_role_path(users(:jason)), params: { user: { role: \"admin\" } }\n    end\n\n    assert_response :forbidden\n  end\n\n  test \"admin cannot change owner role to member\" do\n    assert users(:jason).owner?\n\n    assert_no_changes -> { users(:jason).reload.role } do\n      put user_role_path(users(:jason)), params: { user: { role: \"member\" } }\n    end\n\n    assert_response :forbidden\n  end\nend\n"
  },
  {
    "path": "test/controllers/users/verifications_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Users::VerificationsControllerTest < ActionDispatch::IntegrationTest\n  test \"new renders the auto-submit form\" do\n    sign_in_as :david\n\n    get new_users_verification_path\n\n    assert_response :ok\n  end\n\n  test \"create verifies the user and redirects to join\" do\n    sign_in_as :david\n\n    user = users(:david)\n    user.update_column(:verified_at, nil)\n    assert_not user.verified?\n\n    post users_verifications_path\n\n    assert_redirected_to new_users_join_path\n    assert user.reload.verified?\n  end\nend\n"
  },
  {
    "path": "test/controllers/users_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass UsersControllerTest < ActionDispatch::IntegrationTest\n  test \"show\" do\n    sign_in_as :kevin\n\n    get user_path(users(:david))\n    assert_in_body users(:david).name\n  end\n\n  test \"update oneself\" do\n    sign_in_as :kevin\n\n    get edit_user_path(users(:kevin))\n    assert_response :ok\n\n    put user_path(users(:kevin)), params: { user: { name: \"New Kevin\" } }\n    assert_redirected_to user_path(users(:kevin))\n    assert_equal \"New Kevin\", users(:kevin).reload.name\n  end\n\n  test \"update other as admin\" do\n    sign_in_as :kevin\n\n    get edit_user_path(users(:david))\n    assert_response :ok\n\n    put user_path(users(:david)), params: { user: { name: \"New David\" } }\n    assert_redirected_to user_path(users(:david))\n    assert_equal \"New David\", users(:david).reload.name\n  end\n\n  test \"destroy\" do\n    sign_in_as :kevin\n\n    assert_difference -> { User.active.count }, -1 do\n      delete user_path(users(:david))\n    end\n\n    assert_redirected_to account_settings_path\n    assert_nil User.active.find_by(id: users(:david).id)\n  end\n\n  test \"admin cannot deactivate the owner\" do\n    sign_in_as :kevin\n\n    assert users(:jason).owner?\n    assert users(:jason).active\n\n    assert_no_difference -> { User.active.count } do\n      delete user_path(users(:jason))\n    end\n\n    assert_response :forbidden\n    assert users(:jason).reload.active\n  end\n\n  test \"non-admins cannot perform actions\" do\n    sign_in_as :jz\n\n    put user_path(users(:david)), params: { user: { role: \"admin\" } }\n    assert_response :forbidden\n\n    delete user_path(users(:david))\n    assert_response :forbidden\n  end\n\n  test \"update with invalid avatar content type shows validation error\" do\n    sign_in_as :kevin\n\n    svg_file = fixture_file_upload(\"avatar.svg\", \"image/svg+xml\")\n\n    put user_path(users(:kevin)), params: { user: { avatar: svg_file } }\n    assert_response :unprocessable_entity\n    assert_select \"form[action='#{user_path(users(:kevin))}']\"\n    assert_select \".txt-negative\", text: /must be a JPEG, PNG, GIF, or WebP image/\n  end\n\n  test \"update with oversized avatar shows validation error\" do\n    sign_in_as :kevin\n\n    png_file = fixture_file_upload(\"avatar.png\", \"image/png\")\n\n    ActiveStorage::Analyzer::ImageAnalyzer::Vips.any_instance.stubs(:metadata).returns({ width: 5000, height: 100 })\n\n    put user_path(users(:kevin)), params: { user: { avatar: png_file } }\n    assert_response :unprocessable_entity\n    assert_select \".txt-negative\", text: /width must be less than 4096px/\n  end\n\n  test \"update with valid avatar\" do\n    sign_in_as :kevin\n\n    png_file = fixture_file_upload(\"avatar.png\", \"image/png\")\n\n    put user_path(users(:kevin)), params: { user: { avatar: png_file } }\n    assert_redirected_to user_path(users(:kevin))\n    assert users(:kevin).reload.avatar.attached?\n    assert_equal \"image/png\", users(:kevin).avatar.content_type\n  end\n\n  test \"index as JSON\" do\n    sign_in_as :kevin\n\n    get users_path, as: :json\n    assert_response :success\n    assert_equal users(:kevin).account.users.active.count, @response.parsed_body.count\n  end\n\n  test \"show as JSON\" do\n    sign_in_as :kevin\n\n    get user_path(users(:david)), as: :json\n    assert_response :success\n    assert_equal users(:david).name, @response.parsed_body[\"name\"]\n  end\n\n  test \"update as JSON\" do\n    sign_in_as :kevin\n\n    put user_path(users(:david)), params: { user: { name: \"New David\" } }, as: :json\n\n    assert_response :no_content\n    assert_equal \"New David\", users(:david).reload.name\n  end\n\n  test \"update as JSON with invalid avatar returns errors\" do\n    sign_in_as :kevin\n\n    svg_file = fixture_file_upload(\"avatar.svg\", \"image/svg+xml\")\n\n    put user_path(users(:kevin), format: :json), params: { user: { avatar: svg_file } }\n\n    assert_response :unprocessable_entity\n    assert @response.parsed_body[\"avatar\"].present?\n  end\n\n  test \"destroy as JSON\" do\n    sign_in_as :kevin\n\n    assert_difference -> { User.active.count }, -1 do\n      delete user_path(users(:david)), as: :json\n    end\n\n    assert_response :no_content\n  end\n\n  test \"index avoids N+1 queries on identity\" do\n    sign_in_as :kevin\n\n    assert_queries_match(/FROM [`\"]identities[`\"].* IN \\(/, count: 1) do\n      get users_path, as: :json\n      assert_response :success\n    end\n\n    json = @response.parsed_body\n    assert json.first[\"email_address\"].present?\n  end\nend\n"
  },
  {
    "path": "test/controllers/webhooks/activations_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Webhooks::ActivationsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"create\" do\n    webhook = webhooks(:inactive)\n\n    assert_not webhook.active?\n\n    assert_changes -> { webhook.reload.active? }, from: false, to: true do\n      post board_webhook_activation_path(webhook.board, webhook)\n    end\n\n    assert_redirected_to board_webhook_path(webhook.board, webhook)\n  end\n\n  test \"cannot activate webhook on board without access\" do\n    logout_and_sign_in_as :jason\n    webhook = webhooks(:inactive)  # on private board, jason has no access\n\n    post board_webhook_activation_path(webhook.board, webhook)\n    assert_response :not_found\n  end\n\n  test \"non-admin cannot activate webhook\" do\n    logout_and_sign_in_as :jz  # member with writebook access, but not admin\n    webhook = webhooks(:active)  # on writebook board\n\n    post board_webhook_activation_path(webhook.board, webhook)\n    assert_response :forbidden\n  end\n\n  test \"create as JSON\" do\n    webhook = webhooks(:inactive)\n\n    assert_not webhook.active?\n\n    assert_changes -> { webhook.reload.active? }, from: false, to: true do\n      post board_webhook_activation_path(webhook.board, webhook), as: :json\n    end\n\n    assert_response :created\n    assert_equal webhook.id, @response.parsed_body[\"id\"]\n    assert_equal true, @response.parsed_body[\"active\"]\n  end\n\n  test \"cannot activate webhook on board without access as JSON\" do\n    logout_and_sign_in_as :jason\n    webhook = webhooks(:inactive)\n\n    post board_webhook_activation_path(webhook.board, webhook), as: :json\n\n    assert_response :not_found\n  end\n\n  test \"non-admin cannot activate webhook as JSON\" do\n    logout_and_sign_in_as :jz\n    webhook = webhooks(:active)\n\n    post board_webhook_activation_path(webhook.board, webhook), as: :json\n\n    assert_response :forbidden\n  end\nend\n"
  },
  {
    "path": "test/controllers/webhooks_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass WebhooksControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n  end\n\n  test \"index\" do\n    get board_webhooks_path(boards(:writebook))\n    assert_response :success\n  end\n\n  test \"show\" do\n    webhook = webhooks(:active)\n    get board_webhook_path(webhook.board, webhook)\n    assert_response :success\n\n    webhook = webhooks(:inactive)\n    get board_webhook_path(webhook.board, webhook)\n    assert_response :success\n  end\n\n  test \"new\" do\n    get new_board_webhook_path(boards(:writebook))\n    assert_response :success\n    assert_select \"form\"\n  end\n\n  test \"create with valid params\" do\n    board = boards(:writebook)\n\n    assert_difference \"Webhook.count\", 1 do\n      post board_webhooks_path(board), params: {\n        webhook: {\n          name: \"Test Webhook\",\n          url: \"https://example.com/webhook\",\n          subscribed_actions: [ \"\", \"card_published\", \"card_closed\" ]\n        }\n      }\n    end\n\n    webhook = Webhook.last\n\n    assert_redirected_to board_webhook_path(webhook.board, webhook)\n    assert_equal board, webhook.board\n    assert_equal \"Test Webhook\", webhook.name\n    assert_equal \"https://example.com/webhook\", webhook.url\n    assert_equal [ \"card_published\", \"card_closed\" ], webhook.subscribed_actions\n  end\n\n  test \"create with invalid params\" do\n    board = boards(:writebook)\n    assert_no_difference \"Webhook.count\" do\n      post board_webhooks_path(board), params: {\n        webhook: {\n          name: \"\",\n          url: \"invalid-url\"\n        }\n      }\n    end\n\n    assert_response :unprocessable_entity\n  end\n\n  test \"edit\" do\n    webhook = webhooks(:active)\n    get edit_board_webhook_path(webhook.board, webhook)\n    assert_response :success\n    assert_select \"form\"\n\n    webhook = webhooks(:inactive)\n    get edit_board_webhook_path(webhook.board, webhook)\n    assert_response :success\n    assert_select \"form\"\n  end\n\n  test \"update with valid params\" do\n    webhook = webhooks(:active)\n    patch board_webhook_path(webhook.board, webhook), params: {\n      webhook: {\n        name: \"Updated Webhook\",\n        subscribed_actions: [ \"card_published\" ]\n      }\n    }\n\n    webhook.reload\n\n    assert_redirected_to board_webhook_path(webhook.board, webhook)\n    assert_equal \"Updated Webhook\", webhook.name\n    assert_equal [ \"card_published\" ], webhook.subscribed_actions\n  end\n\n  test \"update with invalid params\" do\n    webhook = webhooks(:active)\n    patch board_webhook_path(webhook.board, webhook), params: {\n      webhook: {\n        name: \"\"\n      }\n    }\n\n    assert_response :unprocessable_entity\n\n    assert_no_changes -> { webhook.reload.url } do\n      patch board_webhook_path(webhook.board, webhook), params: {\n        webhook: {\n          name: \"Updated Webhook\",\n          url: \"https://different.com/webhook\"\n        }\n      }\n    end\n\n    assert_redirected_to board_webhook_path(webhook.board, webhook)\n  end\n\n  test \"destroy\" do\n    webhook = webhooks(:active)\n\n    assert_difference \"Webhook.count\", -1 do\n      delete board_webhook_path(webhook.board, webhook)\n    end\n\n    assert_redirected_to board_webhooks_path(webhook.board)\n  end\n\n  test \"cannot access webhooks on board without access\" do\n    logout_and_sign_in_as :jason\n\n    webhook = webhooks(:inactive)  # on private board, jason has no access\n\n    get board_webhooks_path(webhook.board)\n    assert_response :not_found\n  end\n\n  test \"index as JSON\" do\n    board = boards(:writebook)\n\n    get board_webhooks_path(board), as: :json\n\n    assert_response :success\n    assert_kind_of Array, @response.parsed_body\n    assert_equal board.webhooks.count, @response.parsed_body.count\n    assert_equal webhooks(:active).id, @response.parsed_body.first[\"id\"]\n  end\n\n  test \"show as JSON\" do\n    webhook = webhooks(:active)\n\n    get board_webhook_path(webhook.board, webhook), as: :json\n\n    assert_response :success\n    assert_equal webhook.id, @response.parsed_body[\"id\"]\n    assert_equal webhook.name, @response.parsed_body[\"name\"]\n    assert_equal webhook.url, @response.parsed_body[\"payload_url\"]\n    assert_equal webhook.active?, @response.parsed_body[\"active\"]\n    assert_equal webhook.signing_secret, @response.parsed_body[\"signing_secret\"]\n    assert_equal webhook.subscribed_actions, @response.parsed_body[\"subscribed_actions\"]\n    assert_equal webhook.board.id, @response.parsed_body.dig(\"board\", \"id\")\n  end\n\n  test \"create as JSON\" do\n    board = boards(:writebook)\n\n    assert_difference \"Webhook.count\", 1 do\n      post board_webhooks_path(board), params: {\n        webhook: {\n          name: \"Test Webhook\",\n          url: \"https://example.com/webhook\",\n          subscribed_actions: [ \"\", \"card_published\", \"card_closed\" ]\n        }\n      }, as: :json\n    end\n\n    webhook = Webhook.last\n\n    assert_response :created\n    assert_equal board_webhook_url(board, webhook, format: :json), @response.headers[\"Location\"]\n    assert_equal webhook.id, @response.parsed_body[\"id\"]\n    assert_equal \"https://example.com/webhook\", @response.parsed_body[\"payload_url\"]\n    assert_equal webhook.signing_secret, @response.parsed_body[\"signing_secret\"]\n  end\n\n  test \"create with invalid params as JSON\" do\n    board = boards(:writebook)\n\n    assert_no_difference \"Webhook.count\" do\n      post board_webhooks_path(board), params: {\n        webhook: {\n          name: \"\",\n          url: \"invalid-url\"\n        }\n      }, as: :json\n    end\n\n    assert_response :unprocessable_entity\n    assert @response.parsed_body[\"name\"].present?\n    assert @response.parsed_body[\"url\"].present?\n  end\n\n  test \"update as JSON\" do\n    webhook = webhooks(:active)\n\n    patch board_webhook_path(webhook.board, webhook), params: {\n      webhook: {\n        name: \"Updated Webhook\",\n        subscribed_actions: [ \"card_published\" ]\n      }\n    }, as: :json\n\n    webhook.reload\n\n    assert_response :success\n    assert_equal \"Updated Webhook\", webhook.name\n    assert_equal [ \"card_published\" ], webhook.subscribed_actions\n    assert_equal \"Updated Webhook\", @response.parsed_body[\"name\"]\n    assert_equal [ \"card_published\" ], @response.parsed_body[\"subscribed_actions\"]\n  end\n\n  test \"update with invalid params as JSON\" do\n    webhook = webhooks(:active)\n\n    patch board_webhook_path(webhook.board, webhook), params: {\n      webhook: {\n        name: \"\"\n      }\n    }, as: :json\n\n    assert_response :unprocessable_entity\n    assert @response.parsed_body[\"name\"].present?\n  end\n\n  test \"update does not change url as JSON\" do\n    webhook = webhooks(:active)\n\n    assert_no_changes -> { webhook.reload.url } do\n      patch board_webhook_path(webhook.board, webhook), params: {\n        webhook: {\n          name: \"Updated Webhook\",\n          url: \"https://different.com/webhook\"\n        }\n      }, as: :json\n    end\n\n    assert_response :success\n    assert_equal webhook.reload.url, @response.parsed_body[\"payload_url\"]\n  end\n\n  test \"destroy as JSON\" do\n    webhook = webhooks(:active)\n\n    assert_difference \"Webhook.count\", -1 do\n      delete board_webhook_path(webhook.board, webhook), as: :json\n    end\n\n    assert_response :no_content\n  end\n\n  test \"non-admin cannot access webhook endpoints as JSON\" do\n    logout_and_sign_in_as :jz\n\n    get board_webhooks_path(boards(:writebook)), as: :json\n\n    assert_response :forbidden\n  end\n\n  test \"cannot access webhooks on board without access as JSON\" do\n    logout_and_sign_in_as :jason\n\n    get board_webhooks_path(boards(:private)), as: :json\n\n    assert_response :not_found\n  end\nend\n"
  },
  {
    "path": "test/fixtures/accesses.yml",
    "content": "writebook_david:\n  id: <%= ActiveRecord::FixtureSet.identify(\"writebook_david\", :uuid) %>\n  account: 37s_uuid\n  board: writebook_uuid\n  user: david_uuid\n\nwritebook_jz:\n  id: <%= ActiveRecord::FixtureSet.identify(\"writebook_jz\", :uuid) %>\n  account: 37s_uuid\n  board: writebook_uuid\n  user: jz_uuid\n\nwritebook_kevin:\n  id: <%= ActiveRecord::FixtureSet.identify(\"writebook_kevin\", :uuid) %>\n  account: 37s_uuid\n  board: writebook_uuid\n  user: kevin_uuid\n\nprivate_kevin:\n  id: <%= ActiveRecord::FixtureSet.identify(\"private_kevin\", :uuid) %>\n  account: 37s_uuid\n  board: private_uuid\n  user: kevin_uuid\n\nmiltons_wish_list_mike:\n  id: <%= ActiveRecord::FixtureSet.identify(\"miltons_wish_list_mike\", :uuid) %>\n  account: initech_uuid\n  board: miltons_wish_list_uuid\n  user: mike_uuid\n"
  },
  {
    "path": "test/fixtures/account/join_codes.yml",
    "content": "37s:\n  id: <%= ActiveRecord::FixtureSet.identify(\"37s_join_code\", :uuid) %>\n  code: 37S0-5678-9XYZ\n  usage_count: 0\n  usage_limit: 10\n  account: 37s_uuid\n\ninitech:\n  id: <%= ActiveRecord::FixtureSet.identify(\"initech_join_code\", :uuid) %>\n  code: INIT-5678-9XYZ\n  usage_count: 10\n  usage_limit: 10\n  account: initech_uuid\n"
  },
  {
    "path": "test/fixtures/accounts.yml",
    "content": "37s:\n  id: <%= ActiveRecord::FixtureSet.identify(\"37s\", :uuid) %>\n  name: 37signals\n  external_account_id: <%= ActiveRecord::FixtureSet.identify(\"37signals\") %>\n  cards_count: 5\n\ninitech:\n  id: <%= ActiveRecord::FixtureSet.identify(\"initech\", :uuid) %>\n  name: Initech LLC\n  external_account_id: <%= ActiveRecord::FixtureSet.identify(\"initech\") %>\n  cards_count: 0\n\nacme:\n  id: <%= ActiveRecord::FixtureSet.identify(\"acme\", :uuid) %>\n  name: ACME\n  external_account_id: <%= ActiveRecord::FixtureSet.identify(\"acme\") %>\n  cards_count: 0\n"
  },
  {
    "path": "test/fixtures/action_text/rich_texts.yml",
    "content": "logo_agreement_jz:\n  id: <%= ActiveRecord::FixtureSet.identify(\"logo_agreement_jz_rich_text\", :uuid) %>\n  account: 37s_uuid\n  record: logo_agreement_jz_uuid (Comment)\n  name: body\n  body: I agree.\n\nlogo_agreement_kevin:\n  id: <%= ActiveRecord::FixtureSet.identify(\"logo_agreement_kevin_rich_text\", :uuid) %>\n  account: 37s_uuid\n  record: logo_agreement_kevin_uuid (Comment)\n  name: body\n  body: Same, let's do it.\n\nlayout_overflowing_david:\n  id: <%= ActiveRecord::FixtureSet.identify(\"layout_overflowing_david_rich_text\", :uuid) %>\n  account: 37s_uuid\n  record: layout_overflowing_david_uuid (Comment)\n  name: body\n  body: The text is overflowing the container.\n"
  },
  {
    "path": "test/fixtures/assignees_filters.yml",
    "content": "jz_assignments_jz:\n  assignee_id: <%= ActiveRecord::FixtureSet.identify(\"jz\", :uuid) %>\n  filter_id: <%= ActiveRecord::FixtureSet.identify(\"jz_assignments\", :uuid) %>\n"
  },
  {
    "path": "test/fixtures/assignments.yml",
    "content": "logo_jz:\n  id: <%= ActiveRecord::FixtureSet.identify(\"logo_jz\", :uuid) %>\n  account: 37s_uuid\n  assigner: david_uuid\n  assignee: jz_uuid\n  card: logo_uuid\n  created_at: <%= 1.week.ago %>\n\nlogo_kevin:\n  id: <%= ActiveRecord::FixtureSet.identify(\"logo_kevin\", :uuid) %>\n  account: 37s_uuid\n  assigner: david_uuid\n  assignee: kevin_uuid\n  card: logo_uuid\n  created_at: <%= 1.day.ago %>\n\nlayout_jz:\n  id: <%= ActiveRecord::FixtureSet.identify(\"layout_jz\", :uuid) %>\n  account: 37s_uuid\n  assigner: david_uuid\n  assignee: jz_uuid\n  card: layout_uuid\n"
  },
  {
    "path": "test/fixtures/boards.yml",
    "content": "writebook:\n  id: <%= ActiveRecord::FixtureSet.identify(\"writebook\", :uuid) %>\n  name: Writebook\n  creator: david_uuid\n  all_access: true\n  account: 37s_uuid\n\nprivate:\n  id: <%= ActiveRecord::FixtureSet.identify(\"private\", :uuid) %>\n  name: Private board\n  creator: kevin_uuid\n  all_access: false\n  account: 37s_uuid\n\nmiltons_wish_list:\n  id: <%= ActiveRecord::FixtureSet.identify(\"miltons_wish_list\", :uuid) %>\n  name: Milton's Wish List\n  creator: mike_uuid\n  all_access: true\n  account: initech_uuid\n"
  },
  {
    "path": "test/fixtures/card/goldnesses.yml",
    "content": "logo:\n  id: <%= ActiveRecord::FixtureSet.identify(\"logo_goldness\", :uuid) %>\n  account: 37s_uuid\n  card: logo_uuid\n"
  },
  {
    "path": "test/fixtures/cards.yml",
    "content": "logo:\n  id: <%= ActiveRecord::FixtureSet.identify(\"logo\", :uuid) %>\n  number: 1\n  board: writebook_uuid\n  creator: david_uuid\n  column: writebook_triage_uuid\n  title: The logo isn't big enough\n  due_on: <%= 3.days.from_now %>\n  created_at: <%= 1.week.ago %>\n  status: published\n  last_active_at: <%= 1.week.ago %>\n  account: 37s_uuid\n\nlayout:\n  id: <%= ActiveRecord::FixtureSet.identify(\"layout\", :uuid) %>\n  number: 2\n  board: writebook_uuid\n  creator: david_uuid\n  column: writebook_triage_uuid\n  title: Layout is broken\n  created_at: <%= 1.week.ago %>\n  status: published\n  last_active_at: <%= 1.week.ago %>\n  account: 37s_uuid\n\ntext:\n  id: <%= ActiveRecord::FixtureSet.identify(\"text\", :uuid) %>\n  number: 3\n  board: writebook_uuid\n  creator: kevin_uuid\n  column: writebook_in_progress_uuid\n  title: The text is too small\n  created_at: <%= 1.week.ago %>\n  status: published\n  last_active_at: <%= 1.week.ago %>\n  account: 37s_uuid\n\nshipping:\n  id: <%= ActiveRecord::FixtureSet.identify(\"shipping\", :uuid) %>\n  number: 4\n  board: writebook_uuid\n  creator: kevin_uuid\n  column: writebook_triage_uuid\n  title: We need to ship the app\n  created_at: <%= 1.week.ago %>\n  status: published\n  last_active_at: <%= 1.week.ago %>\n  account: 37s_uuid\n\nbuy_domain:\n  id: <%= ActiveRecord::FixtureSet.identify(\"buy_domain\", :uuid) %>\n  number: 5\n  board: writebook_uuid\n  creator: david_uuid\n  title: Buy domain\n  created_at: <%= 1.week.ago %>\n  status: published\n  last_active_at: <%= 1.week.ago %>\n  account: 37s_uuid\n\nradio:\n  id: <%= ActiveRecord::FixtureSet.identify(\"radio\", :uuid) %>\n  number: 1\n  board: miltons_wish_list_uuid\n  creator: mike_uuid\n  title: I want to play my radio at a reasonable volume\n  created_at: <%= 1.week.ago %>\n  status: published\n  last_active_at: <%= 1.week.ago %>\n  account: initech_uuid\n\npaycheck:\n  id: <%= ActiveRecord::FixtureSet.identify(\"paycheck\", :uuid) %>\n  number: 2\n  board: miltons_wish_list_uuid\n  creator: mike_uuid\n  title: I haven't received my paycheck\n  created_at: <%= 1.week.ago %>\n  status: published\n  last_active_at: <%= 1.week.ago %>\n  account: initech_uuid\n\nunfinished_thoughts:\n  id: <%= ActiveRecord::FixtureSet.identify(\"unfinished_thoughts\", :uuid) %>\n  number: 3\n  board: miltons_wish_list_uuid\n  creator: mike_uuid\n  title: Some unfinished thoughts\n  created_at: <%= 1.week.ago %>\n  status: drafted\n  last_active_at: <%= 1.week.ago %>\n  account: initech_uuid\n"
  },
  {
    "path": "test/fixtures/closures.yml",
    "content": "shipping:\n  id: <%= ActiveRecord::FixtureSet.identify(\"shipping_closure\", :uuid) %>\n  account: 37s_uuid\n  card: shipping_uuid\n  user: kevin_uuid\n"
  },
  {
    "path": "test/fixtures/columns.yml",
    "content": "# Columns for writebook board (which has qa workflow)\nwritebook_triage:\n  id: <%= ActiveRecord::FixtureSet.identify(\"writebook_triage\", :uuid) %>\n  name: Triage\n  color: \"var(--color-card-4)\"\n  board: writebook_uuid\n  position: 0\n  account: 37s_uuid\n\nwritebook_in_progress:\n  id: <%= ActiveRecord::FixtureSet.identify(\"writebook_in_progress\", :uuid) %>\n  name: In progress\n  color: \"var(--color-card-2)\"\n  board: writebook_uuid\n  position: 1\n  account: 37s_uuid\n\nwritebook_on_hold:\n  id: <%= ActiveRecord::FixtureSet.identify(\"writebook_on_hold\", :uuid) %>\n  name: On Hold\n  color: \"var(--color-card-4)\"\n  board: writebook_uuid\n  position: 2\n  account: 37s_uuid\n\nwritebook_review:\n  id: <%= ActiveRecord::FixtureSet.identify(\"writebook_review\", :uuid) %>\n  name: Review\n  color: \"var(--color-card-3)\"\n  board: writebook_uuid\n  position: 3\n  account: 37s_uuid\n"
  },
  {
    "path": "test/fixtures/comments.yml",
    "content": "logo_1:\n  id: <%= ActiveRecord::FixtureSet.identify(\"logo_1\", :uuid) %>\n  card: logo_uuid\n  creator: system_uuid\n  created_at: <%= 1.week.ago %>\n  account: 37s_uuid\n\nlogo_agreement_jz:\n  id: <%= ActiveRecord::FixtureSet.identify(\"logo_agreement_jz\", :uuid) %>\n  card: logo_uuid\n  creator: jz_uuid\n  created_at: <%= 2.days.ago %>\n  account: 37s_uuid\n\nlogo_3:\n  id: <%= ActiveRecord::FixtureSet.identify(\"logo_3\", :uuid) %>\n  card: logo_uuid\n  creator: system_uuid\n  created_at: <%= 1.day.ago %>\n  account: 37s_uuid\n\nlogo_agreement_kevin:\n  id: <%= ActiveRecord::FixtureSet.identify(\"logo_agreement_kevin\", :uuid) %>\n  card: logo_uuid\n  creator: kevin_uuid\n  created_at: <%= 2.hours.ago %>\n  account: 37s_uuid\n\nlogo_5:\n  id: <%= ActiveRecord::FixtureSet.identify(\"logo_5\", :uuid) %>\n  card: logo_uuid\n  creator: system_uuid\n  created_at: <%= 1.hour.ago %>\n  account: 37s_uuid\n\nlayout_1:\n  id: <%= ActiveRecord::FixtureSet.identify(\"layout_1\", :uuid) %>\n  card: layout_uuid\n  creator: system_uuid\n  account: 37s_uuid\n\nlayout_overflowing_david:\n  id: <%= ActiveRecord::FixtureSet.identify(\"layout_overflowing_david\", :uuid) %>\n  card: layout_uuid\n  creator: david_uuid\n  account: 37s_uuid\n\ntext_1:\n  id: <%= ActiveRecord::FixtureSet.identify(\"text_1\", :uuid) %>\n  card: text_uuid\n  creator: system_uuid\n  account: 37s_uuid\n\nshipping_1:\n  id: <%= ActiveRecord::FixtureSet.identify(\"shipping_1\", :uuid) %>\n  card: shipping_uuid\n  creator: system_uuid\n  account: 37s_uuid\n"
  },
  {
    "path": "test/fixtures/entropies.yml",
    "content": "37s_account:\n  id: <%= ActiveRecord::FixtureSet.identify(\"37s_account\", :uuid) %>\n  account: 37s_uuid\n  container: 37s_uuid (Account)\n  auto_postpone_period: <%= 30.days.to_i %>\n\nwritebook_board:\n  id: <%= ActiveRecord::FixtureSet.identify(\"writebook_board\", :uuid) %>\n  account: 37s_uuid\n  container: writebook_uuid (Board)\n  auto_postpone_period: <%= 90.days.to_i %>\n\nprivate_board:\n  id: <%= ActiveRecord::FixtureSet.identify(\"private_board\", :uuid) %>\n  account: 37s_uuid\n  container: private_uuid (Board)\n  auto_postpone_period: <%= 30.days.to_i %>\n\ninitech_account:\n  id: <%= ActiveRecord::FixtureSet.identify(\"initech_account\", :uuid) %>\n  account: initech_uuid\n  container: initech_uuid (Account)\n  auto_postpone_period: <%= 30.days.to_i %>\n\nmiltons_wish_list_board:\n  id: <%= ActiveRecord::FixtureSet.identify(\"miltons_wish_list_board\", :uuid) %>\n  account: initech_uuid\n  container: miltons_wish_list_uuid (Board)\n  auto_postpone_period: <%= 90.days.to_i %>\n"
  },
  {
    "path": "test/fixtures/events.yml",
    "content": "logo_published:\n  id: <%= ActiveRecord::FixtureSet.identify(\"logo_published\", :uuid) %>\n  creator: david_uuid\n  board: writebook_uuid\n  eventable: logo_uuid (Card)\n  action: card_published\n  created_at: <%= 1.week.ago %>\n  account: 37s_uuid\n\nlogo_assignment_jz:\n  id: <%= ActiveRecord::FixtureSet.identify(\"logo_assignment_jz\", :uuid) %>\n  creator: david_uuid\n  board: writebook_uuid\n  eventable: logo_uuid (Card)\n  action: card_assigned\n  particulars: <%= { assignee_ids: [ ActiveRecord::FixtureSet.identify(\"jz\", :uuid) ] }.to_json %>\n  created_at: <%= 1.week.ago + 1.hour %>\n  account: 37s_uuid\n\nlogo_assignment_david:\n  id: <%= ActiveRecord::FixtureSet.identify(\"logo_assignment_david\", :uuid) %>\n  creator: david_uuid\n  board: writebook_uuid\n  eventable: logo_uuid (Card)\n  action: card_assigned\n  particulars: <%= { assignee_ids: [ ActiveRecord::FixtureSet.identify(\"david\", :uuid) ] }.to_json %>\n  created_at: <%= 1.week.ago + 1.hour %>\n  account: 37s_uuid\n\nlogo_assignment_km:\n  id: <%= ActiveRecord::FixtureSet.identify(\"logo_assignment_km\", :uuid) %>\n  creator: david_uuid\n  board: writebook_uuid\n  eventable: logo_uuid (Card)\n  action: card_assigned\n  particulars: <%= { assignee_ids: [ ActiveRecord::FixtureSet.identify(\"kevin\", :uuid) ] }.to_json %>\n  created_at: <%= 1.day.ago %>\n  account: 37s_uuid\n\nlayout_published:\n  id: <%= ActiveRecord::FixtureSet.identify(\"layout_published\", :uuid) %>\n  creator: david_uuid\n  board: writebook_uuid\n  eventable: layout_uuid (Card)\n  action: card_published\n  created_at: <%= 1.week.ago %>\n  account: 37s_uuid\n\nlayout_commented:\n  id: <%= ActiveRecord::FixtureSet.identify(\"layout_commented\", :uuid) %>\n  creator: david_uuid\n  board: writebook_uuid\n  eventable: layout_overflowing_david_uuid (Comment)\n  action: comment_created\n  created_at: <%= 1.week.ago %>\n  account: 37s_uuid\n\nlayout_assignment_jz:\n  id: <%= ActiveRecord::FixtureSet.identify(\"layout_assignment_jz\", :uuid) %>\n  creator: david_uuid\n  board: writebook_uuid\n  eventable: layout_uuid (Card)\n  action: card_assigned\n  particulars: <%= { assignee_ids: [ ActiveRecord::FixtureSet.identify(\"jz\", :uuid) ] }.to_json %>\n  created_at: <%= 1.hour.ago %>\n  account: 37s_uuid\n\ntext_published:\n  id: <%= ActiveRecord::FixtureSet.identify(\"text_published\", :uuid) %>\n  creator: kevin_uuid\n  board: writebook_uuid\n  eventable: text_uuid (Card)\n  action: card_published\n  created_at: <%= 1.week.ago %>\n  account: 37s_uuid\n\nshipping_published:\n  id: <%= ActiveRecord::FixtureSet.identify(\"shipping_published\", :uuid) %>\n  creator: kevin_uuid\n  board: writebook_uuid\n  eventable: shipping_uuid (Card)\n  action: card_published\n  created_at: <%= 1.week.ago %>\n  account: 37s_uuid\n\nshipping_closed:\n  id: <%= ActiveRecord::FixtureSet.identify(\"shipping_closed\", :uuid) %>\n  creator: kevin_uuid\n  board: writebook_uuid\n  eventable: shipping_uuid (Card)\n  action: card_closed\n  created_at: <%= 2.days.ago %>\n  account: 37s_uuid\n"
  },
  {
    "path": "test/fixtures/exports.yml",
    "content": "pending_account_export:\n  id: <%= ActiveRecord::FixtureSet.identify(\"pending_account_export\", :uuid) %>\n  account: 37s_uuid\n  user: david_uuid\n  type: Account::Export\n  status: pending\n\ncompleted_account_export:\n  id: <%= ActiveRecord::FixtureSet.identify(\"completed_account_export\", :uuid) %>\n  account: 37s_uuid\n  user: david_uuid\n  type: Account::Export\n  status: completed\n  completed_at: <%= 1.hour.ago.to_fs(:db) %>\n\npending_user_data_export:\n  id: <%= ActiveRecord::FixtureSet.identify(\"pending_user_data_export\", :uuid) %>\n  account: 37s_uuid\n  user: david_uuid\n  type: User::DataExport\n  status: pending\n\ncompleted_user_data_export:\n  id: <%= ActiveRecord::FixtureSet.identify(\"completed_user_data_export\", :uuid) %>\n  account: 37s_uuid\n  user: david_uuid\n  type: User::DataExport\n  status: completed\n  completed_at: <%= 1.hour.ago.to_fs(:db) %>\n"
  },
  {
    "path": "test/fixtures/filters.yml",
    "content": "jz_assignments:\n  id: <%= ActiveRecord::FixtureSet.identify(\"jz_assignments\", :uuid) %>\n  creator: david_uuid\n  fields: <%= { indexed_by: :all, sorted_by: :newest }.to_json %>\n  params_digest: <%= Filter.digest_params({ indexed_by: :all, sorted_by: :newest, tag_ids: [ ActiveRecord::FixtureSet.identify(\"mobile\", :uuid) ], assignee_ids: [ ActiveRecord::FixtureSet.identify(\"jz\", :uuid) ] }) %>\n  account: 37s_uuid\n"
  },
  {
    "path": "test/fixtures/filters_tags.yml",
    "content": "jz_assignments_mobile:\n  filter_id: <%= ActiveRecord::FixtureSet.identify(\"jz_assignments\", :uuid) %>\n  tag_id: <%= ActiveRecord::FixtureSet.identify(\"mobile\", :uuid) %>\n"
  },
  {
    "path": "test/fixtures/identities.yml",
    "content": "david:\n  email_address: david@37signals.com\n  staff: true\n\njz:\n  email_address: jz@37signals.com\n\njason:\n  email_address: jason@37signals.com\n  staff: true\n\nkevin:\n  email_address: kevin@37signals.com\n  staff: true\n\nmike:\n  email_address: mike@37signals.com\n"
  },
  {
    "path": "test/fixtures/identity/access_tokens.yml",
    "content": "jasons_api_token:\n  identity: jason\n  token: 018cf1425682700098f24f0799e3fe20\n  description: My Superscript\n  permission: read\n\ndavids_api_token:\n  identity: david\n  token: x18cf1425682700098f24f0799e3fe20\n  description: My Superscript\n  permission: write\n"
  },
  {
    "path": "test/fixtures/mentions.yml",
    "content": "logo_card_david_mention_by_jz:\n  id: <%= ActiveRecord::FixtureSet.identify(\"logo_card_david_mention_by_jz\", :uuid) %>\n  account: 37s_uuid\n  source: logo_uuid (Card)\n  mentioner: jz_uuid\n  mentionee: david_uuid\n\nlogo_comment_david_mention_by_jz:\n  id: <%= ActiveRecord::FixtureSet.identify(\"logo_comment_david_mention_by_jz\", :uuid) %>\n  account: 37s_uuid\n  source: logo_agreement_jz_uuid (Comment)\n  mentioner: jz_uuid\n  mentionee: david_uuid\n"
  },
  {
    "path": "test/fixtures/notifications.yml",
    "content": "logo_assignment_kevin:\n  id: <%= ActiveRecord::FixtureSet.identify(\"logo_assignment_kevin\", :uuid) %>\n  user: kevin_uuid\n  source: logo_assignment_km_uuid (Event)\n  card: logo_uuid\n  unread_count: 2\n  created_at: <%= 1.week.ago %>\n  updated_at: <%= 1.week.ago + 1.second %>\n  creator: david_uuid\n  account: 37s_uuid\n\nlayout_commented_kevin:\n  id: <%= ActiveRecord::FixtureSet.identify(\"layout_commented_kevin\", :uuid) %>\n  user: kevin_uuid\n  source: layout_commented_uuid (Event)\n  card: layout_uuid\n  unread_count: 1\n  created_at: <%= 1.week.ago %>\n  updated_at: <%= 1.week.ago + 2.seconds %>\n  creator: david_uuid\n  account: 37s_uuid\n\nlogo_mentioned_david:\n  id: <%= ActiveRecord::FixtureSet.identify(\"logo_mentioned_david\", :uuid) %>\n  user: david_uuid\n  source: logo_comment_david_mention_by_jz_uuid (Mention)\n  card: logo_uuid\n  unread_count: 2\n  created_at: <%= 1.week.ago %>\n  updated_at: <%= 1.week.ago + 3.seconds %>\n  creator: david_uuid\n  account: 37s_uuid\n"
  },
  {
    "path": "test/fixtures/pins.yml",
    "content": "logo_kevin:\n  id: <%= ActiveRecord::FixtureSet.identify(\"logo_kevin_pin\", :uuid) %>\n  account: 37s_uuid\n  card: logo_uuid\n  user: kevin_uuid\n\nshipping_kevin:\n  id: <%= ActiveRecord::FixtureSet.identify(\"shipping_kevin_pin\", :uuid) %>\n  account: 37s_uuid\n  card: shipping_uuid\n  user: kevin_uuid \n"
  },
  {
    "path": "test/fixtures/reactions.yml",
    "content": "kevin:\n  id: <%= ActiveRecord::FixtureSet.identify(\"kevin_reaction\", :uuid) %>\n  account: 37s_uuid\n  content: \"👍\"\n  reactable: logo_agreement_jz_uuid (Comment)\n  reacter: kevin_uuid\n\ndavid:\n  id: <%= ActiveRecord::FixtureSet.identify(\"david_reaction\", :uuid) %>\n  account: 37s_uuid\n  content: \"👍\"\n  reactable: logo_agreement_jz_uuid (Comment)\n  reacter: david_uuid\n\nlogo_card_kevin:\n  id: <%= ActiveRecord::FixtureSet.identify(\"logo_card_kevin_reaction\", :uuid) %>\n  account: 37s_uuid\n  content: \"🎯\"\n  reactable: logo_uuid (Card)\n  reacter: kevin_uuid\n\nlogo_card_david:\n  id: <%= ActiveRecord::FixtureSet.identify(\"logo_card_david_reaction\", :uuid) %>\n  account: 37s_uuid\n  content: \"👍\"\n  reactable: logo_uuid (Card)\n  reacter: david_uuid\n"
  },
  {
    "path": "test/fixtures/sessions.yml",
    "content": "david:\n  identity: david\n\nkevin:\n  identity: kevin\n\njz:\n  identity: jz\n\njason:\n  identity: jason\n\nmike:\n  identity: mike\n"
  },
  {
    "path": "test/fixtures/taggings.yml",
    "content": "logo_web:\n  id: <%= ActiveRecord::FixtureSet.identify(\"logo_web_tagging\", :uuid) %>\n  account: 37s_uuid\n  card: logo_uuid\n  tag: web_uuid\n\nlayout_web:\n  id: <%= ActiveRecord::FixtureSet.identify(\"layout_web_tagging\", :uuid) %>\n  account: 37s_uuid\n  card: layout_uuid\n  tag: web_uuid\n\nlayout_mobile:\n  id: <%= ActiveRecord::FixtureSet.identify(\"layout_mobile_tagging\", :uuid) %>\n  account: 37s_uuid\n  card: layout_uuid\n  tag: mobile_uuid\n\ntext_mobile:\n  id: <%= ActiveRecord::FixtureSet.identify(\"text_mobile_tagging\", :uuid) %>\n  account: 37s_uuid\n  card: text_uuid\n  tag: mobile_uuid\n"
  },
  {
    "path": "test/fixtures/tags.yml",
    "content": "web:\n  id: <%= ActiveRecord::FixtureSet.identify(\"web\", :uuid) %>\n  title: web\n  account: 37s_uuid\n\nmobile:\n  id: <%= ActiveRecord::FixtureSet.identify(\"mobile\", :uuid) %>\n  title: mobile\n  account: 37s_uuid\n"
  },
  {
    "path": "test/fixtures/user/settings.yml",
    "content": "_fixture:\n  model_class: User::Settings\n\ndavid_settings:\n  id: <%= ActiveRecord::FixtureSet.identify(\"david_settings\", :uuid) %>\n  account: 37s_uuid\n  user: david_uuid\n  bundle_email_frequency: never\n\njz_settings:\n  id: <%= ActiveRecord::FixtureSet.identify(\"jz_settings\", :uuid) %>\n  account: 37s_uuid\n  user: jz_uuid\n  bundle_email_frequency: never\n\nkevin_settings:\n  id: <%= ActiveRecord::FixtureSet.identify(\"kevin_settings\", :uuid) %>\n  account: 37s_uuid\n  user: kevin_uuid\n  bundle_email_frequency: never\n\nsystem_settings:\n  id: <%= ActiveRecord::FixtureSet.identify(\"system_settings\", :uuid) %>\n  account: 37s_uuid\n  user: system_uuid\n  bundle_email_frequency: never\n"
  },
  {
    "path": "test/fixtures/users.yml",
    "content": "david:\n  id: <%= ActiveRecord::FixtureSet.identify(\"david\", :uuid) %>\n  name: David\n  role: member\n  identity: david\n  account: 37s_uuid\n  verified_at: <%= Time.current.to_fs(:db) %>\n\njz:\n  id: <%= ActiveRecord::FixtureSet.identify(\"jz\", :uuid) %>\n  name: JZ\n  role: member\n  identity: jz\n  account: 37s_uuid\n  verified_at: <%= Time.current.to_fs(:db) %>\n\njason:\n  id: <%= ActiveRecord::FixtureSet.identify(\"jason\", :uuid) %>\n  name: Jason\n  role: owner\n  identity: jason\n  account: 37s_uuid\n  verified_at: <%= Time.current.to_fs(:db) %>\n\nkevin:\n  id: <%= ActiveRecord::FixtureSet.identify(\"kevin\", :uuid) %>\n  name: Kevin\n  role: admin\n  identity: kevin\n  account: 37s_uuid\n  verified_at: <%= Time.current.to_fs(:db) %>\n\nsystem:\n  id: <%= ActiveRecord::FixtureSet.identify(\"system\", :uuid) %>\n  name: System\n  role: system\n  account: 37s_uuid\n\nmike:\n  id: <%= ActiveRecord::FixtureSet.identify(\"mike\", :uuid) %>\n  name: Mike\n  role: admin\n  identity: mike\n  account: initech_uuid\n  verified_at: <%= Time.current.to_fs(:db) %>\n\nsystem_initech:\n  id: <%= ActiveRecord::FixtureSet.identify(\"system_initech\", :uuid) %>\n  name: System\n  role: system\n  account: initech_uuid\n"
  },
  {
    "path": "test/fixtures/watches.yml",
    "content": "logo_david:\n  id: <%= ActiveRecord::FixtureSet.identify(\"logo_david_watch\", :uuid) %>\n  account: 37s_uuid\n  card: logo_uuid\n  user: david_uuid\n  watching: true\n\nlogo_kevin:\n  id: <%= ActiveRecord::FixtureSet.identify(\"logo_kevin_watch\", :uuid) %>\n  account: 37s_uuid\n  card: logo_uuid\n  user: kevin_uuid\n  watching: true\n\nlayout_david:\n  id: <%= ActiveRecord::FixtureSet.identify(\"layout_david_watch\", :uuid) %>\n  account: 37s_uuid\n  card: layout_uuid\n  user: david_uuid\n  watching: true\n\nlayout_kevin:\n  id: <%= ActiveRecord::FixtureSet.identify(\"layout_kevin_watch\", :uuid) %>\n  account: 37s_uuid\n  card: layout_uuid\n  user: kevin_uuid\n  watching: true\n\ntext_david:\n  id: <%= ActiveRecord::FixtureSet.identify(\"text_david_watch\", :uuid) %>\n  account: 37s_uuid\n  card: text_uuid\n  user: david_uuid\n  watching: true\n\ntext_jz:\n  id: <%= ActiveRecord::FixtureSet.identify(\"text_jz_watch\", :uuid) %>\n  account: 37s_uuid\n  card: text_uuid\n  user: jz_uuid\n  watching: true\n\nshipping_david:\n  id: <%= ActiveRecord::FixtureSet.identify(\"shipping_david_watch\", :uuid) %>\n  account: 37s_uuid\n  card: shipping_uuid\n  user: david_uuid\n  watching: true\n\nshipping_jz:\n  id: <%= ActiveRecord::FixtureSet.identify(\"shipping_jz_watch\", :uuid) %>\n  account: 37s_uuid\n  card: shipping_uuid\n  user: jz_uuid\n  watching: true\n\nshipping_kevin:\n  id: <%= ActiveRecord::FixtureSet.identify(\"shipping_kevin_watch\", :uuid) %>\n  account: 37s_uuid\n  card: shipping_uuid\n  user: kevin_uuid\n  watching: true\n"
  },
  {
    "path": "test/fixtures/webhook/delinquency_trackers.yml",
    "content": "# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html\n\nactive_webhook_tracker:\n  id: <%= ActiveRecord::FixtureSet.identify(\"active_webhook_tracker\", :uuid) %>\n  account: 37s_uuid\n  webhook: active_uuid\n  consecutive_failures_count: 1\n  first_failure_at: <%= 1.hour.ago %>\n\ninactive_webhook_tracker:\n  id: <%= ActiveRecord::FixtureSet.identify(\"inactive_webhook_tracker\", :uuid) %>\n  account: 37s_uuid\n  webhook: inactive_uuid\n  consecutive_failures_count: 1\n  first_failure_at: <%= 1.hour.ago %>\n"
  },
  {
    "path": "test/fixtures/webhook/deliveries.yml",
    "content": "# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html\n\nsuccessfully_completed:\n  id: <%= ActiveRecord::FixtureSet.identify(\"successfully_completed_delivery\", :uuid) %>\n  account: 37s_uuid\n  webhook: active_uuid\n  event: logo_published_uuid\n  state: completed\n  request: '<%= { headers: {} }.to_json %>'\n  response: '<%= { code: 200, headers: {} }.to_json %>'\n  created_at: <%= 1.week.ago %>\n\nunsuccessfully_completed:\n  id: <%= ActiveRecord::FixtureSet.identify(\"unsuccessfully_completed_delivery\", :uuid) %>\n  account: 37s_uuid\n  webhook: active_uuid\n  event: logo_assignment_jz_uuid\n  state: completed\n  request: '<%= { headers: {} }.to_json %>'\n  response: '<%= { code: 422, headers: {} }.to_json %>'\n  created_at: <%= 1.week.ago + 1.hour %>\n\nerrored:\n  id: <%= ActiveRecord::FixtureSet.identify(\"errored_delivery\", :uuid) %>\n  account: 37s_uuid\n  webhook: active_uuid\n  event: layout_published_uuid\n  state: errored\n  request: '<%= { headers: {} }.to_json %>'\n  response: '<%= { error: \"destination_unreachable\" }.to_json %>'\n  created_at: <%= 1.week.ago %>\n\npending:\n  id: <%= ActiveRecord::FixtureSet.identify(\"pending_delivery\", :uuid) %>\n  account: 37s_uuid\n  webhook: active_uuid\n  event: shipping_closed_uuid\n  state: pending\n  request: null\n  response: null\n  created_at: <%= 2.day.ago %>\n\nin_progress:\n  id: <%= ActiveRecord::FixtureSet.identify(\"in_progress_delivery\", :uuid) %>\n  account: 37s_uuid\n  webhook: active_uuid\n  event: logo_assignment_km_uuid\n  state: in_progress\n  request: null\n  response: null\n  created_at: <%= 1.day.ago %>\n"
  },
  {
    "path": "test/fixtures/webhooks.yml",
    "content": "active:\n  id: <%= ActiveRecord::FixtureSet.identify(\"active\", :uuid) %>\n  active: true\n  name: Production API\n  url: https://api.example.com/webhooks\n  signing_secret: p94Bx2HjempCdYB4DTyZkY1b # gitleaks:allow randomly generated\n  subscribed_actions: '<%= %w[ card_published card_assigned card_closed ].to_json %>'\n  board: writebook_uuid\n  account: 37s_uuid\n\ninactive:\n  id: <%= ActiveRecord::FixtureSet.identify(\"inactive\", :uuid) %>\n  active: false\n  name: Test Webhook\n  url: https://test.example.com/webhooks\n  signing_secret: H8ms8ADcV92v2x17hnLEiL5m # gitleaks:allow randomly generated\n  subscribed_actions: '<%= %w[ card_published card_assigned card_closed ].to_json %>'\n  board: private_uuid\n  account: 37s_uuid\n"
  },
  {
    "path": "test/helpers/.keep",
    "content": ""
  },
  {
    "path": "test/helpers/action_text_rendering_test.rb",
    "content": "require \"test_helper\"\n\nclass ActionTextRenderingTest < ActionView::TestCase\n  test \"data-action attributes in user content are stripped\" do\n    malicious_html = <<~HTML\n      <p>Click here: <a href=\"#\" data-action=\"dangerous#action\">malicious link</a></p>\n    HTML\n\n    content = ActionText::Content.new(malicious_html)\n    rendered = content.to_s\n\n    assert_no_match(/data-action/, rendered)\n    assert_match(/<a href=\"#\">malicious link<\\/a>/, rendered)\n  end\nend\n"
  },
  {
    "path": "test/helpers/application_helper_test.rb",
    "content": "require \"test_helper\"\n\nclass ApplicationHelperTest < ActionView::TestCase\n  def parse(html)\n    Nokogiri::HTML::DocumentFragment.parse(html)\n  end\n\n  test \"page_title_tag on untenanted page\" do\n    Current.account = nil\n\n    assert_select parse(page_title_tag), \"title\", text: \"Fizzy\"\n  end\n\n  test \"page_title_tag on untenanted page with a page title\" do\n    @page_title = \"Holodeck\"\n    Current.account = nil\n\n    assert_select parse(page_title_tag), \"title\", text: \"Holodeck | Fizzy\"\n  end\n\n  test \"page_title_tag on tenanted page when user has a single account\" do\n    Current.session = sessions(:david)\n\n    assert_select parse(page_title_tag), \"title\", text: \"Fizzy\"\n  end\n\n  test \"page_title_tag on tenanted page when user has multiple accounts\" do\n    Current.session = sessions(:david)\n    other_account = Account.create!(external_account_id: \"dangling-tenant\", name: \"Other Account\")\n    identities(:david).users.create!(account: other_account, name: \"David\")\n\n    assert_select parse(page_title_tag), \"title\", text: \"37signals | Fizzy\"\n  end\n\n  test \"page_title_tag on tenanted page with a page title when user has a single account\" do\n    Current.session = sessions(:david)\n    @page_title = \"Holodeck\"\n\n    assert_select parse(page_title_tag), \"title\", text: \"Holodeck | Fizzy\"\n  end\n\n  test \"page_title_tag on tenanted page with a page title when user has multiple account\" do\n    Current.session = sessions(:david)\n    other_account = Account.create!(external_account_id: \"dangling-tenant\", name: \"Other Account\")\n    identities(:david).users.create!(account: other_account, name: \"David\")\n    @page_title = \"Holodeck\"\n\n    assert_select parse(page_title_tag), \"title\", text: \"Holodeck | 37signals | Fizzy\"\n  end\nend\n"
  },
  {
    "path": "test/helpers/entropy_helper_test.rb",
    "content": "require \"test_helper\"\n\nclass EntropyHelperTest < ActionView::TestCase\n  test \"stalled_bubble_options_for returns nil when card has no activity spike\" do\n    assert_nil stalled_bubble_options_for(cards(:logo))\n  end\n\n  test \"stalled_bubble_options_for returns options when card has activity spike\" do\n    card = cards(:logo)\n    card.create_activity_spike!\n\n    options = stalled_bubble_options_for(card)\n    assert_not_nil options\n    assert_equal card.last_activity_spike_at.iso8601, options[:lastActivitySpikeAt]\n  end\n\n  test \"stalled_bubble_options_for includes updatedAt for client-side staleness check\" do\n    card = cards(:logo)\n    card.create_activity_spike!\n\n    travel_to 3.months.from_now\n\n    # Touch the card to simulate step completion\n    card.touch\n\n    options = stalled_bubble_options_for(card)\n    # The helper must include updatedAt so JS can check if card was recently updated\n    assert_equal card.updated_at.iso8601, options[:updatedAt]\n  end\nend\n"
  },
  {
    "path": "test/helpers/excerpt_helper_test.rb",
    "content": "require \"test_helper\"\n\nclass ExcerptHelperTest < ActionView::TestCase\n  test \"quote\" do\n    assert_excerpt(\"> Hello world\", \">    Hello world\")\n  end\n\n  test \"ul\" do\n    assert_excerpt(\"• Hello world\", \"-    Hello world\")\n    assert_excerpt(\"• Hello world\", \"  -    Hello world\")\n  end\n\n  test \"ol\" do\n    assert_excerpt(\"99. Hello world\", \"99. Hello world\")\n  end\n\n  test \"large spaces\" do\n    assert_excerpt(\"Hello world\", \"   Hello    world     \")\n  end\n\n  test \"long text\" do\n    assert_excerpt(\"A\"*197 + \"...\", \"A\"*1000)\n    assert_excerpt(\"A\"*97 + \"...\", \"A\"*1000, length: 100)\n  end\n\n  private\n    def assert_excerpt(expected, content, ...)\n      assert_equal expected, format_excerpt(ActionText::Content.new(content), ...), \"Excerpt of Action Text Content does not match\"\n      assert_equal expected, format_excerpt(content, ...), \"Excerpt of String does not match\"\n    end\nend\n"
  },
  {
    "path": "test/helpers/hotkeys_helper_test.rb",
    "content": "require \"test_helper\"\n\nclass HotkeysHelperTest < ActionView::TestCase\n  include SetPlatform\n\n  test \"mac modifier key\" do\n    emulate_mac\n\n    assert_equal \"⌘J\", hotkey_label([ \"⌘\", \"J\" ])\n  end\n\n  test \"linux modifier key\" do\n    emulate_linux\n\n    assert_equal \"Ctrl+J\", hotkey_label([ \"ctrl\", \"J\" ])\n  end\n\n  test \"mac enter\" do\n    emulate_mac\n\n    assert_equal \"Return+J\", hotkey_label([ \"enter\", \"J\" ])\n  end\n\n  test \"linux enter\" do\n    emulate_linux\n\n    assert_equal \"Enter+J\", hotkey_label([ \"enter\", \"J\" ])\n  end\n\n  test \"mac hyper\" do\n    emulate_mac\n\n    assert_equal \"Hyper+J\", hotkey_label([ \"hyper\", \"J\" ])\n  end\n\n  test \"linux hyper\" do\n    emulate_linux\n\n    assert_equal \"Hyper+J\", hotkey_label([ \"hyper\", \"J\" ])\n  end\n\n  private\n    def emulate_mac\n      stub_platform = ApplicationPlatform.new(\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36\")\n      self.stubs(:platform).returns(stub_platform)\n    end\n\n    def emulate_linux\n      stub_platform = ApplicationPlatform.new(\"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36\")\n      self.stubs(:platform).returns(stub_platform)\n    end\nend\n"
  },
  {
    "path": "test/helpers/html_helper_test.rb",
    "content": "require \"test_helper\"\n\nclass HtmlHelperTest < ActionView::TestCase\n  test \"convert URLs into anchor tags\" do\n    assert_equal_html \\\n      %(<p>Check this: <a href=\"https://example.com\" rel=\"noopener noreferrer\">https://example.com</a></p>),\n      format_html(\"<p>Check this: https://example.com</p>\")\n    assert_equal_html \\\n      %(<p>Check this: <a href=\"https://example.com/\" rel=\"noopener noreferrer\">https://example.com/</a></p>),\n      format_html(\"<p>Check this: https://example.com/</p>\")\n  end\n\n  test \"convert multiple URLs in the same string\" do\n    assert_equal_html \\\n      %(Visit <a href=\"https://foo.com/\" rel=\"noopener noreferrer\">https://foo.com/</a>. Also see <a href=\"https://bar.com/\" rel=\"noopener noreferrer\">https://bar.com/</a>!),\n      format_html(\"Visit https://foo.com/. Also see https://bar.com/!\")\n  end\n\n  test \"don't include punctuation in URL autolinking\" do\n    assert_equal_html \\\n      %(<p>Check this: <a href=\"https://example.com/\" rel=\"noopener noreferrer\">https://example.com/</a>!</p>),\n      format_html(\"<p>Check this: https://example.com/!</p>\")\n    assert_equal_html \\\n      %(<p>Check this: <a href=\"https://example.com/\" rel=\"noopener noreferrer\">https://example.com/</a>.</p>),\n      format_html(\"<p>Check this: https://example.com/.</p>\")\n    assert_equal_html \\\n      %(<p>Check this: <a href=\"https://example.com/\" rel=\"noopener noreferrer\">https://example.com/</a>?</p>),\n      format_html(\"<p>Check this: https://example.com/?</p>\")\n    assert_equal_html \\\n      %(<p>Check this: <a href=\"https://example.com/\" rel=\"noopener noreferrer\">https://example.com/</a>,</p>),\n      format_html(\"<p>Check this: https://example.com/,</p>\")\n    assert_equal_html \\\n      %(<p>Check this: <a href=\"https://example.com/\" rel=\"noopener noreferrer\">https://example.com/</a>:</p>),\n      format_html(\"<p>Check this: https://example.com/:</p>\")\n    assert_equal_html \\\n      %(<p>Check this: <a href=\"https://example.com/\" rel=\"noopener noreferrer\">https://example.com/</a>;</p>),\n      format_html(\"<p>Check this: https://example.com/;</p>\")\n    assert_equal_html \\\n      %(<p>Check this: <a href=\"https://example.com/\" rel=\"noopener noreferrer\">https://example.com/</a>\"</p>),\n      format_html(\"<p>Check this: https://example.com/\\\"</p>\")\n    assert_equal_html \\\n      %(<p>Check this: <a href=\"https://example.com/\" rel=\"noopener noreferrer\">https://example.com/</a>'</p>),\n      format_html(\"<p>Check this: https://example.com/'</p>\")\n\n    # trailing entities that decode to punctuation\n    # use assert_equal and not assert_equal_html to make sure we're getting entities correct\n    assert_equal \\\n      %(<p>Check this: <a href=\"https://example.com/\" rel=\"noopener noreferrer\">https://example.com/</a>&lt;</p>),\n      format_html(\"<p>Check this: https://example.com/&lt;</p>\")\n    assert_equal \\\n      %(<p>Check this: <a href=\"https://example.com/\" rel=\"noopener noreferrer\">https://example.com/</a>&gt;</p>),\n      format_html(\"<p>Check this: https://example.com/&gt;</p>\")\n    assert_equal \\\n      %(<p>Check this: <a href=\"https://example.com/\" rel=\"noopener noreferrer\">https://example.com/</a>\"</p>),\n      format_html(\"<p>Check this: https://example.com/&quot;</p>\")\n\n    # multiple punctuation characters including entities\n    assert_equal_html \\\n      %(<p>Check this: <a href=\"https://example.com/\" rel=\"noopener noreferrer\">https://example.com/</a>!?;</p>),\n      format_html(\"<p>Check this: https://example.com/!?;</p>\")\n    assert_equal_html \\\n      %(&lt;img src=\"<a href=\"https://example.com/\" rel=\"noopener noreferrer\">https://example.com/</a>\"&gt;),\n      format_html(%(&lt;img src=&quot;https://example.com/&quot;&gt;))\n    assert_equal_html \\\n      %(&lt;img src=\"<a href=\"https://example.com/\" rel=\"noopener noreferrer\">https://example.com/</a>\"!&gt;),\n      format_html(%(&lt;img src=&quot;https://example.com/&quot;!&gt;))\n  end\n\n  test \"make sure the linked content is properly sanitized\" do\n    # https://hackerone.com/reports/3481093\n    result = format_html(%(https://google.com/\\\"&gt;test&lt;/a&gt;&lt;input&gt;&lt;/input&gt;))\n    assert_no_match(/<input>/i, result, \"should not create an input element\")\n\n    result = format_html(%(https://google.com/\\\"&gt;&lt;script&gt;alert('xss')&lt;/script&gt;))\n    assert_no_match(/<script>/i, result, \"should not create a script element\")\n  end\n\n  test \"handle URLs with query parameters\" do\n    # use assert_equal and not assert_equal_html to make sure we're getting entities correct\n    assert_equal \\\n      %(<p>Check this: <a href=\"https://example.com/a?b=c&amp;d=e\" rel=\"noopener noreferrer\">https://example.com/a?b=c&amp;d=e</a></p>),\n      format_html(\"<p>Check this: https://example.com/a?b=c&amp;d=e</p>\")\n\n    assert_equal \\\n      %(<p>Check this: <a href=\"https://example.com/a?b=c&amp;d=e\" rel=\"noopener noreferrer\">https://example.com/a?b=c&amp;d=e</a></p>),\n      format_html(\"<p>Check this: https://example.com/a?b=c&d=e</p>\")\n  end\n\n  test \"respect existing links\" do\n    assert_equal_html \\\n      %(<p>Check this: <a href=\"https://example.com\">https://example.com</a></p>),\n      format_html(\"<p>Check this: <a href=\\\"https://example.com\\\">https://example.com</a></p>\")\n  end\n\n  test \"convert email addresses into mailto links\" do\n    assert_equal_html \\\n      %(<p>Contact us at <a href=\"mailto:support@example.com\" rel=\"noopener noreferrer\">support@example.com</a></p>),\n      format_html(\"<p>Contact us at support@example.com</p>\")\n  end\n\n  test \"respect existing linked emails\" do\n    assert_equal_html \\\n      %(<p>Contact us at <a href=\"mailto:support@example.com\">support@example.com</a></p>),\n      format_html(%(<p>Contact us at <a href=\"mailto:support@example.com\">support@example.com</a></p>))\n  end\n\n  test \"gracefully handle regexp timeout by skipping auto-linking\" do\n    input = \"<p>Check this: https://example.com</p>\"\n\n    String.class_eval do\n      alias_method :original_scan, :scan\n      define_method(:scan) do |*args, &block|\n        if args.first == AutoLinkScrubber::AUTOLINK_REGEXP\n          raise Regexp::TimeoutError\n        end\n        original_scan(*args, &block)\n      end\n    end\n\n    assert_equal_html %(<p>Check this: https://example.com</p>), format_html(input)\n  ensure\n    String.class_eval do\n      alias_method :scan, :original_scan\n      remove_method :original_scan\n    end\n  end\n\n  test \"skip auto-linking in very large text nodes\" do\n    url = \"https://example.com\"\n    large_text = \"x\" * 5_000 + \" #{url} \" + \"y\" * 5_000\n    input = \"<p>#{large_text}</p>\"\n\n    result = format_html(input)\n\n    assert_no_match(/<a/, result)\n    assert_includes result, url\n  end\n\n  test \"don't autolink content in excluded elements\" do\n    %w[ figcaption pre code ].each do |element|\n      assert_equal_html \\\n        \"<#{element}>Check this: https://example.com</#{element}>\",\n        format_html(\"<#{element}>Check this: https://example.com</#{element}>\")\n    end\n  end\n\n  test \"preserve escaped HTML containing URLs\" do\n    input = 'before text &lt;img src=\"https://example.com/image.png\"&gt; after text'\n    output = format_html(input)\n\n    assert_no_match(/<img/, output, \"should not create an img element\")\n    assert_includes output, \"&lt;img\"\n  end\n\n  test \"card_html_title renders backticks as code elements\" do\n    assert_equal \"Fix the <code>bug</code> in production\", card_html_title(cards(:logo).tap { _1.title = \"Fix the `bug` in production\" })\n  end\n\n  test \"card_html_title renders multiple code spans\" do\n    assert_equal \"<code>foo</code> and <code>bar</code>\", card_html_title(cards(:logo).tap { _1.title = \"`foo` and `bar`\" })\n  end\n\n  test \"card_html_title renders code spans without surrounding spaces\" do\n    assert_equal \"what<code>about</code>this\", card_html_title(cards(:logo).tap { _1.title = \"what`about`this\" })\n  end\n\n  test \"card_html_title escapes HTML tags\" do\n    assert_equal \"&lt;script&gt;alert(1)&lt;/script&gt;\", card_html_title(cards(:logo).tap { _1.title = \"<script>alert(1)</script>\" })\n  end\n\n  test \"card_html_title escapes HTML inside backticks\" do\n    assert_equal \"<code>&lt;script&gt;</code>\", card_html_title(cards(:logo).tap { _1.title = \"`<script>`\" })\n  end\n\n  test \"card_html_title returns blank title as-is\" do\n    assert_nil card_html_title(cards(:logo).tap { _1.title = nil })\n    assert_equal \"\", card_html_title(cards(:logo).tap { _1.title = \"\" })\n  end\nend\n"
  },
  {
    "path": "test/integration/active_storage_authorization_test.rb",
    "content": "require \"test_helper\"\n\nclass ActiveStorageAuthorizationTest < ActionDispatch::IntegrationTest\n  setup do\n    Current.session = sessions(:david)\n    @account = accounts(\"37s\")\n    @board = boards(:writebook)\n    @card = cards(:logo)\n    @blob = attach_blob_to_card(@card)\n  end\n\n  test \"authenticated user with board access can view blob\" do\n    sign_in_as :david\n\n    get rails_blob_path(@blob, disposition: :inline)\n    assert_response :redirect\n    assert_match %r{rails/active_storage}, response.location\n  end\n\n  test \"authenticated user without board access cannot view blob\" do\n    sign_in_as :mike\n\n    get rails_blob_path(@blob, disposition: :inline)\n    assert_response :forbidden\n  end\n\n  test \"unauthenticated user cannot view blob\" do\n    get rails_blob_path(@blob, disposition: :inline)\n    assert_response :redirect\n    assert_match %r{/session/new}, response.location\n  end\n\n  test \"authenticated user with board access can view representation\" do\n    sign_in_as :david\n\n    get rails_representation_path(@blob.representation(resize_to_limit: [ 100, 100 ]))\n    assert_response :redirect\n    assert_match %r{rails/active_storage/}, response.location\n  end\n\n  test \"authenticated user without board access cannot view representation\" do\n    sign_in_as :mike\n\n    get rails_representation_path(@blob.representation(resize_to_limit: [ 100, 100 ]))\n    assert_response :forbidden\n  end\n\n  test \"unauthenticated user can view blob on published board with published card\" do\n    @board.publish\n\n    get rails_blob_path(@blob, disposition: :inline)\n    assert_response :redirect\n    assert_match %r{rails/active_storage}, response.location\n  end\n\n  test \"unauthenticated user cannot view blob on published board with draft card\" do\n    @board.publish\n\n    # Create the draft card and attachment with proper Current context\n    draft_blob = nil\n    Current.with(account: @account, session: sessions(:david)) do\n      draft_card = @board.cards.create!(title: \"Draft\", status: :drafted, creator: users(:david))\n      draft_card.image.attach io: file_fixture(\"moon.jpg\").open, filename: \"draft.jpg\", content_type: \"image/jpeg\"\n      draft_blob = draft_card.image.blob\n    end\n\n    get rails_blob_path(draft_blob, disposition: :inline)\n    assert_response :redirect\n    assert_match %r{/session/new}, response.location\n  end\n\n  # Rich text embeds in cards\n\n  test \"authenticated user with board access can view rich text embed in card\" do\n    sign_in_as :david\n\n    blob = attach_blob_as_rich_text_embed(@card)\n\n    get rails_blob_path(blob, disposition: :inline)\n    assert_response :redirect\n    assert_match %r{rails/active_storage}, response.location\n  end\n\n  test \"authenticated user without board access cannot view rich text embed in card\" do\n    sign_in_as :mike\n\n    blob = attach_blob_as_rich_text_embed(@card)\n\n    get rails_blob_path(blob, disposition: :inline)\n    assert_response :forbidden\n  end\n\n  test \"unauthenticated user can view rich text embed in card on published board\" do\n    @board.publish\n\n    blob = attach_blob_as_rich_text_embed(@card)\n\n    get rails_blob_path(blob, disposition: :inline)\n    assert_response :redirect\n    assert_match %r{rails/active_storage}, response.location\n  end\n\n  # Rich text embeds in comments\n\n  test \"authenticated user with board access can view rich text embed in comment\" do\n    sign_in_as :david\n\n    comment = comments(:logo_1)\n    blob = attach_blob_as_rich_text_embed(comment)\n\n    get rails_blob_path(blob, disposition: :inline)\n    assert_response :redirect\n    assert_match %r{rails/active_storage}, response.location\n  end\n\n  test \"authenticated user without board access cannot view rich text embed in comment\" do\n    sign_in_as :mike\n\n    comment = comments(:logo_1)\n    blob = attach_blob_as_rich_text_embed(comment)\n\n    get rails_blob_path(blob, disposition: :inline)\n    assert_response :forbidden\n  end\n\n  test \"unauthenticated user can view rich text embed in comment on published board\" do\n    @board.publish\n\n    comment = comments(:logo_1)\n    blob = attach_blob_as_rich_text_embed(comment)\n\n    get rails_blob_path(blob, disposition: :inline)\n    assert_response :redirect\n    assert_match %r{rails/active_storage}, response.location\n  end\n\n  test \"unauthenticated user can view avatar\" do\n    blob = attach_avatar_to(users(:david))\n\n    get rails_blob_path(blob, disposition: :inline)\n    assert_response :redirect\n    assert_match %r{rails/active_storage}, response.location\n  end\n\n  test \"unauthenticated user can view avatar thumbnail\" do\n    blob = attach_avatar_to(users(:david))\n\n    get rails_representation_path(blob.representation(resize_to_fill: [ 256, 256 ]))\n    assert_response :redirect\n    assert_match %r{rails/active_storage}, response.location\n  end\n\n  # Account exports\n\n  test \"export owner can download their export\" do\n    sign_in_as :david\n\n    blob = create_export_blob_for(users(:david))\n\n    get rails_blob_path(blob, disposition: :attachment)\n    assert_response :redirect\n    assert_match %r{rails/active_storage}, response.location\n  end\n\n  test \"non-owner cannot download another user's export\" do\n    sign_in_as :jz\n\n    blob = create_export_blob_for(users(:david))\n\n    get rails_blob_path(blob, disposition: :attachment)\n    assert_response :forbidden\n  end\n\n  test \"unauthenticated user cannot download export\" do\n    blob = create_export_blob_for(users(:david))\n\n    get rails_blob_path(blob, disposition: :attachment)\n    assert_response :redirect\n    assert_match %r{/session/new}, response.location\n  end\n\n  private\n    def attach_blob_to_card(card)\n      Current.with(session: sessions(:david)) do\n        card.image.attach io: file_fixture(\"moon.jpg\").open, filename: \"test.jpg\", content_type: \"image/jpeg\"\n        card.image.blob\n      end\n    end\n\n    def attach_blob_as_rich_text_embed(container)\n      Current.with(account: @account, session: sessions(:david)) do\n        blob = ActiveStorage::Blob.create_and_upload! \\\n          io: file_fixture(\"moon.jpg\").open,\n          filename: \"embed.jpg\",\n          content_type: \"image/jpeg\"\n\n        attachment_html = ActionText::Attachment.from_attachable(blob).to_html\n        if container.respond_to?(:description)\n          container.update!(description: \"<p>Description with image: #{attachment_html}</p>\")\n        else\n          container.update!(body: \"<p>Body with image: #{attachment_html}</p>\")\n        end\n\n        blob.reload\n      end\n    end\n\n    def create_export_blob_for(user)\n      export = Account::Export.create!(account: @account, user: user)\n      export.file.attach io: StringIO.new(\"test export content\"), filename: \"export.zip\", content_type: \"application/zip\"\n      export.file.blob\n    end\n\n    def attach_avatar_to(user)\n      Current.with(account: @account, session: sessions(:david)) do\n        user.avatar.attach io: file_fixture(\"moon.jpg\").open, filename: \"avatar.jpg\", content_type: \"image/jpeg\"\n        user.avatar.blob\n      end\n    end\nend\n"
  },
  {
    "path": "test/integration/blob_key_traversal_test.rb",
    "content": "require \"test_helper\"\n\nclass Account::DataTransfer::ActiveStorage::BlobKeyTraversalTest < ActionDispatch::IntegrationTest\n  test \"import with path traversal blob key does not leak local files\" do\n    blob_id = ActiveRecord::Type::Uuid.generate\n    traversal_key = \"../../config/deploy.yml\"\n    zip = build_zip_with_blob(id: blob_id, key: traversal_key)\n    Account::DataTransfer::ActiveStorage::BlobRecordSet.new(Current.account).import(from: zip)\n    blob = ActiveStorage::Blob.find(blob_id)\n\n    assert_not_equal traversal_key, blob.key\n\n    sign_in_as identities(:david)\n    get rails_blob_path(blob, disposition: \"inline\")\n\n    assert_response :redirect\n\n    follow_redirect!\n\n    assert_response :not_found\n  end\n\n  private\n    def build_zip_with_blob(id:, key:)\n      tempfile = Tempfile.new([ \"malicious\", \".zip\" ])\n      tempfile.binmode\n\n      writer = ZipFile::Writer.new(tempfile)\n      writer.add_file(\"data/active_storage_blobs/#{id}.json\", {\n        id: id,\n        account_id: ActiveRecord::Type::Uuid.generate,\n        byte_size: 32,\n        checksum: \"\",\n        content_type: \"text/plain\",\n        created_at: Time.current.iso8601,\n        filename: \"traversal.txt\",\n        key: key,\n        metadata: {}\n      }.to_json)\n      writer.close\n\n      tempfile.rewind\n      ZipFile::Reader.new(tempfile)\n    end\nend\n"
  },
  {
    "path": "test/integration/card_preview_boost_count_test.rb",
    "content": "require \"test_helper\"\n\nclass CardPreviewBoostCountTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in_as :kevin\n    @card_with_reactions = cards(:logo)\n    @card_without_reactions = cards(:layout)\n    @column = @card_with_reactions.column\n  end\n\n  test \"card preview displays boost count when card has reactions\" do\n    get board_column_path(@column.board, @column)\n    assert_response :success\n\n    # Check that boost count is displayed for cards with reactions\n    assert_select \".card__boosts\", text: /2/\n  end\n\n  test \"card preview does not display boost count when card has no reactions\" do\n    # Ensure layout card is in the same column for this test\n    @card_without_reactions.update!(column: @column)\n\n    get board_column_path(@column.board, @column)\n    assert_response :success\n\n    # Find the card without reactions and verify no boost count is shown\n    # We check the overall page doesn't have a boost count for zero reactions\n    # (This is an imperfect test but reasonable given the structure)\n    assert @card_without_reactions.reactions.none?\n  end\nend\n"
  },
  {
    "path": "test/integration/notifications_test.rb",
    "content": "require \"test_helper\"\n\nclass NotificationDeliveryTest < ActiveSupport::TestCase\n  setup do\n    @assigner = users(:david)\n    @assignee = users(:kevin)\n    @card = cards(:logo)\n\n    @card.assignments.destroy_all\n    @assignee.notifications.destroy_all\n\n    stub_web_push_pool\n\n    @original_targets = Notification.push_targets.dup\n    Notification.push_targets = []\n    Notification.register_push_target(:web)\n    Notification.register_push_target(push_target_with_tracking)\n\n    # Give assignee a web push subscription\n    @assignee.push_subscriptions.create!(\n      endpoint: \"https://fcm.googleapis.com/fcm/send/test123\",\n      p256dh_key: \"test_key\",\n      auth_key: \"test_auth\"\n    )\n\n    Current.user = @assigner\n  end\n\n  teardown do\n    Notification.push_targets = @original_targets\n    @assignee.push_subscriptions.delete_all\n  end\n\n  test \"card assignment creates notification and triggers push\" do\n    assert_difference -> { Notification.count }, 1 do\n      perform_enqueued_jobs only: [ NotifyRecipientsJob, Notification::PushJob ] do\n        @card.toggle_assignment(@assignee)\n      end\n    end\n\n    notification = Notification.last\n    assert_equal @assignee, notification.user\n    assert_equal @assigner, notification.creator\n    assert_equal \"card_assigned\", notification.source.action\n\n    assert_push_delivered_for notification\n    assert_web_push_delivered\n  end\n\n  test \"card assignment notification is bundled for email delivery when bundling enabled\" do\n    @assignee.settings.update!(bundle_email_frequency: :every_few_hours)\n\n    assert_difference -> { Notification.count }, 1 do\n      perform_enqueued_jobs only: NotifyRecipientsJob do\n        @card.toggle_assignment(@assignee)\n      end\n    end\n\n    notification = @assignee.notifications.reload.last\n    assert_not_nil notification, \"Notification should be created for assignee\"\n\n    bundle = @assignee.notification_bundles.pending.last\n    assert_not_nil bundle, \"Bundle should be created when bundling is enabled\"\n    assert_includes bundle.notifications, notification\n  end\n\n  test \"comment creates notification for card watchers and triggers push\" do\n    @card.watch_by(@assignee)\n\n    assert_difference -> { Notification.count }, 1 do\n      perform_enqueued_jobs only: [ NotifyRecipientsJob, Notification::PushJob ] do\n        @card.comments.create!(body: \"Great work on this!\", creator: @assigner)\n      end\n    end\n\n    notification = Notification.last\n    assert_equal @assignee, notification.user\n    assert_equal \"comment_created\", notification.source.action\n\n    assert_push_delivered\n    assert_web_push_delivered\n  end\n\n  test \"mention creates notification and triggers push\" do\n    mention_html = ActionText::Attachment.from_attachable(@assignee).to_html\n\n    perform_enqueued_jobs only: [ Mention::CreateJob, NotifyRecipientsJob, Notification::PushJob ] do\n      @card.comments.create!(\n        body: \"#{mention_html} check this out\",\n        creator: @assigner\n      )\n    end\n\n    mention_notification = @assignee.notifications.find_by(source_type: \"Mention\")\n    assert_not_nil mention_notification\n\n    assert_push_delivered_for mention_notification\n    assert_web_push_delivered\n  end\n\n  test \"system user actions do not create notifications\" do\n    Current.user = users(:system)\n\n    assert_no_difference -> { Notification.count } do\n      perform_enqueued_jobs only: [ NotifyRecipientsJob, Notification::PushJob ] do\n        @card.toggle_assignment(@assignee)\n      end\n    end\n\n    assert_no_push_delivered\n    assert_no_web_push_delivered\n  end\n\n  test \"notifications for inactive users are created but do not trigger push\" do\n    @assignee.deactivate\n\n    assert_difference -> { Notification.count }, 1 do\n      perform_enqueued_jobs only: [ NotifyRecipientsJob, Notification::PushJob ] do\n        @card.toggle_assignment(@assignee)\n      end\n    end\n\n    assert_no_push_delivered\n    assert_no_web_push_delivered\n  end\n\n  private\n    def stub_web_push_pool\n      @web_push_calls = []\n      web_push_pool = stub(\"web_push_pool\")\n      web_push_pool.stubs(:queue).with do |payload, subs|\n        @web_push_calls << { payload: payload, subscriptions: subs }\n      end\n\n      Rails.configuration.x.stubs(:web_push_pool).returns(web_push_pool)\n    end\n\n    def push_target_with_tracking\n      @push_target_calls = []\n      fake_push_target = Class.new(Notification::PushTarget) do\n        class << self\n          attr_accessor :calls\n        end\n\n        def self.process(notification)\n          calls << notification\n        end\n      end\n\n      fake_push_target.tap { it.calls = @push_target_calls }\n    end\n\n    def assert_push_delivered\n      assert_not_empty @push_target_calls, \"Expected push to be delivered\"\n    end\n\n    def assert_push_delivered_for(notification)\n      assert_includes @push_target_calls, notification, \"Expected push to be delivered for notification\"\n    end\n\n    def assert_no_push_delivered\n      assert_empty @push_target_calls, \"Expected no push to be delivered\"\n    end\n\n    def assert_web_push_delivered\n      assert_not_empty @web_push_calls, \"Expected web push to be delivered\"\n    end\n\n    def assert_no_web_push_delivered\n      assert_empty @web_push_calls, \"Expected no web push to be delivered\"\n    end\nend\n"
  },
  {
    "path": "test/jobs/account/data_import_job_test.rb",
    "content": "require \"test_helper\"\n\nclass Account::DataImportJobTest < ActiveJob::TestCase\n  test \"performs import via continuable steps\" do\n    source_account = accounts(\"37s\")\n    exporter = users(:david)\n    identity = exporter.identity\n\n    export = Account::Export.create!(account: source_account, user: exporter)\n    export.build\n\n    export_tempfile = Tempfile.new([ \"export\", \".zip\" ])\n    export.file.open { |f| FileUtils.cp(f.path, export_tempfile.path) }\n\n    source_account.destroy!\n\n    target_account = Account.create_with_owner(account: { name: \"Import Test\" }, owner: { identity: identity, name: exporter.name })\n    import = Account::Import.create!(identity: identity, account: target_account)\n    Current.set(account: target_account) do\n      import.file.attach(io: File.open(export_tempfile.path), filename: \"export.zip\", content_type: \"application/zip\")\n    end\n\n    Account::DataImportJob.perform_now(import)\n\n    assert import.reload.completed?\n  ensure\n    export_tempfile&.close\n    export_tempfile&.unlink\n  end\nend\n"
  },
  {
    "path": "test/jobs/account/incinerate_due_job_test.rb",
    "content": "require \"test_helper\"\n\nclass Account::IncinerateDueJobTest < ActiveJob::TestCase\n  setup do\n    @account = accounts(:\"37s\")\n    @user = users(:david)\n\n    # Stub Stripe methods only in SaaS mode\n    if defined?(Stripe::Subscription)\n      Stripe::Subscription.stubs(:update).returns(true)\n      Stripe::Subscription.stubs(:cancel).returns(true)\n    end\n  end\n\n  test \"finds accounts up for incineration\" do\n    @account.cancel(initiated_by: @user)\n    @account.cancellation.update!(created_at: 31.days.ago)\n\n    Account.any_instance.expects(:incinerate).once\n\n    Account::IncinerateDueJob.perform_now\n  end\n\n  test \"incinerates each old cancelled account\" do\n    # Cancel the test account\n    @account.cancel(initiated_by: @user)\n    @account.cancellation.update!(created_at: 31.days.ago)\n\n    # Just verify it gets incinerated\n    assert_difference -> { Account.count }, -1 do\n      Account::IncinerateDueJob.perform_now\n    end\n  end\n\n  test \"skips recent cancellations\" do\n    @account.cancel(initiated_by: @user)\n    @account.cancellation.update!(created_at: 29.days.ago)\n\n    assert_no_difference -> { Account.count } do\n      Account::IncinerateDueJob.perform_now\n    end\n  end\n\n  test \"handles cursor pagination correctly\" do\n    # Cancel and age the account\n    @account.cancel(initiated_by: @user)\n    @account.cancellation.update!(created_at: 31.days.ago)\n\n    # Verify it processes accounts in the scope\n    assert_difference -> { Account.count }, -1 do\n      Account::IncinerateDueJob.perform_now\n    end\n  end\n\n  test \"only incinerates accounts past grace period\" do\n    # Account at 29 days (within grace period - should not be incinerated)\n    @account.cancel(initiated_by: @user)\n    @account.cancellation.update!(created_at: 29.days.ago)\n\n    assert_no_difference -> { Account.count } do\n      Account::IncinerateDueJob.perform_now\n    end\n\n    # The account should still exist\n    assert Account.exists?(@account.id)\n  end\nend\n"
  },
  {
    "path": "test/jobs/delete_unused_tags_job_test.rb",
    "content": "require \"test_helper\"\n\nclass DeleteUnusedTagsJobTest < ActiveJob::TestCase\n  test \"deletes tags that are not used by any cards\" do\n    unused = Tag.create!(title: \"unused\")\n\n    assert_changes -> { Tag.count }, -1 do\n      DeleteUnusedTagsJob.perform_now\n    end\n\n    assert_not Tag.exists?(unused.id), \"Unused tag should be deleted\"\n  end\nend\n"
  },
  {
    "path": "test/jobs/storage/materialize_job_test.rb",
    "content": "require \"test_helper\"\n\nclass Storage::MaterializeJobTest < ActiveJob::TestCase\n  setup do\n    @account = accounts(\"37s\")\n    @board = boards(:writebook)\n  end\n\n  test \"calls materialize_storage on account\" do\n    Storage::Entry.record(account: @account, delta: 1024, operation: \"attach\")\n\n    Storage::MaterializeJob.perform_now(@account)\n\n    assert_not_nil @account.storage_total\n    assert_equal 1024, @account.bytes_used\n  end\n\n  test \"calls materialize_storage on board\" do\n    Storage::Entry.record(account: @account, board: @board, delta: 2048, operation: \"attach\")\n\n    Storage::MaterializeJob.perform_now(@board)\n\n    assert_not_nil @board.storage_total\n    assert_equal 2048, @board.bytes_used\n  end\n\n  test \"job is idempotent\" do\n    Storage::Entry.record(account: @account, delta: 1024, operation: \"attach\")\n\n    3.times { Storage::MaterializeJob.perform_now(@account) }\n\n    assert_equal 1024, @account.bytes_used\n  end\n\n  test \"job processes entries added between runs\" do\n    Storage::Entry.record(account: @account, delta: 1000, operation: \"attach\")\n    Storage::MaterializeJob.perform_now(@account)\n\n    # Small delay to ensure UUIDv7 timestamp advances\n    travel 1.second\n\n    Storage::Entry.record(account: @account, delta: 500, operation: \"attach\")\n    Storage::MaterializeJob.perform_now(@account)\n\n    assert_equal 1500, @account.bytes_used\n  end\n\n  test \"job queued to backend queue\" do\n    assert_equal \"backend\", Storage::MaterializeJob.new.queue_name\n  end\n\n  test \"job has concurrency limit by owner\" do\n    job = Storage::MaterializeJob.new(@account)\n    # limits_concurrency is a Solid Queue feature\n    # Just verify the job can be instantiated and has the correct queue\n    assert_equal \"backend\", job.queue_name\n  end\nend\n"
  },
  {
    "path": "test/jobs/storage/reconcile_job_test.rb",
    "content": "require \"test_helper\"\n\nclass Storage::ReconcileJobTest < ActiveJob::TestCase\n  setup do\n    Current.session = sessions(:david)\n    @account = accounts(\"37s\")\n    @board = @account.boards.create!(name: \"Test Board\", creator: users(:david))\n    @card = @board.cards.create!(title: \"Test Card\", creator: users(:david))\n  end\n\n  test \"reconcile_storage corrects drift when ledger undercounts\" do\n    @card.image.attach io: StringIO.new(\"x\" * 1000), filename: \"test.png\", content_type: \"image/png\"\n    Storage::Entry.where(board: @board).delete_all\n\n    Storage::ReconcileJob.perform_now(@board)\n\n    entry = Storage::Entry.find_by(board: @board, operation: \"reconcile\")\n    assert_not_nil entry\n    assert_equal 1000, entry.delta\n  end\n\n  test \"reconcile_storage corrects drift when ledger overcounts\" do\n    Storage::Entry.create! \\\n      account_id: @account.id,\n      board_id: @board.id,\n      delta: 5000,\n      operation: \"attach\"\n\n    Storage::ReconcileJob.perform_now(@board)\n\n    entry = Storage::Entry.find_by(board: @board, operation: \"reconcile\")\n    assert_not_nil entry\n    assert_equal(-5000, entry.delta)\n  end\n\n  test \"reconcile_storage creates no entry when ledger matches reality\" do\n    @card.image.attach io: StringIO.new(\"x\" * 1000), filename: \"test.png\", content_type: \"image/png\"\n    initial_count = Storage::Entry.where(board: @board).count\n\n    Storage::ReconcileJob.perform_now(@board)\n\n    assert_equal initial_count, Storage::Entry.where(board: @board).count\n  end\n\n  test \"job queued to backend queue\" do\n    assert_equal \"backend\", Storage::ReconcileJob.new.queue_name\n  end\n\n  test \"job has concurrency limit by owner\" do\n    job = Storage::ReconcileJob.new(@board)\n    # limits_concurrency is a Solid Queue feature\n    # Just verify the job can be instantiated and has the correct queue\n    assert_equal \"backend\", job.queue_name\n  end\n\n  test \"job raises ReconcileAborted when reconcile fails\" do\n    # Use perform_now with a fresh board that we can stub\n    board = @account.boards.create!(name: \"Abort Test Board\", creator: users(:david))\n\n    # Prepend a module to intercept reconcile_storage\n    intercept = Module.new do\n      def reconcile_storage\n        false\n      end\n    end\n    board.singleton_class.prepend(intercept)\n\n    assert_raises Storage::ReconcileJob::ReconcileAborted do\n      # Call perform directly to avoid serialization\n      Storage::ReconcileJob.new.perform(board)\n    end\n  end\nend\n"
  },
  {
    "path": "test/lib/action_pack/passkey_test.rb",
    "content": "require \"test_helper\"\n\nclass ActionPack::PasskeyTest < ActiveSupport::TestCase\n  setup do\n    @identity = identities(:kevin)\n    @private_key = OpenSSL::PKey::EC.generate(\"prime256v1\")\n\n    ActionPack::WebAuthn::Current.host = \"www.example.com\"\n    ActionPack::WebAuthn::Current.origin = \"http://www.example.com\"\n\n    @passkey = @identity.passkeys.create!(\n      credential_id: Base64.urlsafe_encode64(SecureRandom.random_bytes(32), padding: false),\n      public_key: @private_key.public_to_der,\n      sign_count: 0,\n      transports: [ \"internal\" ]\n    )\n  end\n\n  test \"authenticate with valid assertion\" do\n    challenge = ActionPack::Passkey.request_options(credentials: [ @passkey ]).challenge\n    assertion = build_assertion(challenge: challenge)\n\n    result = @passkey.authenticate(assertion, challenge: challenge)\n\n    assert_equal @passkey, result\n  end\n\n  test \"authenticate returns nil with invalid signature\" do\n    challenge = ActionPack::Passkey.request_options(credentials: [ @passkey ]).challenge\n    assertion = build_assertion(challenge: challenge)\n    assertion[:signature] = Base64.urlsafe_encode64(\"invalid\", padding: false)\n\n    assert_nil @passkey.authenticate(assertion, challenge: challenge)\n  end\n\n  test \"authenticate updates sign count and backed_up\" do\n    challenge = ActionPack::Passkey.request_options(credentials: [ @passkey ]).challenge\n    assertion = build_assertion(challenge: challenge, sign_count: 5, backed_up: true)\n\n    @passkey.authenticate(assertion, challenge: challenge)\n\n    assert_equal 5, @passkey.reload.sign_count\n    assert @passkey.backed_up?\n  end\n\n  test \"to_public_key_credential\" do\n    credential = @passkey.to_public_key_credential\n\n    assert_equal @passkey.credential_id, credential.id\n    assert_equal @passkey.sign_count, credential.sign_count\n    assert_equal @passkey.transports, credential.transports\n  end\n\n  private\n    def build_assertion(challenge:, sign_count: 1, backed_up: false)\n      origin = ActionPack::WebAuthn::Current.origin\n\n      client_data_json = {\n        challenge: challenge,\n        origin: origin,\n        type: \"webauthn.get\"\n      }.to_json\n\n      authenticator_data = build_authenticator_data(sign_count: sign_count, backed_up: backed_up)\n      signature = sign(authenticator_data, client_data_json)\n\n      {\n        id: @passkey.credential_id,\n        client_data_json: client_data_json,\n        authenticator_data: Base64.urlsafe_encode64(authenticator_data, padding: false),\n        signature: Base64.urlsafe_encode64(signature, padding: false)\n      }\n    end\n\n    def build_authenticator_data(sign_count:, backed_up: false)\n      rp_id_hash = Digest::SHA256.digest(ActionPack::WebAuthn::Current.host)\n      flags = 0x01 | 0x04 # user present + user verified\n      flags |= 0x08 | 0x10 if backed_up # backup eligible + backup state\n\n      bytes = []\n      bytes.concat(rp_id_hash.bytes)\n      bytes << flags\n      bytes.concat([ sign_count ].pack(\"N\").bytes)\n      bytes.pack(\"C*\")\n    end\n\n    def sign(authenticator_data, client_data_json)\n      client_data_hash = Digest::SHA256.digest(client_data_json)\n      signed_data = authenticator_data + client_data_hash\n      @private_key.sign(\"SHA256\", signed_data)\n    end\nend\n"
  },
  {
    "path": "test/lib/action_pack/web_authn/authenticator/assertion_response_test.rb",
    "content": "require \"test_helper\"\n\nclass ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport::TestCase\n  include WebauthnTestHelper\n\n  setup do\n    ActionPack::WebAuthn::Current.host = \"example.com\"\n\n    @challenge = webauthn_challenge\n    @origin = \"https://example.com\"\n    @client_data_json = {\n      challenge: @challenge,\n      origin: @origin,\n      type: \"webauthn.get\"\n    }.to_json\n\n    # Generate a real key pair for signature verification\n    @private_key = OpenSSL::PKey::EC.generate(\"prime256v1\")\n    @public_key = @private_key\n    @credential = Struct.new(:public_key, :sign_count).new(@public_key, 0)\n\n    @authenticator_data = build_authenticator_data(user_verified: true)\n    @signature = sign(@authenticator_data, @client_data_json)\n\n    @response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(\n      client_data_json: @client_data_json,\n      authenticator_data: @authenticator_data,\n      signature: @signature,\n      credential: @credential,\n      challenge: @challenge,\n      origin: @origin\n    )\n  end\n\n  test \"initializes with credential, authenticator data, and signature\" do\n    assert_equal @credential, @response.credential\n    assert_instance_of ActionPack::WebAuthn::Authenticator::Data, @response.authenticator_data\n    assert_equal @signature, @response.signature\n  end\n\n  test \"validate! succeeds with valid challenge, origin, type, and signature\" do\n    assert_nothing_raised do\n      @response.validate!\n    end\n  end\n\n  test \"validate! raises when type is not webauthn.get\" do\n    client_data_json = {\n      challenge: @challenge,\n      origin: @origin,\n      type: \"webauthn.create\"\n    }.to_json\n\n    response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(\n      client_data_json: client_data_json,\n      authenticator_data: @authenticator_data,\n      signature: sign(@authenticator_data, client_data_json),\n      credential: @credential,\n      challenge: @challenge,\n      origin: @origin\n    )\n\n    error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do\n      response.validate!\n    end\n\n    assert_equal \"Client data type is not webauthn.get\", error.message\n  end\n\n  test \"validate! raises when signature is invalid\" do\n    response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(\n      client_data_json: @client_data_json,\n      authenticator_data: @authenticator_data,\n      signature: Base64.urlsafe_encode64(\"invalid-signature\", padding: false),\n      credential: @credential,\n      challenge: @challenge,\n      origin: @origin\n    )\n\n    error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do\n      response.validate!\n    end\n\n    assert_equal \"Invalid signature\", error.message\n  end\n\n  test \"validate! raises when challenge does not match\" do\n    @response.challenge = \"wrong-challenge\"\n\n    error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do\n      @response.validate!\n    end\n\n    assert_equal \"Challenge does not match\", error.message\n  end\n\n  test \"validate! raises when origin does not match\" do\n    @response.origin = \"https://evil.com\"\n\n    error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do\n      @response.validate!\n    end\n\n    assert_equal \"Origin does not match\", error.message\n  end\n\n  test \"validate! succeeds with user_verification preferred when not verified\" do\n    authenticator_data = build_authenticator_data(user_verified: false)\n    response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(\n      client_data_json: @client_data_json,\n      authenticator_data: authenticator_data,\n      signature: sign(authenticator_data, @client_data_json),\n      credential: @credential,\n      challenge: @challenge,\n      origin: @origin,\n      user_verification: :preferred\n    )\n\n    assert_nothing_raised do\n      response.validate!\n    end\n  end\n\n  test \"validate! succeeds with user_verification required when verified\" do\n    authenticator_data = build_authenticator_data(user_verified: true)\n    response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(\n      client_data_json: @client_data_json,\n      authenticator_data: authenticator_data,\n      signature: sign(authenticator_data, @client_data_json),\n      credential: @credential,\n      challenge: @challenge,\n      origin: @origin,\n      user_verification: :required\n    )\n\n    assert_nothing_raised do\n      response.validate!\n    end\n  end\n\n  test \"validate! raises with user_verification required when not verified\" do\n    authenticator_data = build_authenticator_data(user_verified: false)\n    response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(\n      client_data_json: @client_data_json,\n      authenticator_data: authenticator_data,\n      signature: sign(authenticator_data, @client_data_json),\n      credential: @credential,\n      challenge: @challenge,\n      origin: @origin,\n      user_verification: :required\n    )\n\n    error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do\n      response.validate!\n    end\n\n    assert_equal \"User verification is required\", error.message\n  end\n\n  private\n    def build_authenticator_data(user_verified:)\n      rp_id_hash = Digest::SHA256.digest(\"example.com\")\n      flags = 0x01 # user present\n      flags |= 0x04 if user_verified\n      sign_count = 0\n\n      bytes = []\n      bytes.concat(rp_id_hash.bytes)\n      bytes << flags\n      bytes.concat([ sign_count ].pack(\"N\").bytes)\n      bytes.pack(\"C*\")\n    end\n\n    def sign(authenticator_data, client_data_json)\n      client_data_hash = Digest::SHA256.digest(client_data_json)\n      signed_data = authenticator_data + client_data_hash\n      @private_key.sign(\"SHA256\", signed_data)\n    end\nend\n"
  },
  {
    "path": "test/lib/action_pack/web_authn/authenticator/attestation_response_test.rb",
    "content": "require \"test_helper\"\n\nclass ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSupport::TestCase\n  # Auth data in all objects contains:\n  #   rp_id_hash: SHA-256(\"example.com\") (32 bytes)\n  #   sign_count: 0\n  #   aaguid: 00010203-0405-0607-0809-0a0b0c0d0e0f (16 bytes)\n  #   credential_id: 32 sequential bytes 0x00..0x1f\n  #   cose_key: EC2/ES256 P-256 {1: 2, 3: -7, -1: 1, -2: <x>, -3: <y>}\n\n  # {\"fmt\": \"none\", \"attStmt\": {}, \"authData\": <flags: 0x45 (UP+UV+AT)>}\n  ATTESTATION_NONE_VERIFIED = [ \"a363666d74646e6f6e656761747453746d74a068617574684461\" \\\n    \"746158a4a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce1947\" \\\n    \"4500000000000102030405060708090a0b0c0d0e0f0020000102030405060708090a0b0c\" \\\n    \"0d0e0f101112131415161718191a1b1c1d1e1fa50102032620012158202ba472104c686f\" \\\n    \"39d4b623cc9324953e7053b47cae818e8cf774203a4f51af7122582069cb8ac519bdd929\" \\\n    \"e2bdbe79e9f9b8d14c2d89a7cbd324647a1ccd68b8de3ca0\" ].pack(\"H*\")\n\n  # {\"fmt\": \"none\", \"attStmt\": {}, \"authData\": <flags: 0x41 (UP+AT)>}\n  ATTESTATION_NONE_NOT_VERIFIED = [ \"a363666d74646e6f6e656761747453746d74a06861757468\" \\\n    \"4461746158a4a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce\" \\\n    \"19474100000000000102030405060708090a0b0c0d0e0f0020000102030405060708090a\" \\\n    \"0b0c0d0e0f101112131415161718191a1b1c1d1e1fa50102032620012158202ba472104c\" \\\n    \"686f39d4b623cc9324953e7053b47cae818e8cf774203a4f51af7122582069cb8ac519bd\" \\\n    \"d929e2bdbe79e9f9b8d14c2d89a7cbd324647a1ccd68b8de3ca0\" ].pack(\"H*\")\n\n  # {\"fmt\": \"packed\", \"attStmt\": {}, \"authData\": <flags: 0x45 (UP+UV+AT)>}\n  ATTESTATION_PACKED_VERIFIED = [ \"a363666d74667061636b65646761747453746d74a068617574\" \\\n    \"684461746158a4a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586\" \\\n    \"ce19474500000000000102030405060708090a0b0c0d0e0f0020000102030405060708090\" \\\n    \"a0b0c0d0e0f101112131415161718191a1b1c1d1e1fa50102032620012158202ba472104\" \\\n    \"c686f39d4b623cc9324953e7053b47cae818e8cf774203a4f51af7122582069cb8ac519b\" \\\n    \"dd929e2bdbe79e9f9b8d14c2d89a7cbd324647a1ccd68b8de3ca0\" ].pack(\"H*\")\n\n  include WebauthnTestHelper\n\n  setup do\n    ActionPack::WebAuthn::Current.host = \"example.com\"\n\n    @challenge = webauthn_challenge\n    @origin = \"https://example.com\"\n    @client_data_json = {\n      challenge: @challenge,\n      origin: @origin,\n      type: \"webauthn.create\"\n    }.to_json\n\n    @response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(\n      client_data_json: @client_data_json,\n      attestation_object: ATTESTATION_NONE_VERIFIED,\n      challenge: @challenge,\n      origin: @origin\n    )\n  end\n\n  test \"initializes with attestation object\" do\n    assert_not_nil @response.attestation_object\n  end\n\n  test \"validate! succeeds with valid challenge, origin, and type\" do\n    assert_nothing_raised do\n      @response.validate!\n    end\n  end\n\n  test \"validate! succeeds with user_verification preferred when not verified\" do\n    response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(\n      client_data_json: @client_data_json,\n      attestation_object: ATTESTATION_NONE_NOT_VERIFIED,\n      challenge: @challenge,\n      origin: @origin,\n      user_verification: :preferred\n    )\n\n    assert_nothing_raised do\n      response.validate!\n    end\n  end\n\n  test \"validate! succeeds with user_verification required when verified\" do\n    response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(\n      client_data_json: @client_data_json,\n      attestation_object: ATTESTATION_NONE_VERIFIED,\n      challenge: @challenge,\n      origin: @origin,\n      user_verification: :required\n    )\n\n    assert_nothing_raised do\n      response.validate!\n    end\n  end\n\n  test \"validate! raises with user_verification required when not verified\" do\n    response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(\n      client_data_json: @client_data_json,\n      attestation_object: ATTESTATION_NONE_NOT_VERIFIED,\n      challenge: @challenge,\n      origin: @origin,\n      user_verification: :required\n    )\n\n    error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do\n      response.validate!\n    end\n\n    assert_equal \"User verification is required\", error.message\n  end\n\n  test \"validate! raises when type is not webauthn.create\" do\n    client_data_json = {\n      challenge: @challenge,\n      origin: @origin,\n      type: \"webauthn.get\"\n    }.to_json\n\n    response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(\n      client_data_json: client_data_json,\n      attestation_object: ATTESTATION_NONE_VERIFIED,\n      challenge: @challenge,\n      origin: @origin\n    )\n\n    error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do\n      response.validate!\n    end\n\n    assert_equal \"Client data type is not webauthn.create\", error.message\n  end\n\n  test \"validate! raises when challenge does not match\" do\n    @response.challenge = \"wrong-challenge\"\n\n    error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do\n      @response.validate!\n    end\n\n    assert_equal \"Challenge does not match\", error.message\n  end\n\n  test \"validate! raises when origin does not match\" do\n    @response.origin = \"https://evil.com\"\n\n    error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do\n      @response.validate!\n    end\n\n    assert_equal \"Origin does not match\", error.message\n  end\n\n  test \"validate! raises when attestation format is not registered\" do\n    response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(\n      client_data_json: @client_data_json,\n      attestation_object: ATTESTATION_PACKED_VERIFIED,\n      challenge: @challenge,\n      origin: @origin\n    )\n\n    error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do\n      response.validate!\n    end\n\n    assert_equal \"Unsupported attestation format: packed\", error.message\n  end\n\n  test \"validate! calls registered verifier for custom format\" do\n    verified = false\n    custom_verifier = Object.new\n    custom_verifier.define_singleton_method(:verify!) { |_attestation, client_data_json:| verified = true }\n\n    ActionPack::WebAuthn.register_attestation_verifier(\"packed\", custom_verifier)\n\n    response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(\n      client_data_json: @client_data_json,\n      attestation_object: ATTESTATION_PACKED_VERIFIED,\n      challenge: @challenge,\n      origin: @origin\n    )\n\n    response.validate!\n    assert verified\n  ensure\n    ActionPack::WebAuthn.attestation_verifiers.delete(\"packed\")\n  end\nend\n"
  },
  {
    "path": "test/lib/action_pack/web_authn/authenticator/attestation_test.rb",
    "content": "require \"test_helper\"\n\nclass ActionPack::WebAuthn::Authenticator::AttestationTest < ActiveSupport::TestCase\n  # Attestation object: {\"fmt\": \"none\", \"attStmt\": {}, \"authData\": <164 bytes>}\n  # Auth data contains:\n  #   rp_id_hash: SHA-256(\"example.com\") (32 bytes)\n  #   flags: 0x41 (user present + attested credential)\n  #   sign_count: 42\n  #   aaguid: 00010203-0405-0607-0809-0a0b0c0d0e0f (16 bytes)\n  #   credential_id: 32 sequential bytes 0x00..0x1f\n  #   cose_key: EC2/ES256 P-256 {1: 2, 3: -7, -1: 1, -2: <x>, -3: <y>}\n  ATTESTATION_CBOR = [ \"a363666d74646e6f6e656761747453746d74a068617574684461746158a4a3\" \\\n    \"79a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce1947410000002a\" \\\n    \"000102030405060708090a0b0c0d0e0f0020000102030405060708090a0b0c0d0e0f1011\" \\\n    \"12131415161718191a1b1c1d1e1fa50102032620012158202ba472104c686f39d4b623cc\" \\\n    \"9324953e7053b47cae818e8cf774203a4f51af7122582069cb8ac519bdd929e2bdbe79e9\" \\\n    \"f9b8d14c2d89a7cbd324647a1ccd68b8de3ca0\" ].pack(\"H*\")\n\n  CREDENTIAL_ID_BASE64 = \"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8\"\n  SIGN_COUNT = 42\n\n  test \"decodes attestation object\" do\n    attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(ATTESTATION_CBOR)\n\n    assert_equal \"none\", attestation.format\n    assert_equal({}, attestation.attestation_statement)\n    assert_instance_of ActionPack::WebAuthn::Authenticator::Data, attestation.authenticator_data\n  end\n\n  test \"delegates credential_id to authenticator_data\" do\n    attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(ATTESTATION_CBOR)\n\n    assert_equal CREDENTIAL_ID_BASE64, attestation.credential_id\n  end\n\n  test \"delegates sign_count to authenticator_data\" do\n    attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(ATTESTATION_CBOR)\n\n    assert_equal SIGN_COUNT, attestation.sign_count\n  end\n\n  test \"delegates public_key to authenticator_data\" do\n    attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(ATTESTATION_CBOR)\n\n    assert_instance_of OpenSSL::PKey::EC, attestation.public_key\n  end\nend\n"
  },
  {
    "path": "test/lib/action_pack/web_authn/authenticator/attestation_verifiers/none_test.rb",
    "content": "require \"test_helper\"\n\nclass ActionPack::WebAuthn::Authenticator::AttestationVerifiers::NoneTest < ActiveSupport::TestCase\n  setup do\n    @verifier = ActionPack::WebAuthn::Authenticator::AttestationVerifiers::None.new\n  end\n\n  test \"verify! passes with nil attestation statement\" do\n    attestation = stub(attestation_statement: nil)\n\n    assert_nothing_raised do\n      @verifier.verify!(attestation, client_data_json: \"\")\n    end\n  end\n\n  test \"verify! passes with empty attestation statement\" do\n    attestation = stub(attestation_statement: {})\n\n    assert_nothing_raised do\n      @verifier.verify!(attestation, client_data_json: \"\")\n    end\n  end\n\n  test \"verify! raises with non-empty attestation statement\" do\n    attestation = stub(attestation_statement: { \"sig\" => \"abc\" })\n\n    error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do\n      @verifier.verify!(attestation, client_data_json: \"\")\n    end\n\n    assert_equal \"Attestation statement must be empty for 'none' format\", error.message\n  end\nend\n"
  },
  {
    "path": "test/lib/action_pack/web_authn/authenticator/data_test.rb",
    "content": "require \"test_helper\"\n\nclass ActionPack::WebAuthn::Authenticator::DataTest < ActiveSupport::TestCase\n  # Common values:\n  #   rp_id_hash: SHA-256(\"example.com\") (32 bytes)\n  #   sign_count: 42\n  #   aaguid: 00010203-0405-0607-0809-0a0b0c0d0e0f (16 bytes)\n  #   credential_id: 32 sequential bytes 0x00..0x1f\n  #   cose_key: EC2/ES256 P-256 {1: 2, 3: -7, -1: 1, -2: <x>, -3: <y>}\n\n  RP_ID_HASH = [ \"a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce1947\" ].pack(\"H*\")\n  SIGN_COUNT = 42\n  CREDENTIAL_ID_BASE64 = \"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8\"\n\n  COSE_KEY_CBOR = [ \"a50102032620012158202ba472104c686f39d4b623cc9324953e7053b47cae81\" \\\n    \"8e8cf774203a4f51af7122582069cb8ac519bdd929e2bdbe79e9f9b8d14c2d89a7cbd324\" \\\n    \"647a1ccd68b8de3ca0\" ].pack(\"H*\")\n\n  # rp_id_hash(32) + flags 0x01 (UP) + sign_count 42\n  AUTH_DATA_NO_CREDENTIAL = [ \"a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2\" \\\n    \"125586ce1947010000002a\" ].pack(\"H*\")\n\n  # rp_id_hash(32) + flags 0x41 (UP+AT) + sign_count 42 + aaguid(16) +\n  # credential_id_len 32 (2 bytes) + credential_id(32) + cose_key CBOR\n  AUTH_DATA_WITH_CREDENTIAL = [ \"a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13\" \\\n    \"d2125586ce1947410000002a000102030405060708090a0b0c0d0e0f0020000102030405\" \\\n    \"060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fa5010203262001215820\" \\\n    \"2ba472104c686f39d4b623cc9324953e7053b47cae818e8cf774203a4f51af7122582069\" \\\n    \"cb8ac519bdd929e2bdbe79e9f9b8d14c2d89a7cbd324647a1ccd68b8de3ca0\" ].pack(\"H*\")\n\n  # Error test data: flags 0x41 (AT set) but no attested credential data after header\n  # rp_id_hash(32) + flags 0x41 + sign_count 42\n  AUTH_DATA_AT_FLAG_NO_CREDENTIAL = [ \"a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30\" \\\n    \"ab13d2125586ce1947410000002a\" ].pack(\"H*\")\n\n  # rp_id_hash(32) + flags 0x41 + sign_count 0 + aaguid(16), missing credential_id_len\n  AUTH_DATA_TRUNCATED_BEFORE_CRED_LEN = [ \"a379a6f6eeafb9a55e378c118034e2751e682fab9f\" \\\n    \"2d30ab13d2125586ce19474100000000000102030405060708090a0b0c0d0e0f\" ].pack(\"H*\")\n\n  # rp_id_hash(32) + flags 0x41 + sign_count 0 + aaguid(16) + credential_id_len 9999\n  AUTH_DATA_HUGE_CRED_LEN = [ \"a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2\" \\\n    \"125586ce19474100000000000102030405060708090a0b0c0d0e0f270f\" ].pack(\"H*\")\n\n  test \"decodes authenticator data without attested credential\" do\n    data = ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_NO_CREDENTIAL)\n\n    assert_equal RP_ID_HASH, data.relying_party_id_hash\n    assert_equal 0x01, data.flags\n    assert_equal SIGN_COUNT, data.sign_count\n    assert_nil data.credential_id\n    assert_nil data.public_key_bytes\n  end\n\n  test \"decodes authenticator data with attested credential\" do\n    data = ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_WITH_CREDENTIAL)\n\n    assert_equal RP_ID_HASH, data.relying_party_id_hash\n    assert_equal 0x41, data.flags\n    assert_equal SIGN_COUNT, data.sign_count\n    assert_equal CREDENTIAL_ID_BASE64, data.credential_id\n    assert_equal COSE_KEY_CBOR, data.public_key_bytes\n  end\n\n  test \"user_present? returns true when flag is set\" do\n    data = build_data_with_flags(0x01)\n    assert data.user_present?\n  end\n\n  test \"user_present? returns false when flag is not set\" do\n    data = build_data_with_flags(0x00)\n    assert_not data.user_present?\n  end\n\n  test \"user_verified? returns true when flag is set\" do\n    data = build_data_with_flags(0x04)\n    assert data.user_verified?\n  end\n\n  test \"user_verified? returns false when flag is not set\" do\n    data = build_data_with_flags(0x00)\n    assert_not data.user_verified?\n  end\n\n  test \"backup_eligible? returns true when flag is set\" do\n    data = build_data_with_flags(0x08)\n    assert data.backup_eligible?\n  end\n\n  test \"backup_eligible? returns false when flag is not set\" do\n    data = build_data_with_flags(0x00)\n    assert_not data.backup_eligible?\n  end\n\n  test \"backed_up? returns true when flag is set\" do\n    data = build_data_with_flags(0x10)\n    assert data.backed_up?\n  end\n\n  test \"backed_up? returns false when flag is not set\" do\n    data = build_data_with_flags(0x00)\n    assert_not data.backed_up?\n  end\n\n  test \"public_key returns OpenSSL key when public_key_bytes present\" do\n    data = ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_WITH_CREDENTIAL)\n\n    assert_instance_of OpenSSL::PKey::EC, data.public_key\n  end\n\n  test \"public_key returns nil when public_key_bytes not present\" do\n    data = ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_NO_CREDENTIAL)\n\n    assert_nil data.public_key\n  end\n\n  test \"raises when attested credential flag set but data truncated before AAGUID\" do\n    assert_raises(ActionPack::WebAuthn::InvalidResponseError) do\n      ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_AT_FLAG_NO_CREDENTIAL)\n    end\n  end\n\n  test \"raises when attested credential flag set but data truncated before credential ID\" do\n    assert_raises(ActionPack::WebAuthn::InvalidResponseError) do\n      ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_TRUNCATED_BEFORE_CRED_LEN)\n    end\n  end\n\n  test \"raises when credential ID length exceeds remaining bytes\" do\n    assert_raises(ActionPack::WebAuthn::InvalidResponseError) do\n      ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_HUGE_CRED_LEN)\n    end\n  end\n\n  private\n    def build_data_with_flags(flags)\n      ActionPack::WebAuthn::Authenticator::Data.new(\n        bytes: [],\n        relying_party_id_hash: RP_ID_HASH,\n        flags: flags,\n        sign_count: 0,\n        credential_id: nil,\n        public_key_bytes: nil\n      )\n    end\nend\n"
  },
  {
    "path": "test/lib/action_pack/web_authn/authenticator/response_test.rb",
    "content": "require \"test_helper\"\n\nclass ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCase\n  include WebauthnTestHelper\n\n  setup do\n    ActionPack::WebAuthn::Current.host = \"example.com\"\n\n    @challenge = webauthn_challenge\n    @origin = \"https://example.com\"\n    @client_data_json = {\n      challenge: @challenge,\n      origin: @origin,\n      type: \"webauthn.create\"\n    }.to_json\n\n    @authenticator_data = build_authenticator_data\n    @response = TestableResponse.new(\n      client_data_json: @client_data_json,\n      authenticator_data: @authenticator_data,\n      challenge: @challenge,\n      origin: @origin\n    )\n  end\n\n  class TestableResponse < ActionPack::WebAuthn::Authenticator::Response\n    attr_reader :authenticator_data\n\n    def initialize(authenticator_data:, **attrs)\n      super(**attrs)\n      @authenticator_data = authenticator_data\n    end\n  end\n\n  test \"parses client data JSON\" do\n    assert_equal @challenge, @response.client_data[\"challenge\"]\n    assert_equal @origin, @response.client_data[\"origin\"]\n  end\n\n  test \"valid? returns true when challenge and origin match\" do\n    assert @response.valid?\n  end\n\n  test \"valid? returns false when challenge does not match\" do\n    @response.challenge = \"wrong-challenge\"\n    assert_not @response.valid?\n  end\n\n  test \"valid? returns false when origin does not match\" do\n    @response.origin = \"https://evil.com\"\n    assert_not @response.valid?\n  end\n\n  test \"validate! raises when challenge does not match\" do\n    @response.challenge = \"wrong-challenge\"\n\n    error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do\n      @response.validate!\n    end\n\n    assert_equal \"Challenge does not match\", error.message\n  end\n\n  test \"validate! raises when origin does not match\" do\n    @response.origin = \"https://evil.com\"\n\n    error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do\n      @response.validate!\n    end\n\n    assert_equal \"Origin does not match\", error.message\n  end\n\n  test \"validate! raises when crossOrigin is true\" do\n    client_data_json = {\n      challenge: @challenge,\n      origin: @origin,\n      type: \"webauthn.create\",\n      crossOrigin: true\n    }.to_json\n\n    response = TestableResponse.new(\n      client_data_json: client_data_json,\n      authenticator_data: @authenticator_data,\n      challenge: @challenge,\n      origin: @origin\n    )\n\n    error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do\n      response.validate!\n    end\n\n    assert_equal \"Cross-origin requests are not supported\", error.message\n  end\n\n  test \"validate! raises when relying party ID does not match\" do\n    rp_id_hash = Digest::SHA256.digest(\"evil.com\")\n    flags = 0x05\n    sign_count = 0\n\n    bytes = []\n    bytes.concat(rp_id_hash.bytes)\n    bytes << flags\n    bytes.concat([ sign_count ].pack(\"N\").bytes)\n\n    wrong_rp_data = ActionPack::WebAuthn::Authenticator::Data.decode(bytes.pack(\"C*\"))\n\n    response = TestableResponse.new(\n      client_data_json: @client_data_json,\n      authenticator_data: wrong_rp_data,\n      challenge: @challenge,\n      origin: @origin\n    )\n\n    error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do\n      response.validate!\n    end\n\n    assert_equal \"Relying party ID does not match\", error.message\n  end\n\n  test \"validate! raises when tokenBinding status is present\" do\n    client_data_json = {\n      challenge: @challenge,\n      origin: @origin,\n      type: \"webauthn.create\",\n      tokenBinding: { status: \"present\", id: \"some-id\" }\n    }.to_json\n\n    response = TestableResponse.new(\n      client_data_json: client_data_json,\n      authenticator_data: @authenticator_data,\n      challenge: @challenge,\n      origin: @origin\n    )\n\n    error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do\n      response.validate!\n    end\n\n    assert_equal \"Token binding is not supported\", error.message\n  end\n\n  private\n    def build_authenticator_data\n      rp_id_hash = Digest::SHA256.digest(\"example.com\")\n      flags = 0x05 # user present + user verified\n      sign_count = 0\n\n      bytes = []\n      bytes.concat(rp_id_hash.bytes)\n      bytes << flags\n      bytes.concat([ sign_count ].pack(\"N\").bytes)\n\n      ActionPack::WebAuthn::Authenticator::Data.decode(bytes.pack(\"C*\"))\n    end\nend\n"
  },
  {
    "path": "test/lib/action_pack/web_authn/cbor_decoder_test.rb",
    "content": "require \"test_helper\"\n\nclass ActionPack::WebAuthn::CborDecoderTest < ActiveSupport::TestCase\n  test \"decodes unsigned integer 0\" do\n    assert_equal 0, decode(\"00\")\n  end\n\n  test \"decodes unsigned integer 1\" do\n    assert_equal 1, decode(\"01\")\n  end\n\n  test \"decodes unsigned integer 10\" do\n    assert_equal 10, decode(\"0a\")\n  end\n\n  test \"decodes unsigned integer 23\" do\n    assert_equal 23, decode(\"17\")\n  end\n\n  test \"decodes unsigned integer 24 (single byte follows)\" do\n    assert_equal 24, decode(\"1818\")\n  end\n\n  test \"decodes unsigned integer 25\" do\n    assert_equal 25, decode(\"1819\")\n  end\n\n  test \"decodes unsigned integer 100\" do\n    assert_equal 100, decode(\"1864\")\n  end\n\n  test \"decodes unsigned integer 1000 (two bytes follow)\" do\n    assert_equal 1000, decode(\"1903e8\")\n  end\n\n  test \"decodes unsigned integer 1000000 (four bytes follow)\" do\n    assert_equal 1000000, decode(\"1a000f4240\")\n  end\n\n  test \"decodes unsigned integer 1000000000000 (eight bytes follow)\" do\n    assert_equal 1000000000000, decode(\"1b000000e8d4a51000\")\n  end\n\n  test \"decodes negative integer -1\" do\n    assert_equal(-1, decode(\"20\"))\n  end\n\n  test \"decodes negative integer -10\" do\n    assert_equal(-10, decode(\"29\"))\n  end\n\n  test \"decodes negative integer -100\" do\n    assert_equal(-100, decode(\"3863\"))\n  end\n\n  test \"decodes negative integer -1000\" do\n    assert_equal(-1000, decode(\"3903e7\"))\n  end\n\n  test \"decodes empty byte string\" do\n    assert_equal \"\", decode(\"40\")\n  end\n\n  test \"decodes byte string with 4 bytes\" do\n    assert_equal \"\\x01\\x02\\x03\\x04\".b, decode(\"4401020304\")\n  end\n\n  test \"decodes empty text string\" do\n    result = decode(\"60\")\n    assert_equal \"\", result\n    assert_equal Encoding::UTF_8, result.encoding\n  end\n\n  test \"decodes text string 'a'\" do\n    result = decode(\"6161\")\n    assert_equal \"a\", result\n    assert_equal Encoding::UTF_8, result.encoding\n  end\n\n  test \"decodes text string 'IETF'\" do\n    result = decode(\"6449455446\")\n    assert_equal \"IETF\", result\n    assert_equal Encoding::UTF_8, result.encoding\n  end\n\n  test \"decodes text string with unicode\" do\n    result = decode(\"62c3bc\")\n    assert_equal \"ü\", result\n    assert_equal Encoding::UTF_8, result.encoding\n  end\n\n  test \"decodes empty array\" do\n    assert_equal [], decode(\"80\")\n  end\n\n  test \"decodes array [1, 2, 3]\" do\n    assert_equal [ 1, 2, 3 ], decode(\"83010203\")\n  end\n\n  test \"decodes nested array [1, [2, 3], [4, 5]]\" do\n    assert_equal [ 1, [ 2, 3 ], [ 4, 5 ] ], decode(\"8301820203820405\")\n  end\n\n  test \"decodes array with 25 elements\" do\n    expected = (1..25).to_a\n    # 0x9819 = array with 25 elements (0x98 = type 4 + additional 24, 0x19 = 25)\n    # integers 1-23 encode as single bytes, 24 = 0x1818, 25 = 0x1819\n    elements = (1..23).map { |n| format(\"%02x\", n) }.join + \"18181819\"\n    assert_equal expected, decode(\"9819\" + elements)\n  end\n\n  test \"decodes empty map\" do\n    assert_equal({}, decode(\"a0\"))\n  end\n\n  test \"decodes map {1: 2, 3: 4}\" do\n    assert_equal({ 1 => 2, 3 => 4 }, decode(\"a201020304\"))\n  end\n\n  test \"decodes map with string keys\" do\n    assert_equal({ \"a\" => 1, \"b\" => 2 }, decode(\"a2616101616202\"))\n  end\n\n  test \"decodes nested map\" do\n    assert_equal({ \"a\" => { \"b\" => 1 } }, decode(\"a16161a1616201\"))\n  end\n\n  test \"decodes false\" do\n    assert_equal false, decode(\"f4\")\n  end\n\n  test \"decodes true\" do\n    assert_equal true, decode(\"f5\")\n  end\n\n  test \"decodes null\" do\n    assert_nil decode(\"f6\")\n  end\n\n  test \"decodes undefined as nil\" do\n    assert_nil decode(\"f7\")\n  end\n\n  test \"decodes tagged value, ignoring tag\" do\n    # 0xc0 = tag 0 (date/time string), followed by text \"2013-03-21T20:04:00Z\"\n    assert_equal \"2013-03-21T20:04:00Z\", decode(\"c074323031332d30332d32315432303a30343a30305a\")\n  end\n\n  test \"decodes tagged integer\" do\n    # 0xc1 = tag 1 (epoch time), followed by integer 1363896240\n    assert_equal 1363896240, decode(\"c11a514b67b0\")\n  end\n\n  test \"raises error for reserved additional info values\" do\n    assert_raises(ActionPack::WebAuthn::InvalidCborError) do\n      decode(\"1c\")\n    end\n  end\n\n  test \"decodes indefinite length array\" do\n    assert_equal [ 1, 2, 3 ], decode(\"9f010203ff\")\n  end\n\n  test \"decodes empty indefinite length array\" do\n    assert_equal [], decode(\"9fff\")\n  end\n\n  test \"decodes empty indefinite length map\" do\n    assert_equal({}, decode(\"bfff\"))\n  end\n\n  test \"decodes indefinite length map\" do\n    assert_equal({ \"a\" => 1, \"b\" => 2 }, decode(\"bf616101616202ff\"))\n  end\n\n  test \"decodes indefinite length byte string\" do\n    assert_equal \"\\x01\\x02\\x03\".b, decode(\"5f4201024103ff\")\n  end\n\n  test \"decodes indefinite length text string\" do\n    result = decode(\"7f657374726561646d696e67ff\")\n    assert_equal \"streaming\", result\n    assert_equal Encoding::UTF_8, result.encoding\n  end\n\n  test \"decodes half-precision float 0.0\" do\n    assert_equal 0.0, decode(\"f90000\")\n  end\n\n  test \"decodes half-precision float 1.0\" do\n    assert_equal 1.0, decode(\"f93c00\")\n  end\n\n  test \"decodes half-precision float 1.5\" do\n    assert_equal 1.5, decode(\"f93e00\")\n  end\n\n  test \"decodes half-precision float -4.0\" do\n    assert_equal(-4.0, decode(\"f9c400\"))\n  end\n\n  test \"decodes half-precision positive infinity\" do\n    assert_equal Float::INFINITY, decode(\"f97c00\")\n  end\n\n  test \"decodes half-precision NaN\" do\n    assert_predicate decode(\"f97e00\"), :nan?\n  end\n\n  test \"decodes single-precision float 100000.0\" do\n    assert_equal 100000.0, decode(\"fa47c35000\")\n  end\n\n  test \"decodes single-precision positive infinity\" do\n    assert_equal Float::INFINITY, decode(\"fa7f800000\")\n  end\n\n  test \"decodes double-precision float 1.1\" do\n    assert_in_delta 1.1, decode(\"fb3ff199999999999a\"), 0.0001\n  end\n\n  test \"decodes double-precision float -4.1\" do\n    assert_in_delta(-4.1, decode(\"fbc010666666666666\"), 0.0001)\n  end\n\n  test \"decodes double-precision positive infinity\" do\n    assert_equal Float::INFINITY, decode(\"fb7ff0000000000000\")\n  end\n\n  test \"decodes double-precision negative infinity\" do\n    assert_equal(-Float::INFINITY, decode(\"fbfff0000000000000\"))\n  end\n\n  test \"raises error for unsupported simple value\" do\n    assert_raises(ActionPack::WebAuthn::InvalidCborError) do\n      decode(\"e0\")\n    end\n  end\n\n  test \"decode accepts string input\" do\n    bytes = [ 0x01 ].pack(\"C*\")\n    assert_equal 1, ActionPack::WebAuthn::CborDecoder.decode(bytes)\n  end\n\n  test \"decode accepts array input\" do\n    assert_equal 1, ActionPack::WebAuthn::CborDecoder.decode([ 0x01 ])\n  end\n\n  test \"raises error for empty input\" do\n    assert_raises(ActionPack::WebAuthn::InvalidCborError) do\n      ActionPack::WebAuthn::CborDecoder.decode([])\n    end\n  end\n\n  test \"raises error for truncated byte string\" do\n    # 0x44 = byte string of length 4, but only 2 bytes follow\n    assert_raises(ActionPack::WebAuthn::InvalidCborError) do\n      decode(\"440102\")\n    end\n  end\n\n  test \"raises error for truncated integer\" do\n    # 0x19 = 2-byte integer follows, but only 1 byte provided\n    assert_raises(ActionPack::WebAuthn::InvalidCborError) do\n      decode(\"19ff\")\n    end\n  end\n\n  test \"raises error for truncated array\" do\n    # 0x82 = array of 2 items, but only 1 provided\n    assert_raises(ActionPack::WebAuthn::InvalidCborError) do\n      decode(\"8201\")\n    end\n  end\n\n  test \"raises error for deeply nested structure\" do\n    # Build array nested 20 levels deep: [[[[...]]]]\n    # 0x81 = array of 1 item\n    deeply_nested = \"81\" * 20 + \"01\"\n\n    error = assert_raises(ActionPack::WebAuthn::InvalidCborError) do\n      decode(deeply_nested)\n    end\n\n    assert_equal \"Maximum nesting depth exceeded\", error.message\n  end\n\n  test \"raises error for input exceeding max size\" do\n    error = assert_raises(ActionPack::WebAuthn::InvalidCborError) do\n      ActionPack::WebAuthn::CborDecoder.decode([ 0x01 ], max_size: 0)\n    end\n\n    assert_equal \"Input exceeds maximum size\", error.message\n  end\n\n  private\n    def decode(hex)\n      bytes = [ hex ].pack(\"H*\").bytes\n      ActionPack::WebAuthn::CborDecoder.decode(bytes)\n    end\nend\n"
  },
  {
    "path": "test/lib/action_pack/web_authn/cose_key_test.rb",
    "content": "require \"test_helper\"\n\nclass ActionPack::WebAuthn::CoseKeyTest < ActiveSupport::TestCase\n  # EC2/ES256 P-256 public key (32-byte x and y coordinates)\n  EC2_X = [ \"2ba472104c686f39d4b623cc9324953e7053b47cae818e8cf774203a4f51af71\" ].pack(\"H*\")\n  EC2_Y = [ \"69cb8ac519bdd929e2bdbe79e9f9b8d14c2d89a7cbd324647a1ccd68b8de3ca0\" ].pack(\"H*\")\n\n  # CBOR: {1: 2, 3: -7, -1: 1, -2: <x 32 bytes>, -3: <y 32 bytes>}\n  EC2_CBOR = [ \"a50102032620012158202ba472104c686f39d4b623cc9324953e7053b47cae81\" \\\n    \"8e8cf774203a4f51af7122582069cb8ac519bdd929e2bdbe79e9f9b8d14c2d89a7cbd324\" \\\n    \"647a1ccd68b8de3ca0\" ].pack(\"H*\")\n\n  # Ed25519 public key (32 bytes)\n  ED25519_X = [ \"a95ee02872a2c5224b394832767bea746620e50776e845872228716065f16005\" ].pack(\"H*\")\n\n  # CBOR: {1: 1, 3: -8, -1: 6, -2: <x 32 bytes>}\n  OKP_CBOR = [ \"a4010103272006215820a95ee02872a2c5224b394832767bea746620e50776e8\" \\\n    \"45872228716065f16005\" ].pack(\"H*\")\n\n  # RSA 2048-bit public key (256-byte modulus, 3-byte exponent 65537)\n  RSA_N = [ \"d388adb3aa7812402281c57ce870821b17558f0a247a771834892d85399ecd4f\" \\\n    \"830dd35f65e7afe5030d9ee10f4873567039976486202cce8ac499114194d32fe615026e\" \\\n    \"7eeee5b2ff564041d68b9b33c35a2ac17210c69c9e85fa74249b06e4ffa6b38ff5ef54e\" \\\n    \"1860aa59a6fb043e2b65ecf0ce8d0ff90d25683ca2da016618308f3fa7f74efc178ec46\" \\\n    \"e0224f10cf0eed7d46cc6167210f088cc6b77fc08a7fcd14536aa9c726519806a96ea00\" \\\n    \"517ce1ed1336ae6962338a6c4cc4754d953ebbffb5d6b1bc76368b552b628adb788b0bc\" \\\n    \"9f895dff6b1c74d79ce210b5941995beb1f498a1e9123666bdc92bc6b0f2a04fdb40cf1\" \\\n    \"d253ba1582673ec293113\" ].pack(\"H*\")\n  RSA_E = [ \"010001\" ].pack(\"H*\")\n\n  # CBOR: {1: 3, 3: -257, -1: <n 256 bytes>, -2: <e 3 bytes>}\n  RSA_CBOR = [ \"a401030339010020590100d388adb3aa7812402281c57ce870821b17558f0a24\" \\\n    \"7a771834892d85399ecd4f830dd35f65e7afe5030d9ee10f4873567039976486202cce8a\" \\\n    \"c499114194d32fe615026e7eeee5b2ff564041d68b9b33c35a2ac17210c69c9e85fa742\" \\\n    \"49b06e4ffa6b38ff5ef54e1860aa59a6fb043e2b65ecf0ce8d0ff90d25683ca2da01661\" \\\n    \"8308f3fa7f74efc178ec46e0224f10cf0eed7d46cc6167210f088cc6b77fc08a7fcd145\" \\\n    \"36aa9c726519806a96ea00517ce1ed1336ae6962338a6c4cc4754d953ebbffb5d6b1bc7\" \\\n    \"6368b552b628adb788b0bc9f895dff6b1c74d79ce210b5941995beb1f498a1e9123666b\" \\\n    \"dc92bc6b0f2a04fdb40cf1d253ba1582673ec2931132143010001\" ].pack(\"H*\")\n\n  setup do\n    @ec2_parameters = {\n      1 => 2,    # kty: EC2\n      3 => -7,   # alg: ES256\n      -1 => 1,   # crv: P-256\n      -2 => EC2_X,\n      -3 => EC2_Y\n    }\n\n    @rsa_parameters = {\n      1 => 3,     # kty: RSA\n      3 => -257,  # alg: RS256\n      -1 => RSA_N,\n      -2 => RSA_E\n    }\n\n    @okp_parameters = {\n      1 => 1,    # kty: OKP\n      3 => -8,   # alg: EdDSA\n      -1 => 6,   # crv: Ed25519\n      -2 => ED25519_X\n    }\n  end\n\n  test \"initializes with key type, algorithm, and parameters\" do\n    key = ActionPack::WebAuthn::CoseKey.new(\n      key_type: 2,\n      algorithm: -7,\n      parameters: @ec2_parameters\n    )\n\n    assert_equal 2, key.key_type\n    assert_equal(-7, key.algorithm)\n    assert_equal @ec2_parameters, key.parameters\n  end\n\n  test \"decodes EC2/ES256 key from CBOR\" do\n    key = ActionPack::WebAuthn::CoseKey.decode(EC2_CBOR)\n\n    assert_equal 2, key.key_type\n    assert_equal(-7, key.algorithm)\n  end\n\n  test \"decodes OKP/EdDSA key from CBOR\" do\n    key = ActionPack::WebAuthn::CoseKey.decode(OKP_CBOR)\n\n    assert_equal 1, key.key_type\n    assert_equal(-8, key.algorithm)\n  end\n\n  test \"decodes RSA/RS256 key from CBOR\" do\n    key = ActionPack::WebAuthn::CoseKey.decode(RSA_CBOR)\n\n    assert_equal 3, key.key_type\n    assert_equal(-257, key.algorithm)\n  end\n\n  test \"converts EC2/ES256 key to OpenSSL EC key\" do\n    key = ActionPack::WebAuthn::CoseKey.new(\n      key_type: 2,\n      algorithm: -7,\n      parameters: @ec2_parameters\n    )\n\n    openssl_key = key.to_openssl_key\n\n    assert_instance_of OpenSSL::PKey::EC, openssl_key\n    assert_equal \"prime256v1\", openssl_key.group.curve_name\n  end\n\n  test \"converts OKP/EdDSA key to OpenSSL Ed25519 key\" do\n    key = ActionPack::WebAuthn::CoseKey.new(\n      key_type: 1,\n      algorithm: -8,\n      parameters: @okp_parameters\n    )\n\n    openssl_key = key.to_openssl_key\n\n    assert_equal \"ED25519\", openssl_key.oid\n  end\n\n  test \"converts RSA/RS256 key to OpenSSL RSA key\" do\n    key = ActionPack::WebAuthn::CoseKey.new(\n      key_type: 3,\n      algorithm: -257,\n      parameters: @rsa_parameters\n    )\n\n    openssl_key = key.to_openssl_key\n\n    assert_instance_of OpenSSL::PKey::RSA, openssl_key\n    assert_equal 65537, openssl_key.e.to_i\n  end\n\n  test \"raises error for unsupported key type/algorithm combination\" do\n    key = ActionPack::WebAuthn::CoseKey.new(\n      key_type: 99,\n      algorithm: -7,\n      parameters: {}\n    )\n\n    error = assert_raises(ActionPack::WebAuthn::UnsupportedKeyTypeError) do\n      key.to_openssl_key\n    end\n\n    assert_match(/99\\/-7/, error.message)\n  end\n\n  test \"raises error for unsupported OKP curve\" do\n    parameters = @okp_parameters.merge(-1 => 5) # Ed448 instead of Ed25519\n    key = ActionPack::WebAuthn::CoseKey.new(\n      key_type: 1,\n      algorithm: -8,\n      parameters: parameters\n    )\n\n    error = assert_raises(ActionPack::WebAuthn::UnsupportedKeyTypeError) do\n      key.to_openssl_key\n    end\n\n    assert_match(/curve/, error.message.downcase)\n  end\n\n  test \"raises error for unsupported EC curve\" do\n    parameters = @ec2_parameters.merge(-1 => 2) # P-384 instead of P-256\n    key = ActionPack::WebAuthn::CoseKey.new(\n      key_type: 2,\n      algorithm: -7,\n      parameters: parameters\n    )\n\n    error = assert_raises(ActionPack::WebAuthn::UnsupportedKeyTypeError) do\n      key.to_openssl_key\n    end\n\n    assert_match(/curve/, error.message.downcase)\n  end\n\n  test \"raises error for EC2 key with missing coordinates\" do\n    parameters = @ec2_parameters.except(-3) # missing y coordinate\n    key = ActionPack::WebAuthn::CoseKey.new(\n      key_type: 2,\n      algorithm: -7,\n      parameters: parameters\n    )\n\n    error = assert_raises(ActionPack::WebAuthn::InvalidKeyError) do\n      key.to_openssl_key\n    end\n\n    assert_match(/missing ec2 key coordinates/i, error.message)\n  end\n\n  test \"raises error for EC2 key with wrong coordinate length\" do\n    parameters = @ec2_parameters.merge(-2 => \"\\x00\" * 16) # 16 bytes instead of 32\n    key = ActionPack::WebAuthn::CoseKey.new(\n      key_type: 2,\n      algorithm: -7,\n      parameters: parameters\n    )\n\n    error = assert_raises(ActionPack::WebAuthn::InvalidKeyError) do\n      key.to_openssl_key\n    end\n\n    assert_match(/invalid ec2 coordinate length/i, error.message)\n  end\n\n  test \"raises error for OKP key with missing coordinate\" do\n    parameters = @okp_parameters.except(-2) # missing x coordinate\n    key = ActionPack::WebAuthn::CoseKey.new(\n      key_type: 1,\n      algorithm: -8,\n      parameters: parameters\n    )\n\n    error = assert_raises(ActionPack::WebAuthn::InvalidKeyError) do\n      key.to_openssl_key\n    end\n\n    assert_match(/missing okp key coordinate/i, error.message)\n  end\n\n  test \"raises error for RSA key with missing parameters\" do\n    parameters = @rsa_parameters.except(-1) # missing n\n    key = ActionPack::WebAuthn::CoseKey.new(\n      key_type: 3,\n      algorithm: -257,\n      parameters: parameters\n    )\n\n    error = assert_raises(ActionPack::WebAuthn::InvalidKeyError) do\n      key.to_openssl_key\n    end\n\n    assert_match(/missing rsa key parameters/i, error.message)\n  end\n\n  test \"raises error for RSA key smaller than 2048 bits\" do\n    small_n = \"\\x01\" + (\"\\x00\" * 127) # 1024-bit modulus\n    parameters = @rsa_parameters.merge(-1 => small_n)\n    key = ActionPack::WebAuthn::CoseKey.new(\n      key_type: 3,\n      algorithm: -257,\n      parameters: parameters\n    )\n\n    error = assert_raises(ActionPack::WebAuthn::InvalidKeyError) do\n      key.to_openssl_key\n    end\n\n    assert_match(/at least 2048 bits/i, error.message)\n  end\nend\n"
  },
  {
    "path": "test/lib/action_pack/web_authn/public_key_credential/creation_options_test.rb",
    "content": "require \"test_helper\"\n\nclass ActionPack::WebAuthn::PublicKeyCredential::CreationOptionsTest < ActiveSupport::TestCase\n  setup do\n    @relying_party = ActionPack::WebAuthn::RelyingParty.new(id: \"example.com\", name: \"Example App\")\n    @options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(\n      id: \"user-123\",\n      name: \"user@example.com\",\n      display_name: \"Test User\",\n      relying_party: @relying_party\n    )\n  end\n\n  test \"initializes with required parameters\" do\n    assert_equal \"user-123\", @options.id\n    assert_equal \"user@example.com\", @options.name\n    assert_equal \"Test User\", @options.display_name\n    assert_equal @relying_party, @options.relying_party\n  end\n\n  test \"generates base64url encoded challenge\" do\n    assert_match(/\\A[A-Za-z0-9_-]+\\z/, @options.challenge)\n  end\n\n  test \"generates signed challenge containing nonce\" do\n    signed_message = Base64.urlsafe_decode64(@options.challenge)\n    nonce = ActionPack::WebAuthn.challenge_verifier.verified(signed_message)\n\n    assert_not_nil nonce\n    assert_equal 32, Base64.strict_decode64(nonce).bytesize\n  end\n\n  test \"as_json\" do\n    assert_equal @options.challenge, @options.as_json[:challenge]\n\n    assert_equal({ id: \"example.com\", name: \"Example App\" }, @options.as_json[:rp])\n\n    user = @options.as_json[:user]\n    assert_equal Base64.urlsafe_encode64(\"user-123\", padding: false), user[:id]\n    assert_equal \"user@example.com\", user[:name]\n    assert_equal \"Test User\", user[:displayName]\n\n    assert_equal [\n      { type: \"public-key\", alg: -7 },\n      { type: \"public-key\", alg: -8 },\n      { type: \"public-key\", alg: -257 }\n    ], @options.as_json[:pubKeyCredParams]\n\n    assert_equal \"required\", @options.as_json[:authenticatorSelection][:residentKey]\n    assert_equal true, @options.as_json[:authenticatorSelection][:requireResidentKey]\n    assert_equal \"preferred\", @options.as_json[:authenticatorSelection][:userVerification]\n  end\n\n  test \"as_json includes residentKey in authenticatorSelection\" do\n    options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(\n      id: \"user-123\",\n      name: \"user@example.com\",\n      display_name: \"Test User\",\n      resident_key: :required,\n      relying_party: @relying_party\n    )\n\n    assert_equal \"required\", options.as_json[:authenticatorSelection][:residentKey]\n    assert_equal true, options.as_json[:authenticatorSelection][:requireResidentKey]\n  end\n\n  test \"as_json excludes excludeCredentials when empty\" do\n    assert_nil @options.as_json[:excludeCredentials]\n  end\n\n  test \"as_json includes excludeCredentials\" do\n    credentials = [\n      build_credential(id: \"cred-1\", transports: [ \"usb\", \"nfc\" ]),\n      build_credential(id: \"cred-2\", transports: [ \"internal\" ])\n    ]\n\n    options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(\n      id: \"user-123\",\n      name: \"user@example.com\",\n      display_name: \"Test User\",\n      exclude_credentials: credentials,\n      relying_party: @relying_party\n    )\n\n    assert_equal [\n      { type: \"public-key\", id: \"cred-1\", transports: [ \"usb\", \"nfc\" ] },\n      { type: \"public-key\", id: \"cred-2\", transports: [ \"internal\" ] }\n    ], options.as_json[:excludeCredentials]\n  end\n\n  test \"as_json excludes attestation when none\" do\n    assert_nil @options.as_json[:attestation]\n  end\n\n  test \"as_json includes attestation when not none\" do\n    options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(\n      id: \"user-123\",\n      name: \"user@example.com\",\n      display_name: \"Test User\",\n      attestation: :direct,\n      relying_party: @relying_party\n    )\n\n    assert_equal \"direct\", options.as_json[:attestation]\n  end\n\n  test \"raises with invalid attestation preference\" do\n    assert_raises(ActionPack::WebAuthn::InvalidOptionsError) do\n      ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(\n        id: \"user-123\",\n        name: \"user@example.com\",\n        display_name: \"Test User\",\n        attestation: :invalid,\n        relying_party: @relying_party\n      )\n    end\n  end\n\n  test \"as_json excludeCredentials omits transports when empty\" do\n    options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(\n      id: \"user-123\",\n      name: \"user@example.com\",\n      display_name: \"Test User\",\n      exclude_credentials: [ build_credential(id: \"cred-1\") ],\n      relying_party: @relying_party\n    )\n\n    assert_equal [\n      { type: \"public-key\", id: \"cred-1\" }\n    ], options.as_json[:excludeCredentials]\n  end\n\n  private\n    def build_credential(id:, transports: [])\n      ActionPack::WebAuthn::PublicKeyCredential.new(\n        id: id,\n        public_key: OpenSSL::PKey::EC.generate(\"prime256v1\"),\n        sign_count: 0,\n        transports: transports\n      )\n    end\nend\n"
  },
  {
    "path": "test/lib/action_pack/web_authn/public_key_credential/request_options_test.rb",
    "content": "require \"test_helper\"\n\nclass ActionPack::WebAuthn::PublicKeyCredential::RequestOptionsTest < ActiveSupport::TestCase\n  setup do\n    @relying_party = ActionPack::WebAuthn::RelyingParty.new(id: \"example.com\", name: \"Example App\")\n    @credentials = [\n      build_credential(id: \"credential-1\"),\n      build_credential(id: \"credential-2\")\n    ]\n    @options = ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new(\n      credentials: @credentials,\n      relying_party: @relying_party\n    )\n  end\n\n  test \"initializes with required parameters\" do\n    assert_equal @credentials, @options.credentials\n    assert_equal @relying_party, @options.relying_party\n  end\n\n  test \"generates base64url encoded challenge\" do\n    assert_match(/\\A[A-Za-z0-9_-]+\\z/, @options.challenge)\n  end\n\n  test \"generates signed challenge containing nonce\" do\n    signed_message = Base64.urlsafe_decode64(@options.challenge)\n    nonce = ActionPack::WebAuthn.challenge_verifier.verified(signed_message)\n\n    assert_not_nil nonce\n    assert_equal 32, Base64.strict_decode64(nonce).bytesize\n  end\n\n  test \"as_json\" do\n    assert_equal @options.challenge, @options.as_json[:challenge]\n    assert_equal \"example.com\", @options.as_json[:rpId]\n    assert_equal [\n      { type: \"public-key\", id: \"credential-1\" },\n      { type: \"public-key\", id: \"credential-2\" }\n    ], @options.as_json[:allowCredentials]\n    assert_equal \"preferred\", @options.as_json[:userVerification]\n  end\n\n  test \"as_json includes transports when present\" do\n    credentials = [\n      build_credential(id: \"cred-1\", transports: [ \"usb\", \"nfc\" ]),\n      build_credential(id: \"cred-2\", transports: [ \"internal\" ])\n    ]\n\n    options = ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new(\n      credentials: credentials,\n      relying_party: @relying_party\n    )\n\n    assert_equal [\n      { type: \"public-key\", id: \"cred-1\", transports: [ \"usb\", \"nfc\" ] },\n      { type: \"public-key\", id: \"cred-2\", transports: [ \"internal\" ] }\n    ], options.as_json[:allowCredentials]\n  end\n\n  test \"as_json omits transports when empty\" do\n    credentials = [ build_credential(id: \"cred-1\") ]\n\n    options = ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new(\n      credentials: credentials,\n      relying_party: @relying_party\n    )\n\n    assert_equal [\n      { type: \"public-key\", id: \"cred-1\" }\n    ], options.as_json[:allowCredentials]\n  end\n\n  private\n    def build_credential(id:, transports: [])\n      ActionPack::WebAuthn::PublicKeyCredential.new(\n        id: id,\n        public_key: OpenSSL::PKey::EC.generate(\"prime256v1\"),\n        sign_count: 0,\n        transports: transports\n      )\n    end\nend\n"
  },
  {
    "path": "test/lib/action_pack/web_authn/relying_party_test.rb",
    "content": "require \"test_helper\"\n\nclass ActionPack::WebAuthn::RelyingPartyTest < ActiveSupport::TestCase\n  test \"initializes with explicit id and name\" do\n    relying_party = ActionPack::WebAuthn::RelyingParty.new(id: \"example.com\", name: \"Example App\")\n\n    assert_equal \"example.com\", relying_party.id\n    assert_equal \"Example App\", relying_party.name\n  end\n\n  test \"initializes with default id from Current.host\" do\n    ActionPack::WebAuthn::Current.set(host: \"default.example.com\") do\n      relying_party = ActionPack::WebAuthn::RelyingParty.new(name: \"Example App\")\n\n      assert_equal \"default.example.com\", relying_party.id\n    end\n  end\n\n  test \"initializes with default name from Rails application\" do\n    relying_party = ActionPack::WebAuthn::RelyingParty.new(id: \"example.com\")\n\n    assert_equal Rails.application.name, relying_party.name\n  end\n\n  test \"as_json returns id and name\" do\n    relying_party = ActionPack::WebAuthn::RelyingParty.new(id: \"example.com\", name: \"Example App\")\n\n    assert_equal({ id: \"example.com\", name: \"Example App\" }, relying_party.as_json)\n  end\nend\n"
  },
  {
    "path": "test/lib/rails_ext/action_pack_passkey_infer_name_from_aaguid_test.rb",
    "content": "require \"test_helper\"\n\nclass ActionPackPasskeyInferNameFromAaguidTest < ActiveSupport::TestCase\n  setup do\n    @identity = identities(:kevin)\n    @private_key = OpenSSL::PKey::EC.generate(\"prime256v1\")\n\n    ActionPack::WebAuthn::Current.host = \"www.example.com\"\n    ActionPack::WebAuthn::Current.origin = \"http://www.example.com\"\n  end\n\n  test \"authenticator lookup by known aaguid\" do\n    authenticator = Passkey::Authenticator.find_by_aaguid(\"dd4ec289-e01d-41c9-bb89-70fa845d4bf2\")\n\n    assert_equal \"Apple Passwords\", authenticator.name\n  end\n\n  test \"authenticator lookup returns nil for unknown aaguid\" do\n    assert_nil Passkey::Authenticator.find_by_aaguid(\"00000000-0000-0000-0000-000000000000\")\n  end\n\n  test \"authenticator lookup by aaguid on passkey\" do\n    passkey = @identity.passkeys.create!(\n      credential_id: Base64.urlsafe_encode64(SecureRandom.random_bytes(32), padding: false),\n      public_key: @private_key.public_to_der,\n      sign_count: 0,\n      aaguid: \"dd4ec289-e01d-41c9-bb89-70fa845d4bf2\"\n    )\n\n    assert_equal \"Apple Passwords\", passkey.authenticator.name\n  end\nend\n"
  },
  {
    "path": "test/lib/rails_ext/active_record_uuid_type_test.rb",
    "content": "require \"test_helper\"\n\nclass ActiveRecordUuidTypeTest < ActiveSupport::TestCase\n  setup do\n    @type = ActiveRecord::Type::Uuid.new\n    @sample_uuid = \"01jcqzx8h0000000000000000\" # base36 UUID\n  end\n\n  test \"cast nil returns nil\" do\n    assert_nil @type.cast(nil)\n  end\n\n  test \"cast returns value as-is\" do\n    result = @type.cast(@sample_uuid)\n    assert_equal @sample_uuid, result\n  end\n\n  test \"serialize returns binary Data object\" do\n    result = @type.serialize(@sample_uuid)\n\n    assert_instance_of ActiveModel::Type::Binary::Data, result\n    assert_equal 16, result.to_s.bytesize\n    assert_equal Encoding::BINARY, result.to_s.encoding\n  end\n\n  test \"serialize nil returns nil\" do\n    assert_nil @type.serialize(nil)\n  end\n\n  test \"deserialize converts binary to base36\" do\n    binary_data = @type.serialize(@sample_uuid)\n\n    result = @type.deserialize(binary_data)\n\n    assert_equal @sample_uuid, result\n  end\n\n  test \"deserialize handles raw binary string\" do\n    binary_data = @type.serialize(@sample_uuid)\n    raw_binary = binary_data.to_s\n\n    result = @type.deserialize(raw_binary)\n\n    assert_equal @sample_uuid, result\n  end\n\n  test \"deserialize nil returns nil\" do\n    assert_nil @type.deserialize(nil)\n  end\nend\n"
  },
  {
    "path": "test/lib/rails_ext/active_storage_analyze_job_skip_detached_test.rb",
    "content": "require \"test_helper\"\n\nclass ActiveStorageAnalyzeJobSkipDetachedTest < ActiveSupport::TestCase\n  test \"skips analysis when blob has no attachments\" do\n    blob = ActiveStorage::Blob.create_and_upload!(\n      io: StringIO.new(\"x\" * 1024), filename: \"orphan.txt\", content_type: \"text/plain\"\n    )\n\n    blob.expects(:analyze).never\n\n    ActiveStorage::AnalyzeJob.perform_now(blob)\n  end\n\n  test \"performs analysis when blob has attachments\" do\n    card = cards(:logo)\n    card.image.attach io: StringIO.new(\"x\" * 1024), filename: \"test.png\", content_type: \"image/png\"\n    blob = card.image.blob\n\n    blob.expects(:analyze).once\n\n    ActiveStorage::AnalyzeJob.perform_now(blob)\n  end\nend\n"
  },
  {
    "path": "test/lib/rails_ext/active_storage_blob_service_url_for_direct_upload_expiry_test.rb",
    "content": "require \"test_helper\"\n\nclass ActiveStorageBlobServiceUrlForDirectUploadExpiryTest < ActiveSupport::TestCase\n  setup do\n    @blob = ActiveStorage::Blob.new(key: \"test\", filename: \"test.txt\", byte_size: 1024, checksum: \"abc\")\n  end\n\n  test \"uses extended expiry by default\" do\n    @blob.service.expects(:url_for_direct_upload).with(@blob.key, expires_in: ActiveStorage.service_urls_for_direct_uploads_expire_in, content_type: @blob.content_type, content_length: @blob.byte_size, checksum: @blob.checksum, custom_metadata: {}).returns(\"https://example.com/upload\")\n\n    assert_equal \"https://example.com/upload\", @blob.service_url_for_direct_upload\n  end\n\n  test \"service_urls_for_direct_uploads_expire_in is configurable\" do\n    original_expire_in = ActiveStorage.service_urls_for_direct_uploads_expire_in\n    ActiveStorage.service_urls_for_direct_uploads_expire_in = 96.hours\n\n    @blob.service.expects(:url_for_direct_upload).with(@blob.key, expires_in: 96.hours, content_type: @blob.content_type, content_length: @blob.byte_size, checksum: @blob.checksum, custom_metadata: {}).returns(\"https://example.com/upload\")\n\n    assert_equal \"https://example.com/upload\", @blob.service_url_for_direct_upload\n  ensure\n    ActiveStorage.service_urls_for_direct_uploads_expire_in = original_expire_in\n  end\nend\n"
  },
  {
    "path": "test/lib/rails_ext/string_test.rb",
    "content": "require \"test_helper\"\n\nclass StringTest < ActiveSupport::TestCase\n  test \"#all_emoji?\" do\n    assert \"😊\".all_emoji?\n    assert \"😊😊😊\".all_emoji?\n\n    assert_not \"Hello 😊\".all_emoji?\n    assert_not \"Hello\".all_emoji?\n  end\nend\n"
  },
  {
    "path": "test/lib/web_push/persistent_request_test.rb",
    "content": "require \"test_helper\"\n\nclass WebPush::PersistentRequestTest < ActiveSupport::TestCase\n  PUBLIC_TEST_IP = \"142.250.185.206\"\n  ENDPOINT = \"https://fcm.googleapis.com/fcm/send/test123\"\n\n  test \"pins connection to endpoint_ip\" do\n    request = stub_request(:post, ENDPOINT)\n      .with(ipaddr: PUBLIC_TEST_IP)\n      .to_return(status: 201)\n\n    notification = WebPush::Notification.new(\n      title: \"Test\",\n      body: \"Test notification\",\n      url: \"/test\",\n      badge: 0,\n      endpoint: ENDPOINT,\n      endpoint_ip: PUBLIC_TEST_IP,\n      p256dh_key: \"BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM\",\n      auth_key: \"tBHItJI5svbpez7KI4CCXg\"\n    )\n    notification.deliver\n\n    assert_requested request\n  end\nend\n"
  },
  {
    "path": "test/mailers/.keep",
    "content": ""
  },
  {
    "path": "test/mailers/account_mailer_test.rb",
    "content": "require \"test_helper\"\n\nclass AccountMailerTest < ActionMailer::TestCase\n  setup do\n    @account = accounts(:\"37s\")\n    @user = users(:david)\n    @account.cancel(initiated_by: @user)\n    @cancellation = @account.cancellation\n  end\n\n  test \"cancellation sends to initiating user\" do\n    email = AccountMailer.cancellation(@cancellation)\n\n    assert_emails 1 do\n      email.deliver_now\n    end\n\n    assert_equal [ @user.identity.email_address ], email.to\n  end\n\n  test \"cancellation includes account name\" do\n    email = AccountMailer.cancellation(@cancellation)\n\n    assert_match @account.name, email.body.encoded\n  end\n\n  test \"cancellation includes support email\" do\n    email = AccountMailer.cancellation(@cancellation)\n\n    assert_match \"support@fizzy.do\", email.body.encoded\n  end\n\n  test \"cancellation has correct subject\" do\n    email = AccountMailer.cancellation(@cancellation)\n\n    assert_equal \"Your Fizzy account was cancelled\", email.subject\n  end\n\n  test \"cancellation has both HTML and text parts\" do\n    email = AccountMailer.cancellation(@cancellation)\n\n    assert email.html_part.present?, \"Email should have HTML part\"\n    assert email.text_part.present?, \"Email should have text part\"\n  end\n\n  test \"cancellation mentions account access is removed\" do\n    email = AccountMailer.cancellation(@cancellation)\n\n    assert_match /no one can access/i, email.body.encoded\n  end\n\n  test \"cancellation mentions data will be deleted\" do\n    email = AccountMailer.cancellation(@cancellation)\n\n    assert_match /deleted/i, email.body.encoded\n  end\nend\n"
  },
  {
    "path": "test/mailers/export_mailer_test.rb",
    "content": "require \"test_helper\"\n\nclass ExportMailerTest < ActionMailer::TestCase\n  test \"completed for account export\" do\n    export = Account::Export.create!(account: Current.account, user: users(:david))\n    email = ExportMailer.completed(export)\n\n    assert_emails 1 do\n      email.deliver_now\n    end\n\n    assert_equal [ \"david@37signals.com\" ], email.to\n    assert_equal \"Your Fizzy data export is ready for download\", email.subject\n    assert_match %r{/exports/#{export.id}}, email.body.encoded\n  end\n\n  test \"completed for user data export\" do\n    export = User::DataExport.create!(account: Current.account, user: users(:david))\n    email = ExportMailer.completed(export)\n\n    assert_emails 1 do\n      email.deliver_now\n    end\n\n    assert_equal [ \"david@37signals.com\" ], email.to\n    assert_equal \"Your Fizzy data export is ready for download\", email.subject\n    assert_match %r{/users/#{export.user.id}/data_exports/#{export.id}}, email.body.encoded\n  end\nend\n"
  },
  {
    "path": "test/mailers/import_mailer_test.rb",
    "content": "require \"test_helper\"\n\nclass ImportMailerTest < ActionMailer::TestCase\n  test \"completed\" do\n    email = ImportMailer.completed(identities(:david), accounts(:\"37s\"))\n\n    assert_emails 1 do\n      email.deliver_now\n    end\n\n    assert_equal [ \"david@37signals.com\" ], email.to\n    assert_equal \"Your Fizzy account import is done\", email.subject\n    assert_match accounts(:\"37s\").slug, email.body.encoded\n  end\n\n  test \"failed with no reason\" do\n    import = Account::Import.create!(account: Current.account, identity: identities(:david), status: :failed)\n    email = ImportMailer.failed(import)\n\n    assert_emails 1 do\n      email.deliver_now\n    end\n\n    assert_equal [ \"david@37signals.com\" ], email.to\n    assert_equal \"Your Fizzy account import failed\", email.subject\n    assert_match \"corrupted export data\", email.body.encoded\n  end\n\n  test \"failed with conflict reason\" do\n    import = Account::Import.create!(account: Current.account, identity: identities(:david), status: :failed, failure_reason: :conflict)\n    email = ImportMailer.failed(import)\n\n    assert_emails 1 do\n      email.deliver_now\n    end\n\n    assert_match \"account you are trying to import already exists\", email.body.encoded\n  end\n\n  test \"failed with invalid_export reason\" do\n    import = Account::Import.create!(account: Current.account, identity: identities(:david), status: :failed, failure_reason: :invalid_export)\n    email = ImportMailer.failed(import)\n\n    assert_emails 1 do\n      email.deliver_now\n    end\n\n    assert_match \"isn't a Fizzy account export\", email.body.encoded\n  end\nend\n"
  },
  {
    "path": "test/mailers/magic_link_mailer_test.rb",
    "content": "require \"test_helper\"\n\nclass MagicLinkMailerTest < ActionMailer::TestCase\n  test \"sign_in_instructions\" do\n    magic_link = MagicLink.create!(identity: identities(:kevin))\n    email = MagicLinkMailer.sign_in_instructions(magic_link)\n\n    assert_emails 1 do\n      email.deliver_now\n    end\n\n    assert_equal [ \"kevin@37signals.com\" ], email.to\n    assert_equal \"Your Fizzy code is #{ magic_link.code }\", email.subject\n    assert_match magic_link.code, email.body.encoded\n  end\nend\n"
  },
  {
    "path": "test/mailers/notification/bundle_mailer_test.rb",
    "content": "require \"test_helper\"\n\nclass Notification::BundleMailerTest < ActionMailer::TestCase\n  setup do\n    @user = users(:david)\n    @user.notifications.destroy_all\n\n    @bundle = Notification::Bundle.create!(\n      user: @user,\n      starts_at: 1.hour.ago,\n      ends_at: 1.hour.from_now\n    )\n  end\n\n  test \"renders avatar with initials in span when avatar is not attached\" do\n    create_notification(@user)\n\n    html = Nokogiri::HTML5(Notification::BundleMailer.notification(@bundle).html_part.body.to_s)\n\n    avatar = html.at_css(\"span.avatar\")\n    assert avatar, \"Expected a span.avatar element\"\n    assert_equal @user.initials, avatar.text.strip\n    assert_match /background-color: #[A-F0-9]{6}/, avatar[\"style\"]\n  end\n\n  test \"renders avatar with external image URL when avatar is attached\" do\n    @user.avatar.attach(\n      io: File.open(Rails.root.join(\"test\", \"fixtures\", \"files\", \"avatar.png\")),\n      filename: \"avatar.png\",\n      content_type: \"image/png\"\n    )\n\n    create_notification(@user)\n\n    html = Nokogiri::HTML5(Notification::BundleMailer.notification(@bundle).html_part.body.to_s)\n\n    avatar = html.at_css(\"img.avatar\")\n    assert avatar, \"Expected an img.avatar element\"\n    assert avatar[\"src\"].present?\n    assert_equal @user.name, avatar[\"alt\"]\n  end\n\n  test \"groups notifications by board, sorted alphabetically\" do\n    private_board = boards(:private)\n    private_card = Current.with(user: @user) do\n      private_board.cards.create!(\n        title: \"Private card\", creator: @user, status: :published, account: @user.account\n      )\n    end\n    private_event = Event.create!(\n      creator: @user, board: private_board, eventable: private_card,\n      action: :card_published, account: @user.account\n    )\n\n    create_notification(@user, source: events(:logo_published))\n    create_notification(@user, source: private_event)\n    create_notification(@user, source: events(:layout_published))\n\n    html = Nokogiri::HTML5(Notification::BundleMailer.notification(@bundle).html_part.body.to_s)\n\n    board_headers = html.css(\".notification__board\")\n    assert_equal 2, board_headers.size, \"Should have exactly two board headers\"\n    assert_equal [ \"Private board\", \"Writebook\" ], board_headers.map(&:text)\n  end\n\n  test \"board header links to the board\" do\n    create_notification(@user, source: events(:logo_published))\n\n    html = Nokogiri::HTML5(Notification::BundleMailer.notification(@bundle).html_part.body.to_s)\n    board = boards(:writebook)\n\n    link = html.at_css(\".notification__board a\")\n    assert_equal \"Writebook\", link.text\n    assert_match %r{boards/#{board.id}}, link[\"href\"]\n  end\n\n  test \"shows multiple cards under same board header\" do\n    create_notification(@user, source: events(:logo_published))\n    create_notification(@user, source: events(:layout_published))\n\n    html = Nokogiri::HTML5(Notification::BundleMailer.notification(@bundle).html_part.body.to_s)\n\n    assert_equal 1, html.css(\".notification__board\").size, \"Same board should only have one header\"\n\n    card_titles = html.css(\".card__title\").map(&:text)\n    assert_includes card_titles, \"#1 The logo isn't big enough\"\n    assert_includes card_titles, \"#2 Layout is broken\"\n  end\n\n  test \"renders inline code in card title\" do\n    cards(:logo).update_column :title, \"Fix the `bug` in production\"\n    create_notification(@user, source: events(:logo_published))\n\n    html = Nokogiri::HTML5(Notification::BundleMailer.notification(@bundle).html_part.body.to_s)\n\n    title_link = html.at_css(\".card__title\")\n    assert_equal \"#1 Fix the <code>bug</code> in production\", title_link.inner_html\n  end\n\n  test \"skips notifications whose source event was deleted\" do\n    notification = create_notification(@user)\n    notification.source.destroy\n\n    email = Notification::BundleMailer.notification(@bundle)\n    assert_not email.respond_to?(:deliver) && email.message.is_a?(Mail::Message),\n      \"Should not generate a real email when all notifications are stale\"\n  end\n\n  private\n    def create_notification(user, source: events(:logo_published))\n      Notification.create!(user: user, creator: user, source: source, created_at: 30.minutes.ago)\n    end\nend\n"
  },
  {
    "path": "test/mailers/previews/export_mailer_preview.rb",
    "content": "class ExportMailerPreview < ActionMailer::Preview\n  def completed\n    export = Account::Export.new(\n      id: \"preview-export-id\",\n      account: Account.first,\n      user: User.first\n    )\n\n    ExportMailer.completed(export)\n  end\nend\n"
  },
  {
    "path": "test/mailers/previews/magic_link_mailer_preview.rb",
    "content": "class MagicLinkMailerPreview < ActionMailer::Preview\n  def magic_link\n    identity = Identity.all.sample\n    magic_link = MagicLink.new(identity: identity)\n    magic_link.valid?\n\n    MagicLinkMailer.sign_in_instructions(magic_link)\n  end\nend\n"
  },
  {
    "path": "test/mailers/previews/notification/bundle_mailer_preview.rb",
    "content": "class Notification::BundleMailerPreview < ActionMailer::Preview\n  def notification\n    bundle = Notification::Bundle.all.sample\n    Current.account = bundle.account\n    Notification::BundleMailer.notification bundle\n  end\nend\n"
  },
  {
    "path": "test/mailers/previews/user_mailer_preview.rb",
    "content": "class UserMailerPreview < ActionMailer::Preview\n  def email_change_confirmation\n    new_email_address = \"new.email@example.com\"\n    user = User.active.sample\n    token = user.send(:generate_email_address_change_token, to: new_email_address)\n    Current.account = user.account\n\n    UserMailer.email_change_confirmation(\n      email_address: new_email_address,\n      token: token,\n      user: user\n    )\n  end\nend\n"
  },
  {
    "path": "test/mailers/smtp_delivery_error_test.rb",
    "content": "require \"test_helper\"\n\nclass SmtpDeliveryErrorTest < ActionMailer::TestCase\n  class TestMailer < ApplicationMailer\n    def smtp_syntax_error(message)\n      raise Net::SMTPSyntaxError, Net::SMTP::Response.parse(message)\n    end\n\n    def smtp_fatal_error(message)\n      raise Net::SMTPFatalError, Net::SMTP::Response.parse(message)\n    end\n\n    def ephemeral_retry\n      self.class.goes_boom_once\n    end\n\n    def self.goes_boom_once\n      # Stubbed in test to raise exception once\n    end\n  end\n  tests TestMailer\n\n  test \"deliver_later ignores bad recipient addresses\" do\n    assert_nothing_raised do\n      perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do\n        TestMailer.smtp_syntax_error(\"501 5.1.3 Bad recipient address syntax\\n\").deliver_later\n      end\n    end\n  end\n\n  test \"deliver_later ignores rejected recipient addresses\" do\n    assert_nothing_raised do\n      perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do\n        TestMailer.smtp_fatal_error(\"550 5.1.1 fooaddress: Recipient address rejected: User unknown in local recipient table\\n\").deliver_later\n      end\n    end\n  end\n\n  test \"deliver_later re-raises other SMTP syntax errors\" do\n    perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do\n      assert_raises Net::SMTPSyntaxError do\n        TestMailer.smtp_syntax_error(\"not a recipient address error\").deliver_later\n      end\n    end\n  end\n\n\n  [ Net::OpenTimeout, Net::ReadTimeout, Net::SMTPServerBusy.new(Net::SMTP::Response.parse(\"4xx Server Busy\")) ].each do |exception|\n    test \"deliver_later retries temporary #{exception}\" do\n      TestMailer.stubs(:goes_boom_once).raises(exception).then.returns(nil)\n\n      perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do\n        assert_nothing_raised do\n          TestMailer.ephemeral_retry.deliver_later\n        end\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "test/middleware/account_slug_extractor_test.rb",
    "content": "require \"test_helper\"\nrequire \"rack/mock\"\n\nclass AccountSlugExtractorTest < ActiveSupport::TestCase\n  test \"moves account prefix from PATH_INFO to SCRIPT_NAME\" do\n    account = accounts(:initech)\n    slug = AccountSlug.encode(account.external_account_id)\n\n    captured = call_with_env \"/#{slug}/boards\"\n\n    assert_equal \"/#{slug}\", captured.fetch(:script_name)\n    assert_equal \"/boards\", captured.fetch(:path_info)\n    assert_equal account.external_account_id, captured.fetch(:external_account_id)\n    assert_equal account, captured.fetch(:current_account)\n  end\n\n  test \"treats a bare account prefix as the root path\" do\n    account = accounts(:initech)\n    slug = AccountSlug.encode(account.external_account_id)\n\n    captured = call_with_env \"/#{slug}\"\n\n    assert_equal \"/#{slug}\", captured.fetch(:script_name)\n    assert_equal \"/\", captured.fetch(:path_info)\n  end\n\n  test \"detects the account prefix when already in SCRIPT_NAME\" do\n    account = accounts(:initech)\n    slug = AccountSlug.encode(account.external_account_id)\n\n    captured = call_with_env \"/boards\", \"SCRIPT_NAME\" => \"/#{slug}\"\n\n    assert_equal \"/#{slug}\", captured.fetch(:script_name)\n    assert_equal \"/boards\", captured.fetch(:path_info)\n    assert_equal account, captured.fetch(:current_account)\n  end\n\n  test \"clears Current.account when no account prefix is present\" do\n    captured = call_with_env \"/boards\"\n\n    assert_equal \"\", captured.fetch(:script_name)\n    assert_equal \"/boards\", captured.fetch(:path_info)\n    assert_nil captured.fetch(:external_account_id)\n    assert_nil captured.fetch(:current_account)\n  end\n\n  test \"encodes account IDs without zero-padding\" do\n    assert_equal \"1\", AccountSlug.encode(1)\n  end\n\n  test \"decodes both padded and non-padded slugs\" do\n    assert_equal 123, AccountSlug.decode(\"123\")\n    assert_equal 123, AccountSlug.decode(\"0000123\")\n  end\n\n  private\n    def call_with_env(path, extra_env = {})\n      captured = {}\n      extra_env = { \"action_dispatch.routes\" => Rails.application.routes }.merge(extra_env)\n\n      app = ->(env) do\n        captured[:script_name] = env[\"SCRIPT_NAME\"]\n        captured[:path_info] = env[\"PATH_INFO\"]\n        captured[:external_account_id] = env[\"fizzy.external_account_id\"]\n        captured[:current_account] = Current.account\n        [ 200, {}, [ \"ok\" ] ]\n      end\n\n      middleware = AccountSlug::Extractor.new(app)\n      middleware.call Rack::MockRequest.env_for(path, extra_env.merge(method: \"GET\"))\n\n      captured\n    end\nend\n"
  },
  {
    "path": "test/models/access_test.rb",
    "content": "require \"test_helper\"\n\nclass AccessTest < ActiveSupport::TestCase\n  test \"acesssed\" do\n    freeze_time\n\n    assert_changes -> { accesses(:writebook_kevin).reload.accessed_at }, from: nil, to: Time.current do\n      accesses(:writebook_kevin).accessed\n    end\n\n    travel 2.minutes\n\n    assert_no_changes -> { accesses(:writebook_kevin).reload.accessed_at } do\n      accesses(:writebook_kevin).accessed\n    end\n  end\n\n  test \"event notifications are destroyed when access is lost\" do\n    kevin = users(:kevin)\n    board = boards(:writebook)\n\n    # make sure we have test coverage for both cards and comments\n    assert kevin.notifications.map(&:source).map(&:eventable_type).uniq.sort == [ \"Card\", \"Comment\" ]\n\n    notifications_to_be_destroyed = kevin.notifications.select do |notification|\n      notification.card&.board == board\n    end\n    assert notifications_to_be_destroyed.any?\n\n    kevin_access = accesses(:writebook_kevin)\n\n    perform_enqueued_jobs only: Board::CleanInaccessibleDataJob do\n      kevin_access.destroy\n    end\n\n    remaining_notifications = kevin.notifications.reload.select do |notification|\n      notification.card&.board == board\n    end\n\n    assert_empty remaining_notifications\n  end\n\n  test \"mentions are destroyed when access is lost\" do\n    david = users(:david)\n    board = boards(:writebook)\n\n    # make sure we have test coverage for both cards and comments\n    assert david.mentions.map(&:source_type).uniq.sort == [ \"Card\", \"Comment\" ]\n\n    mentions_to_be_destroyed = david.mentions.select do |mention|\n      mention.card&.board == board\n    end\n    assert mentions_to_be_destroyed.any?\n\n    david_access = accesses(:writebook_david)\n\n    perform_enqueued_jobs only: Board::CleanInaccessibleDataJob do\n      david_access.destroy\n    end\n\n    remaining_mentions = david.mentions.reload.select do |mention|\n      mention.card&.board == board\n    end\n\n    assert_empty remaining_mentions\n  end\n\n  test \"watches are destroyed when access is lost\" do\n    kevin = users(:kevin)\n    board = boards(:writebook)\n    card = cards(:logo) # Kevin watches this card\n\n    assert card.watched_by?(kevin)\n\n    kevin_access = accesses(:writebook_kevin)\n\n    perform_enqueued_jobs only: Board::CleanInaccessibleDataJob do\n      kevin_access.destroy\n    end\n\n    assert_not card.watched_by?(kevin)\n  end\n\n  test \"pins are destroyed when access is lost\" do\n    kevin = users(:kevin)\n    board = boards(:writebook)\n    card = cards(:logo) # Kevin has pinned this card\n\n    other_board = boards(:miltons_wish_list)\n    other_card = cards(:radio)\n    other_board.accesses.grant_to(kevin)\n    other_card.pin_by(kevin)\n\n    assert card.pinned_by?(kevin)\n    assert other_card.pinned_by?(kevin)\n\n    kevin_access = accesses(:writebook_kevin)\n\n    perform_enqueued_jobs only: Board::CleanInaccessibleDataJob do\n      kevin_access.destroy\n    end\n\n    assert_not card.pinned_by?(kevin)\n    assert other_card.pinned_by?(kevin), \"Pin on other board should not be destroyed\"\n  end\nend\n"
  },
  {
    "path": "test/models/account/cancellable_test.rb",
    "content": "require \"test_helper\"\n\nclass Account::CancellableTest < ActiveSupport::TestCase\n  setup do\n    @account = accounts(:\"37s\")\n    @user = users(:david)\n  end\n\n  test \"cancel\" do\n    assert_difference -> { Account::Cancellation.count }, 1 do\n      assert_enqueued_with(job: ActionMailer::MailDeliveryJob) do\n        @account.cancel(initiated_by: @user)\n      end\n    end\n\n    assert @account.cancelled?\n    assert_equal @user, @account.cancellation.initiated_by\n  end\n\n  test \"cancel does nothing if already cancelled\" do\n    @account.cancel(initiated_by: @user)\n\n    assert_no_changes -> { @account.cancellation.reload.created_at } do\n      @account.cancel(initiated_by: @user)\n    end\n  end\n\n  test \"cancel does nothing when in single-tenant mode\" do\n    Account.stubs(:accepting_signups?).returns(false)\n\n    assert_no_difference -> { Account::Cancellation.count } do\n      @account.cancel(initiated_by: @user)\n    end\n\n    assert_not @account.cancelled?\n  end\n\n  test \"cancelled? returns true when cancellation exists\" do\n    assert_not @account.cancelled?\n\n    @account.cancel(initiated_by: @user)\n\n    assert @account.cancelled?\n  end\n\n  test \"reactivate\" do\n    @account.cancel(initiated_by: @user)\n\n    assert @account.cancelled?\n\n    @account.reactivate\n    @account.reload\n\n    assert_not @account.cancelled?\n    assert_nil @account.cancellation\n  end\n\n  test \"reactivate does nothing if not cancelled\" do\n    assert_not @account.cancelled?\n\n    assert_nothing_raised do\n      @account.reactivate\n    end\n\n    assert_not @account.cancelled?\n  end\n\n  test \"active scope excludes cancelled accounts\" do\n    account2 = accounts(:initech)\n\n    initial_active_count = Account.active.count\n\n    @account.cancel(initiated_by: @user)\n\n    assert_equal initial_active_count - 1, Account.active.count\n    assert_not_includes Account.active, @account\n    assert_includes Account.active, account2\n  end\nend\n"
  },
  {
    "path": "test/models/account/cancellation_test.rb",
    "content": "require \"test_helper\"\n\nclass Account::CancellationTest < ActiveSupport::TestCase\n  # test \"the truth\" do\n  #   assert true\n  # end\nend\n"
  },
  {
    "path": "test/models/account/data_transfer/action_text/rich_text_record_set_test.rb",
    "content": "require \"test_helper\"\n\nclass Account::DataTransfer::ActionText::RichTextRecordSetTest < ActiveSupport::TestCase\n  test \"check rejects ActionText record referencing existing card in another account\" do\n    importing_account = Account.create!(name: \"Importing Account\", external_account_id: 99999999)\n\n    victim_card = cards(:logo)\n    assert_not_equal importing_account.id, victim_card.account_id, \"Card must belong to a different account\"\n\n    # Create a malicious ActionText record that points to the victim's card\n    malicious_action_text_data = {\n      \"id\" => \"malicious_action_text_id_12345\",\n      \"account_id\" => importing_account.id,\n      \"record_type\" => \"Card\",\n      \"record_id\" => victim_card.id,\n      \"name\" => \"description\",\n      \"body\" => \"<p>Injected content from attacker</p>\",\n      \"created_at\" => Time.current.iso8601,\n      \"updated_at\" => Time.current.iso8601\n    }\n\n    tempfile = Tempfile.new([ \"malicious_import\", \".zip\" ])\n    tempfile.binmode\n\n    writer = ZipFile::Writer.new(tempfile)\n    writer.add_file(\"data/action_text_rich_texts/#{malicious_action_text_data['id']}.json\", malicious_action_text_data.to_json)\n    writer.close\n    tempfile.rewind\n\n    reader = ZipFile::Reader.new(tempfile)\n\n    record_set = Account::DataTransfer::ActionText::RichTextRecordSet.new(importing_account)\n    record_set.importable_model_names = %w[ ActionText::RichText Card ]\n\n    error = assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do\n      record_set.check(from: reader)\n    end\n\n    assert_match(/references existing record.*Card.*#{victim_card.id}/i, error.message)\n  ensure\n    tempfile&.close\n    tempfile&.unlink\n    importing_account&.destroy\n  end\n\n  test \"transform_body_for_import skips GIDs belonging to another account\" do\n    victim_tag = tags(:web)\n    attacker_account = accounts(:initech)\n    assert_not_equal attacker_account.id, victim_tag.account_id\n\n    cross_tenant_gid = victim_tag.to_global_id.to_s\n    html = %(<action-text-attachment gid=\"#{cross_tenant_gid}\"></action-text-attachment>)\n\n    record_set = Account::DataTransfer::ActionText::RichTextRecordSet.new(attacker_account)\n    result = record_set.send(:transform_body_for_import, html)\n\n    assert_no_match(/sgid=/, result, \"Cross-tenant GID must not be converted to SGID\")\n    assert_match(/gid=/, result, \"Original GID should remain unconverted\")\n  end\n\n  test \"transform_body_for_import converts GIDs belonging to the same account\" do\n    own_tag = tags(:web)\n    own_account = accounts(:\"37s\")\n    assert_equal own_account.id, own_tag.account_id\n\n    same_account_gid = own_tag.to_global_id.to_s\n    html = %(<action-text-attachment gid=\"#{same_account_gid}\"></action-text-attachment>)\n\n    record_set = Account::DataTransfer::ActionText::RichTextRecordSet.new(own_account)\n    result = record_set.send(:transform_body_for_import, html)\n\n    assert_match(/sgid=/, result, \"Same-account GID should be converted to SGID\")\n    assert_no_match(/ gid=/, result, \"GID should be removed after SGID conversion\")\n  end\n\n  test \"transform_body_for_import handles non-existent record GIDs gracefully\" do\n    nonexistent_gid = \"gid://fizzy/Tag/00000000000000000000000000\"\n    html = %(<action-text-attachment gid=\"#{nonexistent_gid}\"></action-text-attachment>)\n\n    record_set = Account::DataTransfer::ActionText::RichTextRecordSet.new(accounts(:\"37s\"))\n    result = record_set.send(:transform_body_for_import, html)\n\n    assert_no_match(/sgid=/, result, \"Non-existent record should not produce SGID\")\n  end\n\n  test \"replace_account_slugs rewrites relative URLs with account slug\" do\n    target_account = accounts(:\"37s\")\n    source_slug = \"9999999\"\n    target_slug = AccountSlug.encode(target_account.external_account_id)\n\n    html = %(<p>See <a href=\"/#{source_slug}/cards/42\">card 42</a></p>)\n\n    record_set = Account::DataTransfer::ActionText::RichTextRecordSet.new(target_account)\n    result = record_set.send(:transform_body_for_import, html)\n\n    assert_includes result, \"/#{target_slug}/cards/42\"\n    assert_not_includes result, source_slug\n  end\n\n  test \"replace_account_slugs leaves absolute URLs alone\" do\n    target_account = accounts(:\"37s\")\n\n    html = %(<p>See <a href=\"https://fizzy.app/9999999/boards/7\">board</a></p>)\n\n    record_set = Account::DataTransfer::ActionText::RichTextRecordSet.new(target_account)\n    result = record_set.send(:transform_body_for_import, html)\n\n    assert_includes result, \"https://fizzy.app/9999999/boards/7\"\n  end\n\n  test \"replace_account_slugs leaves plain text alone\" do\n    target_account = accounts(:\"37s\")\n\n    html = \"<p>Nothing to rewrite here</p>\"\n\n    record_set = Account::DataTransfer::ActionText::RichTextRecordSet.new(target_account)\n    result = record_set.send(:transform_body_for_import, html)\n\n    assert_equal html, result\n  end\n\n  test \"relativize_urls strips instance host from absolute URLs\" do\n    with_default_url_host(\"fizzy.example.com\") do\n      record_set = Account::DataTransfer::ActionText::RichTextRecordSet.new(accounts(:\"37s\"))\n\n      html = %(<p>See <a href=\"https://fizzy.example.com/123/cards/42\">card</a></p>)\n      result = record_set.send(:relativize_urls, html)\n\n      assert_includes result, %(/123/cards/42)\n      assert_not_includes result, \"fizzy.example.com\"\n    end\n  end\n\n  test \"relativize_urls preserves query and fragment\" do\n    with_default_url_host(\"fizzy.example.com\") do\n      record_set = Account::DataTransfer::ActionText::RichTextRecordSet.new(accounts(:\"37s\"))\n\n      html = %(<p><a href=\"https://fizzy.example.com/123/cards/42?tab=comments#comment_1\">link</a></p>)\n      result = record_set.send(:relativize_urls, html)\n\n      assert_includes result, \"/123/cards/42?tab=comments#comment_1\"\n      assert_not_includes result, \"fizzy.example.com\"\n    end\n  end\n\n  test \"relativize_urls leaves external URLs alone\" do\n    with_default_url_host(\"fizzy.example.com\") do\n      record_set = Account::DataTransfer::ActionText::RichTextRecordSet.new(accounts(:\"37s\"))\n\n      html = %(<p><a href=\"https://github.com/some/repo\">link</a></p>)\n      result = record_set.send(:relativize_urls, html)\n\n      assert_includes result, \"https://github.com/some/repo\"\n    end\n  end\n\n  test \"relativize_urls is a no-op when host is not configured\" do\n    with_default_url_host(nil) do\n      record_set = Account::DataTransfer::ActionText::RichTextRecordSet.new(accounts(:\"37s\"))\n\n      html = %(<p><a href=\"https://fizzy.example.com/123/cards/42\">link</a></p>)\n      result = record_set.send(:relativize_urls, html)\n\n      assert_includes result, \"https://fizzy.example.com/123/cards/42\"\n    end\n  end\n\n  private\n    def with_default_url_host(host)\n      options = Rails.application.routes.default_url_options\n      had_key = options.key?(:host)\n      original = options[:host]\n      options[:host] = host\n      yield\n    ensure\n      if had_key\n        options[:host] = original\n      else\n        options.delete(:host)\n      end\n    end\nend\n"
  },
  {
    "path": "test/models/account/data_transfer/active_storage/blob_record_set_test.rb",
    "content": "require \"test_helper\"\n\nclass Account::DataTransfer::ActiveStorage::BlobRecordSetTest < ActiveSupport::TestCase\n  test \"import generates fresh keys instead of using exported keys\" do\n    blob_id = ActiveRecord::Type::Uuid.generate\n    exported_key = \"original-exported-key-abc123\"\n\n    zip = build_zip_with_blob(id: blob_id, key: exported_key)\n    Account::DataTransfer::ActiveStorage::BlobRecordSet.new(Current.account).import(from: zip)\n\n    blob = ActiveStorage::Blob.find(blob_id)\n    assert_not_equal exported_key, blob.key\n    assert_equal 28, blob.key.length\n  end\n\n  test \"import preserves blob metadata\" do\n    blob_id = ActiveRecord::Type::Uuid.generate\n\n    zip = build_zip_with_blob(\n      id: blob_id,\n      key: \"some-key\",\n      filename: \"report.pdf\",\n      content_type: \"application/pdf\",\n      byte_size: 12345,\n      checksum: \"abc123checksum\"\n    )\n    Account::DataTransfer::ActiveStorage::BlobRecordSet.new(Current.account).import(from: zip)\n\n    blob = ActiveStorage::Blob.find(blob_id)\n    assert_equal \"report.pdf\", blob.filename.to_s\n    assert_equal \"application/pdf\", blob.content_type\n    assert_equal 12345, blob.byte_size\n    assert_equal \"abc123checksum\", blob.checksum\n  end\n\n  private\n    def build_zip_with_blob(id:, key:, filename: \"test.txt\", content_type: \"text/plain\", byte_size: 32, checksum: \"\")\n      tempfile = Tempfile.new([ \"test_export\", \".zip\" ])\n      tempfile.binmode\n\n      writer = ZipFile::Writer.new(tempfile)\n      writer.add_file(\"data/active_storage_blobs/#{id}.json\", {\n        id: id,\n        account_id: ActiveRecord::Type::Uuid.generate,\n        byte_size: byte_size,\n        checksum: checksum,\n        content_type: content_type,\n        created_at: Time.current.iso8601,\n        filename: filename,\n        key: key,\n        metadata: {}\n      }.to_json)\n      writer.close\n\n      tempfile.rewind\n      ZipFile::Reader.new(tempfile)\n    end\nend\n"
  },
  {
    "path": "test/models/account/data_transfer/active_storage/file_record_set_test.rb",
    "content": "require \"test_helper\"\n\nclass Account::DataTransfer::ActiveStorage::FileRecordSetTest < ActiveSupport::TestCase\n  test \"import uploads file data to blobs with regenerated keys\" do\n    blob_id = ActiveRecord::Type::Uuid.generate\n    old_key = \"original-key-for-file\"\n    file_content = \"hello world file content\"\n\n    zip = build_zip_with_blob_and_file(blob_id: blob_id, old_key: old_key, file_content: file_content)\n\n    Account::DataTransfer::ActiveStorage::BlobRecordSet.new(Current.account).import(from: zip)\n    Account::DataTransfer::ActiveStorage::FileRecordSet.new(Current.account).import(from: zip)\n\n    blob = ActiveStorage::Blob.find(blob_id)\n    assert_not_equal old_key, blob.key\n    assert_equal file_content, blob.download\n  end\n\n  test \"import handles keys containing path separators\" do\n    blob_id = ActiveRecord::Type::Uuid.generate\n    old_key = \"folder/subfolder/file-key\"\n    file_content = \"nested key content\"\n\n    zip = build_zip_with_blob_and_file(blob_id: blob_id, old_key: old_key, file_content: file_content)\n\n    Account::DataTransfer::ActiveStorage::BlobRecordSet.new(Current.account).import(from: zip)\n    Account::DataTransfer::ActiveStorage::FileRecordSet.new(Current.account).import(from: zip)\n\n    blob = ActiveStorage::Blob.find(blob_id)\n    assert_not_equal old_key, blob.key\n    assert_equal file_content, blob.download\n  end\n\n  test \"import raises IntegrityError for storage file without matching blob metadata\" do\n    blob_id = ActiveRecord::Type::Uuid.generate\n    old_key = \"key-with-metadata\"\n    orphan_key = \"orphaned-storage-key\"\n\n    zip = build_zip_with_orphaned_storage_file(\n      blob_id: blob_id,\n      old_key: old_key,\n      orphan_key: orphan_key\n    )\n\n    Account::DataTransfer::ActiveStorage::BlobRecordSet.new(Current.account).import(from: zip)\n\n    assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do\n      Account::DataTransfer::ActiveStorage::FileRecordSet.new(Current.account).import(from: zip)\n    end\n  end\n\n  test \"import raises IntegrityError when mapped blob is not found in database\" do\n    blob_id = ActiveRecord::Type::Uuid.generate\n    old_key = \"key-for-missing-blob\"\n\n    zip = build_zip_with_blob_and_file(blob_id: blob_id, old_key: old_key, file_content: \"data\")\n\n    # Import file data WITHOUT importing blob metadata first\n    assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do\n      Account::DataTransfer::ActiveStorage::FileRecordSet.new(Current.account).import(from: zip)\n    end\n  end\n\n  test \"check raises IntegrityError for storage file without matching blob metadata\" do\n    blob_id = ActiveRecord::Type::Uuid.generate\n    old_key = \"key-with-metadata\"\n    orphan_key = \"orphaned-storage-key\"\n\n    zip = build_zip_with_orphaned_storage_file(\n      blob_id: blob_id,\n      old_key: old_key,\n      orphan_key: orphan_key\n    )\n\n    assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do\n      Account::DataTransfer::ActiveStorage::FileRecordSet.new(Current.account).check(from: zip)\n    end\n  end\n\n  test \"import raises IntegrityError for duplicate blob keys in export\" do\n    blob_id_1 = ActiveRecord::Type::Uuid.generate\n    blob_id_2 = ActiveRecord::Type::Uuid.generate\n    duplicate_key = \"same-key-for-both\"\n\n    zip = build_zip_with_duplicate_keys(\n      blob_id_1: blob_id_1,\n      blob_id_2: blob_id_2,\n      key: duplicate_key\n    )\n\n    assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do\n      Account::DataTransfer::ActiveStorage::FileRecordSet.new(Current.account).import(from: zip)\n    end\n  end\n\n  private\n    def build_zip_with_blob_and_file(blob_id:, old_key:, file_content:)\n      tempfile = Tempfile.new([ \"test_export\", \".zip\" ])\n      tempfile.binmode\n\n      writer = ZipFile::Writer.new(tempfile)\n      writer.add_file(\"data/active_storage_blobs/#{blob_id}.json\", {\n        id: blob_id,\n        account_id: ActiveRecord::Type::Uuid.generate,\n        byte_size: file_content.bytesize,\n        checksum: Digest::MD5.base64digest(file_content),\n        content_type: \"text/plain\",\n        created_at: Time.current.iso8601,\n        filename: \"test.txt\",\n        key: old_key,\n        metadata: {}\n      }.to_json)\n      writer.add_file(\"storage/#{old_key}\", file_content, compress: false)\n      writer.close\n\n      tempfile.rewind\n      ZipFile::Reader.new(tempfile)\n    end\n\n    def build_zip_with_orphaned_storage_file(blob_id:, old_key:, orphan_key:)\n      tempfile = Tempfile.new([ \"test_export\", \".zip\" ])\n      tempfile.binmode\n\n      writer = ZipFile::Writer.new(tempfile)\n      writer.add_file(\"data/active_storage_blobs/#{blob_id}.json\", {\n        id: blob_id,\n        account_id: ActiveRecord::Type::Uuid.generate,\n        byte_size: 10,\n        checksum: \"\",\n        content_type: \"text/plain\",\n        created_at: Time.current.iso8601,\n        filename: \"test.txt\",\n        key: old_key,\n        metadata: {}\n      }.to_json)\n      writer.add_file(\"storage/#{old_key}\", \"file data\", compress: false)\n      writer.add_file(\"storage/#{orphan_key}\", \"orphan data\", compress: false)\n      writer.close\n\n      tempfile.rewind\n      ZipFile::Reader.new(tempfile)\n    end\n\n    def build_zip_with_duplicate_keys(blob_id_1:, blob_id_2:, key:)\n      tempfile = Tempfile.new([ \"test_export\", \".zip\" ])\n      tempfile.binmode\n\n      writer = ZipFile::Writer.new(tempfile)\n      writer.add_file(\"data/active_storage_blobs/#{blob_id_1}.json\", {\n        id: blob_id_1,\n        account_id: ActiveRecord::Type::Uuid.generate,\n        byte_size: 10,\n        checksum: \"\",\n        content_type: \"text/plain\",\n        created_at: Time.current.iso8601,\n        filename: \"file1.txt\",\n        key: key,\n        metadata: {}\n      }.to_json)\n      writer.add_file(\"data/active_storage_blobs/#{blob_id_2}.json\", {\n        id: blob_id_2,\n        account_id: ActiveRecord::Type::Uuid.generate,\n        byte_size: 10,\n        checksum: \"\",\n        content_type: \"text/plain\",\n        created_at: Time.current.iso8601,\n        filename: \"file2.txt\",\n        key: key,\n        metadata: {}\n      }.to_json)\n      writer.add_file(\"storage/#{key}\", \"file data\", compress: false)\n      writer.close\n\n      tempfile.rewind\n      ZipFile::Reader.new(tempfile)\n    end\nend\n"
  },
  {
    "path": "test/models/account/data_transfer/record_set_test.rb",
    "content": "require \"test_helper\"\n\nclass Account::DataTransfer::RecordSetTest < ActiveSupport::TestCase\n  setup do\n    @importable_model_names = %w[ Card Board Event ]\n  end\n\n  test \"check rejects polymorphic type not in the importable models allowlist\" do\n    event_data = build_event_data(eventable_type: \"Identity\")\n\n    record_set = Account::DataTransfer::RecordSet.new(account: importing_account, model: Event, importable_model_names: @importable_model_names)\n\n    error = assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do\n      record_set.check(from: build_reader(dir: \"events\", data: event_data))\n    end\n\n    assert_match(/unrecognized.*type/i, error.message)\n  end\n\n  test \"check rejects nonexistent polymorphic type\" do\n    event_data = build_event_data(eventable_type: \"Nonexistent::ClassName\")\n\n    record_set = Account::DataTransfer::RecordSet.new(account: importing_account, model: Event, importable_model_names: @importable_model_names)\n\n    error = assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do\n      record_set.check(from: build_reader(dir: \"events\", data: event_data))\n    end\n\n    assert_match(/unrecognized.*type/i, error.message)\n  end\n\n  test \"check rejects non-ActiveRecord class used as polymorphic type\" do\n    event_data = build_event_data(eventable_type: \"ActiveSupport::BroadcastLogger\")\n\n    record_set = Account::DataTransfer::RecordSet.new(account: importing_account, model: Event, importable_model_names: @importable_model_names)\n\n    error = assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do\n      record_set.check(from: build_reader(dir: \"events\", data: event_data))\n    end\n\n    assert_match(/unrecognized.*type/i, error.message)\n  end\n\n  test \"check accepts polymorphic type in the importable models allowlist\" do\n    event_data = build_event_data(eventable_type: \"Card\")\n\n    record_set = Account::DataTransfer::RecordSet.new(account: importing_account, model: Event, importable_model_names: @importable_model_names)\n\n    assert_nothing_raised do\n      record_set.check(from: build_reader(dir: \"events\", data: event_data))\n    end\n  end\n\n  private\n    def importing_account\n      @importing_account ||= Account.create!(name: \"Importing Account\", external_account_id: 99999999)\n    end\n\n    def build_event_data(eventable_type:)\n      {\n        \"id\" => \"test_event_id_12345678901234\",\n        \"account_id\" => \"nonexistent_account_id_1234567\",\n        \"board_id\" => \"nonexistent_board_id_12345678\",\n        \"creator_id\" => \"nonexistent_user_id_123456789\",\n        \"eventable_type\" => eventable_type,\n        \"eventable_id\" => \"nonexistent_id_1234567890123\",\n        \"action\" => \"created\",\n        \"particulars\" => \"{}\",\n        \"created_at\" => Time.current.iso8601,\n        \"updated_at\" => Time.current.iso8601\n      }\n    end\n\n    def build_reader(dir:, data:)\n      tempfile = Tempfile.new([ \"import_test\", \".zip\" ])\n      tempfile.binmode\n\n      writer = ZipFile::Writer.new(tempfile)\n      writer.add_file(\"data/#{dir}/#{data['id']}.json\", data.to_json)\n      writer.close\n      tempfile.rewind\n\n      @tempfiles ||= []\n      @tempfiles << tempfile\n\n      ZipFile::Reader.new(tempfile)\n    end\n\n    def teardown\n      @tempfiles&.each { |f| f.close; f.unlink }\n      @importing_account&.destroy\n    end\nend\n"
  },
  {
    "path": "test/models/account/export_test.rb",
    "content": "require \"test_helper\"\n\nclass Account::ExportTest < ActiveSupport::TestCase\n  test \"build_later enqueues DataExportJob\" do\n    export = Account::Export.create!(account: Current.account, user: users(:david))\n\n    assert_enqueued_with(job: DataExportJob, args: [ export ]) do\n      export.build_later\n    end\n  end\n\n  test \"build sets status to failed on error\" do\n    export = Account::Export.create!(account: Current.account, user: users(:david))\n    ZipFile.stubs(:create_for).raises(StandardError.new(\"Test error\"))\n\n    assert_raises(StandardError) do\n      export.build\n    end\n\n    assert export.failed?\n  end\n\n  test \"cleanup deletes exports completed more than 24 hours ago\" do\n    old_export = Account::Export.create!(account: Current.account, user: users(:david), status: :completed, completed_at: 25.hours.ago)\n    recent_export = Account::Export.create!(account: Current.account, user: users(:david), status: :completed, completed_at: 23.hours.ago)\n    pending_export = Account::Export.create!(account: Current.account, user: users(:david), status: :pending)\n\n    Export.cleanup\n\n    assert_not Export.exists?(old_export.id)\n    assert Export.exists?(recent_export.id)\n    assert Export.exists?(pending_export.id)\n  end\n\n  test \"build generates zip with account data\" do\n    export = Account::Export.create!(account: Current.account, user: users(:david))\n\n    export.build\n\n    assert export.completed?\n    assert export.file.attached?\n    assert_equal \"application/zip\", export.file.content_type\n  end\n\n  test \"build includes blob files in zip\" do\n    blob = ActiveStorage::Blob.create_and_upload!(\n      io: file_fixture(\"moon.jpg\").open,\n      filename: \"moon.jpg\",\n      content_type: \"image/jpeg\"\n    )\n    export = Account::Export.create!(account: Current.account, user: users(:david))\n\n    export.build\n\n    assert export.completed?\n    export.file.open do |file|\n      reader = ZipKit::FileReader.read_zip_structure(io: file)\n      entry = reader.find { |e| e.filename == \"storage/#{blob.key}\" }\n      assert entry, \"Expected blob file in zip\"\n    end\n  end\n\n  test \"export excludes blobs and attachments from previous exports\" do\n    first_export = Account::Export.create!(account: Current.account, user: users(:david))\n    first_export.build\n    assert first_export.completed?\n\n    first_export_blob = first_export.file.blob\n    first_export_attachment = first_export.file.attachment\n\n    second_export = Account::Export.create!(account: Current.account, user: users(:david))\n    second_export.build\n    assert second_export.completed?\n\n    second_export.file.open do |file|\n      reader = ZipKit::FileReader.read_zip_structure(io: file)\n      filenames = reader.map(&:filename)\n\n      blob_entries = filenames.select { |f| f.start_with?(\"data/active_storage_blobs/\") }\n      blob_ids = blob_entries.map { |f| File.basename(f, \".json\") }\n      assert_not_includes blob_ids, first_export_blob.id, \"Export should not include blob metadata from previous exports\"\n\n      storage_entries = filenames.select { |f| f.start_with?(\"storage/\") }\n      assert_not storage_entries.any? { |f| f == \"storage/#{first_export_blob.key}\" },\n        \"Export should not include blob file from previous exports\"\n\n      attachment_entries = filenames.select { |f| f.start_with?(\"data/active_storage_attachments/\") }\n      attachment_ids = attachment_entries.map { |f| File.basename(f, \".json\") }\n      assert_not_includes attachment_ids, first_export_attachment.id,\n        \"Export should not include attachment records from previous exports\"\n    end\n  end\n\n  test \"build succeeds when rich text references missing blob\" do\n    blob = ActiveStorage::Blob.create_and_upload!(\n      io: file_fixture(\"moon.jpg\").open,\n      filename: \"moon.jpg\",\n      content_type: \"image/jpeg\"\n    )\n    card = cards(:logo)\n    card.update!(description: \"<action-text-attachment sgid=\\\"#{blob.attachable_sgid}\\\"></action-text-attachment>\")\n    ActiveStorage::Blob.where(id: blob.id).delete_all\n\n    export = Account::Export.create!(account: Current.account, user: users(:david))\n\n    export.build\n\n    assert export.completed?\n  end\nend\n"
  },
  {
    "path": "test/models/account/external_id_sequence_test.rb",
    "content": "require \"test_helper\"\n\nclass Account::ExternalIdSequenceTest < ActiveSupport::TestCase\n  setup do\n    Account::ExternalIdSequence.delete_all\n  end\n\n  test \"generate sequential values\" do\n    first_value = Account::ExternalIdSequence.next\n    second_value = Account::ExternalIdSequence.next\n    third_value = Account::ExternalIdSequence.next\n\n    assert_equal first_value + 1, second_value\n    assert_equal second_value + 1, third_value\n  end\n\n  test \"start from the maximum existing external account id\" do\n    max_id = Account.maximum(:external_account_id) || 0\n\n    first_value = Account::ExternalIdSequence.next\n\n    assert_equal max_id + 1, first_value\n  end\n\n  test \"use a single record for the sequence\" do\n    3.times { Account::ExternalIdSequence.next }\n\n    assert_equal 1, Account::ExternalIdSequence.count\n  end\n\n  test \"handle concurrent access safely\" do\n    values = 20.times.map do\n      Thread.new do\n        Account::ExternalIdSequence.next\n      end\n    end.map(&:value)\n\n    assert_equal 20, values.uniq.size, \"All values should be unique\"\n    assert_equal values.min..values.max, values.sort.first..values.sort.last\n    assert_equal 20, values.max - values.min + 1, \"Values should be sequential with no gaps\"\n  end\n\n  test \"#value creates the first record if it doesn't yet exist\" do\n    assert_nil Account::ExternalIdSequence.first\n\n    value = nil\n    assert_difference -> { Account::ExternalIdSequence.count }, 1 do\n      value = Account::ExternalIdSequence.value\n    end\n\n    assert_not_nil value\n    assert_equal value, Account::ExternalIdSequence.first.value\n  end\nend\n"
  },
  {
    "path": "test/models/account/import_test.rb",
    "content": "require \"test_helper\"\n\nclass Account::ImportTest < ActiveSupport::TestCase\n  test \"cleanup deletes completed imports older than 24 hours\" do\n    identity = identities(:david)\n    old_completed = Account::Import.create!(account: Current.account, identity: identity, status: :completed, completed_at: 25.hours.ago)\n    recent_completed = Account::Import.create!(account: Current.account, identity: identity, status: :completed, completed_at: 23.hours.ago)\n\n    Account::Import.cleanup\n\n    assert_not Account::Import.exists?(old_completed.id)\n    assert Account::Import.exists?(recent_completed.id)\n  end\n\n  test \"cleanup destroys accounts for failed imports older than 7 days\" do\n    identity = identities(:david)\n    old_failed_account = Account.create!(name: \"Old Failed Import\")\n    old_failed = Account::Import.create!(account: old_failed_account, identity: identity, status: :failed, created_at: 8.days.ago)\n    recent_failed_account = Account.create!(name: \"Recent Failed Import\")\n    recent_failed = Account::Import.create!(account: recent_failed_account, identity: identity, status: :failed, created_at: 6.days.ago)\n\n    Account::Import.cleanup\n\n    assert_not Account::Import.exists?(old_failed.id)\n    assert_not Account.exists?(old_failed_account.id)\n    assert Account::Import.exists?(recent_failed.id)\n    assert Account.exists?(recent_failed_account.id)\n  end\n\n  test \"export and import round-trip preserves account data\" do\n    source_account = accounts(\"37s\")\n    exporter = users(:david)\n    identity = exporter.identity\n\n    source_account_digest = account_digest(source_account)\n\n    export = Account::Export.create!(account: source_account, user: exporter)\n    export.build\n\n    assert export.completed?\n\n    export_tempfile = Tempfile.new([ \"export\", \".zip\" ])\n    export.file.open { |f| FileUtils.cp(f.path, export_tempfile.path) }\n\n    source_account.destroy!\n\n    target_account = Account.create_with_owner(account: { name: \"Import Test\" }, owner: { identity: identity, name: exporter.name })\n    import = Account::Import.create!(identity: identity, account: target_account)\n    Current.set(account: target_account) do\n      import.file.attach(io: File.open(export_tempfile.path), filename: \"export.zip\", content_type: \"application/zip\")\n    end\n\n    import.check\n    assert_not import.failed?\n\n    import.process\n    assert import.completed?\n\n    assert_equal source_account_digest, account_digest(target_account)\n  ensure\n    export_tempfile&.close\n    export_tempfile&.unlink\n  end\n\n  test \"import reconciles cards count so new cards get correct numbers\" do\n    source_account = accounts(\"37s\")\n    exporter = users(:david)\n    identity = exporter.identity\n    max_card_number = source_account.cards.maximum(:number)\n\n    export = Account::Export.create!(account: source_account, user: exporter)\n    export.build\n\n    export_tempfile = Tempfile.new([ \"export\", \".zip\" ])\n    export.file.open { |f| FileUtils.cp(f.path, export_tempfile.path) }\n\n    source_account.destroy!\n\n    target_account = Account.create_with_owner(account: { name: \"Import Test\" }, owner: { identity: identity, name: exporter.name })\n    import = Account::Import.create!(identity: identity, account: target_account)\n    Current.set(account: target_account) do\n      import.file.attach(io: File.open(export_tempfile.path), filename: \"export.zip\", content_type: \"application/zip\")\n    end\n\n    import.check\n    import.process\n\n    target_account.reload\n    assert_operator target_account.cards_count, :>=, max_card_number\n  ensure\n    export_tempfile&.close\n    export_tempfile&.unlink\n  end\n\n  test \"check sets no failure_reason for unexpected errors\" do\n    import = Account::Import.create!(identity: identities(:david), account: Account.create!(name: \"Import Test\"))\n\n    assert_raises(NoMethodError) { import.check }\n\n    assert import.failed?\n    assert_nil import.failure_reason\n  end\n\n  test \"check sets failure_reason to invalid_export for non-Fizzy ZIP\" do\n    target_account = Account.create!(name: \"Import Test\")\n    import = Account::Import.create!(identity: identities(:david), account: target_account)\n\n    # Create a ZIP with no account.json\n    tempfile = Tempfile.new([ \"bad_export\", \".zip\" ])\n    tempfile.binmode\n    writer = ZipFile::Writer.new(tempfile)\n    writer.add_file(\"data/dummy.json\", '{\"hello\": \"world\"}')\n    writer.close\n    tempfile.rewind\n\n    Current.set(account: target_account) do\n      import.file.attach(io: tempfile, filename: \"export.zip\", content_type: \"application/zip\")\n    end\n\n    assert_raises(Account::DataTransfer::RecordSet::IntegrityError) { import.check }\n\n    assert import.failed?\n    assert_equal \"invalid_export\", import.failure_reason\n  ensure\n    tempfile&.close\n    tempfile&.unlink\n  end\n\n  test \"check sets failure_reason to invalid_export for non-ZIP file\" do\n    target_account = Account.create!(name: \"Import Test\")\n    import = Account::Import.create!(identity: identities(:david), account: target_account)\n\n    tempfile = Tempfile.new([ \"not_a_zip\", \".zip\" ])\n    tempfile.write(\"this is not a zip file at all\")\n    tempfile.rewind\n\n    Current.set(account: target_account) do\n      import.file.attach(io: tempfile, filename: \"export.zip\", content_type: \"application/zip\")\n    end\n\n    assert_raises(ZipFile::InvalidFileError) { import.check }\n\n    assert import.failed?\n    assert_equal \"invalid_export\", import.failure_reason\n  ensure\n    tempfile&.close\n    tempfile&.unlink\n  end\n\n  test \"check sets failure_reason to conflict when records already exist\" do\n    source_account = accounts(\"37s\")\n    exporter = users(:david)\n    identity = exporter.identity\n\n    export = Account::Export.create!(account: source_account, user: exporter)\n    export.build\n\n    export_tempfile = Tempfile.new([ \"export\", \".zip\" ])\n    export.file.open { |f| FileUtils.cp(f.path, export_tempfile.path) }\n\n    # Import without destroying the source, so records still exist\n    target_account = Account.create_with_owner(account: { name: \"Import Test\" }, owner: { identity: identity, name: exporter.name })\n    import = Account::Import.create!(identity: identity, account: target_account)\n    Current.set(account: target_account) do\n      import.file.attach(io: File.open(export_tempfile.path), filename: \"export.zip\", content_type: \"application/zip\")\n    end\n\n    assert_raises(Account::DataTransfer::RecordSet::ConflictError) { import.check }\n\n    assert import.failed?\n    assert_equal \"conflict\", import.failure_reason\n  ensure\n    export_tempfile&.close\n    export_tempfile&.unlink\n  end\n\n  test \"export and import round-trip preserves blobs and attachments\" do\n    source_account = accounts(\"37s\")\n    exporter = users(:david)\n    identity = exporter.identity\n\n    ActiveStorage::Blob.create_and_upload!(\n      io: StringIO.new(\"test image data\"),\n      filename: \"logo.png\",\n      content_type: \"image/png\"\n    )\n\n    source_blob_count = ActiveStorage::Blob.where(account: source_account).count\n    source_blob_keys = ActiveStorage::Blob.where(account: source_account).pluck(:key)\n\n    assert_operator source_blob_count, :>, 0\n\n    export = Account::Export.create!(account: source_account, user: exporter)\n    export.build\n\n    export_tempfile = Tempfile.new([ \"export\", \".zip\" ])\n    export.file.open { |f| FileUtils.cp(f.path, export_tempfile.path) }\n\n    ActiveStorage::Blob.where(account: source_account).delete_all\n    source_account.destroy!\n\n    target_account = Account.create_with_owner(account: { name: \"Import Test\" }, owner: { identity: identity, name: exporter.name })\n    import = Account::Import.create!(identity: identity, account: target_account)\n    Current.set(account: target_account) do\n      import.file.attach(io: File.open(export_tempfile.path), filename: \"export.zip\", content_type: \"application/zip\")\n    end\n\n    import.check\n    assert_not import.failed?\n\n    import.process\n    assert import.completed?\n\n    imported_blob = ActiveStorage::Blob.find_by(account: target_account, filename: \"logo.png\")\n    assert_not_nil imported_blob\n    assert_not_includes source_blob_keys, imported_blob.key\n    assert_equal \"test image data\", imported_blob.download\n  ensure\n    export_tempfile&.close\n    export_tempfile&.unlink\n  end\n\n  private\n    def account_digest(account)\n      {\n        name: account.name,\n        board_count: Board.where(account: account).count,\n        column_count: Column.where(account: account).count,\n        card_count: Card.where(account: account).count,\n        comment_count: Comment.where(account: account).count,\n        tag_count: Tag.where(account: account).count\n      }\n    end\nend\n"
  },
  {
    "path": "test/models/account/incineratable_test.rb",
    "content": "require \"test_helper\"\n\nclass Account::IncineratableTest < ActiveSupport::TestCase\n  setup do\n    @account = accounts(:\"37s\")\n    @user = users(:david)\n  end\n\n  test \"incinerate destroys account\" do\n    assert_difference -> { Account.count }, -1 do\n      @account.incinerate\n    end\n\n    assert_not Account.exists?(@account.id)\n  end\n\n  test \"due_for_incineration finds old cancellations\" do\n    @account.cancel(initiated_by: @user)\n\n    @account.cancellation.update!(created_at: 31.days.ago)\n    assert_equal [ @account ], Account.due_for_incineration\n\n    @account.cancellation.update!(created_at: 29.days.ago)\n    assert Account.due_for_incineration.empty?\n  end\nend\n"
  },
  {
    "path": "test/models/account/join_code_test.rb",
    "content": "require \"test_helper\"\n\nclass Account::JoinCodeTest < ActiveSupport::TestCase\n  test \"generate code\" do\n    join_code = Account::JoinCode.create!(account: Current.account)\n\n    assert join_code.code.present?\n\n    parts = join_code.code.split(\"-\")\n    assert_equal 3, parts.count\n  end\n\n  test \"redeem_if increments usage_count when block returns true\" do\n    join_code = account_join_codes(:\"37s\")\n\n    assert_difference -> { join_code.reload.usage_count }, +1 do\n      join_code.redeem_if { true }\n    end\n  end\n\n  test \"redeem_if does not increment usage_count when block returns false\" do\n    join_code = account_join_codes(:\"37s\")\n\n    assert_no_difference -> { join_code.reload.usage_count } do\n      join_code.redeem_if { false }\n    end\n  end\n\n  test \"reset\" do\n    join_code = account_join_codes(:\"37s\")\n    original_code = join_code.code\n\n    join_code.reset\n\n    assert_not_equal original_code, join_code.code\n    assert_equal 0, join_code.usage_count\n  end\nend\n"
  },
  {
    "path": "test/models/account/multi_tenantable_test.rb",
    "content": "require \"test_helper\"\n\nclass Account::MultiTenantableTest < ActiveSupport::TestCase\n  test \"accepting_signups? is true when multi_tenant is enabled\" do\n    with_multi_tenant_mode(true) do\n      assert Account.accepting_signups?\n    end\n  end\n\n  test \"accepting_signups? is false when multi_tenant is disabled and accounts exist\" do\n    with_multi_tenant_mode(false) do\n      assert_not Account.accepting_signups?\n    end\n  end\n\n  test \"accepting_signups? is true when multi_tenant is disabled but no accounts exist\" do\n    with_multi_tenant_mode(false) do\n      Account.delete_all\n      assert Account.accepting_signups?\n    end\n  end\nend\n"
  },
  {
    "path": "test/models/account/seedeable_test.rb",
    "content": "require \"test_helper\"\n\nclass Account::SedeableTest < ActiveSupport::TestCase\n  setup do\n    @account = Current.account\n  end\n\n  test \"setup_customer_template adds boards, cards, and comments\" do\n    assert_changes -> { Board.count } do\n      assert_changes -> { Card.count } do\n        @account.setup_customer_template\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "test/models/account_test.rb",
    "content": "require \"test_helper\"\n\nclass AccountTest < ActiveSupport::TestCase\n  test \"create\" do\n    assert_difference \"Account::JoinCode.count\", +1 do\n      Account.create!(name: \"ACME corp\")\n    end\n  end\n\n  test \"slug\" do\n    account = accounts(\"37s\")\n    assert_equal \"/#{account.external_account_id}\", account.slug\n  end\n\n  test \".create_with_owner creates a new local account\" do\n    Current.without_account do\n      identity = identities(:david)\n      account = nil\n\n      assert_changes -> { Account.count }, +1 do\n        assert_changes -> { User.count }, +2 do\n          account = Account.create_with_owner(\n            account: {\n              external_account_id: ActiveRecord::FixtureSet.identify(\"account-create-with-owner-test\"),\n              name: \"Account Create With Owner\"\n            },\n            owner: {\n              name: \"David\",\n              identity: identity\n            }\n          )\n        end\n      end\n\n      assert_not_nil account\n      assert account.persisted?\n      assert_equal ActiveRecord::FixtureSet.identify(\"account-create-with-owner-test\"), account.external_account_id\n      assert_equal \"Account Create With Owner\", account.name\n\n      owner = account.users.find_by(role: \"owner\")\n      assert_equal \"David\", owner.name\n      assert_equal \"david@37signals.com\", owner.identity.email_address\n      assert_equal \"owner\", owner.role\n      assert owner.admin?, \"owner should also be considered an admin\"\n\n      assert_predicate account.system_user, :present?\n\n      assert owner.verified?, \"owner should be verified on account creation\"\n    end\n  end\n\n  test \"#system_user returns the system user of the account\" do\n    system_user = User.find_by!(account: accounts(\"37s\"), role: :system)\n\n    assert_equal system_user, accounts(\"37s\").system_user\n  end\n\n  test \"#system_user raises if there is no system user\" do\n    account = Account.create!(name: \"No System User Account\")\n\n    assert_raises ActiveRecord::RecordNotFound do\n      account.system_user\n    end\n  end\n\n  test \"external_account_id auto-increments on creation\" do\n    account1 = Account.create!(name: \"First Account\")\n    account2 = Account.create!(name: \"Second Account\")\n\n    assert_not_nil account1.external_account_id\n    assert_not_nil account2.external_account_id\n    assert_equal account1.external_account_id + 1, account2.external_account_id\n  end\n\n  test \"external_account_id can be overridden\" do\n    custom_id = 999999\n    sequence_value_before = Account::ExternalIdSequence.first_or_create!(value: 0).value\n\n    account = Account.create!(name: \"Custom ID Account\", external_account_id: custom_id)\n\n    assert_equal custom_id, account.external_account_id\n    assert_equal sequence_value_before, Account::ExternalIdSequence.value\n  end\nend\n"
  },
  {
    "path": "test/models/application_platform_test.rb",
    "content": "require \"test_helper\"\n\nclass ApplicationPlatformTest < ActiveSupport::TestCase\n  NATIVE_ANDROID_UA = \"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Hotwire Native Android/1.0 bridge-components: [buttons overflow-menu form]\"\n  NATIVE_IOS_UA = \"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Hotwire Native iOS/1.0 bridge-components: [title]\"\n  MOBILE_WEB_IOS_UA = \"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1\"\n  DESKTOP_WEB_UA = \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\"\n  NATIVE_WITHOUT_COMPONENTS_UA = \"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Hotwire Native Android/1.0\"\n  NATIVE_WITH_EMPTY_COMPONENTS_UA = \"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Hotwire Native Android/1.0 bridge-components: []\"\n  NATIVE_WITH_MULTIPLE_LISTS_UA = \"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Hotwire Native Android/1.0 bridge-components: [buttons] metadata: [ignored]\"\n\n  test \"bridge_name returns android for native android user agents\" do\n    assert_equal :android, platform_for(NATIVE_ANDROID_UA).bridge_name\n  end\n\n  test \"bridge_name returns ios for native ios user agents\" do\n    assert_equal :ios, platform_for(NATIVE_IOS_UA).bridge_name\n  end\n\n  test \"bridge_name is nil for non-native user agents\" do\n    assert_nil platform_for(MOBILE_WEB_IOS_UA).bridge_name\n    assert_nil platform_for(DESKTOP_WEB_UA).bridge_name\n  end\n\n  test \"bridge_components returns extracted components for native user agents\" do\n    assert_equal \"buttons overflow-menu form\", platform_for(NATIVE_ANDROID_UA).bridge_components\n    assert_equal \"title\", platform_for(NATIVE_IOS_UA).bridge_components\n  end\n\n  test \"bridge_components is blank for non-native user agents\" do\n    assert_equal \"\", platform_for(MOBILE_WEB_IOS_UA).bridge_components\n    assert_equal \"\", platform_for(DESKTOP_WEB_UA).bridge_components\n  end\n\n  test \"bridge_components is blank when native user agent does not include bridge-components metadata\" do\n    assert_equal \"\", platform_for(NATIVE_WITHOUT_COMPONENTS_UA).bridge_components\n  end\n\n  test \"bridge_components supports empty lists\" do\n    assert_equal \"\", platform_for(NATIVE_WITH_EMPTY_COMPONENTS_UA).bridge_components\n  end\n\n  test \"bridge_components only matches through the first closing bracket\" do\n    assert_equal \"buttons\", platform_for(NATIVE_WITH_MULTIPLE_LISTS_UA).bridge_components\n  end\n\n  private\n    def platform_for(user_agent)\n      ApplicationPlatform.new(user_agent)\n    end\nend\n"
  },
  {
    "path": "test/models/assignment_test.rb",
    "content": "require \"test_helper\"\n\nclass AssignmentTest < ActiveSupport::TestCase\n  test \"create\" do\n    card = cards(:text)\n    assignment = card.assignments.create!(assignee: users(:david), assigner: users(:jason))\n\n    assert_equal users(:david), assignment.assignee\n    assert_equal users(:jason), assignment.assigner\n    assert_equal card, assignment.card\n  end\n\n  test \"create cannot exceed assignee limit\" do\n    card = cards(:logo)\n    board = card.board\n    account = card.account\n\n    card.assignments.delete_all\n\n    Assignment::LIMIT.times do |i|\n      identity = Identity.create!(email_address: \"limit_test_#{i}@example.com\")\n      user = account.users.create!(identity: identity, name: \"Limit Test User #{i}\", role: :member)\n      user.accesses.find_or_create_by!(board: board)\n      card.assignments.create!(assignee: user, assigner: users(:david))\n    end\n\n    assert_equal Assignment::LIMIT, card.assignments.count\n\n    identity = Identity.create!(email_address: \"over_limit@example.com\")\n    extra_user = account.users.create!(identity: identity, name: \"Over Limit User\", role: :member)\n    extra_user.accesses.find_or_create_by!(board: board)\n\n    assignment = card.assignments.build(assignee: extra_user, assigner: users(:david))\n\n    assert_not assignment.valid?\n    assert_includes assignment.errors[:base], \"Card already has the maximum of #{Assignment::LIMIT} assignees\"\n  end\nend\n"
  },
  {
    "path": "test/models/board/accessible_test.rb",
    "content": "require \"test_helper\"\n\nclass Board::AccessibleTest < ActiveSupport::TestCase\n  test \"revising access\" do\n    boards(:writebook).update! all_access: false\n\n    boards(:writebook).accesses.revise granted: users(:david, :jz), revoked: users(:kevin)\n    assert_equal users(:david, :jz).to_set, boards(:writebook).users.to_set\n\n    boards(:writebook).accesses.grant_to users(:kevin)\n    assert_includes boards(:writebook).users.reload, users(:kevin)\n\n    boards(:writebook).accesses.revoke_from users(:kevin)\n    assert_not_includes boards(:writebook).users.reload, users(:kevin)\n  end\n\n  test \"grants access to everyone after creation\" do\n    board = Current.set(session: sessions(:david), user: users(:david)) do\n      Board.create! name: \"New board\", all_access: true\n    end\n    assert_equal accounts(\"37s\").users.active.sort, board.users.sort\n  end\n\n  test \"grants access to everyone after update\" do\n    board = Current.set(session: sessions(:david), user: users(:david)) do\n      Board.create! name: \"New board\"\n    end\n    assert_equal [ users(:david) ], board.users\n\n    board.update! all_access: true\n    assert_equal accounts(\"37s\").users.active.sort, board.users.reload.sort\n  end\n\n  test \"board watchers\" do\n    boards(:writebook).access_for(users(:kevin)).watching!\n    assert_includes boards(:writebook).watchers, users(:kevin)\n\n    boards(:writebook).access_for(users(:kevin)).access_only!\n    assert_not_includes boards(:writebook).reload.watchers, users(:kevin)\n  end\n\n  # NOTE: The tests for clearing inaccessible data are in +AccessTest+\nend\n"
  },
  {
    "path": "test/models/board/cards_test.rb",
    "content": "require \"test_helper\"\n\nclass Board::CardsTest < ActiveSupport::TestCase\n  test \"touch cards when the name changes\" do\n    board = boards(:writebook)\n\n    assert_changes -> { board.cards.first.updated_at } do\n      board.update!(name: \"New Name\")\n    end\n\n    assert_no_changes -> { board.cards.first.updated_at } do\n      board.update!(updated_at: 1.hour.from_now)\n    end\n  end\nend\n"
  },
  {
    "path": "test/models/board/publishable_test.rb",
    "content": "require \"test_helper\"\n\nclass Board::PublishableTest < ActiveSupport::TestCase\n  setup do\n    Current.session = sessions(:david)\n  end\n\n  test \"published scope\" do\n    boards(:writebook).publish\n    assert_includes Board.published, boards(:writebook)\n    assert_not_includes Board.published, boards(:private)\n  end\n\n  test \"published?\" do\n    assert_not boards(:writebook).published?\n    boards(:writebook).publish\n    assert boards(:writebook).published?\n  end\n\n  test \"publish and unpublish\" do\n    assert_not boards(:writebook).published?\n\n    assert_difference -> { Board::Publication.count }, +1 do\n      boards(:writebook).publish\n    end\n\n    assert boards(:writebook).published?\n\n    assert_difference -> { Board::Publication.count }, -1 do\n      boards(:writebook).unpublish\n    end\n\n    assert_not boards(:writebook).reload.published?\n  end\n\n  test \"find board by publication key\" do\n    boards(:writebook).publish\n    assert_equal boards(:writebook), Board.find_by_published_key(boards(:writebook).publication.key)\n\n    assert_raise ActiveRecord::RecordNotFound do\n      Board.find_by_published_key(\"invalid\")\n    end\n  end\n\n  test \"touch board when publication is created\" do\n    assert_changes -> { boards(:writebook).reload.updated_at } do\n      boards(:writebook).publish\n    end\n  end\n\n  test \"touch board when publication is destroyed\" do\n    boards(:writebook).publish\n\n    assert_changes -> { boards(:writebook).reload.updated_at } do\n      boards(:writebook).unpublish\n    end\n  end\n\n  test \"publish doesn't create duplicate publications\" do\n    boards(:writebook).publish\n    original_publication = boards(:writebook).publication\n\n    assert_no_difference -> { Board::Publication.count } do\n      boards(:writebook).publish\n    end\n\n    assert_equal original_publication, boards(:writebook).reload.publication\n  end\nend\n"
  },
  {
    "path": "test/models/card/activity_spike/detector_test.rb",
    "content": "require \"test_helper\"\n\nclass Card::ActivitySpike::DetectorTest < ActiveSupport::TestCase\n  include CardActivityTestHelper\n\n  setup do\n    Current.session = sessions(:david)\n    @card = cards(:logo)\n  end\n\n  test \"detect multiple people commenting\" do\n    assert_activity_spike_detected do\n      multiple_people_comment_on(@card)\n    end\n  end\n\n  test \"detect assignments\" do\n    assert_activity_spike_detected do\n      @card.toggle_assignment users(:kevin)\n    end\n  end\n\n  test \"detect reopened cards\" do\n    assert_activity_spike_detected(card: cards(:shipping)) do\n      cards(:shipping).reopen\n    end\n  end\n\n  test \"refresh the activity spike on new spikes\" do\n    multiple_people_comment_on(@card)\n\n    @card = Card.find(@card.id)\n\n    original_last_spike_at = @card.activity_spike.updated_at\n    travel 2.months\n\n    multiple_people_comment_on(@card.reload)\n\n    assert @card.reload.activity_spike.updated_at > original_last_spike_at\n  end\n\n  test \"concurrent spike creation should not create multiple spikes for a card\" do\n    multiple_people_comment_on(@card)\n    @card.activity_spike&.destroy\n\n    5.times.map do\n      Thread.new do\n        ActiveRecord::Base.connection_pool.with_connection do\n          Card.find(@card.id).detect_activity_spikes\n        end\n      end\n    end.each(&:join)\n\n    assert_equal 1, Card::ActivitySpike.where(card: @card).count\n  end\n\n  private\n    def assert_activity_spike_detected(card: @card)\n      assert card.activity_spike.blank?\n      perform_enqueued_jobs only: Card::ActivitySpike::DetectionJob do\n        yield\n      end\n      assert card.reload.activity_spike.present?\n    end\nend\n"
  },
  {
    "path": "test/models/card/assignable_test.rb",
    "content": "require \"test_helper\"\n\nclass Card::AssignableTest < ActiveSupport::TestCase\n  test \"assigning a user makes them watch the card\" do\n    assert_not cards(:layout).assigned_to?(users(:kevin))\n    cards(:layout).unwatch_by users(:kevin)\n\n    with_current_user(:jz) do\n      cards(:layout).toggle_assignment(users(:kevin))\n    end\n\n    assert cards(:layout).assigned_to?(users(:kevin))\n    assert cards(:layout).watched_by?(users(:kevin))\n  end\n\n  test \"toggle_assignment does not add assignee when at limit\" do\n    card = cards(:logo)\n    board = card.board\n    account = card.account\n\n    card.assignments.delete_all\n\n    Assignment::LIMIT.times do |i|\n      identity = Identity.create!(email_address: \"toggle_test_#{i}@example.com\")\n      user = account.users.create!(identity: identity, name: \"Toggle Test User #{i}\", role: :member)\n      user.accesses.find_or_create_by!(board: board)\n      card.assignments.create!(assignee: user, assigner: users(:david))\n    end\n\n    identity = Identity.create!(email_address: \"toggle_over@example.com\")\n    extra_user = account.users.create!(identity: identity, name: \"Toggle Over User\", role: :member)\n    extra_user.accesses.find_or_create_by!(board: board)\n\n    with_current_user(:david) do\n      assert_no_difference \"card.assignments.count\" do\n        card.toggle_assignment(extra_user)\n      end\n    end\n\n    assert_not card.reload.assigned_to?(extra_user)\n  end\nend\n"
  },
  {
    "path": "test/models/card/closeable_test.rb",
    "content": "require \"test_helper\"\n\nclass Card::CloseableTest < ActiveSupport::TestCase\n  setup do\n    Current.session = sessions(:david)\n  end\n\n  test \"closed scope\" do\n    assert_equal [ cards(:shipping) ], Card.closed\n    assert_not_includes Card.open, cards(:shipping)\n  end\n\n  test \"close cards\" do\n    assert_not cards(:logo).closed?\n\n    assert_difference -> { cards(:logo).events.count }, +1 do\n      cards(:logo).close(user: users(:kevin))\n    end\n\n    assert cards(:logo).closed?\n    assert cards(:logo).events.last.action.card_closed?\n    assert_equal users(:kevin), cards(:logo).closed_by\n  end\n\n  test \"reopen cards\" do\n    assert cards(:shipping).closed?\n\n    assert_difference -> { cards(:shipping).events.count }, +1 do\n      cards(:shipping).reopen\n    end\n    assert cards(:shipping).reload.open?\n    assert cards(:shipping).events.last.action.card_reopened?\n  end\n\n  test \"close card from triage column\" do\n    card = cards(:logo)\n    assert_equal columns(:writebook_triage), card.column\n\n    card.close\n    assert card.closed?\n  end\n\n  test \"close card from active column\" do\n    card = cards(:text)\n    assert_equal columns(:writebook_in_progress), card.column\n\n    card.close\n    assert card.closed?\n  end\n\n  test \"close card from NOT NOW\" do\n    card = cards(:logo)\n\n    card.postpone\n    assert card.postponed?\n    assert card.not_now.present?\n\n    card.close\n    assert card.closed?\n    assert_nil card.reload.not_now\n  end\nend\n"
  },
  {
    "path": "test/models/card/colored_test.rb",
    "content": "require \"test_helper\"\n\nclass Card::ColoredTest < ActiveSupport::TestCase\n  test \"use default color if no column\" do\n    cards(:logo).update! column: nil\n    assert_equal Column::Colored::DEFAULT_COLOR, cards(:logo).color\n  end\n\n  test \"infer color from column\" do\n    assert_equal cards(:layout).column.color, cards(:layout).color\n  end\nend\n"
  },
  {
    "path": "test/models/card/commentable_test.rb",
    "content": "require \"test_helper\"\n\nclass Card::CommentableTest < ActiveSupport::TestCase\n  setup do\n    Current.session = sessions(:david)\n  end\n\n  test \"capturing comments\" do\n    assert_difference -> { cards(:logo).comments.count }, +1 do\n      cards(:logo).comments.create!(body: \"Agreed.\")\n    end\n\n    assert_equal \"Agreed.\", cards(:logo).comments.last.body.to_plain_text.chomp\n  end\n\n  test \"creating a comment on a card makes the creator watch the card\" do\n    boards(:writebook).access_for(users(:kevin)).access_only!\n    assert_not cards(:text).watched_by?(users(:kevin))\n\n    with_current_user(:kevin) do\n      cards(:text).comments.create!(body: \"This sounds interesting!\")\n    end\n\n    assert cards(:text).watched_by?(users(:kevin))\n  end\n\n  test \"commentable is true for published cards, false for drafts\" do\n    assert cards(:logo).commentable?\n    assert_not cards(:unfinished_thoughts).commentable?\n  end\nend\n"
  },
  {
    "path": "test/models/card/entropic_test.rb",
    "content": "require \"test_helper\"\n\nclass Card::EntropicTest < ActiveSupport::TestCase\n  setup do\n    Current.session = sessions(:david)\n  end\n\n  test \"auto_postpone_at uses the period defined in the account by default\" do\n    freeze_time\n\n    entropies(:writebook_board).destroy\n    entropies(\"37s_account\").reload.update! auto_postpone_period: 365.days\n    cards(:layout).update! last_active_at: 2.day.ago\n    assert_equal (365 - 2).days.from_now, cards(:layout).entropy.auto_clean_at\n  end\n\n  test \"auto_postpone_at infers the period from the board when present\" do\n    freeze_time\n\n    entropies(:writebook_board).update! auto_postpone_period: 90.days\n    cards(:layout).update! last_active_at: 2.day.ago\n    assert_equal (90 - 2).days.from_now, cards(:layout).entropy.auto_clean_at\n  end\n\n  test \"setting auto_postpone_period in the board without entropy will create it, without affecting the account entropy\" do\n    account_entropy = entropies(\"37s_account\")\n    original_period = account_entropy.auto_postpone_period\n\n    entropies(:writebook_board).destroy\n    boards(:writebook).update! auto_postpone_period: 365.days\n\n    assert_equal original_period, account_entropy.reload.auto_postpone_period\n  end\n\n  test \"auto postpone all due using the default account entropy\" do\n    entropies(:writebook_board).destroy\n\n    cards(:logo).update!(last_active_at: 1.day.ago - entropies(\"37s_account\").auto_postpone_period)\n    cards(:shipping).update!(last_active_at: 1.day.from_now - entropies(\"37s_account\").auto_postpone_period)\n\n    assert_difference -> { Card.postponed.count }, +1 do\n      Card.auto_postpone_all_due\n    end\n\n    assert cards(:logo).reload.postponed?\n    assert_equal accounts(\"37s\").system_user, cards(:logo).postponed_by\n    assert_not cards(:shipping).reload.postponed?\n  end\n\n  test \"auto postpone all due using entropy defined at the board level\" do\n    cards(:logo).update!(last_active_at: 1.day.ago - entropies(:writebook_board).auto_postpone_period)\n    cards(:shipping).update!(last_active_at: 1.day.from_now - entropies(:writebook_board).auto_postpone_period)\n\n    assert_difference -> { Card.postponed.count }, +1 do\n      Card.auto_postpone_all_due\n    end\n\n    assert cards(:logo).reload.postponed?\n    assert_not cards(:shipping).reload.postponed?\n  end\n\n  test \"postponing soon scope\" do\n    cards(:logo, :shipping).each(&:published!)\n\n    cards(:logo).update!(last_active_at: entropies(:writebook_board).auto_postpone_period.seconds.ago + 2.days)\n    cards(:shipping).update!(last_active_at: entropies(:writebook_board).auto_postpone_period.seconds.ago - 2.days)\n\n    assert_includes Card.postponing_soon, cards(:logo)\n    assert_not_includes Card.postponing_soon, cards(:shipping)\n  end\n\n  test \"due_to_be_postponed scope works properly cross-account\" do\n    cards(:logo).update!(last_active_at: entropies(:writebook_board).auto_postpone_period.seconds.ago - 2.days)\n    cards(:radio).update!(last_active_at: entropies(:miltons_wish_list_board).auto_postpone_period.seconds.ago - 2.days)\n\n    assert_equal(cards(:logo, :radio).to_set, Card.due_to_be_postponed.to_set)\n  end\n\n  test \"postponing_soon scope works properly cross-account\" do\n    cards(:logo).update!(last_active_at: entropies(:writebook_board).auto_postpone_period.seconds.ago + 2.days)\n    cards(:radio).update!(last_active_at: entropies(:miltons_wish_list_board).auto_postpone_period.seconds.ago + 2.days)\n\n    assert_equal(cards(:logo, :radio).to_set, Card.postponing_soon.to_set)\n  end\nend\n"
  },
  {
    "path": "test/models/card/eventable/system_commenter_test.rb",
    "content": "require \"test_helper\"\n\nclass Card::Eventable::SystemCommenterTest < ActiveSupport::TestCase\n  setup do\n    Current.session = sessions(:david)\n    @card = cards(:text)\n  end\n\n  test \"card_assigned\" do\n    assert_system_comment \"David assigned this to Kevin\" do\n      @card.toggle_assignment users(:kevin)\n    end\n  end\n\n  test \"card_unassigned\" do\n    @card.toggle_assignment users(:kevin)\n    @card.comments.destroy_all # To skip deduplication logic\n\n    assert_system_comment \"David unassigned from Kevin\" do\n      @card.toggle_assignment users(:kevin)\n    end\n  end\n\n  test \"card_closed\" do\n    assert_system_comment \"Moved to “Done” by David\" do\n      @card.close\n    end\n  end\n\n  test \"card_title_changed\" do\n    assert_system_comment \"David changed the title from “The text is too small” to “Make text larger”\" do\n      @card.update! title: \"Make text larger\"\n    end\n  end\n\n  test \"escapes html in comment body\" do\n    users(:david).update! name: \"<em>Injected</em>\"\n    Current.session = sessions(:david)\n\n    assert_difference -> { @card.comments.count }, 1 do\n      @card.toggle_assignment users(:kevin)\n    end\n\n    comment = @card.comments.last\n    html = comment.body.body.to_html\n    assert_includes html, \"&lt;em&gt;Injected&lt;/em&gt; <strong>assigned</strong> this to Kevin.\"\n    refute_includes html, \"<em>Injected</em>\"\n  end\n\n  test \"don't notify on system comments\" do\n    @card.watch_by(users(:david))\n\n    assert_no_difference -> { Notification.count } do\n      @card.toggle_assignment users(:kevin)\n    end\n  end\n\n  private\n    def assert_system_comment(expected_comment)\n      assert_difference -> { @card.comments.count }, 1 do\n        yield\n        comment = @card.comments.last\n        assert comment.creator.system?\n        assert_match Regexp.new(expected_comment.strip, Regexp::IGNORECASE), comment.body.to_plain_text.strip\n      end\n    end\nend\n"
  },
  {
    "path": "test/models/card/eventable_test.rb",
    "content": "require \"test_helper\"\n\nclass Card::EventableTest < ActiveSupport::TestCase\n  setup do\n    Current.session = sessions(:david)\n  end\n\n  test \"new cards default last_active_at to created_at\" do\n    freeze_time\n\n    card = boards(:writebook).cards.create!(title: \"Some card\", creator: users(:david))\n    assert_equal card.created_at, card.last_active_at\n  end\n\n  test \"new cards with custom created_at default last_active_at to that time\" do\n    custom_time = 1.week.ago.change(usec: 0)\n\n    card = boards(:writebook).cards.create!(title: \"Backdated card\", creator: users(:david), created_at: custom_time)\n    assert_equal custom_time, card.created_at\n    assert_equal custom_time, card.last_active_at\n  end\n\n  test \"new cards preserve explicit last_active_at\" do\n    created_time = 2.weeks.ago.change(usec: 0)\n    last_active_time = 1.week.ago.change(usec: 0)\n\n    card = boards(:writebook).cards.create! \\\n      title: \"Card with explicit timestamps\",\n      creator: users(:david),\n      created_at: created_time,\n      last_active_at: last_active_time\n\n    assert_equal created_time, card.created_at\n    assert_equal last_active_time, card.last_active_at\n  end\n\n  test \"publishing a card does not overwrite last_active_at\" do\n    created_time = 2.weeks.ago.change(usec: 0)\n    last_active_time = 1.week.ago.change(usec: 0)\n\n    card = boards(:writebook).cards.create! \\\n      title: \"Published card\",\n      creator: users(:david),\n      status: :published,\n      created_at: created_time,\n      last_active_at: last_active_time\n\n    assert_equal last_active_time, card.last_active_at\n  end\n\n  test \"tracking events update the last activity time\" do\n    travel_to Time.current\n\n    cards(:logo).close\n    assert_equal Time.current, cards(:logo).last_active_at\n  end\nend\n"
  },
  {
    "path": "test/models/card/exportable_test.rb",
    "content": "require \"test_helper\"\n\nclass Card::ExportableTest < ActiveSupport::TestCase\n  test \"export_json returns card data as JSON\" do\n    card = cards(:logo)\n\n    json = JSON.parse(card.export_json)\n\n    assert_equal 1, json[\"number\"]\n    assert_equal \"The logo isn't big enough\", json[\"title\"]\n    assert_equal \"Writebook\", json[\"board\"]\n    assert_equal \"Triage\", json[\"status\"]\n    assert_equal users(:david).id, json[\"creator\"][\"id\"]\n    assert_equal \"David\", json[\"creator\"][\"name\"]\n    assert_equal \"david@37signals.com\", json[\"creator\"][\"email\"]\n    assert_equal \"\", json[\"description\"]\n    assert_equal 5, json[\"comments\"].count\n    assert_equal card.created_at.iso8601, json[\"created_at\"]\n    assert_equal card.updated_at.iso8601, json[\"updated_at\"]\n  end\n\n  test \"export_attachments returns attachment paths and blobs\" do\n    card = cards(:logo)\n\n    blob = ActiveStorage::Blob.create_and_upload!(\n      io: file_fixture(\"moon.jpg\").open,\n      filename: \"moon.jpg\",\n      content_type: \"image/jpeg\"\n    )\n    attachment_html = ActionText::Attachment.from_attachable(blob).to_html\n    card.update!(description: \"<p>Here is an image:</p>#{attachment_html}\")\n\n    attachments = card.export_attachments\n\n    assert_equal 1, attachments.count\n    assert_equal file_fixture(\"moon.jpg\").binread, attachments.first[:blob].download\n    assert attachments.first[:path].start_with?(\"#{card.number}/\")\n  end\nend\n"
  },
  {
    "path": "test/models/card/golden_test.rb",
    "content": "require \"test_helper\"\n\nclass Card::GoldenTest < ActiveSupport::TestCase\n  setup do\n    Current.session = sessions(:david)\n    @golden, @non_golden = cards(:logo), cards(:text)\n  end\n\n  test \"check whether a card is golden\" do\n    assert @golden.golden?\n    assert_not @non_golden.golden?\n  end\n\n  test \"promote and demote from golden\" do\n    assert_changes -> { @non_golden.reload.golden? }, to: true do\n      @non_golden.gild\n    end\n\n    assert_changes -> { @golden.reload.golden? }, to: false do\n      @golden.ungild\n    end\n  end\n\n  test \"scopes\" do\n    assert_includes Card.golden, @golden\n    assert_not_includes Card.golden, @non_golden\n  end\n\n  test \"with_golden_first orders golden first\" do\n    ordered = Card.where(id: [ @golden.id, @non_golden.id ]).with_golden_first\n    assert_equal [ @golden, @non_golden ], ordered.to_a\n\n    # Preserves base ordering as subordering\n    @non_golden.gild\n    base_ordered = Card.where(id: [ @golden.id, @non_golden.id ]).order(id: :desc).to_a\n    with_golden = Card.where(id: [ @golden.id, @non_golden.id ]).order(id: :desc).with_golden_first.to_a\n    assert_equal base_ordered, with_golden\n  end\n\n  test \"gilding a card touches both the card and the board\" do\n    board = @non_golden.board\n\n    card_updated_at = @non_golden.updated_at\n    board_updated_at = board.updated_at\n\n    travel 1.minute do\n      @non_golden.gild\n    end\n\n    assert @non_golden.reload.updated_at > card_updated_at\n    assert board.reload.updated_at > board_updated_at\n  end\nend\n"
  },
  {
    "path": "test/models/card/messages_test.rb",
    "content": "require \"test_helper\"\n\nclass Card::MessagesTest < ActiveSupport::TestCase\n  test \"creating a card does not create a message by default\" do\n    card = boards(:writebook).cards.create! creator: users(:kevin), title: \"New\"\n\n    assert_empty card.comments\n  end\nend\n"
  },
  {
    "path": "test/models/card/pinnable_test.rb",
    "content": "require \"test_helper\"\n\nclass Card::PinnableTest < ActiveSupport::TestCase\n  setup do\n    Current.session = sessions(:david)\n  end\n\n  test \"broadcasts pin update when title changes\" do\n    assert_broadcasted_pin_update do\n      cards(:logo).update!(title: \"New title\")\n    end\n  end\n\n  test \"broadcasts pin update when column changes\" do\n    assert_broadcasted_pin_update do\n      cards(:logo).update!(column: columns(:writebook_in_progress))\n    end\n  end\n\n  test \"broadcasts pin update when board changes\" do\n    assert_broadcasted_pin_update do\n      cards(:logo).update!(board: boards(:private), column: nil)\n    end\n  end\n\n  test \"does not broadcast pin update when other properties change\" do\n    perform_enqueued_jobs do\n      assert_turbo_stream_broadcasts([ pins(:logo_kevin).user, :pins_tray ], count: 0) do\n        cards(:logo).update!(last_active_at: Time.current)\n      end\n    end\n  end\n\n  private\n    def assert_broadcasted_pin_update(&block)\n      perform_enqueued_jobs do\n        assert_turbo_stream_broadcasts([ pins(:logo_kevin).user, :pins_tray ], &block)\n      end\n    end\nend\n"
  },
  {
    "path": "test/models/card/postponable_test.rb",
    "content": "require \"test_helper\"\n\nclass Card::PostponableTest < ActiveSupport::TestCase\n  setup do\n    Current.session = sessions(:david)\n  end\n\n  test \"check the postponed status of a card\" do\n    card = cards(:logo)\n\n    assert_not card.postponed?\n    assert card.active?\n\n    card.postpone\n    assert card.postponed?\n    assert_not card.active?\n  end\n\n  test \"postpone and resume a card\" do\n    card = cards(:text)\n\n    assert_changes -> { card.reload.postponed? }, to: true do\n      assert_difference -> { card.events.count }, +1 do\n        card.postpone\n      end\n    end\n\n    assert_equal users(:david), card.not_now.user\n    assert card.events.last.action.card_postponed?\n\n    assert_changes -> { card.reload.postponed? }, to: false do\n      card.resume\n    end\n  end\n\n  test \"auto_postpone a card\" do\n    card = cards(:text)\n\n    assert_changes -> { card.reload.postponed? }, to: true do\n      assert_difference -> { card.events.count }, +1 do\n        card.auto_postpone\n      end\n    end\n\n    assert card.events.last.action.card_auto_postponed?\n  end\n\n  test \"scopes\" do\n    logo = cards(:logo)\n    text = cards(:text)\n\n    logo.postpone\n\n    assert_includes Card.postponed, logo\n    assert_not_includes Card.postponed, text\n\n    assert_includes Card.active, text\n    assert_not_includes Card.active, logo\n  end\nend\n"
  },
  {
    "path": "test/models/card/readable_test.rb",
    "content": "require \"test_helper\"\n\nclass Card::ReadableTest < ActiveSupport::TestCase\n  test \"read marks notification as read\" do\n    assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do\n      cards(:logo).read_by(users(:kevin))\n    end\n  end\n\n  test \"read marks mention notification as read\" do\n    assert_changes -> { notifications(:logo_mentioned_david).reload.read? }, from: false, to: true do\n      cards(:logo).read_by(users(:david))\n    end\n  end\n\n  test \"read marks comment notification as read\" do\n    assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: false, to: true do\n      cards(:layout).read_by(users(:kevin))\n    end\n  end\n\n  test \"unread marks notification as unread\" do\n    notifications(:logo_assignment_kevin).read\n\n    assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: true, to: false do\n      cards(:logo).unread_by(users(:kevin))\n    end\n  end\n\n  test \"unread marks mention notification as unread\" do\n    notifications(:logo_mentioned_david).read\n\n    assert_changes -> { notifications(:logo_mentioned_david).reload.read? }, from: true, to: false do\n      cards(:logo).unread_by(users(:david))\n    end\n  end\n\n  test \"unread marks comment notification as unread\" do\n    notifications(:layout_commented_kevin).read\n\n    assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: true, to: false do\n      cards(:layout).unread_by(users(:kevin))\n    end\n  end\n\n  test \"remove inaccessible notifications\" do\n    card = cards(:logo)\n    kevin = users(:kevin)\n    david = users(:david)\n\n    assert card.accessible_to?(kevin)\n    kevin_notification = notifications(:logo_assignment_kevin)\n    david_notification = notifications(:logo_mentioned_david)\n\n    # Kevin loses access\n    card.board.accesses.find_by(user: kevin).destroy\n    assert_not card.accessible_to?(kevin)\n    assert card.accessible_to?(david)\n\n    card.remove_inaccessible_notifications\n\n    # Kevin's notification removed\n    assert_not Notification.exists?(kevin_notification.id)\n\n    # David's notification preserved\n    assert Notification.exists?(david_notification.id)\n  end\nend\n"
  },
  {
    "path": "test/models/card/searchable_test.rb",
    "content": "require \"test_helper\"\n\nclass Card::SearchableTest < ActiveSupport::TestCase\n  include SearchTestHelper\n\n  test \"searchable? returns true for published cards\" do\n    card = @board.cards.create!(title: \"Published Card\", status: \"published\", creator: @user)\n    assert card.searchable?\n  end\n\n  test \"searchable? returns false for draft cards\" do\n    card = @board.cards.create!(title: \"Draft Card\", status: \"drafted\", creator: @user)\n    assert_not card.searchable?\n  end\n\n  test \"card search\" do\n    # Searching by title\n    card = @board.cards.create!(title: \"layout is broken\", status: \"published\", creator: @user)\n    results = Card.mentioning(\"layout\", user: @user)\n    assert_includes results, card\n\n    # Searching by comment\n    card_with_comment = @board.cards.create!(title: \"Some card\", status: \"published\", creator: @user)\n    card_with_comment.comments.create!(body: \"overflowing text\", creator: @user)\n    results = Card.mentioning(\"overflowing\", user: @user)\n    assert_includes results, card_with_comment\n\n    # Sanitizing search query\n    card_broken = @board.cards.create!(title: \"broken layout\", status: \"published\", creator: @user)\n    results = Card.mentioning(\"broken \\\"\", user: @user)\n    assert_includes results, card_broken\n\n    # Empty query returns no results\n    assert_empty Card.mentioning(\"\\\"\", user: @user)\n\n    # Filtering by board_ids\n    other_board = Board.create!(name: \"Other Board\", account: @account, creator: @user)\n    card_in_board = @board.cards.create!(title: \"searchable content\", status: \"published\", creator: @user)\n    card_in_other_board = other_board.cards.create!(title: \"searchable content\", status: \"published\", creator: @user)\n    results = Card.mentioning(\"searchable\", user: @user)\n    assert_includes results, card_in_board\n    assert_not_includes results, card_in_other_board\n  end\n\n  test \"search content is truncated to a reasonable limit\" do\n    search_record_class = Search::Record.for(@user.account_id)\n\n    # Create a card with unreasonably long content\n    long_content = \"asdf \" * Searchable::SEARCH_CONTENT_LIMIT\n    card = @board.cards.create!(title: \"Card with long description\", status: \"published\", creator: @user)\n    card.description = ActionText::Content.new(long_content)\n    card.save!\n\n    # Check if was indexed\n    results = Card.mentioning(\"asdf\", user: @user)\n    assert_includes results, card\n\n    # Check the content length was within the limit\n    search_record = search_record_class.find_by(searchable_type: \"Card\", searchable_id: card.id)\n    assert search_record.content.bytesize <= Searchable::SEARCH_CONTENT_LIMIT\n  end\n\n  test \"deleting card removes search record and FTS entry\" do\n    search_record_class = Search::Record.for(@user.account_id)\n    card = @board.cards.create!(title: \"Card to delete\", status: \"published\", creator: @user)\n\n    # Verify search record exists\n    search_record = search_record_class.find_by(searchable_type: \"Card\", searchable_id: card.id)\n    assert_not_nil search_record, \"Search record should exist after card creation\"\n\n    # For SQLite, verify FTS entry exists\n    if search_record_class.connection.adapter_name == \"SQLite\"\n      fts_entry = search_record.search_records_fts\n      assert_not_nil fts_entry, \"FTS entry should exist\"\n      assert_equal card.title, fts_entry.title\n    end\n\n    # Delete the card\n    card.destroy\n\n    # Verify search record is deleted\n    search_record = search_record_class.find_by(searchable_type: \"Card\", searchable_id: card.id)\n    assert_nil search_record, \"Search record should be deleted after card deletion\"\n\n    # For SQLite, verify FTS entry is deleted\n    if search_record_class.connection.adapter_name == \"SQLite\"\n      fts_count = Search::Record::SQLite::Fts.where(rowid: card.id).count\n      assert_equal 0, fts_count, \"FTS entry should be deleted\"\n    end\n  end\n\n  test \"updating a draft card does not index it\" do\n    search_record_class = Search::Record.for(@user.account_id)\n\n    card = @board.cards.create!(title: \"Draft card\", creator: @user, status: \"drafted\")\n    assert_nil search_record_class.find_by(searchable_type: \"Card\", searchable_id: card.id)\n\n    card.update!(title: \"Updated draft card\")\n    assert_nil search_record_class.find_by(searchable_type: \"Card\", searchable_id: card.id),\n      \"Draft card should not be indexed after update\"\n\n    results = Card.mentioning(\"Updated\", user: @user)\n    assert_not_includes results, card\n  end\n\n  test \"publishing a draft card indexes it\" do\n    search_record_class = Search::Record.for(@user.account_id)\n\n    card = @board.cards.create!(title: \"Draft to publish\", creator: @user, status: \"drafted\")\n    assert_nil search_record_class.find_by(searchable_type: \"Card\", searchable_id: card.id)\n\n    card.publish\n    search_record = search_record_class.find_by(searchable_type: \"Card\", searchable_id: card.id)\n    assert_not_nil search_record, \"Published card should be indexed\"\n    assert_equal card.id, search_record.card_id\n\n    results = Card.mentioning(\"publish\", user: @user)\n    assert_includes results, card\n  end\n\n  test \"unpublishing a draft card removes it from the search index\" do\n    search_record_class = Search::Record.for(@user.account_id)\n\n    card = @board.cards.create!(title: \"Draft to publish\", creator: @user, status: \"published\")\n    assert_not_nil search_record_class.find_by(searchable_type: \"Card\", searchable_id: card.id)\n\n    card.update!(status: \"drafted\")\n\n    assert_nil search_record_class.find_by(searchable_type: \"Card\", searchable_id: card.id)\n    results = Card.mentioning(\"publish\", user: @user)\n    assert_not_includes results, card\n  end\nend\n"
  },
  {
    "path": "test/models/card/stallable_test.rb",
    "content": "require \"test_helper\"\n\nclass Card::StallableTest < ActiveSupport::TestCase\n  include CardActivityTestHelper\n\n  setup do\n    Current.session = sessions(:david)\n  end\n\n  test \"a card without activity spike is not stalled\" do\n    assert_not cards(:logo).stalled?\n    assert_not_includes Card.stalled, cards(:logo)\n  end\n\n  test \"a card with a recent activity spike is not stalled\" do\n    cards(:logo).create_activity_spike!\n\n    assert_not cards(:logo).stalled?\n    assert_not_includes Card.stalled, cards(:logo)\n  end\n\n  test \"a card with an old activity spike is stalled\" do\n    cards(:logo).create_activity_spike!\n\n    travel_to 3.months.from_now\n\n    assert cards(:logo).stalled?\n    assert_includes Card.stalled, cards(:logo)\n  end\n\n  test \"a stalled card can be unstalled with a single comment\" do\n    cards(:logo).create_activity_spike!\n\n    travel_to 3.months.from_now\n\n    assert cards(:logo).stalled?\n    assert_includes Card.stalled, cards(:logo)\n\n    cards(:logo).comments.create!(body: \"A new comment to unstall the card\")\n\n    assert_not cards(:logo).stalled?\n    assert_not_includes Card.stalled, cards(:logo)\n\n    # and stalls again after more time passes\n    travel_to 3.months.from_now\n\n    assert cards(:logo).stalled?\n    assert_includes Card.stalled, cards(:logo)\n  end\n\n  test \"a card with an old activity spike is not stalled after being postponed\" do\n    card = cards(:logo)\n    card.create_activity_spike!\n\n    travel_to 3.months.from_now\n\n    assert card.stalled?\n    assert_includes Card.stalled, card\n\n    travel_to Time.now + card.board.entropy.auto_postpone_period + 1.day\n    assert_includes Card.due_to_be_postponed, card\n\n    Card.auto_postpone_all_due\n\n    assert_not card.reload.stalled?\n    assert_not_includes Card.stalled, card\n  end\n\n  # More fine-grained testing in Card::ActivitySpike::Detector\n  test \"detect activity spikes\" do\n    assert_not cards(:logo).stalled?\n    multiple_people_comment_on(cards(:logo))\n\n    travel_to 1.month.from_now\n    assert cards(:logo).reload.stalled?\n    assert_includes Card.stalled, cards(:logo)\n  end\n\n  test \"don't detect activity spikes when updating attributes other than last_active_at\" do\n    assert_no_enqueued_jobs only: Card::ActivitySpike::DetectionJob do\n      cards(:logo).update! created_at: 1.day.ago\n    end\n  end\n\n  test \"don't detect activity spikes when creating new cards\" do\n    assert_no_enqueued_jobs only: Card::ActivitySpike::DetectionJob do\n      boards(:writebook).cards.create! title: \"A new card\", creator: users(:kevin)\n    end\n  end\nend\n"
  },
  {
    "path": "test/models/card/statuses_test.rb",
    "content": "require \"test_helper\"\n\nclass Card::StatusesTest < ActiveSupport::TestCase\n  setup do\n    Current.session = sessions(:david)\n  end\n\n  test \"cards start out in a `drafted` state\" do\n    card = boards(:writebook).cards.create! creator: users(:kevin), title: \"Newly created card\"\n\n    assert card.drafted?\n  end\n\n  test \"an event is created when a card is created in the published state\" do\n    assert_no_difference(-> { Event.count }) do\n      boards(:writebook).cards.create! creator: users(:kevin), title: \"Draft Card\"\n    end\n\n    assert_difference(-> { Event.count } => +1) do\n      @card = boards(:writebook).cards.create! creator: users(:kevin), title: \"Published Card\", status: :published\n    end\n\n    event = Event.last\n    assert_equal @card, event.eventable\n    assert_equal \"card_published\", event.action\n  end\n\n  test \"an event is created when a card is published\" do\n    card = boards(:writebook).cards.create! creator: users(:kevin), title: \"Published Card\"\n    assert_difference(-> { Event.count } => +1) do\n      card.publish\n    end\n\n    event = Event.last\n    assert_equal card, event.eventable\n    assert_equal \"card_published\", event.action\n  end\n\n  test \"created_at is updated when the card is published\" do\n    freeze_time\n\n    card = travel_to 1.week.ago do\n      boards(:writebook).cards.create! creator: users(:kevin), title: \"Newly created card\"\n    end\n\n    assert card.drafted?\n    assert_equal 1.week.ago, card.created_at\n\n    card.publish\n\n    assert_equal Time.current, card.created_at\n  end\n\n  test \"detect drafts that were just published\" do\n    card = boards(:writebook).cards.create! creator: users(:kevin), title: \"Draft Card\"\n    assert card.drafted?\n    assert_not card.was_just_published?\n\n    card.publish\n\n    assert card.was_just_published?\n    assert_not Card.find(card.id).was_just_published?\n  end\n\n  test \"detect cards that were created and published\" do\n    card = boards(:writebook).cards.create! creator: users(:kevin), title: \"Published Card\", status: :published\n    assert card.was_just_published?\n\n    assert_not Card.find(card.id).was_just_published?\n  end\nend\n"
  },
  {
    "path": "test/models/card/taggable_test.rb",
    "content": "require \"test_helper\"\n\nclass Card::TaggableTest < ActiveSupport::TestCase\n  setup do\n    @card = cards(:logo)\n  end\n\n  test \"toggle tag\" do\n    assert_difference -> { @card.tags.count }, 1 do\n      @card.toggle_tag_with \"ruby\"\n    end\n\n    assert_equal \"ruby\", @card.tags.last.title\n\n    assert_difference -> { @card.tags.count }, -1 do\n      @card.toggle_tag_with \"ruby\"\n    end\n  end\n\n  test \"scope tags by account\" do\n    assert_difference -> { Tag.count }, 2 do\n      cards(:logo).toggle_tag_with \"ruby\"\n      cards(:paycheck).toggle_tag_with \"ruby\"\n    end\n\n    assert_not_equal cards(:logo).tags.last, cards(:paycheck).tags.last\n  end\nend\n"
  },
  {
    "path": "test/models/card/triageable_test.rb",
    "content": "require \"test_helper\"\n\nclass Card::TriageableTest < ActiveSupport::TestCase\n  setup do\n    Current.session = sessions(:david)\n  end\n\n  test \"active cards with columns are triaged\" do\n    assert cards(:logo).triaged?\n    assert cards(:text).triaged?\n    assert_not cards(:buy_domain).triaged?\n  end\n\n  test \"active cards without columns are awaiting triage\" do\n    assert cards(:buy_domain).awaiting_triage?\n    assert_not cards(:logo).awaiting_triage?\n    assert_not cards(:text).awaiting_triage?\n  end\n\n  test \"triage a card\" do\n    card = cards(:buy_domain)\n    column = columns(:writebook_in_progress)\n\n    assert_nil card.column\n    assert card.awaiting_triage?\n\n    assert_difference -> { card.reload.events.where(action: \"card_triaged\").count }, +1 do\n      card.triage_into(column)\n    end\n\n    assert_equal column, card.reload.column\n    assert card.triaged?\n  end\n\n  test \"cannot triage into a column from a different board\" do\n    card = cards(:buy_domain)\n    other_board_column = Column.create!(\n      name: \"Other\",\n      color: \"#000000\",\n      board: boards(:private)\n    )\n\n    assert_raises(RuntimeError, \"The column must belong to the card board\") do\n      card.triage_into(other_board_column)\n    end\n  end\n\n  test \"send a card back to triage\" do\n    card = cards(:logo)\n    assert card.triaged?\n\n    assert_difference -> { card.reload.events.where(action: \"card_sent_back_to_triage\").count }, +1 do\n      card.send_back_to_triage\n    end\n\n    assert card.reload.awaiting_triage?\n  end\n\n  test \"scopes\" do\n    assert_includes Card.awaiting_triage, cards(:buy_domain)\n    assert_not_includes Card.awaiting_triage, cards(:logo)\n    assert_not_includes Card.awaiting_triage, cards(:text)\n\n    assert_includes Card.triaged, cards(:logo)\n    assert_includes Card.triaged, cards(:text)\n    assert_not_includes Card.triaged, cards(:buy_domain)\n  end\nend\n"
  },
  {
    "path": "test/models/card/watchable_test.rb",
    "content": "require \"test_helper\"\n\nclass Card::WatchableTest < ActiveSupport::TestCase\n  setup do\n    Watch.destroy_all\n    Access.all.update!(involvement: :access_only)\n  end\n\n  test \"watched_by?\" do\n    assert_not cards(:logo).watched_by?(users(:kevin))\n\n    cards(:logo).watch_by users(:kevin)\n    assert cards(:logo).watched_by?(users(:kevin))\n\n    cards(:logo).unwatch_by users(:kevin)\n    assert_not cards(:logo).watched_by?(users(:kevin))\n  end\n\n  test \"cards are initially watched by their creator\" do\n    card = boards(:writebook).cards.create!(creator: users(:kevin))\n\n    assert card.watched_by?(users(:kevin))\n  end\n\n  test \"watchers\" do\n    boards(:writebook).access_for(users(:kevin)).watching!\n    boards(:writebook).access_for(users(:jz)).watching!\n\n    cards(:logo).watch_by users(:kevin)\n    cards(:logo).unwatch_by users(:jz)\n    cards(:logo).watch_by users(:david)\n\n    assert_equal [ users(:kevin), users(:david) ].sort, cards(:logo).watchers.sort\n\n    # Only active users\n    users(:david).system!\n    assert_equal [ users(:kevin) ].sort, cards(:logo).watchers.reload.sort\n  end\nend\n"
  },
  {
    "path": "test/models/card_test.rb",
    "content": "require \"test_helper\"\n\nclass CardTest < ActiveSupport::TestCase\n  setup do\n    Current.session = sessions(:david)\n  end\n\n  test \"create assigns a number to the card\" do\n    user = users(:david)\n    board = boards(:writebook)\n    account = board.account\n    card = nil\n\n    assert_difference -> { account.reload.cards_count }, +1 do\n      card = Card.create!(title: \"Test\", board: board, creator: user)\n    end\n\n    assert_equal account.reload.cards_count, card.number\n  end\n\n  test \"assignment states\" do\n    assert cards(:logo).assigned_to?(users(:kevin))\n    assert_not cards(:logo).assigned_to?(users(:david))\n  end\n\n  test \"assignment toggling\" do\n    assert cards(:logo).assigned_to?(users(:kevin))\n\n    assert_difference({ -> { cards(:logo).assignees.count } => -1, -> { Event.count } => +1 }) do\n      cards(:logo).toggle_assignment users(:kevin)\n    end\n    assert_not cards(:logo).reload.assigned_to?(users(:kevin))\n    unassign_event = Event.last\n    assert_equal \"card_unassigned\", unassign_event.action\n    assert_equal [ users(:kevin) ], unassign_event.assignees\n\n    assert_difference %w[ cards(:logo).assignees.count Event.count ], +1 do\n      cards(:logo).toggle_assignment users(:kevin)\n    end\n    assert cards(:logo).assigned_to?(users(:kevin))\n    assign_event = Event.last\n    assert_equal \"card_assigned\", assign_event.action\n    assert_equal [ users(:kevin) ], assign_event.assignees\n  end\n\n  test \"tagged states\" do\n    assert cards(:logo).tagged_with?(tags(:web))\n    assert_not cards(:logo).tagged_with?(tags(:mobile))\n  end\n\n  test \"tag toggling\" do\n    assert cards(:logo).tagged_with?(tags(:web))\n\n    assert_difference \"cards(:logo).taggings.count\", -1 do\n      cards(:logo).toggle_tag_with tags(:web).title\n    end\n    assert_not cards(:logo).tagged_with?(tags(:web))\n\n    assert_difference \"cards(:logo).taggings.count\", +1 do\n      cards(:logo).toggle_tag_with tags(:web).title\n    end\n    assert cards(:logo).tagged_with?(tags(:web))\n\n    assert_difference %w[ cards(:logo).taggings.count Tag.count ], +1 do\n      cards(:logo).toggle_tag_with \"prioritized\"\n    end\n    assert_equal \"prioritized\", cards(:logo).taggings.last.tag.title\n  end\n\n  test \"closed\" do\n    assert_equal [ cards(:shipping) ], Card.closed\n  end\n\n  test \"open\" do\n    assert_equal cards(:logo, :layout, :text, :buy_domain).to_set, accounts(\"37s\").cards.open.to_set\n    assert_equal cards(:radio, :paycheck, :unfinished_thoughts).to_set, accounts(\"initech\").cards.open.to_set\n  end\n\n  test \"card_unassigned\" do\n    assert_equal cards(:shipping, :text, :buy_domain).to_set, accounts(\"37s\").cards.unassigned.to_set\n  end\n\n  test \"assigned to\" do\n    assert_equal cards(:logo, :layout).to_set, Card.assigned_to(users(:jz)).to_set\n  end\n\n  test \"assigned by\" do\n    assert_equal cards(:layout, :logo).to_set, Card.assigned_by(users(:david)).to_set\n  end\n\n  test \"in board\" do\n    new_board = Board.create! name: \"New Board\", creator: users(:david)\n    assert_equal cards(:logo, :shipping, :layout, :text, :buy_domain).to_set, Card.where(board: boards(:writebook)).to_set\n    assert_empty Card.where(board: new_board)\n  end\n\n  test \"tagged with\" do\n    assert_equal cards(:layout, :text), Card.tagged_with(tags(:mobile))\n  end\n\n  test \"for published cards, it should set the default title 'Untitiled' when not provided\" do\n    card = boards(:writebook).cards.create!\n    assert_nil card.title\n\n    card.publish\n    assert_equal \"Untitled\", card.reload.title\n  end\n\n  test \"send back to triage when moved to a new board\" do\n    cards(:logo).update! column: columns(:writebook_in_progress)\n\n    assert_changes -> { cards(:logo).reload.triaged? }, from: true, to: false do\n      cards(:logo).update! board: boards(:private)\n    end\n  end\n\n  test \"grants access to assignees when moved to a new board\" do\n    card = cards(:logo)\n    assignee = users(:david)\n    card.toggle_assignment(assignee)\n\n    board = boards(:private)\n    assert_not_includes board.users, assignee\n\n    card.update!(board: board)\n    assert_includes board.users.reload, assignee\n  end\n\n  test \"move cards to a different board\" do\n    card = cards(:logo)\n    old_board = card.board\n    new_board = boards(:private)\n\n    card.comments.create!(body: \"Sensitive information\", creator: users(:david))\n\n    card_events_on_old_board = card.events.where(board: old_board)\n    comment_events_on_old_board = Event.where(board: old_board, eventable: card.comments)\n\n    assert card_events_on_old_board.exists?\n    assert comment_events_on_old_board.exists?\n\n    card.move_to(new_board)\n\n    assert_equal new_board, card.reload.board\n\n    card_events_on_new_board = card.events.where(board: new_board)\n    comment_events_on_new_board = Event.where(board: new_board, eventable: card.comments)\n\n    assert_empty card_events_on_old_board\n    assert_empty comment_events_on_old_board\n    assert card_events_on_new_board.exists?\n    assert comment_events_on_new_board.exists?\n    assert card_events_on_new_board.find_by(action: \"card_board_changed\")\n  end\n\n  test \"a card is filled if it has either the title or the description set\" do\n    assert Card.new(title: \"Some title\").filled?\n    assert Card.new(description: \"Some description\").filled?\n\n    assert_not Card.new.filled?\n  end\n\n  test \"pins are deleted when card moves to a board user cannot access\" do\n    card = cards(:logo)\n    kevin = users(:kevin)\n    david = users(:david)\n\n    # David pins the card (Kevin already has it pinned via fixture)\n    card.pin_by(david)\n\n    assert card.pinned_by?(kevin)\n    assert card.pinned_by?(david)\n\n    # Kevin has access to the private board, David does not\n    assert boards(:private).accessible_to?(kevin)\n    assert_not boards(:private).accessible_to?(david)\n\n    perform_enqueued_jobs only: Card::CleanInaccessibleDataJob do\n      card.move_to(boards(:private))\n    end\n\n    assert card.pinned_by?(kevin), \"Kevin's pin should remain (has board access)\"\n    assert_not card.pinned_by?(david), \"David's pin should be deleted (no board access)\"\n  end\n\n  test \"watches are deleted when card moves to a board user cannot access\" do\n    card = cards(:logo)\n    kevin = users(:kevin)\n    david = users(:david)\n\n    # Both watch the card via fixtures\n    assert card.watched_by?(kevin)\n    assert card.watched_by?(david)\n\n    # Kevin has access to the private board, David does not\n    assert boards(:private).accessible_to?(kevin)\n    assert_not boards(:private).accessible_to?(david)\n\n    perform_enqueued_jobs only: Card::CleanInaccessibleDataJob do\n      card.move_to(boards(:private))\n    end\n\n    assert card.watched_by?(kevin), \"Kevin's watch should remain (has board access)\"\n    assert_not card.watched_by?(david), \"David's watch should be deleted (no board access)\"\n  end\n\n  test \"card has reactions association\" do\n    card = cards(:logo)\n    user = users(:david)\n\n    assert_difference \"card.reactions.count\", +1 do\n      card.reactions.create!(content: \"👍\", reacter: user)\n    end\n\n    reaction = card.reactions.last\n    assert_equal \"👍\", reaction.content\n    assert_equal user, reaction.reacter\n    assert_equal card, reaction.reactable\n  end\nend\n"
  },
  {
    "path": "test/models/column/colored_test.rb",
    "content": "require \"test_helper\"\n\nclass Column::ColoredTest < ActiveSupport::TestCase\n  test \"creates column with default color when color not provided\" do\n    column = boards(:writebook).columns.create!(name: \"New Column\")\n\n    assert_equal Column::Colored::DEFAULT_COLOR, column.color\n  end\n\n  test \"update the column color\" do\n    columns(:writebook_triage).update!(color: \"var(--color-card-3)\")\n\n    assert_not_nil columns(:writebook_triage).color\n    assert_equal Color.for_value(\"var(--color-card-3)\"), columns(:writebook_triage).color\n  end\nend\n"
  },
  {
    "path": "test/models/column/positioned_test.rb",
    "content": "require \"test_helper\"\n\nclass Column::PositionedTest < ActiveSupport::TestCase\n  test \"auto position new columns\" do\n    board = boards(:writebook)\n    max_position = board.columns.maximum(:position)\n\n    new_column = board.columns.create!(name: \"New Column\", color: \"#000000\")\n\n    assert_equal max_position + 1, new_column.position\n  end\n\n  test \"move column to the left\" do\n    board = boards(:writebook)\n    columns = board.columns.sorted.to_a\n\n    column_a = columns[0]\n    column_b = columns[1]\n    original_position_a = column_a.position\n    original_position_b = column_b.position\n\n    column_b.move_left\n\n    assert_equal original_position_b, column_a.reload.position\n    assert_equal original_position_a, column_b.reload.position\n  end\n\n  test \"move left when already at leftmost position\" do\n    board = boards(:writebook)\n    leftmost_column = board.columns.sorted.first\n    original_position = leftmost_column.position\n\n    leftmost_column.move_left\n\n    assert_equal original_position, leftmost_column.reload.position\n  end\n\n  test \"move column to the right\" do\n    board = boards(:writebook)\n    columns = board.columns.sorted.to_a\n\n    column_a = columns[0]\n    column_b = columns[1]\n    original_position_a = column_a.position\n    original_position_b = column_b.position\n\n    column_a.move_right\n\n    assert_equal original_position_b, column_a.reload.position\n    assert_equal original_position_a, column_b.reload.position\n  end\n\n  test \"move right when already at rightmost position\" do\n    board = boards(:writebook)\n    rightmost_column = board.columns.sorted.last\n    original_position = rightmost_column.position\n\n    rightmost_column.move_right\n\n    assert_equal original_position, rightmost_column.reload.position\n  end\nend\n"
  },
  {
    "path": "test/models/column_limits_test.rb",
    "content": "require \"test_helper\"\n\nclass ColumnLimitsTest < ActiveSupport::TestCase\n  # Database errors for exceeding column limits:\n  # - MySQL: ActiveRecord::ValueTooLong\n  # - SQLite: ActiveRecord::CheckViolation\n  COLUMN_LIMIT_ERRORS = [ ActiveRecord::ValueTooLong, ActiveRecord::CheckViolation ]\n\n  test \"account name rejects strings over 255 characters\" do\n    account = Account.new(name: \"a\" * 256)\n    assert_raises(*COLUMN_LIMIT_ERRORS) { account.save! }\n  end\n\n  test \"account name accepts strings up to 255 characters\" do\n    account = Account.new(name: \"a\" * 255)\n    assert account.save\n  end\n\n  test \"account name accepts 255 emoji characters\" do\n    account = Account.new(name: \"🎉\" * 255)\n    assert account.save\n  end\n\n  test \"account name rejects 256 emoji characters\" do\n    account = Account.new(name: \"🎉\" * 256)\n    assert_raises(*COLUMN_LIMIT_ERRORS) { account.save! }\n  end\n\n  # Test text column limits (65535 bytes for TEXT)\n  test \"step content rejects text over 65535 bytes\" do\n    step = Step.new(content: \"a\" * 65536, card: cards(:logo))\n    assert_raises(*COLUMN_LIMIT_ERRORS) { step.save! }\n  end\n\n  test \"step content accepts text up to 65535 bytes\" do\n    step = Step.new(content: \"a\" * 65535, card: cards(:logo))\n    assert step.save\n  end\n\n  test \"step content counts bytes not characters for text columns\" do\n    # 20000 emoji = 20000 chars but 80000 bytes (over 65535 limit)\n    step = Step.new(content: \"🎉\" * 20000, card: cards(:logo))\n    assert_raises(*COLUMN_LIMIT_ERRORS) { step.save! }\n  end\n\n  test \"ActionText::RichText name rejects strings over 255 characters\" do\n    rich_text = ActionText::RichText.new(name: \"a\" * 256, record: cards(:logo))\n    assert_raises(*COLUMN_LIMIT_ERRORS) { rich_text.save! }\n  end\n\n  test \"ActiveStorage::Blob filename rejects strings over 255 characters\" do\n    Current.account = accounts(:\"37s\")\n    blob = ActiveStorage::Blob.new(filename: \"a\" * 256, key: \"test-key\", byte_size: 0, checksum: \"test\", service_name: \"local\")\n    assert_raises(*COLUMN_LIMIT_ERRORS) { blob.save! }\n  end\nend\n"
  },
  {
    "path": "test/models/column_test.rb",
    "content": "require \"test_helper\"\n\nclass ColumnTest < ActiveSupport::TestCase\n  test \"touch all the cards when the name or color changes\" do\n    column = columns(:writebook_triage)\n\n    assert_changes -> { column.cards.first.updated_at } do\n      column.update!(name: \"New Name\")\n    end\n\n    assert_changes -> { column.cards.first.updated_at } do\n      column.update!(color: \"#FF0000\")\n    end\n\n    assert_no_changes -> { column.cards.first.updated_at } do\n      column.update!(updated_at: 1.hour.from_now)\n    end\n  end\n\n  test \"touch all board cards when column is destroyed\" do\n    column = columns(:writebook_triage)\n\n    assert_changes -> { column.board.cards.first.updated_at } do\n      column.destroy\n    end\n  end\nend\n"
  },
  {
    "path": "test/models/comment/searchable_test.rb",
    "content": "require \"test_helper\"\n\nclass Comment::SearchableTest < ActiveSupport::TestCase\n  include SearchTestHelper\n\n  setup do\n    @card = @board.cards.create!(title: \"Test Card\", status: \"published\", creator: @user)\n  end\n\n  test \"searchable? returns true for comments on published cards\" do\n    comment = @card.comments.create!(body: \"test comment\", creator: @user)\n    assert comment.searchable?\n  end\n\n  test \"searchable? returns false for comments on draft cards\" do\n    draft_card = @board.cards.create!(title: \"Draft Card\", status: \"drafted\", creator: @user)\n    comment = draft_card.comments.build(body: \"test comment\", creator: @user)\n    assert_not comment.searchable?\n  end\n\n  test \"comment search\" do\n    search_record_class = Search::Record.for(@user.account_id)\n    # Comment is indexed on create\n    comment = @card.comments.create!(body: \"searchable comment text\", creator: @user)\n    record = search_record_class.find_by(searchable_type: \"Comment\", searchable_id: comment.id)\n    assert_not_nil record\n\n    # Comment is updated in index\n    comment.update!(body: \"updated text\")\n    record = search_record_class.find_by(searchable_type: \"Comment\", searchable_id: comment.id)\n    assert_match /updat/, record.content\n\n    # Comment is removed from index on destroy\n    comment_id = comment.id\n    search_record_id = record.id\n\n    # For SQLite, verify FTS entry exists before deletion\n    if search_record_class.connection.adapter_name == \"SQLite\"\n      fts_entry = record.search_records_fts\n      assert_not_nil fts_entry, \"FTS entry should exist before comment deletion\"\n    end\n\n    comment.destroy\n    record = search_record_class.find_by(searchable_type: \"Comment\", searchable_id: comment_id)\n    assert_nil record\n\n    # For SQLite, verify FTS entry is also deleted\n    if search_record_class.connection.adapter_name == \"SQLite\"\n      fts_count = Search::Record::SQLite::Fts.where(rowid: search_record_id).count\n      assert_equal 0, fts_count, \"FTS entry should be deleted after comment deletion\"\n    end\n\n    # Finding cards via comment search\n    card_with_comment = @board.cards.create!(title: \"Card One\", status: \"published\", creator: @user)\n    card_with_comment.comments.create!(body: \"unique searchable phrase\", creator: @user)\n    card_without_comment = @board.cards.create!(title: \"Card Two\", status: \"published\", creator: @user)\n    results = Card.mentioning(\"searchable\", user: @user)\n    assert_includes results, card_with_comment\n    assert_not_includes results, card_without_comment\n\n    # Comment stores parent card_id and board_id\n    new_comment = @card.comments.create!(body: \"test comment\", creator: @user)\n    record = search_record_class.find_by(searchable_type: \"Comment\", searchable_id: new_comment.id)\n    assert_equal @card.id, record.card_id\n    assert_equal @board.id, record.board_id\n  end\nend\n"
  },
  {
    "path": "test/models/comment_test.rb",
    "content": "require \"test_helper\"\n\nclass CommentTest < ActiveSupport::TestCase\n  setup do\n    Current.session = sessions(:david)\n  end\n\n  test \"cannot create comment on a draft card\" do\n    draft_card = cards(:unfinished_thoughts)\n\n    comment = draft_card.comments.build(body: \"This should fail\")\n\n    assert_not comment.valid?\n    assert_includes comment.errors[:card], \"does not allow comments\"\n\n    assert_raises(ActiveRecord::RecordInvalid) do\n      draft_card.comments.create!(body: \"This should raise\")\n    end\n  end\n\n  test \"rich text embed variants are processed immediately on attachment\" do\n    comment = cards(:logo).comments.create!(body: \"Check this out\")\n    comment.body.body.attachables # force load\n\n    blob = ActiveStorage::Blob.create_and_upload! \\\n      io: File.open(file_fixture(\"moon.jpg\")),\n      filename: \"moon.jpg\",\n      content_type: \"image/jpeg\"\n\n    comment.body.body = ActionText::Content.new(comment.body.body.to_html).append_attachables(blob)\n    comment.save!\n\n    embed = comment.body.embeds.sole\n\n    Attachments::VARIANTS.each_key do |variant_name|\n      variant = embed.variant(variant_name)\n      assert variant.processed?, \"Expected #{variant_name} variant to be processed immediately\"\n    end\n  end\nend\n"
  },
  {
    "path": "test/models/concerns/mentions_test.rb",
    "content": "require \"test_helper\"\n\nclass MentionsTest < ActiveSupport::TestCase\n  setup do\n    Current.session = sessions(:david)\n  end\n\n  test \"don't create mentions when creating or updating drafts\" do\n    assert_no_difference -> { Mention.count } do\n      perform_enqueued_jobs only: Mention::CreateJob do\n        card = boards(:writebook).cards.create title: \"Cleanup\", description: \"Did you finish up with the cleanup, #{mention_html_for(users(:david))}?\"\n        card.update description: \"Any thoughts here #{mention_html_for(users(:jz))}\"\n      end\n    end\n  end\n\n  test \"create mentions from plain text mentions when publishing cards\" do\n    perform_enqueued_jobs only: Mention::CreateJob do\n      card = assert_no_difference -> { Mention.count } do\n        boards(:writebook).cards.create title: \"Cleanup\", description: \"Did you finish up with the cleanup, #{mention_html_for(users(:david))}?\"\n      end\n\n      card = Card.find(card.id)\n\n      assert_difference -> { Mention.count }, +1 do\n        card.publish\n      end\n    end\n  end\n\n  test \"create mentions from rich text mentions when publishing cards\" do\n    perform_enqueued_jobs only: Mention::CreateJob do\n      card = assert_no_difference -> { Mention.count } do\n        boards(:writebook).cards.create title: \"Cleanup\", description: \"Did you finish up with the cleanup, #{mention_html_for(users(:david))}?\"\n      end\n\n      card = Card.find(card.id)\n\n      assert_difference -> { Mention.count }, +1 do\n        card.published!\n      end\n    end\n  end\n\n  test \"don't create repeated mentions when updating cards\" do\n    perform_enqueued_jobs only: Mention::CreateJob do\n      card = boards(:writebook).cards.create title: \"Cleanup\", description: \"Did you finish up with the cleanup, #{mention_html_for(users(:david))}?\"\n\n      assert_difference -> { Mention.count }, +1 do\n        card.published!\n      end\n\n      assert_no_difference -> { Mention.count } do\n        card.update description: \"Any thoughts here #{mention_html_for(users(:david))}\"\n      end\n\n      assert_difference -> { Mention.count }, +1 do\n        card.update description: \"Any thoughts here #{mention_html_for(users(:jz))}\"\n      end\n    end\n  end\n\n  test \"create mentions from plain text mentions when posting comments\" do\n    perform_enqueued_jobs only: Mention::CreateJob do\n      card = boards(:writebook).cards.create title: \"Cleanup\", description: \"Some initial content\", status: :published\n\n      assert_difference -> { Mention.count }, +1 do\n        card.comments.create!(body: \"Great work on this #{mention_html_for(users(:david))}!\")\n      end\n    end\n  end\n\n  test \"can't mention users that don't have access to the board\" do\n    boards(:writebook).update! all_access: false\n    boards(:writebook).accesses.revoke_from(users(:david))\n\n    assert_no_difference -> { Mention.count }, +1 do\n      perform_enqueued_jobs only: Mention::CreateJob do\n        boards(:writebook).cards.create title: \"Cleanup\", description: \"Did you finish up with the cleanup, #{mention_html_for(users(:david))}?\"\n      end\n    end\n  end\n\n  test \"notify new mentionees when editing a comment to add a mention\" do\n    card = boards(:writebook).cards.create title: \"Fresh card\", description: \"Some content\", status: :published\n\n    perform_enqueued_jobs do\n      comment = card.comments.create!(body: \"Initial thought\")\n\n      assert_difference -> { users(:kevin).notifications.count }, +1 do\n        comment.update!(body: \"Actually, #{mention_html_for(users(:kevin))}, what do you think?\")\n      end\n    end\n  end\n\n  test \"mentionees are added as watchers of the card\" do\n    perform_enqueued_jobs only: Mention::CreateJob do\n      card = boards(:writebook).cards.create title: \"Cleanup\", description: \"Did you finish up with the cleanup #{mention_html_for(users(:kevin))}?\"\n      card.published!\n      assert card.watchers.include?(users(:kevin))\n    end\n  end\n\n  private\n    def mention_html_for(user)\n      ActionText::Attachment.from_attachable(user).to_html\n    end\nend\n"
  },
  {
    "path": "test/models/entropy_test.rb",
    "content": "require \"test_helper\"\n\nclass Entropy::Test < ActiveSupport::TestCase\n  test \"touch cards when entropy changes for board\" do\n    assert_changes -> { boards(:writebook).cards.first.updated_at } do\n      boards(:writebook).entropy.update!(auto_postpone_period: 7.days)\n    end\n  end\n\n  test \"default auto-postpone period is included in allowed periods\" do\n    assert_includes Entropy::AUTO_POSTPONE_PERIODS_IN_DAYS, Entropy::DEFAULT_AUTO_POSTPONE_PERIOD_IN_DAYS\n  end\n\n  test \"board entropy falls back to account entropy period when value is invalid\" do\n    board = boards(:writebook)\n    board.entropy.update_column(:auto_postpone_period, 999.days.to_i)\n\n    assert_equal Current.account.entropy.auto_postpone_period_in_days, board.entropy.auto_postpone_period_in_days\n  end\n\n  test \"touch cards when entropy changes for account container\" do\n    account = Current.account\n\n    assert_changes -> { account.cards.first.updated_at } do\n      boards(:writebook).entropy.update!(auto_postpone_period: 7.days)\n    end\n  end\nend\n"
  },
  {
    "path": "test/models/event/description_test.rb",
    "content": "require \"test_helper\"\n\nclass Event::DescriptionTest < ActiveSupport::TestCase\n  test \"html description is html safe\" do\n    description = events(:logo_published).description_for(users(:david))\n\n    assert_predicate description.to_html, :html_safe?\n  end\n\n  test \"generates html description for card published event\" do\n    description = events(:logo_published).description_for(users(:david))\n\n    assert_includes description.to_html, \"added\"\n    assert_includes description.to_html, \"logo\"\n  end\n\n  test \"plain text description is html safe\" do\n    description = events(:logo_published).description_for(users(:david))\n\n    assert_predicate description.to_plain_text, :html_safe?\n  end\n\n  test \"generates plain text description for card published event\" do\n    description = events(:logo_published).description_for(users(:david))\n\n    assert_includes description.to_plain_text, \"David added\"\n    assert_includes description.to_plain_text, \"logo\"\n  end\n\n  test \"generates description for comment event\" do\n    description = events(:layout_commented).description_for(users(:jz))\n\n    assert_includes description.to_plain_text, \"David commented on\"\n  end\n\n  test \"uses always the name even when the event creator is the current user\" do\n    description = events(:logo_published).description_for(users(:david))\n\n    assert_includes description.to_plain_text, \"David added\"\n  end\n\n  test \"uses creator name when event creator is not the current user\" do\n    description = events(:logo_published).description_for(users(:jz))\n\n    assert_includes description.to_plain_text, \"David added\"\n  end\n\n  test \"to_html escapes assignee names\" do\n    users(:jz).update_column(:name, \"Tom & Jerry\")\n    description = events(:logo_assignment_jz).description_for(users(:david))\n\n    assert_includes description.to_html, \"Tom &amp; Jerry\"\n    assert_not_includes description.to_html, \"Tom & Jerry\"\n  end\n\n  test \"to_plain_text escapes assignee names\" do\n    users(:jz).update_column(:name, \"Tom & Jerry\")\n    description = events(:logo_assignment_jz).description_for(users(:david))\n\n    assert_includes description.to_plain_text, \"Tom &amp; Jerry\"\n    assert_not_includes description.to_plain_text, \"Tom &amp;amp; Jerry\"\n  end\n\n  test \"to_html escapes unassigned names\" do\n    users(:jz).update_column(:name, \"Tom & Jerry\")\n    event = events(:logo_assignment_jz)\n    event.update_column(:action, \"card_unassigned\")\n    description = event.description_for(users(:david))\n\n    assert_includes description.to_html, \"Tom &amp; Jerry\"\n    assert_not_includes description.to_html, \"Tom & Jerry\"\n  end\n\n  test \"to_plain_text escapes unassigned names\" do\n    users(:jz).update_column(:name, \"Tom & Jerry\")\n    event = events(:logo_assignment_jz)\n    event.update_column(:action, \"card_unassigned\")\n    description = event.description_for(users(:david))\n\n    assert_includes description.to_plain_text, \"Tom &amp; Jerry\"\n    assert_not_includes description.to_plain_text, \"Tom &amp;amp; Jerry\"\n  end\n\n  test \"to_html escapes old title in renamed description\" do\n    event = events(:logo_published)\n    event.update_columns(action: \"card_title_changed\", particulars: { \"particulars\" => { \"old_title\" => \"Tom & Jerry\" } })\n    description = event.description_for(users(:david))\n\n    assert_includes description.to_html, \"Tom &amp; Jerry\"\n    assert_not_includes description.to_html, \"Tom & Jerry\"\n  end\n\n  test \"to_plain_text escapes old title in renamed description\" do\n    event = events(:logo_published)\n    event.update_columns(action: \"card_title_changed\", particulars: { \"particulars\" => { \"old_title\" => \"Tom & Jerry\" } })\n    description = event.description_for(users(:david))\n\n    assert_includes description.to_plain_text, \"Tom &amp; Jerry\"\n    assert_not_includes description.to_plain_text, \"Tom &amp;amp; Jerry\"\n  end\n\n  test \"to_html escapes board name in moved description\" do\n    event = events(:logo_published)\n    event.update_columns(action: \"card_board_changed\", particulars: { \"particulars\" => { \"new_board\" => \"Tom & Jerry\" } })\n    description = event.description_for(users(:david))\n\n    assert_includes description.to_html, \"Tom &amp; Jerry\"\n    assert_not_includes description.to_html, \"Tom & Jerry\"\n  end\n\n  test \"to_plain_text escapes board name in moved description\" do\n    event = events(:logo_published)\n    event.update_columns(action: \"card_board_changed\", particulars: { \"particulars\" => { \"new_board\" => \"Tom & Jerry\" } })\n    description = event.description_for(users(:david))\n\n    assert_includes description.to_plain_text, \"Tom &amp; Jerry\"\n    assert_not_includes description.to_plain_text, \"Tom &amp;amp; Jerry\"\n  end\n\n  test \"to_html escapes column name in triaged description\" do\n    event = events(:logo_published)\n    event.update_columns(action: \"card_triaged\", particulars: { \"particulars\" => { \"column\" => \"Tom & Jerry\" } })\n    description = event.description_for(users(:david))\n\n    assert_includes description.to_html, \"Tom &amp; Jerry\"\n    assert_not_includes description.to_html, \"Tom & Jerry\"\n  end\n\n  test \"to_plain_text escapes column name in triaged description\" do\n    event = events(:logo_published)\n    event.update_columns(action: \"card_triaged\", particulars: { \"particulars\" => { \"column\" => \"Tom & Jerry\" } })\n    description = event.description_for(users(:david))\n\n    assert_includes description.to_plain_text, \"Tom &amp; Jerry\"\n    assert_not_includes description.to_plain_text, \"Tom &amp;amp; Jerry\"\n  end\n\n  test \"escapes html in card titles in plain text description\" do\n    card = cards(:logo)\n    card.update_column(:title, \"<script>alert('xss')</script>\")\n\n    description = events(:logo_published).description_for(users(:david))\n\n    assert_includes description.to_plain_text, \"&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;\"\n    assert_not_includes description.to_plain_text, \"<script>\"\n  end\nend\n"
  },
  {
    "path": "test/models/filter/search_test.rb",
    "content": "require \"test_helper\"\n\nclass Filter::SearchTest < ActiveSupport::TestCase\n  include SearchTestHelper\n\n  test \"deduplicate multiple results\" do\n    card = @board.cards.create!(title: \"Duplicate results test\", description: \"Have you had any haggis today?\", creator: @user, status: \"published\")\n    card.comments.create(body: \"I hate haggis.\", creator: @user)\n    card.comments.create(body: \"I love haggis.\", creator: @user)\n\n    filter = @user.filters.new(terms: [ \"haggis\" ], indexed_by: \"all\", sorted_by: \"latest\")\n\n    assert_equal [ card ], filter.cards.to_a\n  end\n\n  test \"multiple terms all match\" do\n    matching_card = @board.cards.create!(title: \"haggis neeps tatties\", creator: @user, status: \"published\")\n    @board.cards.create!(title: \"haggis only\", creator: @user, status: \"published\")\n    @board.cards.create!(title: \"neeps only\", creator: @user, status: \"published\")\n\n    filter = @user.filters.new(terms: [ \"haggis\", \"neeps\" ], indexed_by: \"all\", sorted_by: \"latest\")\n\n    assert_equal [ matching_card ], filter.cards.to_a\n  end\nend\n"
  },
  {
    "path": "test/models/filter_test.rb",
    "content": "require \"test_helper\"\n\nclass FilterTest < ActiveSupport::TestCase\n  setup do\n    Current.session = sessions(:david)\n  end\n\n  test \"cards\" do\n    @new_board = Board.create! name: \"Inaccessible Board\", creator: users(:david)\n    @new_card = @new_board.cards.create!(status: \"published\")\n\n    cards(:layout).comments.create!(body: \"I hate haggis\")\n    cards(:logo).comments.create!(body: \"I love haggis\")\n\n    assert_not_includes users(:kevin).filters.new.cards, @new_card\n\n    filter = users(:david).filters.new creator_ids: [ users(:david).id ], tag_ids: [ tags(:mobile).id ]\n    assert_equal [ cards(:layout) ], filter.cards\n\n    filter = users(:david).filters.new assignment_status: \"unassigned\", board_ids: [ @new_board.id ]\n    assert_equal [ @new_card ], filter.cards\n\n    filter = users(:david).filters.new indexed_by: \"closed\"\n    assert_equal [ cards(:shipping) ], filter.cards\n\n    cards(:shipping).postpone\n    filter = users(:david).filters.new indexed_by: \"not_now\"\n    assert_includes filter.cards, cards(:shipping)\n\n    filter = users(:david).filters.new card_ids: [ cards(:logo, :layout).collect(&:id) ]\n    assert_equal [ cards(:logo), cards(:layout) ], filter.cards\n  end\n\n  test \"can't see cards in boards that aren't accessible\" do\n    boards(:writebook).update! all_access: false\n    boards(:writebook).accesses.revoke_from users(:david)\n\n    assert_empty users(:david).filters.new(board_ids: [ boards(:writebook).id ]).cards\n  end\n\n  test \"can't see boards that aren't accessible\" do\n    boards(:writebook).update! all_access: false\n    boards(:writebook).accesses.revoke_from users(:david)\n\n    assert_empty users(:david).filters.new(board_ids: [ boards(:writebook).id ]).boards\n  end\n\n  test \"remembering equivalent filters\" do\n    assert_difference \"Filter.count\", +1 do\n      filter = users(:david).filters.remember(sorted_by: \"latest\", assignment_status: \"unassigned\", tag_ids: [ tags(:mobile).id ])\n\n      assert_changes \"filter.reload.updated_at\" do\n        assert_equal filter, users(:david).filters.remember(tag_ids: [ tags(:mobile).id ], assignment_status: \"unassigned\")\n      end\n    end\n  end\n\n  test \"remembering equivalent filters for different users\" do\n    assert_difference \"Filter.count\", +2 do\n      users(:david).filters.remember(assignment_status: \"unassigned\", tag_ids: [ tags(:mobile).id ])\n      users(:kevin).filters.remember(assignment_status: \"unassigned\", tag_ids: [ tags(:mobile).id ])\n    end\n  end\n\n  test \"turning into params\" do\n    filter = users(:david).filters.new sorted_by: \"latest\", tag_ids: \"\", assignee_ids: [ users(:jz).id ], board_ids: [ boards(:writebook).id ]\n    expected = { assignee_ids: [ users(:jz).id ], board_ids: [ boards(:writebook).id ] }\n    assert_equal expected, filter.as_params\n  end\n\n  test \"cacheability\" do\n    assert_not filters(:jz_assignments).cacheable?\n    assert users(:david).filters.create!(board_ids: [ boards(:writebook).id ]).cacheable?\n  end\n\n  test \"terms\" do\n    assert_equal [], users(:david).filters.new.terms\n    assert_equal [ \"haggis\" ], users(:david).filters.new(terms: [ \"haggis\" ]).terms\n  end\n\n  test \"resource removal\" do\n    filter = users(:david).filters.create! tag_ids: [ tags(:mobile).id ], board_ids: [ boards(:writebook).id ]\n\n    assert_includes filter.as_params[:tag_ids], tags(:mobile).id\n    assert_includes filter.tags, tags(:mobile)\n    assert_includes filter.as_params[:board_ids], boards(:writebook).id\n    assert_includes filter.boards, boards(:writebook)\n\n    assert_changes \"filter.reload.updated_at\" do\n      tags(:mobile).destroy!\n    end\n    assert_nil Filter.find(filter.id).as_params[:tag_ids]\n\n    assert_changes \"Filter.exists?(filter.id)\" do\n      boards(:writebook).destroy!\n    end\n  end\n\n  test \"duplicate filters are removed after a resource is destroyed\" do\n    users(:david).filters.create! tag_ids: [ tags(:mobile).id ], board_ids: [ boards(:writebook).id ]\n    users(:david).filters.create! tag_ids: [ tags(:mobile).id, tags(:web).id ], board_ids: [ boards(:writebook).id ]\n\n    assert_difference \"Filter.count\", -1 do\n      tags(:web).destroy!\n    end\n  end\n\n  test \"summary\" do\n    assert_equal \"Newest, #mobile, and assigned to JZ\", filters(:jz_assignments).summary\n\n    filters(:jz_assignments).update!(assignees: [], tags: [], boards: [ boards(:writebook) ])\n    assert_equal \"Newest\", filters(:jz_assignments).summary\n\n    filters(:jz_assignments).update!(indexed_by: \"stalled\", sorted_by: \"latest\")\n    assert_equal \"Stalled\", filters(:jz_assignments).summary\n  end\n\n  test \"get a clone with some changed params\" do\n    seed_filter = users(:david).filters.new indexed_by: \"all\", terms: [ \"haggis\" ]\n    filter = seed_filter.with(indexed_by: \"closed\")\n\n    assert filter.indexed_by.closed?\n    assert_equal [ \"haggis\" ], filter.terms\n  end\n\n  test \"creation window\" do\n    filter = users(:david).filters.new creation: \"this week\"\n\n    cards(:logo).update_columns created_at: 2.weeks.ago\n    assert_not_includes filter.cards, cards(:logo)\n\n    cards(:logo).update_columns created_at: Time.current\n    assert_includes filter.cards, cards(:logo)\n  end\n\n  test \"closure window\" do\n    filter = users(:david).filters.new closure: \"this week\"\n\n    cards(:shipping).closure.update_columns created_at: 2.weeks.ago\n    assert_not_includes filter.cards, cards(:shipping)\n\n    cards(:shipping).closure.update_columns created_at: Time.current\n    assert_includes filter.cards, cards(:shipping)\n  end\n\n  test \"completed by\" do\n    cards(:shipping).closure.update_columns user_id: users(:david).id\n\n    filter = users(:david).filters.new closer_ids: [ users(:david).id ]\n    assert_includes filter.cards, cards(:shipping)\n\n    filter = users(:david).filters.new closer_ids: [ users(:jz).id ]\n    assert_not_includes filter.cards, cards(:shipping)\n\n    cards(:shipping).closure.update_columns user_id: users(:jz).id\n\n    filter = users(:david).filters.new closer_ids: [ users(:jz).id ]\n    assert_includes filter.cards, cards(:shipping)\n  end\n\n  test \"check if a filter is used\" do\n    assert users(:david).filters.new(creator_ids: [ users(:david).id ]).used?\n    assert_not users(:david).filters.new.used?\n\n    assert users(:david).filters.new(board_ids: [ boards(:writebook).id ]).used?\n    assert_not users(:david).filters.new(board_ids: [ boards(:writebook).id ]).used?(ignore_boards: true)\n  end\n\n  test \"board titles are scoped to creator's account\" do\n    # Give mike (initech) access to the board in his account\n    boards(:miltons_wish_list).accesses.grant_to(users(:mike))\n    assert_equal 1, users(:mike).boards.count\n\n    # Filter with no boards selected should show the single board name from mike's account\n    filter = users(:mike).filters.new(creator: users(:mike))\n    assert_equal [ \"Milton's Wish List\" ], filter.board_titles\n\n    # Should NOT leak board names from other accounts (37s has multiple boards)\n    assert Board.where.not(account: accounts(:initech)).exists?\n    assert_not_includes filter.board_titles, \"Writebook\"\n    assert_not_includes filter.board_titles, \"Private board\"\n  end\nend\n"
  },
  {
    "path": "test/models/identity/access_token_test.rb",
    "content": "require \"test_helper\"\n\nclass Identity::AccessTokenTest < ActiveSupport::TestCase\nend\n"
  },
  {
    "path": "test/models/identity/joinable_test.rb",
    "content": "require \"test_helper\"\n\nclass Identity::JoinableTest < ActiveSupport::TestCase\n  test \"join creates a new user and returns true\" do\n    identity = identities(:david)\n\n    assert_difference -> { User.count }, 1 do\n      result = identity.join(accounts(:initech))\n      assert result, \"join should return true when creating a new user\"\n    end\n\n    user = identity.users.find_by!(account: accounts(:initech))\n    assert_equal identity.email_address, user.name\n  end\n\n  test \"join with custom attributes\" do\n    identity = identities(:mike)\n\n    result = identity.join(accounts(\"37s\"), name: \"Mike\")\n    assert result\n\n    user = identity.users.find_by!(account: accounts(\"37s\"))\n    assert_equal \"Mike\", user.name\n  end\n\n  test \"join returns false if user already exists\" do\n    identity = identities(:david)\n    account = accounts(\"37s\")\n\n    assert identity.users.exists?(account: account), \"David should already be a member of 37s\"\n\n    assert_no_difference -> { User.count } do\n      result = identity.join(account)\n      assert_not result, \"join should return false when user already exists\"\n    end\n  end\nend\n"
  },
  {
    "path": "test/models/identity/transferable_test.rb",
    "content": "require \"test_helper\"\n\nclass Identity::TransferableTest < ActiveSupport::TestCase\n  test \"transfer_id\" do\n    identity = identities(:david)\n    transfer_id = identity.transfer_id\n\n    assert_kind_of String, transfer_id\n  end\n\n  test \"find_by_transfer_id\" do\n    identity = identities(:kevin)\n    transfer_id = identity.transfer_id\n\n    found = Identity.find_by_transfer_id(transfer_id)\n    assert_equal identity, found\n\n    found = Identity.find_by_transfer_id(\"invalid_id\")\n    assert_nil found\n\n    expired_id = identity.signed_id(purpose: :transfer, expires_in: -1.second)\n    found = Identity.find_by_transfer_id(expired_id)\n    assert_nil found\n  end\nend\n"
  },
  {
    "path": "test/models/identity_test.rb",
    "content": "require \"test_helper\"\n\nclass IdentityTest < ActiveSupport::TestCase\n  include ActionMailer::TestHelper\n\n  test \"send_magic_link\" do\n    identity = identities(:david)\n\n    assert_emails 1 do\n      magic_link = identity.send_magic_link\n      assert_not_nil magic_link\n      assert_equal identity, magic_link.identity\n    end\n  end\n\n  test \"email address format validation\" do\n    invalid_emails = [\n      \"sam smith@example.com\",       # space in local part\n      \"@example.com\",                # missing local part\n      \"test@\",                       # missing domain\n      \"test\",                        # missing @ and domain\n      \"<script>@example.com\",        # angle brackets\n      \"test@example.com\\nX-Inject:\" # newline (header injection attempt)\n    ]\n\n    invalid_emails.each do |email|\n      identity = Identity.new(email_address: email)\n      assert_not identity.valid?, \"expected #{email.inspect} to be invalid\"\n      assert identity.errors[:email_address].any?, \"expected error on email_address for #{email.inspect}\"\n    end\n  end\n\n  test \"join\" do\n    identity = identities(:david)\n    account = accounts(:initech)\n\n    Current.without_account do\n      assert_difference \"User.count\", 1 do\n        identity.join(account)\n      end\n\n      user = account.users.find_by!(identity: identity)\n\n      assert_not_nil user\n      assert_equal identity, user.identity\n      assert_equal identity.email_address, user.name\n    end\n  end\n\n  test \"destroy deactivates users before nullifying identity\" do\n    identity = identities(:kevin)\n    user = users(:kevin)\n\n    assert_predicate user, :active?\n    assert_predicate user.accesses, :any?\n\n    identity.destroy!\n    user.reload\n\n    assert_nil user.identity_id, \"identity should be nullified\"\n    assert_not_predicate user, :active?\n    assert_empty user.accesses, \"user accesses should be removed\"\n  end\nend\n"
  },
  {
    "path": "test/models/magic_link/code_test.rb",
    "content": "require \"test_helper\"\n\nclass MagicLink::CodeTest < ActiveSupport::TestCase\n  test \"generate\" do\n    code = MagicLink::Code.generate(6)\n\n    assert_equal 6, code.length\n    assert_match(/\\A[#{SecureRandom::BASE32_ALPHABET.join}]+\\z/, code)\n  end\n\n  test \"sanitize\" do\n    assert_equal \"011123\", MagicLink::Code.sanitize(\"OIL123\")\n    assert_equal \"ABC123\", MagicLink::Code.sanitize(\"ABC-123 !@#\")\n    assert_nil MagicLink::Code.sanitize(nil)\n    assert_nil MagicLink::Code.sanitize(\"\")\n  end\nend\n"
  },
  {
    "path": "test/models/magic_link_test.rb",
    "content": "require \"test_helper\"\n\nclass MagicLinkTest < ActiveSupport::TestCase\n  test \"new\" do\n    magic_link = MagicLink.create!(identity: identities(:kevin))\n\n    assert magic_link.code.present?\n    assert_equal MagicLink::CODE_LENGTH, magic_link.code.length\n    assert magic_link.expires_at.present?\n    assert_in_delta MagicLink::EXPIRATION_TIME.from_now, magic_link.expires_at, 1.second\n  end\n\n  test \"active\" do\n    active_link = MagicLink.create!(identity: identities(:kevin))\n    expired_link = MagicLink.create!(identity: identities(:kevin))\n    expired_link.update_column(:expires_at, 1.hour.ago)\n\n    assert_includes MagicLink.active, active_link\n    assert_not_includes MagicLink.active, expired_link\n  end\n\n  test \"stale\" do\n    active_link = MagicLink.create!(identity: identities(:kevin))\n    expired_link = MagicLink.create!(identity: identities(:kevin))\n    expired_link.update_column(:expires_at, 1.hour.ago)\n\n    assert_includes MagicLink.stale, expired_link\n    assert_not_includes MagicLink.stale, active_link\n  end\n\n  test \"consume\" do\n    magic_link = MagicLink.create!(identity: identities(:kevin))\n    code_with_spaces = magic_link.code.downcase.chars.join(\" \")\n\n    consumed_magic_link = MagicLink.consume(code_with_spaces)\n    assert_equal magic_link, consumed_magic_link\n    assert_not MagicLink.exists?(magic_link.id)\n\n    expired_link = MagicLink.create!(identity: identities(:kevin))\n    expired_link.update_column(:expires_at, 1.hour.ago)\n    assert_nil MagicLink.consume(expired_link.code)\n    assert MagicLink.exists?(expired_link.id)\n\n    assert_nil MagicLink.consume(\"INVALID\")\n    assert_nil MagicLink.consume(nil)\n  end\n\n  test \"cleanup\" do\n    active_link = MagicLink.create!(identity: identities(:kevin))\n    expired_link = MagicLink.create!(identity: identities(:kevin))\n    expired_link.update_column(:expires_at, 1.hour.ago)\n\n    MagicLink.cleanup\n\n    assert MagicLink.exists?(active_link.id)\n    assert_not MagicLink.exists?(expired_link.id)\n  end\nend\n"
  },
  {
    "path": "test/models/notification/bundle_test.rb",
    "content": "require \"test_helper\"\n\nclass Notification::BundleTest < ActiveSupport::TestCase\n  include ActionMailer::TestHelper\n\n  setup do\n    @user = users(:david)\n    @user.notifications.destroy_all\n    @user.settings.bundle_email_every_few_hours!\n  end\n\n  test \"new notifications are bundled\" do\n    notification = assert_difference -> { @user.notification_bundles.pending.count }, 1 do\n      @user.notifications.create!(source: events(:logo_published), creator: @user)\n    end\n\n    bundle = @user.notification_bundles.pending.last\n    assert_includes bundle.notifications, notification\n  end\n\n  test \"don't bundle new notifications if bundling is disabled\" do\n    @user.settings.bundle_email_never!\n\n    assert_no_difference -> { @user.notification_bundles.count } do\n      @user.notifications.create!(source: events(:logo_published), creator: @user)\n    end\n  end\n\n  test \"notifications are bundled within the aggregation period\" do\n    @user.notification_bundles.destroy_all\n\n    notification_1 = assert_difference -> { @user.notification_bundles.pending.count }, 1 do\n      @user.notifications.create!(source: events(:logo_published), creator: @user)\n    end\n    travel_to 3.hours.from_now\n\n    notification_2 = assert_no_difference -> { @user.notification_bundles.count } do\n      @user.notifications.create!(source: events(:layout_published), creator: @user)\n    end\n    travel_to 3.days.from_now\n\n    notification_3 = assert_difference -> { @user.notification_bundles.pending.count }, 1 do\n      @user.notifications.create!(source: events(:text_published), creator: @user)\n    end\n\n    assert_equal 2, @user.notification_bundles.count\n    bundle_1, bundle_2 = @user.notification_bundles.all.to_a\n    assert_includes bundle_1.notifications, notification_1\n    assert_includes bundle_1.notifications, notification_2\n    assert_includes bundle_2.notifications, notification_3\n  end\n\n  test \"overlapping bundles are invalid\" do\n    bundle_1 = @user.notification_bundles.create!(\n      starts_at: Time.current,\n      ends_at: 4.hours.from_now,\n      status: :pending\n    )\n\n    bundle_2 = @user.notification_bundles.build(\n      starts_at: 2.hours.from_now,\n      ends_at: 6.hours.from_now,\n      status: :pending\n    )\n    assert_not bundle_2.valid?\n\n    # Bundle with overlapping end time should be invalid\n    bundle_3 = @user.notification_bundles.build(\n      starts_at: 2.hours.ago,\n      ends_at: 2.hours.from_now,\n      status: :pending\n    )\n    assert_not bundle_3.valid?\n\n    # Bundle completely within another bundle should be invalid\n    bundle_4 = @user.notification_bundles.build(\n      starts_at: 1.hour.from_now,\n      ends_at: 3.hours.from_now,\n      status: :pending\n    )\n    assert_not bundle_4.valid?\n\n    # Non-overlapping bundle should be valid\n    bundle_5 = @user.notification_bundles.build(\n      starts_at: 5.hours.from_now,\n      ends_at: 9.hours.from_now,\n      status: :pending\n    )\n    assert bundle_5.valid?\n  end\n\n  test \"overlapping bundles that are created relying on set_default_window are not created\" do\n    @user.notification_bundles.destroy_all\n\n    bundle = @user.notification_bundles.create!(starts_at: Time.current)\n\n    assert_raises ActiveRecord::RecordInvalid do\n      @user.notification_bundles.create!(starts_at: bundle.starts_at - 1.second)\n    end\n  end\n\n  test \"deliver_all delivers due bundles\" do\n    @user.notification_bundles.destroy_all\n\n    notification = @user.notifications.create!(source: events(:logo_published), creator: @user)\n\n    bundle = @user.notification_bundles.pending.last\n\n    assert bundle.pending?\n    assert_includes bundle.notifications, notification\n\n    bundle.update!(ends_at: 1.minute.ago)\n\n    perform_enqueued_jobs only: Notification::Bundle::DeliverJob do\n      Notification::Bundle.deliver_all\n    end\n\n    bundle.reload\n    assert bundle.delivered?\n  end\n\n  test \"deliver_all don't deliver bundles that are not due\" do\n    @user.notifications.create!(source: events(:logo_published), creator: @user)\n    bundle = @user.notification_bundles.pending.last\n\n    bundle.update!(ends_at: 1.minute.from_now)\n\n    perform_enqueued_jobs only: Notification::Bundle::DeliverJob do\n      Notification::Bundle.deliver_all\n    end\n\n    bundle.reload\n    assert bundle.pending?\n  end\n\n  test \"deliver sends email with time in user's time zone\" do\n    @user.settings.update!(timezone_name: \"Madrid\")\n\n    freeze_time Time.utc(2025, 1, 15, 14, 30, 0) do\n      @user.notifications.create!(source: events(:logo_published), creator: @user)\n      bundle = @user.notification_bundles.pending.last\n      bundle.deliver\n\n      email = ActionMailer::Base.deliveries.last\n      assert_not_nil email\n\n      # Time in Madrid should be 15:30 (UTC+1 in winter)\n      assert_match /notifications since 3pm/i, email.text_part&.body&.to_s\n    end\n  end\n\n  test \"out-of-order notification bundling should still work\" do\n    first_notification = @user.notifications.create!(source: events(:logo_published), creator: @user)\n    second_notification = @user.notifications.create!(source: events(:layout_commented), creator: @user)\n    @user.notification_bundles.destroy_all\n\n    assert first_notification.updated_at <= second_notification.updated_at\n    @user.bundle(second_notification)\n    @user.bundle(first_notification)\n\n    assert_equal 1, @user.notification_bundles.pending.count\n    assert_equal 2, @user.notification_bundles.last.notifications.count\n    assert_includes @user.notification_bundles.last.notifications, first_notification\n    assert_includes @user.notification_bundles.last.notifications, second_notification\n  end\n\n  test \"deliver does not send email for cancelled accounts\" do\n    @user.notifications.create!(source: events(:logo_published), creator: @user)\n    bundle = @user.notification_bundles.pending.last\n\n    @user.account.cancel(initiated_by: @user)\n\n    assert_no_emails do\n      deliver_enqueued_emails do\n        bundle.deliver\n      end\n    end\n\n    assert bundle.delivered?, \"Bundle should be marked as delivered even if not sent\"\n  end\nend\n"
  },
  {
    "path": "test/models/notification/push_target/web_test.rb",
    "content": "require \"test_helper\"\n\nclass Notification::PushTarget::WebTest < ActiveSupport::TestCase\n  setup do\n    @user = users(:david)\n    @notification = notifications(:logo_mentioned_david)\n\n    @user.push_subscriptions.create!(\n      endpoint: \"https://fcm.googleapis.com/fcm/send/test123\",\n      p256dh_key: \"test_key\",\n      auth_key: \"test_auth\"\n    )\n\n    @web_push_pool = mock(\"web_push_pool\")\n    Rails.configuration.x.stubs(:web_push_pool).returns(@web_push_pool)\n  end\n\n  test \"pushes to web when user has subscriptions\" do\n    @web_push_pool.expects(:queue).once.with do |payload, subscriptions|\n      payload.is_a?(Hash) &&\n        payload[:title].present? &&\n        payload[:body].present? &&\n        payload[:url].present? &&\n        subscriptions.count == 1\n    end\n\n    Notification::PushTarget::Web.new(@notification).process\n  end\n\n  test \"does not push when user has no subscriptions\" do\n    @user.push_subscriptions.delete_all\n    @web_push_pool.expects(:queue).never\n\n    Notification::PushTarget::Web.new(@notification).process\n  end\n\n  test \"payload includes card title for card events\" do\n    @notification.update!(source: events(:logo_published))\n\n    @web_push_pool.expects(:queue).once.with do |payload, _|\n      payload[:title] == @notification.card.title\n    end\n\n    Notification::PushTarget::Web.new(@notification).process\n  end\n\n  test \"payload for comment includes RE prefix\" do\n    event = events(:layout_commented)\n    notification = @user.notifications.create!(source: event, creator: event.creator)\n\n    @web_push_pool.expects(:queue).once.with do |payload, _|\n      payload[:title].start_with?(\"RE:\")\n    end\n\n    Notification::PushTarget::Web.new(notification).process\n  end\n\n  test \"payload for assignment includes assigned message\" do\n    @notification.update!(source: events(:logo_assignment_david))\n\n    @web_push_pool.expects(:queue).once.with do |payload, _|\n      payload[:body].include?(\"Assigned to you\")\n    end\n\n    Notification::PushTarget::Web.new(@notification).process\n  end\n\n  test \"payload for triage includes column name\" do\n    event = events(:logo_published)\n    event.update!(action: \"card_triaged\", particulars: { \"particulars\" => { \"column\" => \"In Progress\" } })\n    @notification.update!(source: event)\n\n    @web_push_pool.expects(:queue).once.with do |payload, _|\n      payload[:body] == \"Moved to In Progress by #{event.creator.name}\"\n    end\n\n    Notification::PushTarget::Web.new(@notification).process\n  end\n\n  test \"payload for triage falls back when column name is missing\" do\n    event = events(:logo_published)\n    event.update!(action: \"card_triaged\", particulars: {})\n    @notification.update!(source: event)\n\n    @web_push_pool.expects(:queue).once.with do |payload, _|\n      payload[:body] == \"Moved by #{event.creator.name}\"\n    end\n\n    Notification::PushTarget::Web.new(@notification).process\n  end\n\n  test \"payload for triage falls back when column name is blank\" do\n    event = events(:logo_published)\n    event.update!(action: \"card_triaged\", particulars: { \"particulars\" => { \"column\" => \"\" } })\n    @notification.update!(source: event)\n\n    @web_push_pool.expects(:queue).once.with do |payload, _|\n      payload[:body] == \"Moved by #{event.creator.name}\"\n    end\n\n    Notification::PushTarget::Web.new(@notification).process\n  end\n\n  test \"payload for sent back to triage includes Maybe?\" do\n    event = events(:logo_published)\n    event.update!(action: \"card_sent_back_to_triage\")\n    @notification.update!(source: event)\n\n    @web_push_pool.expects(:queue).once.with do |payload, _|\n      payload[:body] == \"Moved back to Maybe? by #{event.creator.name}\"\n    end\n\n    Notification::PushTarget::Web.new(@notification).process\n  end\n\n  test \"payload for board change includes new board name\" do\n    event = events(:logo_published)\n    event.update!(\n      action: \"card_board_changed\",\n      particulars: { \"particulars\" => { \"old_board\" => \"Old Board\", \"new_board\" => \"New Board\" } }\n    )\n    @notification.update!(source: event)\n\n    @web_push_pool.expects(:queue).once.with do |payload, _|\n      payload[:body] == \"Moved to New Board by #{event.creator.name}\"\n    end\n\n    Notification::PushTarget::Web.new(@notification).process\n  end\n\n  test \"payload for board change falls back when board name is missing\" do\n    event = events(:logo_published)\n    event.update!(action: \"card_board_changed\", particulars: {})\n    @notification.update!(source: event)\n\n    @web_push_pool.expects(:queue).once.with do |payload, _|\n      payload[:body] == \"Moved by #{event.creator.name}\"\n    end\n\n    Notification::PushTarget::Web.new(@notification).process\n  end\n\n  test \"payload for board change falls back when board name is blank\" do\n    event = events(:logo_published)\n    event.update!(\n      action: \"card_board_changed\",\n      particulars: { \"particulars\" => { \"new_board\" => \"\" } }\n    )\n    @notification.update!(source: event)\n\n    @web_push_pool.expects(:queue).once.with do |payload, _|\n      payload[:body] == \"Moved by #{event.creator.name}\"\n    end\n\n    Notification::PushTarget::Web.new(@notification).process\n  end\n\n  test \"payload for collection change includes new collection name\" do\n    event = events(:logo_published)\n    event.update!(\n      action: \"card_collection_changed\",\n      particulars: { \"particulars\" => { \"new_collection\" => \"New Collection\" } }\n    )\n    @notification.update!(source: event)\n\n    @web_push_pool.expects(:queue).once.with do |payload, _|\n      payload[:body] == \"Moved to New Collection by #{event.creator.name}\"\n    end\n\n    Notification::PushTarget::Web.new(@notification).process\n  end\n\n  test \"payload for collection change falls back when collection name is missing\" do\n    event = events(:logo_published)\n    event.update!(action: \"card_collection_changed\", particulars: {})\n    @notification.update!(source: event)\n\n    @web_push_pool.expects(:queue).once.with do |payload, _|\n      payload[:body] == \"Moved by #{event.creator.name}\"\n    end\n\n    Notification::PushTarget::Web.new(@notification).process\n  end\n\n  test \"payload for collection change falls back when collection name is blank\" do\n    event = events(:logo_published)\n    event.update!(action: \"card_collection_changed\", particulars: { \"particulars\" => { \"new_collection\" => \"\" } })\n    @notification.update!(source: event)\n\n    @web_push_pool.expects(:queue).once.with do |payload, _|\n      payload[:body] == \"Moved by #{event.creator.name}\"\n    end\n\n    Notification::PushTarget::Web.new(@notification).process\n  end\n\n  test \"payload for title change includes new title\" do\n    event = events(:logo_published)\n    event.update!(\n      action: \"card_title_changed\",\n      particulars: { \"particulars\" => { \"old_title\" => \"Old Title\", \"new_title\" => \"New Title\" } }\n    )\n    @notification.update!(source: event)\n\n    @web_push_pool.expects(:queue).once.with do |payload, _|\n      payload[:body] == \"Renamed to New Title by #{event.creator.name}\"\n    end\n\n    Notification::PushTarget::Web.new(@notification).process\n  end\n\n  test \"payload for title change falls back when title is missing\" do\n    event = events(:logo_published)\n    event.update!(action: \"card_title_changed\", particulars: {})\n    @notification.update!(source: event)\n\n    @web_push_pool.expects(:queue).once.with do |payload, _|\n      payload[:body] == \"Renamed by #{event.creator.name}\"\n    end\n\n    Notification::PushTarget::Web.new(@notification).process\n  end\n\n  test \"payload for title change falls back when title is blank\" do\n    event = events(:logo_published)\n    event.update!(action: \"card_title_changed\", particulars: { \"particulars\" => { \"new_title\" => \"\" } })\n    @notification.update!(source: event)\n\n    @web_push_pool.expects(:queue).once.with do |payload, _|\n      payload[:body] == \"Renamed by #{event.creator.name}\"\n    end\n\n    Notification::PushTarget::Web.new(@notification).process\n  end\n\n  test \"payload for postponed includes Not Now\" do\n    event = events(:logo_published)\n    event.update!(action: \"card_postponed\")\n    @notification.update!(source: event)\n\n    @web_push_pool.expects(:queue).once.with do |payload, _|\n      payload[:body] == \"Moved to Not Now by #{event.creator.name}\"\n    end\n\n    Notification::PushTarget::Web.new(@notification).process\n  end\n\n  test \"payload for auto postponed includes inactivity message\" do\n    event = events(:logo_published)\n    event.update!(action: \"card_auto_postponed\")\n    @notification.update!(source: event)\n\n    @web_push_pool.expects(:queue).once.with do |payload, _|\n      payload[:body] == \"Moved to Not Now due to inactivity\"\n    end\n\n    Notification::PushTarget::Web.new(@notification).process\n  end\n\n  test \"payload for unhandled action uses updated fallback message\" do\n    event = events(:logo_published)\n    event.update!(action: \"card_unassigned\")\n    @notification.update!(source: event)\n\n    @web_push_pool.expects(:queue).once.with do |payload, _|\n      payload[:body] == \"Updated by #{event.creator.name}\"\n    end\n\n    Notification::PushTarget::Web.new(@notification).process\n  end\n\n  test \"payload for mention includes mentioner name\" do\n    @web_push_pool.expects(:queue).once.with do |payload, _|\n      payload[:title].include?(\"mentioned you\")\n    end\n\n    Notification::PushTarget::Web.new(@notification).process\n  end\nend\n"
  },
  {
    "path": "test/models/notification/pushable_test.rb",
    "content": "require \"test_helper\"\n\nclass Notification::PushableTest < ActiveSupport::TestCase\n  setup do\n    @user = users(:david)\n    @notification = notifications(:logo_mentioned_david)\n  end\n\n  test \"push_later enqueues Notification::PushJob\" do\n    assert_enqueued_with(job: Notification::PushJob, args: [ @notification ]) do\n      @notification.push_later\n    end\n  end\n\n  test \"push calls process on all registered targets\" do\n    target_class = mock(\"push_target_class\")\n    target_class.expects(:process).with(@notification)\n\n    original_targets = Notification.push_targets\n    Notification.push_targets = [ target_class ]\n\n    @notification.push\n  ensure\n    Notification.push_targets = original_targets\n  end\n\n  test \"push_later is called after notification is created\" do\n    assert_enqueued_with(job: Notification::PushJob) do\n      @user.notifications.create!(\n        source: events(:layout_published),\n        creator: users(:jason)\n      )\n    end\n  end\n\n  test \"push_later is called when notification source changes\" do\n    assert_enqueued_with(job: Notification::PushJob) do\n      @notification.update!(source: events(:logo_published))\n    end\n  end\n\n  test \"push_later is not called for other updates\" do\n    assert_no_enqueued_jobs only: Notification::PushJob do\n      @notification.update!(unread_count: 5)\n    end\n  end\n\n  test \"register_push_target accepts symbols\" do\n    original_targets = Notification.push_targets.dup\n\n    Notification.register_push_target(:web)\n\n    assert_includes Notification.push_targets, Notification::PushTarget::Web\n  ensure\n    Notification.push_targets = original_targets\n  end\n\n  test \"push processes targets for normal notifications\" do\n    target_class = mock(\"push_target_class\")\n    target_class.expects(:process).with(@notification)\n\n    original_targets = Notification.push_targets\n    Notification.push_targets = [ target_class ]\n\n    @notification.push\n  ensure\n    Notification.push_targets = original_targets\n  end\n\n  test \"push skips targets when creator is system user\" do\n    @notification.update!(creator: users(:system))\n\n    target_class = mock(\"push_target_class\")\n    target_class.expects(:process).never\n\n    original_targets = Notification.push_targets\n    Notification.push_targets = [ target_class ]\n\n    @notification.push\n  ensure\n    Notification.push_targets = original_targets\n  end\n\n  test \"push skips targets for cancelled accounts\" do\n    @user.account.cancel(initiated_by: @user)\n\n    target_class = mock(\"push_target_class\")\n    target_class.expects(:process).never\n\n    original_targets = Notification.push_targets\n    Notification.push_targets = [ target_class ]\n\n    @notification.push\n  ensure\n    Notification.push_targets = original_targets\n  end\nend\n"
  },
  {
    "path": "test/models/notification_test.rb",
    "content": "require \"test_helper\"\n\nclass NotificationTest < ActiveSupport::TestCase\n  test \"read marks notification as read\" do\n    notification = notifications(:logo_assignment_kevin)\n    notification.update!(read_at: nil, unread_count: 2)\n\n    assert_changes -> { notification.reload.read? }, from: false, to: true do\n      notification.read\n    end\n\n    assert_equal 0, notification.unread_count\n  end\n\n  test \"unread marks notification as unread\" do\n    notification = notifications(:logo_assignment_kevin)\n    notification.read\n\n    assert_changes -> { notification.reload.read? }, from: true, to: false do\n      notification.unread\n    end\n\n    assert_equal 1, notification.unread_count\n  end\n\n  test \"read_all marks all notifications and resets unread counts\" do\n    kevin = users(:kevin)\n\n    kevin.notifications.unread.read_all\n\n    assert kevin.notifications.reload.all?(&:read?)\n    assert kevin.notifications.reload.all? { |n| n.unread_count == 0 }\n  end\n\n  test \"unread_count tracks notification count per card\" do\n    notification = notifications(:logo_assignment_kevin)\n    assert_equal 2, notification.unread_count\n  end\n\n  test \"broadcasting on create prepends\" do\n    kevin = users(:kevin)\n    layout = cards(:layout)\n\n    notifications(:layout_commented_kevin).destroy\n\n    perform_enqueued_jobs do\n      Notification.create!(user: kevin, source: events(:layout_commented), creator: users(:david))\n    end\n\n    assert_turbo_stream_broadcasts [ kevin, :notifications ]\n  end\n\n  test \"broadcasting on update when read removes\" do\n    notification = notifications(:layout_commented_kevin)\n\n    assert_turbo_stream_broadcasts([ notification.user, :notifications ], count: 1) do\n      perform_enqueued_jobs do\n        notification.read\n      end\n    end\n  end\n\n  test \"broadcasting on destroy removes\" do\n    notification = notifications(:logo_assignment_kevin)\n\n    assert_turbo_stream_broadcasts([ notification.user, :notifications ], count: 1) do\n      notification.destroy\n    end\n  end\nend\n"
  },
  {
    "path": "test/models/notifier/event_notifier_test.rb",
    "content": "require \"test_helper\"\n\nclass Notifier::EventNotifierTest < ActiveSupport::TestCase\n  test \"for returns the matching notifier class for the event\" do\n    assert_kind_of Notifier::CardEventNotifier, Notifier.for(events(:logo_published))\n  end\n\n  test \"generate does not create notifications if the event was system-generated\" do\n    cards(:logo).drafted!\n    events(:logo_published).update!(creator: accounts(\"37s\").system_user)\n\n    assert_no_difference -> { Notification.count } do\n      Notifier.for(events(:logo_published)).notify\n    end\n  end\n\n  test \"creates a notification for each watcher, other than the event creator (events)\" do\n    notifications = Notifier.for(events(:layout_commented)).notify\n\n    assert_equal [ users(:kevin) ], notifications.map(&:user)\n  end\n\n  test \"creates a notification for each watcher (mentions)\" do\n    notifications = Notifier.for(events(:layout_commented)).notify\n\n    assert_equal [ users(:kevin) ], notifications.map(&:user)\n  end\n\n  test \"does not create a notification for access-only users\" do\n    boards(:writebook).access_for(users(:kevin)).access_only!\n\n    notifications = Notifier.for(events(:layout_commented)).notify\n\n    assert_equal [ users(:kevin) ], notifications.map(&:user)\n  end\n\n  test \"links to the card\" do\n    boards(:writebook).access_for(users(:kevin)).watching!\n\n    Notifier.for(events(:logo_published)).notify\n\n    assert_equal cards(:logo), Notification.last.source.eventable\n  end\n\n  test \"assignment events only create a notification for the assignee\" do\n    boards(:writebook).access_for(users(:jz)).watching!\n    boards(:writebook).access_for(users(:kevin)).watching!\n\n    notifications = Notifier.for(events(:logo_assignment_jz)).notify\n\n    assert_equal [ users(:jz) ], notifications.map(&:user)\n  end\n\n  test \"assignment events do not notify users who are access-only for the board\" do\n    boards(:writebook).access_for(users(:jz)).watching!\n    events(:logo_assignment_jz).update! creator: users(:jz)\n\n    notifications = Notifier.for(events(:logo_assignment_jz)).notify\n\n    assert_empty notifications\n  end\n\n  test \"assignment events do not notify you if you assigned yourself\" do\n    boards(:writebook).access_for(users(:david)).watching!\n\n    notifications = Notifier.for(events(:logo_assignment_david)).notify\n\n    assert_empty notifications\n  end\n\n  test \"create notifications on publish for mentionees\" do\n    users(:kevin).mentioned_by(users(:david), at: cards(:logo))\n\n    notifications = Notifier.for(events(:logo_published)).notify\n\n    assert_includes notifications.map(&:user), users(:kevin)\n  end\n\n  test \"create notifications on publish for mentionees that are not watching\" do\n    users(:kevin).mentioned_by(users(:david), at: cards(:logo))\n    cards(:logo).unwatch_by(users(:kevin))\n\n    notifications = Notifier.for(events(:logo_published)).notify\n\n    assert_includes notifications.map(&:user), users(:kevin)\n  end\n\n  test \"don't create notifications on comment for mentionees\" do\n    users(:david).mentioned_by(users(:kevin), at: cards(:layout))\n\n    assert_no_difference -> { users(:david).notifications.count } do\n      Notifier.for(events(:layout_commented)).notify\n    end\n  end\n\n  test \"don't create notifications on comment for mentionees even before mention records exist\" do\n    comment = cards(:layout).comments.create!(\n      body: \"Hey #{mention_html_for(users(:kevin))}, what do you think?\",\n      creator: users(:david)\n    )\n    event = boards(:writebook).events.create!(\n      action: \"comment_created\", creator: users(:david), eventable: comment\n    )\n\n    assert_empty comment.mentionees, \"Mention records should not exist yet\"\n\n    notifications = Notifier.for(event).notify\n\n    assert_not_includes notifications.map(&:user), users(:kevin)\n  end\n\n  test \"assignment events notify assignees regardless of involvement level\" do\n    boards(:writebook).access_for(users(:jz)).access_only!\n\n    notifications = Notifier.for(events(:logo_assignment_jz)).notify\n\n    assert_equal [ users(:jz) ], notifications.map(&:user)\n  end\n\n  private\n    def mention_html_for(user)\n      ActionText::Attachment.from_attachable(user).to_html\n    end\nend\n"
  },
  {
    "path": "test/models/notifier/mention_notifier_test.rb",
    "content": "require \"test_helper\"\n\nclass Notifier::EventNotifierTest < ActiveSupport::TestCase\n  test \"for returns the matching notifier class for the mention\" do\n    assert_kind_of Notifier::MentionNotifier, Notifier.for(mentions(:logo_card_david_mention_by_jz))\n  end\n\n  test \"notify the mentionee\" do\n    users(:kevin).mentioned_by(users(:david), at: cards(:logo))\n\n    assert_no_difference -> { users(:kevin).notifications.count } do\n      Notifier.for(mentions(:logo_card_david_mention_by_jz)).notify\n    end\n  end\n\n  test \"create notifications for mentionee\" do\n    assert_no_difference -> { users(:david).notifications.count } do\n      Notifier.for(events(:layout_commented)).notify\n    end\n  end\n\n  test \"don't create notifications for self-mentions\" do\n    assert_no_difference -> { users(:jz).notifications.count } do\n      Notifier.for(events(:layout_commented)).notify\n    end\n  end\n\n  test \"updates source_type correctly even when a concurrent job modifies it between load and save\" do\n    # Start with a notification sourced from a Mention for kevin on the layout card\n    notifications(:layout_commented_kevin).destroy\n    mention = users(:kevin).mentioned_by(users(:david), at: comments(:layout_overflowing_david))\n    notification = Notification.create!(\n      user: users(:kevin), card: cards(:layout),\n      source: mention, creator: users(:david), unread_count: 1\n    )\n\n    # Override create_or_find_by to simulate a concurrent EventNotifier updating\n    # source_type in the database after the notification is loaded but before\n    # the MentionNotifier's update! runs — reproducing the race condition where\n    # Rails' dirty tracking skips source_type because it hasn't changed from\n    # the stale in-memory value.\n    Notification.class_eval do\n      class << self\n        alias_method :original_create_or_find_by, :create_or_find_by\n\n        def create_or_find_by(...)\n          original_create_or_find_by(...).tap do |record|\n            unless record.previously_new_record?\n              where(id: record.id).update_all(source_type: \"Event\")\n            end\n          end\n        end\n      end\n    end\n\n    new_mention = users(:kevin).mentioned_by(users(:jz), at: comments(:layout_overflowing_david))\n    Notifier.for(new_mention).notify\n\n    notification.reload\n    assert_equal \"Mention\", notification.source_type\n    assert_equal new_mention, notification.source\n  ensure\n    Notification.class_eval do\n      class << self\n        alias_method :create_or_find_by, :original_create_or_find_by\n        remove_method :original_create_or_find_by\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "test/models/push/subscription_test.rb",
    "content": "require \"test_helper\"\n\nclass Push::SubscriptionTest < ActiveSupport::TestCase\n  PUBLIC_TEST_IP = \"142.250.185.206\" # google.com IP\n\n  setup do\n    stub_dns_resolution(PUBLIC_TEST_IP)\n  end\n\n  test \"valid subscription with permitted endpoint\" do\n    subscription = Push::Subscription.new(\n      user: users(:david),\n      endpoint: \"https://fcm.googleapis.com/fcm/send/abc123\",\n      p256dh_key: \"test_key\",\n      auth_key: \"test_auth\"\n    )\n\n    assert subscription.valid?\n  end\n\n  test \"rejects endpoint with non-https scheme\" do\n    subscription = Push::Subscription.new(\n      user: users(:david),\n      endpoint: \"http://fcm.googleapis.com/fcm/send/abc123\",\n      p256dh_key: \"test_key\",\n      auth_key: \"test_auth\"\n    )\n\n    assert_not subscription.valid?\n    assert_includes subscription.errors[:endpoint], \"must use HTTPS\"\n  end\n\n  test \"rejects endpoint with non-permitted host\" do\n    subscription = Push::Subscription.new(\n      user: users(:david),\n      endpoint: \"https://attacker.example.com/webhook\",\n      p256dh_key: \"test_key\",\n      auth_key: \"test_auth\"\n    )\n\n    assert_not subscription.valid?\n    assert_includes subscription.errors[:endpoint], \"is not a permitted push service\"\n  end\n\n  test \"rejects endpoint that resolves to private IP\" do\n    stub_dns_resolution(\"192.168.1.1\")\n\n    subscription = Push::Subscription.new(\n      user: users(:david),\n      endpoint: \"https://fcm.googleapis.com/fcm/send/abc123\",\n      p256dh_key: \"test_key\",\n      auth_key: \"test_auth\"\n    )\n\n    assert_not subscription.valid?\n    assert_includes subscription.errors[:endpoint], \"resolves to a private or invalid IP address\"\n  end\n\n  test \"rejects endpoint that resolves to loopback IP\" do\n    stub_dns_resolution(\"127.0.0.1\")\n\n    subscription = Push::Subscription.new(\n      user: users(:david),\n      endpoint: \"https://fcm.googleapis.com/fcm/send/abc123\",\n      p256dh_key: \"test_key\",\n      auth_key: \"test_auth\"\n    )\n\n    assert_not subscription.valid?\n    assert_includes subscription.errors[:endpoint], \"resolves to a private or invalid IP address\"\n  end\n\n  test \"rejects endpoint that resolves to link-local IP (AWS IMDS)\" do\n    stub_dns_resolution(\"169.254.169.254\")\n\n    subscription = Push::Subscription.new(\n      user: users(:david),\n      endpoint: \"https://fcm.googleapis.com/fcm/send/abc123\",\n      p256dh_key: \"test_key\",\n      auth_key: \"test_auth\"\n    )\n\n    assert_not subscription.valid?\n    assert_includes subscription.errors[:endpoint], \"resolves to a private or invalid IP address\"\n  end\n\n  test \"resolved_endpoint_ip returns pinned public IP\" do\n    subscription = Push::Subscription.new(\n      user: users(:david),\n      endpoint: \"https://fcm.googleapis.com/fcm/send/abc123\",\n      p256dh_key: \"test_key\",\n      auth_key: \"test_auth\"\n    )\n\n    assert_equal PUBLIC_TEST_IP, subscription.resolved_endpoint_ip\n  end\n\n  test \"accepts all permitted push service domains\" do\n    permitted_endpoints = [\n      \"https://fcm.googleapis.com/fcm/send/token123\",\n      \"https://jmt17.google.com/fcm/send/token123\",\n      \"https://updates.push.services.mozilla.com/wpush/v2/token123\",\n      \"https://web.push.apple.com/QaBC123\",\n      \"https://wns2-db5p.notify.windows.com/w/?token=abc123\"\n    ]\n\n    permitted_endpoints.each do |endpoint|\n      subscription = Push::Subscription.new(\n        user: users(:david),\n        endpoint: endpoint,\n        p256dh_key: \"test_key\",\n        auth_key: \"test_auth\"\n      )\n\n      assert subscription.valid?, \"Expected #{endpoint} to be valid, got errors: #{subscription.errors.full_messages}\"\n    end\n  end\n\n  private\n    def stub_dns_resolution(*ips)\n      dns_mock = mock(\"dns\")\n      dns_mock.stubs(:each_address).multiple_yields(*ips)\n      Resolv::DNS.stubs(:open).yields(dns_mock)\n    end\nend\n"
  },
  {
    "path": "test/models/qr_code_link_test.rb",
    "content": "require \"test_helper\"\n\nclass QrCodeLinkTest < ActiveSupport::TestCase\n  test \"links can be signed and verified\" do\n    link = QrCodeLink.new \"https://example.com\"\n    signed_link = link.signed\n\n    verified = QrCodeLink.from_signed(signed_link)\n    assert_equal link.url, verified.url\n  end\nend\n"
  },
  {
    "path": "test/models/reaction_test.rb",
    "content": "require \"test_helper\"\n\nclass ReactionTest < ActiveSupport::TestCase\n  setup do\n    Current.session = sessions(:david)\n  end\n\n  test \"creating a comment reaction touches the card activity\" do\n    assert_changes -> { cards(:logo).reload.last_active_at } do\n      comments(:logo_1).reactions.create!(content: \"Nice!\")\n    end\n  end\n\n  test \"reactions are deleted when comment is destroyed\" do\n    comment = comments(:logo_1)\n    comment.reactions.create!(content: \"👍\")\n    reaction_ids = comment.reactions.pluck(:id)\n\n    assert reaction_ids.any?, \"Expected comment to have reactions\"\n\n    comment.destroy\n\n    assert_empty Reaction.where(id: reaction_ids)\n  end\n\n  test \"creating a card reaction touches the card activity\" do\n    card = cards(:logo)\n\n    assert_changes -> { card.reload.last_active_at } do\n      card.reactions.create!(content: \"🎉\")\n    end\n  end\n\n  test \"reactions are deleted when card is destroyed\" do\n    card = cards(:logo)\n    reaction_ids = card.reactions.pluck(:id)\n\n    assert reaction_ids.any?, \"Expected card to have reactions\"\n\n    card.destroy\n\n    assert_empty Reaction.where(id: reaction_ids)\n  end\nend\n"
  },
  {
    "path": "test/models/search/highlighter_test.rb",
    "content": "require \"test_helper\"\n\nclass Search::HighlighterTest < ActiveSupport::TestCase\n  test \"highlight simple word match\" do\n    highlighter = Search::Highlighter.new(\"hello\")\n    result = highlighter.highlight(\"Hello world\")\n\n    assert_equal \"#{mark('Hello')} world\", result\n  end\n\n  test \"highlight multiple occurrences\" do\n    highlighter = Search::Highlighter.new(\"test\")\n    result = highlighter.highlight(\"This is a test and another test\")\n\n    assert_equal \"This is a #{mark('test')} and another #{mark('test')}\", result\n  end\n\n  test \"highlight case insensitive\" do\n    highlighter = Search::Highlighter.new(\"ruby\")\n    result = highlighter.highlight(\"Ruby is great and RUBY rocks\")\n\n    assert_equal \"#{mark('Ruby')} is great and #{mark('RUBY')} rocks\", result\n  end\n\n  test \"highlight quoted phrases\" do\n    highlighter = Search::Highlighter.new('\"hello world\"')\n    result = highlighter.highlight(\"Say hello world to everyone\")\n\n    assert_equal \"Say #{mark('hello world')} to everyone\", result\n  end\n\n  test \"snippet returns full text with highlights when under max words\" do\n    highlighter = Search::Highlighter.new(\"ruby\")\n    result = highlighter.snippet(\"Ruby is great\", max_words: 20)\n\n    assert_equal \"#{mark('Ruby')} is great\", result\n  end\n\n  test \"snippet creates excerpt around match\" do\n    highlighter = Search::Highlighter.new(\"match\")\n    text = \"word \" * 10 + \"match \" + \"word \" * 10\n    result = highlighter.snippet(text, max_words: 10)\n\n    assert result.start_with?(\"...\")\n    assert result.end_with?(\"...\")\n    assert_includes result, mark(\"match\")\n  end\n\n  test \"snippet adds leading ellipsis when match is not at start\" do\n    highlighter = Search::Highlighter.new(\"middle\")\n    text = \"word \" * 20 + \"middle\"\n    result = highlighter.snippet(text, max_words: 10)\n\n    assert result.start_with?(\"...\")\n    assert_not result.end_with?(\"...\")\n    assert_includes result, mark(\"middle\")\n  end\n\n  test \"snippet adds trailing ellipsis when text continues after excerpt\" do\n    highlighter = Search::Highlighter.new(\"start\")\n    text = \"start \" + \"word \" * 30\n    result = highlighter.snippet(text, max_words: 10)\n\n    assert result.end_with?(\"...\")\n    assert_not result.start_with?(\"...\")\n    assert_includes result, mark(\"start\")\n  end\n\n  test \"snippet falls back to truncation when no match found\" do\n    highlighter = Search::Highlighter.new(\"nomatch\")\n    text = \"This text does not contain the search term \" + \"word \" * 50\n    result = highlighter.snippet(text, max_words: 10)\n\n    assert_includes result, \"...\"\n    assert_not_includes result, Search::Highlighter::OPENING_MARK\n  end\n\n  test \"highlight escapes HTML and preserves marks\" do\n    highlighter = Search::Highlighter.new(\"test\")\n    result = highlighter.highlight(\"<script>test</script>\")\n\n    assert_equal \"&lt;script&gt;#{mark('test')}&lt;/script&gt;\", result\n  end\n\n  private\n    def mark(text)\n      \"#{Search::Highlighter::OPENING_MARK}#{text}#{Search::Highlighter::CLOSING_MARK}\"\n    end\nend\n"
  },
  {
    "path": "test/models/search/stemmer_test.rb",
    "content": "require \"test_helper\"\n\nclass Search::StemmerTest < ActiveSupport::TestCase\n  test \"stem single word\" do\n    result = Search::Stemmer.stem(\"running\")\n\n    assert_equal \"run\", result\n  end\n\n  test \"stem multiple words\" do\n    result = Search::Stemmer.stem(\"test, running      JUMPING & walking\")\n\n    assert_equal \"test run jump walk\", result\n  end\n\n  test \"stem hyphenated words\" do\n    result = Search::Stemmer.stem(\"BC3-IOS-1D8B\")\n\n    assert_equal \"bc3 io 1d8b\", result\n  end\n\n  test \"stem words separated by repeated punctuation\" do\n    result = Search::Stemmer.stem(\"foo---bar\")\n\n    assert_equal \"foo bar\", result\n  end\nend\n"
  },
  {
    "path": "test/models/search_test.rb",
    "content": "require \"test_helper\"\n\nclass SearchTest < ActiveSupport::TestCase\n  include SearchTestHelper\n\n  test \"search\" do\n    # Search cards and comments\n    card = @board.cards.create!(title: \"layout design\", creator: @user, status: \"published\")\n    comment_card = @board.cards.create!(title: \"Some card\", creator: @user, status: \"published\")\n    comment_card.comments.create!(body: \"overflowing text\", creator: @user)\n\n    results = Search::Record.for(@user.account_id).search(\"layout\", user: @user)\n    assert results.find { |it| it.card_id == card.id }\n\n    results = Search::Record.for(@user.account_id).search(\"overflowing\", user: @user)\n    assert results.find { |it| it.card_id == comment_card.id && it.searchable_type == \"Comment\" }\n\n    # Drafted cards are excluded from search results\n    drafted_card = @board.cards.create!(title: \"drafted searchable content\", creator: @user, status: \"drafted\")\n    results = Search::Record.for(@user.account_id).search(\"drafted\", user: @user)\n    assert_not results.find { |it| it.card_id == drafted_card.id }\n\n    # Don't include inaccessible boards\n    other_user = User.create!(name: \"Other User\", account: @account)\n    inaccessible_board = Board.create!(name: \"Inaccessible Board\", account: @account, creator: other_user)\n    accessible_card = @board.cards.create!(title: \"searchable content\", creator: @user, status: \"published\")\n    inaccessible_card = inaccessible_board.cards.create!(title: \"searchable content\", creator: other_user, status: \"published\")\n\n    results = Search::Record.for(@user.account_id).search(\"searchable\", user: @user)\n    assert results.find { |it| it.card_id == accessible_card.id }\n    assert_not results.find { |it| it.card_id == inaccessible_card.id }\n\n    # Empty board_ids returns no results\n    user_without_access = User.create!(name: \"No Access User\", account: @account)\n    results = Search::Record.for(user_without_access.account_id).search(\"anything\", user: user_without_access)\n    assert_empty results\n  end\n\n  test \"search for hyphenated strings\" do\n    card = @board.cards.create!(title: \"BC3-IOS-1D8B\", creator: @user, status: \"published\")\n\n    results = Search::Record.for(@user.account_id).search(\"BC3-IOS-1D8B\", user: @user)\n    assert results.find { |it| it.card_id == card.id }\n  end\nend\n"
  },
  {
    "path": "test/models/signup/account_name_generator_test.rb",
    "content": "require \"test_helper\"\n\nclass Signup::AccountNameGeneratorTest < ActiveSupport::TestCase\n  setup do\n    @identity = Identity.create!(email_address: \"newart.userbaum@example.com\")\n    @name = \"Newart userbaum\"\n    @generator = Signup::AccountNameGenerator.new(identity: @identity, name: @name)\n  end\n\n  test \"generate\" do\n    account_name = @generator.generate\n    assert_equal \"Newart's Fizzy\", account_name, \"The 1st account doesn't have 1st in the name\"\n\n    first_account = Account.create!(external_account_id: \"1st\", name: account_name)\n    Current.without_account do\n      @identity.users.create!(account: first_account, name: @name)\n      @identity.reload\n    end\n\n    account_name = @generator.generate\n    assert_equal \"Newart's 2nd Fizzy\", account_name\n\n    second_account = Account.create!(external_account_id: \"2nd\", name: account_name)\n    Current.without_account do\n      @identity.users.create!(account: second_account, name: @name)\n      @identity.reload\n    end\n\n    account_name = @generator.generate\n    assert_equal \"Newart's 3rd Fizzy\", account_name\n\n    third_account = Account.create!(external_account_id: \"3rd\", name: account_name)\n    Current.without_account do\n      @identity.users.create!(account: third_account, name: @name)\n      @identity.reload\n    end\n\n    account_name = @generator.generate\n    assert_equal \"Newart's 4th Fizzy\", account_name\n\n    fourth_account = Account.create!(external_account_id: \"4th\", name: account_name)\n    Current.without_account do\n      @identity.users.create!(account: fourth_account, name: @name)\n      @identity.reload\n    end\n\n    account_name = @generator.generate\n    assert_equal \"Newart's 5th Fizzy\", account_name\n  end\n\n  test \"generate continues from the previous highest index\" do\n    account = Account.create!(external_account_id: \"12th\", name: \"Newart's 12th Fizzy\")\n    Current.without_account do\n      @identity.users.create!(account: account, name: @name)\n      @identity.reload\n    end\n\n    account_name = @generator.generate\n    assert_equal \"Newart's 13th Fizzy\", account_name\n  end\nend\n"
  },
  {
    "path": "test/models/signup_test.rb",
    "content": "require \"test_helper\"\n\nclass SignupTest < ActiveSupport::TestCase\n  test \"validates email format for identity creation\" do\n    signup = Signup.new(email_address: \"not-an-email\")\n    assert_not signup.valid?(:identity_creation)\n    assert signup.errors[:email_address].any?\n\n    signup = Signup.new(email_address: \"valid@example.com\")\n    assert signup.valid?(:identity_creation)\n  end\n\n  test \"#create_identity\" do\n    signup = Signup.new(email_address: \"brian@example.com\")\n\n    magic_link = nil\n    assert_difference -> { Identity.count }, 1 do\n      assert_difference -> { MagicLink.count }, 1 do\n        magic_link = signup.create_identity\n      end\n    end\n\n    assert_kind_of MagicLink, magic_link\n    assert_empty signup.errors\n    assert signup.identity\n    assert signup.identity.persisted?\n\n    signup_existing = Signup.new(email_address: \"brian@example.com\")\n\n    assert_no_difference -> { Identity.count } do\n      assert_difference -> { MagicLink.count }, 1 do\n        magic_link = signup_existing.create_identity\n      end\n    end\n\n    assert_kind_of MagicLink, magic_link\n\n    signup_invalid = Signup.new(email_address: \"\")\n    assert_raises do\n      signup_invalid.create_identity\n    end\n  end\n\n  test \"#complete\" do\n    Account.any_instance.expects(:setup_customer_template).once\n\n    Current.without_account do\n      signup = Signup.new(full_name: \"Kevin\", identity: identities(:kevin))\n\n      assert signup.complete\n\n      assert signup.account\n      assert signup.user\n      assert_equal \"Kevin\", signup.user.name\n\n      signup_invalid = Signup.new(\n        full_name: \"\",\n        identity: identities(:kevin)\n      )\n      assert_not signup_invalid.complete\n      assert_not_empty signup_invalid.errors[:full_name]\n    end\n  end\n\n  test \"#complete with invalid data\" do\n    Current.without_account do\n      signup = Signup.new\n      assert_not signup.complete\n      assert signup.errors[:full_name].any?\n      assert signup.errors[:identity].any?\n      assert_nil signup.account\n      assert_nil signup.user\n    end\n  end\n\n  test \"#complete with name that is too long\" do\n    Current.without_account do\n      signup = Signup.new(full_name: \"A\" * 241, identity: identities(:kevin))\n      signup.expects(:create_tenant).never\n\n      assert_not signup.complete\n\n      assert signup.errors[:full_name].any?\n      assert_nil signup.account\n      assert_nil signup.user\n    end\n  end\nend\n"
  },
  {
    "path": "test/models/ssrf_protection_test.rb",
    "content": "require \"test_helper\"\n\nclass SsrfProtectionTest < ActiveSupport::TestCase\n  test \"blocks loopback addresses\" do\n    stub_dns_resolution(\"127.0.0.1\")\n    assert_nil SsrfProtection.resolve_public_ip(\"localhost\")\n  end\n\n  test \"blocks private 10.x.x.x addresses\" do\n    stub_dns_resolution(\"10.0.0.1\")\n    assert_nil SsrfProtection.resolve_public_ip(\"internal.example.com\")\n  end\n\n  test \"blocks private 172.16.x.x addresses\" do\n    stub_dns_resolution(\"172.16.0.1\")\n    assert_nil SsrfProtection.resolve_public_ip(\"internal.example.com\")\n  end\n\n  test \"blocks private 192.168.x.x addresses\" do\n    stub_dns_resolution(\"192.168.1.1\")\n    assert_nil SsrfProtection.resolve_public_ip(\"internal.example.com\")\n  end\n\n  test \"blocks link-local addresses (AWS metadata endpoint)\" do\n    stub_dns_resolution(\"169.254.169.254\")\n    assert_nil SsrfProtection.resolve_public_ip(\"metadata.example.com\")\n  end\n\n  test \"blocks carrier-grade NAT addresses\" do\n    stub_dns_resolution(\"100.64.0.1\")\n    assert_nil SsrfProtection.resolve_public_ip(\"cgnat.example.com\")\n  end\n\n  test \"blocks benchmark testing addresses\" do\n    stub_dns_resolution(\"198.18.0.1\")\n    assert_nil SsrfProtection.resolve_public_ip(\"benchmark.example.com\")\n  end\n\n  test \"blocks broadcast addresses\" do\n    stub_dns_resolution(\"0.0.0.1\")\n    assert_nil SsrfProtection.resolve_public_ip(\"broadcast.example.com\")\n  end\n\n  test \"allows public addresses\" do\n    stub_dns_resolution(\"93.184.216.34\")\n    assert_equal \"93.184.216.34\", SsrfProtection.resolve_public_ip(\"example.com\")\n  end\n\n  test \"returns first public IP when multiple addresses resolve\" do\n    stub_dns_resolution(\"10.0.0.1\", \"93.184.216.34\", \"192.168.1.1\")\n    assert_equal \"93.184.216.34\", SsrfProtection.resolve_public_ip(\"multi.example.com\")\n  end\n\n  # IPv6 address format tests (SSRF bypass prevention)\n\n  test \"blocks IPv4-mapped IPv6 addresses with private IPs\" do\n    stub_dns_resolution(\"::ffff:192.168.1.1\")\n    assert_nil SsrfProtection.resolve_public_ip(\"mapped-private.example.com\")\n  end\n\n  test \"blocks IPv4-mapped IPv6 addresses with link-local IPs\" do\n    stub_dns_resolution(\"::ffff:169.254.169.254\")\n    assert_nil SsrfProtection.resolve_public_ip(\"mapped-metadata.example.com\")\n  end\n\n  test \"blocks IPv4-mapped IPv6 addresses even with public IPs\" do\n    stub_dns_resolution(\"::ffff:93.184.216.34\")\n    assert_nil SsrfProtection.resolve_public_ip(\"mapped-public.example.com\")\n  end\n\n  test \"blocks IPv4-compatible IPv6 addresses with private IPs\" do\n    stub_dns_resolution(\"::192.168.1.1\")\n    assert_nil SsrfProtection.resolve_public_ip(\"compat-private.example.com\")\n  end\n\n  test \"blocks IPv4-compatible IPv6 addresses with link-local IPs\" do\n    stub_dns_resolution(\"::169.254.169.254\")\n    assert_nil SsrfProtection.resolve_public_ip(\"compat-metadata.example.com\")\n  end\n\n  test \"blocks IPv4-compatible IPv6 addresses even with public IPs\" do\n    stub_dns_resolution(\"::93.184.216.34\")\n    assert_nil SsrfProtection.resolve_public_ip(\"compat-public.example.com\")\n  end\n\n  private\n    def stub_dns_resolution(*ips)\n      dns_mock = mock(\"dns\")\n      dns_mock.stubs(:each_address).multiple_yields(*ips)\n      Resolv::DNS.stubs(:open).yields(dns_mock)\n    end\nend\n"
  },
  {
    "path": "test/models/storage/attachment_tracking_test.rb",
    "content": "require \"test_helper\"\n\nclass Storage::AttachmentTrackingTest < ActiveSupport::TestCase\n  setup do\n    Current.session = sessions(:david)\n    Current.request_id = \"test-request-123\"\n    @account = accounts(\"37s\")\n    @board = boards(:writebook)\n    @card = cards(:logo)\n  end\n\n\n  # Attachment Creation\n\n  test \"attaching file creates storage entry with positive delta\" do\n    assert_difference \"Storage::Entry.count\", +1 do\n      @card.image.attach io: StringIO.new(\"x\" * 2048), filename: \"test.png\", content_type: \"image/png\"\n    end\n\n    entry = Storage::Entry.last\n    assert_equal 2048, entry.delta\n    assert_equal \"attach\", entry.operation\n    assert_equal @account.id, entry.account_id\n    assert_equal @board.id, entry.board_id\n    assert_equal @card.class.name, entry.recordable_type\n    assert_equal @card.id, entry.recordable_id\n    assert_equal @card.image.blob.id, entry.blob_id\n    assert_equal Current.user.id, entry.user_id\n    assert_equal Current.request_id, entry.request_id\n  end\n\n  test \"attaching file enqueues MaterializeJob for account\" do\n    assert_enqueued_with job: Storage::MaterializeJob, args: [ @account ] do\n      @card.image.attach io: StringIO.new(\"x\" * 1024), filename: \"test.png\", content_type: \"image/png\"\n    end\n  end\n\n  test \"attaching file enqueues MaterializeJob for board\" do\n    assert_enqueued_with job: Storage::MaterializeJob, args: [ @board ] do\n      @card.image.attach io: StringIO.new(\"x\" * 1024), filename: \"test.png\", content_type: \"image/png\"\n    end\n  end\n\n\n  # Attachment Deletion\n\n  test \"destroying attachment creates storage entry with negative delta\" do\n    @card.image.attach io: StringIO.new(\"x\" * 2048), filename: \"test.png\", content_type: \"image/png\"\n    attachment = @card.image.attachment\n    blob_id = attachment.blob_id\n\n    # Destroy the attachment directly to trigger callbacks\n    attachment.destroy!\n\n    entry = Storage::Entry.find_by(operation: \"detach\", recordable: @card)\n    assert_not_nil entry, \"Expected detach entry to be created\"\n    assert_equal -2048, entry.delta\n    assert_equal \"detach\", entry.operation\n    assert_equal blob_id, entry.blob_id\n  end\n\n  test \"destroying attachment uses snapshotted IDs from before_destroy\" do\n    @card.image.attach io: StringIO.new(\"x\" * 1024), filename: \"test.png\", content_type: \"image/png\"\n\n    # Capture expected values before destroy\n    expected_account_id = @account.id\n    expected_board_id = @board.id\n    expected_recordable_type = @card.class.name\n    expected_recordable_id = @card.id\n\n    attachment = @card.image.attachment\n    attachment.destroy!\n\n    entry = Storage::Entry.find_by(operation: \"detach\", recordable_id: expected_recordable_id)\n    assert_not_nil entry, \"Expected detach entry to be created\"\n    assert_equal expected_account_id, entry.account_id\n    assert_equal expected_board_id, entry.board_id\n    assert_equal expected_recordable_type, entry.recordable_type\n    assert_equal expected_recordable_id, entry.recordable_id\n  end\n\n\n  # Non-Trackable Records\n\n  test \"does not track attachments on records without account method\" do\n    # Account uploads are not trackable (Account.account returns self, but\n    # uploads on Account are not board-scoped in the same way)\n    # This test verifies the guard clause works\n\n    # Create a model that doesn't respond to :board\n    identity = identities(:david)\n\n    # Identity doesn't have :account or :board, so attachments shouldn't be tracked\n    # (Though in practice, Identity may not have attachments in this codebase)\n    # We test the guard by checking that the tracking module handles non-trackable records\n    assert_respond_to @card, :account\n    assert_respond_to @card, :board\n  end\n\n\n  # Edge Cases\n\n  test \"attachment tracking handles nil board gracefully\" do\n    # Create a card with nil board association won't happen in practice\n    # but test that entry creation handles nil board_id\n    @card.image.attach io: StringIO.new(\"x\" * 1024), filename: \"test.png\", content_type: \"image/png\"\n    entry = Storage::Entry.last\n    assert_not_nil entry.account_id\n    # board_id should be present for cards\n    assert_not_nil entry.board_id\n  end\n\n  test \"replacing attachment creates detach and attach entries\" do\n    # First attachment\n    @card.image.attach io: StringIO.new(\"x\" * 1024), filename: \"first.png\", content_type: \"image/png\"\n    initial_count = Storage::Entry.count\n\n    # Replace with new attachment\n    @card.image.attach io: StringIO.new(\"x\" * 2048), filename: \"second.png\", content_type: \"image/png\"\n\n    # Should have detach (-1024) and attach (+2048) entries\n    # Note: depending on purge_later vs purge, the detach might be async\n    entries = Storage::Entry.where(recordable: @card).order(:id).last(2)\n\n    # At minimum, we should have the new attach entry\n    attach_entry = entries.find { |e| e.operation == \"attach\" && e.delta == 2048 }\n    assert_not_nil attach_entry\n  end\n\n\n  # Rich Text Embeds\n  #\n  # ActionText embeds are automatically extracted from body content that contains\n  # <action-text-attachment> tags referencing ActiveStorage::Blob objects.\n  # The embeds association is populated during before_validation callback.\n\n  test \"card description embed creates storage entry\" do\n    blob = ActiveStorage::Blob.create_and_upload! \\\n      io: file_fixture(\"moon.jpg\").open,\n      filename: \"card_embed.jpg\",\n      content_type: \"image/jpeg\"\n\n    # Create rich text content with embedded blob attachment\n    attachment_html = ActionText::Attachment.from_attachable(blob).to_html\n\n    assert_difference \"Storage::Entry.count\", +1 do\n      @card.update!(description: \"<p>Description with image: #{attachment_html}</p>\")\n    end\n\n    entry = Storage::Entry.last\n    assert_equal blob.byte_size, entry.delta\n    assert_equal \"attach\", entry.operation\n    assert_equal \"Card\", entry.recordable_type\n    assert_equal @card.id, entry.recordable_id\n  end\n\n  test \"comment embed creates storage entry via rich text body\" do\n    blob = ActiveStorage::Blob.create_and_upload! \\\n      io: file_fixture(\"moon.jpg\").open,\n      filename: \"comment_image.jpg\",\n      content_type: \"image/jpeg\"\n\n    attachment_html = ActionText::Attachment.from_attachable(blob).to_html\n\n    assert_difference \"Storage::Entry.count\", +1 do\n      @card.comments.create!(body: \"<p>Comment with image: #{attachment_html}</p>\")\n    end\n\n    entry = Storage::Entry.last\n    assert_equal blob.byte_size, entry.delta\n    assert_equal \"attach\", entry.operation\n    assert_equal @account.id, entry.account_id\n    assert_equal @board.id, entry.board_id\n    assert_equal \"Comment\", entry.recordable_type\n  end\n\n  test \"comment embed uses card's board for tracking\" do\n    blob = ActiveStorage::Blob.create_and_upload! \\\n      io: file_fixture(\"moon.jpg\").open,\n      filename: \"test.jpg\",\n      content_type: \"image/jpeg\"\n\n    attachment_html = ActionText::Attachment.from_attachable(blob).to_html\n    comment = @card.comments.create!(body: \"<p>Comment: #{attachment_html}</p>\")\n\n    entry = Storage::Entry.last\n    assert_equal @card.board_id, entry.board_id\n    assert_equal comment.id, entry.recordable_id\n  end\n\n  test \"board public_description embed creates storage entry\" do\n    blob = ActiveStorage::Blob.create_and_upload! \\\n      io: file_fixture(\"moon.jpg\").open,\n      filename: \"board_image.jpg\",\n      content_type: \"image/jpeg\"\n\n    attachment_html = ActionText::Attachment.from_attachable(blob).to_html\n\n    assert_difference \"Storage::Entry.count\", +1 do\n      @board.update!(public_description: \"<p>Board description: #{attachment_html}</p>\")\n    end\n\n    entry = Storage::Entry.last\n    assert_equal blob.byte_size, entry.delta\n    assert_equal \"attach\", entry.operation\n    assert_equal @account.id, entry.account_id\n    assert_equal @board.id, entry.board_id\n    assert_equal \"Board\", entry.recordable_type\n    assert_equal @board.id, entry.recordable_id\n  end\n\n\n  # Reconciliation includes all attachment types\n\n  test \"board calculate_real_storage_bytes includes comment embeds\" do\n    blob = ActiveStorage::Blob.create_and_upload! \\\n      io: file_fixture(\"moon.jpg\").open,\n      filename: \"comment_embed.jpg\",\n      content_type: \"image/jpeg\"\n\n    attachment_html = ActionText::Attachment.from_attachable(blob).to_html\n    @card.comments.create!(body: \"<p>Comment: #{attachment_html}</p>\")\n\n    board_bytes = @board.send(:calculate_real_storage_bytes)\n\n    assert board_bytes >= blob.byte_size, \"board bytes should include comment embed bytes\"\n  end\n\n  test \"account calculate_real_storage_bytes includes comment embeds via boards\" do\n    blob = ActiveStorage::Blob.create_and_upload! \\\n      io: file_fixture(\"moon.jpg\").open,\n      filename: \"comment_embed.jpg\",\n      content_type: \"image/jpeg\"\n\n    attachment_html = ActionText::Attachment.from_attachable(blob).to_html\n    @card.comments.create!(body: \"<p>Comment: #{attachment_html}</p>\")\n\n    account_bytes = @account.send(:calculate_real_storage_bytes)\n\n    assert account_bytes >= blob.byte_size, \"account bytes should include comment embed bytes\"\n  end\n\n\n  # Cascading Deletes\n\n  test \"attachment tracking handles card deletion gracefully\" do\n    @card.image.attach io: StringIO.new(\"x\" * 1024), filename: \"test.png\", content_type: \"image/png\"\n    card_id = @card.id\n\n    # Delete the card - this should trigger attachment purge\n    # The before_destroy snapshot should capture IDs before card is gone\n    perform_enqueued_jobs do\n      assert_nothing_raised do\n        @card.destroy!\n      end\n    end\n\n    # Should have detach entry with snapshotted IDs\n    detach_entry = Storage::Entry.find_by(recordable_id: card_id, operation: \"detach\")\n    assert_not_nil detach_entry, \"Expected detach entry for destroyed card\"\n    assert_equal -1024, detach_entry.delta\n  end\nend\n"
  },
  {
    "path": "test/models/storage/entry_test.rb",
    "content": "require \"test_helper\"\n\nclass Storage::EntryTest < ActiveSupport::TestCase\n  setup do\n    @account = accounts(\"37s\")\n    @board = boards(:writebook)\n    @card = cards(:logo)\n  end\n\n  test \"record creates entry with positive delta\" do\n    assert_difference \"Storage::Entry.count\", +1 do\n      entry = Storage::Entry.record \\\n        account: @account,\n        board: @board,\n        recordable: @card,\n        delta: 1024,\n        operation: \"attach\"\n\n      assert_equal @account.id, entry.account_id\n      assert_equal @board.id, entry.board_id\n      assert_equal @card.class.name, entry.recordable_type\n      assert_equal @card.id, entry.recordable_id\n      assert_equal 1024, entry.delta\n      assert_equal \"attach\", entry.operation\n    end\n  end\n\n  test \"record creates entry with negative delta\" do\n    entry = Storage::Entry.record \\\n      account: @account,\n      board: @board,\n      recordable: @card,\n      delta: -512,\n      operation: \"detach\"\n\n    assert_equal -512, entry.delta\n    assert_equal \"detach\", entry.operation\n  end\n\n  test \"record returns nil and creates no entry when delta is zero\" do\n    assert_no_difference \"Storage::Entry.count\" do\n      result = Storage::Entry.record \\\n        account: @account,\n        board: @board,\n        recordable: @card,\n        delta: 0,\n        operation: \"attach\"\n\n      assert_nil result\n    end\n  end\n\n  test \"record works with destroyed records (destroyed? check)\" do\n    # Simulate a destroyed record - .id still works after destroy\n    @card.destroy\n\n    entry = Storage::Entry.record \\\n      account: @account,\n      board: @board,\n      recordable: @card,\n      delta: 2048,\n      operation: \"detach\"\n\n    assert_equal @account.id, entry.account_id\n    assert_equal @board.id, entry.board_id\n    assert_equal \"Card\", entry.recordable_type\n    assert_equal @card.id, entry.recordable_id\n  end\n\n  test \"record creates entry without board\" do\n    entry = Storage::Entry.record \\\n      account: @account,\n      board: nil,\n      recordable: @card,\n      delta: 1024,\n      operation: \"attach\"\n\n    assert_nil entry.board_id\n  end\n\n  test \"record creates entry without recordable\" do\n    entry = Storage::Entry.record \\\n      account: @account,\n      board: @board,\n      recordable: nil,\n      delta: 1024,\n      operation: \"reconcile\"\n\n    assert_nil entry.recordable_type\n    assert_nil entry.recordable_id\n  end\n\n  test \"record enqueues MaterializeJob for account\" do\n    assert_enqueued_with job: Storage::MaterializeJob, args: [ @account ] do\n      Storage::Entry.record \\\n        account: @account,\n        board: nil,\n        recordable: nil,\n        delta: 1024,\n        operation: \"attach\"\n    end\n  end\n\n  test \"record enqueues MaterializeJob for board when board_id present\" do\n    assert_enqueued_with job: Storage::MaterializeJob, args: [ @board ] do\n      Storage::Entry.record \\\n        account: @account,\n        board: @board,\n        recordable: nil,\n        delta: 1024,\n        operation: \"attach\"\n    end\n  end\n\n  test \"record skips entirely when account is destroyed\" do\n    # No need to track storage for deleted accounts\n    @account.destroy\n\n    assert_no_difference \"Storage::Entry.count\" do\n      result = Storage::Entry.record \\\n        account: @account,\n        delta: 1024,\n        operation: \"attach\"\n\n      assert_nil result\n    end\n  end\n\n  test \"record does not enqueue board job when board is destroyed\" do\n    @board.destroy\n\n    # Account job still enqueued, but destroyed board skips its job\n    jobs = []\n    assert_enqueued_with job: Storage::MaterializeJob, args: [ @account ] do\n      Storage::Entry.record \\\n        account: @account,\n        board: @board,\n        delta: 1024,\n        operation: \"attach\"\n    end\n\n    # Verify board job was NOT enqueued\n    board_jobs = enqueued_jobs.select { |j| j[\"arguments\"].include?(@board.to_global_id.to_s) }\n    assert_empty board_jobs\n  end\n\n  test \"entries belong to account\" do\n    entry = Storage::Entry.record \\\n      account: @account,\n      delta: 1024,\n      operation: \"attach\"\n\n    assert_equal @account, entry.account\n  end\n\n  test \"entries belong to board (optional)\" do\n    entry = Storage::Entry.record \\\n      account: @account,\n      board: @board,\n      delta: 1024,\n      operation: \"attach\"\n\n    assert_equal @board, entry.board\n  end\n\n  test \"entries belong to recordable (polymorphic, optional)\" do\n    entry = Storage::Entry.record \\\n      account: @account,\n      recordable: @card,\n      delta: 1024,\n      operation: \"attach\"\n\n    assert_equal @card, entry.recordable\n  end\nend\n"
  },
  {
    "path": "test/models/storage/no_reuse_test.rb",
    "content": "require \"test_helper\"\n\nclass Storage::NoReuseTest < ActiveSupport::TestCase\n  setup do\n    Current.session = sessions(:david)\n    @account = accounts(\"37s\")\n    Current.account = @account  # Ensure blobs get correct account_id\n    @board = @account.boards.create!(name: \"Test\", creator: users(:david))\n  end\n\n  # No-reuse validation\n  # NOTE: For persisted records, ActiveStorage::Attached::One#attach raises\n  # ActiveRecord::RecordNotSaved when validation fails.\n\n  test \"rejects attaching blob that already has tracked attachment\" do\n    blob = ActiveStorage::Blob.create_and_upload! \\\n      io: StringIO.new(\"x\" * 1000),\n      filename: \"test.png\",\n      content_type: \"image/png\"\n\n    # First attachment succeeds\n    card1 = @board.cards.create!(title: \"Card 1\", creator: users(:david))\n    card1.image.attach(blob)\n    assert card1.image.attached?\n\n    # Second attachment of same blob fails\n    card2 = @board.cards.create!(title: \"Card 2\", creator: users(:david))\n    assert_raises ActiveRecord::RecordNotSaved do\n      card2.image.attach(blob)\n    end\n\n    # Verify only one attachment exists for this blob\n    assert_equal 1, ActiveStorage::Attachment.where(blob_id: blob.id).count\n  end\n\n  test \"allows reuse for ActionText embeds\" do\n    file = file_fixture(\"moon.jpg\")\n    blob = ActiveStorage::Blob.create_and_upload! \\\n      io: file.open,\n      filename: \"embed.jpg\",\n      content_type: \"image/jpeg\"\n\n    embed_html = ActionText::Attachment.from_attachable(blob).to_html\n\n    card1 = @board.cards.create!(title: \"Card 1\", creator: users(:david))\n    card1.update!(description: \"<p>#{embed_html}</p>\")\n    card1.reload\n\n    card2 = @board.cards.create!(title: \"Card 2\", creator: users(:david))\n    card2.update!(description: \"<p>#{embed_html}</p>\")\n    card2.reload\n\n    assert_equal 2, ActiveStorage::Attachment.where(\n      record_type: \"ActionText::RichText\",\n      name: \"embeds\",\n      blob_id: blob.id\n    ).count\n  end\n\n  test \"purge_later does not purge blob when still attached elsewhere\" do\n    file = file_fixture(\"moon.jpg\")\n    blob = ActiveStorage::Blob.create_and_upload! \\\n      io: file.open,\n      filename: \"embed.jpg\",\n      content_type: \"image/jpeg\"\n\n    embed_html = ActionText::Attachment.from_attachable(blob).to_html\n\n    card1 = @board.cards.create!(title: \"Card 1\", creator: users(:david))\n    card1.update!(description: \"<p>#{embed_html}</p>\")\n\n    card2 = @board.cards.create!(title: \"Card 2\", creator: users(:david))\n    card2.update!(description: \"<p>#{embed_html}</p>\")\n\n    attachment = ActiveStorage::Attachment.find_by(\n      record: card1.rich_text_description,\n      name: \"embeds\",\n      blob_id: blob.id\n    )\n\n    assert_no_enqueued_jobs only: ActiveStorage::PurgeJob do\n      attachment.purge_later\n    end\n\n    assert ActiveStorage::Blob.exists?(blob.id)\n    assert_equal 1, ActiveStorage::Attachment.where(blob_id: blob.id).count\n  end\n\n  test \"purge_later enqueues purge when last attachment is removed\" do\n    file = file_fixture(\"moon.jpg\")\n    blob = ActiveStorage::Blob.create_and_upload! \\\n      io: file.open,\n      filename: \"embed.jpg\",\n      content_type: \"image/jpeg\"\n\n    embed_html = ActionText::Attachment.from_attachable(blob).to_html\n\n    card = @board.cards.create!(title: \"Card\", creator: users(:david))\n    card.update!(description: \"<p>#{embed_html}</p>\")\n    card.reload\n\n    attachment = ActiveStorage::Attachment.find_by(\n      record: card.rich_text_description,\n      name: \"embeds\",\n      blob_id: blob.id\n    )\n\n    assert_enqueued_with job: ActiveStorage::PurgeJob, args: [ blob ] do\n      attachment.purge_later\n    end\n  end\n\n  test \"rejects cross-account blob attachment\" do\n    other_account = Account.create!(name: \"Other\")\n    other_board = other_account.boards.create!(name: \"Other Board\", creator: users(:david))\n\n    # Blob created in @account context\n    blob = ActiveStorage::Blob.create_and_upload! \\\n      io: StringIO.new(\"x\" * 1000),\n      filename: \"test.png\",\n      content_type: \"image/png\"\n\n    card = other_board.cards.create!(title: \"Card\", creator: users(:david))\n    assert_raises ActiveRecord::RecordNotSaved do\n      card.image.attach(blob)\n    end\n\n    # Verify attachment was not created (blob account doesn't match record account)\n    assert_not card.reload.image.attached?\n  end\n\n  test \"allows attaching blob to untracked record type\" do\n    file = file_fixture(\"moon.jpg\")\n    blob = ActiveStorage::Blob.create_and_upload! \\\n      io: file.open,\n      filename: \"avatar.jpg\",\n      content_type: \"image/jpeg\"\n\n    # User avatar is not a tracked record type\n    user = users(:david)\n    user.avatar.attach(blob)\n\n    # Should succeed - avatars are not storage-tracked\n    assert user.avatar.attached?\n  end\n\n  test \"allows multiple attachments of same blob to untracked record types\" do\n    file = file_fixture(\"moon.jpg\")\n    blob = ActiveStorage::Blob.create_and_upload! \\\n      io: file.open,\n      filename: \"avatar.jpg\",\n      content_type: \"image/jpeg\"\n\n    # First attachment to untracked (avatar)\n    user1 = users(:david)\n    user1.avatar.attach(blob)\n    assert user1.avatar.attached?\n\n    # Second attachment to untracked (another avatar) should work\n    # since no-reuse only checks tracked contexts\n    user2 = users(:jz)\n    user2.avatar.attach(blob)\n    assert user2.avatar.attached?\n  end\nend\n"
  },
  {
    "path": "test/models/storage/total_test.rb",
    "content": "require \"test_helper\"\n\nclass Storage::TotalTest < ActiveSupport::TestCase\n  setup do\n    @account = accounts(\"37s\")\n    @board = boards(:writebook)\n  end\n\n  test \"pending_entries returns all entries when no cursor\" do\n    # Create some entries\n    3.times do |i|\n      Storage::Entry.record \\\n        account: @account,\n        delta: 1024 * (i + 1),\n        operation: \"attach\"\n    end\n\n    total = @account.create_storage_total!\n    assert_nil total.last_entry_id\n\n    assert_equal 3, total.pending_entries.count\n  end\n\n  test \"pending_entries returns only entries after cursor\" do\n    # Create first entry and set cursor\n    entry1 = Storage::Entry.record(account: @account, delta: 1024, operation: \"attach\")\n    total = @account.create_storage_total!(last_entry_id: entry1.id, bytes_stored: 1024)\n\n    # Advance time to ensure UUIDv7 timestamps sort correctly\n    travel 1.second\n\n    # Create more entries AFTER cursor is set\n    entry2 = Storage::Entry.record(account: @account, delta: 2048, operation: \"attach\")\n    travel 1.second\n    entry3 = Storage::Entry.record(account: @account, delta: 512, operation: \"attach\")\n\n    pending = total.pending_entries\n    assert_equal 2, pending.count\n    assert_includes pending, entry2\n    assert_includes pending, entry3\n    assert_not_includes pending, entry1\n  end\n\n  test \"current_usage returns snapshot value when no pending entries\" do\n    total = @account.create_storage_total!(bytes_stored: 5000)\n\n    # No entries exist, so nothing pending\n    assert_equal 5000, total.current_usage\n  end\n\n  test \"current_usage sums snapshot and pending entries\" do\n    # Create first entry and set cursor\n    entry1 = Storage::Entry.record(account: @account, delta: 1024, operation: \"attach\")\n    total = @account.create_storage_total!(last_entry_id: entry1.id, bytes_stored: 1024)\n\n    # Small delay to ensure UUIDv7 timestamp component advances\n    travel 1.second\n\n    # Create more entries AFTER cursor is set\n    Storage::Entry.record(account: @account, delta: 2048, operation: \"attach\")\n    travel 1.second\n    Storage::Entry.record(account: @account, delta: -512, operation: \"detach\")\n\n    # 1024 (snapshot) + 2048 - 512 (pending) = 2560\n    assert_equal 2560, total.current_usage\n  end\n\n  test \"belongs to owner polymorphically\" do\n    account_total = Storage::Total.create!(owner: @account)\n    assert_equal @account, account_total.owner\n\n    board_total = Storage::Total.create!(owner: @board)\n    assert_equal @board, board_total.owner\n  end\n\n  test \"unique constraint on owner\" do\n    Storage::Total.create!(owner: @account)\n\n    assert_raises ActiveRecord::RecordNotUnique do\n      Storage::Total.create!(owner: @account)\n    end\n  end\nend\n"
  },
  {
    "path": "test/models/storage/totaled_test.rb",
    "content": "require \"test_helper\"\n\nclass Storage::TotaledTest < ActiveSupport::TestCase\n  setup do\n    Current.session = sessions(:david)\n    @account = accounts(\"37s\")\n    @board = boards(:writebook)\n  end\n\n\n  # bytes_used (fast snapshot)\n\n  test \"bytes_used returns 0 when no storage_total exists\" do\n    assert_nil @account.storage_total\n    assert_equal 0, @account.bytes_used\n  end\n\n  test \"bytes_used returns snapshot value\" do\n    @account.create_storage_total!(bytes_stored: 10_000)\n    assert_equal 10_000, @account.bytes_used\n  end\n\n  test \"bytes_used does not include pending entries (fast path)\" do\n    @account.create_storage_total!(bytes_stored: 1000)\n\n    # Create pending entry (not materialized)\n    Storage::Entry.record(account: @account, delta: 500, operation: \"attach\")\n\n    # bytes_used is fast path - only reads snapshot\n    assert_equal 1000, @account.bytes_used\n  end\n\n\n  # bytes_used_exact (snapshot + pending)\n\n  test \"bytes_used_exact creates storage_total if missing\" do\n    assert_nil @account.storage_total\n\n    @account.bytes_used_exact\n\n    assert_not_nil @account.reload.storage_total\n  end\n\n  test \"bytes_used_exact includes pending entries\" do\n    # Create first entry and set cursor at that entry\n    entry = Storage::Entry.record(account: @account, delta: 500, operation: \"attach\")\n    @account.create_storage_total!(bytes_stored: 500, last_entry_id: entry.id)\n\n    # Small delay to ensure UUIDv7 timestamp advances\n    travel 1.second\n\n    # Create pending entry AFTER cursor\n    Storage::Entry.record(account: @account, delta: 256, operation: \"attach\")\n\n    # 500 (snapshot) + 256 (pending) = 756\n    assert_equal 756, @account.bytes_used_exact\n  end\n\n  test \"bytes_used_exact returns 0 when no entries and no snapshot\" do\n    assert_equal 0, @account.bytes_used_exact\n  end\n\n\n  # materialize_storage\n\n  test \"materialize_storage creates storage_total if missing\" do\n    assert_nil @account.storage_total\n\n    Storage::Entry.record(account: @account, delta: 1024, operation: \"attach\")\n    @account.materialize_storage\n\n    total = @account.reload.storage_total\n    assert_not_nil total\n    assert_equal 1024, total.bytes_stored\n  end\n\n  test \"materialize_storage processes all pending entries\" do\n    Storage::Entry.record(account: @account, delta: 1000, operation: \"attach\")\n    Storage::Entry.record(account: @account, delta: 2000, operation: \"attach\")\n    Storage::Entry.record(account: @account, delta: -500, operation: \"detach\")\n\n    @account.materialize_storage\n\n    assert_equal 2500, @account.storage_total.bytes_stored\n    assert_equal 0, @account.storage_total.pending_entries.count\n  end\n\n  test \"materialize_storage updates cursor to latest entry\" do\n    entry1 = Storage::Entry.record(account: @account, delta: 1000, operation: \"attach\")\n    entry2 = Storage::Entry.record(account: @account, delta: 500, operation: \"attach\")\n\n    @account.materialize_storage\n\n    assert_equal entry2.id, @account.storage_total.last_entry_id\n  end\n\n  test \"materialize_storage is idempotent when no new entries\" do\n    Storage::Entry.record(account: @account, delta: 1000, operation: \"attach\")\n    @account.materialize_storage\n\n    initial_bytes = @account.storage_total.bytes_stored\n    initial_cursor = @account.storage_total.last_entry_id\n\n    @account.materialize_storage\n\n    assert_equal initial_bytes, @account.storage_total.bytes_stored\n    assert_equal initial_cursor, @account.storage_total.last_entry_id\n  end\n\n  test \"materialize_storage processes only entries since cursor\" do\n    entry1 = Storage::Entry.record(account: @account, delta: 1000, operation: \"attach\")\n    @account.materialize_storage\n\n    assert_equal 1000, @account.storage_total.bytes_stored\n\n    # Small delay to ensure UUIDv7 timestamp advances\n    travel 1.second\n\n    # Add more entries\n    Storage::Entry.record(account: @account, delta: 500, operation: \"attach\")\n    @account.materialize_storage\n\n    assert_equal 1500, @account.storage_total.bytes_stored\n  end\n\n  test \"materialize_storage does nothing when no entries\" do\n    @account.materialize_storage\n\n    total = @account.reload.storage_total\n    assert_not_nil total\n    assert_equal 0, total.bytes_stored\n    assert_nil total.last_entry_id\n  end\n\n  test \"materialize_storage handles concurrent calls safely\" do\n    # Pre-create storage_total to avoid unique constraint race\n    @account.create_storage_total!\n\n    Storage::Entry.record(account: @account, delta: 1000, operation: \"attach\")\n\n    # Simulate concurrent materialization\n    threads = 3.times.map do\n      Thread.new do\n        ActiveRecord::Base.connection_pool.with_connection do\n          @account.materialize_storage\n        end\n      end\n    end\n    threads.each(&:join)\n\n    # Should still have correct total\n    assert_equal 1000, @account.reload.storage_total.bytes_stored\n  end\n\n\n  # storage_entries association\n\n  test \"account has storage_entries association\" do\n    entry = Storage::Entry.record(account: @account, delta: 1024, operation: \"attach\")\n\n    assert_includes @account.storage_entries, entry\n  end\n\n  test \"board has storage_entries association\" do\n    entry = Storage::Entry.record(account: @account, board: @board, delta: 1024, operation: \"attach\")\n\n    assert_includes @board.storage_entries, entry\n  end\n\n\n  # storage_total association\n\n  test \"storage_total is destroyed when owner is destroyed\" do\n    @account.create_storage_total!(bytes_stored: 1000)\n    total_id = @account.storage_total.id\n\n    # Create a new account to destroy (don't destroy fixtures)\n    new_account = Account.create!(name: \"Temp Account\")\n    new_account.create_storage_total!(bytes_stored: 500)\n    storage_total_id = new_account.storage_total.id\n\n    new_account.destroy!\n\n    assert_not Storage::Total.exists?(storage_total_id)\n  end\n\n\n  # Board-specific tests\n\n  test \"board bytes_used works independently of account\" do\n    # Create entries for both account and board\n    Storage::Entry.record(account: @account, board: nil, delta: 1000, operation: \"attach\")\n    Storage::Entry.record(account: @account, board: @board, delta: 500, operation: \"attach\")\n\n    @account.materialize_storage\n    @board.materialize_storage\n\n    # Account sees all its entries (1000 + 500 = 1500)\n    assert_equal 1500, @account.bytes_used\n\n    # Board only sees entries with its board_id (500)\n    assert_equal 500, @board.bytes_used\n  end\n\n  test \"board and account have independent cursors\" do\n    entry1 = Storage::Entry.record(account: @account, board: @board, delta: 1000, operation: \"attach\")\n\n    @account.materialize_storage\n    # Board not yet materialized\n\n    entry2 = Storage::Entry.record(account: @account, board: @board, delta: 500, operation: \"attach\")\n\n    # Account cursor at entry1, board has no cursor yet\n    assert_equal entry1.id, @account.storage_total.last_entry_id\n\n    @board.materialize_storage\n\n    # Board cursor now at entry2\n    assert_equal entry2.id, @board.storage_total.last_entry_id\n    assert_equal 1500, @board.bytes_used\n  end\n\n\n  # reconcile_storage\n\n  test \"reconcile_storage creates entry for drift\" do\n    board = @account.boards.create!(name: \"Test Board\", creator: users(:david))\n    card = board.cards.create!(title: \"Test Card\", creator: users(:david))\n    card.image.attach io: StringIO.new(\"x\" * 1000), filename: \"test.png\", content_type: \"image/png\"\n\n    # Delete entry to simulate drift\n    Storage::Entry.where(board: board).delete_all\n\n    assert_difference \"Storage::Entry.count\", +1 do\n      board.reconcile_storage\n    end\n\n    entry = Storage::Entry.find_by(board: board, operation: \"reconcile\")\n    assert_equal 1000, entry.delta\n  end\n\n  test \"reconcile_storage no-op when ledger matches reality\" do\n    board = @account.boards.create!(name: \"Test Board\", creator: users(:david))\n    card = board.cards.create!(title: \"Test Card\", creator: users(:david))\n    card.image.attach io: StringIO.new(\"x\" * 1000), filename: \"test.png\", content_type: \"image/png\"\n\n    assert_no_difference \"Storage::Entry.where(operation: 'reconcile').count\" do\n      board.reconcile_storage\n    end\n  end\n\n  test \"reconcile_storage handles empty board\" do\n    board = @account.boards.create!(name: \"Empty Board\", creator: users(:david))\n\n    assert_no_difference \"Storage::Entry.count\" do\n      board.reconcile_storage\n    end\n  end\n\n  test \"reconcile_storage handles negative drift\" do\n    board = @account.boards.create!(name: \"Test Board\", creator: users(:david))\n\n    # Create fake ledger entry with no real attachment\n    Storage::Entry.create! \\\n      account_id: @account.id,\n      board_id: board.id,\n      delta: 5000,\n      operation: \"attach\"\n\n    board.reconcile_storage\n\n    entry = Storage::Entry.find_by(board: board, operation: \"reconcile\")\n    assert_not_nil entry\n    assert_equal(-5000, entry.delta)\n  end\n\n  test \"reconcile_storage aborts when entry added during scan\" do\n    board = @account.boards.create!(name: \"Test Board\", creator: users(:david))\n    card = board.cards.create!(title: \"Test Card\", creator: users(:david))\n    card.image.attach io: StringIO.new(\"x\" * 1000), filename: \"test.png\", content_type: \"image/png\"\n\n    # Delete entry to create drift\n    Storage::Entry.where(board: board).delete_all\n\n    # Intercept calculate_real_storage_bytes to insert a new entry mid-scan,\n    # faithfully simulating a concurrent upload that changes the cursor\n    board.define_singleton_method(:calculate_real_storage_bytes) do\n      Storage::Entry.create!(\n        account_id: account.id,\n        board_id: id,\n        delta: 500,\n        operation: \"attach\"\n      )\n      super()\n    end\n\n    # Should abort and return false without creating reconcile entry\n    assert_no_difference \"Storage::Entry.where(operation: 'reconcile').count\" do\n      result = board.reconcile_storage\n      assert_equal false, result\n    end\n  end\n\n  test \"reconcile_storage returns true on success\" do\n    board = @account.boards.create!(name: \"Test Board\", creator: users(:david))\n\n    result = board.reconcile_storage\n\n    assert_equal true, result\n  end\n\n\n  # ensure_storage_total race safety\n\n  test \"ensure_storage_total handles concurrent creation\" do\n    @account.storage_total&.destroy\n\n    threads = 3.times.map do\n      Thread.new do\n        ActiveRecord::Base.connection_pool.with_connection do\n          @account.bytes_used_exact\n        end\n      end\n    end\n\n    threads.each(&:join)\n    assert_equal 1, Storage::Total.where(owner: @account).count\n  end\n\n\n  # per-attachment reconcile\n\n  test \"reconcile counts each attachment separately\" do\n    board = @account.boards.create!(name: \"Test\", creator: users(:david))\n\n    # Create 3 distinct blobs (one per card) - no reuse\n    file = file_fixture(\"moon.jpg\")\n    expected_bytes = file.size\n\n    3.times do |i|\n      blob = ActiveStorage::Blob.create_and_upload! \\\n        io: file.open,\n        filename: \"image_#{i}.jpg\",\n        content_type: \"image/jpeg\"\n\n      embed = ActionText::Attachment.from_attachable(blob).to_html\n      board.cards.create!(title: \"Card #{i}\", description: \"<p>#{embed}</p>\", creator: users(:david))\n    end\n\n    Storage::Entry.where(board: board).delete_all\n    board.reconcile_storage\n\n    entry = Storage::Entry.find_by(board: board, operation: \"reconcile\")\n    # 3 attachments x file_size bytes\n    assert_equal expected_bytes * 3, entry.delta\n  end\nend\n"
  },
  {
    "path": "test/models/storage/tracked_test.rb",
    "content": "require \"test_helper\"\n\nclass Storage::TrackedTest < ActiveSupport::TestCase\n  setup do\n    Current.session = sessions(:david)\n    @account = accounts(\"37s\")\n    @board1 = boards(:writebook)\n    @board2 = boards(:private)\n    @card = cards(:logo)\n  end\n\n  test \"storage_bytes returns 0 when no attachments\" do\n    assert_equal 0, @card.storage_bytes\n  end\n\n  test \"storage_bytes sums all attachment blob sizes\" do\n    @card.image.attach io: StringIO.new(\"x\" * 1024), filename: \"test.png\", content_type: \"image/png\"\n    assert_equal 1024, @card.storage_bytes\n  end\n\n  test \"storage_bytes includes rich text embeds\" do\n    blob = ActiveStorage::Blob.create_and_upload! \\\n      io: file_fixture(\"moon.jpg\").open,\n      filename: \"embed.jpg\",\n      content_type: \"image/jpeg\"\n\n    embed_html = ActionText::Attachment.from_attachable(blob).to_html\n    @card.update!(description: \"<p>Content with #{embed_html}</p>\")\n\n    assert_equal blob.byte_size, @card.storage_bytes\n  end\n\n  test \"storage_bytes sums direct attachments and rich text embeds\" do\n    @card.image.attach io: StringIO.new(\"x\" * 1024), filename: \"test.png\", content_type: \"image/png\"\n\n    blob = ActiveStorage::Blob.create_and_upload! \\\n      io: file_fixture(\"moon.jpg\").open,\n      filename: \"embed.jpg\",\n      content_type: \"image/jpeg\"\n\n    embed_html = ActionText::Attachment.from_attachable(blob).to_html\n    @card.update!(description: \"<p>Content with #{embed_html}</p>\")\n\n    assert_equal 1024 + blob.byte_size, @card.storage_bytes\n  end\n\n  test \"board transfer creates transfer_out entry for old board\" do\n    @card.image.attach io: StringIO.new(\"x\" * 2048), filename: \"test.png\", content_type: \"image/png\"\n    old_board_id = @card.board_id\n\n    assert_difference \"Storage::Entry.count\", +2 do\n      @card.update!(board: @board2)\n    end\n\n    transfer_out = Storage::Entry.find_by(board_id: old_board_id, operation: \"transfer_out\")\n\n    assert_not_nil transfer_out\n    assert_equal -2048, transfer_out.delta\n    assert_equal @account.id, transfer_out.account_id\n    assert_equal @card.class.name, transfer_out.recordable_type\n    assert_equal @card.id, transfer_out.recordable_id\n  end\n\n  test \"board transfer creates transfer_in entry for new board\" do\n    @card.image.attach io: StringIO.new(\"x\" * 2048), filename: \"test.png\", content_type: \"image/png\"\n    @card.update!(board: @board2)\n\n    transfer_in = Storage::Entry.find_by(board_id: @board2.id, operation: \"transfer_in\")\n\n    assert_not_nil transfer_in\n    assert_equal 2048, transfer_in.delta\n    assert_equal @account.id, transfer_in.account_id\n  end\n\n  test \"board transfer does not create entries when no attachments\" do\n    # Ensure card has no attachments\n    @card.image.purge if @card.image.attached?\n\n    # Count only transfer entries\n    initial_count = Storage::Entry.where(operation: [ \"transfer_out\", \"transfer_in\" ]).count\n\n    @card.update!(board: @board2)\n\n    final_count = Storage::Entry.where(operation: [ \"transfer_out\", \"transfer_in\" ]).count\n    assert_equal initial_count, final_count\n  end\n\n  test \"board transfer moves card description embeds\" do\n    blob = ActiveStorage::Blob.create_and_upload! \\\n      io: file_fixture(\"moon.jpg\").open,\n      filename: \"card_embed.jpg\",\n      content_type: \"image/jpeg\"\n\n    embed_html = ActionText::Attachment.from_attachable(blob).to_html\n    @card.update!(description: \"<p>Desc with image #{embed_html}</p>\")\n\n    old_board_id = @card.board_id\n\n    assert_difference -> { Storage::Entry.where(operation: \"transfer_out\", recordable: @card).count }, +1 do\n      assert_difference -> { Storage::Entry.where(operation: \"transfer_in\", recordable: @card).count }, +1 do\n        @card.update!(board: @board2)\n      end\n    end\n\n    transfer_out = Storage::Entry.where(operation: \"transfer_out\", recordable: @card).last\n    transfer_in = Storage::Entry.where(operation: \"transfer_in\", recordable: @card).last\n\n    assert_equal(-blob.byte_size, transfer_out.delta)\n    assert_equal old_board_id, transfer_out.board_id\n    assert_equal blob.byte_size, transfer_in.delta\n    assert_equal @board2.id, transfer_in.board_id\n  end\n\n  test \"board transfer moves comment embeds\" do\n    blob = ActiveStorage::Blob.create_and_upload! \\\n      io: file_fixture(\"moon.jpg\").open,\n      filename: \"comment_embed.jpg\",\n      content_type: \"image/jpeg\"\n\n    embed_html = ActionText::Attachment.from_attachable(blob).to_html\n    comment = @card.comments.create!(body: \"<p>Comment with image #{embed_html}</p>\")\n\n    old_board_id = @card.board_id\n\n    assert_difference -> { Storage::Entry.where(operation: \"transfer_out\", recordable: comment).count }, +1 do\n      assert_difference -> { Storage::Entry.where(operation: \"transfer_in\", recordable: comment).count }, +1 do\n        @card.update!(board: @board2)\n      end\n    end\n\n    transfer_out = Storage::Entry.where(operation: \"transfer_out\", recordable: comment).last\n    transfer_in = Storage::Entry.where(operation: \"transfer_in\", recordable: comment).last\n\n    assert_equal(-blob.byte_size, transfer_out.delta)\n    assert_equal old_board_id, transfer_out.board_id\n    assert_equal blob.byte_size, transfer_in.delta\n    assert_equal @board2.id, transfer_in.board_id\n  end\n\n  test \"board transfer moves card image and description embed together\" do\n    @card.image.attach io: StringIO.new(\"x\" * 1024), filename: \"test.png\", content_type: \"image/png\"\n\n    blob = ActiveStorage::Blob.create_and_upload! \\\n      io: file_fixture(\"moon.jpg\").open,\n      filename: \"card_embed.jpg\",\n      content_type: \"image/jpeg\"\n\n    embed_html = ActionText::Attachment.from_attachable(blob).to_html\n    @card.update!(description: \"<p>Desc with #{embed_html}</p>\")\n\n    old_board_id = @card.board_id\n    expected_bytes = 1024 + blob.byte_size\n\n    # One transfer_out and one transfer_in for the card (combined bytes)\n    assert_difference -> { Storage::Entry.where(operation: \"transfer_out\", recordable: @card).count }, +1 do\n      assert_difference -> { Storage::Entry.where(operation: \"transfer_in\", recordable: @card).count }, +1 do\n        @card.update!(board: @board2)\n      end\n    end\n\n    transfer_out = Storage::Entry.where(operation: \"transfer_out\", recordable: @card).last\n    transfer_in = Storage::Entry.where(operation: \"transfer_in\", recordable: @card).last\n\n    assert_equal(-expected_bytes, transfer_out.delta)\n    assert_equal expected_bytes, transfer_in.delta\n  end\n\n  test \"board transfer moves multiple comments with embeds\" do\n    blob1 = ActiveStorage::Blob.create_and_upload! \\\n      io: file_fixture(\"moon.jpg\").open,\n      filename: \"embed1.jpg\",\n      content_type: \"image/jpeg\"\n    blob2 = ActiveStorage::Blob.create_and_upload! \\\n      io: file_fixture(\"moon.jpg\").open,\n      filename: \"embed2.jpg\",\n      content_type: \"image/jpeg\"\n\n    comment1 = @card.comments.create!(body: \"<p>#{ActionText::Attachment.from_attachable(blob1).to_html}</p>\")\n    comment2 = @card.comments.create!(body: \"<p>#{ActionText::Attachment.from_attachable(blob2).to_html}</p>\")\n\n    old_board_id = @card.board_id\n\n    # Should create transfer entries for both comments\n    assert_difference -> { Storage::Entry.where(operation: \"transfer_out\").count }, +2 do\n      assert_difference -> { Storage::Entry.where(operation: \"transfer_in\").count }, +2 do\n        @card.update!(board: @board2)\n      end\n    end\n\n    # Verify each comment's transfer\n    assert_equal(-blob1.byte_size, Storage::Entry.find_by(operation: \"transfer_out\", recordable: comment1).delta)\n    assert_equal blob1.byte_size, Storage::Entry.find_by(operation: \"transfer_in\", recordable: comment1).delta\n    assert_equal(-blob2.byte_size, Storage::Entry.find_by(operation: \"transfer_out\", recordable: comment2).delta)\n    assert_equal blob2.byte_size, Storage::Entry.find_by(operation: \"transfer_in\", recordable: comment2).delta\n  end\n\n  test \"board transfer net effect on account is zero\" do\n    @card.image.attach io: StringIO.new(\"x\" * 1024), filename: \"test.png\", content_type: \"image/png\"\n\n    # Materialize account storage before transfer\n    @account.materialize_storage\n    initial_account_bytes = @account.bytes_used\n\n    @card.update!(board: @board2)\n\n    # Materialize again\n    @account.materialize_storage\n\n    # Account total should be unchanged (transfer_out + transfer_in = 0 for account)\n    assert_equal initial_account_bytes, @account.bytes_used\n  end\n\n  test \"board transfer correctly moves storage between boards\" do\n    @card.image.attach io: StringIO.new(\"x\" * 1024), filename: \"test.png\", content_type: \"image/png\"\n\n    # Materialize both boards\n    @board1.materialize_storage\n    @board2.materialize_storage\n\n    board1_initial = @board1.bytes_used\n    board2_initial = @board2.bytes_used\n\n    # Small delay to ensure UUIDv7 timestamp advances for transfer entries\n    travel 1.second\n\n    @card.update!(board: @board2)\n\n    # Materialize again\n    @board1.materialize_storage\n    @board2.materialize_storage\n\n    # Board1 loses 1024, Board2 gains 1024\n    assert_equal board1_initial - 1024, @board1.bytes_used\n    assert_equal board2_initial + 1024, @board2.bytes_used\n  end\n\n  test \"non-board updates do not trigger transfer tracking\" do\n    @card.image.attach io: StringIO.new(\"x\" * 1024), filename: \"test.png\", content_type: \"image/png\"\n    initial_count = Storage::Entry.where(operation: [ \"transfer_out\", \"transfer_in\" ]).count\n\n    @card.update!(title: \"New Title\")\n\n    final_count = Storage::Entry.where(operation: [ \"transfer_out\", \"transfer_in\" ]).count\n    assert_equal initial_count, final_count\n  end\n\n  test \"attachments_for_storage returns all direct attachments\" do\n    @card.image.attach io: StringIO.new(\"x\" * 1024), filename: \"test.png\", content_type: \"image/png\"\n    attachments = @card.send(:attachments_for_storage)\n\n    assert_equal 1, attachments.count\n    assert_equal @card.image.blob.byte_size, attachments.first.blob.byte_size\n  end\n\n  test \"attachments_for_storage includes rich text embeds\" do\n    blob = ActiveStorage::Blob.create_and_upload! \\\n      io: file_fixture(\"moon.jpg\").open,\n      filename: \"embed.jpg\",\n      content_type: \"image/jpeg\"\n\n    embed_html = ActionText::Attachment.from_attachable(blob).to_html\n    @card.update!(description: \"<p>Content with #{embed_html}</p>\")\n\n    attachments = @card.send(:attachments_for_storage)\n\n    assert_equal 1, attachments.count\n    assert_equal blob.byte_size, attachments.first.blob.byte_size\n  end\n\n  test \"attachments_for_storage includes both direct and rich text attachments\" do\n    @card.image.attach io: StringIO.new(\"x\" * 1024), filename: \"test.png\", content_type: \"image/png\"\n\n    blob = ActiveStorage::Blob.create_and_upload! \\\n      io: file_fixture(\"moon.jpg\").open,\n      filename: \"embed.jpg\",\n      content_type: \"image/jpeg\"\n\n    embed_html = ActionText::Attachment.from_attachable(blob).to_html\n    @card.update!(description: \"<p>Content with #{embed_html}</p>\")\n\n    attachments = @card.send(:attachments_for_storage)\n\n    assert_equal 2, attachments.count\n    assert_equal 1024 + blob.byte_size, attachments.sum { |a| a.blob.byte_size }\n  end\nend\n"
  },
  {
    "path": "test/models/tag_test.rb",
    "content": "require \"test_helper\"\n\nclass TagTest < ActiveSupport::TestCase\n  test \"downcase title\" do\n    assert_equal \"a tag\", Tag.create!(title: \"A TAG\").title\n  end\n\n  test \".unused returns tags not associated with any cards\" do\n    unused = Tag.create!(title: \"unused\")\n\n    unused_tags = Tag.unused\n\n    assert_includes unused_tags, unused\n    assert_not_includes unused_tags, tags(:web)\n    assert_not_includes unused_tags, tags(:mobile)\n  end\n\n  test \".unused returns empty relation if all tags are used\" do\n    assert_empty Tag.unused\n  end\nend\n"
  },
  {
    "path": "test/models/time_window_parser_test.rb",
    "content": "require \"test_helper\"\n\nclass TimeWindowParserTest < ActiveSupport::TestCase\n  setup do\n    @now = Time.zone.parse(\"2023-06-15 9am\")\n    @parser = TimeWindowParser.new(now: @now)\n  end\n\n  test \"parse today\" do\n    assert_equal @now.beginning_of_day..@now.end_of_day,\n      @parser.parse(\"today\")\n  end\n\n  test \"parse yesterday\" do\n    yesterday = @now - 1.day\n\n    assert_equal yesterday.beginning_of_day..yesterday.end_of_day,\n      @parser.parse(\"yesterday\")\n  end\n\n  test \"parse this week\" do\n    assert_equal @now.beginning_of_week..@now.end_of_week,\n      @parser.parse(\"this week\")\n  end\n\n  test \"parse this month\" do\n    assert_equal @now.beginning_of_month..@now.end_of_month,\n      @parser.parse(\"this month\")\n  end\n\n  test \"parse this year\" do\n    assert_equal @now.beginning_of_year..@now.end_of_year,\n      @parser.parse(\"this year\")\n  end\n\n  test \"parse last week\" do\n    last_week = @now - 1.week\n\n    assert_equal last_week.beginning_of_week..last_week.end_of_week,\n      @parser.parse(\"last week\")\n  end\n\n  test \"parse last month\" do\n    last_month = @now - 1.month\n\n    assert_equal last_month.beginning_of_month..last_month.end_of_month,\n      @parser.parse(\"last month\")\n  end\n\n  test \"parse last year\" do\n    last_year = @now - 1.year\n\n    assert_equal last_year.beginning_of_year..last_year.end_of_year,\n      @parser.parse(\"last year\")\n  end\n\n  test \"parse with unknown string returns nil\" do\n    assert_nil @parser.parse(\"unknown time window\")\n  end\n\n  test \"returns nil for nil\" do\n    assert_nil @parser.parse(nil)\n  end\nend\n"
  },
  {
    "path": "test/models/user/accessor_test.rb",
    "content": "require \"test_helper\"\n\nclass User::AccessorTest < ActiveSupport::TestCase\n  test \"new users get added to all_access boards on creation\" do\n    user = User.create!(account: accounts(\"37s\"), name: \"Jorge\")\n\n    assert_includes user.boards, boards(:writebook)\n    assert_equal user.account.boards.all_access.count, user.boards.count\n  end\n\n  test \"system user does not get added to boards on creation\" do\n    system_user = User.create!(account: accounts(\"37s\"), role: \"system\", name: \"Test System User\")\n    assert_empty system_user.boards\n  end\n\n  test \"creating a new card draft sets current timestamps\" do\n    user = users(:david)\n    board = boards(:writebook)\n\n    freeze_time do\n      card = user.draft_new_card_in(board)\n\n      assert card.persisted?\n      assert card.drafted?\n      assert_equal user, card.creator\n      assert_equal board, card.board\n      assert_equal Time.current, card.created_at\n      assert_equal Time.current, card.updated_at\n      assert_equal Time.current, card.last_active_at\n    end\n  end\n\n  test \"reusing an existing card draft refreshes timestamps\" do\n    existing_draft = cards(:unfinished_thoughts)\n    user = existing_draft.creator\n    board = existing_draft.board\n\n    freeze_time do\n      card = user.draft_new_card_in(board)\n\n      assert_equal existing_draft, card\n      assert_equal Time.current, card.created_at\n      assert_equal Time.current, card.updated_at\n      assert_equal Time.current, card.last_active_at\n    end\n  end\nend\n"
  },
  {
    "path": "test/models/user/avatar_test.rb",
    "content": "require \"test_helper\"\n\nclass User::AvatarTest < ActiveSupport::TestCase\n  test \"avatar_thumbnail returns variant for variable images\" do\n    users(:david).avatar.attach(io: File.open(file_fixture(\"moon.jpg\")), filename: \"moon.jpg\", content_type: \"image/jpeg\")\n\n    assert users(:david).avatar.variable?\n    assert_equal users(:david).avatar.variant(:thumb).blob, users(:david).avatar_thumbnail.blob\n  end\n\n  test \"avatar_thumbnail returns original blob for non-variable images\" do\n    users(:david).avatar.attach(io: File.open(file_fixture(\"avatar.svg\")), filename: \"avatar.svg\", content_type: \"image/svg+xml\")\n\n    assert_not users(:david).avatar.variable?\n    assert_equal users(:david).avatar.blob, users(:david).avatar_thumbnail.blob\n  end\n\n  test \"allows valid image content types\" do\n    users(:david).avatar.attach(io: File.open(file_fixture(\"moon.jpg\")), filename: \"test.jpg\", content_type: \"image/jpeg\")\n\n    assert users(:david).valid?\n  end\n\n  test \"rejects SVG uploads\" do\n    users(:david).avatar.attach(io: File.open(file_fixture(\"avatar.svg\")), filename: \"avatar.svg\")\n\n    assert_not users(:david).valid?\n    assert_includes users(:david).errors[:avatar], \"must be a JPEG, PNG, GIF, or WebP image\"\n  end\n\n  test \"thumb variant is processed immediately on attachment\" do\n    users(:david).avatar.attach(io: File.open(file_fixture(\"avatar.png\")), filename: \"avatar.png\", content_type: \"image/png\")\n\n    assert users(:david).avatar.variant(:thumb).processed?\n  end\n\n  test \"rejects images that are too wide\" do\n    users(:david).avatar.attach(io: File.open(file_fixture(\"avatar.png\")), filename: \"avatar.png\", content_type: \"image/png\")\n    users(:david).avatar.blob.update!(metadata: { analyzed: true, width: 5000, height: 100 })\n\n    assert_not users(:david).valid?\n    assert_includes users(:david).errors[:avatar], \"width must be less than #{User::Avatar::MAX_AVATAR_DIMENSIONS[:width]}px\"\n  end\n\n  test \"rejects images that are too tall\" do\n    users(:david).avatar.attach(io: File.open(file_fixture(\"avatar.png\")), filename: \"avatar.png\", content_type: \"image/png\")\n    users(:david).avatar.blob.update!(metadata: { analyzed: true, width: 100, height: 5000 })\n\n    assert_not users(:david).valid?\n    assert_includes users(:david).errors[:avatar], \"height must be less than #{User::Avatar::MAX_AVATAR_DIMENSIONS[:height]}px\"\n  end\n\n  test \"accepts images within dimension limits\" do\n    users(:david).avatar.attach(io: File.open(file_fixture(\"avatar.png\")), filename: \"avatar.png\", content_type: \"image/png\")\n    users(:david).avatar.blob.update!(metadata: { analyzed: true, width: 4096, height: 4096 })\n\n    assert users(:david).valid?\n  end\nend\n"
  },
  {
    "path": "test/models/user/configurable_test.rb",
    "content": "require \"test_helper\"\n\nclass User::ConfigurableTest < ActiveSupport::TestCase\n  test \"should create settings for new users\" do\n    user = User.create! account: accounts(\"37s\"), name: \"Some new user\"\n    assert user.settings.present?\n  end\nend\n"
  },
  {
    "path": "test/models/user/data_export_test.rb",
    "content": "require \"test_helper\"\n\nclass User::DataExportTest < ActiveSupport::TestCase\n  test \"build generates zip with card JSON files\" do\n    export = User::DataExport.create!(account: Current.account, user: users(:david))\n\n    export.build\n\n    assert export.completed?\n    assert export.file.attached?\n    assert_equal \"application/zip\", export.file.content_type\n  end\n\n  test \"build sets status to processing then completed\" do\n    export = User::DataExport.create!(account: Current.account, user: users(:david))\n\n    export.build\n\n    assert export.completed?\n    assert_not_nil export.completed_at\n  end\n\n  test \"build sends email when completed\" do\n    export = User::DataExport.create!(account: Current.account, user: users(:david))\n\n    assert_enqueued_jobs 1, only: ActionMailer::MailDeliveryJob do\n      export.build\n    end\n  end\n\n  test \"build includes only accessible cards for user\" do\n    user = users(:david)\n    export = User::DataExport.create!(account: Current.account, user: user)\n\n    export.build\n\n    assert export.completed?\n    assert export.file.attached?\n\n    Tempfile.create([ \"test\", \".zip\" ]) do |temp|\n      temp.binmode\n      export.file.download { |chunk| temp.write(chunk) }\n      temp.rewind\n\n      reader = ZipKit::FileReader.read_zip_structure(io: temp)\n      json_files = reader.select { |e| e.filename.end_with?(\".json\") }\n      assert json_files.any?, \"Zip should contain at least one JSON file\"\n\n      extractor = json_files.first.extractor_from(temp)\n      json_content = JSON.parse(extractor.extract)\n      assert json_content.key?(\"number\")\n      assert json_content.key?(\"title\")\n      assert json_content.key?(\"board\")\n      assert json_content.key?(\"creator\")\n      assert json_content[\"creator\"].key?(\"id\")\n      assert json_content[\"creator\"].key?(\"name\")\n      assert json_content[\"creator\"].key?(\"email\")\n      assert json_content.key?(\"description\")\n      assert json_content.key?(\"comments\")\n    end\n  end\n\n  test \"build_later enqueues DataExportJob\" do\n    export = User::DataExport.create!(account: Current.account, user: users(:david))\n\n    assert_enqueued_with(job: DataExportJob, args: [ export ]) do\n      export.build_later\n    end\n  end\nend\n"
  },
  {
    "path": "test/models/user/email_address_changeable_test.rb",
    "content": "require \"test_helper\"\n\nclass User::EmailAddressChangeableTest < ActiveSupport::TestCase\n  include ActionMailer::TestHelper\n\n  setup do\n    @identity = identities(:kevin)\n    @user = @identity.users.find_by!(account: accounts(\"37s\"))\n    @new_email = \"newart@example.com\"\n    @old_email = @identity.email_address\n  end\n\n  test \"send_email_address_change_confirmation\" do\n    assert_emails 1 do\n      @user.send_email_address_change_confirmation(@new_email)\n    end\n  end\n\n  test \"change_email_address\" do\n    old_identity = @identity\n    new_identity = identities(:mike)\n\n    assert_difference -> { Identity.count }, +1 do\n      @user.change_email_address(@new_email)\n    end\n\n    assert_equal @new_email, @user.reload.identity.email_address\n    assert_not old_identity.reload.users.exists?(id: @user.id)\n    assert_equal @new_email, @user.reload.identity.email_address\n\n    assert_no_difference -> { Identity.count } do\n      @user.change_email_address(new_identity.email_address)\n    end\n    assert_equal new_identity.email_address, @user.reload.identity.email_address\n  end\n\n  test \"change_email_address_using_token\" do\n    token = @user.send(:generate_email_address_change_token, to: @new_email)\n\n    @user.change_email_address_using_token(token)\n\n    assert_equal @new_email, @user.reload.identity.email_address\n  end\n\n  test \"change_email_address_using_token with invalid token\" do\n    assert_not @user.change_email_address_using_token(\"invalid_token\")\n    assert_equal @old_email, @user.reload.identity.email_address\n\n    token = @user.send(:generate_email_address_change_token, to: @new_email)\n    old_email = \"#{SecureRandom.hex(16)}@example.com\"\n    @identity.update!(email_address: old_email)\n    @user.reload\n\n    assert_not @user.change_email_address_using_token(token)\n    assert_equal old_email, @user.reload.identity.email_address\n  end\nend\n"
  },
  {
    "path": "test/models/user/mentionable_test.rb",
    "content": "require \"test_helper\"\n\nclass User::MentionableTest < ActiveSupport::TestCase\n  test \"mentionable handles\" do\n    assert_equal [ \"dhh\", \"david\", \"davidh\" ], User.new(name: \"David Heinemeier-Hansson\").mentionable_handles\n  end\n\n  test \"mentioned by\" do\n    users(:david).mentions.destroy_all\n\n    assert_difference -> { users(:david).mentions.count }, +1 do\n      users(:david).mentioned_by users(:jz), at: cards(:logo)\n    end\n\n    # No dups\n    assert_no_difference -> { users(:david).mentions.count }, +1 do\n      users(:david).mentioned_by users(:jz), at: cards(:logo)\n    end\n  end\nend\n"
  },
  {
    "path": "test/models/user/named_test.rb",
    "content": "require \"test_helper\"\n\nclass User::NamedTest < ActiveSupport::TestCase\n  test \"initials\" do\n    assert_initials \"M\", name: \"Michael\"\n    assert_initials \"SD\", name: \"Salvador Dali\"\n    assert_initials \"LMM\", name: \"Lin-Manuel Miranda\"\n    assert_initials \"OCD\", name: \"O'Conor Díez\"\n    assert_initials \"ACG\", name: \"Anne Christine García\"\n    assert_initials \"ÁL\", name: \"Ángela López\"\n  end\n\n  test \"first name\" do\n    assert_first_name \"Michael\", \"Michael\"\n    assert_first_name \"Salvador\", \"Salvador Dali\"\n    assert_first_name \"Lin-Manuel\", \"Lin-Manuel Miranda\"\n    assert_first_name \"Anne\", \"Anne Christine García\"\n  end\n\n  test \"last name\" do\n    assert_last_name \"Dali\", \"Salvador Dali\"\n    assert_last_name \"Miranda\", \"Lin_Manuel Miranda\"\n    assert_last_name \"Christine García\", \"Anne Christine García\"\n  end\n\n  private\n    def assert_initials(expected, **attributes)\n      assert_equal expected, User.new(attributes).initials\n    end\n\n    def assert_first_name(expected, name)\n      assert_equal expected, User.new(name: name).first_name\n    end\n\n    def assert_last_name(expected, name)\n      assert_equal expected, User.new(name: name).last_name\n    end\nend\n"
  },
  {
    "path": "test/models/user/notifiable_test.rb",
    "content": "require \"test_helper\"\n\nclass User::NotifiableTest < ActiveSupport::TestCase\n  setup do\n    @user = users(:david)\n    @user.notifications.destroy_all\n    @user.settings.bundle_email_every_few_hours!\n  end\n\n  test \"bundle method creates new bundle for first notification\" do\n    notification = assert_difference -> { @user.notification_bundles.count }, 1 do\n      @user.notifications.create!(source: events(:logo_published), creator: @user)\n    end\n\n    bundle = @user.notification_bundles.last\n    assert_equal notification.updated_at, bundle.starts_at\n    assert bundle.pending?\n  end\n\n  test \"bundle method finds existing bundle within aggregation period\" do\n    @user.notifications.create!(source: events(:logo_published), creator: @user)\n\n    assert_no_difference -> { @user.notification_bundles.count } do\n      @user.notifications.create!(source: events(:layout_published), creator: @user)\n    end\n  end\nend\n"
  },
  {
    "path": "test/models/user/role_test.rb",
    "content": "require \"test_helper\"\n\nclass User::RoleTest < ActiveSupport::TestCase\n  test \"can administer others?\" do\n    assert users(:kevin).can_administer?(users(:jz))\n\n    assert_not users(:kevin).can_administer?(users(:kevin))\n    assert_not users(:jz).can_administer?(users(:kevin))\n  end\n\n  test \"owner can administer admins and members\" do\n    assert users(:jason).can_administer?(users(:kevin))\n    assert users(:jason).can_administer?(users(:david))\n    assert users(:jason).can_administer?(users(:jz))\n  end\n\n  test \"owner cannot administer themselves\" do\n    assert_not users(:jason).can_administer?(users(:jason))\n  end\n\n  test \"admin cannot administer the owner\" do\n    assert_not users(:kevin).can_administer?(users(:jason))\n  end\n\n  test \"owner is included in active scope\" do\n    active_users = User.active\n    assert_includes active_users, users(:jason)\n    assert_includes active_users, users(:kevin)\n    assert_includes active_users, users(:david)\n    assert_not_includes active_users, users(:system)\n  end\n\n  test \"owner is also considered an admin\" do\n    assert users(:jason).owner?\n    assert users(:jason).admin?\n\n    assert users(:kevin).admin?\n    assert_not users(:kevin).owner?\n  end\n\n  test \"owner scope returns only active owners\" do\n    owners = accounts(\"37s\").users.owner\n    assert_includes owners, users(:jason)\n    assert_not_includes owners, users(:kevin)\n    assert_not_includes owners, users(:david)\n\n    users(:jason).update!(active: false)\n    assert_not_includes accounts(\"37s\").users.owner, users(:jason)\n  end\n\n  test \"admin scope returns active owners and admins\" do\n    admins = accounts(\"37s\").users.admin\n    assert_includes admins, users(:jason)\n    assert_includes admins, users(:kevin)\n    assert_not_includes admins, users(:david)\n\n    users(:kevin).update!(active: false)\n    assert_not_includes accounts(\"37s\").users.admin, users(:kevin)\n  end\n\n  test \"can administer board?\" do\n    writebook_board = boards(:writebook)\n    private_board = boards(:private)\n\n    # Admin can administer any board\n    assert users(:kevin).can_administer_board?(writebook_board)\n    assert users(:kevin).can_administer_board?(private_board)\n\n    # Creator can administer their own board\n    assert users(:david).can_administer_board?(writebook_board)\n\n    # Regular user cannot administer boards they didn't create\n    assert_not users(:jz).can_administer_board?(writebook_board)\n    assert_not users(:jz).can_administer_board?(private_board)\n\n    # Creator cannot administer other people's boards\n    assert_not users(:david).can_administer_board?(private_board)\n  end\n\n  test \"can administer card?\" do\n    logo_card = cards(:logo)\n    text_card = cards(:text)\n\n    # Admin can administer any card\n    assert users(:kevin).can_administer_card?(logo_card)\n    assert users(:kevin).can_administer_card?(text_card)\n\n    # Creator can administer their own card\n    assert users(:david).can_administer_card?(logo_card)\n\n    # Regular user cannot administer cards they didn't create\n    assert_not users(:jz).can_administer_card?(logo_card)\n    assert_not users(:jz).can_administer_card?(text_card)\n\n    # Creator cannot administer other people's cards\n    assert_not users(:david).can_administer_card?(text_card)\n  end\nend\n"
  },
  {
    "path": "test/models/user/searcher_test.rb",
    "content": "require \"test_helper\"\n\nclass User::SearcherTest < ActiveSupport::TestCase\n  setup do\n    @user = users(:kevin)\n  end\n\n  test \"remember the last search\" do\n    assert_difference -> { @user.search_queries.count }, +1 do\n      @user.remember_search(\"broken\")\n    end\n\n    assert_equal \"broken\", @user.search_queries.last.terms\n  end\n\n  test \"don't duplicate repeated searches but touch the existing match\" do\n    search_result = @user.remember_search(\"broken\")\n    original_updated_at = search_result.updated_at\n\n    travel_to 1.day.from_now\n\n    assert_no_difference -> { @user.search_queries.count }, +1 do\n      @user.remember_search(\"broken\")\n    end\n\n    assert search_result.reload.updated_at > original_updated_at\n  end\nend\n"
  },
  {
    "path": "test/models/user/settings_test.rb",
    "content": "require \"test_helper\"\n\nclass User::SettingsTest < ActiveSupport::TestCase\n  setup do\n    @user = users(:david)\n    @settings = @user.settings\n  end\n\n  test \"changing the bundle email frequency to never will cancel pending bundles\" do\n    @settings.update!(bundle_email_frequency: :every_few_hours)\n    bundle = @user.notification_bundles.create!\n    @settings.update!(bundle_email_frequency: :never)\n    assert_nil Notification::Bundle.find_by(id: bundle.id)\n  end\n\n  test \"changing the bundle email frequency will deliver pending bundles\" do\n    bundle = @user.notification_bundles.create!\n    assert bundle.pending?\n\n    freeze_time Time.current do\n      perform_enqueued_jobs only: Notification::Bundle::DeliverJob do\n        @settings.update!(bundle_email_frequency: :daily)\n      end\n\n      assert bundle.reload.delivered?\n      assert_equal Time.current, bundle.ends_at\n    end\n  end\n\n  test \"changing other settings will not affect pending bundles\" do\n    bundle = @user.notification_bundles.create!\n\n    perform_enqueued_jobs only: Notification::Bundle::DeliverJob do\n      @settings.update!(updated_at: 1.hour.from_now)\n    end\n\n    assert bundle.reload.pending?\n  end\n\n  test \"bundling_emails?\" do\n    @settings.update!(bundle_email_frequency: :never)\n    assert_not @user.settings.bundling_emails?\n\n    @settings.update!(bundle_email_frequency: :every_few_hours)\n    assert @user.settings.bundling_emails?\n\n    @user.update!(role: :system)\n    assert_not @user.settings.bundling_emails?, \"System users should not receive bundled emails\"\n\n    @user.update!(role: :member, active: false)\n    assert_not @user.settings.bundling_emails?, \"Inactive users should not receive bundled emails\"\n\n    @user.update!(active: true)\n    @user.update_column(:verified_at, nil)\n    assert_not @user.settings.bundling_emails?, \"Unverified users should not receive bundled emails\"\n  end\nend\n"
  },
  {
    "path": "test/models/user_test.rb",
    "content": "require \"test_helper\"\n\nclass UserTest < ActiveSupport::TestCase\n  test \"create\" do\n    user = User.create!(\n      account: accounts(\"37s\"),\n      role: \"member\",\n      name: \"Victor Cooper\"\n    )\n\n    assert_equal [ boards(:writebook) ], user.boards\n    assert user.settings.present?\n  end\n\n  test \"creation gives access to all_access boards\" do\n    user = User.create!(\n      account: accounts(\"37s\"),\n      role: \"member\",\n      name: \"Victor Cooper\"\n    )\n\n    assert_equal [ boards(:writebook) ], user.boards\n  end\n\n  test \"deactivate\" do\n    assert_changes -> { users(:jz).active? }, from: true, to: false do\n      assert_changes -> { users(:jz).accesses.count }, from: 1, to: 0 do\n        users(:jz).tap do |user|\n          user.stubs(:close_remote_connections).once\n          user.deactivate\n        end\n      end\n    end\n  end\n\n  test \"initials\" do\n    assert_equal \"JF\", User.new(name: \"jason fried\").initials\n    assert_equal \"DHH\", User.new(name: \"David Heinemeier Hansson\").initials\n    assert_equal \"ÉLH\", User.new(name: \"Éva-Louise Hernández\").initials\n  end\n\n  test \"name methods handle blank names gracefully\" do\n    user = User.new(name: \"\")\n    assert_equal \"\", user.familiar_name\n    assert_nil user.first_name\n    assert_nil user.last_name\n    assert_equal \"\", user.initials\n  end\n\n  test \"validates name presence\" do\n    user = User.new(account: accounts(\"37s\"), role: \"member\", name: \"\")\n    assert_not user.valid?\n    assert_includes user.errors[:name], \"can't be blank\"\n\n    user.name = \"   \"\n    assert_not user.valid?\n    assert_includes user.errors[:name], \"can't be blank\"\n\n    user.name = \"Victor Cooper\"\n    assert user.valid?\n  end\n\n  test \"setup?\" do\n    user = users(:kevin)\n\n    user.update!(name: user.identity.email_address)\n    assert_not user.setup?\n\n    user.update!(name: \"Kevin\")\n    assert user.setup?\n  end\n\n  test \"verified? returns true when verified_at is present\" do\n    user = users(:david)\n    user.update_column(:verified_at, Time.current)\n\n    assert user.verified?\n  end\n\n  test \"verified? returns false when verified_at is nil\" do\n    user = users(:david)\n    user.update_column(:verified_at, nil)\n\n    assert_not user.verified?\n  end\n\n  test \"verify sets verified_at when not already verified\" do\n    user = users(:david)\n    user.update_column(:verified_at, nil)\n\n    assert_nil user.verified_at\n    user.verify\n    assert_not_nil user.reload.verified_at\n  end\n\n  test \"verify does not update verified_at when already verified\" do\n    user = users(:david)\n    original_time = 1.day.ago\n    user.update_column(:verified_at, original_time)\n\n    user.verify\n    assert_equal original_time.to_i, user.reload.verified_at.to_i\n  end\nend\n"
  },
  {
    "path": "test/models/webhook/delinquency_tracker_test.rb",
    "content": "require \"test_helper\"\n\nclass Webhook::DelinquencyTrackerTest < ActiveSupport::TestCase\n  test \"record_delivery_of\" do\n    tracker = webhook_delinquency_trackers(:active_webhook_tracker)\n    webhook = tracker.webhook\n    successful_delivery = webhook_deliveries(:successfully_completed)\n    failed_delivery = webhook_deliveries(:errored)\n\n    tracker.update!(consecutive_failures_count: 5)\n    tracker.record_delivery_of(successful_delivery)\n    tracker.reload\n\n    assert_equal 0, tracker.consecutive_failures_count\n    assert_nil tracker.first_failure_at\n\n    assert_difference -> { tracker.reload.consecutive_failures_count }, +1 do\n      tracker.record_delivery_of(failed_delivery)\n    end\n\n    tracker.reload\n    assert_not_nil tracker.first_failure_at\n\n    assert_difference -> { tracker.reload.consecutive_failures_count }, +1 do\n      assert_no_difference -> { tracker.reload.first_failure_at } do\n        tracker.record_delivery_of(failed_delivery)\n      end\n    end\n\n    travel_to 2.hours.from_now do\n      tracker.update!(consecutive_failures_count: 9)\n      webhook.activate\n\n      assert_changes -> { webhook.reload.active? }, from: true, to: false do\n        tracker.record_delivery_of(failed_delivery)\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "test/models/webhook/delivery_test.rb",
    "content": "require \"test_helper\"\n\nclass Webhook::DeliveryTest < ActiveSupport::TestCase\n  PUBLIC_TEST_IP = \"93.184.216.34\" # example.com's real IP, used as a public IP stand-in\n\n  setup do\n    stub_dns_resolution(PUBLIC_TEST_IP)\n  end\n\n  test \"create\" do\n    webhook = webhooks(:active)\n    event = events(:layout_commented)\n    delivery = Webhook::Delivery.create!(webhook: webhook, event: event)\n\n    assert_equal \"pending\", delivery.state\n  end\n\n  test \"succeeded\" do\n    webhook = webhooks(:active)\n    event = events(:layout_commented)\n    delivery = Webhook::Delivery.new(\n      webhook: webhook,\n      event: event,\n      response: { code: 200 },\n      state: :completed\n    )\n    assert delivery.succeeded?\n\n    delivery.response[:code] = 422\n    assert_not delivery.succeeded?, \"resonse must have a 2XX status\"\n\n    delivery.response[:code] = 200\n    delivery.state = :pending\n    assert_not delivery.succeeded?, \"state must be completed\"\n\n    delivery.state = :in_progress\n    assert_not delivery.succeeded?, \"state must be completed\"\n\n    delivery.state = :errored\n    assert_not delivery.succeeded?, \"state must be completed\"\n\n    delivery.state = :completed\n    delivery.response[:error] = :destination_unreachable\n\n    assert_not delivery.succeeded?, \"the response can't have an error\"\n  end\n\n  test \"deliver_later\" do\n    delivery = webhook_deliveries(:pending)\n\n    assert_enqueued_with job: Webhook::DeliveryJob, args: [ delivery ] do\n      delivery.deliver_later\n    end\n  end\n\n  test \"deliver\" do\n    delivery = webhook_deliveries(:pending)\n\n    stub_request(:post, delivery.webhook.url)\n      .to_return(status: 200, headers: { \"content-type\" => \"application/json\" })\n\n    assert_equal \"pending\", delivery.state\n\n    tracker = delivery.webhook.delinquency_tracker\n    tracker.update!(consecutive_failures_count: 0)\n\n    assert_no_difference -> { tracker.reload.consecutive_failures_count } do\n      delivery.deliver\n    end\n\n    assert delivery.persisted?\n    assert_equal \"completed\", delivery.state\n    assert delivery.request[:headers].present?\n    assert_equal 200, delivery.response[:code]\n    assert delivery.response[:error].blank?\n    assert delivery.succeeded?\n  end\n\n  test \"deliver when the network timeouts\" do\n    delivery = webhook_deliveries(:pending)\n    stub_request(:post, delivery.webhook.url).to_timeout\n\n    tracker = delivery.webhook.delinquency_tracker\n    assert_difference -> { tracker.reload.consecutive_failures_count }, 1 do\n      delivery.deliver\n    end\n\n    assert_equal \"completed\", delivery.state\n    assert_equal \"connection_timeout\", delivery.response[:error]\n    assert_not delivery.succeeded?\n  end\n\n  test \"deliver when the connection is refused\" do\n    delivery = webhook_deliveries(:pending)\n    stub_request(:post, delivery.webhook.url).to_raise(Errno::ECONNREFUSED)\n\n    delivery.deliver\n\n    assert_equal \"completed\", delivery.state\n    assert_equal \"destination_unreachable\", delivery.response[:error]\n  end\n\n  test \"deliver when an SSL error occurs\" do\n    delivery = webhook_deliveries(:pending)\n    stub_request(:post, delivery.webhook.url).to_raise(OpenSSL::SSL::SSLError)\n\n    delivery.deliver\n\n    assert_equal \"completed\", delivery.state\n    assert_equal \"failed_tls\", delivery.response[:error]\n  end\n\n  test \"deliver when an unexpected error occurs\" do\n    delivery = webhook_deliveries(:pending)\n    stub_request(:post, delivery.webhook.url).to_raise(StandardError, \"Unexpected error\")\n\n    assert_raises(StandardError) do\n      delivery.deliver\n    end\n\n    assert_equal \"errored\", delivery.state\n  end\n\n  test \"deliver with basecamp webhook format\" do\n    webhook = Webhook.create!(\n      board: boards(:writebook),\n      name: \"Basecamp\",\n      url: \"https://3.basecamp.com/123/integrations/webhook/buckets/456/chats/789/lines\"\n    )\n    event = events(:layout_commented)\n    delivery = Webhook::Delivery.create!(webhook: webhook, event: event)\n\n    request_stub = stub_request(:post, webhook.url)\n      .with do |request|\n        body = CGI.parse(request.body)\n        body.key?(\"content\") && body[\"content\"].first.present? &&\n        request.headers[\"Content-Type\"] == \"application/x-www-form-urlencoded\"\n      end\n      .to_return(status: 200)\n\n    delivery.deliver\n\n    assert_requested request_stub\n    assert delivery.succeeded?\n  end\n\n  test \"deliver with campfire webhook format\" do\n    webhook = Webhook.create!(\n      board: boards(:writebook),\n      name: \"Campfire\",\n      url: \"https://example.com/rooms/123/456-room-name/messages\"\n    )\n    event = events(:layout_commented)\n    delivery = Webhook::Delivery.create!(webhook: webhook, event: event)\n\n    request_stub = stub_request(:post, webhook.url)\n      .with do |request|\n        request.body.is_a?(String) && !request.body.start_with?(\"{\") && request.body.present? &&\n        request.headers[\"Content-Type\"] == \"text/html\"\n      end\n      .to_return(status: 200)\n\n    delivery.deliver\n\n    assert_requested request_stub\n    assert delivery.succeeded?\n  end\n\n  test \"deliver with slack webhook format\" do\n    webhook = Webhook.create!(\n      board: boards(:writebook),\n      name: \"Slack\",\n      url: \"https://hooks.slack.com/services/T12345678/B12345678/abcdefghijklmnopqrstuvwx\" # gitleaks:allow\n    )\n    event = events(:layout_commented)\n    delivery = Webhook::Delivery.create!(webhook: webhook, event: event)\n\n    request_stub = stub_request(:post, webhook.url)\n      .with do |request|\n        body = JSON.parse(request.body)\n        body.key?(\"text\") && body[\"text\"].present? &&\n        request.headers[\"Content-Type\"] == \"application/json\"\n      end\n      .to_return(status: 200)\n\n    delivery.deliver\n\n    assert_requested request_stub\n    assert delivery.succeeded?\n  end\n\n  test \"deliver with generic webhook format\" do\n    webhook = Webhook.create!(\n      board: boards(:writebook),\n      name: \"Generic\",\n      url: \"https://example.com/webhook\"\n    )\n    event = events(:layout_commented)\n    delivery = Webhook::Delivery.create!(webhook: webhook, event: event)\n\n    request_stub = stub_request(:post, webhook.url)\n      .with do |request|\n        body = JSON.parse(request.body)\n        body.present? && !body.key?(\"line\") && !body.key?(\"text\") &&\n        request.headers[\"Content-Type\"] == \"application/json\"\n      end\n      .to_return(status: 200)\n\n    delivery.deliver\n\n    assert_requested request_stub\n    assert delivery.succeeded?\n  end\n\n  test \"cleanup\" do\n    webhook = webhooks(:active)\n    event = events(:layout_commented)\n\n    fresh_delivery = Webhook::Delivery.create!(webhook: webhook, event: event)\n    stale_delivery = Webhook::Delivery.create!(webhook: webhook, event: event, created_at: 8.days.ago)\n\n    Webhook::Delivery.cleanup\n\n    assert Webhook::Delivery.exists?(fresh_delivery.id)\n    assert_not Webhook::Delivery.exists?(stale_delivery.id)\n  end\n\n  test \"renders the creator name when event creator is current user\" do\n    webhook = Webhook.create!(\n      board: boards(:writebook),\n      name: \"Basecamp\",\n      url: \"https://3.basecamp.com/123/integrations/webhook/buckets/456/chats/789/lines\"\n    )\n    event = events(:logo_published)\n    delivery = Webhook::Delivery.create!(webhook: webhook, event: event)\n\n    Current.session = sessions(:david)\n\n    request_stub = stub_request(:post, webhook.url)\n      .with { |request| CGI.parse(request.body)[\"content\"].first.include?(\"David added\") }\n      .to_return(status: 200)\n\n    delivery.deliver\n\n    assert_requested request_stub\n  end\n\n  test \"basecamp webhook payload html-escapes special characters\" do\n    cards(:logo).update_column(:title, %(Tom & Jerry's <Great> \"Adventure\"))\n\n    webhook = Webhook.create!(\n      board: boards(:writebook),\n      name: \"Basecamp\",\n      url: \"https://3.basecamp.com/123/integrations/webhook/buckets/456/chats/789/lines\"\n    )\n    delivery = Webhook::Delivery.create!(webhook: webhook, event: events(:logo_published))\n\n    captured_body = nil\n    stub_request(:post, webhook.url)\n      .with { |request| captured_body = request.body; true }\n      .to_return(status: 200)\n\n    delivery.deliver\n\n    content = CGI.parse(captured_body)[\"content\"].first\n\n    expected = <<~HTML.strip\n      David added &quot;Tom &amp; Jerry&#39;s &lt;Great&gt; &quot;Adventure&quot;&quot;\n      <a href=\"http://example.org/897362094/cards/1\">↗︎</a>\n    HTML\n    assert_equal expected, content\n  end\n\n  test \"slack webhook payload html-escapes special characters\" do\n    cards(:logo).update_column(:title, %(Tom & Jerry's <Great> \"Adventure\"))\n\n    webhook = Webhook.create!(\n      board: boards(:writebook),\n      name: \"Slack\",\n      url: \"https://hooks.slack.com/services/T12345678/B12345678/abcdefghijklmnopqrstuvwx\" # gitleaks:allow\n    )\n    delivery = Webhook::Delivery.create!(webhook: webhook, event: events(:logo_published))\n\n    captured_body = nil\n    stub_request(:post, webhook.url)\n      .with { |request| captured_body = request.body; true }\n      .to_return(status: 200)\n\n    delivery.deliver\n\n    text = JSON.parse(captured_body)[\"text\"]\n\n    expected = <<~TEXT.strip\n      David added &quot;Tom &amp; Jerry&#39;s &lt;Great&gt; &quot;Adventure&quot;&quot; <http://example.com/897362094/cards/1|Open in Fizzy>\n    TEXT\n    assert_equal expected, text\n  end\n\n  test \"campfire webhook payload html-escapes special characters\" do\n    cards(:logo).update_column(:title, %(Tom & Jerry's <Great> \"Adventure\"))\n\n    webhook = Webhook.create!(\n      board: boards(:writebook),\n      name: \"Campfire\",\n      url: \"https://example.com/rooms/123/456-room-name/messages\"\n    )\n    delivery = Webhook::Delivery.create!(webhook: webhook, event: events(:logo_published))\n\n    captured_body = nil\n    stub_request(:post, webhook.url)\n      .with { |request| captured_body = request.body; true }\n      .to_return(status: 200)\n\n    delivery.deliver\n\n    expected = <<~HTML.strip\n      David added &quot;Tom &amp; Jerry&#39;s &lt;Great&gt; &quot;Adventure&quot;&quot;\n      <a href=\"http://example.org/897362094/cards/1\">↗︎</a>\n    HTML\n    assert_equal expected, captured_body\n  end\n\n  test \"generic webhook payload json-encodes special characters\" do\n    cards(:logo).update_column(:title, %(Tom & Jerry's <Great> \"Adventure\"))\n\n    webhook = Webhook.create!(\n      board: boards(:writebook),\n      name: \"Generic\",\n      url: \"https://example.com/webhook\"\n    )\n    delivery = Webhook::Delivery.create!(webhook: webhook, event: events(:logo_published))\n\n    captured_body = nil\n    stub_request(:post, webhook.url)\n      .with { |request| captured_body = request.body; true }\n      .to_return(status: 200)\n\n    delivery.deliver\n\n    json = JSON.parse(captured_body)\n    assert_equal %(Tom & Jerry's <Great> \"Adventure\"), json[\"eventable\"][\"title\"]\n  end\n\n  test \"renders creator name when event creator is not current user\" do\n    webhook = Webhook.create!(\n      board: boards(:writebook),\n      name: \"Basecamp\",\n      url: \"https://3.basecamp.com/123/integrations/webhook/buckets/456/chats/789/lines\"\n    )\n    event = events(:logo_published)\n    delivery = Webhook::Delivery.create!(webhook: webhook, event: event)\n\n    Current.session = sessions(:kevin)\n\n    request_stub = stub_request(:post, webhook.url)\n      .with { |request| CGI.parse(request.body)[\"content\"].first.include?(\"David added\") }\n      .to_return(status: 200)\n\n    delivery.deliver\n\n    assert_requested request_stub\n  end\n\n  test \"blocks DNS rebinding attack where hostname resolves to private IP after validation\" do\n    webhook = Webhook.create!(\n      board: boards(:writebook),\n      name: \"Rebind Attack\",\n      url: \"https://rebind.attacker.example/webhook\"\n    )\n    event = events(:layout_commented)\n    delivery = Webhook::Delivery.create!(webhook: webhook, event: event)\n\n    # Stub DNS to return a private IP (simulating rebind to internal host)\n    stub_dns_resolution(\"169.254.169.254\") # AWS IMDS link-local address\n\n    delivery.deliver\n\n    assert_equal \"completed\", delivery.state\n    assert_equal \"private_uri\", delivery.response[:error]\n    assert_not delivery.succeeded?\n  end\n\n  test \"connects to the pinned IP address preventing DNS re-resolution\" do\n    webhook = Webhook.create!(\n      board: boards(:writebook),\n      name: \"Pinned IP\",\n      url: \"https://example.com/webhook\"\n    )\n    event = events(:layout_commented)\n    delivery = Webhook::Delivery.create!(webhook: webhook, event: event)\n\n    stub_dns_resolution(PUBLIC_TEST_IP)\n\n    # Verify Net::HTTP.new is called with the pinned IP\n    response_mock = stub(code: \"200\")\n    response_mock.stubs(:read_body)\n\n    http_mock = mock(\"http\")\n    http_mock.stubs(:use_ssl=)\n    http_mock.stubs(:ipaddr=)\n    http_mock.stubs(:open_timeout=)\n    http_mock.stubs(:read_timeout=)\n    http_mock.stubs(:request).yields(response_mock).returns(response_mock)\n\n    Net::HTTP.expects(:new).with(\"example.com\", 443).returns(http_mock)\n\n    delivery.deliver\n\n    assert delivery.succeeded?\n  end\n\n  test \"handles response too large error\" do\n    delivery = webhook_deliveries(:pending)\n\n    large_body = \"x\" * 200.kilobytes\n    stub_request(:post, delivery.webhook.url).to_return(status: 200, body: large_body)\n\n    delivery.deliver\n\n    assert_equal \"completed\", delivery.state\n    assert_equal \"response_too_large\", delivery.response[:error]\n    assert_not delivery.succeeded?\n  end\n\n  test \"allows responses within size limit\" do\n    delivery = webhook_deliveries(:pending)\n\n    small_body = \"x\" * 50.kilobytes\n    stub_request(:post, delivery.webhook.url).to_return(status: 200, body: small_body)\n\n    delivery.deliver\n\n    assert_equal \"completed\", delivery.state\n    assert_equal 200, delivery.response[:code]\n    assert delivery.succeeded?\n  end\n\n  private\n    def stub_dns_resolution(*ips)\n      dns_mock = mock(\"dns\")\n      dns_mock.stubs(:each_address).multiple_yields(*ips)\n      Resolv::DNS.stubs(:open).yields(dns_mock)\n    end\nend\n"
  },
  {
    "path": "test/models/webhook/triggerable_test.rb",
    "content": "require \"test_helper\"\n\nclass Webhook::TriggerableTest < ActiveSupport::TestCase\n  setup do\n    @account = accounts(:\"37s\")\n    @board = boards(:writebook)\n    @card = @board.cards.first\n    @webhook = @board.webhooks.create!(\n      name: \"Test Webhook\",\n      url: \"https://example.com/webhook\",\n      subscribed_actions: [ \"card_published\" ]\n    )\n    # Create a test event\n    @event = @board.events.create!(\n      creator: users(:david),\n      eventable: @card,\n      action: \"card_published\"\n    )\n    @user = users(:david)\n  end\n\n  test \"trigger creates delivery for active accounts\" do\n    assert_difference -> { Webhook::Delivery.count }, 1 do\n      @webhook.trigger(@event)\n    end\n\n    delivery = Webhook::Delivery.last\n    assert_equal @event, delivery.event\n    assert_equal @webhook, delivery.webhook\n  end\n\n  test \"trigger skips cancelled accounts\" do\n    @account.cancel(initiated_by: @user)\n\n    assert_no_difference -> { Webhook::Delivery.count } do\n      @webhook.trigger(@event)\n    end\n  end\n\n  test \"triggered_by scope finds webhooks for event\" do\n    other_webhook = @board.webhooks.create!(\n      name: \"Other Webhook\",\n      url: \"https://example.com/other\",\n      subscribed_actions: [ \"card_closed\" ]\n    )\n\n    matching_webhooks = Webhook.triggered_by(@event)\n\n    assert_includes matching_webhooks, @webhook\n    assert_not_includes matching_webhooks, other_webhook\n  end\n\n  test \"active scope only returns active webhooks\" do\n    @webhook.update!(active: false)\n\n    assert_not_includes Webhook.active, @webhook\n  end\nend\n"
  },
  {
    "path": "test/models/webhook_test.rb",
    "content": "require \"test_helper\"\n\nclass WebhookTest < ActiveSupport::TestCase\n  test \"create\" do\n    webhook = Webhook.create! name: \"Test\", url: \"https://example.com/webhook\", board: boards(:writebook)\n    assert webhook.persisted?\n    assert webhook.active?\n    assert webhook.signing_secret.present?\n    assert webhook.delinquency_tracker.present?\n  end\n\n  test \"validates the url\" do\n    webhook = Webhook.new name: \"Test\", board: boards(:writebook)\n    assert_not webhook.valid?\n    assert_includes webhook.errors[:url], \"not a URL\"\n\n    webhook = Webhook.new name: \"Test\", board: boards(:writebook), url: \"not a url\"\n    assert_not webhook.valid?\n    assert_includes webhook.errors[:url], \"not a URL\"\n\n    webhook = Webhook.new name: \"NOTHING\", board: boards(:writebook), url: \"example.com/webhook\"\n    assert_not webhook.valid?\n    assert_includes webhook.errors[:url], \"must use http or https\"\n\n    webhook = Webhook.new name: \"BLANK\", board: boards(:writebook), url: \"//example.com/webhook\"\n    assert_not webhook.valid?\n    assert_includes webhook.errors[:url], \"must use http or https\"\n\n    webhook = Webhook.new name: \"GOPHER\", board: boards(:writebook), url: \"gopher://example.com/webhook\"\n    assert_not webhook.valid?\n    assert_includes webhook.errors[:url], \"must use http or https\"\n\n    webhook = Webhook.new name: \"HTTP\", board: boards(:writebook), url: \"http://example.com/webhook\"\n    assert webhook.valid?\n\n    webhook = Webhook.new name: \"HTTPS\", board: boards(:writebook), url: \"https://example.com/webhook\"\n    assert webhook.valid?\n\n    webhook = Webhook.new name: \"TRAILING SPACE\", board: boards(:writebook), url: \"https://example.com/webhook \"\n    assert webhook.valid?\n    assert_equal \"https://example.com/webhook\", webhook.url\n  end\n\n  test \"deactivate\" do\n    webhook = webhooks(:active)\n\n    assert_changes -> { webhook.active? }, from: true, to: false do\n      webhook.deactivate\n    end\n  end\n\n  test \"activate\" do\n    webhook = webhooks(:inactive)\n\n    assert_changes -> { webhook.active? }, from: false, to: true do\n      webhook.activate\n    end\n  end\n\n  test \"for_slack?\" do\n    webhook = Webhook.new url: \"https://hooks.slack.com/services/T12345678/B12345678/abcdefghijklmnopqrstuvwx\" # gitleaks:allow\n    assert webhook.for_slack?\n\n    webhook = Webhook.new url: \"https://hooks.slack.com/services/T12345678/B12345678\"\n    assert_not webhook.for_slack?\n\n    webhook = Webhook.new url: \"https://hooks.slack.com/services/T12345678\"\n    assert_not webhook.for_slack?\n\n    webhook = Webhook.new url: \"https://hooks.slack.com/services/\"\n    assert_not webhook.for_slack?\n\n    webhook = Webhook.new url: \"https://example.com/webhook\"\n    assert_not webhook.for_slack?\n  end\n\n  test \"for_campfire?\" do\n    webhook = Webhook.new url: \"https://example.com/rooms/123/456-room-name/messages\"\n    assert webhook.for_campfire?\n\n    webhook = Webhook.new url: \"https://campfire.example.com/rooms/999/123-test-room/messages\"\n    assert webhook.for_campfire?\n\n    webhook = Webhook.new url: \"https://campfire.example.com/rooms/999/123/messages\"\n    assert_not webhook.for_campfire?, \"The bot key is missing a token\"\n\n    webhook = Webhook.new url: \"https://example.com/webhook\"\n    assert_not webhook.for_campfire?\n\n    webhook = Webhook.new url: \"https://example.com/rooms/123/messages\"\n    assert_not webhook.for_campfire?\n\n    webhook = Webhook.new url: \"https://example.com/rooms/123/456-room-name/\"\n    assert_not webhook.for_campfire?\n  end\n\n  test \"for_basecamp?\" do\n    webhook = Webhook.new url: \"https://basecamp.com/999/integrations/some-token/buckets/111/chats/222/lines\"\n    assert webhook.for_basecamp?\n\n    webhook = Webhook.new url: \"https://example.com/webhook\"\n    assert_not webhook.for_basecamp?\n\n    webhook = Webhook.new url: \"https://3.basecamp.com/123/integrations/webhook/buckets/456/chats/\"\n    assert_not webhook.for_basecamp?\n\n    webhook = Webhook.new url: \"https://3.basecamp.com/integrations/webhook/buckets/456/chats/789/lines\"\n    assert_not webhook.for_basecamp?\n  end\nend\n"
  },
  {
    "path": "test/models/zip_file_test.rb",
    "content": "require \"test_helper\"\n\nclass ZipFileTest < ActiveSupport::TestCase\n  test \"writer adds files with content\" do\n    tempfile = Tempfile.new([ \"test\", \".zip\" ])\n    tempfile.binmode\n\n    writer = ZipFile::Writer.new(tempfile)\n    writer.add_file(\"hello.txt\", \"Hello, World!\")\n    writer.close\n\n    assert writer.exists?(\"hello.txt\")\n    assert_not writer.exists?(\"missing.txt\")\n  end\n\n  test \"writer adds files with block\" do\n    tempfile = Tempfile.new([ \"test\", \".zip\" ])\n    tempfile.binmode\n\n    writer = ZipFile::Writer.new(tempfile)\n    writer.add_file(\"hello.txt\") { |sink| sink.write(\"Hello, World!\") }\n    writer.close\n\n    assert writer.exists?(\"hello.txt\")\n  end\n\n  test \"writer globs entries\" do\n    tempfile = Tempfile.new([ \"test\", \".zip\" ])\n    tempfile.binmode\n\n    writer = ZipFile::Writer.new(tempfile)\n    writer.add_file(\"docs/readme.txt\", \"Readme\")\n    writer.add_file(\"docs/guide.txt\", \"Guide\")\n    writer.add_file(\"images/logo.png\", \"PNG data\")\n    writer.close\n\n    assert_equal [ \"docs/guide.txt\", \"docs/readme.txt\" ], writer.glob(\"docs/*.txt\")\n    assert_equal [ \"images/logo.png\" ], writer.glob(\"**/*.png\")\n  end\n\n  test \"reader reads file content\" do\n    tempfile = create_test_zip(\"hello.txt\" => \"Hello, World!\")\n\n    reader = ZipFile::Reader.new(tempfile)\n    content = reader.read(\"hello.txt\")\n\n    assert_equal \"Hello, World!\", content\n  end\n\n  test \"reader reads file with block\" do\n    tempfile = create_test_zip(\"hello.txt\" => \"Hello, World!\")\n\n    reader = ZipFile::Reader.new(tempfile)\n    content = nil\n    reader.read(\"hello.txt\") { |io| content = io.read }\n\n    assert_equal \"Hello, World!\", content\n  end\n\n  test \"reader raises for missing file\" do\n    tempfile = create_test_zip(\"hello.txt\" => \"Hello\")\n\n    reader = ZipFile::Reader.new(tempfile)\n\n    assert_raises(ArgumentError) { reader.read(\"missing.txt\") }\n  end\n\n  test \"reader checks file existence\" do\n    tempfile = create_test_zip(\"hello.txt\" => \"Hello\")\n\n    reader = ZipFile::Reader.new(tempfile)\n\n    assert reader.exists?(\"hello.txt\")\n    assert_not reader.exists?(\"missing.txt\")\n  end\n\n  test \"reader globs entries\" do\n    tempfile = create_test_zip(\n      \"docs/readme.txt\" => \"Readme\",\n      \"docs/guide.txt\" => \"Guide\",\n      \"images/logo.png\" => \"PNG\"\n    )\n\n    reader = ZipFile::Reader.new(tempfile)\n\n    assert_equal [ \"docs/guide.txt\", \"docs/readme.txt\" ], reader.glob(\"docs/*.txt\")\n  end\n\n  test \"reader io provides size\" do\n    tempfile = create_test_zip(\"hello.txt\" => \"Hello, World!\")\n\n    reader = ZipFile::Reader.new(tempfile)\n    reader.read(\"hello.txt\") do |io|\n      assert_equal 13, io.size\n    end\n  end\n\n  test \"reader io supports rewind\" do\n    tempfile = create_test_zip(\"hello.txt\" => \"Hello, World!\")\n\n    reader = ZipFile::Reader.new(tempfile)\n    reader.read(\"hello.txt\") do |io|\n      first_read = io.read\n      io.rewind\n      second_read = io.read\n\n      assert_equal first_read, second_read\n    end\n  end\n\n  test \"reader io tracks eof\" do\n    tempfile = create_test_zip(\"hello.txt\" => \"Hello\")\n\n    reader = ZipFile::Reader.new(tempfile)\n    reader.read(\"hello.txt\") do |io|\n      assert_not io.eof?\n      io.read\n      assert io.eof?\n    end\n  end\n\n  test \"reader raises InvalidFileError for non-zip file\" do\n    tempfile = Tempfile.new([ \"not_a_zip\", \".zip\" ])\n    tempfile.write(\"this is not a zip file at all\")\n    tempfile.rewind\n\n    assert_raises(ZipFile::InvalidFileError) { ZipFile::Reader.new(tempfile) }\n  ensure\n    tempfile&.close\n    tempfile&.unlink\n  end\n\n  private\n    def create_test_zip(files)\n      tempfile = Tempfile.new([ \"test\", \".zip\" ])\n      tempfile.binmode\n\n      writer = ZipFile::Writer.new(tempfile)\n      files.each { |path, content| writer.add_file(path, content) }\n      writer.close\n\n      tempfile.rewind\n      tempfile\n    end\nend\n"
  },
  {
    "path": "test/routes_test.rb",
    "content": "require \"test_helper\"\n\nclass RouteTest < ActionDispatch::IntegrationTest\n  test \"account/join_code\" do\n    assert_recognizes({ controller: \"account/join_codes\", action: \"show\" }, \"/account/join_code\")\n  end\n\n  test \"account/settings\" do\n    assert_recognizes({ controller: \"account/settings\", action: \"show\" }, \"/account/settings\")\n  end\n\n  test \"account/entropy\" do\n    assert_recognizes({ controller: \"account/entropies\", action: \"show\" }, \"/account/entropy\")\n  end\nend\n"
  },
  {
    "path": "test/system/.keep",
    "content": ""
  },
  {
    "path": "test/system/back_link_navigation_test.rb",
    "content": "require \"application_system_test_case\"\n\nclass BackLinkNavigationTest < ApplicationSystemTestCase\n  test \"card back link returns to board filter view when navigating from it\" do\n    sign_in_as(users(:david))\n\n    filter_url = board_url(boards(:writebook), creator_ids: [ users(:david).id ])\n    visit filter_url\n    click_on cards(:logo).title\n\n    back_link = find(\"a.btn--back\")\n    assert_selector \"a.btn--back strong\", text: \"Back to Writebook\"\n    back_link.click\n    assert_current_path filter_url, ignore_query: false\n  end\n\n  test \"card back link returns to global filter view when navigating from it\" do\n    sign_in_as(users(:kevin))\n\n    filter_url = cards_url(creator_ids: [ users(:kevin).id ])\n    visit filter_url\n    click_on cards(:text).title\n\n    assert_selector \"a.btn--back strong\", text: \"Back to all boards\"\n    find(\"a.btn--back\").click\n    assert_current_path filter_url, ignore_query: false\n  end\n\n  test \"card back link returns to activity page when navigating from it\" do\n    sign_in_as(users(:david))\n\n    assert_text \"Layout is broken\"\n    click_on \"Layout is broken\"\n\n    assert_selector \"a.btn--back strong\", text: \"Back to Home\"\n    find(\"a.btn--back\").click\n    assert_current_path root_path\n  end\n\n  test \"card back link returns to activity page when navigating from it without trailing slash\" do\n    sign_in_as(users(:david))\n\n    activity_url_without_trailing_slash = root_url.chomp(\"/\")\n    visit activity_url_without_trailing_slash\n    assert_text \"Layout is broken\"\n    click_on \"Layout is broken\"\n\n    assert_selector \"a.btn--back strong\", text: \"Back to Home\"\n    find(\"a.btn--back\").click\n    assert_current_path activity_url_without_trailing_slash\n  end\n\n  test \"card back link is not rewritten when navigating from a non-filter page\" do\n    sign_in_as(users(:david))\n\n    visit account_settings_url\n    click_on \"Invite people\"\n    visit card_url(cards(:logo))\n\n    assert_selector \"a.btn--back strong\", text: \"Back to Writebook\"\n  end\nend\n"
  },
  {
    "path": "test/system/markdown_paste_test.rb",
    "content": "require \"application_system_test_case\"\n\nclass MarkdownPasteTest < ApplicationSystemTestCase\n  test \"markdown paste adds block spacing\" do\n    sign_in_as(users(:david))\n\n    visit card_url(cards(:layout))\n    find(\"lexxy-editor\").click\n    paste_markdown(\"Hello\\n\\nWorld\")\n\n    within(\"lexxy-editor\") do\n      assert_selector \"p\", text: \"Hello\"\n      assert_selector \"p br\", visible: :all\n      assert_selector \"p\", text: \"World\"\n    end\n  end\n\n  test \"markdown paste preserves line breaks\" do\n    sign_in_as(users(:david))\n\n    visit card_url(cards(:layout))\n    find(\"lexxy-editor\").click\n    paste_markdown(\"Hello\\nWorld\")\n\n    inner_html = find(\"lexxy-editor p\", text: \"Hello\").native.property(\"innerHTML\")\n    children = Nokogiri::HTML5.fragment(inner_html).children\n    assert_pattern do\n      children => [\n        { name: \"span\", inner_html: \"Hello\" },\n        { name: \"br\" },\n        { name: \"span\", inner_html: \"World\" }\n      ]\n    end\n  end\n\n  private\n    def paste_markdown(markdown)\n      page.execute_script(<<~JS, markdown)\n        const dt = new DataTransfer();\n        dt.setData(\"text/plain\", arguments[0]);\n        document.activeElement.dispatchEvent(new ClipboardEvent(\"paste\", { clipboardData: dt, bubbles: true }));\n      JS\n    end\nend\n"
  },
  {
    "path": "test/system/smoke_test.rb",
    "content": "require \"application_system_test_case\"\n\nclass SmokeTest < ApplicationSystemTestCase\n  test \"joining an account\" do\n    account = accounts(\"37s\")\n\n    visit join_url(code: account.join_code.code, script_name: account.slug)\n    fill_in \"Email address\", with: \"newbie@example.com\"\n    click_on \"Continue\"\n\n    assert_selector \"h1\", text: \"Check your email\"\n    identity = Identity.find_by!(email_address: \"newbie@example.com\")\n    code = identity.magic_links.active.first.code\n    fill_in \"code\", with: code\n    send_keys :enter\n\n    assert_selector \"input[id=user_name]\"\n    assert account.users.find_by!(identity:).verified?, \"User was not properly verified\"\n    fill_in \"Full name\", with: \"New Bee\"\n    click_on \"Continue\"\n\n    assert_selector \"h1\", text: \"Writebook\"\n  end\n\n  test \"create a card\" do\n    sign_in_as(users(:david))\n\n    visit board_url(boards(:writebook))\n    click_on \"Add a card\"\n    fill_in \"card_title\", with: \"Hello, world!\"\n    fill_in_lexxy with: \"I am editing this thing\"\n    click_on \"Create card\"\n\n    assert_selector \"h3\", text: \"Hello, world!\"\n  end\n\n  test \"active storage attachments\" do\n    sign_in_as(users(:david))\n\n    visit card_url(cards(:layout))\n    fill_in_lexxy with: \"Here is a comment\"\n    attach_file file_fixture(\"moon.jpg\") do\n      click_on \"Upload file\"\n    end\n\n    within(\"form lexxy-editor figure.attachment[data-content-type='image/jpeg']\") do\n      assert_selector \"img[src*='/rails/active_storage']\"\n      assert_selector \"figcaption textarea[placeholder='moon.jpg']\"\n    end\n\n    click_on \"Post\"\n\n    within(\"action-text-attachment\") do\n      assert_selector \"a img[src*='/rails/active_storage']\"\n      assert_selector \"figcaption span.attachment__name\", text: \"moon.jpg\"\n    end\n\n    # Click the image to open the lightbox\n    find(\"action-text-attachment figure.attachment a:has(img)\").click\n\n    assert_selector \"dialog.lightbox[open]\"\n    within(\"dialog.lightbox\") do\n      assert_selector \"img.lightbox__image[src*='/rails/active_storage']\"\n    end\n  end\n\n  test \"dismissing notifications\" do\n    sign_in_as(users(:david))\n\n    notification = notifications(:logo_mentioned_david)\n\n    assert_selector \"div##{dom_id(notification)}\"\n\n    within_window(open_new_window) { visit card_url(notification.card) }\n\n    assert_no_selector \"div##{dom_id(notification)}\"\n  end\n\n  test \"dragging card to a new column\" do\n    sign_in_as(users(:david))\n\n    card = Card.find(\"03axhd1h3qgnsffqplkyf28fv\")\n    assert_nil(card.column)\n\n    visit board_url(boards(:writebook))\n\n    card_el = page.find(\"#article_card_03axhd1h3qgnsffqplkyf28fv\")\n    column_el = page.find(\"#column_03axmcferfmbnv4qg816nw6bg\")\n    cards_count = column_el.find(\".cards__expander-count\").text.to_i\n\n    card_el.drag_to(column_el)\n\n    column_el.find(\".cards__expander-count\", text: cards_count + 1)\n    assert_equal(\"Triage\", card.reload.column.name)\n  end\n\n  private\n    def fill_in_lexxy(selector = \"lexxy-editor\", with:)\n      editor_element = find(selector)\n      editor_element.set with\n      page.execute_script(\"arguments[0].value = '#{with}'\", editor_element)\n    end\nend\n"
  },
  {
    "path": "test/test_helper.rb",
    "content": "ENV[\"RAILS_ENV\"] ||= \"test\"\nrequire_relative \"../config/environment\"\n\nrequire \"rails/test_help\"\nrequire \"webmock/minitest\"\nrequire_relative \"webmock_ipaddr_extension\"\nrequire \"vcr\"\nrequire \"mocha/minitest\"\nrequire \"turbo/broadcastable/test_helper\"\n\nWebMock.allow_net_connect!\n\nVCR.configure do |config|\n  config.allow_http_connections_when_no_cassette = true\n  config.cassette_library_dir = \"test/vcr_cassettes\"\n  config.hook_into :webmock\n  config.filter_sensitive_data(\"<OPEN_AI_KEY>\") { Rails.application.credentials.openai_api_key || ENV[\"OPEN_AI_API_KEY\"] }\n  config.default_cassette_options = {\n    match_requests_on: [ :method, :uri, :body ]\n  }\n\n  # Ignore timestamps in request bodies\n  config.before_record do |i|\n    if i.request&.body\n      i.request.body.gsub!(/\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2} UTC/, \"<TIME>\")\n    end\n  end\n\n  config.register_request_matcher :body_without_times do |r1, r2|\n    b1 = (r1.body || \"\").gsub(/\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2} UTC/, \"<TIME>\")\n    b2 = (r2.body || \"\").gsub(/\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2} UTC/, \"<TIME>\")\n    b1 == b2\n  end\n\n  config.default_cassette_options = {\n    match_requests_on: [ :method, :uri, :body_without_times ]\n  }\nend\n\nmodule ActiveSupport\n  class TestCase\n    parallelize workers: :number_of_processors, work_stealing: ENV[\"WORK_STEALING\"] != \"false\"\n\n    # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.\n    fixtures :all\n\n    include ActiveJob::TestHelper\n    include ActionTextTestHelper, CachingTestHelper, CardTestHelper, ChangeTestHelper, SessionTestHelper\n    include Turbo::Broadcastable::TestHelper\n\n    setup do\n      Current.account = accounts(\"37s\")\n    end\n\n    teardown do\n      Current.clear_all\n    end\n  end\nend\n\nclass ActionDispatch::IntegrationTest\n  setup do\n    integration_session.default_url_options[:script_name] = \"/#{ActiveRecord::FixtureSet.identify(\"37signals\")}\"\n  end\n\n  private\n    def without_action_dispatch_exception_handling\n      original = Rails.application.config.action_dispatch.show_exceptions\n      Rails.application.config.action_dispatch.show_exceptions = :none\n      Rails.application.instance_variable_set(:@app_env_config, nil) # Clear memoized env_config\n      yield\n    ensure\n      Rails.application.config.action_dispatch.show_exceptions = original\n      Rails.application.instance_variable_set(:@app_env_config, nil) # Reset env_config\n    end\nend\n\nclass ActionDispatch::SystemTestCase\n  setup do\n    self.default_url_options[:script_name] = \"/#{ActiveRecord::FixtureSet.identify(\"37signals\")}\"\n  end\nend\n\nmodule FixturesTestHelper\n  extend ActiveSupport::Concern\n\n  class_methods do\n    def identify(label, column_type = :integer)\n      if label.to_s.end_with?(\"_uuid\")\n        column_type = :uuid\n        label = label.to_s.delete_suffix(\"_uuid\")\n      end\n\n      # Rails passes :string for varchar columns, so handle both :uuid and :string\n      return super(label, column_type) unless column_type.in?([ :uuid, :string ])\n      generate_fixture_uuid(label)\n    end\n\n    private\n\n    def generate_fixture_uuid(label)\n      # Generate deterministic UUIDv7 for fixtures that sorts by fixture ID\n      # This allows .first/.last to work as expected in tests\n      # Use the same CRC32 algorithm as Rails' default fixture ID generation\n      # so that UUIDs sort in the same order as integer IDs\n      fixture_int = Zlib.crc32(\"fixtures/#{label}\") % (2**30 - 1)\n\n      # Translate the deterministic order into times in the past, so that records\n      # created during test runs are also always newer than the fixtures.\n      base_time = Time.utc(2024, 1, 1, 0, 0, 0)\n      timestamp = base_time + (fixture_int / 1000.0)\n\n      uuid_v7_with_timestamp(timestamp, label)\n    end\n\n    def uuid_v7_with_timestamp(time, seed_string)\n      # Generate UUIDv7 with custom timestamp and deterministic random bits\n      # Format: 48-bit timestamp_ms | 12-bit sub_ms_precision | 4-bit version | 62-bit random\n\n      time_ms = time.to_f * 1000\n      timestamp_ms = time_ms.to_i\n\n      # 48-bit timestamp (milliseconds since epoch)\n      bytes = []\n      bytes[0] = (timestamp_ms >> 40) & 0xff\n      bytes[1] = (timestamp_ms >> 32) & 0xff\n      bytes[2] = (timestamp_ms >> 24) & 0xff\n      bytes[3] = (timestamp_ms >> 16) & 0xff\n      bytes[4] = (timestamp_ms >> 8) & 0xff\n      bytes[5] = timestamp_ms & 0xff\n\n      # Use the 12-bit rand_a field for sub-millisecond precision\n      # Extract fractional milliseconds and convert to 12-bit value (0-4095)\n      # This gives us ~0.244 microsecond precision\n      frac_ms = time_ms - timestamp_ms\n      sub_ms_precision = (frac_ms * 4096).to_i & 0xfff\n\n      # Derive deterministic \"random\" bits from seed_string for the remaining random bits\n      hash = Digest::MD5.hexdigest(seed_string)\n\n      # 12-bit sub-ms precision + 4-bit version (0111 for v7)\n      bytes[6] = ((sub_ms_precision >> 8) & 0x0f) | 0x70  # version 7\n      bytes[7] = sub_ms_precision & 0xff\n\n      # 2-bit variant (10) + 62-bit random\n      rand_b = hash[3...19].to_i(16) & ((2**62) - 1)\n      bytes[8] = ((rand_b >> 56) & 0x3f) | 0x80  # variant 10\n      bytes[9] = (rand_b >> 48) & 0xff\n      bytes[10] = (rand_b >> 40) & 0xff\n      bytes[11] = (rand_b >> 32) & 0xff\n      bytes[12] = (rand_b >> 24) & 0xff\n      bytes[13] = (rand_b >> 16) & 0xff\n      bytes[14] = (rand_b >> 8) & 0xff\n      bytes[15] = rand_b & 0xff\n\n      # Format as UUID string and convert to base36 (25 chars)\n      uuid = \"%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x\" % bytes\n      ActiveRecord::Type::Uuid.hex_to_base36(uuid.delete(\"-\"))\n    end\n  end\nend\n\nActiveSupport.on_load(:active_record_fixture_set) do\n  prepend(FixturesTestHelper)\nend\n"
  },
  {
    "path": "test/test_helpers/action_text_test_helper.rb",
    "content": "module ActionTextTestHelper\n  def assert_action_text(expected_html, content)\n    assert_equal_html <<~HTML, content.to_s\n      <div class=\"action-text-content\">#{expected_html}</action-text-content>\n    HTML\n  end\n\n  def assert_equal_html(expected, actual)\n    assert_equal normalize_html(expected), normalize_html(actual)\n  end\n\n  def normalize_html(html)\n    Nokogiri::HTML.fragment(html).tap do |fragment|\n      fragment.traverse do |node|\n        if node.text?\n          node.content = node.text.squish\n        end\n      end\n    end.to_html.strip\n  end\nend\n"
  },
  {
    "path": "test/test_helpers/caching_test_helper.rb",
    "content": "module CachingTestHelper\n  def with_actionview_partial_caching\n    was_cache = ActionView::PartialRenderer.collection_cache\n    was_perform_caching = ApplicationController.perform_caching\n    begin\n      ActionView::PartialRenderer.collection_cache = ActiveSupport::Cache.lookup_store(:memory_store)\n      ApplicationController.perform_caching = true\n      yield\n    ensure\n      ActionView::PartialRenderer.collection_cache = was_cache\n      ApplicationController.perform_caching = was_perform_caching\n    end\n  end\nend\n"
  },
  {
    "path": "test/test_helpers/card_activity_test_helper.rb",
    "content": "module CardActivityTestHelper\n  def multiple_people_comment_on(card, times: 4, people: users(:david, :kevin, :jz))\n    perform_enqueued_jobs only: Card::ActivitySpike::DetectionJob do\n      times.times do |index|\n        creator = people[index % people.size]\n        card.comments.create!(body: \"Comment number #{index}\", creator: creator)\n        travel 1.second\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "test/test_helpers/card_test_helper.rb",
    "content": "module CardTestHelper\n  def assert_card_container_rerendered(card)\n    assert_turbo_stream action: :replace, target: dom_id(card, :card_container)\n  end\nend\n"
  },
  {
    "path": "test/test_helpers/change_test_helper.rb",
    "content": "module ChangeTestHelper\n  def capture_change(target)\n    before = target.call\n    yield\n    after = target.call\n    after - before\n  end\nend\n"
  },
  {
    "path": "test/test_helpers/command_test_helper.rb",
    "content": "module CommandTestHelper\n  def execute_command(string, user: users(:david))\n    parse_command(string, user:).execute\n  end\n\n  def parse_command(string, user: users(:david))\n    parser = Command::Parser.new(user: user, script_name: integration_session.default_url_options[:script_name])\n    parser.parse(string)\n  end\nend\n"
  },
  {
    "path": "test/test_helpers/search_test_helper.rb",
    "content": "module SearchTestHelper\n  extend ActiveSupport::Concern\n\n  included do\n    self.use_transactional_tests = false\n\n    setup :setup_search_test\n    teardown :teardown_search_test\n  end\n\n  def setup_search_test\n    clear_search_records\n    Account.find_by(name: \"Search Test\")&.destroy\n    Identity.find_by(email_address: \"test@example.com\")&.destroy\n\n    @account = Account.create!(name: \"Search Test\", external_account_id: ActiveRecord::FixtureSet.identify(\"search_test\"))\n    Current.account = @account\n    @identity = Identity.create!(email_address: \"test@example.com\")\n    @user = User.create!(name: \"Test User\", account: @account, identity: @identity)\n    Current.user = @user\n    @board = Board.create!(name: \"Test Board\", account: @account, creator: @user)\n  end\n\n  def teardown_search_test\n    clear_search_records\n    Account.find_by(name: \"Search Test\")&.destroy\n    Identity.find_by(email_address: \"test@example.com\")&.destroy\n  end\n\n  private\n    def clear_search_records\n      if ActiveRecord::Base.connection.adapter_name == \"SQLite\"\n        ActiveRecord::Base.connection.execute(\"DELETE FROM search_records\")\n        ActiveRecord::Base.connection.execute(\"DELETE FROM search_records_fts\")\n      else\n        Search::Record::Trilogy::SHARD_COUNT.times do |shard_id|\n          ActiveRecord::Base.connection.execute(\"DELETE FROM search_records_#{shard_id}\")\n        end\n      end\n    end\nend\n"
  },
  {
    "path": "test/test_helpers/session_test_helper.rb",
    "content": "module SessionTestHelper\n  def parsed_cookies\n    ActionDispatch::Cookies::CookieJar.build(request, cookies.to_hash)\n  end\n\n  def sign_in_as(identity)\n    cookies.delete :session_token\n\n    if identity.is_a?(User)\n      user = identity\n      identity = user.identity\n      raise \"User #{user.name} (#{user.id}) doesn't have an associated identity\" unless identity\n    elsif !identity.is_a?(Identity)\n      identity = identities(identity)\n    end\n\n    identity.send_magic_link\n    magic_link = identity.magic_links.order(id: :desc).first\n\n    untenanted do\n      post session_path, params: { email_address: identity.email_address }\n      post session_magic_link_url, params: { code: magic_link.code }\n    end\n\n    assert_response :redirect, \"Posting the Magic Link code should grant access\"\n\n    cookie = cookies.get_cookie \"session_token\"\n    assert_not_nil cookie, \"Expected session_token cookie to be set after sign in\"\n  end\n\n  def logout_and_sign_in_as(identity)\n    Session.delete_all\n    sign_in_as identity\n  end\n\n  def sign_out\n    untenanted do\n      delete session_path\n    end\n    assert_not cookies[:session_token].present?\n  end\n\n  def with_current_user(user)\n    user = users(user) unless user.is_a? User\n    @old_session = Current.session\n    begin\n      Current.session = Session.new(identity: user.identity)\n      yield\n    ensure\n      Current.session = @old_session\n    end\n  end\n\n  def untenanted(&block)\n    original_script_name = integration_session.default_url_options[:script_name]\n    integration_session.default_url_options[:script_name] = \"\"\n    yield\n  ensure\n    integration_session.default_url_options[:script_name] = original_script_name\n  end\n\n  def with_multi_tenant_mode(enabled)\n    previous = Account.multi_tenant\n    Account.multi_tenant = enabled\n    yield\n  ensure\n    Account.multi_tenant = previous\n  end\nend\n"
  },
  {
    "path": "test/test_helpers/vcr_test_helper.rb",
    "content": "# To include in those tests that use VCR. It will automatically insert a VCR cassette named after the test. By default,\n# it will run the test in \"replay\" mode. To switch to record mode, you can either:\n#\n# * Set the environment variable +VCR_RECORD+.\n# * Use +.vcr_record!+ in your test class.\nmodule VcrTestHelper\n  extend ActiveSupport::Concern\n\n  included do\n    class_attribute :vcr_record\n\n    setup do\n      @casette_name = \"#{self.class.name.tableize.singularize}-#{name}\"\n      VCR.insert_cassette @casette_name,\n        record: recording? ? :all : :none,\n        preserve_exact_body_bytes: true\n    end\n\n    teardown do\n      VCR.eject_cassette\n    end\n\n    def recording?\n      vcr_record || ENV[\"VCR_RECORD\"]\n    end\n  end\n\n  class_methods do\n    # Use to force record mode at development time: always perform real http interactions and record fixtures\n    def vcr_record!\n      raise \"#vcr_record! is meant for dev time. You are not supposed to run it in CI.\" if ENV[\"CI\"]\n\n      self.vcr_record = true\n    end\n  end\n\n  def without_vcr_body_matching(&block)\n    VCR.use_cassette(\"#{@casette_name}_without_body\", match_requests_on: [ :method, :uri ], &block)\n  end\nend\n"
  },
  {
    "path": "test/test_helpers/webauthn_test_helper.rb",
    "content": "module WebauthnTestHelper\n  # Fixed EC P-256 key pair for WebAuthn tests.\n  WEBAUTHN_PRIVATE_KEY = OpenSSL::PKey::EC.new(\n    [ \"307702010104201dd589de7210b3318620f32150e3012cce021519df1d6e9e01\" \\\n      \"0471146d395cdca00a06082a8648ce3d030107a14403420004116847fe19e1ad\" \\\n      \"4471ab9980d7ff9cc1e4c7cb7a3af00e082b64fcd84f5ae70114c2495eef16f\" \\\n      \"542b5e57dd1b263661624e3cf28f581b57a441edbd756a41d0e\" ].pack(\"H*\")\n  )\n\n  # Pre-encoded COSE EC2/ES256 public key (CBOR) for the key above.\n  # {1: 2, 3: -7, -1: 1, -2: <x 32 bytes>, -3: <y 32 bytes>}\n  COSE_PUBLIC_KEY = [ \"a5010203262001215820116847fe19e1ad4471ab9980d7ff9cc1\" \\\n    \"e4c7cb7a3af00e082b64fcd84f5ae70122582014c2495eef16f542b5e57dd1b2\" \\\n    \"63661624e3cf28f581b57a441edbd756a41d0e\" ].pack(\"H*\")\n\n  # CBOR prefix for {\"fmt\": \"none\", \"attStmt\": {}, \"authData\": bytes(164)}.\n  # Auth data is always 164 bytes: rp_id_hash(32) + flags(1) + sign_count(4)\n  # + aaguid(16) + credential_id_length(2) + credential_id(32) + cose_key(77).\n  ATTESTATION_OBJECT_CBOR_PREFIX =\n    [ \"a363666d74646e6f6e656761747453746d74a068617574684461746158a4\" ].pack(\"H*\")\n\n  private\n    def request_webauthn_challenge\n      untenanted { post my_passkey_challenge_url }\n      response.parsed_body[\"challenge\"]\n    end\n\n    def webauthn_challenge\n      ActionPack::WebAuthn::PublicKeyCredential::Options.new.challenge\n    end\n\n    def webauthn_private_key\n      WEBAUTHN_PRIVATE_KEY\n    end\n\n    def build_attestation_params(challenge:)\n      credential_id = SecureRandom.random_bytes(32)\n      auth_data = build_attestation_auth_data(credential_id: credential_id)\n\n      {\n        passkey: {\n          client_data_json: webauthn_client_data_json(challenge: challenge, type: \"webauthn.create\"),\n          attestation_object: Base64.urlsafe_encode64(ATTESTATION_OBJECT_CBOR_PREFIX + auth_data, padding: false),\n          transports: [ \"internal\" ]\n        }\n      }\n    end\n\n    def build_assertion_params(challenge:, credential:, sign_count: 1)\n      client_data_json = webauthn_client_data_json(challenge: challenge, type: \"webauthn.get\")\n      authenticator_data = build_assertion_auth_data(sign_count: sign_count)\n      signature = webauthn_sign(authenticator_data, client_data_json)\n\n      {\n        passkey: {\n          id: credential.credential_id,\n          client_data_json: client_data_json,\n          authenticator_data: Base64.urlsafe_encode64(authenticator_data, padding: false),\n          signature: Base64.urlsafe_encode64(signature, padding: false)\n        }\n      }\n    end\n\n    def webauthn_client_data_json(challenge:, type:)\n      { challenge: challenge, origin: \"http://www.example.com\", type: type }.to_json\n    end\n\n    # Attestation auth data includes the attested credential (public key + credential ID).\n    def build_attestation_auth_data(credential_id:)\n      [\n        Digest::SHA256.digest(\"www.example.com\"),   # rp_id_hash\n        [ 0x45 ].pack(\"C\"),                         # flags: UP + UV + AT\n        [ 0 ].pack(\"N\"),                            # sign_count\n        \"\\x00\" * 16,                                # aaguid\n        [ credential_id.bytesize ].pack(\"n\"),       # credential_id_length\n        credential_id,\n        COSE_PUBLIC_KEY\n      ].join.b\n    end\n\n    # Assertion auth data is simpler — no attested credential.\n    def build_assertion_auth_data(sign_count:)\n      [\n        Digest::SHA256.digest(\"www.example.com\"),\n        [ 0x05 ].pack(\"C\"),                         # flags: UP + UV\n        [ sign_count ].pack(\"N\")\n      ].join.b\n    end\n\n    def webauthn_sign(authenticator_data, client_data_json)\n      signed_data = authenticator_data + Digest::SHA256.digest(client_data_json)\n      WEBAUTHN_PRIVATE_KEY.sign(\"SHA256\", signed_data)\n    end\nend\n"
  },
  {
    "path": "test/webmock_ipaddr_extension.rb",
    "content": "# Extends WebMock to support ipaddr matching for testing IP pinning.\n#\n# Usage:\n#   stub_request(:post, \"https://example.com/push\")\n#     .with(ipaddr: \"93.184.216.34\")\n#     .to_return(status: 201)\n#\n# If the HTTP connection's ipaddr doesn't match, the stub won't match and\n# WebMock will raise an error about an unregistered request.\n\nmodule WebMock\n  class RequestSignature\n    attr_accessor :ipaddr\n  end\n\n  module RequestPatternIpaddrExtension\n    attr_accessor :ipaddr_pattern\n\n    def assign_options(options)\n      options = options.dup\n      @ipaddr_pattern = options.delete(:ipaddr) || options.delete(\"ipaddr\")\n      super(options)\n    end\n\n    def matches?(request_signature)\n      super && ipaddr_matches?(request_signature)\n    end\n\n    private\n      def ipaddr_matches?(request_signature)\n        @ipaddr_pattern.nil? || @ipaddr_pattern == request_signature.ipaddr\n      end\n  end\n\n  RequestPattern.prepend RequestPatternIpaddrExtension\n\n  module NetHTTPUtilityIpaddrExtension\n    def request_signature_from_request(net_http, request, body = nil)\n      super.tap { |signature| signature.ipaddr = net_http.ipaddr }\n    end\n  end\n\n  NetHTTPUtility.singleton_class.prepend NetHTTPUtilityIpaddrExtension\nend\n"
  },
  {
    "path": "tmp/.keep",
    "content": ""
  },
  {
    "path": "vendor/javascript/@hotwired--hotwire-native-bridge.js",
    "content": "// @hotwired/hotwire-native-bridge@1.2.2 downloaded from https://ga.jspm.io/npm:@hotwired/hotwire-native-bridge@1.2.2/dist/hotwire-native-bridge.js\n\nimport{Controller as e}from\"@hotwired/stimulus\";var t=class{#e;#t;#s;#n;constructor(){this.#e=null;this.#t=0;this.#s=[];this.#n=new Map}start(){this.notifyApplicationAfterStart()}notifyApplicationAfterStart(){document.dispatchEvent(new Event(\"web-bridge:ready\"))}supportsComponent(e){return!!this.#e&&this.#e.supportsComponent(e)}send({component:e,event:t,data:s,callback:n}){if(!this.#e){this.#i({component:e,event:t,data:s,callback:n});return null}if(!this.supportsComponent(e))return null;const i=this.generateMessageId();const r={id:i,component:e,event:t,data:s||{}};this.#e.receive(r);n&&this.#n.set(i,n);return i}receive(e){this.executeCallbackFor(e)}executeCallbackFor(e){const t=this.#n.get(e.id);t&&t(e)}removeCallbackFor(e){this.#n.has(e)&&this.#n.delete(e)}removePendingMessagesFor(e){this.#s=this.#s.filter((t=>t.component!=e))}generateMessageId(){const e=++this.#t;return e.toString()}setAdapter(e){this.#e=e;document.documentElement.dataset.bridgePlatform=this.#e.platform;this.adapterDidUpdateSupportedComponents();this.#r()}adapterDidUpdateSupportedComponents(){this.#e&&(document.documentElement.dataset.bridgeComponents=this.#e.supportedComponents.join(\" \"))}#i(e){this.#s.push(e)}#r(){this.#s.forEach((e=>this.send(e)));this.#s=[]}};var s=class{constructor(e){this.element=e}get title(){return(this.bridgeAttribute(\"title\")||this.attribute(\"aria-label\")||this.element.textContent||this.element.value).trim()}get enabled(){return!this.disabled}get disabled(){const e=this.bridgeAttribute(\"disabled\");return e===\"true\"||e===this.platform}enableForComponent(e){e.enabled&&this.removeBridgeAttribute(\"disabled\")}hasClass(e){return this.element.classList.contains(e)}attribute(e){return this.element.getAttribute(e)}bridgeAttribute(e){return this.attribute(`data-bridge-${e}`)}setBridgeAttribute(e,t){this.element.setAttribute(`data-bridge-${e}`,t)}removeBridgeAttribute(e){this.element.removeAttribute(`data-bridge-${e}`)}click(){this.platform==\"android\"&&this.element.removeAttribute(\"target\");this.element.click()}get platform(){return document.documentElement.dataset.bridgePlatform}};var{userAgent:n}=window.navigator;function i(e){const t=n.match(/bridge-components: \\[(.*?)\\]/);return!!t&&t[1].split(\" \").includes(e)}var r=class extends e{static component=\"\";static get shouldLoad(){return i(this.component)}pendingMessageCallbacks=[];initialize(){this.pendingMessageCallbacks=[]}connect(){this.removeRestoreEventListener();this.addRestoreEventListener()}disconnect(){this.removePendingCallbacks();this.removePendingMessages();this.removeRestoreEventListener()}addRestoreEventListener(){this.restore=this.restore.bind(this);document.addEventListener(\"native:restore\",this.restore)}removeRestoreEventListener(){document.removeEventListener(\"native:restore\",this.restore)}restore(){this.connect()}get component(){return this.constructor.component}get platformOptingOut(){const{bridgePlatform:e}=document.documentElement.dataset;return this.identifier==this.element.getAttribute(`data-controller-optout-${e}`)}get enabled(){return!this.platformOptingOut&&this.bridge.supportsComponent(this.component)}send(e,t={},s){t.metadata={url:window.location.href};const n={component:this.component,event:e,data:t,callback:s};const i=this.bridge.send(n);s&&this.pendingMessageCallbacks.push(i)}removePendingCallbacks(){this.pendingMessageCallbacks.forEach((e=>this.bridge.removeCallbackFor(e)))}removePendingMessages(){this.bridge.removePendingMessagesFor(this.component)}get bridgeElement(){return new s(this.element)}get bridge(){return window.HotwireNative.web}};if(!window.HotwireNative){const e=new t;window.HotwireNative={web:e};a(e);e.start()}function a(e){window.Strada||(window.Strada={web:e});window.webBridge||(window.webBridge=e)}export{r as BridgeComponent,s as BridgeElement};\n\n"
  },
  {
    "path": "vendor/javascript/@rails--request.js",
    "content": "// @rails/request.js@0.0.13 downloaded from https://ga.jspm.io/npm:@rails/request.js@0.0.13/src/index.js\n\nclass FetchResponse{constructor(t){this.response=t}get statusCode(){return this.response.status}get redirected(){return this.response.redirected}get ok(){return this.response.ok}get unauthenticated(){return this.statusCode===401}get unprocessableEntity(){return this.statusCode===422}get authenticationURL(){return this.response.headers.get(\"WWW-Authenticate\")}get contentType(){const t=this.response.headers.get(\"Content-Type\")||\"\";return t.replace(/;.*$/,\"\")}get headers(){return this.response.headers}get html(){return this.contentType.match(/^(application|text)\\/(html|xhtml\\+xml)$/)?this.text:Promise.reject(new Error(`Expected an HTML response but got \"${this.contentType}\" instead`))}get json(){return this.contentType.match(/^application\\/.*json$/)?this.responseJson||(this.responseJson=this.response.json()):Promise.reject(new Error(`Expected a JSON response but got \"${this.contentType}\" instead`))}get text(){return this.responseText||(this.responseText=this.response.text())}get isTurboStream(){return this.contentType.match(/^text\\/vnd\\.turbo-stream\\.html/)}get isScript(){return this.contentType.match(/\\b(?:java|ecma)script\\b/)}async renderTurboStream(){if(!this.isTurboStream)return Promise.reject(new Error(`Expected a Turbo Stream response but got \"${this.contentType}\" instead`));window.Turbo?await window.Turbo.renderStreamMessage(await this.text):console.warn(\"You must set `window.Turbo = Turbo` to automatically process Turbo Stream events with request.js\")}async activeScript(){if(!this.isScript)return Promise.reject(new Error(`Expected a Script response but got \"${this.contentType}\" instead`));{const t=document.createElement(\"script\");const e=document.querySelector(\"meta[name=csp-nonce]\");if(e){const n=e.nonce===\"\"?e.content:e.nonce;n&&t.setAttribute(\"nonce\",n)}t.innerHTML=await this.text;document.body.appendChild(t)}}}class RequestInterceptor{static register(t){this.interceptor=t}static get(){return this.interceptor}static reset(){this.interceptor=void 0}}function t(t){const e=document.cookie?document.cookie.split(\"; \"):[];const n=`${encodeURIComponent(t)}=`;const s=e.find((t=>t.startsWith(n)));if(s){const t=s.split(\"=\").slice(1).join(\"=\");if(t)return decodeURIComponent(t)}}function e(t){const e={};for(const n in t){const s=t[n];s!==void 0&&(e[n]=s)}return e}function n(t){const e=document.head.querySelector(`meta[name=\"${t}\"]`);return e&&e.content}function s(t){return[...t].reduce(((t,[e,n])=>t.concat(typeof n===\"string\"?[[e,n]]:[])),[])}function r(t,e){for(const[n,s]of e)if(!(s instanceof window.File))if(t.has(n)&&!n.includes(\"[]\")){t.delete(n);t.set(n,s)}else t.append(n,s)}class FetchRequest{constructor(t,e,n={}){this.method=t;this.options=n;this.originalUrl=e.toString()}async perform(){try{const t=RequestInterceptor.get();t&&await t(this)}catch(t){console.error(t)}const t=window.Turbo?window.Turbo.fetch:window.fetch;const e=new FetchResponse(await t(this.url,this.fetchOptions));if(e.unauthenticated&&e.authenticationURL)return Promise.reject(window.location.href=e.authenticationURL);e.isScript&&await e.activeScript();const n=e.ok||e.unprocessableEntity;n&&e.isTurboStream&&await e.renderTurboStream();return e}addHeader(t,e){const n=this.additionalHeaders;n[t]=e;this.options.headers=n}sameHostname(){if(!this.originalUrl.startsWith(\"http:\")&&!this.originalUrl.startsWith(\"https:\"))return true;try{return new URL(this.originalUrl).hostname===window.location.hostname}catch(t){return true}}get fetchOptions(){return{method:this.method.toUpperCase(),headers:this.headers,body:this.formattedBody,signal:this.signal,credentials:this.credentials,redirect:this.redirect,keepalive:this.keepalive}}get headers(){const t={\"X-Requested-With\":\"XMLHttpRequest\",\"Content-Type\":this.contentType,Accept:this.accept};this.sameHostname()&&(t[\"X-CSRF-Token\"]=this.csrfToken);return e(Object.assign(t,this.additionalHeaders))}get csrfToken(){return t(n(\"csrf-param\"))||n(\"csrf-token\")}get contentType(){return this.options.contentType?this.options.contentType:this.body==null||this.body instanceof window.FormData?void 0:this.body instanceof window.File?this.body.type:\"application/json\"}get accept(){switch(this.responseKind){case\"html\":return\"text/html, application/xhtml+xml\";case\"turbo-stream\":return\"text/vnd.turbo-stream.html, text/html, application/xhtml+xml\";case\"json\":return\"application/json, application/vnd.api+json\";case\"script\":return\"text/javascript, application/javascript\";default:return\"*/*\"}}get body(){return this.options.body}get query(){const t=(this.originalUrl.split(\"?\")[1]||\"\").split(\"#\")[0];const e=new URLSearchParams(t);let n=this.options.query;n=n instanceof window.FormData?s(n):n instanceof window.URLSearchParams?n.entries():Object.entries(n||{});r(e,n);const o=e.toString();return o.length>0?`?${o}`:\"\"}get url(){return this.originalUrl.split(\"?\")[0].split(\"#\")[0]+this.query}get responseKind(){return this.options.responseKind||\"html\"}get signal(){return this.options.signal}get redirect(){return this.options.redirect||\"follow\"}get credentials(){return this.options.credentials||\"same-origin\"}get keepalive(){return this.options.keepalive||false}get additionalHeaders(){return this.options.headers||{}}get formattedBody(){const t=Object.prototype.toString.call(this.body)===\"[object String]\";const e=this.headers[\"Content-Type\"]===\"application/json\";return e&&!t?JSON.stringify(this.body):this.body}}async function o(t,e){const n=new FetchRequest(\"get\",t,e);return n.perform()}async function i(t,e){const n=new FetchRequest(\"post\",t,e);return n.perform()}async function c(t,e){const n=new FetchRequest(\"put\",t,e);return n.perform()}async function a(t,e){const n=new FetchRequest(\"patch\",t,e);return n.perform()}async function h(t,e){const n=new FetchRequest(\"delete\",t,e);return n.perform()}export{FetchRequest,FetchResponse,RequestInterceptor,h as destroy,o as get,a as patch,i as post,c as put};\n\n"
  }
]