├── .husky ├── .gitignore ├── pre-commit └── commit-msg ├── server ├── models │ ├── .gitkeep │ ├── StakeholderV2.ts │ ├── Gis.ts │ ├── Transaction.ts │ ├── Country.ts │ ├── CountryV2.ts │ ├── Countries.spec.ts │ ├── Wallets.spec.ts │ ├── Tokens.spec.ts │ ├── SpeciesV2.ts │ ├── Capture.ts │ ├── Contract.ts │ ├── Tokens.ts │ ├── RawCapture.ts │ ├── Bounds.ts │ ├── Wallets.ts │ ├── Organization.ts │ ├── Species.ts │ ├── OrganizationV2.ts │ ├── Planter.ts │ ├── GrowerAccount.ts │ └── TreeV2.ts ├── interfaces │ ├── DbModel.ts │ ├── Bounds.ts │ ├── Species.ts │ ├── Country.ts │ ├── Tree.ts │ ├── Capture.ts │ ├── FilterOptions.ts │ ├── RawCapture.ts │ ├── Wallets.ts │ ├── Stakeholder.ts │ ├── Planter.ts │ ├── Organization.ts │ ├── GrowerAccount.ts │ ├── Tokens.ts │ ├── Contract.ts │ ├── Transaction.ts │ ├── SpeciesFilter.ts │ ├── ContractFilter.ts │ ├── GrowerAccountFilter.ts │ ├── RawCaptureFilter.ts │ └── CaptureFilter.ts ├── services │ ├── parseCentroid.ts │ └── parseCentroid.spec.ts ├── setup.ts ├── loglevel-setup.ts ├── infra │ └── database │ │ ├── delegateRepository.ts │ │ ├── StakeholderRepositoryV2.ts │ │ ├── knex.ts │ │ ├── TreeRepositoryV2.spec.ts │ │ ├── StakeholderRepositoryV2.spec.ts │ │ ├── Session.ts │ │ ├── TransactionRepository.ts │ │ ├── patch.ts │ │ ├── CountryRepositoryV2.ts │ │ ├── BoundsRepository.ts │ │ ├── GisRepository.ts │ │ ├── SpeciesRepositoryV2.ts │ │ ├── TokensRepository.ts │ │ ├── SpeciesRepository.ts │ │ ├── BaseRepository.ts │ │ └── OrganizationRepository.ts ├── app.spec.ts ├── server.ts ├── routers │ ├── stakeholderRouterV2.ts │ ├── boundsRouter.ts │ ├── gisRouter.ts │ ├── transactionsRouter.ts │ ├── tokensRouter.ts │ ├── speciesRouter.ts │ ├── speciesRouterV2.ts │ ├── utils.ts │ ├── walletsRouter.ts │ ├── organizationsRouterV2.ts │ ├── organizationsRouter.ts │ ├── treesRouterV2.ts │ ├── plantersRouter.ts │ ├── contractsRouter.ts │ ├── countriesRouter.ts │ ├── treesRouter.ts │ ├── capturesRouter.ts │ └── rawCapturesRouter.ts ├── utils │ └── HttpError.ts └── app.ts ├── .dockerignore ├── .github ├── workflows │ ├── .gitkeep │ ├── pull-request-ci.yml │ ├── deploy-test-env.yml │ ├── deploy-prod-env.yml │ └── treetracker-query-api-build-deploy-dev.yml └── ISSUE_TEMPLATE │ ├── basic-issue.md │ └── implement-api.md ├── database ├── migrations │ └── .gitkeep └── database.json.example ├── .npmrc ├── scripts ├── vars.sh └── setup-dev-database-passwords.sh ├── docs └── api │ └── spec │ └── examples │ ├── planters │ ├── 940 │ │ ├── organizations.json │ │ └── trees.json │ └── 940.json │ ├── species │ ├── 1.json │ └── 2.json │ ├── countries │ ├── 6632375.json │ ├── 6632544.json │ ├── 6632357.json │ ├── 6632386.json │ └── leader.json │ ├── wallets │ └── 180Earth.json │ ├── transactions │ ├── 1.json │ ├── 2.json │ └── 3.json │ ├── tokens │ ├── 2.json │ ├── 3.json │ ├── 4.json │ └── 1.json │ ├── organizations │ └── 1.json │ └── trees │ ├── 186734.json │ ├── 186736.json │ ├── 186735.json │ └── 186737.json ├── typings-custom └── express-lru.d.ts ├── .prettierignore ├── commitlint.config.js ├── .eslintignore ├── .gitignore ├── deployment ├── base │ ├── namespace.yaml │ ├── kustomization.yaml │ ├── mapping.yaml │ ├── service.yaml │ ├── deployment.yaml │ └── database-connection-sealed-secret.yaml └── overlays │ ├── development │ ├── kustomization.yaml │ └── mapping.yaml │ ├── test │ ├── kustomization.yaml │ ├── mapping.yaml │ └── database-connection-sealed-secret.yaml │ └── prod │ ├── kustomization.yaml │ ├── treetracker-webmap-api-mapping.yaml │ ├── deployment.yaml │ └── database-connection-sealed-secret.yaml ├── .prettierrc.json ├── .jest ├── globalSetup.ts └── setupFile.ts ├── .env.example ├── config └── config.js ├── tsconfig.build.json ├── Dockerfile ├── tsconfig.json ├── .lintstagedrc.js ├── __tests__ ├── e2e │ ├── captures.spec.ts │ ├── bounds.spec.ts │ ├── wallets.spec.ts │ ├── organizations.spec.ts │ ├── species.spec.ts │ ├── planters.spec.ts │ ├── countries.spec.ts │ ├── transactions.spec.ts │ └── tokens.spec.ts ├── supertest.js ├── seed.js └── seed.spec.js ├── jest.config.js ├── .releaserc ├── .vscode └── launch.json ├── CONTRIBUTING.md ├── .prettierrc.js ├── CODEOWNERS ├── .eslintrc.js └── package.json /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /server/models/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /.github/workflows/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /database/migrations/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /scripts/vars.sh: -------------------------------------------------------------------------------- 1 | SCHEMA=query 2 | -------------------------------------------------------------------------------- /docs/api/spec/examples/planters/940/organizations.json: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /typings-custom/express-lru.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'express-lru'; 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | coverage/ 4 | package-lock.json 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@commitlint/config-conventional'] }; 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | database/migrations/ 3 | dist/ 4 | coverage/ 5 | 6 | **/*.js 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | database.json 3 | .env* 4 | .history 5 | .eslintcache 6 | dist/ 7 | -------------------------------------------------------------------------------- /deployment/base/namespace.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | name: webmap 5 | -------------------------------------------------------------------------------- /server/interfaces/DbModel.ts: -------------------------------------------------------------------------------- 1 | export default interface DbModel { 2 | [key: string]: any; 3 | } 4 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "all", 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /docs/api/spec/examples/species/1.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 1, 3 | "name": "Baobab Tree", 4 | "count": 10 5 | } 6 | -------------------------------------------------------------------------------- /docs/api/spec/examples/species/2.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 2, 3 | "name": "Wattle Tree", 4 | "count": 2 5 | } 6 | -------------------------------------------------------------------------------- /.jest/globalSetup.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv'; 2 | dotenv.config(); 3 | export default function globalSetup() {} 4 | -------------------------------------------------------------------------------- /deployment/overlays/development/kustomization.yaml: -------------------------------------------------------------------------------- 1 | bases: 2 | - ../../base 3 | patchesStrategicMerge: 4 | - mapping.yaml 5 | -------------------------------------------------------------------------------- /docs/api/spec/examples/planters/940/trees.json: -------------------------------------------------------------------------------- 1 | { 2 | "summary": { 3 | "total": "0" 4 | }, 5 | "trees": [{}] 6 | } 7 | -------------------------------------------------------------------------------- /.jest/setupFile.ts: -------------------------------------------------------------------------------- 1 | import knex from 'infra/database/knex'; 2 | 3 | afterAll(async () => { 4 | await knex.destroy(); 5 | }); 6 | -------------------------------------------------------------------------------- /deployment/overlays/test/kustomization.yaml: -------------------------------------------------------------------------------- 1 | bases: 2 | - ../../base 3 | patchesStrategicMerge: 4 | - database-connection-sealed-secret.yaml 5 | -------------------------------------------------------------------------------- /deployment/base/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - deployment.yaml 3 | - mapping.yaml 4 | - service.yaml 5 | - database-connection-sealed-secret.yaml 6 | -------------------------------------------------------------------------------- /server/interfaces/Bounds.ts: -------------------------------------------------------------------------------- 1 | type Bounds = { 2 | ne: [lat: number, lon: number]; 3 | se: [lat: number, lon: number]; 4 | }; 5 | 6 | export default Bounds; 7 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL=postgresql://xxxxx:xxxxxxx@xxxxxxx.ondigitalocean.com:25060/database?ssl=true 2 | DATABASE_SCHEMA=schema_name 3 | NODE_LOG_LEVEL=debug 4 | -------------------------------------------------------------------------------- /deployment/overlays/prod/kustomization.yaml: -------------------------------------------------------------------------------- 1 | bases: 2 | - ../../base 3 | patchesStrategicMerge: 4 | - treetracker-webmap-api-mapping.yaml 5 | - deployment.yaml 6 | -------------------------------------------------------------------------------- /config/config.js: -------------------------------------------------------------------------------- 1 | exports.connectionString = process.env.DATABASE_URL; 2 | exports.connectionStringMainDB = process.env.DATABASE_URL_MAINDB; 3 | exports.sentryDSN = ''; 4 | -------------------------------------------------------------------------------- /server/services/parseCentroid.ts: -------------------------------------------------------------------------------- 1 | export default function (json) { 2 | return { 3 | type: json.type, 4 | lat: json.coordinates[0], 5 | lon: json.coordinates[1], 6 | }; 7 | } 8 | -------------------------------------------------------------------------------- /docs/api/spec/examples/countries/6632375.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 6632375, 3 | "name": "Uganda", 4 | "centroid": { 5 | "lon": 32.36907971370351, 6 | "lat": 1.2746929873087136 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /docs/api/spec/examples/countries/6632544.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 6632544, 3 | "name": "China", 4 | "centroid": { 5 | "lon": 103.81907314582409, 6 | "lat": 36.56176537925268 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "files": ["./server/server.ts"], 4 | "include": ["server/**/*.ts"], 5 | "exclude": ["**/*.spec.*", "**/*.test.*"] 6 | } 7 | -------------------------------------------------------------------------------- /docs/api/spec/examples/countries/6632357.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 6632357, 3 | "name": "United States", 4 | "centroid": { 5 | "lon": -112.46167369956703, 6 | "lat": 45.67954720255 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /docs/api/spec/examples/countries/6632386.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 6632386, 3 | "name": "Tanzania", 4 | "centroid": { 5 | "lon": 34.813099809324555, 6 | "lat": -6.275654083316639 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /server/interfaces/Species.ts: -------------------------------------------------------------------------------- 1 | import DbModel from './DbModel'; 2 | 3 | export default interface Species extends DbModel { 4 | id: number; 5 | first_name: string; 6 | last_name: string; 7 | } 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16-alpine 2 | WORKDIR /app 3 | ENV PATH /app/node_modules/.bin:$PATH 4 | COPY package.json ./ 5 | COPY package-lock.json ./ 6 | RUN npm ci 7 | COPY dist ./dist/ 8 | CMD [ "npm", "start" ] 9 | -------------------------------------------------------------------------------- /server/interfaces/Country.ts: -------------------------------------------------------------------------------- 1 | import DbModel from './DbModel'; 2 | 3 | export default interface Country extends DbModel { 4 | name: string; 5 | code: string; 6 | lat: number; 7 | lon: number; 8 | } 9 | -------------------------------------------------------------------------------- /server/interfaces/Tree.ts: -------------------------------------------------------------------------------- 1 | import DbModel from './DbModel'; 2 | 3 | export default interface Tree extends DbModel { 4 | id: number; 5 | lat: number; 6 | lon: number; 7 | time_created: string; 8 | } 9 | -------------------------------------------------------------------------------- /server/interfaces/Capture.ts: -------------------------------------------------------------------------------- 1 | import DbModel from './DbModel'; 2 | 3 | export default interface Capture extends DbModel { 4 | id: number; 5 | lat: number; 6 | lon: number; 7 | time_created: string; 8 | } 9 | -------------------------------------------------------------------------------- /server/interfaces/FilterOptions.ts: -------------------------------------------------------------------------------- 1 | type FilterOptions = { 2 | limit?: number; 3 | offset?: number; 4 | orderBy?: { column: string; direction?: 'asc' | 'desc' }; 5 | }; 6 | 7 | export default FilterOptions; 8 | -------------------------------------------------------------------------------- /server/interfaces/RawCapture.ts: -------------------------------------------------------------------------------- 1 | import DbModel from './DbModel'; 2 | 3 | export default interface Capture extends DbModel { 4 | id: number; 5 | lat: number; 6 | lon: number; 7 | time_created: string; 8 | } 9 | -------------------------------------------------------------------------------- /server/setup.ts: -------------------------------------------------------------------------------- 1 | import log from 'loglevel'; 2 | 3 | if (process.env.NODE_LOG_LEVEL) { 4 | log.setDefaultLevel(process.env.NODE_LOG_LEVEL as log.LogLevelDesc); 5 | } else { 6 | log.setDefaultLevel('info'); 7 | } 8 | -------------------------------------------------------------------------------- /docs/api/spec/examples/wallets/180Earth.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "0248f77a-1531-11ec-82a8-0242ac130003", 3 | "name": "180Earth", 4 | "token_in_wallet": 22, 5 | "photo_url": "'https://180.earth/wp-content/uploads/2020/01/Asset-1.png" 6 | } 7 | -------------------------------------------------------------------------------- /database/database.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "dev": { 3 | "driver": "pg", 4 | "user" : "", 5 | "password" : "", 6 | "database" : "", 7 | "host" : "", 8 | "port" : "", 9 | "ssl" : "", 10 | "schema" : "" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /deployment/base/mapping.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: getambassador.io/v2 2 | kind: Mapping 3 | metadata: 4 | name: treetracker-query-api 5 | namespace: webmap 6 | spec: 7 | prefix: /query/ 8 | service: treetracker-query-api 9 | rewrite: / 10 | timeout_ms: 0 11 | -------------------------------------------------------------------------------- /docs/api/spec/examples/transactions/1.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 1, 3 | "token_id": 1, 4 | "source_wallet_id": 1, 5 | "destination_wallet_id": 2, 6 | "source_wallet_name": "apple", 7 | "destination_wallet_name": "orange", 8 | "processed_at": "2018-02-28" 9 | } 10 | -------------------------------------------------------------------------------- /docs/api/spec/examples/transactions/2.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 2, 3 | "token_id": 2, 4 | "source_wallet_id": 2, 5 | "destination_wallet_id": 1, 6 | "source_wallet_name": "orange", 7 | "destination_wallet_name": "apple", 8 | "processed_at": "2018-03-28" 9 | } 10 | -------------------------------------------------------------------------------- /docs/api/spec/examples/transactions/3.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 3, 3 | "token_id": 3, 4 | "source_wallet_id": 1, 5 | "destination_wallet_id": 2, 6 | "source_wallet_name": "apple", 7 | "destination_wallet_name": "orange", 8 | "processed_at": "2018-09-28" 9 | } 10 | -------------------------------------------------------------------------------- /server/interfaces/Wallets.ts: -------------------------------------------------------------------------------- 1 | import DbModel from './DbModel'; 2 | 3 | export default interface Wallets extends DbModel { 4 | id: string; 5 | name: string; 6 | password: string; 7 | salt: string; 8 | logo_url: string; 9 | created_at: string; 10 | } 11 | -------------------------------------------------------------------------------- /deployment/base/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: treetracker-query-api 5 | spec: 6 | selector: 7 | app: treetracker-query-api 8 | ports: 9 | - name: http 10 | protocol: TCP 11 | port: 80 12 | targetPort: 3006 13 | -------------------------------------------------------------------------------- /server/loglevel-setup.ts: -------------------------------------------------------------------------------- 1 | import log from 'loglevel'; 2 | 3 | export default function initLogLevel() { 4 | if (process.env.NODE_LOG_LEVEL) { 5 | log.setDefaultLevel(process.env.NODE_LOG_LEVEL as log.LogLevelDesc); 6 | } else { 7 | log.setDefaultLevel('info'); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | // This is used by `ts language service` and testing. 2 | // Includes source and test files. 3 | { 4 | "extends": "./tsconfig.base.json", 5 | "include": [ 6 | "server/**/*.ts", 7 | "__tests__/**/*.ts", 8 | ".jest/**/*.ts", 9 | "./typings-custom/**/*.ts" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /server/interfaces/Stakeholder.ts: -------------------------------------------------------------------------------- 1 | import DbModel from './DbModel'; 2 | 3 | export default interface Stakeholder extends DbModel { 4 | id: string; 5 | type: string; 6 | org_name: string; 7 | first_name: string; 8 | last_name: string; 9 | email: string; 10 | phone: string; 11 | website: string; 12 | } 13 | -------------------------------------------------------------------------------- /server/interfaces/Planter.ts: -------------------------------------------------------------------------------- 1 | import DbModel from './DbModel'; 2 | 3 | export default interface Planter extends DbModel { 4 | id: number; 5 | first_name: string; 6 | last_name: string; 7 | links: { 8 | featured_trees: string; 9 | associated_organizations: string; 10 | species: string; 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /server/interfaces/Organization.ts: -------------------------------------------------------------------------------- 1 | import DbModel from './DbModel'; 2 | 3 | export default interface Organization extends DbModel { 4 | id: number; 5 | first_name: string; 6 | last_name: string; 7 | links: { 8 | featured_trees: string; 9 | associated_planters: string; 10 | species: string; 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /server/interfaces/GrowerAccount.ts: -------------------------------------------------------------------------------- 1 | import DbModel from './DbModel'; 2 | 3 | export default interface GrowerAccount extends DbModel { 4 | id: number; 5 | first_name: string; 6 | last_name: string; 7 | links: { 8 | featured_trees: string; 9 | associated_organizations: string; 10 | species: string; 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /server/interfaces/Tokens.ts: -------------------------------------------------------------------------------- 1 | import DbModel from './DbModel'; 2 | 3 | export default interface Tokens extends DbModel { 4 | id: string; 5 | capture_id: string; 6 | wallet_id: string; 7 | transfer_pending: boolean; 8 | transfer_pending_id: string; 9 | created_at: string; 10 | updated_at: string; 11 | claim: boolean; 12 | } 13 | -------------------------------------------------------------------------------- /server/models/StakeholderV2.ts: -------------------------------------------------------------------------------- 1 | import Stakeholder from 'interfaces/Stakeholder'; 2 | import { delegateRepository } from '../infra/database/delegateRepository'; 3 | import StakeholderRepositoryV2 from '../infra/database/StakeholderRepositoryV2'; 4 | 5 | export default { 6 | getById: delegateRepository('getById'), 7 | }; 8 | -------------------------------------------------------------------------------- /deployment/overlays/test/mapping.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: getambassador.io/v2 3 | kind: Mapping 4 | metadata: 5 | name: treetracker-query-api 6 | spec: 7 | cors: 8 | cors: 9 | origins: 10 | - '*' 11 | headers: 12 | - Content-Type 13 | - Authorization 14 | methods: 15 | - 'GET, POST, PATCH, PUT, OPTIONS, DELETE' 16 | -------------------------------------------------------------------------------- /server/interfaces/Contract.ts: -------------------------------------------------------------------------------- 1 | import DbModel from './DbModel'; 2 | 3 | export default interface Contract extends DbModel { 4 | id: number; 5 | agreement_id: number; 6 | worker_id: number; 7 | status: string; 8 | notes: string; 9 | created_at: string; 10 | updated_at: string; 11 | signed_at: string; 12 | closed_at: string; 13 | listed: true; 14 | } 15 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | GREEN='\033[0;32m' 5 | NC='\033[0m' # No Color 6 | echo "${GREEN}Checking commit message for compliance with conventional commits${NC}" 7 | echo "${GREEN}Speficiation: https://www.conventionalcommits.org/en/v1.0.0/${NC}" 8 | npx --no-install commitlint -H 'https://www.conventionalcommits.org/en/v1.0.0/' --edit $1 9 | -------------------------------------------------------------------------------- /deployment/overlays/development/mapping.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: getambassador.io/v2 3 | kind: Mapping 4 | metadata: 5 | name: treetracker-query-api 6 | namespace: webmap 7 | spec: 8 | cors: 9 | cors: 10 | origins: 11 | - '*' 12 | headers: 13 | - Content-Type 14 | - Authorization 15 | methods: 16 | - 'GET, POST, PATCH, PUT, OPTIONS, DELETE' 17 | -------------------------------------------------------------------------------- /server/interfaces/Transaction.ts: -------------------------------------------------------------------------------- 1 | import DbModel from './DbModel'; 2 | 3 | export default interface Transaction extends DbModel { 4 | id: string; 5 | token_id: string; 6 | source_wallet_id: string; 7 | destination_wallet_id: string; 8 | source_wallet_name: string; 9 | destination_wallet_name: string; 10 | processed_at: string; 11 | source_wallet_logo_url: string; 12 | } 13 | -------------------------------------------------------------------------------- /.lintstagedrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // fomat all files recognized by prettier 3 | '*': 'prettier --ignore-unknown --write', 4 | 5 | // lint and test typescript files after prettier 6 | '*.ts': ['eslint --fix --cache'], 7 | 8 | // lint entire project if eslint settings changed 9 | // do not pass file name arguments 10 | '.eslint*': () => 'eslint . --fix --cache', 11 | }; 12 | -------------------------------------------------------------------------------- /server/models/Gis.ts: -------------------------------------------------------------------------------- 1 | import log from 'loglevel'; 2 | import FilterOptions from 'interfaces/FilterOptions'; 3 | import Tree from 'interfaces/Tree'; 4 | import { delegateRepository } from '../infra/database/delegateRepository'; 5 | import GisRepository from '../infra/database/GisRepository'; 6 | 7 | export default { 8 | getNearest: delegateRepository('getNearest'), 9 | }; 10 | -------------------------------------------------------------------------------- /server/interfaces/SpeciesFilter.ts: -------------------------------------------------------------------------------- 1 | import DbModel from './DbModel'; 2 | 3 | interface SpeciesFilter extends DbModel { 4 | id?: number; 5 | scientific_name?: string; 6 | description?: string; 7 | limit?: number; 8 | offset?: number; 9 | keyword?: string; 10 | morphology?: string; 11 | range?: string; 12 | created_at?: string; 13 | updated_at?: string; 14 | } 15 | 16 | export default SpeciesFilter; 17 | -------------------------------------------------------------------------------- /server/infra/database/delegateRepository.ts: -------------------------------------------------------------------------------- 1 | type RepoFunction = ( 2 | repo: RepoType, 3 | ) => (...args: unknown[]) => Promise; 4 | 5 | export function delegateRepository( 6 | methodName: string, 7 | ): RepoFunction { 8 | return function (repo: T) { 9 | return async function (...args) { 10 | const result = await repo[methodName](...args); 11 | return result; 12 | }; 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /__tests__/e2e/captures.spec.ts: -------------------------------------------------------------------------------- 1 | import supertest from 'supertest'; 2 | import app from '../../server/app'; 3 | 4 | describe('', () => { 5 | it('v2/captures/{id}', async () => { 6 | const response = await supertest(app).get( 7 | '/v2/captures/bc276072-440f-4a2d-9c4a-69f7f29201e6', 8 | ); 9 | expect(response.status).toBe(200); 10 | expect(response.body).toMatchObject({ 11 | id: 'bc276072-440f-4a2d-9c4a-69f7f29201e6', 12 | }); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /server/services/parseCentroid.spec.ts: -------------------------------------------------------------------------------- 1 | import parseCentroid from './parseCentroid'; 2 | 3 | describe('parseCentroid', () => { 4 | it('successfully', () => { 5 | const result = parseCentroid( 6 | JSON.parse( 7 | '{"type":"Point","coordinates":[-11.7927124667898,8.56329593037589]}', 8 | ), 9 | ); 10 | expect(result).toMatchObject({ 11 | type: 'Point', 12 | lat: expect.any(Number), 13 | lon: expect.any(Number), 14 | }); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /deployment/overlays/prod/treetracker-webmap-api-mapping.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: getambassador.io/v2 3 | kind: Mapping 4 | metadata: 5 | name: treetracker-query-api 6 | namespace: webmap 7 | spec: 8 | cors: 9 | origins: 10 | - '*' 11 | - https://wallet.treetracker.org 12 | - https://freetown.treetracker.org 13 | - https://map.treetracker.org 14 | - https://forestmatic.com 15 | - https://app.forestmatic.com 16 | - https://staging-app.forestmatic.com 17 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | '^.+\\.(t|j)sx?$': ['@swc/jest'], 4 | }, 5 | testEnvironment: 'node', 6 | modulePaths: ['server/'], 7 | moduleNameMapper: { 8 | '@test/(.*)': ['/.jest/$1', '/__tests__/$1'], 9 | }, 10 | globalSetup: '/.jest/globalSetup.ts', 11 | setupFilesAfterEnv: ['/.jest/setupFile.ts'], 12 | maxConcurrency: 1, 13 | testPathIgnorePatterns: ['/dist/', '/node_modules/'], 14 | }; 15 | -------------------------------------------------------------------------------- /docs/api/spec/examples/countries/leader.json: -------------------------------------------------------------------------------- 1 | { 2 | "countries": [ 3 | { 4 | "id": 6632386, 5 | "name": "Tanzania", 6 | "planted": 100000 7 | }, 8 | { 9 | "id": 6632375, 10 | "name": "Uganda", 11 | "planted": 90000 12 | }, 13 | { 14 | "id": 6632544, 15 | "name": "China", 16 | "planted": 70000 17 | }, 18 | { 19 | "id": 6632357, 20 | "name": "United States", 21 | "planted": 60000 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "branches": ["main"], 3 | "plugins": [ 4 | "@semantic-release/commit-analyzer", 5 | "@semantic-release/release-notes-generator", 6 | "@semantic-release/changelog", 7 | ["@semantic-release/npm", { 8 | "npmPublish": false 9 | }], 10 | ["@semantic-release/git", { 11 | "assets": ["docs", "package.json", "CHANGELOG.md"], 12 | "message": "chore(release): ${nextRelease.version} [skip ci]" 13 | }], 14 | "@semantic-release/github" 15 | ] 16 | } -------------------------------------------------------------------------------- /server/interfaces/ContractFilter.ts: -------------------------------------------------------------------------------- 1 | import DbModel from './DbModel'; 2 | 3 | interface ContractFilter extends DbModel { 4 | id?: string | undefined; 5 | agreement_id?: string | undefined; 6 | worker_id?: string | undefined; 7 | status?: string | undefined; 8 | notes?: string | undefined; 9 | created_at?: string | undefined; 10 | updated_at?: string | undefined; 11 | signed_at?: string | undefined; 12 | closed_at?: string | undefined; 13 | listed?: true | false; 14 | } 15 | 16 | export default ContractFilter; 17 | -------------------------------------------------------------------------------- /server/app.spec.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | import server from 'app'; 3 | 4 | describe('', () => { 5 | it('Test header: content-type: application/json', async () => { 6 | const res = await request(server).get('/'); 7 | expect(res.statusCode).toBe(200); 8 | }); 9 | 10 | it('Test header: content-type: application/json', async () => { 11 | const res = await request(server).post('/'); 12 | expect(res.statusCode).toBe(415); 13 | expect(res.body).toHaveProperty('message', /application.json/); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /server/server.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv'; 2 | import log from 'loglevel'; 3 | import app from 'app'; 4 | import knex from 'infra/database/knex'; 5 | 6 | dotenv.config(); 7 | // setup log level 8 | require('./setup'); 9 | 10 | const port = process.env.NODE_PORT || 3006; 11 | 12 | const server = app.listen(port, () => { 13 | log.warn(`listening on port:${port}`); 14 | log.debug('debug log level!'); 15 | }); 16 | 17 | process.once('SIGINT', () => { 18 | console.log('Terminate request received...'); 19 | knex.destroy(); 20 | server.close(); 21 | }); 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/basic-issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Basic issue 3 | about: basic common issue 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | --- 10 | 11 | Some hints: 12 | 13 | - Please read our readme for more information/guide/tutorial. 14 | - Here is [an engineering book](https://greenstand.gitbook.io/engineering/) in Greenstand. 15 | - To know more about our organization, visit our [website](https://greenstand.org). 16 | - If you want to join the slack community (some resources need the community member's permission), please leave your email address. 17 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "pwa-node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "skipFiles": ["/**"], 12 | "program": "${workspaceFolder}/server/app.ts", 13 | "outFiles": ["${workspaceFolder}/**/*.js"] 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /server/interfaces/GrowerAccountFilter.ts: -------------------------------------------------------------------------------- 1 | import DbModel from './DbModel'; 2 | 3 | interface GrowerAccountFilter extends DbModel { 4 | id?: number; 5 | first_name?: string; 6 | last_name?: string; 7 | limit?: number; 8 | offset?: number; 9 | keyword?: string; 10 | organization_id?: string[] | string; 11 | person_id?: string; 12 | device_identifier?: string; 13 | wallet?: string; 14 | email?: string; 15 | phone?: string; 16 | captures_amount_min?: number; 17 | captures_amount_max?: number; 18 | } 19 | 20 | export default GrowerAccountFilter; 21 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Create your local git branch and rebase it from the shared master branch. Please make sure to rebuild your local database schemas using the migrations (as illustrated in the Database Setup section above) to capture any latest updates/changes. 4 | 5 | When you are ready to submit a pull request from your local branch, please rebase your branch off of the shared master branch again to integrate any new updates in the codebase before submitting. Any developers joining the project should feel free to review any outstanding pull requests and assign themselves to any open tickets on the Issues list. 6 | -------------------------------------------------------------------------------- /docs/api/spec/examples/tokens/2.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 2, 3 | "capture_id": 186735, 4 | "capture_photo_url": "https://treetracker-dev-images.s3.eu-central-1.amazonaws.com/2020.10.19.09.51.07_-5.5080179325596275_38.981510909625214_bd17cf5e-af94-4644-a02d-ca07d55a02ed_IMG_20201019_094716_5079900695025456370.jpg", 5 | "planter_id": 940, 6 | "planter_first_name": "Sebastian ", 7 | "planter_last_name": "Gaertner", 8 | "planter_photo_url": "https://treetracker-dev-images.s3.eu-central-1.amazonaws.com/2020.10.19.09.47.53_-5.508107173727935_38.981361706266256_39f0cc9d-0f13-4547-8142-150f15cabb67_IMG_20201019_094513_6614320100195503436.jpg" 9 | } 10 | -------------------------------------------------------------------------------- /docs/api/spec/examples/tokens/3.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 3, 3 | "capture_id": 186736, 4 | "capture_photo_url": "https://treetracker-dev-images.s3.eu-central-1.amazonaws.com/2020.10.19.09.50.52_-5.508076904796398_38.98152805626448_28181c3e-e5b9-442b-8bb4-00de35de3de2_IMG_20201019_094643_486288846930987329.jpg", 5 | "planter_id": 940, 6 | "planter_first_name": "Sebastian ", 7 | "planter_last_name": "Gaertner", 8 | "planter_photo_url": "https://treetracker-dev-images.s3.eu-central-1.amazonaws.com/2020.10.19.09.47.53_-5.508107173727935_38.981361706266256_39f0cc9d-0f13-4547-8142-150f15cabb67_IMG_20201019_094513_6614320100195503436.jpg" 9 | } 10 | -------------------------------------------------------------------------------- /docs/api/spec/examples/tokens/4.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 4, 3 | "capture_id": 186737, 4 | "capture_photo_url": "https://treetracker-dev-images.s3.eu-central-1.amazonaws.com/2020.10.19.09.50.04_-5.508159084408904_38.98143245004688_ae4ec0dc-0db0-4d92-abcb-f26e57517084_IMG_20201019_094534_4646184952038769407.jpg", 5 | "planter_id": 940, 6 | "planter_first_name": "Sebastian ", 7 | "planter_last_name": "Gaertner", 8 | "planter_photo_url": "https://treetracker-dev-images.s3.eu-central-1.amazonaws.com/2020.10.19.09.47.53_-5.508107173727935_38.981361706266256_39f0cc9d-0f13-4547-8142-150f15cabb67_IMG_20201019_094513_6614320100195503436.jpg" 9 | } 10 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: 'always', // include parens around sole arrow function parameters 3 | bracketSpacing: true, 4 | embeddedLanguageFormatting: 'auto', // format md code blocks based on language 5 | endOfLine: 'lf', 6 | insertPragma: false, 7 | printWidth: 80, 8 | proseWrap: 'preserve', // format long lines in md files 9 | quoteProps: 'as-needed', // add quotes to js object property names 10 | requirePragma: false, // enable to restrict prettier to specified files 11 | semi: false, // use semicolons 12 | singleQuote: true, 13 | tabWidth: 2, 14 | trailingComma: 'all', 15 | useTabs: false, 16 | }; 17 | -------------------------------------------------------------------------------- /server/interfaces/RawCaptureFilter.ts: -------------------------------------------------------------------------------- 1 | import DbModel from './DbModel'; 2 | 3 | interface RawCaptureFilter extends DbModel { 4 | organization_id?: Array | undefined; 5 | reference_id?: string | undefined; 6 | session_id?: string | undefined; 7 | grower_account_id?: string | undefined; 8 | startDate?: string | undefined; 9 | endDate?: string | undefined; 10 | id?: string | undefined; 11 | species_id?: string | undefined; 12 | tag?: string | undefined; 13 | device_identifier?: string | undefined; 14 | wallet?: string | undefined; 15 | status?: string | undefined; 16 | sort?: { order?: string; order_by?: string }; 17 | } 18 | 19 | export default RawCaptureFilter; 20 | -------------------------------------------------------------------------------- /docs/api/spec/examples/tokens/1.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 1, 3 | "wallet_id": "0248f77a-1531-11ec-82a8-0242ac130003", 4 | "capture_id": 186734, 5 | "capture_photo_url": "https://treetracker-dev-images.s3.eu-central-1.amazonaws.com/2020.10.19.09.50.38_-5.508172399749922_38.98146973686408_6bebe71e-5369-4ae0-8c47-9eeff6599fb0_IMG_20201019_094615_7537040365910944885.jpg", 6 | "planter_id": 940, 7 | "planter_first_name": "Sebastian ", 8 | "planter_last_name": "Gaertner", 9 | "planter_photo_url": "https://treetracker-dev-images.s3.eu-central-1.amazonaws.com/2020.10.19.09.47.53_-5.508107173727935_38.981361706266256_39f0cc9d-0f13-4547-8142-150f15cabb67_IMG_20201019_094513_6614320100195503436.jpg", 10 | "created_at": "2018-01-01" 11 | } 12 | -------------------------------------------------------------------------------- /server/routers/stakeholderRouterV2.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import Joi from 'joi'; 3 | import { handlerWrapper } from './utils'; 4 | import Session from '../infra/database/Session'; 5 | import StakeholderRepositoryV2 from '../infra/database/StakeholderRepositoryV2'; 6 | import StakeholderModel from '../models/StakeholderV2'; 7 | 8 | const router = express.Router(); 9 | 10 | router.get( 11 | '/:id', 12 | handlerWrapper(async (req, res) => { 13 | Joi.assert(req.params.id, Joi.string().required()); 14 | const repo = new StakeholderRepositoryV2(new Session()); 15 | const exe = StakeholderModel.getById(repo); 16 | const result = await exe(req.params.id); 17 | res.send(result); 18 | res.end(); 19 | }), 20 | ); 21 | 22 | export default router; 23 | -------------------------------------------------------------------------------- /server/infra/database/StakeholderRepositoryV2.ts: -------------------------------------------------------------------------------- 1 | import Stakeholder from 'interfaces/Stakeholder'; 2 | import HttpError from 'utils/HttpError'; 3 | import BaseRepository from './BaseRepository'; 4 | import Session from './Session'; 5 | 6 | export default class StakeholderRepositoryV2 extends BaseRepository { 7 | constructor(session: Session) { 8 | super('stakeholder.stakeholder', session); 9 | } 10 | 11 | async getById(id: string | number) { 12 | const object = await this.session 13 | .getDB() 14 | .select() 15 | .from(this.tableName) 16 | .where('id', id) 17 | .first(); 18 | 19 | if (!object) { 20 | throw new HttpError(404, `Can not find ${this.tableName} by id:${id}!`); 21 | } 22 | return object; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /server/models/Transaction.ts: -------------------------------------------------------------------------------- 1 | import TransactionRepository from 'infra/database/TransactionRepository'; 2 | import FilterOptions from 'interfaces/FilterOptions'; 3 | import Transaction from 'interfaces/Transaction'; 4 | 5 | function getByFilter( 6 | transactionRepository: TransactionRepository, 7 | ): ( 8 | filter: Partial<{ token_id: string; wallet_id: string }>, 9 | options: FilterOptions, 10 | ) => Promise { 11 | return async function ( 12 | filter: Partial<{ token_id: string; wallet_id: string }>, 13 | options: FilterOptions, 14 | ) { 15 | const transactions = await transactionRepository.getByFilter( 16 | filter, 17 | options, 18 | ); 19 | return transactions; 20 | }; 21 | } 22 | 23 | export default { 24 | getByFilter, 25 | }; 26 | -------------------------------------------------------------------------------- /server/interfaces/CaptureFilter.ts: -------------------------------------------------------------------------------- 1 | import DbModel from './DbModel'; 2 | 3 | interface CaptureFilter extends DbModel { 4 | organization_id?: Array | undefined; 5 | reference_id?: string | undefined; 6 | session_id?: string | undefined; 7 | grower_account_id?: string | undefined; 8 | startDate?: string | undefined; 9 | endDate?: string | undefined; 10 | id?: string | undefined; 11 | tree_id?: string | undefined; 12 | species_id?: string | undefined; 13 | tag?: string | undefined; 14 | device_identifier?: string | undefined; 15 | wallet?: string | undefined; 16 | token_id?: string | undefined; 17 | tokenized?: string | undefined; 18 | status?: string | undefined; 19 | sort?: { order?: string; order_by?: string }; 20 | } 21 | 22 | export default CaptureFilter; 23 | -------------------------------------------------------------------------------- /__tests__/e2e/bounds.spec.ts: -------------------------------------------------------------------------------- 1 | import supertest from 'supertest'; 2 | import app from '../../server/app'; 3 | 4 | describe('testing bounds route', () => { 5 | it('bounds for planter', async () => { 6 | const response = await supertest(app).get('/bounds/?planter_id=5838'); 7 | expect(response.status).toBe(200); 8 | const { bounds } = response.body; 9 | expect(bounds.ne).toBeInstanceOf(Array); 10 | expect(bounds.se).toBeInstanceOf(Array); 11 | }); 12 | 13 | it('bounds for organisation', async () => { 14 | const response = await supertest(app).get('/bounds/?organisation_id=30'); 15 | expect(response.status).toBe(200); 16 | const { bounds } = response.body; 17 | expect(bounds.ne).toBeInstanceOf(Array); 18 | expect(bounds.se).toBeInstanceOf(Array); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/implement-api.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Implement API 3 | about: To implement an API for the micro-service 4 | title: 'Implement API: GET ' 5 | labels: Express, medium, Node.js, postgresql 6 | assignees: '' 7 | --- 8 | 9 | To implement an endpoint: 10 | 11 | - Please write an e2e test to cover it. 12 | - Please follow the guide in the 'readme' to follow our architecture on the server-side. 13 | 14 | --- 15 | 16 | Some hints: 17 | 18 | - Please read our readme for more information/guide/tutorial. 19 | - Here is [an engineering book](https://greenstand.gitbook.io/engineering/) in Greenstand. 20 | - To know more about our organization, visit our [website](https://greenstand.org). 21 | - If you want to join the slack community (some resources need the community member's permission), please leave your email address. 22 | -------------------------------------------------------------------------------- /server/infra/database/knex.ts: -------------------------------------------------------------------------------- 1 | import { Knex, knex } from 'knex'; 2 | import log from 'loglevel'; 3 | 4 | const connection = process.env.DATABASE_URL; 5 | 6 | !connection && log.warn('env var DATABASE_URL not set'); 7 | 8 | const max = 9 | (process.env.DATABASE_POOL_MAX && parseInt(process.env.DATABASE_POOL_MAX)) || 10 | 10; 11 | log.warn('knex pool max:', max); 12 | 13 | const knexConfig: Knex.Config = { 14 | client: 'pg', 15 | // debug: process.env.NODE_LOG_LEVEL === 'debug', 16 | debug: true, 17 | connection, 18 | pool: { min: 0, max }, 19 | }; 20 | 21 | log.debug(process.env.DATABASE_SCHEMA); 22 | if (process.env.DATABASE_SCHEMA) { 23 | log.info('setting a schema'); 24 | knexConfig.searchPath = [process.env.DATABASE_SCHEMA, 'public']; 25 | } 26 | log.debug(knexConfig.searchPath); 27 | 28 | export default knex(knexConfig); 29 | -------------------------------------------------------------------------------- /server/utils/HttpError.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * To define a extended error for API, can pass through the error message, http 3 | * code, to bring some convenient for the internal class to throw out the error 4 | * and the outside of the layer can catch the error and convert to a http 5 | * response to client 6 | */ 7 | 8 | export default class HttpError extends Error { 9 | code: number; 10 | 11 | toRollback: boolean; 12 | 13 | constructor(code: number, message: string, toRollback = true) { 14 | super(message); 15 | Object.setPrototypeOf(this, new.target.prototype); // restore prototype chain 16 | this.code = code; 17 | // set rollback flag, so the transaction of db would rollback when catch this error 18 | // set default to true 19 | this.toRollback = toRollback; 20 | } 21 | 22 | shouldRollback() { 23 | return this.toRollback; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /server/infra/database/TreeRepositoryV2.spec.ts: -------------------------------------------------------------------------------- 1 | import mockDb from 'mock-knex'; 2 | import Session from './Session'; 3 | import TreeRepositoryV2 from './TreeRepositoryV2'; 4 | 5 | describe('TreeRepositoryV2', () => { 6 | it('getById', async () => { 7 | const session = new Session(); 8 | mockDb.mock(session.getDB()); 9 | // eslint-disable-next-line 10 | var tracker = require('mock-knex').getTracker(); 11 | 12 | tracker.install(); 13 | tracker.on('query', (query) => { 14 | expect(query.sql).toBe( 15 | 'select * from "treetracker"."tree" where "id" = $1 limit $2', 16 | ); 17 | query.response([{ id: 'uuid' }]); 18 | }); 19 | 20 | const repo = new TreeRepositoryV2(session); 21 | const result = await repo.getById('uuid'); 22 | expect(result).toMatchObject({ 23 | id: 'uuid', 24 | }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /server/models/Country.ts: -------------------------------------------------------------------------------- 1 | import Country from 'interfaces/Country'; 2 | import CountryRepository from '../infra/database/CountryRepository'; 3 | import { delegateRepository } from '../infra/database/delegateRepository'; 4 | 5 | type Filter = Partial<{ lat: number; lon: number }>; 6 | 7 | function getCountries( 8 | countryRepository: CountryRepository, 9 | ): (filter: Filter) => Promise { 10 | return async function (filter: Filter) { 11 | const countries = await countryRepository.getByFilter(filter); 12 | return countries; 13 | }; 14 | } 15 | 16 | export default { 17 | getCountries, 18 | getById: delegateRepository('getById'), 19 | getByFilter: delegateRepository('getByFilter'), 20 | getLeaderBoard: delegateRepository( 21 | 'getLeaderBoard', 22 | ), 23 | }; 24 | -------------------------------------------------------------------------------- /server/models/CountryV2.ts: -------------------------------------------------------------------------------- 1 | import CountryRepositoryV2 from 'infra/database/CountryRepositoryV2'; 2 | import Country from 'interfaces/Country'; 3 | import { delegateRepository } from '../infra/database/delegateRepository'; 4 | 5 | type Filter = Partial<{ lat: number; lon: number }>; 6 | 7 | function getCountries( 8 | countryRepository: CountryRepositoryV2, 9 | ): (filter: Filter) => Promise { 10 | return async function (filter: Filter) { 11 | const countries = await countryRepository.getByFilter(filter); 12 | return countries; 13 | }; 14 | } 15 | 16 | export default { 17 | getCountries, 18 | getById: delegateRepository('getById'), 19 | getByFilter: delegateRepository('getByFilter'), 20 | getLeaderBoard: delegateRepository( 21 | 'getLeaderBoard', 22 | ), 23 | }; 24 | -------------------------------------------------------------------------------- /server/models/Countries.spec.ts: -------------------------------------------------------------------------------- 1 | import CountryModel from './Country'; 2 | 3 | describe('/countries', () => { 4 | it('', async () => { 5 | const country1 = { 6 | name: 'China', 7 | }; 8 | const repo: any = { 9 | getByFilter: jest.fn(() => Promise.resolve([country1])), 10 | }; 11 | const executeGetCountries = CountryModel.getCountries(repo); 12 | const result = await executeGetCountries({ lat: 0, lon: 0 }); 13 | expect(result).toMatchObject([country1]); 14 | }); 15 | 16 | it('getById', async () => { 17 | const country1 = { 18 | name: 'China', 19 | }; 20 | const repo: any = { 21 | getById: jest.fn(() => Promise.resolve(country1)), 22 | }; 23 | const execute = CountryModel.getById(repo); 24 | const result = await execute(1); 25 | expect(result).toMatchObject(country1); 26 | expect(repo.getById).toBeCalledWith(1); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /server/models/Wallets.spec.ts: -------------------------------------------------------------------------------- 1 | import WalletsModel from './Wallets'; 2 | 3 | describe('/wallets', () => { 4 | it('get wallet by id or name', async () => { 5 | const wallets = { 6 | id: '88a33d5b-5c47-4a32-8572-0899817d3f38', 7 | name: 'NewWalletByAutoTool_49836', 8 | password: null, 9 | salt: null, 10 | logo_url: null, 11 | created_at: '2021-10-08T02:33:20.732Z', 12 | }; 13 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 14 | const repo: any = { 15 | getWalletByIdOrName: jest.fn(() => Promise.resolve(wallets)), 16 | }; 17 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 18 | // @ts-ignore 19 | const execute = WalletsModel.getWalletByIdOrName(repo); 20 | const result = await execute(1); 21 | expect(result).toMatchObject(wallets); 22 | expect(repo.getWalletByIdOrName).toBeCalledWith(1); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /server/infra/database/StakeholderRepositoryV2.spec.ts: -------------------------------------------------------------------------------- 1 | import mockDb from 'mock-knex'; 2 | import Session from './Session'; 3 | import StakeholderRepositoryV2 from './StakeholderRepositoryV2'; 4 | 5 | describe('StakeholderRepositoryV2', () => { 6 | it('getById', async () => { 7 | const session = new Session(); 8 | mockDb.mock(session.getDB()); 9 | // eslint-disable-next-line 10 | var tracker = require('mock-knex').getTracker(); 11 | 12 | tracker.install(); 13 | tracker.on('query', (query) => { 14 | expect(query.sql).toBe( 15 | 'select * from "stakeholder"."stakeholder" where "id" = $1 limit $2', 16 | ); 17 | query.response([{ id: 'mock-uuid' }]); 18 | }); 19 | 20 | const repo = new StakeholderRepositoryV2(session); 21 | const result = await repo.getById('mock-uuid'); 22 | expect(result).toMatchObject({ 23 | id: 'mock-uuid', 24 | }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /server/models/Tokens.spec.ts: -------------------------------------------------------------------------------- 1 | import TokensModel from './Tokens'; 2 | 3 | describe('Tokens', () => { 4 | it('get token by id ', async () => { 5 | const token = { 6 | id: '24f4f5f7-c29e-4707-961a-3515be5a2f3e', 7 | capture_id: 'f6c0e710-d80a-4e93-a9d2-d4edb52856af', 8 | wallet_id: 'eecdf253-05b6-419a-8425-416a3e5fc9a0', 9 | transfer_pending: false, 10 | transfer_pending_id: null, 11 | created_at: '2021-02-18T23:53:29.172Z', 12 | updated_at: '2021-02-18T23:53:29.172Z', 13 | claim: false, 14 | }; 15 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 16 | const repo: any = { 17 | getById: jest.fn(() => Promise.resolve(token)), 18 | }; 19 | 20 | const execute = TokensModel.getById(repo); 21 | const result = await execute(1); 22 | expect(result).toMatchObject(token); 23 | expect(repo.getById).toBeCalledWith(1); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /server/routers/boundsRouter.ts: -------------------------------------------------------------------------------- 1 | import { Router, Request, Response } from 'express'; 2 | import Joi from 'joi'; 3 | import BoundsRepository from 'infra/database/BoundsRepository'; 4 | import Session from 'infra/database/Session'; 5 | import { handlerWrapper } from './utils'; 6 | import BoundsModel from '../models/Bounds'; 7 | 8 | const router = Router(); 9 | router.get( 10 | '/', 11 | handlerWrapper(async (req: Request, res: Response) => { 12 | Joi.assert( 13 | req.query, 14 | Joi.object().keys({ 15 | planter_id: Joi.number().integer().min(0), 16 | organisation_id: Joi.number().integer().min(0), 17 | wallet_id: Joi.number().integer().min(0), 18 | }), 19 | ); 20 | const repo = new BoundsRepository(new Session()); 21 | const result = await BoundsModel.getByFilter(repo)(req.query); 22 | res.send({ 23 | bounds: result, 24 | }); 25 | res.end(); 26 | }), 27 | ); 28 | 29 | export default router; 30 | -------------------------------------------------------------------------------- /server/models/SpeciesV2.ts: -------------------------------------------------------------------------------- 1 | import log from 'loglevel'; 2 | import FilterOptions from 'interfaces/FilterOptions'; 3 | import Species from 'interfaces/Species'; 4 | import SpeciesFilter from 'interfaces/SpeciesFilter'; 5 | import { delegateRepository } from '../infra/database/delegateRepository'; 6 | import SpeciesRepositoryV2 from '../infra/database/SpeciesRepositoryV2'; 7 | 8 | type Filter = Partial<{ 9 | planter_id: number; 10 | wallet_id: string; 11 | grower_id: string; 12 | }>; 13 | 14 | function getByFilter( 15 | speciesRepository: SpeciesRepositoryV2, 16 | ): (filter: SpeciesFilter, options: FilterOptions) => Promise { 17 | return async function (filter: SpeciesFilter, options: FilterOptions) { 18 | const result = await speciesRepository.getByFilter(filter, options); 19 | return result; 20 | }; 21 | } 22 | 23 | export default { 24 | getById: delegateRepository('getById'), 25 | getByGrower: delegateRepository('getByGrower'), 26 | getByFilter, 27 | }; 28 | -------------------------------------------------------------------------------- /server/models/Capture.ts: -------------------------------------------------------------------------------- 1 | import CaptureRepository from 'infra/database/CaptureRepository'; 2 | import { delegateRepository } from 'infra/database/delegateRepository'; 3 | import Capture from 'interfaces/Capture'; 4 | import CaptureFilter from 'interfaces/CaptureFilter'; 5 | import FilterOptions from 'interfaces/FilterOptions'; 6 | 7 | function getByFilter( 8 | captureRepository: CaptureRepository, 9 | ): (filter: CaptureFilter, options: FilterOptions) => Promise { 10 | return async function (filter: CaptureFilter, options: FilterOptions) { 11 | const captures = await captureRepository.getByFilter(filter, options); 12 | return captures; 13 | }; 14 | } 15 | 16 | function getCount( 17 | captureRepository: CaptureRepository, 18 | ): (filter: CaptureFilter) => Promise { 19 | return async function (filter: CaptureFilter) { 20 | const count = await captureRepository.getCount(filter); 21 | return count; 22 | }; 23 | } 24 | 25 | export default { 26 | getByFilter, 27 | getCount, 28 | getById: delegateRepository('getById'), 29 | }; 30 | -------------------------------------------------------------------------------- /.github/workflows/pull-request-ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: API CI for New Pull Requests 5 | 6 | on: 7 | pull_request: 8 | branches: 9 | - main 10 | env: 11 | project-directory: ./ 12 | DATABASE_URL: ${{ secrets.DATABASE_URL }} 13 | 14 | jobs: 15 | test: 16 | name: Run all tests 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Use Node.js 20.x 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: '20.x' 24 | 25 | - name: npm clean install 26 | run: npm ci 27 | 28 | - name: Typescript compiles 29 | run: npm run build 30 | 31 | - name: Eslint 32 | run: npm run lint 33 | 34 | - name: run tests 35 | run: npm test 36 | 37 | - name: run repository tests 38 | run: npm run test-repository 39 | -------------------------------------------------------------------------------- /deployment/overlays/prod/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: treetracker-query-api 5 | labels: 6 | app: treetracker-query-api 7 | namespace: webmap 8 | spec: 9 | replicas: 1 10 | selector: 11 | matchLabels: 12 | app: treetracker-query-api 13 | template: 14 | metadata: 15 | labels: 16 | app: treetracker-query-api 17 | spec: 18 | affinity: 19 | nodeAffinity: 20 | requiredDuringSchedulingIgnoredDuringExecution: 21 | nodeSelectorTerms: 22 | - matchExpressions: 23 | - key: doks.digitalocean.com/node-pool 24 | operator: In 25 | values: 26 | - microservices-node-pool 27 | containers: 28 | - name: treetracker-query-api 29 | env: 30 | - name: DATABASE_URL 31 | valueFrom: 32 | secretKeyRef: 33 | name: dbconnection 34 | key: database 35 | - name: DATABASE_POOL_MAX 36 | value: '5' 37 | -------------------------------------------------------------------------------- /server/models/Contract.ts: -------------------------------------------------------------------------------- 1 | import ContractRepository from 'infra/database/ContractRepository'; 2 | import { delegateRepository } from 'infra/database/delegateRepository'; 3 | import Contract from 'interfaces/Contract'; 4 | import ContractFilter from 'interfaces/ContractFilter'; 5 | import FilterOptions from 'interfaces/FilterOptions'; 6 | 7 | function getByFilter( 8 | contractRepository: ContractRepository, 9 | ): (filter: ContractFilter, options: FilterOptions) => Promise { 10 | return async function (filter: ContractFilter, options: FilterOptions) { 11 | const contracts = await contractRepository.getByFilter(filter, options); 12 | return contracts; 13 | }; 14 | } 15 | 16 | function getCount( 17 | contractRepository: ContractRepository, 18 | ): (filter: ContractFilter) => Promise { 19 | return async function (filter: ContractFilter) { 20 | const count = await contractRepository.getCount(filter); 21 | return count; 22 | }; 23 | } 24 | 25 | export default { 26 | getByFilter, 27 | getCount, 28 | getById: delegateRepository('getById'), 29 | }; 30 | -------------------------------------------------------------------------------- /server/models/Tokens.ts: -------------------------------------------------------------------------------- 1 | import FilterOptions from 'interfaces/FilterOptions'; 2 | import Tokens from 'interfaces/Tokens'; 3 | import { delegateRepository } from '../infra/database/delegateRepository'; 4 | import TokensRepository from '../infra/database/TokensRepository'; 5 | 6 | type Filter = { 7 | wallet: string; 8 | withPlanter?: boolean; 9 | withCapture?: boolean; 10 | }; 11 | 12 | function getByFilter( 13 | tokenRepository: TokensRepository, 14 | ): (filter: Filter, options: FilterOptions) => Promise { 15 | return async (filter: Filter, options: FilterOptions) => { 16 | const tokens = await tokenRepository.getByFilter(filter, options); 17 | return tokens; 18 | }; 19 | } 20 | 21 | function getCountByFilter( 22 | tokenRepository: TokensRepository, 23 | ): (filter: Filter) => Promise { 24 | return async (filter: Filter) => { 25 | const total = await tokenRepository.getCountByFilter(filter); 26 | return total; 27 | }; 28 | } 29 | 30 | export default { 31 | getById: delegateRepository('getById'), 32 | getByFilter, 33 | getCountByFilter, 34 | }; 35 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # This is a comment. 2 | # Each line is a file pattern followed by one or more owners. 3 | 4 | # These owners will be the default owners for everything in 5 | # the repo. Unless a later match takes precedence, 6 | # @global-owner1 and @global-owner2 will be requested for 7 | # review when someone opens a pull request. 8 | * @Greenstand/microservices-working-group 9 | 10 | # The `docs/*` pattern will match files like 11 | # `docs/getting-started.md` but not further nested files like 12 | # `docs/build-app/troubleshooting.md`. 13 | /docs/ docs@example.com 14 | 15 | 16 | # Order is important; the last matching pattern takes the most 17 | # precedence. When someone opens a pull request that only 18 | # modifies JS files, only @js-owner and not the global 19 | # owner(s) will be requested for a review. 20 | *.js @js-owner 21 | 22 | 23 | 24 | # In this example, @octocat owns any file in an apps directory 25 | # anywhere in your repository. 26 | #apps/ @octocat 27 | 28 | # In this example, @doctocat owns any file in the `/docs` 29 | # directory in the root of your repository and any of its 30 | # subdirectories. 31 | ##/docs/ @doctocat 32 | -------------------------------------------------------------------------------- /deployment/base/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: treetracker-query-api 5 | labels: 6 | app: treetracker-query-api 7 | namespace: webmap 8 | spec: 9 | replicas: 1 10 | selector: 11 | matchLabels: 12 | app: treetracker-query-api 13 | template: 14 | metadata: 15 | labels: 16 | app: treetracker-query-api 17 | spec: 18 | affinity: 19 | nodeAffinity: 20 | requiredDuringSchedulingIgnoredDuringExecution: 21 | nodeSelectorTerms: 22 | - matchExpressions: 23 | - key: doks.digitalocean.com/node-pool 24 | operator: In 25 | values: 26 | - microservices-node-pool 27 | containers: 28 | - name: treetracker-query-api 29 | image: greenstand/treetracker-query-api:TAG 30 | ports: 31 | - containerPort: 80 32 | env: 33 | - name: DATABASE_URL 34 | valueFrom: 35 | secretKeyRef: 36 | name: query-api-database-connection 37 | key: db 38 | -------------------------------------------------------------------------------- /__tests__/supertest.js: -------------------------------------------------------------------------------- 1 | /* 2 | * The integration test to test the whole microservice, with DB 3 | */ 4 | require('dotenv').config(); 5 | const request = require('supertest'); 6 | const server = require('../server/app'); 7 | const { expect } = require('chai'); 8 | const seed = require('./seed'); 9 | const log = require('loglevel'); 10 | const sinon = require('sinon'); 11 | 12 | describe('microservice integration tests', () => { 13 | beforeEach(async () => { 14 | //In case other sinon stub would affect me 15 | sinon.restore(); 16 | //before all, seed data to DB 17 | await seed.clear(); 18 | await seed.seed(); 19 | 20 | // do any other setup here 21 | // including authorize to the service if required 22 | }); 23 | 24 | afterEach((done) => { 25 | //after finished all the test, clear data from DB 26 | seed.clear().then(() => { 27 | done(); 28 | }); 29 | }); 30 | 31 | describe('Does something', () => { 32 | it(`Should do something and not fail`, async () => { 33 | const res = await request(server).get(`/path`); 34 | expect(res).to.have.property('statusCode', 200); 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /server/models/RawCapture.ts: -------------------------------------------------------------------------------- 1 | import RawCaptureRepository from 'infra/database/CaptureRepository'; 2 | import { delegateRepository } from 'infra/database/delegateRepository'; 3 | import RawCapture from 'interfaces/Capture'; 4 | import RawCaptureFilter from 'interfaces/CaptureFilter'; 5 | import FilterOptions from 'interfaces/FilterOptions'; 6 | 7 | function getByFilter( 8 | rawCaptureRepository: RawCaptureRepository, 9 | ): (filter: RawCaptureFilter, options: FilterOptions) => Promise { 10 | return async function (filter: RawCaptureFilter, options: FilterOptions) { 11 | const captures = await rawCaptureRepository.getByFilter(filter, options); 12 | return captures; 13 | }; 14 | } 15 | 16 | function getCount( 17 | rawCaptureRepository: RawCaptureRepository, 18 | ): (filter: RawCaptureFilter) => Promise<{ count: number }> { 19 | return async function (filter: RawCaptureFilter) { 20 | const count = await rawCaptureRepository.getCount(filter); 21 | return count; 22 | }; 23 | } 24 | 25 | export default { 26 | getByFilter, 27 | getCount, 28 | getById: delegateRepository('getById'), 29 | }; 30 | -------------------------------------------------------------------------------- /docs/api/spec/examples/organizations/1.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 1, 3 | "name": "180Earth", 4 | "logo_url": "https://180.earth/wp-content/uploads/2020/01/Asset-1.png", 5 | "photo_url": "https://180.earth/wp-content/uploads/2020/01/top-bg-scaled.jpg", 6 | "location": "Shirimatunda, Tanzania", 7 | "created_at": "November 11, 2019", 8 | "about": "Greenway is a Youth-Driven Environmental Protection Organization providing alternative solutions to single-use plastic and planting carbon-sucking trees for socio-economic development and reducing climate crisis. Our social work includes reforestation, landscape restoration, climate education, awareness campaign, conducting research, outreach activities, and collaborating with key stakeholders to implement sustainable solutions.", 9 | "mission": "To combat climate change, desertification, land degradation, carbon emission by inspiring healthier communities affected by severe climate disorder and modestly reducing pollution by 2050.", 10 | "links": { 11 | "featured_trees": "/trees?organization_id=1&limit=4", 12 | "associated_planters": "/planters?organization_id=1&limit=4", 13 | "species": "/species?organization_id=1" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /server/infra/database/Session.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from 'knex'; 2 | import knex from './knex'; 3 | 4 | export default class Session { 5 | transaction: Knex.Transaction | undefined; 6 | 7 | constructor() { 8 | this.transaction = undefined; 9 | } 10 | 11 | getDB() { 12 | if (this.transaction) { 13 | return this.transaction; 14 | } 15 | return knex; 16 | } 17 | 18 | isTransactionInProgress() { 19 | return this.transaction !== undefined; 20 | } 21 | 22 | async beginTransaction() { 23 | if (this.transaction) { 24 | throw new Error('Can not start transaction in transaction'); 25 | } 26 | this.transaction = await knex.transaction(); 27 | } 28 | 29 | async commitTransaction() { 30 | if (!this.transaction) { 31 | throw new Error('Can not commit transaction before start it!'); 32 | } 33 | await this.transaction.commit(); 34 | this.transaction = undefined; 35 | } 36 | 37 | async rollbackTransaction() { 38 | if (!this.transaction) { 39 | throw new Error('Can not rollback transaction before start it!'); 40 | } 41 | await this.transaction.rollback(); 42 | this.transaction = undefined; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /deployment/base/database-connection-sealed-secret.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: bitnami.com/v1alpha1 2 | kind: SealedSecret 3 | metadata: 4 | creationTimestamp: null 5 | name: query-api-database-connection 6 | namespace: webmap 7 | spec: 8 | encryptedData: 9 | db: AgCQgHQOc0gCay83TkWkloNjODKuzb+mci+14gauxh8cYc8oIh24vsafbNKcbkthWPEKUxXY9m4sQcOikQhEbevOxteJliaoosYrK0YCtQxZMpnCgbtrVVe+WMc19nS+yOn1FWvXMSmrFcpO13myvT98OqiozqoxeF8xKePXR03dziNPNghxbZTGTEe4t3g8alcM4ASuQFEk+9VemCo/Ejw1yFzJfVnJP9EEywk/0w24Cou0Jk6KkAKPLi8bWbdne+mU1VhHKUboZnlIJ32RgUwxjQjUMUhno2nDYBYedUeV/qbp6m4DWGLrGwznE0blP/5SEXD0LOxqObPPOmgvabnsPo51csnKYQK24R7t/1uO5mcahZzsRgVmKfl/5Q7pellRDdv0tXBzj1Mb3MjtA5jIWnM4DbFNRBJQE1q2elUTmZ83FEvhqxgqz4nfeB6Yy60BOq3V2WBhKjgivOWMWLYZRjRfZqfrdnxdMGiLMTJ+FmMJtfEJV9UQLZvrbnZyG5ereQxkWwHBtCD9/ivJ46pGLyGNhZuZMebz7SV3H0mE9J2vG9BiZ8QoHE+AWRVDTOibONA4xG4MzjG7ii+6+cQSWq3ZgoKIUrJ5wN/46W6uRcreseOHycrhYHFYJmsZ4M/Rauf58RZKkvv8RsvqSGx0ovcSEm5FrbF58tw7uNFtG+fSdqWICEQ6bjnRavzv0lNXzdv5dI2sBglX4qQvSLD1GEiOPEyqhxLggbDtE4yoipK8j5UjLwYR+GDX1fVSvq8oJtO3XjmH2S8Cr9sqdcB8ErguGWKpJoIvjoiMUp9dtrPTBDVZ3kJWJZ3wUHkATbNv7FODxRE6idRLWzDm+OzL86SDFuv3Fi+ZE1srvMiSOb1NhGNhJ0SP 10 | template: 11 | metadata: 12 | creationTimestamp: null 13 | name: query-api-database-connection 14 | namespace: webmap 15 | -------------------------------------------------------------------------------- /deployment/overlays/test/database-connection-sealed-secret.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: bitnami.com/v1alpha1 2 | kind: SealedSecret 3 | metadata: 4 | creationTimestamp: null 5 | name: query-api-database-connection 6 | namespace: webmap 7 | spec: 8 | encryptedData: 9 | db: AgB5/dCEJieiDjukbzrvFORjrqY7WciUE2pTzWXS2aIFM8PXKXawMJ8Cut9oXpWvqFXq+sEUCnmdzCx33y8WL4xCwsw6cmvD9nSfwwj0TmIUKDrPTtXZlWJE43w4RVmwVbF43awpChg1OmptX2lMEU92pmSwRxjj+Gv7NeJXGYPC1TuolYAM2h39qaKcf0CoHtocdBcw+ZLl6jCVoHgbPMxZJL0W3kgvesKsE/SuwGvmKZYahQz9dbDqtrGAlOuTGbZiKurrqfZbFsnEWpN+GO0ktM7VbsBzP7INigFcxzeNdhS9UF7I8VRT+bG9KHZXKlaozGpoqdiFRPTrBZGhk0hYWcxUgGpEm9bHi4myjDLp5dasv4Fn15Z7NoK1VVzRQ4jZBGG7K7OjRGZIfZ8gQX4ayFT695A+rs0Fs9K97LeRnFnNVqAtZ9/yNCUgahndG/R8HaQCX8r6yoJFxHpowAT5kLAjgiCmcpkV+1lBx131EIFhAc9zjyZtGICKmRa8TJSKABDDE4KZ2rdjxQBYj8BhtK+jwAalQ62mBMRKfgmxqriqrYtemmf/27n6WTjyaY1KhP+FYiOWYrT9T1nhwfJOb1gEuad5/c0IesoZjl+jkU0Q0TGgMlPRCGs8mZvs0uetV1T9Yp7XfNhLRwTYl2guR1wUBJpvMheVoPyQQWqI/Pdx3cbxWjOYqyRPnesdnnuMwIhuOu1wvGRazN3YmSlYQbZVUrikYsc3Q7WwErXWErcZpNj72MhqhbD/dY1sOvCQYIjOQnpYUsMLCLKqDMI7rOkkbuxgpe1EgHfAfz46tFwK1bXTNFsL/spE0nfikTVA4cJTE7qTBdxo/xvi7ldjt1+QQ91V5T1TIkFmUzAdKGQ== 10 | template: 11 | metadata: 12 | creationTimestamp: null 13 | name: query-api-database-connection 14 | namespace: webmap 15 | -------------------------------------------------------------------------------- /docs/api/spec/examples/planters/940.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 940, 3 | "first_name": "Sebastian ", 4 | "last_name": "Gaertner", 5 | "photo_url": "https://treetracker-dev-images.s3.eu-central-1.amazonaws.com/2020.10.19.09.47.53_-5.508107173727935_38.981361706266256_39f0cc9d-0f13-4547-8142-150f15cabb67_IMG_20201019_094513_6614320100195503436.jpg", 6 | "trees_planted": 4, 7 | "about": "Greenway is a Youth-Driven Environmental Protection Organization providing alternative solutions to single-use plastic and planting carbon-sucking trees for socio-economic development and reducing climate crisis. Our social work includes reforestation, landscape restoration, climate education, awareness campaign, conducting research, outreach activities, and collaborating with key stakeholders to implement sustainable solutions.", 8 | "mission": "To combat climate change, desertification, land degradation, carbon emission by inspiring healthier communities affected by severe climate disorder and modestly reducing pollution by 2050.", 9 | "created_time": "2018-01-01", 10 | "country": "Tanzania", 11 | "links": { 12 | "featured_trees": "/trees?planter_id=940&limit=4", 13 | "associated_organizations": "/organizations?planter_id=940", 14 | "species": "/species?planter_id=940" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /deployment/overlays/prod/database-connection-sealed-secret.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: bitnami.com/v1alpha1 2 | kind: SealedSecret 3 | metadata: 4 | creationTimestamp: null 5 | name: query-api-database-connection 6 | namespace: webmap 7 | spec: 8 | encryptedData: 9 | db: AgArioRReOncA6CZbintWWfS2bzl79J1ulTA13dtydS9vBq4NLy3HrqvHAk1Zv+8VzwCQJLT8CFdSbiGxuPCYPbuVFk7GJaBpNjYBWCxVJV+j8FDuCqNbpu8Q6nQ8ORvScr3UcgSGF9ONtOyKlz79WlSuE6IupxUHCLggkmynWkCa3imSrLmLMvDSvidz9eCar5TcRxgS5AJJIMXm2hlCksdgJhQ49xhpH9eKz5ishAQv8NloR1nxFe7VEl+TCIT8Ha+7YxYa1lFgkfJ0Z/77nDJTe7yi6OuqKd21qfNmwh/fQDCm6smXPcaeTti9Pi7fY7v3HvkZxIvef8cJizatYOxPyX6FvW2qfk+2es6S8pO2u6G+ekRGUhNql58281xulrFnvRWWaQi6yyiLs5Gze1P9mayjtpevtFAIcOLBR0/Paeyio7K0En331eVEBSF17C43RDMeA97p+we2T57dIi56fntb85auEyQBpb7h7gN0VXcFUEmwLn5LDH2HpL00VS1pqHQYdFvEkaz80hy0hnvCQ1M5+bP4JFN8yCnjW1c5/AgWqyhbZPSSCp/Gzi60KKd4qhfXdnr22un6719q33ai4ALljWgrVxoVsPe6CvXIVOAnA7Jr+CggbfYPyL5NKH+hmFxlDAjeOASlbACyKKPJZxbxVUwOI1kbYYiOf+PyuCRqIluzZpxbCdKW5zslD7aH6yyQHZTugnefMSpzklUMAycBTEZmAtBC0BIcPxWJGVgKqIezcXE/h4LqJotTrvKXR8WWn76woVSMxBF2gEunUElJXwgLDnp6KfVNsRVRtCj8bCaiX3ErztCJWNgLf76e/ZFsbjqhU8ziifXxqKxBkyjNEtr/5wDBjPxLloht8u1YEHTOdfJ2ZQESNp5jUtWos0SPBdJ 10 | template: 11 | metadata: 12 | creationTimestamp: null 13 | name: query-api-database-connection 14 | namespace: webmap 15 | -------------------------------------------------------------------------------- /server/routers/gisRouter.ts: -------------------------------------------------------------------------------- 1 | import { Router, Request, Response } from 'express'; 2 | import Joi from 'joi'; 3 | import BoundsRepository from 'infra/database/BoundsRepository'; 4 | import GisRepository from 'infra/database/GisRepository'; 5 | import Session from 'infra/database/Session'; 6 | import { handlerWrapper } from './utils'; 7 | import BoundsModel from '../models/Bounds'; 8 | import GisModel from '../models/Gis'; 9 | 10 | const router = Router(); 11 | router.get( 12 | '/location/nearest', 13 | handlerWrapper(async (req: Request, res: Response) => { 14 | Joi.assert( 15 | req.query, 16 | Joi.object().keys({ 17 | zoom_level: Joi.number().integer().min(0).required(), 18 | lat: Joi.number().min(-90).max(90).required(), 19 | lng: Joi.number().min(-180).max(180).required(), 20 | wallet_id: Joi.string().optional(), 21 | planter_id: Joi.number().integer().optional(), 22 | organization_id: Joi.number().integer().optional(), 23 | map_name: Joi.string().optional(), 24 | }), 25 | ); 26 | const repo = new GisRepository(new Session()); 27 | const result = await GisModel.getNearest(repo)(req.query); 28 | res.send({ 29 | nearest: result, 30 | }); 31 | res.end(); 32 | }), 33 | ); 34 | 35 | export default router; 36 | -------------------------------------------------------------------------------- /server/models/Bounds.ts: -------------------------------------------------------------------------------- 1 | import log from 'loglevel'; 2 | import BoundsRepository from 'infra/database/BoundsRepository'; 3 | import Bounds from 'interfaces/Bounds'; 4 | 5 | type BoundsFilter = Partial<{ 6 | planter_id: string; 7 | wallet_id: string; 8 | organisation_id: string[] | string; 9 | }>; 10 | 11 | function getByFilter( 12 | boundsRepository: BoundsRepository, 13 | ): (filter: BoundsFilter) => Promise { 14 | // eslint-disable-next-line func-names 15 | return async function (filter: BoundsFilter) { 16 | if (filter.planter_id) { 17 | log.warn(`getting bounds using planterId ${filter.planter_id}`); 18 | const bounds = await boundsRepository.filterByPlanter(filter.planter_id); 19 | return bounds; 20 | } 21 | 22 | if (filter.wallet_id) { 23 | log.warn(`getting bounds using wallet_id ${filter.wallet_id}`); 24 | const bounds = await boundsRepository.filterByWallet(filter.wallet_id); 25 | return bounds; 26 | } 27 | 28 | if (filter.organisation_id) { 29 | log.warn( 30 | `getting bounds using organisation_id ${filter.organisation_id}`, 31 | ); 32 | const bounds = await boundsRepository.filterByOrganisation( 33 | filter.organisation_id, 34 | ); 35 | return bounds; 36 | } 37 | }; 38 | } 39 | 40 | export default { 41 | getByFilter, 42 | }; 43 | -------------------------------------------------------------------------------- /scripts/setup-dev-database-passwords.sh: -------------------------------------------------------------------------------- 1 | #echo 'Access Token:' 2 | #read TOKEN 3 | 4 | doctl auth init # --access-token $TOKEN 5 | 6 | #echo 'Schema:' 7 | #read SCHEMA 8 | source scripts/vars.sh 9 | 10 | SERVICE_PASSWORD=`doctl databases user get 397700c8-6591-4a27-8d65-50af5c9a0263 s_$SCHEMA --format Password --no-header` 11 | MIGRATION_PASSWORD=`doctl databases user get 397700c8-6591-4a27-8d65-50af5c9a0263 m_$SCHEMA --format Password --no-header` 12 | INTEGRATION_PASSWORD=`doctl databases user get 397700c8-6591-4a27-8d65-50af5c9a0263 integration --format Password --no-header` 13 | 14 | cp .env.test.example .env.test 15 | sed -i '' "s/{SCHEMA}/$SCHEMA/" .env.test 16 | sed -i '' "s/{PASSWORD}/$INTEGRATION_PASSWORD/" .env.test 17 | 18 | cp .env.development.example .env.development 19 | sed -i '' "s/{SCHEMA}/$SCHEMA/" .env.development 20 | sed -i '' "s/{PASSWORD}/$SERVICE_PASSWORD/" .env.development 21 | sed -i '' "s/{SEEDER_PASSWORD}/$MIGRATION_PASSWORD/" .env.development 22 | 23 | cp database/database.json.example database/database.json 24 | 25 | sed -i '' "s/{SCHEMA}/$SCHEMA/" database/database.json 26 | sed -i '' "s/{INTEGRATION_PASSWORD}/$INTEGRATION_PASSWORD/" database/database.json 27 | sed -i '' "s/{MIGRATION_PASSWORD}/$MIGRATION_PASSWORD/" database/database.json 28 | 29 | 30 | #echo $SERVICE_PASSWORD 31 | #echo $MIGRATION_PASSWORD 32 | #echo $INTEGRATION_PASSWORD 33 | -------------------------------------------------------------------------------- /server/routers/transactionsRouter.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import Joi from 'joi'; 3 | import TransactionRepository from 'infra/database/TransactionRepository'; 4 | import Transaction from 'models/Transaction'; 5 | import { handlerWrapper } from './utils'; 6 | import Session from '../infra/database/Session'; 7 | 8 | const router = express.Router(); 9 | 10 | router.get( 11 | '/', 12 | handlerWrapper(async (req, res) => { 13 | Joi.assert( 14 | req.query, 15 | Joi.object().keys({ 16 | limit: Joi.number().integer().min(-15).max(1000), 17 | offset: Joi.number().integer().min(0), 18 | wallet_id: Joi.string().guid(), 19 | token_id: Joi.string().guid(), 20 | }), 21 | ); 22 | let { limit = 20, offset = 0 } = req.query; 23 | const { token_id, wallet_id } = req.query; 24 | limit = parseInt(limit); 25 | offset = parseInt(offset); 26 | const repo = new TransactionRepository(new Session()); 27 | const filter: Partial<{ token_id: string; wallet_id: string }> = {}; 28 | if (token_id) { 29 | filter.token_id = token_id; 30 | } else if (wallet_id) { 31 | filter.wallet_id = wallet_id; 32 | } 33 | const result = await Transaction.getByFilter(repo)(filter, { 34 | limit, 35 | offset, 36 | }); 37 | 38 | res.send({ 39 | total: result.length, 40 | offset, 41 | limit, 42 | transactions: result, 43 | }); 44 | res.end(); 45 | }), 46 | ); 47 | 48 | export default router; 49 | -------------------------------------------------------------------------------- /server/models/Wallets.ts: -------------------------------------------------------------------------------- 1 | import log from 'loglevel'; 2 | import FilterOptions from 'interfaces/FilterOptions'; 3 | import Wallets from 'interfaces/Wallets'; 4 | import { delegateRepository } from '../infra/database/delegateRepository'; 5 | import WalletsRepository from '../infra/database/WalletsRepository'; 6 | 7 | type Filter = Partial<{ name: string }>; 8 | 9 | function getByFilter( 10 | WalletRepository: WalletsRepository, 11 | ): (filter: Filter, options: FilterOptions) => Promise { 12 | return async (filter: Filter, options: FilterOptions) => { 13 | if (filter.name) { 14 | log.warn('using wallet name filter...'); 15 | const wallets = await WalletRepository.getByName(filter.name, options); 16 | return wallets; 17 | } 18 | const wallets = await WalletRepository.getByFilter(filter, options); 19 | return wallets; 20 | }; 21 | } 22 | 23 | function getCount( 24 | WalletRepository: WalletsRepository, 25 | ): (filter: Filter) => Promise { 26 | return async (filter: Filter) => { 27 | const count = await WalletRepository.getCount(filter); 28 | return count; 29 | }; 30 | } 31 | 32 | export default { 33 | getWalletByIdOrName: delegateRepository( 34 | 'getWalletByIdOrName', 35 | ), 36 | getWalletTokenContinentCount: delegateRepository( 37 | 'getWalletTokenContinentCount', 38 | ), 39 | getByFilter, 40 | getCount, 41 | getFeaturedWallet: delegateRepository( 42 | 'getFeaturedWallet', 43 | ), 44 | }; 45 | -------------------------------------------------------------------------------- /.github/workflows/deploy-test-env.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to Test Env 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | git-tag: 7 | description: 'tag' 8 | required: true 9 | 10 | env: 11 | project-directory: ./ 12 | 13 | jobs: 14 | deploy: 15 | name: Deploy, requires approval 16 | runs-on: ubuntu-latest 17 | if: | 18 | github.repository == 'Greenstand/treetracker-query-api' 19 | steps: 20 | - uses: actions/checkout@v2 21 | with: 22 | ref: ${{ github.event.inputs.git-tag }} 23 | - name: get-npm-version 24 | id: package-version 25 | uses: martinbeentjes/npm-get-version-action@master 26 | - name: Install kustomize 27 | run: curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash 28 | working-directory: ${{ env.project-directory }} 29 | - name: Run kustomize 30 | run: (cd ./deployment/base && ../../kustomize edit set image greenstand/treetracker-query-api:${{ steps.package-version.outputs.current-version }} ) 31 | working-directory: ${{ env.project-directory }} 32 | - name: Install doctl for kubernetes 33 | uses: digitalocean/action-doctl@v2 34 | with: 35 | token: ${{ secrets.TEST_DIGITALOCEAN_TOKEN }} 36 | - name: Save DigitalOcean kubeconfig 37 | run: doctl kubernetes cluster kubeconfig save ${{ secrets.TEST_CLUSTER_NAME }} 38 | - name: Update kubernetes resources 39 | run: kustomize build deployment/overlays/test | kubectl apply -n webmap --wait -f - 40 | working-directory: ${{ env.project-directory }} 41 | -------------------------------------------------------------------------------- /.github/workflows/deploy-prod-env.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to Prod Env 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | git-tag: 7 | description: 'tag' 8 | required: true 9 | 10 | env: 11 | project-directory: ./ 12 | 13 | jobs: 14 | deploy: 15 | name: Deploy, requires approval 16 | runs-on: ubuntu-latest 17 | if: | 18 | github.repository == 'Greenstand/treetracker-query-api' 19 | steps: 20 | - uses: actions/checkout@v2 21 | with: 22 | ref: ${{ github.event.inputs.git-tag }} 23 | - name: get-npm-version 24 | id: package-version 25 | uses: martinbeentjes/npm-get-version-action@master 26 | - name: Install kustomize 27 | run: curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash 28 | working-directory: ${{ env.project-directory }} 29 | - name: Run kustomize 30 | run: (cd ./deployment/base && ../../kustomize edit set image greenstand/treetracker-query-api:${{ steps.package-version.outputs.current-version }} ) 31 | working-directory: ${{ env.project-directory }} 32 | - name: Install doctl for kubernetes 33 | uses: digitalocean/action-doctl@v2 34 | with: 35 | token: ${{ secrets.DIGITALOCEAN_PRODUCTION_TOKEN }} 36 | - name: Save DigitalOcean kubeconfig 37 | run: doctl kubernetes cluster kubeconfig save ${{ secrets.PRODUCTION_CLUSTER_NAME }} 38 | - name: Update kubernetes resources 39 | run: kustomize build deployment/overlays/prod | kubectl apply -n webmap --wait -f - 40 | working-directory: ${{ env.project-directory }} 41 | -------------------------------------------------------------------------------- /__tests__/e2e/wallets.spec.ts: -------------------------------------------------------------------------------- 1 | import supertest from 'supertest'; 2 | import app from '../../server/app'; 3 | 4 | describe('wallets', () => { 5 | it('get wallet by id or name', async () => { 6 | const response = await supertest(app).get( 7 | '/wallets/88a33d5b-5c47-4a32-8572-0899817d3f38', 8 | ); 9 | expect(response.status).toBe(200); 10 | expect(response.body).toMatchObject({ 11 | id: '88a33d5b-5c47-4a32-8572-0899817d3f38', 12 | name: 'NewWalletByAutoTool_49836', 13 | password: null, 14 | salt: null, 15 | logo_url: null, 16 | // created_at: '2021-10-08T02:33:20.732Z', 17 | }); 18 | }); 19 | 20 | it('get wallet by name', async () => { 21 | const response = await supertest(app).get( 22 | '/wallets/NewWalletByAutoTool_49836', 23 | ); 24 | expect(response.status).toBe(200); 25 | expect(response.body).toMatchObject({ 26 | id: '88a33d5b-5c47-4a32-8572-0899817d3f38', 27 | name: 'NewWalletByAutoTool_49836', 28 | password: null, 29 | salt: null, 30 | logo_url: null, 31 | // created_at: '2021-10-08T02:33:20.732Z', 32 | }); 33 | }); 34 | 35 | it('get wallet token-continent count ', async () => { 36 | const response = await supertest(app).get( 37 | '/wallets/eecdf253-05b6-419a-8425-416a3e5fc9a0/token-region-count', 38 | ); 39 | expect(response.status).toBe(200); 40 | expect(response.body.walletStatistics).toBeInstanceOf(Array); 41 | expect(response.body.walletStatistics).toEqual( 42 | expect.arrayContaining([ 43 | expect.objectContaining({ continent: 'Africa' }), 44 | expect.objectContaining({ continent: 'North America' }), 45 | ]), 46 | ); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /__tests__/e2e/organizations.spec.ts: -------------------------------------------------------------------------------- 1 | import supertest from 'supertest'; 2 | import app from '../../server/app'; 3 | 4 | describe('organizations', () => { 5 | it('organizations/{id}', async () => { 6 | const response = await supertest(app).get('/organizations/1'); 7 | expect(response.status).toBe(200); 8 | expect(response.body).toMatchObject({ 9 | id: 1, 10 | links: { 11 | featured_trees: expect.stringMatching(/trees/), 12 | associated_planters: expect.stringMatching(/planters/), 13 | species: expect.stringMatching(/species/), 14 | }, 15 | }); 16 | }); 17 | it('organizations/{map_name}', async () => { 18 | const response = await supertest(app).get('/organizations/freetown'); 19 | expect(response.status).toBe(200); 20 | expect(response.body).toMatchObject({ 21 | map_name: 'freetown', 22 | links: { 23 | featured_trees: expect.stringMatching(/trees/), 24 | associated_planters: expect.stringMatching(/planters/), 25 | species: expect.stringMatching(/species/), 26 | }, 27 | }); 28 | }); 29 | it( 30 | 'organizations?planter_id=1&limit=1', 31 | async () => { 32 | const response = await supertest(app).get( 33 | '/organizations?planter_id=1&limit=1', 34 | ); 35 | expect(response.status).toBe(200); 36 | expect(response.body.organizations).toBeInstanceOf(Array); 37 | expect(response.body.organizations[0]).toMatchObject({ 38 | id: expect.any(Number), 39 | links: { 40 | featured_trees: expect.stringMatching(/trees/), 41 | associated_planters: expect.stringMatching(/planters/), 42 | species: expect.stringMatching(/species/), 43 | }, 44 | }); 45 | }, 46 | 1000 * 30, 47 | ); 48 | }); 49 | -------------------------------------------------------------------------------- /server/infra/database/TransactionRepository.ts: -------------------------------------------------------------------------------- 1 | import FilterOptions from 'interfaces/FilterOptions'; 2 | import Transaction from 'interfaces/Transaction'; 3 | import BaseRepository from './BaseRepository'; 4 | import Session from './Session'; 5 | 6 | export default class TransactionRepository extends BaseRepository { 7 | constructor(session: Session) { 8 | super('wallet.transaction', session); 9 | } 10 | 11 | async getByFilter( 12 | filter: Partial<{ token_id: string; wallet_id: string }>, 13 | options: FilterOptions, 14 | ) { 15 | const { token_id, wallet_id } = filter; 16 | const { limit, offset } = options; 17 | let sql = ` 18 | SELECT 19 | t.id, 20 | t.token_id, 21 | t.source_wallet_id, 22 | t.destination_wallet_id, 23 | srcWallet.name AS source_wallet_name, 24 | destWallet.name AS destination_wallet_name, 25 | t.processed_at, 26 | srcWallet.logo_url AS source_wallet_logo_url 27 | FROM 28 | wallet.transaction t 29 | LEFT JOIN wallet.wallet srcWallet ON srcWallet.id = t.source_wallet_id 30 | LEFT JOIN wallet.wallet destWallet ON destWallet.id = t.destination_wallet_id 31 | `; 32 | if (token_id) { 33 | sql += ` 34 | WHERE 35 | t.token_id = '${token_id}' 36 | `; 37 | } else if (wallet_id) { 38 | sql += ` 39 | WHERE 40 | t.source_wallet_id = '${wallet_id}' 41 | OR t.destination_wallet_id = '${wallet_id}' 42 | `; 43 | } 44 | 45 | sql += ` 46 | LIMIT ${limit} 47 | OFFSET ${offset}; 48 | `; 49 | const object = await this.session.getDB().raw(sql); 50 | return object.rows; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /server/routers/tokensRouter.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import Joi from 'joi'; 3 | import TokensModel from 'models/Tokens'; 4 | import { handlerWrapper } from './utils'; 5 | import Session from '../infra/database/Session'; 6 | import TokensRepository from '../infra/database/TokensRepository'; 7 | 8 | const router = express.Router(); 9 | 10 | type filter = { 11 | wallet: string; 12 | withPlanter?: boolean; 13 | withCapture?: boolean; 14 | }; 15 | 16 | router.get( 17 | '/:tokenId', 18 | handlerWrapper(async (req, res) => { 19 | Joi.assert(req.params.tokenId, Joi.string().required()); 20 | const repo = new TokensRepository(new Session()); 21 | const exe = TokensModel.getById(repo); 22 | const result = await exe(req.params.tokenId); 23 | res.send(result); 24 | res.end(); 25 | }), 26 | ); 27 | 28 | router.get( 29 | '/', 30 | handlerWrapper(async (req, res) => { 31 | Joi.assert( 32 | req.query, 33 | Joi.object().keys({ 34 | limit: Joi.number().integer().min(1).max(1000), 35 | offset: Joi.number().integer().min(0), 36 | wallet: Joi.string().required(), 37 | withPlanter: Joi.boolean().sensitive(true), 38 | withCapture: Joi.boolean().sensitive(true), 39 | }), 40 | ); 41 | 42 | const { 43 | limit = 20, 44 | offset = 0, 45 | withCapture, 46 | withPlanter, 47 | wallet, 48 | } = req.query; 49 | 50 | const filter: filter = { wallet }; 51 | if (withCapture) filter.withCapture = withCapture === 'true'; 52 | if (withPlanter) filter.withPlanter = withPlanter === 'true'; 53 | 54 | const repo = new TokensRepository(new Session()); 55 | const result = await TokensModel.getByFilter(repo)(filter, { 56 | limit, 57 | offset, 58 | }); 59 | res.send({ 60 | total: await TokensModel.getCountByFilter(repo)(filter), 61 | offset, 62 | limit, 63 | tokens: result, 64 | }); 65 | res.end(); 66 | }), 67 | ); 68 | 69 | export default router; 70 | -------------------------------------------------------------------------------- /server/models/Organization.ts: -------------------------------------------------------------------------------- 1 | import log from 'loglevel'; 2 | import FilterOptions from 'interfaces/FilterOptions'; 3 | import Organization from 'interfaces/Organization'; 4 | import { delegateRepository } from '../infra/database/delegateRepository'; 5 | import OrganizationRepository from '../infra/database/OrganizationRepository'; 6 | 7 | type Filter = Partial<{ 8 | planter_id: number; 9 | organization_id: number; 10 | ids: Array; 11 | }>; 12 | 13 | function getByFilter( 14 | organizationRepository: OrganizationRepository, 15 | ): (filter: Filter, options: FilterOptions) => Promise { 16 | return async function (filter: Filter, options: FilterOptions) { 17 | if (filter.planter_id) { 18 | log.warn('using planter filter...'); 19 | const trees = await organizationRepository.getByPlanter( 20 | filter.planter_id, 21 | options, 22 | ); 23 | return trees; 24 | } 25 | if (filter?.ids?.length) { 26 | log.warn('using ids filter...'); 27 | const trees = await organizationRepository.getByIds(filter.ids, options); 28 | return trees; 29 | } 30 | const trees = await organizationRepository.getByFilter(filter, options); 31 | return trees; 32 | }; 33 | } 34 | 35 | function getOrganizationLinks(organization) { 36 | const links = { 37 | featured_trees: `/trees?organization_id=${organization.id}&limit=20&offset=0`, 38 | associated_planters: `/planters?organization_id=${organization.id}&limit=20&offset=0`, 39 | species: `/species?organization_id=${organization.id}&limit=20&offset=0`, 40 | }; 41 | return links; 42 | } 43 | 44 | export default { 45 | getById: delegateRepository('getById'), 46 | getByMapName: delegateRepository( 47 | 'getByMapName', 48 | ), 49 | getByFilter, 50 | getOrganizationLinks, 51 | getFeaturedOrganizations: delegateRepository< 52 | OrganizationRepository, 53 | Organization 54 | >('getFeaturedOrganizations'), 55 | }; 56 | -------------------------------------------------------------------------------- /__tests__/seed.js: -------------------------------------------------------------------------------- 1 | import { knex } from 'knex'; 2 | import log from 'loglevel'; 3 | const uuid = require('uuid'); 4 | 5 | const connection = process.env.DATABASE_URL; 6 | 7 | !connection && log.warn('env var DATABASE_URL not set'); 8 | 9 | const knexConfig = { 10 | client: 'pg', 11 | // debug: process.env.NODE_LOG_LEVEL === 'debug', 12 | debug: true, 13 | connection, 14 | pool: { min: 0, max: 10 }, 15 | }; 16 | 17 | const dataRawCaptureFeature = [ 18 | { 19 | id: uuid.v4(), 20 | lat: '41.50414585511928', 21 | lon: '-75.66275380279951', 22 | location: '0101000020E6100000B514ED8E6AEA52C05D13F4D987C04440', 23 | field_user_id: 5127, 24 | field_username: 'test', 25 | created_at: new Date().toUTCString(), 26 | updated_at: new Date().toUTCString(), 27 | }, 28 | { 29 | id: uuid.v4(), 30 | lat: '40.50414585511928', 31 | lon: '-75.66275380279951', 32 | location: '0101000020E6100000B514ED8E6AEA52C05D13F4D987C04440', 33 | field_user_id: 5127, 34 | field_username: 'test', 35 | created_at: new Date().toUTCString(), 36 | updated_at: new Date().toUTCString(), 37 | }, 38 | { 39 | id: uuid.v4(), 40 | lat: '57.57641356164619', 41 | lon: '-113.11416324692146', 42 | location: '0101000020E6100000B4FB5C734E475CC0E21E6AEBC7C94C40', 43 | field_user_id: 5127, 44 | field_username: 'test', 45 | created_at: new Date().toUTCString(), 46 | updated_at: new Date().toUTCString(), 47 | }, 48 | ]; 49 | 50 | async function seed() { 51 | knexConfig.searchPath = [process.env.DATABASE_SCHEMA, 'webmap']; 52 | const serverCon = knex(knexConfig); 53 | const response = await serverCon.transaction(async (trx) => { 54 | return await trx.insert(dataRawCaptureFeature).into('raw_capture_feature'); 55 | }); 56 | serverCon.destroy(); 57 | return response; 58 | } 59 | 60 | async function clear() { 61 | knexConfig.searchPath = [process.env.DATABASE_SCHEMA, 'webmap']; 62 | const serverCon = knex(knexConfig); 63 | const response = await serverCon('raw_capture_feature').del(); 64 | serverCon.destroy(); 65 | return response; 66 | } 67 | 68 | export default { clear, seed }; 69 | -------------------------------------------------------------------------------- /server/models/Species.ts: -------------------------------------------------------------------------------- 1 | import log from 'loglevel'; 2 | import FilterOptions from 'interfaces/FilterOptions'; 3 | import Species from 'interfaces/Species'; 4 | import { delegateRepository } from '../infra/database/delegateRepository'; 5 | import SpeciesRepository from '../infra/database/SpeciesRepository'; 6 | 7 | type Filter = Partial<{ 8 | planter_id: number; 9 | organization_id: number; 10 | wallet_id: string; 11 | }>; 12 | 13 | function getByFilter( 14 | speciesRepository: SpeciesRepository, 15 | ): (filter: Filter, options: FilterOptions) => Promise { 16 | return async function (filter: Filter, options: FilterOptions) { 17 | if (filter.organization_id) { 18 | log.warn('using org filter...'); 19 | const trees = await speciesRepository.getByOrganization( 20 | filter.organization_id, 21 | options, 22 | ); 23 | return trees; 24 | } 25 | if (filter.planter_id) { 26 | log.warn('using planter filter...'); 27 | const trees = await speciesRepository.getByPlanter( 28 | filter.planter_id, 29 | options, 30 | ); 31 | return trees; 32 | } 33 | 34 | if (filter.wallet_id) { 35 | log.warn('using wallet filter...'); 36 | const trees = await speciesRepository.getByWallet( 37 | filter.wallet_id, 38 | options, 39 | ); 40 | return trees; 41 | } 42 | 43 | const trees = await speciesRepository.getByFilter(filter, options); 44 | return trees; 45 | }; 46 | } 47 | 48 | function countByFilter( 49 | speciesRepository: SpeciesRepository, 50 | ): (filter: Filter) => Promise { 51 | return async function (filter: Filter) { 52 | if (filter.organization_id) { 53 | log.warn('using org filter...'); 54 | const total = await speciesRepository.countByOrganization( 55 | filter.organization_id, 56 | ); 57 | return total; 58 | } 59 | const total = await speciesRepository.countByFilter(filter); 60 | return total; 61 | }; 62 | } 63 | 64 | export default { 65 | getById: delegateRepository('getById'), 66 | getByFilter, 67 | countByFilter, 68 | }; 69 | -------------------------------------------------------------------------------- /__tests__/e2e/species.spec.ts: -------------------------------------------------------------------------------- 1 | import supertest from 'supertest'; 2 | import app from '../../server/app'; 3 | 4 | describe('species', () => { 5 | it('species/{id}', async () => { 6 | const response = await supertest(app).get('/species/8'); 7 | expect(response.status).toBe(200); 8 | expect(response.body).toMatchObject({ 9 | id: 8, 10 | name: expect.any(String), 11 | }); 12 | }); 13 | 14 | it( 15 | 'species?organization_id=1&limit=1', 16 | async () => { 17 | const response = await supertest(app).get( 18 | '/species?organization_id=1&limit=1', 19 | ); 20 | expect(response.status).toBe(200); 21 | expect(response.body.species).toBeInstanceOf(Array); 22 | expect(response.body.species[0]).toMatchObject({ 23 | total: expect.stringMatching(/\d+/), 24 | name: expect.any(String), 25 | id: expect.any(Number), 26 | desc: expect.any(String), 27 | }); 28 | }, 29 | 1000 * 30, 30 | ); 31 | 32 | it( 33 | 'species?planter_id=1&limit=1', 34 | async () => { 35 | const response = await supertest(app).get( 36 | '/species?planter_id=1&limit=1', 37 | ); 38 | expect(response.status).toBe(200); 39 | expect(response.body.species).toBeInstanceOf(Array); 40 | expect(response.body.species[0]).toMatchObject({ 41 | total: expect.stringMatching(/\d+/), 42 | name: expect.any(String), 43 | id: expect.any(Number), 44 | desc: expect.any(String), 45 | }); 46 | }, 47 | 1000 * 30, 48 | ); 49 | 50 | it( 51 | 'species?wallet_id=eecdf253-05b6-419a-8425-416a3e5fc9a0&limit=1', 52 | async () => { 53 | const response = await supertest(app).get( 54 | '/species?wallet_id=eecdf253-05b6-419a-8425-416a3e5fc9a0&limit=1', 55 | ); 56 | expect(response.status).toBe(200); 57 | expect(response.body.species).toBeInstanceOf(Array); 58 | expect(response.body.species[0]).toMatchObject({ 59 | total: expect.stringMatching(/\d+/), 60 | name: expect.any(String), 61 | id: expect.any(Number), 62 | desc: expect.any(String), 63 | }); 64 | }, 65 | 1000 * 30, 66 | ); 67 | }); 68 | -------------------------------------------------------------------------------- /server/routers/speciesRouter.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import Joi from 'joi'; 3 | import log from 'loglevel'; 4 | import { handlerWrapper } from './utils'; 5 | import Session from '../infra/database/Session'; 6 | import SpeciesRepository from '../infra/database/SpeciesRepository'; 7 | import SpeciesModel from '../models/Species'; 8 | 9 | const router = express.Router(); 10 | type Filter = Partial<{ 11 | planter_id: number; 12 | organization_id: number; 13 | wallet_id: string; 14 | }>; 15 | 16 | router.get( 17 | '/:id', 18 | handlerWrapper(async (req, res) => { 19 | Joi.assert(req.params.id, Joi.number().required()); 20 | const repo = new SpeciesRepository(new Session()); 21 | const exe = SpeciesModel.getById(repo); 22 | const result = await exe(req.params.id); 23 | res.send(result); 24 | res.end(); 25 | }), 26 | ); 27 | 28 | router.get( 29 | '/', 30 | handlerWrapper(async (req, res) => { 31 | Joi.assert( 32 | req.query, 33 | Joi.object().keys({ 34 | organization_id: Joi.number().integer().min(0), 35 | planter_id: Joi.number().integer().min(0), 36 | wallet_id: Joi.string(), 37 | limit: Joi.number().integer().min(1).max(1000), 38 | offset: Joi.number().integer().min(0), 39 | }), 40 | ); 41 | const { 42 | limit = 20, 43 | offset = 0, 44 | planter_id, 45 | organization_id, 46 | wallet_id, 47 | } = req.query; 48 | const repo = new SpeciesRepository(new Session()); 49 | const filter: Filter = {}; 50 | if (organization_id) { 51 | filter.organization_id = organization_id; 52 | } else if (planter_id) { 53 | filter.planter_id = planter_id; 54 | } else if (wallet_id) { 55 | filter.wallet_id = wallet_id; 56 | } 57 | const begin = Date.now(); 58 | const result = await SpeciesModel.getByFilter(repo)(filter, { 59 | limit, 60 | offset, 61 | }); 62 | log.warn('species filter:', filter, 'took time:', Date.now() - begin, 'ms'); 63 | res.send({ 64 | total: null, 65 | offset, 66 | limit, 67 | species: result, 68 | }); 69 | res.end(); 70 | }), 71 | ); 72 | 73 | export default router; 74 | -------------------------------------------------------------------------------- /server/infra/database/patch.ts: -------------------------------------------------------------------------------- 1 | import log from 'loglevel'; 2 | import Session from './Session'; 3 | 4 | // enum 5 | export enum PATCH_TYPE { 6 | EXTRA_PLANTER = 'extra_planter', 7 | EXTRA_ORG = 'extra_organization', 8 | EXTRA_WALLET = 'extra_wallet', 9 | } 10 | 11 | async function patch(object: any, patchType: PATCH_TYPE, session: Session) { 12 | let configName; 13 | switch (patchType) { 14 | case PATCH_TYPE.EXTRA_PLANTER: 15 | configName = 'extra-planter'; 16 | break; 17 | case PATCH_TYPE.EXTRA_ORG: 18 | configName = 'extra-organization'; 19 | break; 20 | case PATCH_TYPE.EXTRA_WALLET: 21 | configName = 'extra-wallet'; 22 | break; 23 | default: 24 | throw new Error('Invalid patch type'); 25 | } 26 | 27 | let result = object; 28 | if (object instanceof Array) { 29 | if (object.length > 0) { 30 | const ids = object.map((o) => o.id); 31 | 32 | const res = await session.getDB().raw( 33 | ` 34 | select data, ref_id from webmap.config where name = '${configName}' and ref_id in (${ids 35 | .map((e) => `'${e}'`) 36 | .join(',')}) 37 | `, 38 | ); 39 | 40 | if (res.rows.length > 0) { 41 | result = []; 42 | log.debug('found result, patch'); 43 | object.forEach((o) => { 44 | const extra = res.rows.find((r) => r.ref_id === o.id); 45 | if (extra) { 46 | if (patchType === PATCH_TYPE.EXTRA_WALLET) { 47 | delete extra.data?.about; 48 | } 49 | result.push({ ...o, ...extra.data }); 50 | } else { 51 | result.push(o); 52 | } 53 | }); 54 | } 55 | } 56 | } else { 57 | const res = await session.getDB().raw(` 58 | select data from webmap.config where name = '${configName}' and ref_id = '${object.id}' 59 | `); 60 | 61 | if (res.rows.length === 1) { 62 | log.debug('found result, patch'); 63 | const patchData = res.rows[0]; 64 | if (patchType === PATCH_TYPE.EXTRA_WALLET) { 65 | delete patchData.data?.about; 66 | } 67 | result = { ...object, ...patchData.data }; 68 | } 69 | } 70 | 71 | return result; 72 | } 73 | 74 | export default patch; 75 | -------------------------------------------------------------------------------- /server/models/OrganizationV2.ts: -------------------------------------------------------------------------------- 1 | import log from 'loglevel'; 2 | import OrganizationRepositoryV2 from 'infra/database/OrganizationRepositoryV2'; 3 | import FilterOptions from 'interfaces/FilterOptions'; 4 | import Organization from 'interfaces/Organization'; 5 | import { delegateRepository } from '../infra/database/delegateRepository'; 6 | 7 | type Filter = Partial<{ 8 | planter_id: number; 9 | organization_id: number; 10 | grower_id: string; 11 | ids: Array; 12 | }>; 13 | 14 | function getByFilter( 15 | organizationRepository: OrganizationRepositoryV2, 16 | ): (filter: Filter, options: FilterOptions) => Promise { 17 | return async function (filter: Filter, options: FilterOptions) { 18 | if (filter.planter_id) { 19 | log.warn('using planter filter...'); 20 | const trees = await organizationRepository.getByPlanter( 21 | filter.planter_id, 22 | options, 23 | ); 24 | return trees; 25 | } 26 | if (filter.grower_id) { 27 | log.warn('using grower filter...'); 28 | const trees = await organizationRepository.getByGrower( 29 | filter.grower_id, 30 | options, 31 | ); 32 | return trees; 33 | } 34 | if (filter?.ids?.length) { 35 | log.warn('using ids filter...'); 36 | const trees = await organizationRepository.getByIds(filter.ids, options); 37 | return trees; 38 | } 39 | const trees = await organizationRepository.getByFilter(filter, options); 40 | return trees; 41 | }; 42 | } 43 | 44 | function getOrganizationLinks(organization) { 45 | const links = { 46 | featured_trees: `/trees?organization_id=${organization.id}&limit=20&offset=0`, 47 | associated_planters: `/planters?organization_id=${organization.id}&limit=20&offset=0`, 48 | species: `/species?organization_id=${organization.id}&limit=20&offset=0`, 49 | }; 50 | return links; 51 | } 52 | 53 | export default { 54 | getById: delegateRepository( 55 | 'getById', 56 | ), 57 | getByMapName: delegateRepository( 58 | 'getByMapName', 59 | ), 60 | getByFilter, 61 | getOrganizationLinks, 62 | getFeaturedOrganizations: delegateRepository< 63 | OrganizationRepositoryV2, 64 | Organization 65 | >('getFeaturedOrganizations'), 66 | }; 67 | -------------------------------------------------------------------------------- /__tests__/e2e/planters.spec.ts: -------------------------------------------------------------------------------- 1 | import log from 'loglevel'; 2 | import supertest from 'supertest'; 3 | import app from '../../server/app'; 4 | 5 | describe('planters', () => { 6 | it( 7 | 'planters/{id}', 8 | async () => { 9 | const response = await supertest(app).get('/planters/3564'); 10 | expect(response.status).toBe(200); 11 | expect(response.body).toMatchObject({ 12 | id: 3564, 13 | continent_name: 'North America', 14 | country_name: 'Costa Rica', 15 | links: { 16 | featured_trees: expect.stringMatching(/trees/), 17 | associated_organizations: expect.stringMatching(/organizations/), 18 | species: expect.stringMatching(/species/), 19 | }, 20 | }); 21 | }, 22 | 1000 * 30, 23 | ); 24 | 25 | it( 26 | 'planters?organization_id=178&limit=1', 27 | async () => { 28 | const response = await supertest(app).get( 29 | '/planters?organization_id=178&limit=1', 30 | ); 31 | log.warn('xxx:', response.body); 32 | expect(response.status).toBe(200); 33 | expect(response.body.planters).toBeInstanceOf(Array); 34 | expect(response.body.planters[0]).toMatchObject({ 35 | id: 2001, 36 | organization_id: 178, 37 | links: { 38 | featured_trees: expect.stringMatching(/trees/), 39 | associated_organizations: expect.stringMatching(/organizations/), 40 | species: expect.stringMatching(/species/), 41 | }, 42 | }); 43 | }, 44 | 1000 * 31, 45 | ); 46 | 47 | it.skip('planters?keyword=da&limit=1', async () => { 48 | const response = await supertest(app).get('/planters?keyword=da&limit=1'); 49 | expect(response.status).toBe(200); 50 | expect(response.body.planters).toBeInstanceOf(Array); 51 | expect(response.body.planters.length <= 1).toBe(true); 52 | expect( 53 | /^da/.test(response.body.planters[0].first_name) || 54 | /^da/.test(response.body.planters[0].last_name), 55 | ).toBe(true); 56 | }); 57 | 58 | it('planters/featured', async () => { 59 | const response = await supertest(app).get('/planters/featured'); 60 | expect(response.status).toBe(200); 61 | expect(response.body.planters).toBeInstanceOf(Array); 62 | expect(response.body.planters.length === 10).toBe(true); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /docs/api/spec/examples/trees/186734.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 186734, 3 | "time_created": "2020-10-19T06:46:40.000Z", 4 | "time_updated": "2020-10-19T06:46:40.000Z", 5 | "missing": false, 6 | "priority": false, 7 | "cause_of_death_id": null, 8 | "planter_id": 940, 9 | "primary_location_id": null, 10 | "settings_id": null, 11 | "override_settings_id": null, 12 | "dead": 0, 13 | "photo_id": null, 14 | "photo_url": "https://treetracker-dev-images.s3.eu-central-1.amazonaws.com/2020.10.19.09.50.38_-5.508172399749922_38.98146973686408_6bebe71e-5369-4ae0-8c47-9eeff6599fb0_IMG_20201019_094615_7537040365910944885.jpg", 15 | "certificate_id": null, 16 | "estimated_geometric_location": "0101000020E610000027ECE2CCA07D43407E9F76585E0816C0", 17 | "lat": "-5.508172399749922", 18 | "lon": "38.98146973686408", 19 | "gps_accuracy": 4, 20 | "active": true, 21 | "planter_photo_url": "https://treetracker-dev-images.s3.eu-central-1.amazonaws.com/2020.10.19.09.47.53_-5.508107173727935_38.981361706266256_39f0cc9d-0f13-4547-8142-150f15cabb67_IMG_20201019_094513_6614320100195503436.jpg", 22 | "device_id": null, 23 | "sequence": null, 24 | "note": "", 25 | "verified": false, 26 | "uuid": "38572a5b-ac5b-4a77-943a-72254376735e", 27 | "approved": false, 28 | "status": "planted", 29 | "cluster_regions_assigned": true, 30 | "species_id": null, 31 | "planting_organization_id": 1, 32 | "payment_id": null, 33 | "contract_id": null, 34 | "token_issued": false, 35 | "morphology": null, 36 | "age": null, 37 | "species": null, 38 | "capture_approval_tag": null, 39 | "rejection_reason": null, 40 | "matching_hash": null, 41 | "device_identifier": "651f04008af0d91a", 42 | "images": {}, 43 | "domain_specific_data": {}, 44 | "token_id": 1, 45 | "name": null, 46 | "earnings_id": null, 47 | "species_name": null, 48 | "first_name": "Sebastian ", 49 | "last_name": "Gaertner", 50 | "user_image_url": "https://treetracker-production.nyc3.digitaloceanspaces.com/2019.07.10.18.32.42_b4fad89a-10b6-40cc-a134-0085d0e581d2_IMG_20190710_183201_8089920786231467340.jpg", 51 | "token_uuid": null, 52 | "wallet": null, 53 | "attributes": { 54 | "dbh": "13", 55 | "abs_step_count": "75351", 56 | "delta_step_count": "0", 57 | "height_color": "yellow", 58 | "app_build": "1.4.0", 59 | "app_flavor": "justdiggit", 60 | "app_version": "113" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /__tests__/seed.spec.js: -------------------------------------------------------------------------------- 1 | /* 2 | * seed data to DB for testing 3 | */ 4 | const pool = require('../server/database/database.js'); 5 | const uuid = require('uuid'); 6 | const log = require('loglevel'); 7 | const assert = require('assert'); 8 | const knex = require('knex')({ 9 | client: 'pg', 10 | // debug: true, 11 | connection: require('../config/config').connectionString, 12 | }); 13 | 14 | // Example of a database seed using knex 15 | // This follows from the wallet microservice 16 | // New mircroservices will need their own seed story 17 | 18 | const capture = { 19 | id: 999999, 20 | }; 21 | 22 | const captureB = { 23 | id: 999998, 24 | }; 25 | 26 | const token = { 27 | id: 9, 28 | uuid: uuid.v4(), 29 | }; 30 | 31 | const wallet = { 32 | id: 12, 33 | name: 'wallet', 34 | password: 'test1234', 35 | passwordHash: 36 | '31dd4fe716e1a908f0e9612c1a0e92bfdd9f66e75ae12244b4ee8309d5b869d435182f5848b67177aa17a05f9306e23c10ba41675933e2cb20c66f1b009570c1', 37 | salt: 'TnDe2LDPS7VaPD9GQWL3fhG4jk194nde', 38 | type: 'p', 39 | }; 40 | 41 | const storyOfThisSeed = ` 42 | a capture: #${capture.id} 43 | 44 | a token: #${token.id} 45 | capture: #${capture.id} 46 | wallet: #${wallet.id} 47 | uuid: ${token.uuid} 48 | 49 | wallet #${wallet.id} connected to capture #${capture.id}, get a token #${token.id} 50 | 51 | Another capture: #${captureB.id} 52 | 53 | 54 | `; 55 | console.debug( 56 | '--------------------------story of database ----------------------------------', 57 | storyOfThisSeed, 58 | '-----------------------------------------------------------------------------', 59 | ); 60 | 61 | async function seed() { 62 | log.debug('seed api key'); 63 | 64 | // wallet 65 | await knex('wallet').insert({ 66 | id: wallet.id, 67 | type: wallet.type, 68 | name: wallet.name, 69 | password: wallet.passwordHash, 70 | salt: wallet.salt, 71 | }); 72 | 73 | // token 74 | log.log('seed token'); 75 | await knex('token').insert({ 76 | id: token.id, 77 | capture_id: capture.id, 78 | entity_id: wallet.id, 79 | uuid: token.uuid, 80 | }); 81 | 82 | await knex('token').insert(tokenB); 83 | } 84 | 85 | async function clear() { 86 | log.debug('clear tables'); 87 | await knex('token').del(); 88 | await knex('wallet').del(); 89 | } 90 | 91 | module.exports = { 92 | seed, 93 | clear, 94 | wallet, 95 | token, 96 | }; 97 | -------------------------------------------------------------------------------- /server/routers/speciesRouterV2.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import Joi from 'joi'; 3 | import log from 'loglevel'; 4 | import { handlerWrapper } from './utils'; 5 | import Session from '../infra/database/Session'; 6 | import SpeciesRepositoryV2 from '../infra/database/SpeciesRepositoryV2'; 7 | import SpeciesModel from '../models/SpeciesV2'; 8 | 9 | const router = express.Router(); 10 | type Filter = Partial<{ 11 | planter_id: number; 12 | organization_id: string; 13 | wallet_id: string; 14 | grower_id: string; 15 | }>; 16 | 17 | router.get( 18 | '/:id', 19 | handlerWrapper(async (req, res) => { 20 | Joi.assert(req.params.id, Joi.string().uuid().required()); 21 | const repo = new SpeciesRepositoryV2(new Session()); 22 | const exe = SpeciesModel.getById(repo); 23 | const result = await exe(req.params.id); 24 | res.send(result); 25 | res.end(); 26 | }), 27 | ); 28 | 29 | router.get( 30 | '/', 31 | handlerWrapper(async (req, res) => { 32 | Joi.assert( 33 | req.query, 34 | Joi.object().keys({ 35 | limit: Joi.number().integer().min(1).max(1000), 36 | offset: Joi.number().integer().min(0), 37 | keyword: Joi.string(), 38 | id: Joi.string().uuid(), 39 | scientific_name: Joi.string(), 40 | description: Joi.string(), 41 | morphology: Joi.string(), 42 | range: Joi.string(), 43 | created_at: Joi.string(), 44 | updated_at: Joi.string(), 45 | }), 46 | ); 47 | const { 48 | limit = 20, 49 | offset = 0, 50 | planter_id, 51 | organization_id, 52 | wallet_id, 53 | grower_id, 54 | } = req.query; 55 | const repo = new SpeciesRepositoryV2(new Session()); 56 | const filter: Filter = {}; 57 | if (organization_id) { 58 | filter.organization_id = organization_id; 59 | } else if (planter_id) { 60 | filter.planter_id = planter_id; 61 | } else if (wallet_id) { 62 | filter.wallet_id = wallet_id; 63 | } else if (grower_id) { 64 | filter.grower_id = grower_id; 65 | } 66 | const begin = Date.now(); 67 | const result = await SpeciesModel.getByFilter(repo)(filter, { 68 | limit, 69 | offset, 70 | }); 71 | log.warn('species filter:', filter, 'took time:', Date.now() - begin, 'ms'); 72 | res.send({ 73 | offset, 74 | limit, 75 | species: result, 76 | }); 77 | res.end(); 78 | }), 79 | ); 80 | 81 | export default router; 82 | -------------------------------------------------------------------------------- /docs/api/spec/examples/trees/186736.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 186736, 3 | "time_created": "2020-10-19T06:47:15.000Z", 4 | "time_updated": "2020-10-19T06:47:15.000Z", 5 | "missing": false, 6 | "priority": false, 7 | "cause_of_death_id": null, 8 | "planter_id": 940, 9 | "primary_location_id": null, 10 | "settings_id": null, 11 | "override_settings_id": null, 12 | "dead": 0, 13 | "photo_id": null, 14 | "photo_url": "https://treetracker-dev-images.s3.eu-central-1.amazonaws.com/2020.10.19.09.50.52_-5.508076904796398_38.98152805626448_28181c3e-e5b9-442b-8bb4-00de35de3de2_IMG_20201019_094643_486288846930987329.jpg", 15 | "certificate_id": null, 16 | "estimated_geometric_location": "0101000020E610000096E11AB6A27D434051D0E74F450816C0", 17 | "lat": "-5.508076904796398", 18 | "lon": "38.98152805626448", 19 | "gps_accuracy": 3, 20 | "active": true, 21 | "planter_photo_url": "https://treetracker-dev-images.s3.eu-central-1.amazonaws.com/2020.10.19.09.47.53_-5.508107173727935_38.981361706266256_39f0cc9d-0f13-4547-8142-150f15cabb67_IMG_20201019_094513_6614320100195503436.jpg", 22 | "device_id": null, 23 | "sequence": null, 24 | "note": "", 25 | "verified": false, 26 | "uuid": "a2dc5663-2e0e-4002-8ded-bbdec66df3a3", 27 | "approved": false, 28 | "status": "planted", 29 | "cluster_regions_assigned": true, 30 | "species_id": null, 31 | "planting_organization_id": 1, 32 | "payment_id": null, 33 | "contract_id": null, 34 | "token_issued": false, 35 | "morphology": null, 36 | "age": null, 37 | "species": null, 38 | "capture_approval_tag": null, 39 | "rejection_reason": null, 40 | "matching_hash": null, 41 | "device_identifier": "651f04008af0d91a", 42 | "images": {}, 43 | "domain_specific_data": {}, 44 | "token_id": null, 45 | "name": null, 46 | "earnings_id": null, 47 | "species_name": null, 48 | "first_name": "Sebastian ", 49 | "last_name": "Gaertner", 50 | "user_image_url": "https://treetracker-production.nyc3.digitaloceanspaces.com/2019.07.10.18.32.42_b4fad89a-10b6-40cc-a134-0085d0e581d2_IMG_20190710_183201_8089920786231467340.jpg", 51 | "token_uuid": null, 52 | "wallet": null, 53 | "attributes": { 54 | "dbh": "35", 55 | "abs_step_count": "75351", 56 | "delta_step_count": "0", 57 | "rotation_matrix": "0.7345227,0.574929,-0.36046112,0.0,-0.6743088,0.6779337,-0.2927671,0.0,0.07604805,0.4581064,0.8856381,0.0,0.0,0.0,0.0,1.0", 58 | "height_color": "green", 59 | "app_build": "1.4.0", 60 | "app_flavor": "justdiggit", 61 | "app_version": "113" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /docs/api/spec/examples/trees/186735.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 186735, 3 | "time_created": "2020-10-19T06:47:40.000Z", 4 | "time_updated": "2020-10-19T06:47:40.000Z", 5 | "missing": false, 6 | "priority": false, 7 | "cause_of_death_id": null, 8 | "planter_id": 940, 9 | "primary_location_id": null, 10 | "settings_id": null, 11 | "override_settings_id": null, 12 | "dead": 0, 13 | "photo_id": null, 14 | "photo_url": "https://treetracker-dev-images.s3.eu-central-1.amazonaws.com/2020.10.19.09.51.07_-5.5080179325596275_38.981510909625214_bd17cf5e-af94-4644-a02d-ca07d55a02ed_IMG_20201019_094716_5079900695025456370.jpg", 15 | "certificate_id": null, 16 | "estimated_geometric_location": "0101000020E6100000F6C04426A27D4340238058DA350816C0", 17 | "lat": "-5.5080179325596275", 18 | "lon": "38.981510909625214", 19 | "gps_accuracy": 3, 20 | "active": true, 21 | "planter_photo_url": "https://treetracker-dev-images.s3.eu-central-1.amazonaws.com/2020.10.19.09.47.53_-5.508107173727935_38.981361706266256_39f0cc9d-0f13-4547-8142-150f15cabb67_IMG_20201019_094513_6614320100195503436.jpg", 22 | "device_id": null, 23 | "sequence": null, 24 | "note": "", 25 | "verified": false, 26 | "uuid": "a7eb740b-e539-48f2-8b0c-ff77bf2e898d", 27 | "approved": false, 28 | "status": "planted", 29 | "cluster_regions_assigned": true, 30 | "species_id": null, 31 | "planting_organization_id": 1, 32 | "payment_id": null, 33 | "contract_id": null, 34 | "token_issued": false, 35 | "morphology": null, 36 | "age": null, 37 | "species": null, 38 | "capture_approval_tag": null, 39 | "rejection_reason": null, 40 | "matching_hash": null, 41 | "device_identifier": "651f04008af0d91a", 42 | "images": {}, 43 | "domain_specific_data": {}, 44 | "token_id": null, 45 | "name": null, 46 | "earnings_id": null, 47 | "species_name": null, 48 | "first_name": "Sebastian ", 49 | "last_name": "Gaertner", 50 | "user_image_url": "https://treetracker-production.nyc3.digitaloceanspaces.com/2019.07.10.18.32.42_b4fad89a-10b6-40cc-a134-0085d0e581d2_IMG_20190710_183201_8089920786231467340.jpg", 51 | "token_uuid": null, 52 | "wallet": null, 53 | "attributes": { 54 | "dbh": "12", 55 | "abs_step_count": "75351", 56 | "delta_step_count": "0", 57 | "rotation_matrix": "0.48572278,0.8352238,-0.2578219,0.0,-0.87364197,0.47353113,-0.11187259,0.0,0.02864749,0.27958348,0.9596938,0.0,0.0,0.0,0.0,1.0", 58 | "height_color": "yellow", 59 | "app_build": "1.4.0", 60 | "app_flavor": "justdiggit", 61 | "app_version": "113" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /docs/api/spec/examples/trees/186737.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 186737, 3 | "time_created": "2020-10-19T06:46:10.000Z", 4 | "time_updated": "2020-10-19T06:46:10.000Z", 5 | "missing": false, 6 | "priority": false, 7 | "cause_of_death_id": null, 8 | "planter_id": 940, 9 | "primary_location_id": null, 10 | "settings_id": null, 11 | "override_settings_id": null, 12 | "dead": 0, 13 | "photo_id": null, 14 | "photo_url": "https://treetracker-dev-images.s3.eu-central-1.amazonaws.com/2020.10.19.09.50.04_-5.508159084408904_38.98143245004688_ae4ec0dc-0db0-4d92-abcb-f26e57517084_IMG_20201019_094534_4646184952038769407.jpg", 15 | "certificate_id": null, 16 | "estimated_geometric_location": "0101000020E61000009E171A949F7D434062CEE2DA5A0816C0", 17 | "lat": "-5.508159084408904", 18 | "lon": "38.98143245004688", 19 | "gps_accuracy": 4, 20 | "active": true, 21 | "planter_photo_url": "https://treetracker-dev-images.s3.eu-central-1.amazonaws.com/2020.10.19.09.47.53_-5.508107173727935_38.981361706266256_39f0cc9d-0f13-4547-8142-150f15cabb67_IMG_20201019_094513_6614320100195503436.jpg", 22 | "device_id": null, 23 | "sequence": null, 24 | "note": "", 25 | "verified": false, 26 | "uuid": "5f1f275d-a4da-4486-9433-2d659a52c5a9", 27 | "approved": false, 28 | "status": "planted", 29 | "cluster_regions_assigned": true, 30 | "species_id": null, 31 | "planting_organization_id": 1, 32 | "payment_id": null, 33 | "contract_id": null, 34 | "token_issued": false, 35 | "morphology": null, 36 | "age": null, 37 | "species": null, 38 | "capture_approval_tag": null, 39 | "rejection_reason": null, 40 | "matching_hash": null, 41 | "device_identifier": "651f04008af0d91a", 42 | "images": {}, 43 | "domain_specific_data": {}, 44 | "token_id": null, 45 | "name": null, 46 | "earnings_id": null, 47 | "species_name": null, 48 | "first_name": "Sebastian ", 49 | "last_name": "Gaertner", 50 | "user_image_url": "https://treetracker-production.nyc3.digitaloceanspaces.com/2019.07.10.18.32.42_b4fad89a-10b6-40cc-a134-0085d0e581d2_IMG_20190710_183201_8089920786231467340.jpg", 51 | "token_uuid": null, 52 | "wallet": null, 53 | "attributes": { 54 | "dbh": "20", 55 | "abs_step_count": "75351", 56 | "delta_step_count": "75352", 57 | "rotation_matrix": "-0.9759576,0.21661246,0.024082791,0.0,-0.16504723,-0.8067132,0.56742203,0.0,0.14233884,0.5498067,0.8230745,0.0,0.0,0.0,0.0,1.0", 58 | "height_color": "yellow", 59 | "app_build": "1.4.0", 60 | "app_flavor": "justdiggit", 61 | "app_version": "113" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /server/routers/utils.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Some utils for router/express 3 | */ 4 | import { ValidationError } from 'joi'; 5 | import log from 'loglevel'; 6 | import HttpError from '../utils/HttpError'; 7 | 8 | /* 9 | * This is from the library https://github.com/Abazhenov/express-async-handler 10 | * Made some customization for our project. With this, we can throw Error from 11 | * the handler function or internal function call stack, and parse the error, 12 | * send to the client with appropriate response (http error code & json body) 13 | * 14 | * USAGE: wrap the express handler with this function: 15 | * 16 | * router.get("/xxx", handlerWrap(async (res, rep) => { 17 | * ... 18 | * })); 19 | * 20 | * Then, add the errorHandler below to the express global error handler. 21 | * 22 | */ 23 | const handlerWrapper = (fn) => 24 | function wrap(...args) { 25 | const fnReturn = fn(...args); 26 | const next = args[args.length - 1]; 27 | return Promise.resolve(fnReturn).catch((e) => { 28 | next(e); 29 | }); 30 | }; 31 | 32 | const errorHandler = (err, _req, res, _next) => { 33 | if (process.env.NODE_LOG_LEVEL === 'debug') { 34 | log.error('catch error:', err); 35 | } else { 36 | log.error('catch error:', err); 37 | } 38 | if (err instanceof HttpError) { 39 | res.status(err.code).send({ 40 | code: err.code, 41 | message: err.message, 42 | }); 43 | } else if (err instanceof ValidationError) { 44 | res.status(422).send({ 45 | code: 422, 46 | message: err.details.map((m) => m.message).join(';'), 47 | }); 48 | } else { 49 | res.status(500).send({ 50 | code: 500, 51 | message: `Unknown error (${err.message})`, 52 | }); 53 | } 54 | }; 55 | 56 | const queryFormatter = (req) => { 57 | const { whereNulls, whereNotNulls, whereIns, organization_id, ...others } = 58 | req.query; 59 | 60 | // parse values before verifying 61 | const query = { 62 | whereNulls: whereNulls?.length ? JSON.parse(whereNulls) : [], 63 | whereNotNulls: whereNotNulls?.length ? JSON.parse(whereNotNulls) : [], 64 | whereIns: whereIns?.length ? JSON.parse(whereIns) : [], 65 | ...others, 66 | }; 67 | if (req.query.organization_id) { 68 | query.organization_id = JSON.parse(req.query.organization_id); 69 | } 70 | 71 | if (req.query.captures_amount_min) { 72 | query.captures_amount_min = parseInt(req.query.captures_amount_min); 73 | } 74 | 75 | if (req.query.captures_amount_max) { 76 | query.captures_amount_max = parseInt(req.query.captures_amount_max); 77 | } 78 | 79 | return query; 80 | }; 81 | 82 | export { handlerWrapper, errorHandler, queryFormatter }; 83 | -------------------------------------------------------------------------------- /server/routers/walletsRouter.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import Joi from 'joi'; 3 | import FilterOptions from 'interfaces/FilterOptions'; 4 | import { handlerWrapper } from './utils'; 5 | import Session from '../infra/database/Session'; 6 | import WalletsRepository from '../infra/database/WalletsRepository'; 7 | import WalletModel from '../models/Wallets'; 8 | 9 | const router = express.Router(); 10 | type Filter = Partial<{ id: number; name: string }>; 11 | 12 | router.get( 13 | '/featured', 14 | handlerWrapper(async (req, res) => { 15 | const repo = new WalletsRepository(new Session()); 16 | const exe = WalletModel.getFeaturedWallet(repo); 17 | const result = await exe(); 18 | res.send({ 19 | wallets: result, 20 | }); 21 | res.end(); 22 | }), 23 | ); 24 | 25 | router.get( 26 | '/:walletIdOrName/token-region-count', 27 | handlerWrapper(async (req: express.Request, res: express.Response) => { 28 | Joi.assert(req.params.walletIdOrName, Joi.string().required()); 29 | const repo = new WalletsRepository(new Session()); 30 | const exe = WalletModel.getWalletTokenContinentCount(repo); 31 | const result = await exe(req.params.walletIdOrName); 32 | res.send({ 33 | walletStatistics: result, 34 | }); 35 | res.end(); 36 | }), 37 | ); 38 | 39 | router.get( 40 | '/:walletIdOrName', 41 | handlerWrapper(async (req, res) => { 42 | Joi.assert(req.params.walletIdOrName, Joi.string().required()); 43 | const repo = new WalletsRepository(new Session()); 44 | const exe = WalletModel.getWalletByIdOrName(repo); 45 | const result = await exe(req.params.walletIdOrName); 46 | res.send(result); 47 | res.end(); 48 | }), 49 | ); 50 | 51 | router.get( 52 | '/', 53 | handlerWrapper(async (req, res) => { 54 | Joi.assert( 55 | req.query, 56 | Joi.object().keys({ 57 | name: Joi.string(), 58 | limit: Joi.number().integer().min(1).max(1000), 59 | offset: Joi.number().integer().min(0), 60 | }), 61 | ); 62 | const { limit = 20, offset = 0, name } = req.query; 63 | const repo = new WalletsRepository(new Session()); 64 | const filter: Filter = {}; 65 | if (name) { 66 | filter.name = name; 67 | } 68 | 69 | const options: FilterOptions = { 70 | limit, 71 | offset, 72 | }; 73 | 74 | const result = await WalletModel.getByFilter(repo)(filter, options); 75 | const count = await WalletModel.getCount(repo)(filter); 76 | res.send({ 77 | total: Number(count), 78 | offset, 79 | limit, 80 | wallets: result, 81 | }); 82 | res.end(); 83 | }), 84 | ); 85 | 86 | export default router; 87 | -------------------------------------------------------------------------------- /__tests__/e2e/countries.spec.ts: -------------------------------------------------------------------------------- 1 | import supertest from 'supertest'; 2 | import app from '../../server/app'; 3 | import seed from '../seed'; 4 | 5 | describe('', () => { 6 | it('countries/6632544', async () => { 7 | const response = await supertest(app).get('/countries/6632544'); 8 | expect(response.status).toBe(200); 9 | expect(response.body).toMatchObject({ 10 | id: 6632544, 11 | name: 'China', 12 | }); 13 | }); 14 | 15 | // 103.819073145824,36.5617653792527 16 | it('countries?lat=36.5617653792527&lon=103.819073145824', async () => { 17 | const response = await supertest(app).get( 18 | '/countries?lat=36.5617653792527&lon=103.819073145824', 19 | ); 20 | expect(response.status).toBe(200); 21 | expect(response.body.countries[0]).toMatchObject({ 22 | id: 6632544, 23 | name: 'China', 24 | }); 25 | }); 26 | 27 | it('countries/v2/6632544', async () => { 28 | const response = await supertest(app).get('/countries/v2/6632544'); 29 | expect(response.status).toBe(200); 30 | expect(response.body).toMatchObject({ 31 | id: 6632544, 32 | name: 'China', 33 | }); 34 | }); 35 | 36 | // 103.819073145824,36.5617653792527 37 | it('countries/v2/?lat=36.5617653792527&lon=103.819073145824', async () => { 38 | const response = await supertest(app).get( 39 | '/countries/v2/?lat=36.5617653792527&lon=103.819073145824', 40 | ); 41 | expect(response.status).toBe(200); 42 | expect(response.body.countries[0]).toMatchObject({ 43 | id: 6632544, 44 | name: 'China', 45 | }); 46 | }); 47 | 48 | it( 49 | 'countries/leaderboard/Europe', 50 | async () => { 51 | const response = await supertest(app).get( 52 | '/countries/leaderboard/Europe', 53 | ); 54 | expect(response.status).toBe(200); 55 | expect(response.body.countries[0]).toMatchObject({ 56 | id: expect.any(Number), 57 | name: expect.any(String), 58 | planted: expect.any(String), 59 | centroid: expect.stringMatching(/coordinates/), 60 | }); 61 | }, 62 | 1000 * 600, 63 | ); 64 | 65 | it('countries/v2/leaderboard', async () => { 66 | const response = await seed 67 | .clear() 68 | .then(async () => 69 | seed 70 | .seed() 71 | .then(async () => supertest(app).get('/countries/v2/leaderboard')), 72 | ); 73 | expect(response.status).toBe(200); 74 | expect(response.body.countries[0]).toMatchObject({ 75 | id: 6632357, 76 | name: 'United States', 77 | planted: '2', 78 | centroid: expect.stringMatching(/coordinates/), 79 | }); 80 | await seed.clear(); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /server/infra/database/CountryRepositoryV2.ts: -------------------------------------------------------------------------------- 1 | import Country from 'interfaces/Country'; 2 | import HttpError from 'utils/HttpError'; 3 | import BaseRepository from './BaseRepository'; 4 | import Session from './Session'; 5 | 6 | type Filter = Partial<{ lat: number; lon: number }>; 7 | 8 | export default class CountryRepositoryV2 extends BaseRepository { 9 | constructor(session: Session) { 10 | super('region', session); 11 | } 12 | 13 | async getById(id: string | number) { 14 | const object = await this.session 15 | .getDB() 16 | .select( 17 | this.session.getDB().raw(` 18 | id, 19 | name, 20 | St_asgeojson(centroid) as centroid 21 | `), 22 | ) 23 | .table(this.tableName) 24 | .where('id', id) 25 | .first(); 26 | if (!object) { 27 | throw new HttpError(404, `Can not found ${this.tableName} by id:${id}`); 28 | } 29 | return object; 30 | } 31 | 32 | async getByFilter( 33 | filter: Filter, 34 | // options?: { limit?: number | undefined } | undefined, 35 | ): Promise { 36 | const { lat, lon } = filter; 37 | const sql = ` 38 | WITH country_id AS ( 39 | select id from region_type where type = 'country' 40 | ) 41 | SELECT 42 | id, 43 | name, 44 | St_asgeojson(centroid) as centroid 45 | FROM 46 | region 47 | WHERE 48 | ST_Contains(geom, ST_GeomFromText('POINT(${lon} ${lat})', 4326)) = true 49 | AND 50 | type_id in (select id from country_id); 51 | `; 52 | const object = await this.session.getDB().raw(sql); 53 | if (!object || object.rows.length <= 0) { 54 | throw new HttpError( 55 | 404, 56 | `Can not found ${this.tableName} by lat:${lat} lon:${lon}`, 57 | ); 58 | } 59 | return object.rows; 60 | } 61 | 62 | 63 | async getLeaderBoard(top = 10) { 64 | const sql = ` 65 | select r.*, public.region.name, ST_AsGeoJSON(public.region.centroid) as centroid from ( 66 | select count(public.region.id) as planted, public.region.id 67 | from webmap.raw_capture_feature 68 | LEFT JOIN public.region 69 | on ST_WITHIN(webmap.raw_capture_feature.location, public.region.geom) 70 | left join public.region_type 71 | on public.region.type_id = public.region_type.id 72 | where 73 | public.region_type.type = 'country' 74 | group by public.region.id 75 | order by count(public.region.id) desc 76 | limit ${top} 77 | ) r left join public.region 78 | on r.id = public.region.id 79 | ; 80 | `; 81 | const object = await this.session.getDB().raw(sql); 82 | return object.rows; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'plugin:import/recommended', 4 | 'plugin:import/typescript', 5 | 'plugin:@typescript-eslint/recommended', 6 | 'airbnb-base', 7 | 'airbnb-typescript/base', 8 | 'prettier', 9 | ], 10 | 11 | plugins: [], 12 | 13 | rules: { 14 | // disabled or modified because too strict 15 | 'no-nested-ternary': 'off', 16 | 'no-restricted-globals': 'warn', 17 | 'no-console': ['warn', { allow: ['info', 'error'] }], 18 | 'import/prefer-default-export': 'off', 19 | 'no-plusplus': ['error', { allowForLoopAfterthoughts: true }], 20 | 'no-promise-executor-return': 'off', 21 | 'consistent-return': 'off', 22 | radix: 'off', 23 | '@typescript-eslint/no-unused-expressions': [ 24 | 'error', 25 | { allowShortCircuit: true }, 26 | ], 27 | 28 | // non-default errors 29 | '@typescript-eslint/require-await': 'error', 30 | '@typescript-eslint/await-thenable': 'error', 31 | 32 | '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], 33 | 34 | // naming conventions 35 | camelcase: 'off', 36 | '@typescript-eslint/naming-convention': [ 37 | 'error', 38 | { 39 | selector: 'variable', 40 | format: ['camelCase', 'UPPER_CASE', 'snake_case'], 41 | }, 42 | ], 43 | 44 | // allow test files to use dev deps 45 | 'import/no-extraneous-dependencies': [ 46 | 'error', 47 | { 48 | devDependencies: ['**/*.{test,spec}.ts', 'test/**/*'], 49 | }, 50 | ], 51 | 52 | // import sorting 53 | 'import/order': [ 54 | 'error', 55 | { 56 | groups: [ 57 | 'builtin', // node built in 58 | 'external', // installed dependencies 59 | 'internal', // baseUrl 60 | 'index', // ./ 61 | 'sibling', // ./* 62 | 'parent', // ../* 63 | 'object', // ts only 64 | 'type', // ts only 65 | ], 66 | pathGroups: [ 67 | { 68 | pattern: '@test/**', 69 | group: 'internal', 70 | position: 'after', 71 | }, 72 | ], 73 | pathGroupsExcludedImportTypes: ['builtin'], 74 | alphabetize: { 75 | order: 'asc', 76 | caseInsensitive: true, 77 | }, 78 | 'newlines-between': 'never', 79 | }, 80 | ], 81 | }, 82 | 83 | env: { 84 | es2021: true, 85 | node: true, 86 | jest: true, 87 | }, 88 | 89 | parser: '@typescript-eslint/parser', 90 | 91 | parserOptions: { 92 | ecmaVersion: 13, 93 | sourceType: 'module', 94 | project: ['./tsconfig.json'], 95 | }, 96 | 97 | // report unused eslint-disable comments 98 | reportUnusedDisableDirectives: true, 99 | 100 | settings: { 101 | 'import/resolver': { 102 | node: { 103 | extensions: ['.ts'], 104 | moduleDirectory: ['server/'], 105 | }, 106 | }, 107 | }, 108 | }; 109 | -------------------------------------------------------------------------------- /server/models/Planter.ts: -------------------------------------------------------------------------------- 1 | import log from 'loglevel'; 2 | import FilterOptions from 'interfaces/FilterOptions'; 3 | import Planter from 'interfaces/Planter'; 4 | import { delegateRepository } from '../infra/database/delegateRepository'; 5 | import PlanterRepository from '../infra/database/PlanterRepository'; 6 | 7 | type Filter = Partial<{ organization_id: number }>; 8 | 9 | function getByFilter( 10 | planterRepository: PlanterRepository, 11 | ): (filter: Filter, options: FilterOptions) => Promise { 12 | return async function (filter: Filter, options: FilterOptions) { 13 | if (filter.organization_id) { 14 | log.warn('using org filter...'); 15 | const trees = await planterRepository.getByOrganization( 16 | filter.organization_id, 17 | options, 18 | ); 19 | return trees; 20 | } 21 | const trees = await planterRepository.getByFilter(filter, options); 22 | return trees; 23 | }; 24 | } 25 | 26 | function countByFilter( 27 | planterRepository: PlanterRepository, 28 | ): (filter: Filter) => Promise { 29 | return async function (filter: Filter) { 30 | if (filter.organization_id) { 31 | log.warn('using org filter...'); 32 | const total = await planterRepository.countByOrganization( 33 | filter.organization_id, 34 | ); 35 | return total; 36 | } 37 | const total = await planterRepository.countByFilter(filter); 38 | return total; 39 | }; 40 | } 41 | 42 | function getByName( 43 | planterRepository: PlanterRepository, 44 | ): (keyword: string, options: FilterOptions) => Promise { 45 | return async function (keyword: string, options: FilterOptions) { 46 | log.warn('using planter name filter...'); 47 | const planters = await planterRepository.getByName(keyword, options); 48 | return planters; 49 | }; 50 | } 51 | 52 | function countByName( 53 | planterRepository: PlanterRepository, 54 | ): (keyword: string) => Promise { 55 | return async function (keyword: string) { 56 | const total = await planterRepository.countByName(keyword); 57 | return total; 58 | }; 59 | } 60 | 61 | function getPlanterLinks(planter) { 62 | const links = { 63 | featured_trees: `/trees?planter_id=${planter.id}&limit=20&offset=0`, 64 | associated_organizations: `/organizations?planter_id=${planter.id}&limit=20&offset=0`, 65 | species: `/species?planter_id=${planter.id}&limit=20&offset=0`, 66 | }; 67 | return links; 68 | } 69 | 70 | function getFeaturedPlanters( 71 | planterRepository: PlanterRepository, 72 | ): (options: FilterOptions) => Promise { 73 | return async function (options: FilterOptions) { 74 | log.warn('using featured planters filter...'); 75 | const planters = await planterRepository.getFeaturedPlanters(options); 76 | return planters; 77 | }; 78 | } 79 | 80 | export default { 81 | getById: delegateRepository('getById'), 82 | getByFilter, 83 | getByName, 84 | getPlanterLinks, 85 | getFeaturedPlanters, 86 | countByName, 87 | countByFilter 88 | }; 89 | -------------------------------------------------------------------------------- /server/infra/database/BoundsRepository.ts: -------------------------------------------------------------------------------- 1 | import Bounds from 'interfaces/Bounds'; 2 | import HttpError from 'utils/HttpError'; 3 | import Session from './Session'; 4 | 5 | export default class BoundsRepository { 6 | session: Session; 7 | 8 | constructor(session: Session) { 9 | this.session = session; 10 | } 11 | 12 | async filterByPlanter(planterId: string): Promise { 13 | const planterBoundsSql = ` 14 | select 15 | ST_EXTENT(ST_GeomFromText('POINT(' || t.lon || ' ' || t.lat || ')', 4326)) as bounds 16 | from public.planter p 17 | left join public.trees t on t.planter_id = p.id 18 | where p.id = ${planterId}; 19 | `; 20 | const { bounds } = (await this.session.getDB().raw(planterBoundsSql)) 21 | .rows[0]; 22 | if (!bounds) { 23 | throw new HttpError( 24 | 404, 25 | `Can not found bounds for this planter ${planterId}`, 26 | ); 27 | } 28 | return BoundsRepository.convertStringToBounds(bounds); 29 | } 30 | 31 | async filterByWallet(walletId: string): Promise { 32 | const walletBoundsSql = ` 33 | select 34 | ST_EXTENT(ST_GeomFromText('POINT(' || t.lon || ' ' || t.lat || ')', 4326)) as bounds 35 | from public.trees t 36 | inner join wallet.token tk on tk.capture_id::text = t.uuid 37 | where tk.wallet_id = ${walletId} 38 | `; 39 | const { bounds } = (await this.session.getDB().raw(walletBoundsSql)) 40 | .rows[0]; 41 | if (!bounds) { 42 | throw new HttpError( 43 | 404, 44 | `Can not found bounds for this wallet ${walletId}`, 45 | ); 46 | } 47 | return BoundsRepository.convertStringToBounds(bounds); 48 | } 49 | 50 | async filterByOrganisation( 51 | organisationId: string[] | string, 52 | ): Promise { 53 | const organisationBoundsSql = ` 54 | select 55 | ST_EXTENT(ST_GeomFromText('POINT(' || t.lon || ' ' || t.lat || ')', 4326)) as bounds 56 | from public.entity e 57 | inner join public.planter p on e.id = p.organization_id 58 | inner join public.trees t on t.planter_id = p.id 59 | where e.id = ${organisationId}; 60 | `; 61 | const { bounds } = (await this.session.getDB().raw(organisationBoundsSql)) 62 | .rows[0]; 63 | if (!bounds) { 64 | throw new HttpError( 65 | 404, 66 | `Could not found bounds for this organisation ${organisationId}`, 67 | ); 68 | } 69 | return BoundsRepository.convertStringToBounds(bounds); 70 | } 71 | 72 | static convertStringToBounds(boundString: string): Bounds { 73 | // result that we get back from db 74 | // BOX(-165.75204 -87.53491,178.08949 82.28428) 75 | const trimmedString = boundString.slice(4, boundString.length - 2); 76 | const lngLatStrArr = trimmedString.split(','); 77 | const lngLatNumArr = lngLatStrArr.map((coorStr) => 78 | coorStr.split(' ').map((str) => Number(str)), 79 | ); 80 | return { 81 | ne: [lngLatNumArr[0][1], lngLatNumArr[0][0]], 82 | se: [lngLatNumArr[1][1], lngLatNumArr[1][0]], 83 | }; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /server/routers/organizationsRouterV2.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import Joi from 'joi'; 3 | import { handlerWrapper } from './utils'; 4 | import OrganizationRepositoryV2 from '../infra/database/OrganizationRepositoryV2'; 5 | import Session from '../infra/database/Session'; 6 | import OrganizationModel from '../models/OrganizationV2'; 7 | 8 | type Filter = Partial<{ 9 | planter_id: number; 10 | organization_id: number; 11 | grower_id: string; 12 | ids: Array; 13 | }>; 14 | 15 | const router = express.Router(); 16 | 17 | router.get( 18 | '/featured', 19 | handlerWrapper(async (req, res) => { 20 | const repo = new OrganizationRepositoryV2(new Session()); 21 | const result = await OrganizationModel.getFeaturedOrganizations(repo)(); 22 | res.send({ 23 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 24 | // @ts-ignore 25 | organizations: result.map((org) => ({ 26 | ...org, 27 | links: OrganizationModel.getOrganizationLinks(org), 28 | })), 29 | }); 30 | res.end(); 31 | }), 32 | ); 33 | 34 | router.get( 35 | '/:id', 36 | handlerWrapper(async (req, res) => { 37 | Joi.assert(req.params.id, Joi.string().required()); 38 | const repo = new OrganizationRepositoryV2(new Session()); 39 | const exe = OrganizationModel.getById(repo); 40 | // if (isNaN(req.params.id)) { 41 | // exe = OrganizationModel.getByMapName(repo); 42 | // } else { 43 | // exe = OrganizationModel.getById(repo); 44 | // } 45 | const result = await exe(req.params.id); 46 | result.links = OrganizationModel.getOrganizationLinks(result); 47 | res.send(result); 48 | res.end(); 49 | }), 50 | ); 51 | 52 | router.get( 53 | '/', 54 | handlerWrapper(async (req, res) => { 55 | const query = { ...req.query }; 56 | query.ids = JSON.parse(req.query.ids); 57 | Joi.assert( 58 | query, 59 | Joi.object().keys({ 60 | planter_id: Joi.number().integer().min(0), 61 | grower_id: Joi.string().guid(), 62 | ids: Joi.array().items(Joi.string().uuid()), 63 | limit: Joi.number().integer().min(1).max(1000), 64 | offset: Joi.number().integer().min(0), 65 | }), 66 | ); 67 | const { limit = 20, offset = 0, planter_id, grower_id, ids = [] } = query; 68 | const repo = new OrganizationRepositoryV2(new Session()); 69 | const filter: Filter = {}; 70 | if (planter_id) { 71 | filter.planter_id = planter_id; 72 | } else if (grower_id) { 73 | filter.grower_id = grower_id; 74 | } 75 | if (ids.length) { 76 | filter.ids = ids; 77 | } 78 | const result = await OrganizationModel.getByFilter(repo)(filter, { 79 | limit, 80 | offset, 81 | }); 82 | res.send({ 83 | total: null, 84 | offset, 85 | limit, 86 | organizations: result.map((organization) => ({ 87 | ...organization, 88 | links: OrganizationModel.getOrganizationLinks(organization), 89 | })), 90 | }); 91 | res.end(); 92 | }), 93 | ); 94 | 95 | export default router; 96 | -------------------------------------------------------------------------------- /server/infra/database/GisRepository.ts: -------------------------------------------------------------------------------- 1 | import Bounds from 'interfaces/Bounds'; 2 | import HttpError from 'utils/HttpError'; 3 | import Session from './Session'; 4 | 5 | export default class GisRepository { 6 | session: Session; 7 | 8 | constructor(session: Session) { 9 | this.session = session; 10 | } 11 | 12 | async getNearest(params): Promise { 13 | let { zoom_level, lng, lat } = params; 14 | zoom_level = parseInt(zoom_level); 15 | lng = parseFloat(lng); 16 | lat = parseFloat(lat); 17 | console.log('lng:', lng); 18 | let sql; 19 | if (zoom_level <= 11) { 20 | sql = ` 21 | SELECT 22 | ST_ASGeoJson(centroid) 23 | FROM 24 | active_tree_region 25 | WHERE 26 | zoom_level = ${zoom_level} 27 | ORDER BY 28 | active_tree_region.centroid <-> 29 | ST_SetSRID(ST_MakePoint(${lng}, ${lat}),4326) 30 | LIMIT 1; 31 | `; 32 | } else if (zoom_level < 15 && zoom_level > 11) { 33 | sql = ` 34 | SELECT 35 | ST_ASGeoJson(location) 36 | FROM 37 | clusters 38 | ORDER BY 39 | location <-> 40 | ST_SetSRID(ST_MakePoint(${lng}, ${lat}),4326) 41 | LIMIT 1; 42 | `; 43 | } else if (zoom_level >= 15) { 44 | sql = ` 45 | WITH box AS ( 46 | SELECT ST_Extent(ST_MakeLine(ST_Project(ST_SetSRID(ST_MakePoint(${lng}, ${lat}),4326), 10000, radians(45))::geometry, ST_Project(ST_SetSRID(ST_MakePoint(${lng}, ${lat}),4326), 10000, radians(225))::geometry)) AS geom 47 | ) 48 | SELECT 49 | ST_ASGeoJson(estimated_geometric_location) 50 | FROM 51 | trees 52 | ${ 53 | params.wallet_id 54 | ? 'join wallet.token on trees.uuid = wallet.token.capture_id::text join wallet.wallet on wallet.token.wallet_id = wallet.wallet.id ' 55 | : '' 56 | } 57 | ${ 58 | params.organization_id || params.map_name 59 | ? 'join planter on trees.planter_id = planter.id' 60 | : '' 61 | } 62 | WHERE 63 | active = true 64 | ${ 65 | params.wallet_id 66 | ? /[0-9a-f-]{36}/.test(params.wallet_id) 67 | ? `and wallet.token.wallet_id = '${params.wallet_id}' ` 68 | : `and wallet.wallet.name = '${params.wallet_id}'` 69 | : '' 70 | } 71 | ${params.planter_id ? `and planter_id = ${params.planter_id}` : ''} 72 | ${ 73 | params.organization_id 74 | ? `and planter.organization_id in ( SELECT entity_id from getEntityRelationshipChildren(${params.organization_id}))` 75 | : '' 76 | } 77 | ${ 78 | params.map_name 79 | ? `and planter.organization_id in ( SELECT entity_id from getEntityRelationshipChildren((select id from entity where map_name = '${params.map_name}')))` 80 | : '' 81 | } 82 | and estimated_geometric_location && (select geom from box) 83 | ORDER BY 84 | estimated_geometric_location <-> 85 | ST_SetSRID(ST_MakePoint(${lng}, ${lat}),4326) 86 | LIMIT 1; 87 | `; 88 | } 89 | console.log('query:', sql); 90 | const result = await this.session.getDB().raw(sql); 91 | // {"st_asgeojson":"{\"type\":\"Point\",\"coordinates\":[39.1089215842116,-5.12839483715479]}"} 92 | return result.rows.length > 0 93 | ? JSON.parse(result.rows[0].st_asgeojson) 94 | : null; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /server/routers/organizationsRouter.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import Joi from 'joi'; 3 | import { handlerWrapper } from './utils'; 4 | import OrganizationRepository from '../infra/database/OrganizationRepository'; 5 | import Session from '../infra/database/Session'; 6 | import OrganizationModel from '../models/Organization'; 7 | 8 | type Filter = Partial<{ 9 | planter_id: number; 10 | organization_id: number; 11 | ids: Array; 12 | stakeholder_uuid: string; 13 | }>; 14 | 15 | const router = express.Router(); 16 | 17 | router.get( 18 | '/featured', 19 | handlerWrapper(async (req, res) => { 20 | const repo = new OrganizationRepository(new Session()); 21 | const result = await OrganizationModel.getFeaturedOrganizations(repo)(); 22 | res.send({ 23 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 24 | // @ts-ignore 25 | organizations: result.map((org) => ({ 26 | ...org, 27 | links: OrganizationModel.getOrganizationLinks(org), 28 | })), 29 | }); 30 | res.end(); 31 | }), 32 | ); 33 | 34 | router.get( 35 | '/:id', 36 | handlerWrapper(async (req, res) => { 37 | Joi.assert(req.params.id, Joi.string().required()); 38 | const repo = new OrganizationRepository(new Session()); 39 | let exe; 40 | if (isNaN(req.params.id)) { 41 | exe = OrganizationModel.getByMapName(repo); 42 | } else { 43 | exe = OrganizationModel.getById(repo); 44 | } 45 | const result = await exe(req.params.id); 46 | result.links = OrganizationModel.getOrganizationLinks(result); 47 | res.send(result); 48 | res.end(); 49 | }), 50 | ); 51 | 52 | router.get( 53 | '/', 54 | handlerWrapper(async (req, res) => { 55 | const query = { ...req.query }; 56 | query.ids = JSON.parse(req.query.ids); 57 | Joi.assert( 58 | query, 59 | Joi.object().keys({ 60 | planter_id: Joi.number().integer().min(0), 61 | ids: Joi.array().items(Joi.number()), 62 | stakeholder_uuid: Joi.string().uuid(), 63 | limit: Joi.number().integer().min(1).max(1000), 64 | offset: Joi.number().integer().min(0), 65 | }), 66 | ); 67 | const { 68 | limit = 20, 69 | offset = 0, 70 | planter_id, 71 | ids = [], 72 | stakeholder_uuid, 73 | } = query; 74 | const repo = new OrganizationRepository(new Session()); 75 | const filter: Filter = {}; 76 | if (planter_id) { 77 | filter.planter_id = planter_id; 78 | } 79 | if (stakeholder_uuid) { 80 | filter.stakeholder_uuid = stakeholder_uuid; 81 | } 82 | if (ids.length) { 83 | filter.ids = ids; 84 | } 85 | const result = await OrganizationModel.getByFilter(repo)(filter, { 86 | limit, 87 | offset, 88 | }); 89 | res.send({ 90 | total: null, 91 | offset, 92 | limit, 93 | organizations: result.map((organization) => ({ 94 | ...organization, 95 | links: OrganizationModel.getOrganizationLinks(organization), 96 | })), 97 | }); 98 | res.end(); 99 | }), 100 | ); 101 | 102 | export default router; 103 | -------------------------------------------------------------------------------- /server/routers/treesRouterV2.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import Joi from 'joi'; 3 | import FilterOptions from 'interfaces/FilterOptions'; 4 | import Tree from 'interfaces/Tree'; 5 | import { handlerWrapper } from './utils'; 6 | import Session from '../infra/database/Session'; 7 | import TreeRepositoryV2 from '../infra/database/TreeRepositoryV2'; 8 | import TreeModel from '../models/TreeV2'; 9 | 10 | const router = express.Router(); 11 | type Filter = Partial<{ 12 | planter_id: string; 13 | organization_id: number; 14 | date_range: { startDate: string; endDate: string }; 15 | tag: string; 16 | wallet_id: string; 17 | }>; 18 | 19 | router.get( 20 | '/featured', 21 | handlerWrapper(async (req, res) => { 22 | const repo = new TreeRepositoryV2(new Session()); 23 | const exe = TreeModel.getFeaturedTree(repo); 24 | const result = await exe(); 25 | res.send({ 26 | trees: result, 27 | }); 28 | res.end(); 29 | }), 30 | ); 31 | 32 | router.get( 33 | '/:id', 34 | handlerWrapper(async (req, res) => { 35 | Joi.assert(req.params.id, Joi.string().required()); 36 | const repo = new TreeRepositoryV2(new Session()); 37 | const exe = TreeModel.getById(repo); 38 | const result = await exe(req.params.id); 39 | res.send(result); 40 | res.end(); 41 | }), 42 | ); 43 | 44 | router.get( 45 | '/', 46 | handlerWrapper(async (req, res) => { 47 | Joi.assert( 48 | req.query, 49 | Joi.object().keys({ 50 | planter_id: Joi.string().uuid(), 51 | organization_id: Joi.number().integer().min(0), 52 | wallet_id: Joi.string().uuid(), 53 | tag: Joi.string(), 54 | limit: Joi.number().integer().min(1).max(1000), 55 | order_by: Joi.string(), 56 | desc: Joi.boolean(), 57 | offset: Joi.number().integer().min(0), 58 | startDate: Joi.string().regex(/^\d{4}-\d{2}-\d{2}$/), 59 | endDate: Joi.string().regex(/^\d{4}-\d{2}-\d{2}$/), 60 | }), 61 | ); 62 | const { 63 | limit = 20, 64 | offset = 0, 65 | order_by, 66 | desc = true, 67 | organization_id, 68 | startDate, 69 | endDate, 70 | tag, 71 | wallet_id, 72 | } = req.query; 73 | 74 | const repo = new TreeRepositoryV2(new Session()); 75 | const filter: Filter = {}; 76 | const options: FilterOptions = { 77 | limit, 78 | offset, 79 | orderBy: { 80 | column: order_by || 'created_at', 81 | direction: desc === true ? 'desc' : 'asc', 82 | }, 83 | }; 84 | 85 | if (organization_id) { 86 | filter.organization_id = organization_id; 87 | } else if (startDate && endDate) { 88 | filter.date_range = { startDate, endDate }; 89 | } else if (tag) { 90 | filter.tag = tag; 91 | } else if (wallet_id) { 92 | filter.wallet_id = wallet_id; 93 | } 94 | 95 | const result = await TreeModel.getByFilter(repo)(filter, options); 96 | res.send({ 97 | total: await TreeModel.countByFilter(repo)(filter, options), 98 | offset, 99 | limit, 100 | trees: result, 101 | }); 102 | res.end(); 103 | }), 104 | ); 105 | 106 | export default router; 107 | -------------------------------------------------------------------------------- /server/infra/database/SpeciesRepositoryV2.ts: -------------------------------------------------------------------------------- 1 | import FilterOptions from 'interfaces/FilterOptions'; 2 | import Species from 'interfaces/Species'; 3 | import BaseRepository from './BaseRepository'; 4 | import Session from './Session'; 5 | 6 | export default class SpeciesRepositoryV2 extends BaseRepository { 7 | constructor(session: Session) { 8 | super('tree_species', session); 9 | this.tableName = 'herbarium.species'; 10 | } 11 | 12 | filterWhereBuilder(object, builder) { 13 | const result = builder; 14 | const { 15 | whereNulls = [], 16 | whereNotNulls = [], 17 | whereIns = [], 18 | ...parameters 19 | } = object; 20 | 21 | result.whereNot(`${this.tableName}.status`, 'deleted'); 22 | whereNotNulls.forEach((whereNot) => { 23 | result.whereNotNull(whereNot); 24 | }); 25 | 26 | whereNulls.forEach((whereNull) => { 27 | result.whereNull(whereNull); 28 | }); 29 | 30 | whereIns.forEach((whereIn) => { 31 | result.whereIn(whereIn.field, whereIn.values); 32 | }); 33 | 34 | const filterObject = { ...parameters }; 35 | 36 | if (filterObject.id) { 37 | result.where(`${this.tableName}.id`, '=', filterObject.id); 38 | delete filterObject.id; 39 | } 40 | 41 | if (filterObject.scientific_name) { 42 | result.where( 43 | `${this.tableName}.scientific_name`, 44 | 'ilike', 45 | `%${filterObject.scientific_name}%`, 46 | ); 47 | delete filterObject.scientific_name; 48 | } 49 | 50 | if (filterObject.organization_id) { 51 | result.where( 52 | `${this.tableName}.organization_id`, 53 | '=', 54 | `${filterObject.organization_id}`, 55 | ); 56 | delete filterObject.organization_id; 57 | } 58 | 59 | // if 'captures_amount_max' === 0, 'captures_amount_min' can be only 0. 60 | if (filterObject.captures_amount_max === 0) { 61 | result.whereNull('c.captures_count'); 62 | delete filterObject.captures_amount_min; 63 | delete filterObject.captures_amount_max; 64 | } 65 | 66 | // if 'captures_amount_max' === 0 and 'captures_amount_max' is not defined, all results should be returned. 67 | if ( 68 | filterObject.captures_amount_min === 0 && 69 | !filterObject.captures_amount_max 70 | ) { 71 | delete filterObject.captures_amount_min; 72 | delete filterObject.captures_amount_max; 73 | } 74 | 75 | if (filterObject.captures_amount_min) { 76 | result.where( 77 | `c.captures_count`, 78 | '>=', 79 | `${filterObject.captures_amount_min}`, 80 | ); 81 | delete filterObject.captures_amount_min; 82 | } 83 | 84 | if (filterObject.captures_amount_max) { 85 | result.where( 86 | `c.captures_count`, 87 | '<=', 88 | `${filterObject.captures_amount_max}`, 89 | ); 90 | delete filterObject.captures_amount_max; 91 | } 92 | 93 | if (filterObject.wallet) { 94 | result.where( 95 | `${this.tableName}.wallet`, 96 | 'ilike', 97 | `%${filterObject.wallet}%`, 98 | ); 99 | delete filterObject.wallet; 100 | } 101 | 102 | result.where(filterObject); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /server/routers/plantersRouter.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import Joi from 'joi'; 3 | import FilterOptions from 'interfaces/FilterOptions'; 4 | import { handlerWrapper } from './utils'; 5 | import PlanterRepository from '../infra/database/PlanterRepository'; 6 | import Session from '../infra/database/Session'; 7 | import PlanterModel from '../models/Planter'; 8 | 9 | const router = express.Router(); 10 | type Filter = Partial<{ planter_id: number; organization_id: number }>; 11 | 12 | router.get( 13 | '/featured', 14 | handlerWrapper(async (req, res) => { 15 | const repo = new PlanterRepository(new Session()); 16 | const result = await PlanterModel.getFeaturedPlanters(repo)({ limit: 10 }); 17 | res.send({ 18 | planters: result.map((planter) => ({ 19 | ...planter, 20 | links: PlanterModel.getPlanterLinks(planter), 21 | })), 22 | }); 23 | res.end(); 24 | }), 25 | ); 26 | 27 | router.get( 28 | '/:id', 29 | handlerWrapper(async (req, res) => { 30 | Joi.assert(req.params.id, Joi.number().required()); 31 | const repo = new PlanterRepository(new Session()); 32 | const exe = PlanterModel.getById(repo); 33 | const result = await exe(req.params.id); 34 | result.links = PlanterModel.getPlanterLinks(result); 35 | res.send(result); 36 | res.end(); 37 | }), 38 | ); 39 | 40 | router.get( 41 | '/', 42 | handlerWrapper(async (req, res) => { 43 | Joi.assert( 44 | req.query, 45 | Joi.object().keys({ 46 | keyword: Joi.string(), 47 | organization_id: Joi.number().integer().min(0), 48 | limit: Joi.number().integer().min(1).max(1000), 49 | offset: Joi.number().integer().min(0), 50 | order_by: Joi.string(), 51 | order: Joi.string().valid('asc', 'desc', 'ASC', 'DESC'), 52 | }), 53 | ); 54 | const { 55 | limit = 20, 56 | offset = 0, 57 | organization_id, 58 | keyword, 59 | order_by = null, 60 | order = 'asc', 61 | } = req.query; 62 | const options: FilterOptions = { limit, offset }; 63 | if (order_by) { 64 | options.orderBy = { column: order_by, direction: order }; 65 | } 66 | const repo = new PlanterRepository(new Session()); 67 | const filter: Filter = {}; 68 | if (organization_id) { 69 | filter.organization_id = organization_id; 70 | } 71 | 72 | if (keyword !== undefined) { 73 | const result = await PlanterModel.getByName(repo)(keyword, options); 74 | res.send({ 75 | total: await PlanterModel.countByName(repo)(keyword), 76 | offset, 77 | limit, 78 | planters: result.map((planter) => ({ 79 | ...planter, 80 | links: PlanterModel.getPlanterLinks(planter), 81 | })), 82 | }); 83 | res.end(); 84 | } else { 85 | const result = await PlanterModel.getByFilter(repo)(filter, options); 86 | res.send({ 87 | total: await PlanterModel.countByFilter(repo)(filter), 88 | offset, 89 | limit, 90 | planters: result.map((planter) => ({ 91 | ...planter, 92 | links: PlanterModel.getPlanterLinks(planter), 93 | })), 94 | }); 95 | res.end(); 96 | } 97 | }), 98 | ); 99 | 100 | export default router; 101 | -------------------------------------------------------------------------------- /server/app.ts: -------------------------------------------------------------------------------- 1 | import cors from 'cors'; 2 | import express from 'express'; 3 | import log from 'loglevel'; 4 | import responseTime from 'response-time'; 5 | import organizationsRouterV2 from 'routers/organizationsRouterV2'; 6 | import boundsRouter from './routers/boundsRouter'; 7 | import capturesRouter from './routers/capturesRouter'; 8 | import contractsRouter from './routers/contractsRouter'; 9 | import countriesRouter from './routers/countriesRouter'; 10 | import gisRouter from './routers/gisRouter'; 11 | import growerAccountsRouter from './routers/growerAccountsRouter'; 12 | import organizationsRouter from './routers/organizationsRouter'; 13 | import plantersRouter from './routers/plantersRouter'; 14 | import rawCapturesRouter from './routers/rawCapturesRouter'; 15 | import speciesRouter from './routers/speciesRouter'; 16 | import speciesRouterV2 from './routers/speciesRouterV2'; 17 | import stakeholderRouterV2 from './routers/stakeholderRouterV2'; 18 | import tokensRouter from './routers/tokensRouter'; 19 | import transactionsRouter from './routers/transactionsRouter'; 20 | import treesRouter from './routers/treesRouter'; 21 | import treesRouterV2 from './routers/treesRouterV2'; 22 | import { errorHandler, handlerWrapper } from './routers/utils'; 23 | import walletsRouter from './routers/walletsRouter'; 24 | import HttpError from './utils/HttpError'; 25 | 26 | const version = process.env.npm_package_version; 27 | 28 | const app = express(); 29 | 30 | // Sentry.init({ dsn: config.sentry_dsn }); 31 | 32 | app.use( 33 | responseTime((req, res, time) => { 34 | log.warn('API took:', req.originalUrl, time); 35 | }), 36 | ); 37 | 38 | // app allow cors 39 | app.use(cors()); 40 | 41 | /* 42 | * Check request 43 | */ 44 | app.use( 45 | handlerWrapper((req, _res, next) => { 46 | if ( 47 | req.method === 'POST' || 48 | req.method === 'PATCH' || 49 | req.method === 'PUT' 50 | ) { 51 | if (req.headers['content-type'] !== 'application/json') { 52 | throw new HttpError( 53 | 415, 54 | 'Invalid content type. API only supports application/json', 55 | ); 56 | } 57 | } 58 | next(); 59 | }), 60 | ); 61 | 62 | app.use(express.urlencoded({ extended: false })); // parse application/x-www-form-urlencoded 63 | app.use(express.json()); // parse application/json 64 | 65 | // routers 66 | app.use('/countries', countriesRouter); 67 | app.use('/v2/countries', countriesRouter); 68 | app.use('/trees', treesRouter); 69 | app.use('/planters', plantersRouter); 70 | app.use('/organizations', organizationsRouter); 71 | app.use('/v2/organizations', organizationsRouterV2); 72 | app.use('/species', speciesRouter); 73 | app.use('/v2/species', speciesRouterV2); 74 | app.use('/wallets', walletsRouter); 75 | app.use('/v2/wallets', walletsRouter); 76 | app.use('/transactions', transactionsRouter); 77 | app.use('/tokens', tokensRouter); 78 | app.use('/v2/tokens', tokensRouter); 79 | app.use('/v2/captures', capturesRouter); 80 | app.use('/raw-captures', rawCapturesRouter); 81 | app.use('/v2/growers', growerAccountsRouter); 82 | app.use('/v2/trees', treesRouterV2); 83 | app.use('/bounds', boundsRouter); 84 | app.use('/gis', gisRouter); 85 | app.use('/contract', contractsRouter); 86 | app.use('/v2/stakeholder', stakeholderRouterV2); 87 | // Global error handler 88 | app.use(errorHandler); 89 | 90 | app.get('*', (req, res) => { 91 | res.status(404).send(version); 92 | }); 93 | 94 | export default app; 95 | -------------------------------------------------------------------------------- /server/routers/contractsRouter.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import Joi from 'joi'; 3 | import ContractRepository from 'infra/database/ContractRepository'; 4 | import Session from 'infra/database/Session'; 5 | import { handlerWrapper, queryFormatter } from './utils'; 6 | import ContractModel from '../models/Contract'; 7 | 8 | const router = express.Router(); 9 | 10 | router.get( 11 | '/count', 12 | handlerWrapper(async (req, res) => { 13 | const query = queryFormatter(req); 14 | 15 | Joi.assert( 16 | query, 17 | Joi.object().keys({ 18 | // contract table filters 19 | id: Joi.string().uuid(), 20 | agreement_id: Joi.string().uuid(), 21 | worker_id: Joi.string().uuid(), // grower_account_id? 22 | listed: Joi.boolean(), 23 | // organization_id: Joi.array(), 24 | startDate: Joi.string().regex(/^\d{4}-\d{2}-\d{2}$/), 25 | endDate: Joi.string().regex(/^\d{4}-\d{2}-\d{2}$/), 26 | // defaults 27 | limit: Joi.number().integer().min(1).max(20000), 28 | offset: Joi.number().integer().min(0), 29 | whereNulls: Joi.array(), 30 | whereNotNulls: Joi.array(), 31 | whereIns: Joi.array(), 32 | }), 33 | ); 34 | const { ...rest } = req.query; 35 | 36 | const repo = new ContractRepository(new Session()); 37 | const count = await ContractModel.getCount(repo)({ ...rest }); 38 | res.send({ 39 | count: Number(count), 40 | }); 41 | res.end(); 42 | }), 43 | ); 44 | 45 | router.get( 46 | '/:id', 47 | handlerWrapper(async (req, res) => { 48 | Joi.assert(req.params.id, Joi.string().required()); 49 | const repo = new ContractRepository(new Session()); 50 | const exe = ContractModel.getById(repo); 51 | const result = await exe(req.params.id); 52 | res.send(result); 53 | res.end(); 54 | }), 55 | ); 56 | 57 | router.get( 58 | '/', 59 | handlerWrapper(async (req, res) => { 60 | const query = queryFormatter(req); 61 | 62 | Joi.assert( 63 | query, 64 | Joi.object().keys({ 65 | // contract table filters 66 | id: Joi.string().uuid(), 67 | agreement_id: Joi.string().uuid(), 68 | worker_id: Joi.string().uuid(), // grower_account_id? 69 | listed: Joi.boolean(), 70 | // organization_id: Joi.array(), 71 | startDate: Joi.string().regex(/^\d{4}-\d{2}-\d{2}$/), 72 | endDate: Joi.string().regex(/^\d{4}-\d{2}-\d{2}$/), 73 | // defaults 74 | limit: Joi.number().integer().min(1).max(20000), 75 | offset: Joi.number().integer().min(0), 76 | whereNulls: Joi.array(), 77 | whereNotNulls: Joi.array(), 78 | whereIns: Joi.array(), 79 | }), 80 | ); 81 | const { 82 | limit = 25, 83 | offset = 0, 84 | order = 'desc', 85 | order_by = 'id', 86 | ...rest 87 | } = query; 88 | 89 | const repo = new ContractRepository(new Session()); 90 | const exe = ContractModel.getByFilter(repo); 91 | const sort = { order, order_by }; 92 | const result = await exe({ ...rest, sort }, { limit, offset }); 93 | const count = await ContractModel.getCount(repo)({ ...rest }); 94 | res.send({ 95 | contracts: result, 96 | total: Number(count), 97 | offset, 98 | limit, 99 | }); 100 | res.end(); 101 | }), 102 | ); 103 | 104 | export default router; 105 | -------------------------------------------------------------------------------- /server/infra/database/TokensRepository.ts: -------------------------------------------------------------------------------- 1 | import FilterOptions from 'interfaces/FilterOptions'; 2 | import Tokens from 'interfaces/Tokens'; 3 | import HttpError from 'utils/HttpError'; 4 | import BaseRepository from './BaseRepository'; 5 | import Session from './Session'; 6 | 7 | type Filter = { 8 | wallet: string; 9 | withPlanter?: boolean; 10 | withCapture?: boolean; 11 | }; 12 | export default class TokensRepository extends BaseRepository { 13 | constructor(session: Session) { 14 | super('wallet.token', session); 15 | } 16 | 17 | async getById(tokenId: string) { 18 | const sql = ` 19 | select wallet.token.*,public.trees.id as tree_id,public.trees.image_url as tree_image_url, 20 | public.tree_species.name as tree_species_name 21 | from wallet.token 22 | left join public.trees on 23 | wallet.token.capture_id::text = public.trees.uuid::text 24 | left join public.tree_species 25 | on public.trees.species_id = public.tree_species.id 26 | where wallet.token.id = '${tokenId}' 27 | `; 28 | 29 | const object = await this.session.getDB().raw(sql); 30 | 31 | if (object && object.rows.length > 0) { 32 | return object.rows[0]; 33 | } 34 | throw new HttpError( 35 | 404, 36 | `Can not found ${this.tableName} by id:${tokenId}`, 37 | ); 38 | 39 | } 40 | 41 | async getByFilter(filter: Filter, options: FilterOptions) { 42 | const { limit, offset } = options; 43 | const { withCapture, withPlanter } = filter; 44 | 45 | const wihtPlanterQueryPart1 = `planter.id as planter_id, 46 | planter.first_name as planter_first_name, 47 | planter.last_name as planter_last_name, 48 | planter.image_url as planter_photo_url 49 | `; 50 | 51 | const wihtPlanterQueryPart2 = `left join public.planter as planter on 52 | capture.planter_id = planter.id 53 | `; 54 | let wihtCaptureQueryPart1 = `capture.image_url as capture_photo_url`; 55 | 56 | const wihtCaptureQueryPart2 = `left join public.trees as capture on 57 | capture.uuid::text = wlt_tkn.capture_id::text 58 | `; 59 | 60 | let sql = ''; 61 | 62 | if (withCapture || withPlanter) { 63 | sql += `select wlt_tkn.*,`; 64 | } else { 65 | sql += `select wlt_tkn.*`; 66 | } 67 | 68 | if (withCapture && withPlanter) { 69 | wihtCaptureQueryPart1 += ','; 70 | } 71 | 72 | sql += `${withCapture ? wihtCaptureQueryPart1 : ''} 73 | ${withPlanter ? wihtPlanterQueryPart1 : ''} 74 | from wallet.token as wlt_tkn 75 | left join wallet.wallet as wlt_wallet on 76 | wlt_wallet.id = wlt_tkn.wallet_id 77 | ${withCapture || withPlanter ? wihtCaptureQueryPart2 : ''} 78 | ${withPlanter ? wihtPlanterQueryPart2 : ''} 79 | where wlt_wallet.id::text = '${filter.wallet}' or 80 | wlt_wallet.name = '${filter.wallet}' 81 | LIMIT ${limit} 82 | OFFSET ${offset} 83 | `; 84 | const object = await this.session.getDB().raw(sql); 85 | return object.rows; 86 | } 87 | 88 | async getCountByFilter(filter: Filter) { 89 | const sql = `SELECT 90 | COUNT(*) 91 | from wallet.token as wlt_tkn 92 | left join wallet.wallet as wlt_wallet on 93 | wlt_wallet.id = wlt_tkn.wallet_id 94 | where wlt_wallet.id::text = '${filter.wallet}' or 95 | wlt_wallet.name = '${filter.wallet}' 96 | `; 97 | const total = await this.session.getDB().raw(sql); 98 | return parseInt(total.rows[0].count.toString());; 99 | } 100 | 101 | } 102 | -------------------------------------------------------------------------------- /server/infra/database/SpeciesRepository.ts: -------------------------------------------------------------------------------- 1 | import FilterOptions from 'interfaces/FilterOptions'; 2 | import Species from 'interfaces/Species'; 3 | import BaseRepository from './BaseRepository'; 4 | import Session from './Session'; 5 | 6 | export default class SpeciesRepository extends BaseRepository { 7 | constructor(session: Session) { 8 | super('tree_species', session); 9 | } 10 | 11 | async getByOrganization(organization_id: number, options: FilterOptions) { 12 | const { limit, offset } = options; 13 | const sql = ` 14 | SELECT 15 | species_id as id, total, ts.name, ts.desc 16 | FROM 17 | ( 18 | SELECT 19 | ss.species_id, count(ss.species_id) as total 20 | from webmap.species_stat ss 21 | WHERE 22 | ss.planter_id IN ( 23 | SELECT 24 | id 25 | FROM planter p 26 | WHERE 27 | p.organization_id in ( SELECT entity_id from getEntityRelationshipChildren(${organization_id})) 28 | ) 29 | OR 30 | ss.planting_organization_id = ${organization_id} 31 | GROUP BY ss.species_id 32 | ) s_count 33 | JOIN tree_species ts 34 | ON ts.id = s_count.species_id 35 | ORDER BY total DESC 36 | LIMIT ${limit} 37 | OFFSET ${offset} 38 | `; 39 | const object = await this.session.getDB().raw(sql); 40 | return object.rows; 41 | } 42 | 43 | async countByOrganization(organization_id: number) { 44 | const totalSql = ` 45 | SELECT 46 | species_id as id, total, ts.name, ts.desc 47 | FROM 48 | ( 49 | SELECT 50 | ss.species_id, count(ss.species_id) as total 51 | from webmap.species_stat ss 52 | WHERE 53 | ss.planter_id IN ( 54 | SELECT 55 | id 56 | FROM planter p 57 | WHERE 58 | p.organization_id in ( SELECT entity_id from getEntityRelationshipChildren(${organization_id})) 59 | ) 60 | OR 61 | ss.planting_organization_id = ${organization_id} 62 | GROUP BY ss.species_id 63 | ) s_count 64 | JOIN tree_species ts 65 | ON ts.id = s_count.species_id 66 | ORDER BY total DESC 67 | 68 | `; 69 | const total = await this.session.getDB().raw(totalSql); 70 | return parseInt(total.rows.length); 71 | } 72 | 73 | async getByPlanter(planter_id: number, options: FilterOptions) { 74 | const { limit, offset } = options; 75 | const sql = ` 76 | SELECT 77 | species_id as id, total, ts.name, ts.desc 78 | FROM 79 | ( 80 | SELECT 81 | ss.species_id, count(ss.species_id) as total 82 | from webmap.species_stat ss 83 | WHERE 84 | ss.planter_id = ${planter_id} 85 | GROUP BY ss.species_id 86 | ) s_count 87 | JOIN tree_species ts 88 | ON ts.id = s_count.species_id 89 | ORDER BY total DESC 90 | LIMIT ${limit} 91 | OFFSET ${offset} 92 | `; 93 | const object = await this.session.getDB().raw(sql); 94 | return object.rows; 95 | } 96 | 97 | async getByWallet(wallet_id: string, options: FilterOptions) { 98 | const { limit, offset } = options; 99 | const sql = ` 100 | SELECT 101 | species_id as id, total, ts.name, ts.desc 102 | FROM 103 | ( 104 | SELECT 105 | ss.species_id, count(ss.species_id) as total 106 | from webmap.species_stat ss 107 | WHERE 108 | ss.wallet_id::text = '${wallet_id}' 109 | GROUP BY ss.species_id 110 | ) s_count 111 | JOIN tree_species ts 112 | ON ts.id = s_count.species_id 113 | ORDER BY total DESC 114 | LIMIT ${limit} 115 | OFFSET ${offset} 116 | `; 117 | const object = await this.session.getDB().raw(sql); 118 | return object.rows; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /server/routers/countriesRouter.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import expressLru from 'express-lru'; 3 | import Joi from 'joi'; 4 | import CountryRepositoryV2 from 'infra/database/CountryRepositoryV2'; 5 | import { handlerWrapper } from './utils'; 6 | import CountryRepository from '../infra/database/CountryRepository'; 7 | import Session from '../infra/database/Session'; 8 | import CountryModel from '../models/Country'; 9 | import CountryModelV2 from '../models/CountryV2'; 10 | 11 | const router = express.Router(); 12 | 13 | const cache = expressLru({ 14 | max: 10, 15 | ttl: 604800 * 1000, 16 | skip() { 17 | // skip conditions 18 | return process.env.CACHE_ENABLED !== 'true'; 19 | }, 20 | }); 21 | 22 | router.get( 23 | '/v2/leaderboard', 24 | handlerWrapper(async (req, res) => { 25 | const repo = new CountryRepositoryV2(new Session()); 26 | const exe = CountryModelV2.getLeaderBoard(repo); 27 | const result = await exe(req.params.id); 28 | res.send({ 29 | countries: result, 30 | }); 31 | res.end(); 32 | }), 33 | ); 34 | 35 | router.get( 36 | '/v2/:id', 37 | handlerWrapper(async (req, res) => { 38 | Joi.assert(req.params.id, Joi.number().required()); 39 | const repo = new CountryRepositoryV2(new Session()); 40 | const exe = CountryModelV2.getById(repo); 41 | const result = await exe(req.params.id); 42 | res.send(result); 43 | res.end(); 44 | }), 45 | ); 46 | 47 | router.get( 48 | '/v2/', 49 | handlerWrapper(async (req, res) => { 50 | Joi.assert( 51 | req.query, 52 | Joi.object().keys({ 53 | limit: Joi.number().integer().min(1).max(1000), 54 | offset: Joi.number().integer().min(0), 55 | lat: Joi.number(), 56 | lon: Joi.number(), 57 | }), 58 | ); 59 | const repo = new CountryRepositoryV2(new Session()); 60 | const result = await CountryModelV2.getByFilter(repo)(req.query); 61 | res.send({ 62 | countries: result, 63 | }); 64 | res.end(); 65 | }), 66 | ); 67 | 68 | router.get( 69 | '/leaderboard/:region', 70 | cache, 71 | handlerWrapper(async (req, res) => { 72 | Joi.assert(req.params.region, Joi.string().required()); 73 | const repo = new CountryRepository(new Session()); 74 | const exe = CountryModel.getLeaderBoard(repo); 75 | const result = await exe(req.params.region); 76 | res.send({ 77 | countries: result, 78 | }); 79 | res.end(); 80 | }), 81 | ); 82 | 83 | router.get( 84 | '/leaderboard', 85 | handlerWrapper(async (req, res) => { 86 | const repo = new CountryRepository(new Session()); 87 | const exe = CountryModel.getLeaderBoard(repo); 88 | const result = await exe(''); 89 | res.send({ 90 | countries: result, 91 | }); 92 | res.end(); 93 | }), 94 | ); 95 | 96 | router.get( 97 | '/:id', 98 | handlerWrapper(async (req, res) => { 99 | Joi.assert(req.params.id, Joi.number().required()); 100 | const repo = new CountryRepository(new Session()); 101 | const exe = CountryModel.getById(repo); 102 | const result = await exe(req.params.id); 103 | res.send(result); 104 | res.end(); 105 | }), 106 | ); 107 | 108 | router.get( 109 | '/', 110 | handlerWrapper(async (req, res) => { 111 | Joi.assert( 112 | req.query, 113 | Joi.object().keys({ 114 | limit: Joi.number().integer().min(1).max(1000), 115 | offset: Joi.number().integer().min(0), 116 | lat: Joi.number(), 117 | lon: Joi.number(), 118 | }), 119 | ); 120 | const repo = new CountryRepository(new Session()); 121 | const result = await CountryModel.getByFilter(repo)(req.query); 122 | res.send({ 123 | countries: result, 124 | }); 125 | res.end(); 126 | }), 127 | ); 128 | 129 | export default router; 130 | -------------------------------------------------------------------------------- /__tests__/e2e/transactions.spec.ts: -------------------------------------------------------------------------------- 1 | import supertest from 'supertest'; 2 | import app from '../../server/app'; 3 | 4 | describe('transactions', () => { 5 | it('get transactions', async () => { 6 | const response = await supertest(app).get('/transactions'); 7 | expect(response.status).toBe(200); 8 | expect(response.body).toMatchObject({ 9 | total: 2, 10 | limit: 20, 11 | offset: 0, 12 | transactions: expect.arrayContaining([ 13 | expect.objectContaining({ id: '1e36679e-eb5d-48ed-adf7-be61d11e709b' }), 14 | expect.objectContaining({ id: '3bdc013c-3bd8-466c-9604-8490981e80f4' }), 15 | ]), 16 | }); 17 | }); 18 | 19 | it('get transactions associated with wallet_id', async () => { 20 | const response = await supertest(app).get( 21 | '/transactions?wallet_id=ca851239-ed14-492e-9a19-0269c6405dfd', 22 | ); 23 | expect(response.status).toBe(200); 24 | expect(response.body).toMatchObject({ 25 | total: 2, 26 | limit: 20, 27 | offset: 0, 28 | transactions: expect.arrayContaining([ 29 | expect.objectContaining({ id: '3bdc013c-3bd8-466c-9604-8490981e80f4' }), 30 | expect.objectContaining({ id: '1e36679e-eb5d-48ed-adf7-be61d11e709b' }), 31 | ]), 32 | }); 33 | }); 34 | 35 | it('get transactions associated with token_id', async () => { 36 | const response = await supertest(app).get( 37 | '/transactions?token_id=69632058-5ef2-4b60-85e0-c0389e502904', 38 | ); 39 | expect(response.status).toBe(200); 40 | expect(response.body).toMatchObject({ 41 | total: 2, 42 | limit: 20, 43 | offset: 0, 44 | transactions: expect.arrayContaining([ 45 | expect.objectContaining({ id: '1e36679e-eb5d-48ed-adf7-be61d11e709b' }), 46 | expect.objectContaining({ id: '3bdc013c-3bd8-466c-9604-8490981e80f4' }), 47 | ]), 48 | }); 49 | }); 50 | 51 | it(`token_id supercedes wallet_id that doesn't exist if both are passed`, async () => { 52 | const response = await supertest(app).get( 53 | '/transactions?wallet_id=c56a4180-65aa-42ec-a945-5fd21dec0538&token_id=69632058-5ef2-4b60-85e0-c0389e502904', 54 | ); 55 | expect(response.status).toBe(200); 56 | expect(response.body).toMatchObject({ 57 | total: 2, 58 | limit: 20, 59 | offset: 0, 60 | transactions: expect.arrayContaining([ 61 | expect.objectContaining({ id: '3bdc013c-3bd8-466c-9604-8490981e80f4' }), 62 | expect.objectContaining({ id: '1e36679e-eb5d-48ed-adf7-be61d11e709b' }), 63 | ]), 64 | }); 65 | }); 66 | 67 | it('token_id not found', async () => { 68 | const response = await supertest(app).get( 69 | '/transactions?token_id=c56a4180-65aa-42ec-a945-5fd21dec0538', 70 | ); 71 | expect(response.status).toBe(200); 72 | expect(response.body).toMatchObject({ 73 | total: 0, 74 | limit: 20, 75 | offset: 0, 76 | transactions: [], 77 | }); 78 | }); 79 | 80 | it('wallet_id not found', async () => { 81 | const response = await supertest(app).get( 82 | '/transactions?wallet_id=c56a4180-65aa-42ec-a945-5fd21dec0538', 83 | ); 84 | expect(response.status).toBe(200); 85 | expect(response.body).toMatchObject({ 86 | total: 0, 87 | limit: 20, 88 | offset: 0, 89 | transactions: [], 90 | }); 91 | }); 92 | 93 | it('wallet_id exists, but token_id does not', async () => { 94 | const response = await supertest(app).get( 95 | '/transactions?token_id=c56a4180-65aa-42ec-a945-5fd21dec0538&wallet_id=5c30d6f9-c6a5-4451-bc47-51f1b25e44ef', 96 | ); 97 | expect(response.status).toBe(200); 98 | expect(response.body).toMatchObject({ 99 | total: 0, 100 | limit: 20, 101 | offset: 0, 102 | transactions: [], 103 | }); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /server/models/GrowerAccount.ts: -------------------------------------------------------------------------------- 1 | import log from 'loglevel'; 2 | import FilterOptions from 'interfaces/FilterOptions'; 3 | import GrowerAccount from 'interfaces/GrowerAccount'; 4 | import { delegateRepository } from '../infra/database/delegateRepository'; 5 | import GrowerAccountRepository from '../infra/database/GrowerAccountRepository'; 6 | import GrowerAccountFilter from '../interfaces/GrowerAccountFilter'; 7 | 8 | function getCount( 9 | growerAccountRepository: GrowerAccountRepository, 10 | ): (filter: GrowerAccountFilter) => Promise<{ count: number }> { 11 | return async function (filter: GrowerAccountFilter) { 12 | const count = await growerAccountRepository.getCount(filter); 13 | return count; 14 | }; 15 | } 16 | 17 | function getByFilter( 18 | growerAccountRepository: GrowerAccountRepository, 19 | ): ( 20 | filter: GrowerAccountFilter, 21 | options: FilterOptions, 22 | ) => Promise { 23 | return async function (filter: GrowerAccountFilter, options: FilterOptions) { 24 | const result = await growerAccountRepository.getByFilter(filter, options); 25 | return result; 26 | }; 27 | } 28 | 29 | function getSelfiesById( 30 | growerAccountRepository: GrowerAccountRepository, 31 | ): (id: string) => Promise { 32 | return async function (id: string) { 33 | log.warn('using planter name filter...'); 34 | const grower_accounts = await growerAccountRepository.getSelfiesById(id); 35 | return grower_accounts; 36 | }; 37 | } 38 | 39 | function getByName( 40 | growerAccountRepository: GrowerAccountRepository, 41 | ): (keyword: string, options: FilterOptions) => Promise { 42 | return async function (keyword: string, options: FilterOptions) { 43 | log.warn('using planter name filter...'); 44 | const grower_accounts = await growerAccountRepository.getByName( 45 | keyword, 46 | options, 47 | ); 48 | return grower_accounts; 49 | }; 50 | } 51 | 52 | function getGrowerAccountLinks(planter) { 53 | const links = { 54 | featured_trees: `/trees?planter_id=${planter.id}&limit=20&offset=0`, 55 | associated_organizations: `/organizations?planter_id=${planter.id}&limit=20&offset=0`, 56 | species: `/species?planter_id=${planter.id}&limit=20&offset=0`, 57 | }; 58 | return links; 59 | } 60 | 61 | function getFeaturedGrowers( 62 | growerAccountRepository: GrowerAccountRepository, 63 | ): (options: FilterOptions) => Promise { 64 | return async function (options: FilterOptions) { 65 | log.warn('using featured planters filter...'); 66 | const grower_accounts = 67 | await growerAccountRepository.getFeaturedGrowerAccounts(options); 68 | return grower_accounts; 69 | }; 70 | } 71 | 72 | function getWalletsCount( 73 | growerAccountRepository: GrowerAccountRepository, 74 | ): (filter: GrowerAccountFilter) => Promise<{ count: number }> { 75 | return async (filter: GrowerAccountFilter) => { 76 | const count = await growerAccountRepository.getWalletsCount(filter); 77 | return count; 78 | }; 79 | } 80 | 81 | function getWalletsByFilter( 82 | growerAccountRepository: GrowerAccountRepository, 83 | ): ( 84 | filter: GrowerAccountFilter, 85 | options: FilterOptions, 86 | ) => Promise { 87 | return async (filter: GrowerAccountFilter, options: FilterOptions) => { 88 | const result = await growerAccountRepository.getWalletsByFilter( 89 | filter, 90 | options, 91 | ); 92 | return result.map((obj) => obj.wallet); 93 | }; 94 | } 95 | 96 | export default { 97 | getById: delegateRepository( 98 | 'getById', 99 | ), 100 | getSelfiesById, 101 | getCount, 102 | getByFilter, 103 | getByName, 104 | getGrowerAccountLinks, 105 | getFeaturedGrowers, 106 | getWalletsCount, 107 | getWalletsByFilter, 108 | }; 109 | -------------------------------------------------------------------------------- /server/infra/database/BaseRepository.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | /* eslint-disable no-restricted-syntax */ 3 | import { Knex } from 'knex'; 4 | import HttpError from 'utils/HttpError'; 5 | import Session from './Session'; 6 | 7 | type FilterOptions = { 8 | limit?: number; 9 | orderBy?: { column: keyof T; direction?: 'asc' | 'desc' }; 10 | offset?: number; 11 | }; 12 | 13 | export default class BaseRepository { 14 | tableName: string; 15 | 16 | session: Session; 17 | 18 | constructor(tableName: string, session: Session) { 19 | this.tableName = tableName; 20 | this.session = session; 21 | } 22 | 23 | async getById(id: string | number): Promise { 24 | const object = await this.session 25 | .getDB() 26 | .select() 27 | .table(this.tableName) 28 | .where('id', id) 29 | .first(); 30 | if (!object) { 31 | throw new HttpError(404, `Can not found ${this.tableName} by id:${id}`); 32 | } 33 | return object; 34 | } 35 | 36 | /* 37 | * select by filter 38 | * support: and / or 39 | * options: 40 | * limit: number 41 | */ 42 | 43 | async getByFilter( 44 | filter: unknown, 45 | options: Partial>, 46 | ): Promise { 47 | const whereBuilder = function (object: any, builder: Knex.QueryBuilder) { 48 | let result = builder; 49 | if (object.and) { 50 | for (const one of object.and) { 51 | if (one.or) { 52 | result = result.andWhere((subBuilder) => 53 | whereBuilder(one, subBuilder), 54 | ); 55 | } else { 56 | result = result.andWhere( 57 | Object.keys(one)[0], 58 | Object.values(one)[0] as any, 59 | ); 60 | } 61 | } 62 | } else if (object.or) { 63 | for (const one of object.or) { 64 | if (one.and) { 65 | result = result.orWhere((subBuilder) => 66 | whereBuilder(one, subBuilder), 67 | ); 68 | } else { 69 | result = result.orWhere( 70 | Object.keys(one)[0], 71 | Object.values(one)[0] as any, 72 | ); 73 | } 74 | } 75 | } else { 76 | result.where(object); 77 | } 78 | return result; 79 | }; 80 | 81 | let promise = this.session 82 | .getDB() 83 | .select() 84 | .table(this.tableName) 85 | .where((builder) => whereBuilder(filter, builder)); 86 | if (options && options.limit) { 87 | promise = promise.limit(options && options.limit); 88 | } 89 | if (options && options.orderBy) { 90 | const direction = 91 | options.orderBy.direction !== undefined 92 | ? options.orderBy.direction 93 | : 'asc'; 94 | promise = promise.orderBy(options.orderBy.column as string, direction); 95 | } 96 | if (options && options.offset) { 97 | promise = promise.offset(options && options.offset); 98 | } 99 | const result = await promise; 100 | return result as T[]; 101 | } 102 | 103 | async countByFilter(filter) { 104 | const result = await this.session 105 | .getDB() 106 | .count() 107 | .table(this.tableName) 108 | .where(filter); 109 | return parseInt(result[0].count.toString()); 110 | } 111 | 112 | async update(object: T & { id: string | number }) { 113 | const result = await this.session 114 | .getDB()(this.tableName) 115 | .update(object) 116 | .where('id', object.id) 117 | .returning('*'); 118 | return result[0]; 119 | } 120 | 121 | async create(object: T) { 122 | const result = await this.session 123 | .getDB()(this.tableName) 124 | .insert(object) 125 | .returning('*'); 126 | return result[0]; 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /server/infra/database/OrganizationRepository.ts: -------------------------------------------------------------------------------- 1 | import FilterOptions from 'interfaces/FilterOptions'; 2 | import Organization from 'interfaces/Organization'; 3 | import HttpError from 'utils/HttpError'; 4 | import BaseRepository from './BaseRepository'; 5 | import patch, { PATCH_TYPE } from './patch'; 6 | import Session from './Session'; 7 | 8 | export default class OrganizationRepository extends BaseRepository { 9 | constructor(session: Session) { 10 | super('entity', session); 11 | } 12 | 13 | async getByPlanter(planter_id: number, options: FilterOptions) { 14 | const { limit, offset } = options; 15 | const sql = ` 16 | SELECT 17 | entity.*, 18 | l.country_id, l.country_name, l.continent_id, l.continent_name 19 | FROM entity 20 | LEFT JOIN webmap.organization_location l ON l.id = entity.id 21 | LEFT JOIN planter ON planter.organization_id = entity.id 22 | WHERE planter.id = ${planter_id} 23 | LIMIT ${limit} 24 | OFFSET ${offset} 25 | `; 26 | const object = await this.session.getDB().raw(sql); 27 | const objectPatched = await patch( 28 | object.rows, 29 | PATCH_TYPE.EXTRA_ORG, 30 | this.session, 31 | ); 32 | return objectPatched; 33 | } 34 | 35 | async getById(id: string | number) { 36 | const object = await this.session 37 | .getDB() 38 | .select( 39 | this.session.getDB().raw(` 40 | entity.*, 41 | l.country_id, l.country_name, l.continent_id, l.continent_name 42 | from entity 43 | left join webmap.organization_location l on l.id = entity.id 44 | `), 45 | ) 46 | .where('entity.id', id) 47 | .first(); 48 | 49 | if (!object) { 50 | throw new HttpError(404, `Can not find ${this.tableName} by id:${id}`); 51 | } 52 | const objectPatched = await patch( 53 | object, 54 | PATCH_TYPE.EXTRA_ORG, 55 | this.session, 56 | ); 57 | return objectPatched; 58 | } 59 | 60 | async getByMapName(mapName: string) { 61 | const object = await this.session 62 | .getDB() 63 | .select( 64 | this.session.getDB().raw(` 65 | entity.*, 66 | l.country_id, l.country_name, l.continent_id, l.continent_name 67 | from entity 68 | LEFT JOIN webmap.organization_location l ON l.id = entity.id 69 | `), 70 | ) 71 | .where('entity.map_name', mapName) 72 | .first(); 73 | 74 | if (!object) { 75 | throw new HttpError( 76 | 404, 77 | `Can not find ${this.tableName} by map name:${mapName}`, 78 | ); 79 | } 80 | const objectPatched = await patch( 81 | object, 82 | PATCH_TYPE.EXTRA_ORG, 83 | this.session, 84 | ); 85 | return objectPatched; 86 | } 87 | 88 | async getFeaturedOrganizations() { 89 | const sql = ` 90 | select 91 | entity.*, 92 | l.country_id, l.country_name, l.continent_id, l.continent_name 93 | from entity 94 | LEFT JOIN webmap.organization_location l ON l.id = entity.id 95 | join ( 96 | --- convert json array to row 97 | SELECT json_array_elements(data -> 'organizations') AS organization_id FROM webmap.config WHERE name = 'featured-organization' 98 | ) AS t ON 99 | t.organization_id::text::integer = entity.id; 100 | `; 101 | const object = await this.session.getDB().raw(sql); 102 | 103 | const objectPatched = await patch( 104 | object.rows, 105 | PATCH_TYPE.EXTRA_ORG, 106 | this.session, 107 | ); 108 | return objectPatched; 109 | } 110 | 111 | async getByIds(ids: Array, options: FilterOptions) { 112 | const { limit, offset } = options; 113 | const sql = ` 114 | SELECT 115 | * 116 | FROM entity 117 | WHERE id IN (${ids}) 118 | LIMIT ${limit} 119 | OFFSET ${offset} 120 | `; 121 | const object = await this.session.getDB().raw(sql); 122 | return object.rows; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /server/models/TreeV2.ts: -------------------------------------------------------------------------------- 1 | import log from 'loglevel'; 2 | import FilterOptions from 'interfaces/FilterOptions'; 3 | import Tree from 'interfaces/Tree'; 4 | import { delegateRepository } from '../infra/database/delegateRepository'; 5 | import TreeRepositoryV2 from '../infra/database/TreeRepositoryV2'; 6 | 7 | type Filter = Partial<{ 8 | organization_id: number; 9 | date_range: { startDate: string; endDate: string }; 10 | tag: string; 11 | wallet_id: string; 12 | }>; 13 | 14 | function getByFilter( 15 | treeRepository: TreeRepositoryV2, 16 | ): (filter: Filter, options: FilterOptions) => Promise { 17 | return async function (filter: Filter, options: FilterOptions) { 18 | if (filter.organization_id) { 19 | log.warn('using org filter...'); 20 | const trees = await treeRepository.getByOrganization( 21 | filter.organization_id, 22 | options, 23 | ); 24 | return trees; 25 | } 26 | if (filter.date_range) { 27 | log.warn('using date range filter...'); 28 | const trees = await treeRepository.getByDateRange( 29 | filter.date_range, 30 | options, 31 | ); 32 | return trees; 33 | } 34 | if (filter.tag) { 35 | log.warn('using tag filter...'); 36 | const trees = await treeRepository.getByTag(filter.tag, options); 37 | return trees; 38 | } 39 | if (filter.wallet_id) { 40 | log.warn('using wallet filter...'); 41 | const trees = await treeRepository.getByWallet(filter.wallet_id, options); 42 | return trees; 43 | } 44 | 45 | const trees = await treeRepository.getByFilter(filter, options); 46 | return trees; 47 | }; 48 | } 49 | 50 | function countByFilter( 51 | treeRepository: TreeRepositoryV2, 52 | ): (filter: Filter, options: FilterOptions) => Promise { 53 | return async function (filter: Filter, options: FilterOptions) { 54 | if (filter.organization_id) { 55 | log.warn('using org filter...'); 56 | const total = await treeRepository.getByOrganization( 57 | filter.organization_id, 58 | options, 59 | true, 60 | ); 61 | return total; 62 | } 63 | if (filter.date_range) { 64 | log.warn('using date range filter...'); 65 | const total = await treeRepository.getByDateRange( 66 | filter.date_range, 67 | options, 68 | true, 69 | ); 70 | return total; 71 | } 72 | if (filter.tag) { 73 | log.warn('using tag filter...'); 74 | const total = await treeRepository.getByTag(filter.tag, options, true); 75 | return total; 76 | } 77 | if (filter.wallet_id) { 78 | log.warn('using wallet filter...'); 79 | const total = await treeRepository.getByWallet( 80 | filter.wallet_id, 81 | options, 82 | true, 83 | ); 84 | return total; 85 | } 86 | 87 | const total = await treeRepository.countByFilter(filter); 88 | return total; 89 | }; 90 | } 91 | 92 | /* 93 | featured tree, some highlighted tree, for a tempororily solution 94 | we just put the newest, verified tree 95 | */ 96 | function getFeaturedTreeDepricated(treeRepository: TreeRepositoryV2) { 97 | return async () => { 98 | // const trees = await treeRepository.getByFilter( 99 | // { 100 | // approved: true, 101 | // }, 102 | // { limit: 10, orderBy: { column: 'time_created', direction: 'desc' } }, 103 | // ); 104 | // 105 | const trees: Array = []; 106 | // eslint-disable-next-line no-restricted-syntax 107 | for (const id of [186737, 186735, 186736, 186734]) { 108 | // eslint-disable-next-line no-await-in-loop 109 | const tree = await treeRepository.getById(id); 110 | trees.push(tree); 111 | } 112 | return trees; 113 | }; 114 | } 115 | 116 | export default { 117 | getById: delegateRepository('getById'), 118 | getByFilter, 119 | getFeaturedTree: delegateRepository( 120 | 'getFeaturedTree', 121 | ), 122 | countByFilter, 123 | }; 124 | -------------------------------------------------------------------------------- /server/routers/treesRouter.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import Joi from 'joi'; 3 | import FilterOptions from 'interfaces/FilterOptions'; 4 | import { handlerWrapper } from './utils'; 5 | import Session from '../infra/database/Session'; 6 | import TreeRepository from '../infra/database/TreeRepository'; 7 | import TreeModel from '../models/Tree'; 8 | import HttpError from '../utils/HttpError'; 9 | 10 | const router = express.Router(); 11 | 12 | type GeoJson = Partial<{ 13 | geometry: { 14 | coordinates: number[]; 15 | }; 16 | }>; 17 | 18 | type Filter = Partial<{ 19 | planter_id: number; 20 | organization_id: number; 21 | date_range: { startDate: string; endDate: string }; 22 | tag: string; 23 | wallet_id: string; 24 | active: true; 25 | geoJsonArr: GeoJson[]; 26 | }>; 27 | 28 | router.get( 29 | '/featured', 30 | handlerWrapper(async (req, res) => { 31 | const repo = new TreeRepository(new Session()); 32 | const exe = TreeModel.getFeaturedTree(repo); 33 | const result = await exe(); 34 | res.send({ 35 | trees: result, 36 | }); 37 | res.end(); 38 | }), 39 | ); 40 | 41 | router.get( 42 | '/:val', 43 | handlerWrapper(async (req, res) => { 44 | const repo = new TreeRepository(new Session()); 45 | let result; 46 | if (isNaN(Number(req.params.val))) { 47 | Joi.assert(req.params.val, Joi.string().guid().required()); 48 | const exe = TreeModel.getByUUID(repo); 49 | result = await exe(req.params.val); 50 | } else { 51 | Joi.assert(req.params.val, Joi.number().required()); 52 | const exe = TreeModel.getById(repo); 53 | result = await exe(req.params.val); 54 | } 55 | 56 | if (result.active === false) { 57 | throw new HttpError(404, `Can not find trees by id:${result.id}`); 58 | } 59 | 60 | res.send(result); 61 | res.end(); 62 | }), 63 | ); 64 | 65 | router.get( 66 | '/', 67 | handlerWrapper(async (req, res) => { 68 | Joi.assert( 69 | req.query, 70 | Joi.object().keys({ 71 | planter_id: Joi.number().integer().min(0), 72 | organization_id: Joi.number().integer().min(0), 73 | wallet_id: Joi.string().uuid(), 74 | tag: Joi.string(), 75 | limit: Joi.number().integer().min(1).max(1000), 76 | order_by: Joi.string(), 77 | desc: Joi.boolean(), 78 | offset: Joi.number().integer().min(0), 79 | startDate: Joi.string().regex(/^\d{4}-\d{2}-\d{2}$/), 80 | endDate: Joi.string().regex(/^\d{4}-\d{2}-\d{2}$/), 81 | geoJsonStr: Joi.string(), 82 | }), 83 | ); 84 | const { 85 | limit = 20, 86 | offset = 0, 87 | order_by, 88 | desc = true, 89 | planter_id, 90 | organization_id, 91 | startDate, 92 | endDate, 93 | tag, 94 | wallet_id, 95 | geoJsonStr, 96 | } = req.query; 97 | const repo = new TreeRepository(new Session()); 98 | const filter: Filter = { active: true }; 99 | const options: FilterOptions = { 100 | limit, 101 | offset, 102 | orderBy: { 103 | column: order_by || 'time_created', 104 | direction: desc === true ? 'desc' : 'asc', 105 | }, 106 | }; 107 | 108 | if (planter_id) { 109 | filter.planter_id = planter_id; 110 | } else if (organization_id) { 111 | filter.organization_id = organization_id; 112 | } else if (startDate && endDate) { 113 | filter.date_range = { startDate, endDate }; 114 | } else if (tag) { 115 | filter.tag = tag; 116 | } else if (wallet_id) { 117 | filter.wallet_id = wallet_id; 118 | } else if (geoJsonStr) { 119 | filter.geoJsonArr = JSON.parse(decodeURIComponent(geoJsonStr)); 120 | } 121 | 122 | const result = await TreeModel.getByFilter(repo)(filter, options); 123 | res.send({ 124 | total: await TreeModel.countByFilter(repo)(filter, options), 125 | offset, 126 | limit, 127 | trees: result, 128 | }); 129 | res.end(); 130 | }), 131 | ); 132 | 133 | export default router; 134 | -------------------------------------------------------------------------------- /.github/workflows/treetracker-query-api-build-deploy-dev.yml: -------------------------------------------------------------------------------- 1 | name: Service CI/CD Pipeline to Release and Deploy to Dev Env 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | env: 9 | project-directory: ./ 10 | 11 | jobs: 12 | release: 13 | name: Release 14 | runs-on: ubuntu-latest 15 | if: | 16 | !contains(github.event.head_commit.message, 'skip-ci') && 17 | github.event_name == 'push' && 18 | github.repository == "Greenstand/${{ github.event.repository.name }}" 19 | steps: 20 | - uses: actions/checkout@v2 21 | with: 22 | ref: ${{ github.event.inputs.git-tag }} 23 | - name: Use Node.js 20.8.1 #semantic-release 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: '20.8.1' 27 | 28 | - name: npm clean install 29 | run: npm ci 30 | working-directory: ${{ env.project-directory }} 31 | 32 | - run: npm i -g semantic-release @semantic-release/{git,exec,changelog} 33 | 34 | - run: semantic-release 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | 38 | - run: npm run build 39 | working-directory: ${{ env.project-directory }} 40 | 41 | - run: npm prune --production 42 | working-directory: ${{ env.project-directory }} 43 | 44 | - name: get-npm-version 45 | id: package-version 46 | uses: martinbeentjes/npm-get-version-action@master 47 | 48 | - name: Set up QEMU 49 | uses: docker/setup-qemu-action@v1 50 | 51 | - name: Set up Docker Buildx 52 | uses: docker/setup-buildx-action@v1 53 | 54 | - name: Login to DockerHub 55 | uses: docker/login-action@v1 56 | with: 57 | username: ${{ secrets.DOCKERHUB_USERNAME }} 58 | password: ${{ secrets.DOCKERHUB_TOKEN }} 59 | 60 | - name: Build snapshot and push on merge 61 | id: docker_build_release 62 | uses: docker/build-push-action@v2 63 | with: 64 | context: ./ 65 | file: ./Dockerfile 66 | push: true 67 | tags: greenstand/${{ github.event.repository.name }}:${{ steps.package-version.outputs.current-version }} 68 | 69 | - id: export_bumped_version 70 | run: | 71 | export BUMPED_VERSION="${{ steps.package-version.outputs.current-version }}" 72 | echo "::set-output name=bumped_version::${BUMPED_VERSION}" 73 | 74 | outputs: 75 | bumped_version: ${{ steps.export_bumped_version.outputs.bumped_version }} 76 | 77 | deploy: 78 | name: Deploy to dev env 79 | runs-on: ubuntu-latest 80 | needs: release 81 | if: | 82 | !contains(github.event.head_commit.message, 'skip-ci') && 83 | github.event_name == 'push' && 84 | github.repository == "Greenstand/${{ github.event.repository.name }}" 85 | steps: 86 | - uses: actions/checkout@v2 87 | - name: Install kustomize 88 | run: curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash 89 | - name: Run kustomize 90 | run: (cd deployment/base && ../../kustomize edit set image greenstand/${{ github.event.repository.name }}:${{ needs.release.outputs.bumped_version }} ) 91 | - name: Install doctl for kubernetes 92 | uses: digitalocean/action-doctl@v2 93 | with: 94 | token: ${{ secrets.DEV_DIGITALOCEAN_TOKEN }} 95 | - name: Save DigitalOcean kubeconfig 96 | run: doctl kubernetes cluster kubeconfig save ${{ secrets.DEV_CLUSTER_NAME }} 97 | #- name: Delete completed migration jobs prior to deployment 98 | # run: kubectl -n ${{ secrets.K8S_NAMESPACE }} delete job --ignore-not-found=true database-migration-job 99 | - name: Update kubernetes resources 100 | run: kustomize build deployment/overlays/development | kubectl apply -n ${{ secrets.K8S_NAMESPACE }} --wait -f - 101 | #- name: Attempt to wait for migration job to complete 102 | # run: kubectl wait -n ${{ secrets.K8S_NAMESPACE }} --for=condition=complete --timeout=45s job/database-migration-job 103 | -------------------------------------------------------------------------------- /server/routers/capturesRouter.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import Joi from 'joi'; 3 | import CaptureRepository from 'infra/database/CaptureRepository'; 4 | import Session from 'infra/database/Session'; 5 | import { handlerWrapper, queryFormatter } from './utils'; 6 | import CaptureModel from '../models/Capture'; 7 | 8 | const router = express.Router(); 9 | 10 | router.get( 11 | '/count', 12 | handlerWrapper(async (req, res) => { 13 | const query = queryFormatter(req); 14 | 15 | // verify filter values 16 | Joi.assert( 17 | query, 18 | Joi.object().keys({ 19 | grower_account_id: Joi.string().uuid(), 20 | grower_reference_id: Joi.number(), 21 | organization_id: Joi.array().items(Joi.string().uuid()), 22 | session_id: Joi.string().uuid(), 23 | limit: Joi.number().integer().min(1).max(20000), 24 | offset: Joi.number().integer().min(0), 25 | startDate: Joi.string().regex(/^\d{4}-\d{2}-\d{2}$/), 26 | endDate: Joi.string().regex(/^\d{4}-\d{2}-\d{2}$/), 27 | id: Joi.string().uuid(), 28 | reference_id: Joi.string(), 29 | tree_id: Joi.string().uuid(), 30 | species_id: Joi.string().uuid(), 31 | tag_id: Joi.string().uuid(), 32 | device_identifier: Joi.string(), 33 | wallet: Joi.string(), 34 | tokenized: Joi.string(), 35 | order_by: Joi.string(), 36 | order: Joi.string(), 37 | token_id: Joi.string().uuid(), 38 | whereNulls: Joi.array(), 39 | whereNotNulls: Joi.array(), 40 | whereIns: Joi.array(), 41 | }), 42 | ); 43 | const { ...rest } = req.query; 44 | 45 | const repo = new CaptureRepository(new Session()); 46 | const count = await CaptureModel.getCount(repo)({ ...rest }); 47 | res.send({ 48 | count: Number(count), 49 | }); 50 | res.end(); 51 | }), 52 | ); 53 | 54 | router.get( 55 | '/:id', 56 | handlerWrapper(async (req, res) => { 57 | Joi.assert(req.params.id, Joi.string().required()); 58 | const repo = new CaptureRepository(new Session()); 59 | const exe = CaptureModel.getById(repo); 60 | const result = await exe(req.params.id); 61 | res.send(result); 62 | res.end(); 63 | }), 64 | ); 65 | 66 | router.get( 67 | '/', 68 | handlerWrapper(async (req, res) => { 69 | const query = queryFormatter(req); 70 | 71 | // verify filter values 72 | Joi.assert( 73 | query, 74 | Joi.object().keys({ 75 | grower_account_id: Joi.string().uuid(), 76 | grower_reference_id: Joi.number(), 77 | organization_id: Joi.array().items(Joi.string().uuid()), 78 | session_id: Joi.string().uuid(), 79 | limit: Joi.number().integer().min(1).max(20000), 80 | offset: Joi.number().integer().min(0), 81 | startDate: Joi.string().regex(/^\d{4}-\d{2}-\d{2}$/), 82 | endDate: Joi.string().regex(/^\d{4}-\d{2}-\d{2}$/), 83 | id: Joi.string().uuid(), 84 | reference_id: Joi.string(), 85 | tree_id: Joi.string().uuid(), 86 | species_id: Joi.string().uuid(), 87 | tag_id: Joi.string().uuid(), 88 | device_identifier: Joi.string(), 89 | wallet: Joi.string(), 90 | tokenized: Joi.string(), 91 | order_by: Joi.string(), 92 | order: Joi.string(), 93 | token_id: Joi.string().uuid(), 94 | whereNulls: Joi.array(), 95 | whereNotNulls: Joi.array(), 96 | whereIns: Joi.array(), 97 | }), 98 | ); 99 | const { 100 | limit = 25, 101 | offset = 0, 102 | order = 'desc', 103 | order_by = 'captured_at', 104 | ...rest 105 | } = query; 106 | 107 | const repo = new CaptureRepository(new Session()); 108 | const exe = CaptureModel.getByFilter(repo); 109 | const sort = { order, order_by }; 110 | const result = await exe({ ...rest, sort }, { limit, offset }); 111 | const count = await CaptureModel.getCount(repo)({ ...rest }); 112 | res.send({ 113 | captures: result, 114 | total: Number(count), 115 | offset, 116 | limit, 117 | }); 118 | res.end(); 119 | }), 120 | ); 121 | 122 | export default router; 123 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "treetracker-query-api", 3 | "version": "1.75.0", 4 | "private": false, 5 | "keywords": [ 6 | "ecology" 7 | ], 8 | "homepage": "https://github.com/Greenstand/treetracker-query-api#readme", 9 | "bugs": { 10 | "url": "https://github.com/Greenstand/treetracker-query-api/issues" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/Greenstand/treetracker-query-api" 15 | }, 16 | "license": "GPL-3.0-or-later", 17 | "author": "Greenstand Engineers", 18 | "main": "server/server.js", 19 | "directories": { 20 | "doc": "docs" 21 | }, 22 | "scripts": { 23 | "build": "tsc -p tsconfig.build.json", 24 | "db-migrate-ci": "cd database; db-migrate up", 25 | "dev": "nodemon --watch 'server/**' --ext 'ts,json' --ignore 'server/**/*.spec.ts' --exec \"npm run start:dev\"", 26 | "format": "prettier ./ --write", 27 | "lint": "eslint . --cache", 28 | "lint:fix": "eslint . --cache --fix", 29 | "mock-server": "prism mock ./docs/api/spec/query-api.yaml", 30 | "prepare": "husky install", 31 | "server-test": "DEBUG=express:* NODE_LOG_LEVEL=debug nodemon server/serverTest.js", 32 | "start": "NODE_TLS_REJECT_UNAUTHORIZED='0' NODE_PATH=dist/ node dist/server.js", 33 | "start:dev": "NODE_PATH=server/ ts-node -r dotenv/config --project tsconfig.build.json server/server.ts", 34 | "test": "npm run test-unit; npm run test-integration;npm run test-repository", 35 | "test-e2e": "jest ./__tests__/e2e/", 36 | "test-integration": "NODE_ENV=test mocha -r dotenv/config dotenv_config_path=.env.test --exit --timeout 20000 './__tests__/supertest.js'", 37 | "test-repository": "jest ./server/infra/database/", 38 | "test-seedDB": "NODE_ENV=test mocha -r dotenv/config dotenv_config_path=.env.test --timeout 10000 './**/*.spec.js'", 39 | "test-unit": "jest ./server/models/", 40 | "test-watch": "NODE_ENV=test NODE_LOG_LEVEL=info mocha -r dotenv/config dotenv_config_path=.env.test --timeout 10000 -w -b --ignore './server/repositories/**/*.spec.js' './server/setup.js' './server/**/*.spec.js' './__tests__/seed.spec.js' './__tests__/supertest.js'", 41 | "test-watch-debug": "NODE_ENV=test NODE_LOG_LEVEL=debug mocha -r dotenv/config dotenv_config_path=.env.test --timeout 10000 -w -b --ignore './server/repositories/**/*.spec.js' './server/setup.js' './server/**/*.spec.js' './__tests__/seed.spec.js' './__tests__/supertest.js'" 42 | }, 43 | "dependencies": { 44 | "@sentry/node": "^5.1.0", 45 | "body-parser": "^1.18.2", 46 | "cors": "^2.8.5", 47 | "dotenv": "^10.0.0", 48 | "expect-runtime": "^0.7.0", 49 | "express": "^4.16.2", 50 | "express-async-handler": "^1.1.4", 51 | "express-lru": "^1.0.0", 52 | "express-validator": "^6.4.0", 53 | "joi": "^17.5.0", 54 | "knex": "^0.95.14", 55 | "loglevel": "^1.6.8", 56 | "pg": "^8.7.1", 57 | "rascal": "^14.4.0", 58 | "response-time": "^2.3.2", 59 | "uuid": "^8.2.0" 60 | }, 61 | "devDependencies": { 62 | "@commitlint/cli": "^11.0.0", 63 | "@commitlint/config-conventional": "^11.0.0", 64 | "@swc/core": "^1.2.133", 65 | "@swc/jest": "^0.2.17", 66 | "@types/express": "^4.17.13", 67 | "@types/jest": "^27.0.3", 68 | "@types/mock-knex": "^0.4.3", 69 | "@types/node": "^16.11.6", 70 | "@types/rascal": "^10.0.4", 71 | "@types/supertest": "^2.0.11", 72 | "@types/uuid": "^8.3.1", 73 | "@typescript-eslint/eslint-plugin": "^5.10.0", 74 | "@typescript-eslint/parser": "^5.10.0", 75 | "db-migrate": "^0.11.12", 76 | "db-migrate-pg": "^1.2.2", 77 | "eslint": "^8.7.0", 78 | "eslint-config-airbnb-base": "^15.0.0", 79 | "eslint-config-airbnb-typescript": "^16.1.0", 80 | "eslint-config-prettier": "^8.3.0", 81 | "eslint-plugin-import": "^2.25.4", 82 | "husky": "^7.0.4", 83 | "is-ci": "^3.0.1", 84 | "jest": "^27.4.7", 85 | "lint-staged": "^11.2.6", 86 | "mock-knex": "^0.4.11", 87 | "node-cipher": "^5.0.1", 88 | "nodemon": "^2.0.14", 89 | "prettier": "^2.5.1", 90 | "prettier-plugin-packagejson": "^2.2.15", 91 | "supertest": "^4.0.2", 92 | "ts-node": "^10.4.0", 93 | "typescript": "^4.4.4" 94 | }, 95 | "engines": { 96 | "node": ">=16", 97 | "npm": ">=6.0.0" 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /__tests__/e2e/tokens.spec.ts: -------------------------------------------------------------------------------- 1 | import supertest from 'supertest'; 2 | import app from '../../server/app'; 3 | 4 | describe('Tokens', () => { 5 | it('/tokens/{tokenId}', async () => { 6 | const response = await supertest(app).get( 7 | '/tokens/125ce23f-e0e5-4b2b-84e8-6340cd158afd', 8 | ); 9 | 10 | expect(response.status).toBe(200); 11 | expect(response.body).toMatchObject({ 12 | id: '125ce23f-e0e5-4b2b-84e8-6340cd158afd', 13 | capture_id: '6b0de333-c61d-4094-adfb-d34a0ad15cf6', 14 | wallet_id: 'eecdf253-05b6-419a-8425-416a3e5fc9a0', 15 | transfer_pending: false, 16 | transfer_pending_id: null, 17 | claim: false, 18 | tree_id: 951896, 19 | tree_species_name: 'apple', 20 | }); 21 | }); 22 | 23 | it('/token/{tokenId} should return 404 for invalid id', async () => { 24 | const response = await supertest(app).get( 25 | '/tokens/b1e2d4f9-1c02-444f-9ca9-8b4477ee55cb', 26 | ); 27 | expect(response.status).toBe(404); 28 | expect(response.body).toMatchObject({ 29 | code: 404, 30 | message: 31 | 'Can not found wallet.token by id:b1e2d4f9-1c02-444f-9ca9-8b4477ee55cb', 32 | }); 33 | }); 34 | 35 | it('/tokens?wallet=bd60973b-2f08-45c5-afb3-7ec018180f17', async () => { 36 | const response = await supertest(app).get( 37 | '/tokens?wallet=bd60973b-2f08-45c5-afb3-7ec018180f17', 38 | ); 39 | 40 | expect(response.status).toBe(200); 41 | expect(response.body).toMatchObject({ 42 | tokens: expect.anything(), 43 | }); 44 | 45 | expect(response.body.tokens[0]).toMatchObject({ 46 | wallet_id: 'bd60973b-2f08-45c5-afb3-7ec018180f17', 47 | }); 48 | 49 | expect(response.body.tokens[4]).toMatchObject({ 50 | wallet_id: 'bd60973b-2f08-45c5-afb3-7ec018180f17', 51 | }); 52 | 53 | expect(response.body.tokens[9]).toMatchObject({ 54 | wallet_id: 'bd60973b-2f08-45c5-afb3-7ec018180f17', 55 | }); 56 | }); 57 | 58 | it('/tokens?wallet=Malinda51', async () => { 59 | const response = await supertest(app).get('/tokens?wallet=Malinda51'); 60 | 61 | expect(response.status).toBe(200); 62 | expect(response.body).toMatchObject({ 63 | tokens: expect.anything(), 64 | }); 65 | 66 | for (let i = 0; i < response.body.tokens.length; i++) { 67 | expect(response.body.tokens[i]).toMatchObject({ 68 | wallet_id: 'eecdf253-05b6-419a-8425-416a3e5fc9a0', 69 | }); 70 | } 71 | }); 72 | 73 | it('/tokens?wallet=Dave.Mertz68&withPlanter=true&withCapture=false', async () => { 74 | const response = await supertest(app).get( 75 | '/tokens?wallet=Dave.Mertz68&withPlanter=true&withCapture=false', 76 | ); 77 | 78 | expect(response.status).toBe(200); 79 | expect(response.body).toMatchObject({ 80 | tokens: expect.anything(), 81 | }); 82 | 83 | for (let i = 0; i < response.body.tokens.length; i++) { 84 | expect(response.body.tokens[i]).toMatchObject({ 85 | wallet_id: 'bd60973b-2f08-45c5-afb3-7ec018180f17', 86 | planter_first_name: 'Tristin', 87 | planter_last_name: 'Hills', 88 | planter_id: 5429, 89 | }); 90 | } 91 | 92 | for (let i = 0; i < response.body.tokens.length; i++) { 93 | expect(response.body.tokens[i].capture_photo_url).toBeUndefined(); 94 | } 95 | }); 96 | 97 | it('/tokens?wallet=Dave.Mertz68&withPlanter=true&withCapture=true', async () => { 98 | const response = await supertest(app).get( 99 | '/tokens?wallet=Dave.Mertz68&withPlanter=true&withCapture=true', 100 | ); 101 | 102 | expect(response.status).toBe(200); 103 | expect(response.body).toMatchObject({ 104 | tokens: expect.anything(), 105 | }); 106 | 107 | for (let i = 0; i < response.body.tokens.length; i++) { 108 | expect(response.body.tokens[i]).toMatchObject({ 109 | wallet_id: 'bd60973b-2f08-45c5-afb3-7ec018180f17', 110 | planter_first_name: 'Tristin', 111 | planter_last_name: 'Hills', 112 | planter_id: 5429, 113 | }); 114 | } 115 | 116 | expect(response.body.tokens[0]).toMatchObject({ 117 | wallet_id: 'bd60973b-2f08-45c5-afb3-7ec018180f17', 118 | planter_first_name: 'Tristin', 119 | planter_last_name: 'Hills', 120 | planter_id: 5429, 121 | capture_id: '60f2fa61-03ce-4895-8ae8-2987be0ddccb', 122 | }); 123 | }); 124 | }); 125 | -------------------------------------------------------------------------------- /server/routers/rawCapturesRouter.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import Joi from 'joi'; 3 | import RawCaptureRepository from 'infra/database/RawCaptureRepository'; 4 | import Session from 'infra/database/Session'; 5 | import { handlerWrapper, queryFormatter } from './utils'; 6 | import RawCaptureModel from '../models/RawCapture'; 7 | 8 | const router = express.Router(); 9 | 10 | router.get( 11 | '/count', 12 | handlerWrapper(async (req, res) => { 13 | const query = queryFormatter(req); 14 | 15 | // verify filter values 16 | Joi.assert( 17 | query, 18 | Joi.object().keys({ 19 | limit: Joi.number().integer().min(1).max(1000), 20 | offset: Joi.number().integer().min(0), 21 | status: Joi.string().allow('unprocessed', 'approved', 'rejected'), 22 | bulk_pack_file_name: Joi.string(), 23 | grower_account_id: Joi.string().uuid(), 24 | grower_reference_id: Joi.number(), 25 | organization_id: Joi.array().items(Joi.string().uuid()), 26 | session_id: Joi.string().uuid(), 27 | startDate: Joi.string().regex(/^\d{4}-\d{2}-\d{2}$/), 28 | endDate: Joi.string().regex(/^\d{4}-\d{2}-\d{2}$/), 29 | id: Joi.string().uuid(), 30 | reference_id: Joi.string(), 31 | tree_id: Joi.string().uuid(), 32 | species_id: Joi.string().uuid(), 33 | tag_id: Joi.string().uuid(), 34 | device_identifier: Joi.string(), 35 | wallet: Joi.string(), 36 | tokenized: Joi.string(), 37 | sort: Joi.object(), 38 | token_id: Joi.string().uuid(), 39 | whereNulls: Joi.array(), 40 | whereNotNulls: Joi.array(), 41 | whereIns: Joi.array(), 42 | }), 43 | ); 44 | const { ...rest } = query; 45 | 46 | const repo = new RawCaptureRepository(new Session()); 47 | const count = await RawCaptureModel.getCount(repo)({ ...rest }); 48 | res.send({ 49 | count: Number(count), 50 | }); 51 | res.end(); 52 | }), 53 | ); 54 | 55 | router.get( 56 | '/:id', 57 | handlerWrapper(async (req, res) => { 58 | Joi.assert(req.params.id, Joi.string().required()); 59 | const repo = new RawCaptureRepository(new Session()); 60 | const exe = RawCaptureModel.getById(repo); 61 | const result = await exe(req.params.id); 62 | res.send(result); 63 | res.end(); 64 | }), 65 | ); 66 | 67 | router.get( 68 | '/', 69 | handlerWrapper(async (req, res) => { 70 | const query = queryFormatter(req); 71 | 72 | // verify filter values 73 | Joi.assert( 74 | query, 75 | Joi.object().keys({ 76 | limit: Joi.number().integer().min(1).max(1000), 77 | offset: Joi.number().integer().min(0), 78 | status: Joi.string().allow('unprocessed', 'approved', 'rejected'), 79 | bulk_pack_file_name: Joi.string(), 80 | grower_account_id: Joi.string().uuid(), 81 | grower_reference_id: Joi.number(), 82 | organization_id: Joi.array().items(Joi.string().uuid()), 83 | session_id: Joi.string().uuid(), 84 | startDate: Joi.string().regex(/^\d{4}-\d{2}-\d{2}$/), 85 | endDate: Joi.string().regex(/^\d{4}-\d{2}-\d{2}$/), 86 | id: Joi.string().uuid(), 87 | reference_id: Joi.string(), 88 | tree_id: Joi.string().uuid(), 89 | species_id: Joi.string().uuid(), 90 | tag_id: Joi.string().uuid(), 91 | device_identifier: Joi.string(), 92 | wallet: Joi.string(), 93 | tokenized: Joi.string(), 94 | sort: Joi.object(), 95 | token_id: Joi.string().uuid(), 96 | whereNulls: Joi.array(), 97 | whereNotNulls: Joi.array(), 98 | whereIns: Joi.array(), 99 | }), 100 | ); 101 | const { 102 | limit = 25, 103 | offset = 0, 104 | order = 'desc', 105 | order_by = 'captured_at', 106 | ...rest 107 | } = query; 108 | 109 | const repo = new RawCaptureRepository(new Session()); 110 | const exe = RawCaptureModel.getByFilter(repo); 111 | const sort = { order, order_by }; 112 | const result = await exe({ ...rest, sort }, { limit, offset }); 113 | const count = await RawCaptureModel.getCount(repo)({ ...rest }); 114 | res.send({ 115 | raw_captures: result, 116 | total: Number(count), 117 | offset, 118 | limit, 119 | }); 120 | res.end(); 121 | }), 122 | ); 123 | 124 | export default router; 125 | --------------------------------------------------------------------------------