├── .dockerignore ├── .eslintrc.js ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .husky └── pre-commit ├── LICENSE ├── README.md ├── apps ├── api │ ├── .eslintrc.js │ ├── Dockerfile │ ├── codegen.yml │ ├── package.json │ ├── src │ │ ├── app.ts │ │ ├── config.ts │ │ ├── datasources │ │ │ ├── CivicAPI.ts │ │ │ ├── KnowledgeGraphAPI.ts │ │ │ ├── OpenSecretsAPI.ts │ │ │ ├── ProPublicaAPI.ts │ │ │ └── index.ts │ │ ├── entity │ │ │ ├── CivicAPI.entity.ts │ │ │ ├── KnowledgeGraphAPI.entity.ts │ │ │ ├── OpenSecretsAPI.entity.ts │ │ │ └── ProPublicaAPI.entity.ts │ │ ├── resolvers │ │ │ ├── IntegraResolver.ts │ │ │ └── index.ts │ │ ├── schema.ts │ │ ├── services │ │ │ ├── index.ts │ │ │ └── mongoDB.ts │ │ ├── types │ │ │ └── index.ts │ │ └── utils │ │ │ ├── appError.ts │ │ │ ├── errorInterceptor.ts │ │ │ ├── index.ts │ │ │ ├── logger.ts │ │ │ └── startServer.ts │ └── tsconfig.json └── client │ ├── .eslintrc.cjs │ ├── export-images.config.js │ ├── next-env.d.ts │ ├── next-sitemap.config.js │ ├── next.config.js │ ├── package.json │ ├── public │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── robots.txt │ ├── site.webmanifest │ ├── sitemap-0.xml │ └── sitemap.xml │ ├── src │ ├── assets │ │ ├── 404.webp │ │ ├── index.ts │ │ ├── logo.svg │ │ ├── logo.webp │ │ └── state_flags │ │ │ ├── ak.svg │ │ │ ├── al.svg │ │ │ ├── ar.svg │ │ │ ├── as.svg │ │ │ ├── az.svg │ │ │ ├── ca.svg │ │ │ ├── co.svg │ │ │ ├── ct.svg │ │ │ ├── dc.svg │ │ │ ├── de.svg │ │ │ ├── fl.svg │ │ │ ├── ga.svg │ │ │ ├── gu.svg │ │ │ ├── hi.svg │ │ │ ├── ia.svg │ │ │ ├── id.svg │ │ │ ├── il.svg │ │ │ ├── in.svg │ │ │ ├── index.ts │ │ │ ├── ks.svg │ │ │ ├── ky.svg │ │ │ ├── la.svg │ │ │ ├── ma.svg │ │ │ ├── md.svg │ │ │ ├── me.svg │ │ │ ├── mi.svg │ │ │ ├── mn.svg │ │ │ ├── mo.svg │ │ │ ├── ms.svg │ │ │ ├── mt.svg │ │ │ ├── nc.svg │ │ │ ├── nd.svg │ │ │ ├── ne.svg │ │ │ ├── nh.svg │ │ │ ├── nj.svg │ │ │ ├── nm.svg │ │ │ ├── nv.svg │ │ │ ├── ny.svg │ │ │ ├── oh.svg │ │ │ ├── ok.svg │ │ │ ├── or.svg │ │ │ ├── pa.svg │ │ │ ├── pr.svg │ │ │ ├── ri.svg │ │ │ ├── sc.svg │ │ │ ├── sd.svg │ │ │ ├── tn.svg │ │ │ ├── tx.svg │ │ │ ├── um.svg │ │ │ ├── ut.svg │ │ │ ├── va.svg │ │ │ ├── vi.svg │ │ │ ├── vt.svg │ │ │ ├── wa.svg │ │ │ ├── wi.svg │ │ │ ├── wv.svg │ │ │ └── wy.svg │ ├── components │ │ ├── Card │ │ │ ├── Card.tsx │ │ │ ├── DetailsCard.tsx │ │ │ ├── IconCard.tsx │ │ │ ├── OfficialCard.tsx │ │ │ ├── StatCard.tsx │ │ │ └── index.ts │ │ ├── Chart │ │ │ ├── BarChart.tsx │ │ │ ├── HorizontalBarChart.tsx │ │ │ ├── PieChart.tsx │ │ │ ├── ScatterChart.tsx │ │ │ ├── Stat.tsx │ │ │ ├── Table.tsx │ │ │ ├── index.ts │ │ │ └── utils │ │ │ │ └── colors.ts │ │ ├── Form │ │ │ ├── AutocompleteInput.tsx │ │ │ ├── LocationInput.tsx │ │ │ ├── OfficialInput.tsx │ │ │ ├── Select.tsx │ │ │ └── index.ts │ │ ├── Icon │ │ │ ├── Icon.tsx │ │ │ ├── SocialMediaIcon.tsx │ │ │ ├── StateIcon.tsx │ │ │ └── index.ts │ │ ├── Layout │ │ │ ├── Breadcrumbs.tsx │ │ │ ├── Footer.tsx │ │ │ ├── Layout.tsx │ │ │ ├── NavLink.tsx │ │ │ ├── Navbar.tsx │ │ │ ├── Sidebar.tsx │ │ │ ├── Tabs.tsx │ │ │ └── index.ts │ │ ├── Misc │ │ │ ├── Avatar.tsx │ │ │ ├── Error.tsx │ │ │ ├── ErrorBoundary.tsx │ │ │ ├── Head.tsx │ │ │ ├── Image.tsx │ │ │ ├── Link.tsx │ │ │ ├── Loading.tsx │ │ │ ├── ToggleDarkMode.tsx │ │ │ └── index.ts │ │ └── Official │ │ │ ├── Fundraising.tsx │ │ │ ├── Legislation.tsx │ │ │ ├── OfficialDetails.tsx │ │ │ ├── OfficialHeader.tsx │ │ │ ├── Overview.tsx │ │ │ ├── Stats │ │ │ ├── About.tsx │ │ │ ├── BillsIntroducedTable.tsx │ │ │ ├── Contact.tsx │ │ │ ├── Description.tsx │ │ │ ├── FinancialInfo.tsx │ │ │ ├── IndustryDonationsChart.tsx │ │ │ ├── NetWorth.tsx │ │ │ ├── Office.tsx │ │ │ ├── PolarizationChart.tsx │ │ │ ├── Positions.tsx │ │ │ ├── SocialMedia.tsx │ │ │ ├── VotedWithPartyChart.tsx │ │ │ └── index.ts │ │ │ └── index.ts │ ├── hooks │ │ ├── index.ts │ │ ├── useEventListener.tsx │ │ ├── useLocalStorage.tsx │ │ ├── useOfficialData.tsx │ │ ├── useOfficialId.tsx │ │ └── useScript.tsx │ ├── main.css │ ├── pages │ │ ├── 404.tsx │ │ ├── _app.tsx │ │ ├── _document.tsx │ │ ├── _error.tsx │ │ ├── index.tsx │ │ ├── official │ │ │ └── [slug].tsx │ │ └── officials │ │ │ └── index.tsx │ ├── types │ │ ├── graphql.tsx │ │ └── index.ts │ └── utils │ │ ├── apolloClient.ts │ │ ├── convertToUSD.ts │ │ ├── fetcher.ts │ │ ├── index.ts │ │ ├── logger.ts │ │ ├── theme.ts │ │ └── truncateLink.ts │ └── tsconfig.json ├── docker-compose.yml ├── fly.toml ├── package.json ├── packages ├── database │ ├── .eslintrc.js │ ├── package.json │ ├── prisma │ │ └── schema.prisma │ ├── src │ │ ├── client.ts │ │ ├── index.ts │ │ └── schema.ts │ ├── tsconfig.json │ └── tsup.config.ts ├── eslint-config-custom-server │ ├── index.js │ └── package.json ├── eslint-config-custom │ ├── index.js │ └── package.json └── tsconfig │ ├── base.json │ ├── graphql.json │ ├── nextjs.json │ └── package.json ├── turbo.json └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | apps/client -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ["custom"], 4 | settings: { 5 | next: { 6 | rootDir: ["apps/*/"], 7 | }, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | types: [opened, synchronize] 8 | 9 | jobs: 10 | build: 11 | name: Build and Test 12 | timeout-minutes: 15 13 | runs-on: "${{ matrix.os }}" 14 | env: 15 | TURBO_TOKEN: "${{ secrets.TURBO_TOKEN }}" 16 | TURBO_TEAM: "${{ secrets.TURBO_TEAM }}" 17 | TURBO_REMOTE_ONLY: true 18 | SKIP_BUILD_STATIC_GENERATION: true 19 | strategy: 20 | matrix: 21 | os: 22 | - ubuntu-latest 23 | - macos-latest 24 | steps: 25 | - name: Check out code 26 | uses: actions/checkout@v3 27 | with: 28 | fetch-depth: 2 29 | - name: Setup Node.js environment 30 | uses: actions/setup-node@v3 31 | with: 32 | node-version: 16 33 | cache: npm 34 | - name: Install dependencies 35 | run: npm install 36 | - name: Build 37 | run: npm run build 38 | 39 | deploy-server: 40 | name: Deploy server 41 | timeout-minutes: 15 42 | env: 43 | FLY_API_TOKEN: "${{ secrets.FLY_API_TOKEN }}" 44 | TURBO_TOKEN: "${{ secrets.TURBO_TOKEN }}" 45 | TURBO_TEAM: "${{ secrets.TURBO_TEAM }}" 46 | TURBO_REMOTE_ONLY: true 47 | runs-on: ubuntu-latest 48 | steps: 49 | - uses: actions/checkout@v3 50 | - uses: superfly/flyctl-actions/setup-flyctl@master 51 | - run: flyctl deploy --remote-only 52 | 53 | deploy-client: 54 | name: Deploy client 55 | needs: deploy-server 56 | timeout-minutes: 15 57 | runs-on: ubuntu-latest 58 | steps: 59 | - name: Invoke deployment hook on Cloudflare Pages 60 | uses: joelwmale/webhook-action@master 61 | with: 62 | url: "${{ secrets.CLOUDFLARE_WEBHOOK_URL }}" 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | node_modules 3 | .pnp 4 | .pnp.js 5 | 6 | # testing 7 | coverage 8 | 9 | # build 10 | build 11 | dist 12 | tsconfig.tsbuildinfo 13 | .next 14 | target 15 | 16 | # misc 17 | .DS_Store 18 | *.pem 19 | 20 | # debug 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | .pnpm-debug.log* 25 | 26 | # env files 27 | .env 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | #yarn 34 | .pnp.* 35 | .yarn/* 36 | !.yarn/patches 37 | !.yarn/plugins 38 | !.yarn/releases 39 | !.yarn/sdks 40 | !.yarn/versions 41 | 42 | # turbo 43 | .turbo 44 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn lint-staged -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Jeremy Thinh Nguyen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Integra 2 | 3 | ![GitHub Actions status](https://github.com/jeremynguyencs/integra/actions/workflows/ci.yml/badge.svg) 4 | 5 | Integra is a web application for learning about your local politicians. It is built with a [React](https://reactjs.org) and [Next.js](https://nextjs.org) front end, an [Express](https://expressjs.com) Node.js [GraphQL](https://graphql.org) backend API, and a [MongoDB](https://www.mongodb.com) database with a [Prisma](https://www.prisma.io). 6 | 7 | The web application is located at [integra.vote](https://integra.vote) and hosted with [Cloudflare Pages](https://pages.cloudflare.com). The GraphQL Express API server is hosted on [api.integra.vote](https://api.integra.vote/graphql) with [fly.io](https://fly.io). 8 | 9 | ## Installation 10 | 11 | ```bash 12 | git clone https://github.com/jeremynguyencs/integra 13 | cd integra 14 | yarn 15 | ``` 16 | 17 | ## Usage 18 | 19 | ```bash 20 | # Run the development server locally 21 | yarn dev 22 | 23 | # Lint and format code 24 | yarn lint 25 | yarn format 26 | 27 | # Build 28 | yarn build 29 | ``` 30 | 31 | ## License 32 | 33 | [MIT](https://choosealicense.com/licenses/mit) 34 | -------------------------------------------------------------------------------- /apps/api/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ["custom-server"], 4 | }; 5 | -------------------------------------------------------------------------------- /apps/api/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine AS builder 2 | RUN apk update 3 | # Set working directory 4 | WORKDIR /app 5 | RUN yarn global add turbo 6 | COPY . . 7 | RUN turbo prune --scope=api --docker 8 | 9 | # Add lockfile and package.json's of isolated subworkspace 10 | FROM node:alpine AS installer 11 | RUN apk update 12 | WORKDIR /app 13 | COPY --from=builder /app/out/json/ . 14 | COPY --from=builder /app/out/yarn.lock ./yarn.lock 15 | COPY --from=builder /app/out/full/ . 16 | COPY .gitignore .gitignore 17 | COPY turbo.json turbo.json 18 | 19 | RUN yarn install 20 | RUN yarn turbo run build --filter=api... 21 | 22 | FROM node:alpine AS runner 23 | WORKDIR /app 24 | 25 | # Don't run production as root 26 | RUN addgroup --system --gid 1001 expressjs 27 | RUN adduser --system --uid 1001 expressjs 28 | USER expressjs 29 | COPY --from=installer /app . 30 | 31 | CMD node apps/api/dist/app.js -------------------------------------------------------------------------------- /apps/api/codegen.yml: -------------------------------------------------------------------------------- 1 | overwrite: true 2 | schema: "http://localhost:8080/graphql" 3 | generates: 4 | src/generated/graphql.tsx: 5 | plugins: 6 | - "typescript" 7 | - "typescript-operations" 8 | - "typescript-resolvers" 9 | - "typescript-react-apollo" 10 | ./graphql.schema.json: 11 | plugins: 12 | - "introspection" 13 | -------------------------------------------------------------------------------- /apps/api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api", 3 | "private": true, 4 | "version": "0.0.0", 5 | "scripts": { 6 | "start": "node ./dist/app.js", 7 | "build": "tsc", 8 | "dev": "nodemon src/app.ts", 9 | "lint": "eslint --ext .ts ./src", 10 | "codegen": "graphql-codegen --config codegen.yml" 11 | }, 12 | "dependencies": { 13 | "apollo-datasource-rest": "^3.7.0", 14 | "apollo-server-express": "^3.10.2", 15 | "class-validator": "^0.13.2", 16 | "cors": "^2.8.5", 17 | "database": "*", 18 | "dotenv": "^16.0.2", 19 | "express": "^4.18.1", 20 | "express-rate-limit": "^6.6.0", 21 | "graphql": "^15.3.0", 22 | "graphql-fields": "^2.0.3", 23 | "graphql-scalars": "^1.18.0", 24 | "helmet": "^6.0.0", 25 | "mongodb": "^4.10.0", 26 | "pino": "^8.6.0", 27 | "pino-pretty": "^9.1.0", 28 | "reflect-metadata": "^0.1.13", 29 | "tslib": "^2.4.0", 30 | "type-graphql": "^1.1.1" 31 | }, 32 | "devDependencies": { 33 | "@graphql-codegen/cli": "2.12.1", 34 | "@types/cors": "^2.8.12", 35 | "@types/express": "^4.17.14", 36 | "@types/graphql-fields": "^1.3.4", 37 | "@types/node": "^18.7.18", 38 | "eslint-config-custom-server": "*", 39 | "nodemon": "^2.0.20", 40 | "ts-node": "^10.9.1", 41 | "tsconfig": "*", 42 | "@graphql-codegen/typescript-resolvers": "2.7.3", 43 | "@graphql-codegen/typescript-operations": "2.5.3", 44 | "@graphql-codegen/typescript-react-apollo": "3.3.3", 45 | "@graphql-codegen/typescript": "2.7.3", 46 | "@graphql-codegen/introspection": "2.2.1" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /apps/api/src/app.ts: -------------------------------------------------------------------------------- 1 | import "reflect-metadata"; 2 | import "dotenv/config"; 3 | 4 | import { ApolloServer, ExpressContext } from "apollo-server-express"; 5 | import { EventEmitter } from "events"; 6 | 7 | import type { Application } from "express"; 8 | 9 | import { schema } from "./schema"; 10 | import { PORT } from "./config"; 11 | import { prisma } from "database"; 12 | import { startServer, logger } from "./utils"; 13 | import dataSources from "./datasources"; 14 | import { mongoDB } from "./services"; 15 | 16 | /** 17 | * Express application wrapper class to centralize initialization 18 | */ 19 | class App extends EventEmitter { 20 | public app: Application; 21 | 22 | constructor() { 23 | super(); 24 | this.app = startServer(); 25 | this.setupApollo(); 26 | } 27 | 28 | listen() { 29 | this.app.listen(PORT, () => { 30 | logger.info(`⚡ Server is listening on port ${PORT}`); 31 | }); 32 | } 33 | 34 | async setupApollo() { 35 | const apolloServer = new ApolloServer({ 36 | schema, 37 | dataSources, 38 | context: ({ req, res }: ExpressContext) => ({ 39 | req, 40 | res, 41 | prisma, 42 | mongoDB: new mongoDB("integra", "official"), 43 | }), 44 | plugins: [logger], 45 | csrfPrevention: true, 46 | cache: "bounded", 47 | }); 48 | await apolloServer.start(); 49 | apolloServer.applyMiddleware({ app: this.app, cors: true }); 50 | } 51 | } 52 | 53 | new App().listen(); 54 | -------------------------------------------------------------------------------- /apps/api/src/config.ts: -------------------------------------------------------------------------------- 1 | // ENV VARIABLES 2 | export const { 3 | PORT = 8080, 4 | DATABASE_URL = "mongodb://localhost:27017", 5 | GOOGLE_CIVIC_API_KEY = "", 6 | GOOGLE_KNOWLEDGE_GRAPH_API_KEY = "", 7 | OPEN_SECRETS_API_KEY = "", 8 | PROPUBLICA_API_KEY = "", 9 | NODE_ENV = "development", 10 | } = process.env; 11 | 12 | export const isDevelopment = NODE_ENV === "development"; 13 | -------------------------------------------------------------------------------- /apps/api/src/datasources/CivicAPI.ts: -------------------------------------------------------------------------------- 1 | import { RESTDataSource, RequestOptions } from "apollo-datasource-rest"; 2 | import { 3 | CivicAPIResponse, 4 | CivicOfficial, 5 | Office, 6 | } from "../entity/CivicAPI.entity"; 7 | 8 | import { GOOGLE_CIVIC_API_KEY } from "../config"; 9 | 10 | class CivicAPI extends RESTDataSource { 11 | constructor() { 12 | super(); 13 | this.baseURL = "https://www.googleapis.com/civicinfo/v2/"; 14 | } 15 | 16 | willSendRequest(request: RequestOptions) { 17 | request.params.set("key", GOOGLE_CIVIC_API_KEY); 18 | request.params.set("levels", "country"); 19 | } 20 | 21 | // Given an address, return an array of the congresspeople in that district 22 | async getRepresentatives(address: string): Promise { 23 | const senate = this.getRepresentative(address, "legislatorUpperBody"); 24 | const house = this.getRepresentative(address, "legislatorLowerBody"); 25 | 26 | return await Promise.all([senate, house]).then(([senate, house]) => { 27 | // Returns empty array if it doesn't exist, as some regions don't have senators (e.g. Washington D.C.) 28 | const senators = senate?.officials ?? []; 29 | const representatives = house?.officials ?? []; 30 | return [...senators, ...representatives]; 31 | }); 32 | } 33 | 34 | // Given an address and a role, return an array of officials 35 | async getRepresentative(address: string, roles: string) { 36 | return this.get("representatives", { 37 | address, 38 | roles, 39 | }); 40 | } 41 | 42 | async getOffices(address: string): Promise { 43 | const { offices } = await this.getResponse(address); 44 | return offices; 45 | } 46 | 47 | // Get response from Google Civic API 48 | async getResponse( 49 | address: string, 50 | params?: { [key: string]: Object | Object[] | undefined } 51 | ): Promise { 52 | const res = await this.get("representatives", { 53 | address, 54 | ...params, 55 | }); 56 | 57 | // Flattening the division object into an array of objects with the ocdId as the key 58 | const divisions = Object.keys(res?.divisions).map((key) => { 59 | return { 60 | ocdID: key, 61 | ...res.divisions[key], 62 | }; 63 | }); 64 | return { 65 | ...res, 66 | divisions: [].concat(...divisions), 67 | }; 68 | } 69 | } 70 | 71 | export default CivicAPI; 72 | -------------------------------------------------------------------------------- /apps/api/src/datasources/KnowledgeGraphAPI.ts: -------------------------------------------------------------------------------- 1 | import { RequestOptions, RESTDataSource } from "apollo-datasource-rest"; 2 | 3 | import { GOOGLE_KNOWLEDGE_GRAPH_API_KEY } from "../config"; 4 | import { KnowledgeGraphAPIResponse } from "../entity/KnowledgeGraphAPI.entity"; 5 | 6 | class KnowledgeGraphAPI extends RESTDataSource { 7 | constructor() { 8 | super(); 9 | this.baseURL = "https://kgsearch.googleapis.com/v1/entities:search"; 10 | } 11 | 12 | willSendRequest(request: RequestOptions) { 13 | request.params.set("key", GOOGLE_KNOWLEDGE_GRAPH_API_KEY); 14 | request.params.set("indent", "true"); 15 | request.params.set("languages", "en"); 16 | } 17 | 18 | async getResponse( 19 | id: string, 20 | limit = 10 21 | ): Promise { 22 | const res = await this.get("", { ids: id, limit }); 23 | // Google's API returns JSON where some keys start with @, removed the @ 24 | return JSON.parse(JSON.stringify(res).replace(/@/g, "")); 25 | } 26 | } 27 | 28 | export default KnowledgeGraphAPI; 29 | -------------------------------------------------------------------------------- /apps/api/src/datasources/OpenSecretsAPI.ts: -------------------------------------------------------------------------------- 1 | import { RESTDataSource, RequestOptions } from "apollo-datasource-rest"; 2 | 3 | import type { 4 | Legislator, 5 | MemPFDProfile, 6 | candIndustry, 7 | candSummary, 8 | } from "../entity/OpenSecretsAPI.entity"; 9 | import { OPEN_SECRETS_API_KEY } from "../config"; 10 | 11 | class OpenSecretsAPI extends RESTDataSource { 12 | constructor() { 13 | super(); 14 | this.baseURL = "http://www.opensecrets.org/api/"; 15 | } 16 | 17 | willSendRequest(request: RequestOptions) { 18 | request.params.set("apikey", OPEN_SECRETS_API_KEY!); 19 | request.params.set("output", "json"); 20 | } 21 | 22 | async getLegislators(id: string): Promise { 23 | const res = JSON.parse( 24 | await this.get("", { 25 | method: "getLegislators", 26 | id, 27 | }) 28 | ); 29 | // the Legislator is returned in the response.response.legislator array in its property '@attributes' 30 | return flatten(res.response.legislator); 31 | } 32 | 33 | async memPFDprofile(id: string): Promise { 34 | const res = JSON.parse( 35 | await this.get("", { 36 | method: "memPFDprofile", 37 | cid: id, 38 | }) 39 | ).response.member_profile; 40 | 41 | const assets = res.assets?.asset; 42 | const transactions = res.transactions?.transaction; 43 | const positions = res.positions?.position; 44 | 45 | // API returns empty string instead of undefined for null values 46 | // instead of empty string "", return undefined 47 | const member_profile = Object.fromEntries( 48 | // eslint-disable-next-line no-unused-vars 49 | Object.entries(res["@attributes"]).filter(([_key, value]) => value !== "") 50 | ); 51 | 52 | const profile = { 53 | member_profile, 54 | asset: flatten(assets), 55 | transaction: flatten(transactions), 56 | position: flatten(positions), 57 | } as unknown as MemPFDProfile; 58 | 59 | return profile; 60 | } 61 | 62 | async candSummary(id: string): Promise { 63 | const res = JSON.parse( 64 | await this.get("", { 65 | method: "candSummary", 66 | cid: id, 67 | }) 68 | ).response.summary["@attributes"]; 69 | 70 | return res; 71 | } 72 | 73 | async candIndustry(id: string): Promise { 74 | const res = JSON.parse( 75 | await this.get("", { 76 | method: "candIndustry", 77 | cid: id, 78 | }) 79 | ).response.industries; 80 | return { 81 | cand_name: res["@attributes"].cand_name, 82 | cid: res["@attributes"].cid, 83 | cycle: res["@attributes"].cycle, 84 | origin: res["@attributes"].origin, 85 | source: res["@attributes"].source, 86 | last_updated: res["@attributes"].last_updated, 87 | industries: flatten(res.industry), 88 | }; 89 | } 90 | } 91 | 92 | // given an array of objects with a '@attributes' property, return an object with the keys/values of the '@attributes' property 93 | const flatten = (obj: { "@attributes": any }[]): any => { 94 | if (!obj) return obj; 95 | if (!Array.isArray(obj)) return [obj["@attributes"]]; 96 | return obj.map((item: { "@attributes": any }) => item["@attributes"]); 97 | }; 98 | 99 | export default OpenSecretsAPI; 100 | -------------------------------------------------------------------------------- /apps/api/src/datasources/ProPublicaAPI.ts: -------------------------------------------------------------------------------- 1 | import { RecentBill } from "./../entity/ProPublicaAPI.entity"; 2 | import { RESTDataSource, RequestOptions } from "apollo-datasource-rest"; 3 | 4 | import { PROPUBLICA_API_KEY } from "../config"; 5 | import type { Vote } from "../entity/ProPublicaAPI.entity"; 6 | 7 | class ProPublicaAPI extends RESTDataSource { 8 | constructor() { 9 | super(); 10 | this.baseURL = "https://api.propublica.org/congress/v1/members"; 11 | } 12 | 13 | willSendRequest(request: RequestOptions) { 14 | request.headers.set("X-API-Key", PROPUBLICA_API_KEY!); 15 | } 16 | 17 | // Get a Specific Member’s Vote Positions 18 | async getVotes(memberId: string): Promise { 19 | const res = await this.get(`/${memberId}/votes.json`); 20 | return res.results[0].votes; 21 | } 22 | 23 | async getRecentBills(memberId: string): Promise { 24 | const res = await this.get(`/${memberId}/bills/introduced.json`); 25 | return res.results[0].bills; 26 | } 27 | } 28 | 29 | export default ProPublicaAPI; 30 | -------------------------------------------------------------------------------- /apps/api/src/datasources/index.ts: -------------------------------------------------------------------------------- 1 | import CivicAPI from "./CivicAPI"; 2 | import KnowledgeGraphAPI from "./KnowledgeGraphAPI"; 3 | import OpenSecretsAPI from "./OpenSecretsAPI"; 4 | import ProPublicaAPI from "./ProPublicaAPI"; 5 | 6 | const dataSources = () => ({ 7 | civicAPI: new CivicAPI(), 8 | knowledgeGraphAPI: new KnowledgeGraphAPI(), 9 | openSecretsAPI: new OpenSecretsAPI(), 10 | proPublicaAPI: new ProPublicaAPI(), 11 | }); 12 | 13 | export default dataSources; 14 | -------------------------------------------------------------------------------- /apps/api/src/entity/CivicAPI.entity.ts: -------------------------------------------------------------------------------- 1 | import { Field, Int, ObjectType } from "type-graphql"; 2 | 3 | // Return type given by the Google Civic API response 4 | @ObjectType() 5 | export class CivicAPIResponse { 6 | @Field() 7 | kind: string; 8 | 9 | @Field(() => NormalizedInput) 10 | normalizedInput: NormalizedInput; 11 | 12 | @Field(() => [Division], { nullable: true }) 13 | divisions: Division[]; 14 | 15 | @Field(() => [Office], { nullable: true }) 16 | offices: Office[]; 17 | 18 | @Field(() => [CivicOfficial]) 19 | officials: CivicOfficial[]; 20 | } 21 | 22 | @ObjectType() 23 | export class NormalizedInput { 24 | @Field({ nullable: true }) 25 | locationName: string; 26 | @Field() 27 | line1: string; 28 | @Field({ nullable: true }) 29 | line2: string; 30 | @Field() 31 | city: string; 32 | @Field() 33 | state: string; 34 | @Field() 35 | zip: string; 36 | } 37 | 38 | // Return type of an official from the Google Civic API response 39 | @ObjectType() 40 | export class CivicOfficial { 41 | @Field() 42 | name: string; 43 | 44 | @Field(() => [Address]) 45 | address: Address[]; 46 | 47 | @Field() 48 | party: string; 49 | 50 | @Field(() => [String]) 51 | phones: string[]; 52 | 53 | @Field(() => [String], { nullable: true }) 54 | urls: string[]; 55 | 56 | @Field({ nullable: true }) 57 | photoUrl: string; 58 | 59 | @Field(() => [Channel]) 60 | channels: Channel[]; 61 | } 62 | 63 | @ObjectType() 64 | export class Division { 65 | @Field() 66 | ocdID: string; 67 | 68 | @Field() 69 | name: string; 70 | 71 | @Field({ nullable: true }) 72 | alsoKnownAs: string; 73 | 74 | @Field(() => [Int]) 75 | officeIndices: number[]; 76 | } 77 | 78 | @ObjectType() 79 | class Address { 80 | @Field({ nullable: true }) 81 | locationName: string; 82 | @Field() 83 | line1: string; 84 | @Field({ nullable: true }) 85 | line2: string; 86 | @Field({ nullable: true }) 87 | line3: string; 88 | @Field() 89 | city: string; 90 | @Field() 91 | state: string; 92 | @Field() 93 | zip: string; 94 | } 95 | 96 | @ObjectType() 97 | class Channel { 98 | @Field() 99 | type: string; 100 | 101 | @Field() 102 | id: string; 103 | } 104 | 105 | @ObjectType() 106 | export class Office { 107 | @Field({ nullable: true }) 108 | name: string; 109 | 110 | @Field({ nullable: true }) 111 | divisionId: string; 112 | 113 | @Field(() => [String], { nullable: true }) 114 | levels: string[]; 115 | 116 | @Field(() => [String], { nullable: true }) 117 | roles: string[]; 118 | 119 | @Field(() => [OfficeID], { nullable: true }) 120 | offices: OfficeID[]; 121 | 122 | @Field(() => [Int], { nullable: true }) 123 | officialIndices: number[]; 124 | } 125 | 126 | @ObjectType() 127 | class OfficeID { 128 | @Field() 129 | name: string; 130 | 131 | @Field() 132 | official: boolean; 133 | } 134 | -------------------------------------------------------------------------------- /apps/api/src/entity/KnowledgeGraphAPI.entity.ts: -------------------------------------------------------------------------------- 1 | import { Field, Int, ObjectType } from "type-graphql"; 2 | 3 | @ObjectType() 4 | class DetailedDescription { 5 | @Field() 6 | url: string; 7 | @Field() 8 | articleBody: string; 9 | @Field() 10 | license: string; 11 | } 12 | 13 | @ObjectType() 14 | class Image { 15 | @Field({ nullable: true }) 16 | contentUrl?: string; 17 | @Field() 18 | url: string; 19 | @Field({ nullable: true }) 20 | license?: string; 21 | } 22 | 23 | @ObjectType() 24 | class Result { 25 | @Field() 26 | id: string; 27 | @Field() 28 | name: string; 29 | @Field(() => [String]) 30 | type: string[]; 31 | @Field() 32 | description: string; 33 | @Field(() => Image) 34 | image: Image; 35 | @Field(() => DetailedDescription, { nullable: true }) 36 | detailedDescription?: DetailedDescription; 37 | @Field(() => String) 38 | url: string; 39 | } 40 | 41 | @ObjectType() 42 | class Context { 43 | @Field() 44 | vocab: string; 45 | @Field({ nullable: true }) 46 | goog: string; 47 | @Field() 48 | resultScore: string; 49 | @Field({ nullable: true }) 50 | detailedDescription: string; 51 | @Field() 52 | EntitySearchResult: string; 53 | @Field() 54 | kg: string; 55 | } 56 | 57 | @ObjectType() 58 | export class KnowledgeGraphAPIResponse { 59 | @Field(() => Context) 60 | context: Context; 61 | @Field() 62 | type: string; 63 | @Field(() => [ItemListElement]) 64 | itemListElement: ItemListElement[]; 65 | } 66 | 67 | @ObjectType() 68 | export class ItemListElement { 69 | @Field() 70 | type: string; 71 | 72 | @Field() 73 | result: Result; 74 | 75 | @Field(() => Int) 76 | resultScore: number; 77 | } 78 | -------------------------------------------------------------------------------- /apps/api/src/entity/OpenSecretsAPI.entity.ts: -------------------------------------------------------------------------------- 1 | import { Field, Float, Int, ObjectType } from "type-graphql"; 2 | 3 | @ObjectType() 4 | export class Legislator { 5 | @Field({ nullable: true }) 6 | cid: string; 7 | @Field() 8 | firstlast: string; 9 | @Field() 10 | lastname: string; 11 | @Field() 12 | party: string; 13 | @Field() 14 | office: string; 15 | @Field() 16 | gender: string; 17 | @Field({ nullable: true }) 18 | firstelectoff: string; 19 | @Field({ nullable: true }) 20 | exitcode: string; 21 | @Field() 22 | comments: string; 23 | @Field() 24 | phone: string; 25 | @Field() 26 | fax: string; 27 | @Field() 28 | website: string; 29 | @Field({ nullable: true }) 30 | webform: string; 31 | @Field() 32 | congress_office: string; 33 | @Field() 34 | bioguide_id: string; 35 | @Field() 36 | votesmart_id: string; 37 | @Field() 38 | feccandid: string; 39 | @Field() 40 | twitter_id: string; 41 | @Field() 42 | youtube_url: string; 43 | @Field() 44 | facebook_id: string; 45 | @Field() 46 | birthdate: string; 47 | } 48 | 49 | @ObjectType() 50 | export class MemberProfile { 51 | @Field({ nullable: true }) 52 | name?: string; 53 | @Field({ nullable: true }) 54 | member_id?: string; 55 | @Field(() => Int, { nullable: true }) 56 | net_low?: number; 57 | @Field(() => Int, { nullable: true }) 58 | net_high?: number; 59 | @Field(() => Int, { nullable: true }) 60 | positions_held_count?: number; 61 | @Field(() => Int, { nullable: true }) 62 | asset_count?: number; 63 | @Field(() => Int, { nullable: true }) 64 | asset_low?: number; 65 | @Field(() => Int, { nullable: true }) 66 | asset_high?: number; 67 | @Field(() => Int, { nullable: true }) 68 | transaction_count?: number; 69 | @Field(() => Int, { nullable: true }) 70 | tx_low?: number; 71 | @Field(() => Int, { nullable: true }) 72 | tx_high?: number; 73 | @Field() 74 | source: string; 75 | @Field() 76 | origin: string; 77 | @Field() 78 | update_timestamp: string; 79 | } 80 | 81 | @ObjectType() 82 | class Asset { 83 | @Field({ nullable: true }) 84 | name?: string; 85 | @Field(() => Int, { nullable: true }) 86 | holdings_low?: number; 87 | @Field(() => Int, { nullable: true }) 88 | holdings_high?: number; 89 | @Field({ nullable: true }) 90 | industry?: string; 91 | @Field({ nullable: true }) 92 | sector?: string; 93 | @Field({ nullable: true }) 94 | subsidiary_of?: string; 95 | } 96 | 97 | @ObjectType() 98 | export class MemPFDProfile { 99 | @Field(() => MemberProfile, { nullable: true }) 100 | member_profile?: MemberProfile; 101 | 102 | @Field(() => [Asset], { nullable: true }) 103 | asset?: Asset[]; 104 | 105 | @Field(() => [Transaction], { nullable: true }) 106 | transaction?: Transaction[]; 107 | 108 | @Field(() => [Position], { nullable: true }) 109 | position?: Position[]; 110 | } 111 | 112 | @ObjectType() 113 | class Transaction { 114 | @Field() 115 | asset_name: string; 116 | 117 | @Field() 118 | tx_date: string; 119 | 120 | @Field() 121 | tx_action: string; 122 | 123 | @Field(() => Int) 124 | value_low: number; 125 | 126 | @Field(() => Int) 127 | value_high: number; 128 | } 129 | 130 | @ObjectType() 131 | class Position { 132 | @Field({ nullable: true }) 133 | title?: string; 134 | 135 | @Field({ nullable: true }) 136 | organization?: string; 137 | } 138 | 139 | @ObjectType() 140 | export class candIndustry { 141 | @Field() 142 | cand_name: string; 143 | @Field() 144 | cid: string; 145 | @Field() 146 | cycle: string; 147 | @Field() 148 | origin: string; 149 | @Field() 150 | source: string; 151 | @Field() 152 | last_updated: string; 153 | @Field(() => [Industry]) 154 | industries: Industry[]; 155 | } 156 | 157 | @ObjectType() 158 | class Industry { 159 | @Field() 160 | industry_name: string; 161 | @Field() 162 | industry_code: string; 163 | @Field() 164 | indivs: number; 165 | @Field() 166 | pacs: number; 167 | @Field() 168 | total: number; 169 | } 170 | 171 | @ObjectType() 172 | export class candSummary { 173 | @Field() 174 | cand_name: string; 175 | @Field() 176 | cid: string; 177 | @Field() 178 | cycle: string; 179 | @Field() 180 | state: string; 181 | @Field() 182 | party: string; 183 | @Field() 184 | chamber: string; 185 | @Field() 186 | first_elected: string; 187 | @Field() 188 | next_election: string; 189 | @Field(() => Float) 190 | total: number; 191 | @Field(() => Float) 192 | spent: number; 193 | @Field(() => Float) 194 | cash_on_hand: number; 195 | @Field(() => Float) 196 | debt: number; 197 | @Field() 198 | origin: string; 199 | @Field() 200 | source: string; 201 | @Field() 202 | last_updated: string; 203 | } 204 | -------------------------------------------------------------------------------- /apps/api/src/entity/ProPublicaAPI.entity.ts: -------------------------------------------------------------------------------- 1 | import { Field, Int, ObjectType } from "type-graphql"; 2 | 3 | @ObjectType() 4 | export class Bill { 5 | @Field() 6 | bill_id: string; 7 | @Field() 8 | number: string; 9 | @Field({ nullable: true }) 10 | bill_uri?: string; 11 | @Field({ nullable: true }) 12 | title?: string; 13 | @Field({ nullable: true }) 14 | latest_action?: string; 15 | } 16 | 17 | @ObjectType() 18 | class VoteTotal { 19 | @Field(() => Int) 20 | yes: number; 21 | 22 | @Field(() => Int) 23 | no: number; 24 | 25 | @Field(() => Int) 26 | present: number; 27 | 28 | @Field(() => Int) 29 | not_voting: number; 30 | } 31 | 32 | @ObjectType() 33 | export class Vote { 34 | @Field() 35 | member_id: string; 36 | 37 | @Field() 38 | chamber: string; 39 | 40 | @Field() 41 | roll_call: string; 42 | 43 | @Field() 44 | vote_uri: string; 45 | 46 | @Field() 47 | bill: Bill; 48 | 49 | @Field() 50 | description: string; 51 | 52 | @Field() 53 | question: string; 54 | 55 | @Field() 56 | result: string; 57 | 58 | @Field() 59 | date: string; 60 | 61 | @Field() 62 | time: string; 63 | 64 | @Field() 65 | total: VoteTotal; 66 | 67 | @Field() 68 | position: string; 69 | } 70 | 71 | @ObjectType() 72 | export class RecentBill { 73 | @Field() 74 | congress: number; 75 | 76 | @Field() 77 | bill_id: string; 78 | 79 | @Field() 80 | bill_type: string; 81 | 82 | @Field() 83 | bill_uri: string; 84 | 85 | @Field() 86 | title: string; 87 | 88 | @Field() 89 | short_title: string; 90 | 91 | @Field() 92 | sponsor_title: string; 93 | 94 | @Field() 95 | sponsor_name: string; 96 | 97 | @Field() 98 | sponsor_state: string; 99 | 100 | @Field() 101 | sponsor_party: string; 102 | 103 | @Field() 104 | sponsor_uri: string; 105 | 106 | @Field() 107 | congressdotgov_url: string; 108 | 109 | @Field() 110 | govtrack_url: string; 111 | 112 | @Field() 113 | introduced_date: string; 114 | 115 | @Field() 116 | active: boolean; 117 | 118 | @Field(() => Int) 119 | cosponsors: number; 120 | 121 | @Field({ nullable: true }) 122 | committees: string; 123 | 124 | @Field() 125 | primary_subject: string; 126 | 127 | @Field() 128 | summary: string; 129 | 130 | @Field() 131 | summary_short: string; 132 | 133 | @Field() 134 | latest_major_action_date: string; 135 | 136 | @Field() 137 | latest_major_action: string; 138 | } 139 | -------------------------------------------------------------------------------- /apps/api/src/resolvers/IntegraResolver.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Arg, 3 | Ctx, 4 | FieldResolver, 5 | Int, 6 | Query, 7 | Resolver, 8 | Root, 9 | } from "type-graphql"; 10 | 11 | import { ItemListElement } from "../entity/KnowledgeGraphAPI.entity"; 12 | import { 13 | candIndustry, 14 | candSummary, 15 | MemPFDProfile, 16 | } from "../entity/OpenSecretsAPI.entity"; 17 | import { RecentBill, Vote } from "../entity/ProPublicaAPI.entity"; 18 | import { Context, Official } from "../types"; 19 | import { AppError } from "../utils"; 20 | 21 | @Resolver(() => Official) 22 | export class OfficialResolver { 23 | // Basic Field Resolvers 24 | @FieldResolver(() => String) 25 | name(@Root() parent: Official): string { 26 | return `${parent.first_name} ${parent.last_name}`; 27 | } 28 | 29 | @FieldResolver(() => Int) 30 | age(@Root() parent: Official): number { 31 | // Not perfect solution, would use library 32 | const getAge = (birthDate: Date): number => 33 | Math.floor( 34 | (Date.now() - birthDate.getTime()) / 35 | // 1000 * 60 * 60 * 24 * 365 36 | 3.15576e10 37 | ); 38 | return getAge(parent.date_of_birth); 39 | } 40 | 41 | @FieldResolver(() => String) 42 | slug(@Root() parent: Official): string { 43 | const { full_name, id } = parent; 44 | const slug = `${full_name.split(" ").join("-").toLowerCase()}-${id}`; 45 | return encodeURIComponent(slug); 46 | } 47 | 48 | @FieldResolver(() => String) 49 | photo_url(@Root() parent: Official): string { 50 | return `https://theunitedstates.io/images/congress/450x550/${parent.bioguide_id}.jpg`; 51 | } 52 | 53 | @FieldResolver(() => [ItemListElement]) 54 | async googleKnowledgeGraph( 55 | @Root() parent: Official, 56 | @Ctx() ctx: Context 57 | ): Promise { 58 | if (!parent.google_entity_id) throw new AppError("No google_entity_id"); 59 | const res = await ctx.dataSources.knowledgeGraphAPI.getResponse( 60 | parent.google_entity_id 61 | ); 62 | return res.itemListElement; 63 | } 64 | 65 | @FieldResolver(() => MemPFDProfile) 66 | async memPFDProfile( 67 | @Root() parent: Official, 68 | @Ctx() ctx: Context 69 | ): Promise { 70 | return await ctx.dataSources.openSecretsAPI.memPFDprofile(parent.crp_id); 71 | } 72 | 73 | @FieldResolver(() => candSummary) 74 | async candSummary( 75 | @Root() parent: Official, 76 | @Ctx() ctx: Context 77 | ): Promise { 78 | return await ctx.dataSources.openSecretsAPI.candSummary(parent.crp_id); 79 | } 80 | 81 | @FieldResolver(() => candIndustry) 82 | async candIndustry( 83 | @Root() parent: Official, 84 | @Ctx() ctx: Context, 85 | @Arg("limit", { nullable: true }) limit: number = 5 86 | ): Promise { 87 | const data = await ctx.dataSources.openSecretsAPI.candIndustry( 88 | parent.crp_id 89 | ); 90 | return { 91 | ...data, 92 | industries: data.industries.slice(0, limit), 93 | }; 94 | } 95 | 96 | @FieldResolver(() => [Vote]) 97 | async votes(@Root() parent: Official, @Ctx() ctx: Context): Promise { 98 | return await ctx.dataSources.proPublicaAPI.getVotes(parent.bioguide_id); 99 | } 100 | 101 | @FieldResolver(() => [RecentBill]) 102 | async recentBills( 103 | @Root() parent: Official, 104 | @Ctx() ctx: Context 105 | ): Promise { 106 | return await ctx.dataSources.proPublicaAPI.getRecentBills( 107 | parent.bioguide_id 108 | ); 109 | } 110 | 111 | @Query(() => [Official]) 112 | async findOfficialByLocation( 113 | @Arg("location") location: string, 114 | @Ctx() ctx: Context 115 | ): Promise { 116 | // essentially, for some address, return the respective offices for that location 117 | const offices = ( 118 | await ctx.dataSources.civicAPI.getOffices(location) 119 | ).filter( 120 | ({ name }: { name: string }) => 121 | name === "U.S. Senator" || name === "U.S. Representative" 122 | ); 123 | // search for the officials in that given office in the database 124 | const integraOfficials = await Promise.all( 125 | offices.map(({ divisionId }: { divisionId: string }) => { 126 | return ctx.prisma.official.findMany({ 127 | where: { 128 | ocd_id: divisionId, 129 | in_office: true, 130 | }, 131 | }); 132 | }) 133 | ); 134 | return integraOfficials.flat(); 135 | } 136 | 137 | @Query(() => [Official]) 138 | async findOfficialByName( 139 | @Arg("name") name: string, 140 | @Ctx() ctx: Context 141 | ): Promise { 142 | const [firstName, lastName] = name.split(" "); 143 | 144 | const official = await ctx.prisma.official.findMany({ 145 | where: { 146 | first_name: firstName, 147 | last_name: lastName, 148 | in_office: true, 149 | }, 150 | }); 151 | 152 | if (!lastName) { 153 | const getOfficials = await ctx.prisma.official.findMany({ 154 | where: { 155 | first_name: { 156 | startsWith: name, 157 | mode: "insensitive", 158 | }, 159 | }, 160 | take: 5, 161 | }); 162 | return getOfficials ?? []; 163 | } 164 | 165 | if (official.length !== 0) return official; 166 | 167 | const officialByName = await ctx.mongoDB.search("full_name", [ 168 | { 169 | query: firstName, 170 | path: "first_name", 171 | }, 172 | { 173 | query: lastName, 174 | path: "last_name", 175 | }, 176 | ]); 177 | 178 | return officialByName.map((official: any) => { 179 | return { 180 | ...official, 181 | id: official._id.toString(), 182 | } as Official; 183 | }); 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /apps/api/src/resolvers/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FindFirstOfficialResolver, 3 | FindManyOfficialResolver, 4 | FindUniqueOfficialResolver, 5 | } from "database"; 6 | import type { NonEmptyArray } from "type-graphql"; 7 | 8 | import { OfficialResolver } from "./IntegraResolver"; 9 | 10 | const resolvers: NonEmptyArray | NonEmptyArray = [ 11 | OfficialResolver, 12 | FindFirstOfficialResolver, 13 | FindManyOfficialResolver, 14 | FindUniqueOfficialResolver, 15 | ]; 16 | 17 | export default resolvers; 18 | -------------------------------------------------------------------------------- /apps/api/src/schema.ts: -------------------------------------------------------------------------------- 1 | import { buildSchemaSync } from "type-graphql"; 2 | 3 | import { ErrorInterceptor } from "./utils"; 4 | 5 | import resolvers from "./resolvers"; 6 | 7 | export const schema = buildSchemaSync({ 8 | resolvers, 9 | globalMiddlewares: [ErrorInterceptor], 10 | }); 11 | -------------------------------------------------------------------------------- /apps/api/src/services/index.ts: -------------------------------------------------------------------------------- 1 | export { default as mongoDB } from "./mongoDB"; 2 | -------------------------------------------------------------------------------- /apps/api/src/services/mongoDB.ts: -------------------------------------------------------------------------------- 1 | import { MongoClient, Db, Collection } from "mongodb"; 2 | 3 | import { DATABASE_URL } from "../config"; 4 | 5 | type Query = { 6 | query: string; 7 | path: string; 8 | }; 9 | 10 | class MongoDB { 11 | db: Db; 12 | collection: Collection; 13 | 14 | constructor(database: string, collectionName: string) { 15 | this.db = new MongoClient(DATABASE_URL).db(database); 16 | this.collection = this.db.collection(collectionName); 17 | } 18 | 19 | async search(index: string, queries: Query[], limit = 10) { 20 | const agg = this.collection.aggregate([ 21 | { 22 | $search: { 23 | index, 24 | compound: { 25 | should: queries.map(({ query, path }) => ({ 26 | autocomplete: { 27 | query, 28 | path, 29 | }, 30 | })), 31 | }, 32 | }, 33 | }, 34 | { 35 | $limit: limit, 36 | }, 37 | ]); 38 | return agg.toArray(); 39 | } 40 | } 41 | 42 | export default MongoDB; 43 | -------------------------------------------------------------------------------- /apps/api/src/types/index.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | export { Official } from "database"; 3 | 4 | import { mongoDB } from "../services"; 5 | import dataSources from "../datasources"; 6 | 7 | export type Context = { 8 | dataSources: ReturnType; 9 | prisma: PrismaClient; 10 | mongoDB: mongoDB; 11 | }; 12 | -------------------------------------------------------------------------------- /apps/api/src/utils/appError.ts: -------------------------------------------------------------------------------- 1 | import { ApolloError } from "apollo-server-errors"; 2 | 3 | export class AppError extends ApolloError { 4 | constructor(message: string) { 5 | super(message, "APP_ERROR"); 6 | Object.defineProperty(this, "name", { value: "AppError" }); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /apps/api/src/utils/errorInterceptor.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareFn } from "type-graphql"; 2 | import { logger } from "./logger"; 3 | 4 | export const ErrorInterceptor: MiddlewareFn = async (_action, next) => { 5 | try { 6 | return await next(); 7 | } catch (err) { 8 | if (err instanceof Error) { 9 | logger.error(err.message); 10 | } 11 | throw err; 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /apps/api/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./appError"; 2 | export * from "./startServer"; 3 | export * from "./errorInterceptor"; 4 | export * from "./logger"; 5 | -------------------------------------------------------------------------------- /apps/api/src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import pino from "pino"; 2 | import pretty from "pino-pretty"; 3 | import type { 4 | ApolloServerPlugin, 5 | GraphQLRequestContext, 6 | GraphQLRequestListener, 7 | } from "apollo-server-plugin-base"; 8 | 9 | import { Context } from "../types"; 10 | 11 | class Logger implements ApolloServerPlugin { 12 | private logger: pino.Logger; 13 | 14 | constructor() { 15 | this.logger = pino( 16 | { 17 | level: "info", 18 | timestamp: pino.stdTimeFunctions.isoTime, 19 | }, 20 | pretty({ 21 | colorize: true, 22 | }) 23 | ); 24 | } 25 | 26 | async serverWillStart() { 27 | this.logger.info(`🚀 Apollo Server is starting up`); 28 | } 29 | 30 | async requestDidStart( 31 | requestContext: GraphQLRequestContext 32 | ): Promise> { 33 | if (requestContext.request.operationName === "IntrospectionQuery") return; 34 | this.logger.info( 35 | `🔥 Request started: ${requestContext.request.operationName}` 36 | ); 37 | } 38 | 39 | public info(message: string, data?: any) { 40 | this.logger.info(message, data); 41 | } 42 | 43 | public error(message: string, data?: any) { 44 | this.logger.error(message, data); 45 | } 46 | } 47 | 48 | export const logger = new Logger(); 49 | -------------------------------------------------------------------------------- /apps/api/src/utils/startServer.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import cors from "cors"; 3 | import rateLimit from "express-rate-limit"; 4 | import helmet from "helmet"; 5 | 6 | import { isDevelopment } from "../config"; 7 | 8 | export const startServer = () => { 9 | const app = express(); 10 | 11 | app 12 | .disable("x-powered-by") 13 | .use(express.json()) 14 | .use(express.urlencoded({ extended: true })) 15 | .use(cors()) 16 | .use( 17 | rateLimit({ 18 | windowMs: 15 * 60 * 1000, // 15 minutes 19 | max: 100, // limit each IP to 100 requests per windowMs 20 | }) 21 | ) 22 | .use( 23 | helmet({ 24 | // necessary for GraphQL Sandbox 25 | crossOriginEmbedderPolicy: !isDevelopment, 26 | contentSecurityPolicy: !isDevelopment, 27 | }) 28 | ) 29 | .enable("trust proxy"); 30 | 31 | return app; 32 | }; 33 | -------------------------------------------------------------------------------- /apps/api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/graphql.json", 3 | "compilerOptions": { 4 | "outDir": "./dist" 5 | }, 6 | "exclude": ["node_modules"], 7 | "include": ["src"] 8 | } 9 | -------------------------------------------------------------------------------- /apps/client/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ["custom"], 4 | }; 5 | -------------------------------------------------------------------------------- /apps/client/export-images.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('next-export-optimize-images').Config} 3 | */ 4 | const nextImagesConfig = { 5 | outDir: "dist", 6 | imageDir: "_images", 7 | convertFormat: [ 8 | ["png", "webp"], 9 | ["jpg", "webp"], 10 | ], 11 | }; 12 | 13 | module.exports = nextImagesConfig; 14 | -------------------------------------------------------------------------------- /apps/client/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /apps/client/next-sitemap.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next-sitemap').IConfig} */ 2 | module.exports = { 3 | siteUrl: "https://integra.vote", 4 | generateRobotsTxt: true, 5 | }; 6 | -------------------------------------------------------------------------------- /apps/client/next.config.js: -------------------------------------------------------------------------------- 1 | const withExportImages = require("next-export-optimize-images"); 2 | const withBundleAnalyzer = require("@next/bundle-analyzer")({ 3 | enabled: process.env.ANALYZE === "true", 4 | }); 5 | 6 | const securityHeaders = [ 7 | { 8 | key: "X-DNS-Prefetch-Control", 9 | value: "on", 10 | }, 11 | { 12 | key: "X-XSS-Protection", 13 | value: "1; mode=block", 14 | }, 15 | { 16 | key: "X-Content-Type-Options", 17 | value: "nosniff", 18 | }, 19 | { 20 | key: "Referrer-Policy", 21 | value: "origin-when-cross-origin", 22 | }, 23 | ]; 24 | 25 | /** @type {import('next').NextConfig} */ 26 | const nextConfig = { 27 | reactStrictMode: true, 28 | swcMinify: false, 29 | poweredByHeader: false, 30 | experimental: { 31 | nextScriptWorkers: true, 32 | }, 33 | images: { 34 | loader: "custom", 35 | imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], 36 | deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], 37 | }, 38 | headers: [ 39 | { 40 | source: "/:path*", 41 | headers: securityHeaders, 42 | }, 43 | ], 44 | env: { 45 | nextImageExportOptimizer_imageFolderPath: "public/images", 46 | nextImageExportOptimizer_exportFolderPath: "dist", 47 | nextImageExportOptimizer_quality: 75, 48 | nextImageExportOptimizer_storePicturesInWEBP: true, 49 | nextImageExportOptimizer_generateAndUseBlurImages: true, 50 | }, 51 | webpack(config) { 52 | config.module.rules.push({ 53 | test: /\.svg$/i, 54 | issuer: /\.[jt]sx?$/, 55 | use: ["@svgr/webpack"], 56 | }); 57 | return config; 58 | }, 59 | }; 60 | 61 | module.exports = (_phase, { defaultConfig }) => { 62 | const plugins = [withExportImages, withBundleAnalyzer]; 63 | 64 | return plugins.reduce((acc, plugin) => plugin(acc), { 65 | ...defaultConfig, 66 | ...nextConfig, 67 | }); 68 | }; 69 | -------------------------------------------------------------------------------- /apps/client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "private": true, 4 | "version": "0.0.0", 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build && next export -o dist && yarn postbuild", 8 | "postbuild": "next-export-optimize-images && next-sitemap", 9 | "analyze": "ANALYZE=true yarn build", 10 | "start": "next start", 11 | "lint": "next lint" 12 | }, 13 | "dependencies": { 14 | "@apollo/client": "^3.6.9", 15 | "@builder.io/partytown": "^0.7.0", 16 | "@chakra-ui/icons": "^2.0.10", 17 | "@chakra-ui/react": "^2.3.2", 18 | "@emotion/react": "^11.10.4", 19 | "@emotion/styled": "^11.10.4", 20 | "@heroicons/react": "^2.0.11", 21 | "@tanstack/react-table": "^8.5.13", 22 | "chakra-react-select": "^4.2.2", 23 | "chart.js": "^3.9.1", 24 | "database": "*", 25 | "framer-motion": "^7.3.6", 26 | "graphql": "^15.3.0", 27 | "next": "^12.3.1", 28 | "next-sitemap": "^3.1.22", 29 | "pino": "^8.6.0", 30 | "pino-pretty": "^9.1.0", 31 | "react": "^18.2.0", 32 | "react-chartjs-2": "^4.3.1", 33 | "react-dom": "^18.2.0", 34 | "react-hook-form": "^7.36.0", 35 | "reflect-metadata": "^0.1.13", 36 | "swr": "^1.3.0" 37 | }, 38 | "devDependencies": { 39 | "@next/bundle-analyzer": "^12.3.1", 40 | "@svgr/webpack": "^6.3.1", 41 | "@types/google.maps": "^3.50.1", 42 | "@types/react": "^18.0.21", 43 | "@types/react-dom": "^18.0.6", 44 | "apollo": "^2.34.0", 45 | "eslint": "^8.23.1", 46 | "eslint-config-custom": "*", 47 | "graphql-codegen": "^0.4.0", 48 | "next-export-optimize-images": "^1.7.0", 49 | "tsconfig": "*", 50 | "typescript": "^4.8.3" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /apps/client/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-code/integra/7988133d77587c635059dd81c1d898d324a55d49/apps/client/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /apps/client/public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-code/integra/7988133d77587c635059dd81c1d898d324a55d49/apps/client/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /apps/client/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-code/integra/7988133d77587c635059dd81c1d898d324a55d49/apps/client/public/apple-touch-icon.png -------------------------------------------------------------------------------- /apps/client/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-code/integra/7988133d77587c635059dd81c1d898d324a55d49/apps/client/public/favicon-16x16.png -------------------------------------------------------------------------------- /apps/client/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-code/integra/7988133d77587c635059dd81c1d898d324a55d49/apps/client/public/favicon-32x32.png -------------------------------------------------------------------------------- /apps/client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-code/integra/7988133d77587c635059dd81c1d898d324a55d49/apps/client/public/favicon.ico -------------------------------------------------------------------------------- /apps/client/public/robots.txt: -------------------------------------------------------------------------------- 1 | # * 2 | User-agent: * 3 | Allow: / 4 | 5 | # Host 6 | Host: https://integra.vote 7 | 8 | # Sitemaps 9 | Sitemap: https://integra.vote/sitemap.xml 10 | -------------------------------------------------------------------------------- /apps/client/public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Integra", 3 | "short_name": "Integra", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /apps/client/public/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | https://integra.vote/sitemap-0.xml 4 | -------------------------------------------------------------------------------- /apps/client/src/assets/404.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-code/integra/7988133d77587c635059dd81c1d898d324a55d49/apps/client/src/assets/404.webp -------------------------------------------------------------------------------- /apps/client/src/assets/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Logo } from "./logo.webp"; 2 | export { default as NotFoundImage } from "./404.webp"; 3 | -------------------------------------------------------------------------------- /apps/client/src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /apps/client/src/assets/logo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-code/integra/7988133d77587c635059dd81c1d898d324a55d49/apps/client/src/assets/logo.webp -------------------------------------------------------------------------------- /apps/client/src/assets/state_flags/ak.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /apps/client/src/assets/state_flags/al.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /apps/client/src/assets/state_flags/ar.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /apps/client/src/assets/state_flags/az.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /apps/client/src/assets/state_flags/co.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /apps/client/src/assets/state_flags/dc.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /apps/client/src/assets/state_flags/gu.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /apps/client/src/assets/state_flags/hi.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /apps/client/src/assets/state_flags/in.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /apps/client/src/assets/state_flags/index.ts: -------------------------------------------------------------------------------- 1 | export { default as AL } from "./al.svg"; 2 | export { default as AK } from "./ak.svg"; 3 | export { default as AZ } from "./az.svg"; 4 | export { default as AR } from "./ar.svg"; 5 | export { default as CA } from "./ca.svg"; 6 | export { default as CO } from "./co.svg"; 7 | export { default as CT } from "./ct.svg"; 8 | export { default as DE } from "./de.svg"; 9 | export { default as DC } from "./dc.svg"; 10 | export { default as FL } from "./fl.svg"; 11 | export { default as GA } from "./ga.svg"; 12 | export { default as HI } from "./hi.svg"; 13 | export { default as ID } from "./id.svg"; 14 | export { default as IL } from "./il.svg"; 15 | export { default as IN } from "./in.svg"; 16 | export { default as IA } from "./ia.svg"; 17 | export { default as KS } from "./ks.svg"; 18 | export { default as KY } from "./ky.svg"; 19 | export { default as LA } from "./la.svg"; 20 | export { default as ME } from "./me.svg"; 21 | export { default as MD } from "./md.svg"; 22 | export { default as MA } from "./ma.svg"; 23 | export { default as MI } from "./mi.svg"; 24 | export { default as MN } from "./mn.svg"; 25 | export { default as MS } from "./ms.svg"; 26 | export { default as MO } from "./mo.svg"; 27 | export { default as MT } from "./mt.svg"; 28 | export { default as NE } from "./ne.svg"; 29 | export { default as NV } from "./nv.svg"; 30 | export { default as NH } from "./nh.svg"; 31 | export { default as NJ } from "./nj.svg"; 32 | export { default as NM } from "./nm.svg"; 33 | export { default as NY } from "./ny.svg"; 34 | export { default as NC } from "./nc.svg"; 35 | export { default as ND } from "./nd.svg"; 36 | export { default as OH } from "./oh.svg"; 37 | export { default as OK } from "./ok.svg"; 38 | export { default as OR } from "./or.svg"; 39 | export { default as PA } from "./pa.svg"; 40 | export { default as RI } from "./ri.svg"; 41 | export { default as SC } from "./sc.svg"; 42 | export { default as SD } from "./sd.svg"; 43 | export { default as TN } from "./tn.svg"; 44 | export { default as TX } from "./tx.svg"; 45 | export { default as UT } from "./ut.svg"; 46 | export { default as VT } from "./vt.svg"; 47 | export { default as VA } from "./va.svg"; 48 | export { default as WA } from "./wa.svg"; 49 | export { default as WV } from "./wv.svg"; 50 | export { default as WI } from "./wi.svg"; 51 | export { default as WY } from "./wy.svg"; 52 | -------------------------------------------------------------------------------- /apps/client/src/assets/state_flags/md.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /apps/client/src/assets/state_flags/ms.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /apps/client/src/assets/state_flags/nc.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /apps/client/src/assets/state_flags/nm.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /apps/client/src/assets/state_flags/oh.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /apps/client/src/assets/state_flags/pr.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /apps/client/src/assets/state_flags/ri.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /apps/client/src/assets/state_flags/tn.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /apps/client/src/assets/state_flags/tx.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /apps/client/src/assets/state_flags/um.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /apps/client/src/components/Card/Card.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Box, useColorModeValue } from "@chakra-ui/react"; 3 | import type { BoxProps } from "@chakra-ui/react"; 4 | 5 | type CardProps = { 6 | children?: React.ReactNode; 7 | } & BoxProps; 8 | 9 | const Card = ({ children, ...rest }: CardProps) => { 10 | return ( 11 | 21 | {children} 22 | 23 | ); 24 | }; 25 | 26 | export default Card; 27 | -------------------------------------------------------------------------------- /apps/client/src/components/Card/DetailsCard.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Flex, Text, Skeleton, Box, useColorModeValue } from "@chakra-ui/react"; 3 | 4 | import { Card } from "@/components/Card"; 5 | import { Icon } from "@/components/Icon"; 6 | 7 | type DetailsCardProps = { 8 | title: string; 9 | isLoaded?: boolean; 10 | items: { 11 | heading: string; 12 | text: string; 13 | icon?: React.ComponentProps; 14 | }[]; 15 | } & React.ComponentProps; 16 | 17 | const DetailsCard = ({ 18 | title, 19 | isLoaded = true, 20 | items, 21 | ...props 22 | }: DetailsCardProps) => { 23 | const headingColor = useColorModeValue("gray.500", "gray.300"); 24 | const borderColor = useColorModeValue("gray.200", "whiteAlpha.300"); 25 | 26 | return ( 27 | 28 | 29 | {title} 30 | 31 | 32 | 40 | {items.map(({ heading, text, icon }, index) => ( 41 | 42 | 49 | {heading} 50 | 51 | 52 | {icon && ( 53 | 60 | )} 61 | {text ?? "N/A"} 62 | 63 | 64 | ))} 65 | 66 | 67 | 68 | ); 69 | }; 70 | 71 | export default DetailsCard; 72 | -------------------------------------------------------------------------------- /apps/client/src/components/Card/IconCard.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Flex, 4 | Text, 5 | Skeleton, 6 | Link, 7 | Box, 8 | useColorModeValue, 9 | } from "@chakra-ui/react"; 10 | 11 | import { Card } from "@/components/Card"; 12 | import { Icon } from "@/components/Icon"; 13 | import { truncateLink } from "@/utils"; 14 | 15 | type IconCardProps = { 16 | title: string; 17 | isLoaded?: boolean; 18 | items: { 19 | icon: React.ComponentProps; 20 | text?: string; 21 | href?: string; 22 | }[]; 23 | } & React.ComponentProps; 24 | 25 | const IconCard = ({ title, isLoaded, items, ...props }: IconCardProps) => { 26 | const iconColor = useColorModeValue("gray.500", "gray.300"); 27 | 28 | return ( 29 | 30 | 31 | {title} 32 | 33 | 34 | 42 | {items.map(({ icon, text, href }, index) => { 43 | const url = href ? new URL(href) : undefined; 44 | 45 | return ( 46 | 47 | 48 | 49 | {url && text ? ( 50 | 51 | {text} 52 | 53 | ) : url ? ( 54 | 55 | {truncateLink(`${url.hostname}${url.pathname}`)} 56 | 57 | ) : ( 58 | {text ?? "N/A"} 59 | )} 60 | 61 | ); 62 | })} 63 | 64 | 65 | 66 | ); 67 | }; 68 | 69 | export default IconCard; 70 | -------------------------------------------------------------------------------- /apps/client/src/components/Card/OfficialCard.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useRouter } from "next/router"; 3 | import { 4 | Box, 5 | Button, 6 | Divider, 7 | Flex, 8 | Heading, 9 | Text, 10 | Tag, 11 | useBoolean, 12 | useColorModeValue, 13 | } from "@chakra-ui/react"; 14 | 15 | import { Card } from "@/components/Card"; 16 | import { Avatar } from "@/components/Misc"; 17 | import type { Official } from "@/types"; 18 | 19 | type OfficalCardProps = { 20 | official: Official; 21 | }; 22 | 23 | const OfficialCard = ({ official }: OfficalCardProps) => { 24 | const router = useRouter(); 25 | const [isLoading, setIsLoading] = useBoolean(); 26 | 27 | return ( 28 | 29 | 30 | 31 | 32 | {official.name} 33 | 34 | {official.title} 35 | 36 | 37 | Age {official.age} • Served {official.seniority} 38 | {parseInt(official.seniority) !== 1 ? " years" : " year"} 39 | 40 | 49 | {official.party} 50 | 51 | 52 | 53 | 54 | 74 | 75 | ); 76 | }; 77 | 78 | export default OfficialCard; 79 | -------------------------------------------------------------------------------- /apps/client/src/components/Card/StatCard.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Heading, Skeleton } from "@chakra-ui/react"; 3 | import type { BoxProps } from "@chakra-ui/react"; 4 | 5 | import { Card } from "@/components/Card"; 6 | 7 | type StatCardProps = { 8 | isLoaded: boolean; 9 | title: string; 10 | children: React.ReactNode; 11 | } & BoxProps; 12 | 13 | const StatCard = ({ isLoaded, title, children, ...rest }: StatCardProps) => { 14 | return ( 15 | 16 | 17 | 18 | {title} 19 | 20 | {children} 21 | 22 | 23 | ); 24 | }; 25 | 26 | export default StatCard; 27 | -------------------------------------------------------------------------------- /apps/client/src/components/Card/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Card } from "./Card"; 2 | export { default as OfficialCard } from "./OfficialCard"; 3 | export { default as StatCard } from "./StatCard"; 4 | export { default as IconCard } from "./IconCard"; 5 | export { default as DetailsCard } from "./DetailsCard"; 6 | -------------------------------------------------------------------------------- /apps/client/src/components/Chart/BarChart.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Chart as ChartJS, 4 | CategoryScale, 5 | LinearScale, 6 | BarElement, 7 | Title, 8 | Tooltip, 9 | Legend, 10 | } from "chart.js"; 11 | import { Bar } from "react-chartjs-2"; 12 | import { Skeleton, useColorModeValue } from "@chakra-ui/react"; 13 | import type { ChartOptions, ChartData } from "chart.js"; 14 | 15 | import { backgroundColors, borderColors } from "@/components/Chart"; 16 | 17 | ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip); 18 | 19 | type BarChartProps = { 20 | title: string; 21 | labels: string[]; 22 | datasets: number[]; 23 | }; 24 | 25 | const BarChart = ({ title, labels, datasets }: BarChartProps) => { 26 | ChartJS.defaults.color = useColorModeValue("#1a202c", "white"); 27 | 28 | const options: ChartOptions<"bar"> = { 29 | responsive: true, 30 | plugins: { 31 | legend: { 32 | position: "top", 33 | }, 34 | title: { 35 | display: true, 36 | text: title, 37 | }, 38 | }, 39 | }; 40 | 41 | const data: ChartData<"bar"> = { 42 | labels, 43 | datasets: [ 44 | { 45 | label: "Data", 46 | data: datasets, 47 | backgroundColor: backgroundColors(datasets?.length), 48 | borderColor: borderColors(datasets?.length), 49 | borderWidth: 1, 50 | }, 51 | ], 52 | }; 53 | 54 | return ( 55 | 56 | 57 | 58 | ); 59 | }; 60 | 61 | export default BarChart; 62 | -------------------------------------------------------------------------------- /apps/client/src/components/Chart/HorizontalBarChart.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Chart as ChartJS, 4 | CategoryScale, 5 | LinearScale, 6 | BarElement, 7 | Title, 8 | Tooltip, 9 | Legend, 10 | } from "chart.js"; 11 | import { Bar } from "react-chartjs-2"; 12 | import { Skeleton, useColorModeValue } from "@chakra-ui/react"; 13 | import type { ChartOptions, ChartData } from "chart.js"; 14 | 15 | ChartJS.unregister(Legend); 16 | 17 | ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip); 18 | 19 | type HorizontalBarChartProps = { 20 | title: string; 21 | labels: string[]; 22 | datasets: number[]; 23 | backgroundColor?: string; 24 | borderColor?: string; 25 | }; 26 | 27 | const HorizontalBarChart = ({ 28 | title, 29 | labels, 30 | datasets, 31 | backgroundColor, 32 | borderColor, 33 | }: HorizontalBarChartProps) => { 34 | ChartJS.defaults.color = useColorModeValue("#1a202c", "white"); 35 | 36 | const options: ChartOptions<"bar"> = { 37 | responsive: true, 38 | indexAxis: "y", 39 | plugins: { 40 | legend: { 41 | position: "top", 42 | }, 43 | title: { 44 | display: true, 45 | text: title, 46 | }, 47 | }, 48 | scales: { 49 | y: { 50 | stacked: true, 51 | }, 52 | x: { 53 | max: 1, 54 | min: -1, 55 | }, 56 | }, 57 | }; 58 | 59 | const data: ChartData<"bar"> = { 60 | labels, 61 | datasets: [ 62 | { 63 | label: labels[0], 64 | data: datasets, 65 | backgroundColor, 66 | borderColor, 67 | borderWidth: 1, 68 | }, 69 | ], 70 | }; 71 | 72 | return ( 73 | 74 | 75 | 76 | ); 77 | }; 78 | 79 | export default HorizontalBarChart; 80 | -------------------------------------------------------------------------------- /apps/client/src/components/Chart/PieChart.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { Chart as ChartJS, ArcElement, Title, Tooltip, Legend } from "chart.js"; 4 | import { Pie } from "react-chartjs-2"; 5 | import type { ChartOptions, ChartData } from "chart.js"; 6 | import { Skeleton, useColorModeValue } from "@chakra-ui/react"; 7 | 8 | import { 9 | greenAndRedBackgroundColors, 10 | greenAndRedBorderColors, 11 | } from "@/components/Chart"; 12 | 13 | ChartJS.register(ArcElement, Title, Tooltip, Legend); 14 | 15 | type PieChartProps = { 16 | title: string; 17 | labels: string[]; 18 | datasets: number[]; 19 | }; 20 | 21 | const PieChart = ({ title, labels, datasets }: PieChartProps) => { 22 | ChartJS.defaults.color = useColorModeValue("#1a202c", "white"); 23 | 24 | const options: ChartOptions<"pie"> = { 25 | responsive: true, 26 | plugins: { 27 | legend: { 28 | position: "top", 29 | }, 30 | title: { 31 | display: true, 32 | text: title, 33 | }, 34 | }, 35 | }; 36 | 37 | const data: ChartData<"pie"> = { 38 | labels, 39 | datasets: [ 40 | { 41 | data: datasets, 42 | backgroundColor: greenAndRedBackgroundColors, 43 | borderColor: greenAndRedBorderColors, 44 | borderWidth: 1, 45 | }, 46 | ], 47 | }; 48 | return ( 49 | 50 | 51 | 52 | ); 53 | }; 54 | 55 | export default PieChart; 56 | -------------------------------------------------------------------------------- /apps/client/src/components/Chart/ScatterChart.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Chart as ChartJS, 4 | LinearScale, 5 | PointElement, 6 | LineElement, 7 | Tooltip, 8 | Legend, 9 | } from "chart.js"; 10 | import { Scatter } from "react-chartjs-2"; 11 | import { Skeleton, useColorModeValue } from "@chakra-ui/react"; 12 | 13 | import type { ChartOptions, ChartData } from "chart.js"; 14 | 15 | ChartJS.register(LinearScale, PointElement, LineElement, Tooltip, Legend); 16 | 17 | type PieChartProps = { 18 | title: string; 19 | datasets: { 20 | x: number; 21 | y: number; 22 | }[]; 23 | }; 24 | 25 | const ScatterChart = ({ title, datasets }: PieChartProps) => { 26 | ChartJS.defaults.color = useColorModeValue("#1a202c", "white"); 27 | 28 | const options: ChartOptions<"scatter"> = { 29 | responsive: true, 30 | plugins: { 31 | legend: { 32 | position: "top", 33 | }, 34 | title: { 35 | display: true, 36 | text: title, 37 | }, 38 | }, 39 | }; 40 | 41 | const data: ChartData<"scatter"> = { 42 | datasets: [ 43 | { 44 | label: "Scatter Dataset", 45 | data: datasets, 46 | backgroundColor: "rgba(255, 99, 132, 1)", 47 | }, 48 | ], 49 | }; 50 | return ( 51 | 52 | 53 | 54 | ); 55 | }; 56 | 57 | export default ScatterChart; 58 | 59 | const options = { 60 | scales: { 61 | y: { 62 | beginAtZero: true, 63 | }, 64 | }, 65 | }; 66 | -------------------------------------------------------------------------------- /apps/client/src/components/Chart/Stat.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Stat as ChakraStat, 4 | StatLabel, 5 | StatNumber, 6 | StatHelpText, 7 | StatArrow, 8 | Skeleton, 9 | useColorModeValue, 10 | } from "@chakra-ui/react"; 11 | 12 | import type { StatProps as ChakraStatProps } from "@chakra-ui/react"; 13 | 14 | type StatProps = { 15 | label: string; 16 | data: string | React.ReactNode; 17 | isLoaded: boolean; 18 | helpText?: string; 19 | type?: "increase" | "decrease"; 20 | } & ChakraStatProps; 21 | 22 | const Stat = ({ 23 | label, 24 | data, 25 | isLoaded, 26 | helpText, 27 | type, 28 | ...rest 29 | }: StatProps) => { 30 | return ( 31 | 32 | 41 | {label} 42 | {data} 43 | {helpText && ( 44 | 45 | 46 | {helpText} 47 | 48 | )} 49 | 50 | 51 | ); 52 | }; 53 | 54 | export default Stat; 55 | -------------------------------------------------------------------------------- /apps/client/src/components/Chart/Table.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { 3 | chakra, 4 | Table as ChakraTable, 5 | Tbody, 6 | Td, 7 | Th, 8 | Thead, 9 | Tr, 10 | } from "@chakra-ui/react"; 11 | import { 12 | flexRender, 13 | getCoreRowModel, 14 | getPaginationRowModel, 15 | getSortedRowModel, 16 | useReactTable, 17 | } from "@tanstack/react-table"; 18 | import { TriangleDownIcon, TriangleUpIcon } from "@chakra-ui/icons"; 19 | import type { SortingState, ColumnDef } from "@tanstack/react-table"; 20 | 21 | export type DataTableProps = { 22 | data: TData[]; 23 | columns: ColumnDef[]; 24 | }; 25 | 26 | // react-table with chakra ui components 27 | const Table = ({ 28 | data, 29 | columns, 30 | }: DataTableProps) => { 31 | const [sorting, setSorting] = useState([]); 32 | const table = useReactTable({ 33 | columns, 34 | data, 35 | getCoreRowModel: getCoreRowModel(), 36 | onSortingChange: setSorting, 37 | getSortedRowModel: getSortedRowModel(), 38 | getPaginationRowModel: getPaginationRowModel(), 39 | state: { 40 | sorting, 41 | }, 42 | }); 43 | 44 | return ( 45 | 46 | 47 | {table?.getHeaderGroups().map((headerGroup) => ( 48 | 49 | {headerGroup.headers.map((header) => { 50 | const meta: any = header.column.columnDef.meta; 51 | return ( 52 | 57 | {flexRender( 58 | header.column.columnDef.header, 59 | header.getContext() 60 | )} 61 | 62 | 63 | {header.column.getIsSorted() ? ( 64 | header.column.getIsSorted() === "desc" ? ( 65 | 66 | ) : ( 67 | 68 | ) 69 | ) : null} 70 | 71 | 72 | ); 73 | })} 74 | 75 | ))} 76 | 77 | 78 | {table?.getRowModel().rows.map((row) => ( 79 | 80 | {row.getVisibleCells().map((cell) => { 81 | const meta: any = cell.column.columnDef.meta; 82 | return ( 83 | 84 | {flexRender(cell.column.columnDef.cell, cell.getContext())} 85 | 86 | ); 87 | })} 88 | 89 | ))} 90 | 91 | 92 | ); 93 | }; 94 | 95 | export default Table; 96 | -------------------------------------------------------------------------------- /apps/client/src/components/Chart/index.ts: -------------------------------------------------------------------------------- 1 | export { default as BarChart } from "./BarChart"; 2 | export { default as HorizontalBarChart } from "./HorizontalBarChart"; 3 | export { default as Stat } from "./Stat"; 4 | export { default as Table } from "./Table"; 5 | export { default as PieChart } from "./PieChart"; 6 | export { default as ScatterChart } from "./ScatterChart"; 7 | 8 | // utils 9 | export * from "./utils/colors"; 10 | -------------------------------------------------------------------------------- /apps/client/src/components/Chart/utils/colors.ts: -------------------------------------------------------------------------------- 1 | const hex2rgba = (hex: string, alpha = 1) => { 2 | const [r, g, b] = hex.match(/\w\w/g).map((x) => parseInt(x, 16)); 3 | return `rgba(${r},${g},${b},${alpha})`; 4 | }; 5 | 6 | const hexColors = [ 7 | "#ef4444", 8 | "#f97316", 9 | "#f59e0b", 10 | "#22c55e", 11 | "#14b8a6", 12 | "#06b6d4", 13 | "#0ea5e9", 14 | "#3b82f6", 15 | ]; 16 | 17 | // return an array of background/border colors with length of num 18 | const backgroundColors = (num: number) => 19 | hexColors.slice(0, num).map((color) => hex2rgba(color, 0.2)); 20 | const borderColors = (num: number) => hexColors.slice(0, num); 21 | 22 | // array of colors red and blue 23 | const greenAndRedBorderColors = ["#22c55e", "#ef4444"]; 24 | const greenAndRedBackgroundColors = [ 25 | hex2rgba("#22c55e", 0.2), 26 | hex2rgba("#ef4444", 0.2), 27 | ]; 28 | 29 | export { 30 | backgroundColors, 31 | borderColors, 32 | greenAndRedBorderColors, 33 | greenAndRedBackgroundColors, 34 | }; 35 | -------------------------------------------------------------------------------- /apps/client/src/components/Form/AutocompleteInput.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useCallback, useEffect, useRef } from "react"; 2 | 3 | import { useScript } from "@/hooks"; 4 | import { ControllerRenderProps } from "react-hook-form"; 5 | import { Select } from "@/components/Form"; 6 | 7 | const GOOGLE_MAPS_API_KEY = process.env 8 | .NEXT_PUBLIC_GOOGLE_MAPS_API_KEY as string; 9 | 10 | type AutocompleteService = google.maps.places.AutocompleteService | null; 11 | 12 | const AutoCompleteInput = forwardRef((props: ControllerRenderProps, ref) => { 13 | const status = useScript( 14 | `https://maps.googleapis.com/maps/api/js?key=${GOOGLE_MAPS_API_KEY}&v=beta&libraries=places` 15 | ); 16 | // Store the autocomplete service in a ref 17 | const autocompleteRef = useRef(null); 18 | 19 | // Given an input, return a list of predictions in the form label, value from autocomplete service 20 | const getOptions = useCallback(async (input: string) => { 21 | if (!autocompleteRef.current) return []; 22 | const res = await autocompleteRef.current.getPlacePredictions( 23 | { 24 | input, 25 | region: "us", 26 | componentRestrictions: { country: "us" }, 27 | }, 28 | async (_prediction, status) => { 29 | if (status !== "OK") throw new Error(status); 30 | } 31 | ); 32 | const options = res.predictions.map((prediction) => ({ 33 | label: prediction.description, 34 | value: prediction.description, 35 | })); 36 | return options ?? []; 37 | }, []); 38 | 39 | useEffect(() => { 40 | if (status === "ready") { 41 | autocompleteRef.current = new google.maps.places.AutocompleteService(); 42 | } 43 | }, [status]); 44 | 45 | return ( 46 | 78 | )} 79 | /> 80 | 81 | {errors.official && errors.official.message} 82 | 83 | 84 | 85 | 88 | 89 | 90 | ); 91 | }; 92 | 93 | export default OfficialInput; 94 | -------------------------------------------------------------------------------- /apps/client/src/components/Form/Select.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from "react"; 2 | import { 3 | AsyncProps, 4 | AsyncSelect, 5 | ChakraStylesConfig, 6 | GroupBase, 7 | Options, 8 | SelectInstance, 9 | } from "chakra-react-select"; 10 | 11 | type Option = { 12 | label: string; 13 | value: string; 14 | }; 15 | 16 | type SelectProps = { 17 | getOptions: (input: string) => Promise>; 18 | } & AsyncProps>; 19 | 20 | type SelectRef = SelectInstance>; 21 | 22 | const Select = forwardRef((props, ref) => { 23 | // Custom debounce function that given a function and a delay that debounces the given function 24 | const debounce = (fn: (...args: any[]) => void, delay = 300) => { 25 | let timeout: ReturnType; 26 | 27 | return (...args: any) => { 28 | clearTimeout(timeout); 29 | timeout = setTimeout(() => { 30 | fn(...args); 31 | }, delay); 32 | }; 33 | }; 34 | 35 | const loadOptionsDebounced = debounce( 36 | async (input: string, callback: (options: Options