- {{ flavorText }} +
diff --git a/.env.example b/.env.example index ecf2bb311..d200e7e84 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,6 @@ TWILIO_ACCOUNT_SID=your_twilio_account_id # this is user generated TWILIO_AUTH_TOKEN=TWILIO_SECRET_KEY # To run in single-campaign mode, set the following environment vars -VUE_APP_CAMPAIGN_MODE = "default" # or "default" for normal operation -VUE_APP_FEATURED_CAMPAIGN = "5" # campaign's id from Postgres -VUE_APP_LETTER_TEMPLATE = "" # the template associated with the featured campaign +VUE_APP_CAMPAIGN_MODE = "single" # or "default" for normal operation +VUE_APP_FEATURED_CAMPAIGN = "6" # campaign's id from Postgres +VUE_APP_LETTER_TEMPLATE = "tmpl_4ffaf2960112b63" # the template associated with the featured campaign diff --git a/.github/workflows/action-cats.yml b/.github/workflows/action-cats.yml deleted file mode 100644 index 4e4925373..000000000 --- a/.github/workflows/action-cats.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: Cats đș - -on: - pull_request_target: - types: - - opened - - reopened - -jobs: - aCatForCreatingThePullRequest: - name: A cat for your effort! - runs-on: ubuntu-latest - steps: - - uses: ruairidhwm/action-cats@1.0.2 - with: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 7d981c773..000000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Build application - -on: - workflow_dispatch: - pull_request: - -permissions: - contents: read - -# This allows a subsequently queued workflow run to interrupt previous runs -concurrency: - group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' - cancel-in-progress: true - -jobs: - build: - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Check out repo - uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b - - - name: Setup node - uses: actions/setup-node@969bd2663942d722d85b6a8626225850c2f7be4b - with: - node-version-file: '.node-version' - cache: npm - - - name: Install dependencies - run: npm ci - - - name: Build application - run: npm run build - env: - # Auth0 authentication parameters with nonsensical sample values - SERVER_PORT: 8080 - CLIENT_ORIGIN_URL: http://localhost:8080 - AUTH0_AUDIENCE: your_Auth0_identifier_value - AUTH0_DOMAIN: your_Auth0_domain diff --git a/.github/workflows/cats.yml b/.github/workflows/cats.yml deleted file mode 100644 index 64bac52b3..000000000 --- a/.github/workflows/cats.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Add a cat gif on pr push - -on: - pull_request_target: - types: - - opened - - reopened - -permissions: - contents: read - -jobs: - cats: - name: Cat - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - - name: Action Cats - uses: ruairidhwm/action-cats@1.0.2 - with: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/check-formatting.yml b/.github/workflows/check-formatting.yml deleted file mode 100644 index ca706852b..000000000 --- a/.github/workflows/check-formatting.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Check formatting - -on: - workflow_dispatch: - pull_request: - -permissions: - contents: read - -# This allows a subsequently queued workflow run to interrupt previous runs -concurrency: - group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' - cancel-in-progress: true - -jobs: - check: - runs-on: ubuntu-latest - timeout-minutes: 2 - steps: - - name: Check out repo - uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b - - - name: Setup node - uses: actions/setup-node@969bd2663942d722d85b6a8626225850c2f7be4b - with: - node-version-file: '.node-version' - cache: npm - - - name: Install dependencies - run: npm ci - - - name: Check code style - run: npm run format:check diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index f99aa251a..000000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: 'CodeQL' - -on: - push: - branches: - - main - pull_request: - # The PR base branches below must be a subset of the push branches above - branches: - - main - # Only execute on PRs if relevant files changed - paths: - - '**/*.js' - - '.github/workflows/codeql-analysis.yml' - schedule: - - cron: '27 1 * * 0' - -permissions: - actions: read - contents: read - security-events: write - -# This allows a subsequently queued workflow run to interrupt previous runs -concurrency: - group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' - cancel-in-progress: true - -jobs: - analyze: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@f3feb00acb00f31a6f60280e6ace9ca31d91c76a - with: - languages: javascript - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@f3feb00acb00f31a6f60280e6ace9ca31d91c76a diff --git a/.github/workflows/emojiPR.yml b/.github/workflows/emojiPR.yml deleted file mode 100644 index b305cdd43..000000000 --- a/.github/workflows/emojiPR.yml +++ /dev/null @@ -1,27 +0,0 @@ -# this is an action that will look for keywords in the title of a pull request and add a corresponding emoji to the comment section of the Pull Request so users can quickly identify the intention of a commit by the emoji generated -name: An Emoji for Your Hard Work! -# this action will trigger on every new pull request opened -on: - pull_request: - types: - - opened - -jobs: - # This is the job definition for 'gitemote', which will run as part of the GitHub Actions workflow. - gitemote: - # This job will run on the latest version of Ubuntu - runs-on: ubuntu-latest - # This step checks out a copy of your repository and see if any changes have been made - steps: - - name: Checkout Code - uses: actions/checkout@v2 - # Step 2: Run PR emote generator. - # This custom action is used to generate emotes for pull requests. - # It's referencing to the main branch of an action that I created based off of the original PR Emote Generator action created by @rcmtcristian. - - name: PR emote generator - uses: lingeorge88/gitemotePR_GL@main - with: - # The GITHUB_TOKEN is a special type of secret that is automatically created by GitHub. - # It is used to authenticate in the workflow and perform actions on the repository. - # Here it is passed to the custom action, which uses it to interact with GitHub API. - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/finish-hackpod.yml b/.github/workflows/finish-hackpod.yml deleted file mode 100644 index 89a3bcd2c..000000000 --- a/.github/workflows/finish-hackpod.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Run on Label Assignment (finish hackpod) - -on: - pull_request: - types: - - labeled - -jobs: - run_on_label_assignment: - runs-on: ubuntu-latest - - steps: - - name: Checkout Repository - uses: actions/checkout@v2 - - - name: Install - run: npm install - working-directory: ./roiScript - - - name: Set Branch Name - id: branch_name - run: echo "::set-output name=issue_branch::${{ github.head_ref }}" - - - name: Get Current User - id: current_user - run: echo "::set-output name=gh_handle::${{ github.actor }}" - - - name: Run script on label assignment - if: contains(github.event.pull_request.labels.*.name, 'completed hackpod issue') # no way to set a particular label workflow trigger -- https://github.com/orgs/community/discussions/26261 - env: - PR_PAYLOAD: ${{ toJson(github.event.pull_request._links.comments.href) }} # was: github # github.event.pull_request._links.comments.href - NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }} - NOTION_DATABASE_ID: ${{ secrets.NOTION_DATABASE_ID }} - BRANCH_NAME: ${{ steps.branch_name.outputs.issue_branch }} - GH_HANDLE: ${{ steps.current_user.outputs.gh_handle }} - run: node ./roiScript/send-metrics.mjs diff --git a/.github/workflows/get-originaltime.yml b/.github/workflows/get-originaltime.yml deleted file mode 100644 index 0a1e3474c..000000000 --- a/.github/workflows/get-originaltime.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Detect Original Time Label - -on: - issues: - types: [labeled] - -jobs: - run_on_label_assignment: - runs-on: ubuntu-latest - - steps: - - name: Checkout Repository - uses: actions/checkout@v2 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 16 - - # TODO: fix the unsupported engine error - - name: Install - run: npm install # because of amplify-script@1.0.0 only able to install node: '16.x' - working-directory: ./roiScript - - - name: Send time - if: contains(join(github.event.issue.labels.*.name, ','), 'originaltime-') - env: - LABELS: ${{ toJson(github.event.issue.labels) }} - NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }} - NOTION_DATABASE_ID: ${{ secrets.NOTION_DATABASE_ID }} - ISSUE_NUMBER: ${{ github.event.issue.number }} - GH_HANDLE: ${{ github.actor }} - run: node ./roiScript/send-time.mjs diff --git a/.github/workflows/integration-tests.yaml b/.github/workflows/integration-tests.yaml deleted file mode 100644 index 1e33c335b..000000000 --- a/.github/workflows/integration-tests.yaml +++ /dev/null @@ -1,46 +0,0 @@ -name: Integration Tests - -on: - workflow_dispatch: - pull_request: - -permissions: - contents: read - -# This allows a subsequently queued workflow run to interrupt previous runs -concurrency: - group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' - cancel-in-progress: true - -jobs: - test: - if: ${{ github.repository == 'ProgramEquity/amplify' }} - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Check out repo - uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b - - - name: Setup node - uses: actions/setup-node@969bd2663942d722d85b6a8626225850c2f7be4b - with: - node-version-file: '.node-version' - cache: npm - - - name: Install dependencies - run: npm ci - - - name: Run tests - run: npm test -- server/__tests__/integration/ - env: - # Cicero API key - CICERO_API_KEY: ${{ secrets.TEST_CICERO_API_KEY }} - # Test environment Lob API key - LOB_API_KEY: ${{ secrets.TEST_LOB_API_KEY }} - # Stripe test secret key - STRIPE_SECRET_KEY: ${{ secrets.TEST_STRIPE_SECRET_KEY }} - # Auth0 authentication parameters with nonsensical sample values - SERVER_PORT: 8080 - CLIENT_ORIGIN_URL: http://localhost:8080 - AUTH0_AUDIENCE: your_Auth0_identifier_value - AUTH0_DOMAIN: your_Auth0_domain diff --git a/.github/workflows/issue-metrics.yml b/.github/workflows/issue-metrics.yml deleted file mode 100644 index a482b3927..000000000 --- a/.github/workflows/issue-metrics.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Measure issue timestamp - -on: - issues: - types: [assigned] - -jobs: - record_issue_timestamp: - runs-on: ubuntu-latest - if: github.event_name == 'issues' - steps: - - name: Checkout the repo - uses: actions/checkout@v3 - - - name: Create branch linked to the issue - id: create_branch - run: | - issue_number=$(echo "${{ github.event.issue.number }}") - git checkout -b "issue-${issue_number}" - echo "::set-output name=issue_branch::issue-${issue_number}" - - - name: Install - run: npm install - working-directory: ./roiScript - - - name: Run - env: - GH_TOKEN: ${{ secrets.GH_TOKEN }} - ISSUE_NUMBER: ${{ github.event.issue.number }} - run: node ./roiScript/log-branch.mjs - - - name: Log Current Branch (debugging) - run: | - current_branch=$(git rev-parse --abbrev-ref HEAD) - echo "Currently checked out branch: $current_branch" - - - name: Add Issue Timestamp To File - working-directory: roiScript - run: | - issue_num=$(echo "${{ github.event.issue.number }}") - timestamp=$(date +"%Y-%m-%d %H:%M:%S") - echo "$GITHUB_ACTOR, issue #${issue_num}: $timestamp" >> issue_timestamp.log - - - name: Commit Issue Timestamp - run: | - git config --global user.email "Polinka7max@gmail.com" - git config --global user.name "Dunridge" - git add . - git commit -m "Add issue timestamp by $GITHUB_ACTOR" - git push https://${{ secrets.GH_TOKEN }}@github.com/${{ github.repository }}.git diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml deleted file mode 100644 index de8dfa2ea..000000000 --- a/.github/workflows/labeler.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: label new contributor -on: - issues: - types: [opened] - pull_request_target: - types: [opened] - -jobs: - labeler: - runs-on: ubuntu-latest - steps: - - uses: actions/github-script@v6 - with: - script: | - const eventType = context.eventName; - // Get a list of all issues created by the PR opener - // See: https://octokit.github.io/rest.js/#pagination - const creator = context.payload.sender.login - const opts = github.rest.issues.listForRepo.endpoint.merge({ - ...context.issue, - creator, - state: 'all' - }) - const issues = await github.paginate(opts) - - for (const issue of issues) { - if (issue.number === context.issue.number) { - continue - } - - if (issue.pull_request || issue.user.login === creator) { - return // Creator is already a contributor. - } } - await github.rest.issues.addLabels({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - labels:["new contributor"] - }); diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 31dc75cef..000000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Lint code - -on: - workflow_dispatch: - pull_request: - -permissions: - contents: read - -# This allows a subsequently queued workflow run to interrupt previous runs -concurrency: - group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' - cancel-in-progress: true - -jobs: - lint: - runs-on: ubuntu-latest - timeout-minutes: 2 - steps: - - name: Check out repo - uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b - - - name: Setup node - uses: actions/setup-node@969bd2663942d722d85b6a8626225850c2f7be4b - with: - node-version-file: '.node-version' - cache: npm - - - name: Install dependencies - run: npm ci - - - name: Run linter - run: npm run lint:check diff --git a/.github/workflows/pr-metrics.yml b/.github/workflows/pr-metrics.yml deleted file mode 100644 index 02394f3ac..000000000 --- a/.github/workflows/pr-metrics.yml +++ /dev/null @@ -1,106 +0,0 @@ -name: ROI PR metrics - -on: - pull_request: - types: [review_requested] - -jobs: - record_pr_timestamp: - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' - outputs: - pr_timestamp: ${{ steps.get_pr_timestamp.outputs.pr_timestamp }} - steps: - - name: Get Current Timestamp for PR - id: get_pr_timestamp - run: | - current_timestamp=$(date -u +'%Y-%m-%dT%H:%M:%SZ') - seconds_since_epoch=$(date -d "${current_timestamp}" +%s) - echo "PR timestamp: ${current_timestamp}" - echo "PR timestamp in seconds: ${seconds_since_epoch}" - echo "::set-output name=pr_timestamp::${seconds_since_epoch}" - - get_issue_timestamp: - needs: record_pr_timestamp - runs-on: ubuntu-latest - outputs: - issue_timestamp: ${{ steps.get_issue_timestamp.outputs.issue_timestamp }} - steps: - - name: Checkout the repository - uses: actions/checkout@v2 - - - name: Checkout Source Branch - run: | - git fetch - echo "Current Branch: $(git branch)" - git checkout ${{github.event.pull_request.head.ref}} - - - name: Get Issue Timestamp - id: get_issue_timestamp - working-directory: roiScript - run: | # timestamp: Dunridge, issue #767: 2023-11-14 16:06:02 - issue_timestamp=$(cat issue_timestamp.log | awk -F ': ' '{print $2}') - seconds_since_epoch=$(date -d "${issue_timestamp}" +"%s") - echo "Issue timestamp from the first step: ${issue_timestamp}" - echo "Issue timestamp in seconds: ${seconds_since_epoch}" - echo "::set-output name=issue_timestamp::${seconds_since_epoch}" - echo "" > issue_timestamp.log - - - name: Commit Timestamp Removal # step + echo "" > issue_timestamp.log -- for clearing the issue_timestamp.log file so that this doesn't run on second reviewer assignment - run: | - git config --global user.email "Polinka7max@gmail.com" - git config --global user.name "Dunridge" - git add . - git commit -m "Remove issue timestamp by $GITHUB_ACTOR" - git push https://${{ secrets.GH_TOKEN }}@github.com/${{ github.repository }}.git - - calculate_time_difference: - needs: - - record_pr_timestamp - - get_issue_timestamp - runs-on: ubuntu-latest - outputs: - time_diff: ${{ steps.time_difference.outputs.time_diff }} - env: - pr_timestamp_input: ${{needs.record_pr_timestamp.outputs.pr_timestamp}} - issue_timestamp_input: ${{needs.get_issue_timestamp.outputs.issue_timestamp}} - steps: - - name: Calculate Time Difference - id: time_difference - run: | - issue_timestamp=$issue_timestamp_input - echo "Issue timestamp (seconds): ${issue_timestamp}" - pr_timestamp=$pr_timestamp_input - echo "PR timestamp (seconds): ${pr_timestamp}" - time_diff=$((pr_timestamp - issue_timestamp)) - echo "Time Difference: ${time_diff}" - echo "::set-output name=time_diff::${time_diff}" - - metric: - needs: calculate_time_difference - runs-on: ubuntu-latest - env: - time_diff_input: ${{needs.calculate_time_difference.outputs.time_diff}} - steps: - - name: Checkout code - uses: actions/checkout@v2 - - - name: Setup Node.js - uses: actions/setup-node@v2 - with: - node-version: 18 # pick the node version that's compatible with libraries - - - name: Output Time Diff - run: | - echo "Time Diff For Output: $time_diff_input" - - - name: Install - run: npm install - working-directory: ./roiScript - - - name: Run - env: - GH_TOKEN: ${{ secrets.GH_TOKEN }} - TIME_DIFF: ${{ needs.calculate_time_difference.outputs.time_diff }} - PR_NUMBER: ${{ github.event.number }} - run: node ./roiScript/issue-metrics.mjs diff --git a/.github/workflows/programequity_slack.yml b/.github/workflows/programequity_slack.yml deleted file mode 100644 index 3c6b40061..000000000 --- a/.github/workflows/programequity_slack.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Notify mentioned users via Slack - -on: - issues: - types: [opened, edited] - issue_comment: - types: [created, edited] - pull_request_target: - types: [opened, edited, review_requested] - pull_request_review: - types: [submitted] - pull_request_review_comment: - types: [created, edited] - -permissions: - issues: read - pull-requests: read - -jobs: - mention-to-slack: - runs-on: ubuntu-latest - steps: - - name: Run - uses: abeyuya/actions-mention-to-slack@v2 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - # Send messages to the 'devs' channel in the ProgramEquity Slack workspace - slack-webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }} - icon-url: https://img.icons8.com/color/256/000000/github-2.png - bot-name: 'Mentioned on GitHub: ${{ github.event_name }}' - run-id: ${{ github.run_id }} diff --git a/.github/workflows/scorecards-analysis.yml b/.github/workflows/scorecards-analysis.yml deleted file mode 100644 index f9b50f65d..000000000 --- a/.github/workflows/scorecards-analysis.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: Scorecards supply-chain security - -on: - # Only the default branch is supported. - branch_protection_rule: - schedule: - - cron: '43 10 * * 6' - push: - branches: - - main - -permissions: - contents: read - -jobs: - analysis: - runs-on: ubuntu-latest - - permissions: - actions: read - contents: read - # Used to receive a badge. - id-token: write - # Needed to upload the results to code-scanning dashboard. - security-events: write - - steps: - - name: Check out repo - uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b - with: - persist-credentials: false - - - name: Run analysis - uses: ossf/scorecard-action@e363bfca00e752f91de7b7d2a77340e2e523cb18 - with: - results_file: results.sarif - results_format: sarif - # Read-only PAT token. To create it, - # follow the steps in https://github.com/ossf/scorecard-action#pat-token-creation. - repo_token: ${{ secrets.SCORECARD_READ_TOKEN }} - # Publish the results to enable scorecard badges. For more details, see - # https://github.com/ossf/scorecard-action#publishing-results. - # For private repositories, `publish_results` will automatically be set to `false`, - # regardless of the value entered here. - publish_results: true - - # Upload the results as artifacts (optional). - - name: Upload artifact - uses: actions/upload-artifact@3cea5372237819ed00197afe530f5a7ea3e805c8 - with: - name: SARIF file - path: results.sarif - retention-days: 5 - - # Upload the results to GitHub's code scanning dashboard. - - name: Upload to code-scanning - uses: github/codeql-action/upload-sarif@f3feb00acb00f31a6f60280e6ace9ca31d91c76a - with: - sarif_file: results.sarif - - # Create a job summary with the OSSF badge. - - name: Output custom summary - run: echo "["'!'"[OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/$GITHUB_REPOSITORY/badge)](https://api.securityscorecards.dev/projects/github.com/$GITHUB_REPOSITORY)" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/sleepy-cat.yaml b/.github/workflows/sleepy-cat.yaml new file mode 100644 index 000000000..f149ce690 --- /dev/null +++ b/.github/workflows/sleepy-cat.yaml @@ -0,0 +1,22 @@ +name: sleepy-cat +# on key is triggered when pull request is opened or reopened +on: + pull_request: + # Reference: https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request + types: + - opened + - reopened +# defining what is going to run when the action is triggered +jobs: + sleepyCat-pull-request: + # What shows up when it is triggered + name: A sleepy cat with a good happy dream + # Reference: https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#standard-github-hosted-runners-for-public-repositories + runs-on: ubuntu-latest + steps: + - uses: ruairidhwm/action-cats@1.0.1 + with: + # GitHub token reference: https://docs.github.com/en/actions/security-guides/automatic-token-authentication + # key GITHUB_TOKEN:, expression ${{}}, context secrets.GITHUB_TOKEN - alive for the duration of the workflow, and enable to copy code to vm + # workflow expression reference: https://docs.github.com/en/actions/security-guides/automatic-token-authentication + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml deleted file mode 100644 index bea82409d..000000000 --- a/.github/workflows/unit-tests.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Unit Tests -on: - workflow_dispatch: - pull_request: -permissions: - contents: read -concurrency: - group: >- - ${{ github.workflow }} @ ${{ github.event.pull_request.head.label || - github.head_ref || github.ref }} - cancel-in-progress: true -jobs: - test: - if: ${{ github.repository == 'ProgramEquity/amplify' }} - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Check out repo - uses: actions/checkout@v3 - - name: Setup node - uses: actions/setup-node@v3 - with: - node-version-file: .node-version - cache: npm - - name: Install dependencies - run: npm ci - - name: Run tests - run: npm test -- server/__tests__/unit/ - env: - CICERO_API_KEY: '${{ secrets.TEST_CICERO_API_KEY }}' - LOB_API_KEY: '${{ secrets.TEST_LOB_API_KEY }}' - STRIPE_SECRET_KEY: '${{ secrets.TEST_STRIPE_SECRET_KEY }}' - SERVER_PORT: 8080 - CLIENT_ORIGIN_URL: 'http://localhost:8080' - AUTH0_AUDIENCE: your_Auth0_identifier_value - AUTH0_DOMAIN: your_Auth0_domain diff --git a/.github/workflows/welcome_message.yml b/.github/workflows/welcome_message.yml deleted file mode 100644 index b317d7678..000000000 --- a/.github/workflows/welcome_message.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: 'Welcome New Contributors' - -on: - issues: - types: [opened] - pull_request_target: - types: [opened] -permissions: - issues: write - pull-requests: write -jobs: - welcome-new-contributor: - runs-on: ubuntu-latest - timeout-minutes: 2 - steps: - - name: 'Greet the contributor' - uses: garg3133/welcome-new-contributors@a38583ed8282e23d63d7bf919ca2d9fb95300ca6 - with: - token: ${{ secrets.GITHUB_TOKEN }} - issue-message: 'Hello there, thanks for opening your first issue. We welcome you to the community!' - pr-message: 'Hello there, thanks for opening your first Pull Request. Someone will review it soon.' diff --git a/.github/workflows/workflow-lint.yml b/.github/workflows/workflow-lint.yml deleted file mode 100644 index 077ad3f4c..000000000 --- a/.github/workflows/workflow-lint.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Lint workflows - -on: - workflow_dispatch: - pull_request: - paths: - - '.github/workflows/*.yml' - - '.github/workflows/*.yaml' - -permissions: - contents: read - -# This allows a subsequently queued workflow run to interrupt previous runs -concurrency: - group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' - cancel-in-progress: true - -jobs: - lint-workflows: - runs-on: ubuntu-latest - timeout-minutes: 2 - steps: - - name: Check out repo - uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b - - - name: Run linter - uses: cschleiden/actions-linter@caffd707beda4fc6083926a3dff48444bc7c24aa - with: - # ".github/workflows/scorecards-analysis.yml" is an exception as `on: branch_protection_rule` is not recognized yet - workflows: '[".github/workflows/*.yml", ".github/workflows/*.yaml", "!.github/workflows/scorecards-analysis.yml"]' diff --git a/app-policy.hcl b/app-policy.hcl new file mode 100644 index 000000000..51899b6db --- /dev/null +++ b/app-policy.hcl @@ -0,0 +1,4 @@ +path "secrets/hashi-corp-hackpod/*" +{ +capabilities = ["read"] +} diff --git a/package.json b/package.json index d5f9b3915..8fad5b14b 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "db:create": "npm run db:create:dev && npm run db:create:test", "db:create:dev": "node script/create-db.js --env development", "db:create:test": "node script/drop-db.js --env test && node script/create-db.js --env test", + "db:migrate:create": "knex migrate:make", "db:migrate": "npm run db:migrate:dev && npm run db:migrate:test", "db:migrate:dev": "knex migrate:latest --verbose --env development", "db:migrate:test": "knex migrate:latest --verbose --env test", diff --git a/rebuild b/rebuild new file mode 120000 index 000000000..c2f897577 --- /dev/null +++ b/rebuild @@ -0,0 +1 @@ +./script/rebuild.sh \ No newline at end of file diff --git a/script/rebuild.sh b/script/rebuild.sh new file mode 100755 index 000000000..4ce49efd0 --- /dev/null +++ b/script/rebuild.sh @@ -0,0 +1,3 @@ +#! /bin/bash + +docker-compose -f .docker/docker-compose.yml build amplify \ No newline at end of file diff --git a/script/stringify.js b/script/stringify.js new file mode 100644 index 000000000..b7cbcd68d --- /dev/null +++ b/script/stringify.js @@ -0,0 +1,145 @@ +const reps = JSON.stringify([ + { + name: 'Connie Chan', + title: 'District 1 Supervisor', + photoUrl: 'https://sfbos.org/sites/default/files/Connie_Chan_2023.jpg', + address_line1: '1 Dr Carlton B Goodlett Pl', + address_line2: '#244', + address_city: 'San Francisco', + address_state: 'CA', + address_zip: '94102', + address_country: 'US', + email: 'chanstaff@sfgov.org', + }, + { + name: 'Catherine Stefani', + title: 'District 2 Supervisor', + photoUrl: 'https://sfbos.org/sites/default/files/Supervisor_Stefani_2018.jpg', + address_line1: '1 Dr Carlton B Goodlett Pl', + address_line2: '#244', + address_city: 'San Francisco', + address_state: 'CA', + address_zip: '94102', + address_country: 'US', + email: 'Catherine.Stefani@sfgov.org', + }, + { + name: 'Aaron Peskin', + title: 'District 3 Supervisor', + photoUrl: 'https://sfbos.org/sites/default/files/Aaron_Peskin_2023.jpg', + address_line1: '1 Dr Carlton B Goodlett Pl', + address_line2: '#244', + address_city: 'San Francisco', + address_state: 'CA', + address_zip: '94102', + address_country: 'US', + email: 'Aaron.Peskin@sfgov.org', + }, + { + name: 'Joel Engardio', + title: 'District 4 Supervisor', + photoUrl: 'https://sfbos.org/sites/default/files/Joel_Engardio_2023.jpg', + address_line1: '1 Dr Carlton B Goodlett Pl', + address_line2: '#244', + address_city: 'San Francisco', + address_state: 'CA', + address_zip: '94102', + address_country: 'US', + email: '', + }, + { + name: 'Dean Preston', + title: 'District 5 Supervisor', + photoUrl: 'https://sfbos.org/sites/default/files/Dean_Preston_2023.jpg', + address_line1: '1 Dr Carlton B Goodlett Pl', + address_line2: '#244', + address_city: 'San Francisco', + address_state: 'CA', + address_zip: '94102', + address_country: 'US', + email: 'prestonstaff@sfgov.org', + }, + { + name: 'Matt Dorsey', + title: 'District 6 Supervisor', + photoUrl: 'https://sfbos.org/sites/default/files/Dorsey_2022_lg.jpg', + address_line1: '1 Dr Carlton B Goodlett Pl', + address_line2: '#244', + address_city: 'San Francisco', + address_state: 'CA', + address_zip: '94102', + address_country: 'US', + email: 'DorseyStaff@sfgov.org', + }, + { + name: 'Myrna Melgar', + title: 'District 7 Supervisor', + photoUrl: 'https://sfbos.org/sites/default/files/Myrna_Melgar_2023.jpg', + address_line1: '1 Dr Carlton B Goodlett Pl', + address_line2: '#244', + address_city: 'San Francisco', + address_state: 'CA', + address_zip: '94102', + address_country: 'US', + email: 'melgarstaff@sfgov.org', + }, + { + name: 'Rafael Mandelman', + title: 'District 8 Supervisor', + photoUrl: 'https://sfbos.org/sites/default/files/Supervisor_Mandelman_2018.jpg', + address_line1: '1 Dr Carlton B Goodlett Pl', + address_line2: '#244', + address_city: 'San Francisco', + address_state: 'CA', + address_zip: '94102', + address_country: 'US', + email: 'mandelmanstaff@sfgov.org', + }, + { + name: 'Hillary Ronen', + title: 'District 9 Supervisor', + photoUrl: 'https://sfbos.org/sites/default/files/Supervisor_Ronen_2019.jpg', + address_line1: '1 Dr Carlton B Goodlett Pl', + address_line2: '#244', + address_city: 'San Francisco', + address_state: 'CA', + address_zip: '94102', + address_country: 'US', + email: 'RonenStaff@sfgov.org', + }, + { + name: 'Shamann Walton', + title: 'District 10 Supervisor', + photoUrl: 'https://sfbos.org/sites/default/files/Shamann_Walton_2023.jpg', + address_line1: '1 Dr Carlton B Goodlett Pl', + address_line2: '#244', + address_city: 'San Francisco', + address_state: 'CA', + address_zip: '94102', + address_country: 'US', + email: 'waltonstaff@sfgov.org', + }, + { + name: 'Ahsha Safai', + title: 'District 11 Supervisor', + photoUrl: 'https://sfbos.org/sites/default/files/Ahsha_Safai_2023.jpg', + address_line1: '1 Dr Carlton B Goodlett Pl', + address_line2: '#244', + address_city: 'San Francisco', + address_state: 'CA', + address_zip: '94102', + address_country: 'US', + email: 'Ahsha.Safai@sfgov.org', + } +]) + +const assets = JSON.stringify({ + campaign_logo: 'https://images.squarespace-cdn.com/content/v1/5eebc0039b04b54b2fb0ce52/1598500758914-E5HAIIGCP0ZXKKMN2FT0/TRT+Logo-13.png?format=500w', + campaign_background: 'https://images.squarespace-cdn.com/content/v1/5eebc0039b04b54b2fb0ce52/1610135975708-FV42Q30BPWF887M05O51/Poppies-13.jpg?format=1500w', + 'campaign-img-1': 'https://images.squarespace-cdn.com/content/v1/5eebc0039b04b54b2fb0ce52/1609785926325-63M9MY247ORIL8ON8P5E/tuolumne-camp-960x540.jpg?format=1500w', + 'campaign-img-2': 'https://images.squarespace-cdn.com/content/v1/5eebc0039b04b54b2fb0ce52/1592586646372-TBY3WC065NJ04KT2B38Z/000091400001.jpg?format=1500w', + 'campaign-img-3': 'https://images.squarespace-cdn.com/content/v1/5eebc0039b04b54b2fb0ce52/1609781359705-Q5YNFL6DET5ORHW62ZU7/IMG_2249_WEBSITE+copy.jpg?format=1500w', +}) + +console.log(reps) +console.log(assets) \ No newline at end of file diff --git a/server/__tests__/fixtures/stripe/payment-failure.json b/server/__tests__/fixtures/stripe/payment-failure.json new file mode 100644 index 000000000..8c56c75da --- /dev/null +++ b/server/__tests__/fixtures/stripe/payment-failure.json @@ -0,0 +1,70 @@ +{ + "id": "evt_1NG8Du2eZvKYlo2CUI79vXWy", + "object": "event", + "api_version": "2019-02-19", + "created": 1686089970, + "data": { + "object": { + "id": "pi_test-failed-5678", + "object": "payment_intent", + "amount": 5000, + "amount_capturable": 0, + "amount_details": { + "tip": {} + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": { + "enabled": true + }, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_3MtwBwLkdIwHu7ix28a3tqPa_secret_YrKJUKribcBjcG8HVhfZluoGH", + "confirmation_method": "automatic", + "created": 1680800504, + "currency": "usd", + "customer": null, + "description": null, + "invoice": null, + "last_payment_error": null, + "latest_charge": null, + "livemode": false, + "metadata": {}, + "next_action": null, + "on_behalf_of": null, + "payment_method": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + }, + "link": { + "persistent_token": null + } + }, + "payment_method_types": ["card", "link"], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "failed", + "transfer_data": null, + "transfer_group": null + } + }, + "livemode": false, + "pending_webhooks": 0, + "request": { + "id": null, + "idempotency_key": null + }, + "type": "payment_intent.failed" +} diff --git a/server/__tests__/fixtures/stripe/payment-success.json b/server/__tests__/fixtures/stripe/payment-success.json new file mode 100644 index 000000000..eebcb5ecc --- /dev/null +++ b/server/__tests__/fixtures/stripe/payment-success.json @@ -0,0 +1,70 @@ +{ + "id": "evt_1NG8Du2eZvKYlo2CUI79vXWy", + "object": "event", + "api_version": "2019-02-19", + "created": 1686089970, + "data": { + "object": { + "id": "pi_test-success-1234", + "object": "payment_intent", + "amount": 10000, + "amount_capturable": 0, + "amount_details": { + "tip": {} + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": { + "enabled": true + }, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_3MtwBwLkdIwHu7ix28a3tqPa_secret_YrKJUKribcBjcG8HVhfZluoGH", + "confirmation_method": "automatic", + "created": 1680800504, + "currency": "usd", + "customer": null, + "description": null, + "invoice": null, + "last_payment_error": null, + "latest_charge": null, + "livemode": false, + "metadata": {}, + "next_action": null, + "on_behalf_of": null, + "payment_method": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + }, + "link": { + "persistent_token": null + } + }, + "payment_method_types": ["card", "link"], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + } + }, + "livemode": false, + "pending_webhooks": 0, + "request": { + "id": null, + "idempotency_key": null + }, + "type": "payment_intent.succeeded" +} diff --git a/server/db/migrations/20231022014507_update_constituent_transaction_letters_tables.js b/server/db/migrations/20231022014507_update_constituent_transaction_letters_tables.js new file mode 100644 index 000000000..929c881b1 --- /dev/null +++ b/server/db/migrations/20231022014507_update_constituent_transaction_letters_tables.js @@ -0,0 +1,61 @@ +module.exports = { + async up(knex) { + await knex.schema.alterTable('constituents', (table) => { + table.timestamps(false, true, false) + table.renameColumn('street_address', 'address_line_1') + table.renameColumn('address_two', 'address_line_2') + }) + + // Renaming + await knex.schema.renameTable('sent_letters', 'letters') + + await knex.schema.alterTable('letters', (table) => { + table.integer('transaction_id') + table.foreign('transaction_id').references('transactions.id') + table.string('letter_template') + table.string('letter_version') // Removes fkey for letter versions + table.string('addressee') + table.string('address_line_1') + table.string('address_line_2') + table.string('city') + table.string('state') + table.string('zip') + table.string('return_address') + table.boolean('sent').defaultTo(false) + table.timestamps(false, true, false) + + table.dropColumn('request_id') + table.dropColumn('requested_at') + table.dropColumn('rep_name') + table.dropColumn('rep_address') + table.dropColumn('letter_version_id') + }) + + await knex.schema + .alterTable('transactions', (table) => { + table.integer('constituent_id') + table.foreign('constituent_id').references('constituents.id') + + // Timestamps are not implemented with proper defaults so we need to drop them here... + table.dropTimestamps() + + table.dropColumn('payment_method_type') + table.dropColumn('email') + }) + .then(() => { + // ...then we add timestamps again here. + return knex.schema.alterTable('transactions', (table) => { + table.timestamps(false, true, false) + }) + }) + + await knex.schema.dropTable('letter_versions') + }, + + async down(knex) { + await knex.schema.dropTable('constituents') + await knex.schema.dropTable('transactions') + await knex.schema.dropTable('sent_letters') + await knex.schema.dropTable('letter_versions') + } +} diff --git a/server/db/migrations/20240519161955_campaign_table_updates.js b/server/db/migrations/20240519161955_campaign_table_updates.js new file mode 100644 index 000000000..37b2211a1 --- /dev/null +++ b/server/db/migrations/20240519161955_campaign_table_updates.js @@ -0,0 +1,22 @@ +module.exports = { + async up(knex) { + await knex.schema.alterTable('campaigns', (table) => { + table.jsonb('representatives').nullable() + table.jsonb('assets').nullable() + table.dropColumn('campaign_text') + table.dropColumn('supplemental_text') + }) + + await knex.schema.alterTable('campaigns', (table) => { + table.text('campaign_text') + table.text('supplemental_text') + }) + }, + + async down(knex) { + await knex.schema.alterTable('campaigns', (table) => { + table.dropColumn('representatives') + table.dropColumn('assets') + }) + } +} diff --git a/server/db/migrations/20240528021821_add_letter_merge_vars.js b/server/db/migrations/20240528021821_add_letter_merge_vars.js new file mode 100644 index 000000000..01c4ec6d4 --- /dev/null +++ b/server/db/migrations/20240528021821_add_letter_merge_vars.js @@ -0,0 +1,13 @@ +module.exports = { + async up(knex) { + await knex.schema.alterTable('letters', (table) => { + table.jsonb('merge_variables').nullable() + }) + }, + + async down(knex) { + await knex.schema.alterTable('letters', (table) => { + table.dropColumn('merge_variables') + }) + } +} diff --git a/server/db/migrations/20240528133030_adjust_letter_data_types.js b/server/db/migrations/20240528133030_adjust_letter_data_types.js new file mode 100644 index 000000000..ea74d2b3b --- /dev/null +++ b/server/db/migrations/20240528133030_adjust_letter_data_types.js @@ -0,0 +1,17 @@ +module.exports = { + async up(knex) { + await knex.schema.alterTable('letters', (table) => { + table.dropColumn('letter_template') + }) + + await knex.schema.alterTable('letters', (table) => { + table.text('letter_template') + }) + }, + + async down(knex) { + knex.schema.alterTable('letters', (table) => { + table.dropColumn('letter_template') + }) + } +} diff --git a/server/db/models/_base.js b/server/db/models/_base.js index c89f9b8af..6b9213b1d 100644 --- a/server/db/models/_base.js +++ b/server/db/models/_base.js @@ -1,10 +1,17 @@ -const { Model } = require('objection') +const { Model, snakeCaseMappers } = require('objection') const { createClient } = require('../') class BaseModel extends Model { static get modelPaths() { return [__dirname] } + + // Maps column_name in postgres to columnName in the app. + // Queries must still use snake_case if sql is passed into a query method chain + // For ex. Constituent.query().where('first_name', 'Jennifer') + static get columnNameMappers() { + return snakeCaseMappers() + } } // One-time configuration to link all Objection models derived from this class with Knex diff --git a/server/db/models/constituent.js b/server/db/models/constituent.js new file mode 100644 index 000000000..b6632354d --- /dev/null +++ b/server/db/models/constituent.js @@ -0,0 +1,44 @@ +const BaseModel = require('./_base') + +class Constituent extends BaseModel { + static get tableName() { + return 'constituents' + } + + static get jsonSchema() { + return { + type: 'object', + required: [ + 'email', + 'firstName', + 'lastName', + 'addressLine_1', + 'city', + 'state', + 'zip' + ], + properties: { + email: { type: 'string', minLength: 1, maxLength: 255 }, + first_name: { type: 'string', minLength: 1, maxLength: 255 }, + last_name: { type: 'string', minLength: 1, maxLength: 255 }, + address_line_1: { type: 'string', minLength: 1, maxLength: 255 }, + address_line_2: { type: 'string', minLength: 1, maxLength: 255 }, + city: { type: 'string', minLength: 1, maxLength: 255 }, + state: { type: 'string', minLength: 1, maxLength: 255 }, + zip: { type: 'string', minLength: 1, maxLength: 255 } + } + } + } + + /* + static get relationMappings() { + // TODO: Add relation to transactions + } + */ + + fullName() { + return `${this.first_name} ${this.last_name}` + } +} + +module.exports = Constituent diff --git a/server/db/models/letter-version.js b/server/db/models/letter-version.js index cb74ea3b0..c6d1de6f5 100644 --- a/server/db/models/letter-version.js +++ b/server/db/models/letter-version.js @@ -32,6 +32,9 @@ class LetterVersion extends BaseModel { }, municipality: { anyOf: [{ type: 'string', minLength: 1 }, { type: 'null' }] + }, + mergeVariables: { + } } } diff --git a/server/db/models/letter.js b/server/db/models/letter.js new file mode 100644 index 000000000..648e4872c --- /dev/null +++ b/server/db/models/letter.js @@ -0,0 +1,36 @@ +const BaseModel = require('./_base') + +class Letter extends BaseModel { + static get tableName() { + return 'letters' + } + + static get jsonSchema() { + return { + type: 'object', + required: [ + 'constituentId', + 'transactionId', + 'letterTemplate', + 'letterVersion', + 'addressee', + 'addressLine_1', + 'city', + 'state', + 'zip' + ], + properties: { + letter_template: { type: 'string', minLength: 1, maxLength: 255 }, + letter_version: { type: 'string', minLength: 1, maxLength: 255 }, + addressee: { type: 'string', minLength: 1, maxLength: 255 }, + address_line_1: { type: 'string', minLength: 1, maxLength: 255 }, + address_line_2: { type: 'string', minLength: 1, maxLength: 255 }, + city: { type: 'string', minLength: 1, maxLength: 255 }, + state: { type: 'string', minLength: 1, maxLength: 255 }, + zip: { type: 'string', minLength: 1, maxLength: 255 } + } + } + } +} + +module.exports = Letter diff --git a/server/db/models/transaction.js b/server/db/models/transaction.js new file mode 100644 index 000000000..c4615b841 --- /dev/null +++ b/server/db/models/transaction.js @@ -0,0 +1,47 @@ +const BaseModel = require('./_base') + +class Transaction extends BaseModel { + static get tableName() { + return 'transactions' + } + + static get jsonSchema() { + return { + type: 'object', + required: [ + 'stripeTransactionId', + 'constituentId', + 'amount', + 'currency', + 'paymentMethod' + ], + properties: { + stripe_transaction_id: { type: 'string' }, + constituent_id: { type: 'number' }, + amount: { type: 'number' }, + currency: { type: 'string' }, + payment_method: { type: 'string' }, + status: { type: 'string' } + } + } + } + + /* + static get relationMappings() { + const Constituent = require('./constituent') + + return { + constituent: { + relation: BaseModel.HasOneRelation, + modelClass: Constituent, + join: { + // from 'transactions.constituent_id', + to: 'constituents.id' + } + } + } + } + */ +} + +module.exports = Transaction diff --git a/server/db/seeds/development/seed-campaigns-table.js b/server/db/seeds/development/seed-campaigns-table.js index 873950375..464309f4f 100644 --- a/server/db/seeds/development/seed-campaigns-table.js +++ b/server/db/seeds/development/seed-campaigns-table.js @@ -1,7 +1,6 @@ module.exports = { async seed(knex) { // Deletes ALL existing entries - await knex('letter_versions').del() // Because these have foreign keys linked to the campaigns table await knex('campaigns').del() // Inserts seed entries @@ -45,6 +44,158 @@ module.exports = { cause: 'Civic Rights', type: 'Grant', page_url: 'https://sogoreate-landtrust.org/' + }, + { + id: 6, + organization: "Tuolumne River Trust", + name: "Tuolumne River Trust", + cause: 'Civic Rights', + type: 'Grant', + page_url: 'https://www.tuolumne.org/revive-the-tuolumne', + campaign_tagline: 'For a Healthy and Vibrant River', + campaign_text: '
The Tuolumne River is on the verge of ecological collapse. Historically, the River hosted well over 100,000 salmon, but in 2020 only 1,000 returned to spawn. And itâs not just about fish. Before dams were constructed to divert water to farms and urban areas, salmon transported millions of pounds of nutrients from the ocean to upland habitats, where they fueled the food web and fertilized forests and meadows. Absent those nutrients, the Tuolumneâs salmon-based ecosystem is in a state of crisis.
The main cause of the Riverâs demise is inadequate instream flows. In an average year, only 21% of the Tuolumneâs unimpaired flow reaches the San Joaquin River. During the recent drought, unimpaired flow averaged just 12% for five straight years. Meanwhile, more than three yearsâ worth of water remained impounded behind SFPUC dams, and all that water (and much more) had to be âdumpedâ back into the River two years later to prevent flooding downstream. The Tuolumne experienced one exceptionally good year at the expense of five terrible years.
Revive the Tuolumne aims to reverse this negative trend and restore the River. Our Let it Flow campaign advocates for higher instream flows through federal licensing of dams and by supporting the State Water Boardâs update of the Bay Delta Water Quality Control Plan. Our Use It Wisely campaign promotes water conservation and alternative water supplies to reduce reliance on Tuolumne River water so that more can be made available for environmental purposes.
', + supplemental_text: 'California water policy is complex; competing interests, lawsuits, and antiquated laws inhibit progressive management on the Tuolumne.\nWeâve been working on policy issues since our inception 40 years ago, and have no plans to stop. Through various policy proceedings happening at the state and federal level, we are working to reverse past damage by increasing the amount of water flowing down the river and into the San Francisco Bay-Delta.', + representatives: JSON.stringify([ + { + name: 'Connie Chan', + title: 'District 1 Supervisor', + photoUrl: 'https://sfbos.org/sites/default/files/Connie_Chan_2023.jpg', + address_line1: '1 Dr Carlton B Goodlett Pl', + address_line2: '#244', + address_city: 'San Francisco', + address_state: 'CA', + address_zip: '94102', + address_country: 'US', + email: 'chanstaff@sfgov.org', + }, + { + name: 'Catherine Stefani', + title: 'District 2 Supervisor', + photoUrl: 'https://sfbos.org/sites/default/files/Supervisor_Stefani_2018.jpg', + address_line1: '1 Dr Carlton B Goodlett Pl', + address_line2: '#244', + address_city: 'San Francisco', + address_state: 'CA', + address_zip: '94102', + address_country: 'US', + email: 'Catherine.Stefani@sfgov.org', + }, + { + name: 'Aaron Peskin', + title: 'District 3 Supervisor', + photoUrl: 'https://sfbos.org/sites/default/files/Aaron_Peskin_2023.jpg', + address_line1: '1 Dr Carlton B Goodlett Pl', + address_line2: '#244', + address_city: 'San Francisco', + address_state: 'CA', + address_zip: '94102', + address_country: 'US', + email: 'Aaron.Peskin@sfgov.org', + }, + { + name: 'Joel Engardio', + title: 'District 4 Supervisor', + photoUrl: 'https://sfbos.org/sites/default/files/Joel_Engardio_2023.jpg', + address_line1: '1 Dr Carlton B Goodlett Pl', + address_line2: '#244', + address_city: 'San Francisco', + address_state: 'CA', + address_zip: '94102', + address_country: 'US', + email: '', + }, + { + name: 'Dean Preston', + title: 'District 5 Supervisor', + photoUrl: 'https://sfbos.org/sites/default/files/Dean_Preston_2023.jpg', + address_line1: '1 Dr Carlton B Goodlett Pl', + address_line2: '#244', + address_city: 'San Francisco', + address_state: 'CA', + address_zip: '94102', + address_country: 'US', + email: 'prestonstaff@sfgov.org', + }, + { + name: 'Matt Dorsey', + title: 'District 6 Supervisor', + photoUrl: 'https://sfbos.org/sites/default/files/Dorsey_2022_lg.jpg', + address_line1: '1 Dr Carlton B Goodlett Pl', + address_line2: '#244', + address_city: 'San Francisco', + address_state: 'CA', + address_zip: '94102', + address_country: 'US', + email: 'DorseyStaff@sfgov.org', + }, + { + name: 'Myrna Melgar', + title: 'District 7 Supervisor', + photoUrl: 'https://sfbos.org/sites/default/files/Myrna_Melgar_2023.jpg', + address_line1: '1 Dr Carlton B Goodlett Pl', + address_line2: '#244', + address_city: 'San Francisco', + address_state: 'CA', + address_zip: '94102', + address_country: 'US', + email: 'melgarstaff@sfgov.org', + }, + { + name: 'Rafael Mandelman', + title: 'District 8 Supervisor', + photoUrl: 'https://sfbos.org/sites/default/files/Supervisor_Mandelman_2018.jpg', + address_line1: '1 Dr Carlton B Goodlett Pl', + address_line2: '#244', + address_city: 'San Francisco', + address_state: 'CA', + address_zip: '94102', + address_country: 'US', + email: 'mandelmanstaff@sfgov.org', + }, + { + name: 'Hillary Ronen', + title: 'District 9 Supervisor', + photoUrl: 'https://sfbos.org/sites/default/files/Supervisor_Ronen_2019.jpg', + address_line1: '1 Dr Carlton B Goodlett Pl', + address_line2: '#244', + address_city: 'San Francisco', + address_state: 'CA', + address_zip: '94102', + address_country: 'US', + email: 'RonenStaff@sfgov.org', + }, + { + name: 'Shamann Walton', + title: 'District 10 Supervisor', + photoUrl: 'https://sfbos.org/sites/default/files/Shamann_Walton_2023.jpg', + address_line1: '1 Dr Carlton B Goodlett Pl', + address_line2: '#244', + address_city: 'San Francisco', + address_state: 'CA', + address_zip: '94102', + address_country: 'US', + email: 'waltonstaff@sfgov.org', + }, + { + name: 'Ahsha Safai', + title: 'District 11 Supervisor', + photoUrl: 'https://sfbos.org/sites/default/files/Ahsha_Safai_2023.jpg', + address_line1: '1 Dr Carlton B Goodlett Pl', + address_line2: '#244', + address_city: 'San Francisco', + address_state: 'CA', + address_zip: '94102', + address_country: 'US', + email: 'Ahsha.Safai@sfgov.org', + } + ]), + assets: JSON.stringify({ + campaign_logo: 'https://images.squarespace-cdn.com/content/v1/5eebc0039b04b54b2fb0ce52/1598500758914-E5HAIIGCP0ZXKKMN2FT0/TRT+Logo-13.png?format=500w', + campaign_background: 'https://images.squarespace-cdn.com/content/v1/5eebc0039b04b54b2fb0ce52/1610135975708-FV42Q30BPWF887M05O51/Poppies-13.jpg?format=1500w', + 'campaign-img-1': 'https://images.squarespace-cdn.com/content/v1/5eebc0039b04b54b2fb0ce52/1609785926325-63M9MY247ORIL8ON8P5E/tuolumne-camp-960x540.jpg?format=1500w', + 'campaign-img-2': 'https://images.squarespace-cdn.com/content/v1/5eebc0039b04b54b2fb0ce52/1592586646372-TBY3WC065NJ04KT2B38Z/000091400001.jpg?format=1500w', + 'campaign-img-3': 'https://images.squarespace-cdn.com/content/v1/5eebc0039b04b54b2fb0ce52/1609781359705-Q5YNFL6DET5ORHW62ZU7/IMG_2249_WEBSITE+copy.jpg?format=1500w', + }) } ]) } diff --git a/server/db/seeds/development/seed-constituents-table.js b/server/db/seeds/development/seed-constituents-table.js new file mode 100644 index 000000000..d817cffa3 --- /dev/null +++ b/server/db/seeds/development/seed-constituents-table.js @@ -0,0 +1,26 @@ +module.exports = { + async seed(knex) { + await knex('constituents').del() + + await knex('constituents').insert([ + { + email: 'tester@gmail.com', + first_name: 'Baby', + last_name: 'Cakes', + address_line_1: '123 Fake St', + city: 'Chicago', + state: 'IL', + zip: '60618' + }, + { + email: 'seadog@gmail.com', + first_name: 'Fish', + last_name: 'Cakes', + address_line_1: '420 Lakeview Terrace', + city: 'Grand Rapids', + state: 'MI', + zip: '49501' + } + ]) + } +} diff --git a/server/db/seeds/development/seed-letter_versions-table.js b/server/db/seeds/development/seed-letter_versions-table.js deleted file mode 100644 index 7cfe6951d..000000000 --- a/server/db/seeds/development/seed-letter_versions-table.js +++ /dev/null @@ -1,55 +0,0 @@ -module.exports = { - async seed(knex) { - // Deletes ALL existing entries - await knex('letter_versions').del() - - // Inserts seed entries - await knex('letter_versions').insert([ - { - id: 1, - template_id: 'tmpl_1057bb6f50f81fb', - campaign_id: 1, - office_division: 'Federal', - state: null, - county: null, - municipality: null - }, - { - id: 2, - template_id: 'tmpl_9e6109bc1a3f946', - campaign_id: 2, - office_division: 'State', - state: 'NY', - county: null, - municipality: null - }, - { - id: 3, - template_id: 'tmpl_89271b28e7205a0', - campaign_id: 3, - office_division: 'State', - state: 'NY', - county: null, - municipality: null - }, - { - id: 4, - template_id: 'tmpl_85417656223f2dd', - campaign_id: 4, - office_division: 'State', - state: 'NY', - county: null, - municipality: null - }, - { - id: 5, - template_id: 'tmpl_7aaf42aada49f44', - campaign_id: 5, - office_division: 'State', - state: 'CA', - county: null, - municipality: null - } - ]) - } -} diff --git a/server/db/seeds/development/seed-transactions-table.js b/server/db/seeds/development/seed-transactions-table.js new file mode 100644 index 000000000..b1a8d8d87 --- /dev/null +++ b/server/db/seeds/development/seed-transactions-table.js @@ -0,0 +1,24 @@ +module.exports = { + async seed(knex) { + await knex('transactions').del() + + await knex('transactions').insert([ + { + stripe_transaction_id: 'pi_test-success-1234', + amount: 10000, + currency: 'usd', + status: null, + payment_method: 'credit_card', + constituent_id: 1 + }, + { + stripe_transaction_id: 'pi_test-failed-5678', + amount: 5000, + currency: 'usd', + status: null, + payment_method: 'credit_card', + constituent_id: 2 + } + ]) + } +} diff --git a/server/lib/stripe.js b/server/lib/stripe.js new file mode 100644 index 000000000..0463c6bb9 --- /dev/null +++ b/server/lib/stripe.js @@ -0,0 +1,83 @@ +// Wrappers for Stripe's npm package +require('dotenv').config() +const stripe = require('stripe') + +class StripeError extends Error { + constructor(message) { + super(message) + this.name = 'StripeError' + } +} + +class Stripe { + constructor() { + this.stripeSecret = process.env.STRIPE_SECRET_KEY + this.stripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET + this.livemode = process.env.STRIPE_LIVE_MODE + this.stripe = stripe(this.stripeSecret) + } + + /** + * Checks if Stripe is set to 'live' mode. It should be true in production, but false otherwise. + * Think of this as a server-side analog to the 'livemode' attribute on mode Stripe objects. In fact, + * an event object's livemode value and this method should always match. + */ + livemode() { + return this.livemode === 'true' + } + + /** + * Validates that an event actually comes from Stripe. Returns event or throws StripeError. + * @param {string} signature - Stripe signature header. + * @param {object} rawBody - Requests rawBody attribute. + */ + validateEvent(signature, rawBody) { + try { + return this.stripe.webhooks.constructEvent( + rawBody, + signature, + this.stripeWebhookSecret + ) + } catch (error) { + throw new StripeError(error.message) + } + } + + /** + * Creates a checkout session and returns the url and session id. + * @param {number} donationAmount - Donation amount (in cents). + * @param {string} customerEmail - For (optionally) pre-filling customer email on checkout page. + */ + async createCheckoutSession(donationAmount, redirectUrl, cancelUrl) { + try { + const session = await this.stripe.checkout.sessions.create({ + line_items: [ + { + price_data: { + currency: 'usd', + product_data: { + name: 'Donation' + }, + unit_amount: donationAmount + }, + quantity: 1 + } + ], + mode: 'payment', + allow_promotion_codes: true, + success_url: redirectUrl, + cancel_url: cancelUrl + }) + + return { + url: session.url, + id: session.id, + paymentIntent: session.payment_intent + } + } catch (error) { + throw new StripeError(error.message) + } + } +} + +module.exports = { Stripe, StripeError } diff --git a/server/routes/api/checkout.js b/server/routes/api/checkout.js index e82dc1fe3..6d8926077 100644 --- a/server/routes/api/checkout.js +++ b/server/routes/api/checkout.js @@ -1,97 +1,140 @@ const express = require('express') -const { createClient } = require('../../db') +const { Stripe, StripeError } = require('../../lib/stripe') +const { + PaymentPresenter, + PaymentPresenterError +} = require('../../../shared/presenters/payment-presenter') +const Constituent = require('../../db/models/constituent') +const Transaction = require('../../db/models/transaction') +const Letter = require('../../db/models/letter') + const router = express.Router() -const db = createClient() -const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY) -const { formatDonationAmount } = require('../../../util/format') -const { validateDonationAmount } = require('../../../util/validate') - -router.post('/create-transaction', async (req, res) => { - const { sessionId /*, email /*, campaignId, donationId */ } = req.body || {} - if (!sessionId /*|| !email*/) { - return res.status(400).send({ error: 'No session ID' }) - } - const session = await stripe.checkout.sessions.retrieve(sessionId) - - const formattedTransaction = { - stripe_transaction_id: sessionId, - amount: session.amount_total, - currency: session.currency, - payment_method: 'something not empty', // Not sure what this is for - payment_method_type: session.payment_method_types[0], - email: session.customer_details.email // to-do: get user email from the server auth, if possible + +class CheckoutError extends Error { + constructor(message) { + super(message) + this.name = 'CheckoutError' } +} + +router.post('/create-checkout-session', async (req, res) => { + const { donation, user, letter } = req.body + const origin = req.get('origin') + + console.log(`origin: ${origin}`) try { - // Expire session? - await db('transactions').insert(formattedTransaction) - res.status(200).send(formattedTransaction) + const presenter = new PaymentPresenter() + + // Will throw error if invalid amount is given. + presenter.validatePaymentAmount(donation) + + // TODO: Should be strict https but we need to do some deployment fixes first. + const redirectUrl = `${origin}/complete?session_id={CHECKOUT_SESSION_ID}` + const cancelUrl = origin + + const stripe = new Stripe() + const session = await stripe.createCheckoutSession( + donation, + redirectUrl, + cancelUrl + ) + + // These objects must be recorded in a specific order: + // constituent, then transaction, then letter + // This is because letter needs id from constituent and transaction! + + // TODO: Move Constituent insert to earlier in the cycle. + let constituent + ;[constituent] = await Constituent.query().where('email', user.email) + if (!constituent) { + constituent = await Constituent.query().insert(user) + } + + console.log(constituent.id) + + const transaction = await Transaction.query().insert({ + stripeTransactionId: session.paymentIntent, + constituentId: constituent.id, + amount: donation, + currency: 'usd', + paymentMethod: 'credit_card' + }) + + // Using a temporary mapping here also + await Letter.query().insert({ + transactionId: transaction.id, + constituentId: constituent.id, + ...letter + }) + + return res + .status(200) + .json({ url: session.url, sessionId: session.id }) + .end() } catch (error) { - console.log({ error }) - res.status(400).send() - } + let statusCode = 500 - return res.status(200).end() + if (error instanceof PaymentPresenterError) { + statusCode = 400 + } + + console.error(error) + + // TODO: error logging + return res.status(statusCode).json({ error: error.message }).end() + } }) -// 1. send a request to `/create-payment-intent` -// with a `donationAmount` as string or integer -// If user doesn't select any particular `donationAmount`, send `1` in the donationAmount -// 2. This API will redirect the client to a Stripe Checkout page -// 3. Once user completes payment, will redirect back to `success_url` with -// a Stripe session_id included in the URL. +router.post('/process-transaction', async (req, res) => { + try { + const stripe = new Stripe() -router.post('/create-checkout-session', async (req, res) => { - const { donationAmount } = req.body || {} - const origin = req.get('origin') + // If livemode is false, disable signature checking + // and event reconstructionfor ease of testing. + let event + if (stripe.livemode) { + const signature = req.headers['stripe-signature'] + if (!signature) throw new CheckoutError('No stripe signature on request!') - const input = formatDonationAmount(donationAmount) - const inputIsValid = validateDonationAmount(input) - - if (inputIsValid) { - const donationAmountForStripe = input * 100 // Stripe accepts values in cents - let session - - try { - session = await stripe.checkout.sessions.create({ - line_items: [ - { - price_data: { - currency: 'usd', - product_data: { - name: 'Donation' - }, - unit_amount: donationAmountForStripe - }, - quantity: 1 - } - ], - mode: 'payment', - allow_promotion_codes: true, - success_url: origin + '/complete?session_id={CHECKOUT_SESSION_ID}', - cancel_url: origin - }) - } catch (error) { - const data = { - type: error.type, - code: error.raw.code, - url: error.raw.doc_url, - message: 'An error occurred with Stripe checkout', - entire_error_object: error - } - - console.log(data) - return res.status(500).json(data) + event = stripe.validateEvent(signature, req.rawBody) + } else { + event = req.body + console.log(event) } - // console.log('session:', session) - - // the redirection happens within `DonateMoney.vue` - return res.status(200).json({ url: session.url, sessionId: session.id }) - } else { - return res.status(400).send({ - error: 'Bad request: did not create Stripe checkout session', - message: 'Check backend console for possible failing reasons' - }) + + const data = event.data + const { id: paymentIntent, amount } = data.object + const [eventType, eventOutcome] = req.body.type.split('.') + + // We are not going to send letters from here just yet + // so we will record the transaction no matter the outcome. + if (eventType !== 'payment_intent') { + throw new CheckoutError( + `Unexpected event! Received ${eventType} but it could not be processed.` + ) + } + + await Transaction.query() + .patch({ amount, status: eventOutcome }) + .where({ stripe_transaction_id: paymentIntent }) + + return res.status(200).end() + } catch (error) { + let statusCode = 500 + + if (error instanceof CheckoutError) { + statusCode = 400 + console.error(error.message) + } + + if (error instanceof StripeError) { + // Don't leak Stripe logging. + console.error(error.message) + error.message = 'Payment processing error' + } + + return res.status(statusCode).json({ error: error.message }).end() } }) diff --git a/shared/presenters/payment-presenter.js b/shared/presenters/payment-presenter.js new file mode 100644 index 000000000..1ea9774ee --- /dev/null +++ b/shared/presenters/payment-presenter.js @@ -0,0 +1,56 @@ +// Business logic for donations and payments +require('dotenv').config + +class PaymentPresenterError extends Error { + constructor(message) { + super(message) + this.name = 'PaymentPresenterError' + } +} + +class PaymentPresenter { + constructor() { + this.minimumPayment = process.env.MINIMUM_PAYMENT_AMOUNT + this.maximumPayment = process.env.MAXIMUM_PAYMENT_AMOUNT + } + + /** + * Validates that payments aren't something weird, like NaN or a very large number. + * @param {number} payment + */ + validatePaymentAmount(payment) { + if (typeof payment !== 'number') { + throw new PaymentPresenterError('Payment is not a number') + } + + if (payment < this.minimumPayment || payment > this.maximumPayment) { + throw new PaymentPresenterError('Payment amount is out of range') + } + } + + /** + * Formats a value to cents, given a number, float, or numerical string + * @param {any} payment + * @returns {number} payment in cents + */ + formatPaymentAmount(payment) { + if (typeof payment == 'string') { + const nonNumerics = new RegExp(/[a-z.,]/, 'g') // Strips alphanumerics, commas, and periods out. + + payment = payment.replace(nonNumerics, '') + payment = Number(payment) + + if (isNaN(payment)) { + throw new PaymentPresenterError('Amount is in unexpected format') + } + } + + if (typeof payment == 'object') { + throw new PaymentPresenterError('Unparsable argument') + } + + return payment + } +} + +module.exports = { PaymentPresenter, PaymentPresenterError } diff --git a/src/components/ActionComplete.vue b/src/components/ActionComplete.vue index 9900b2104..06a7fa4fc 100644 --- a/src/components/ActionComplete.vue +++ b/src/components/ActionComplete.vue @@ -1,15 +1,6 @@ - +- {{ flavorText }} +