├── .dockerignore ├── .env.example ├── .eslintignore ├── .eslintrc.cjs ├── .github └── workflows │ ├── main.yml │ ├── pull-request.yml │ └── release.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.cjs ├── .vscode └── settings.json ├── Dockerfile ├── README.md ├── docker-compose.yml ├── drizzle.config.ts ├── drizzle ├── 0000_striped_warbird.sql ├── 0001_good_the_liberteens.sql └── meta │ ├── 0000_snapshot.json │ ├── 0001_snapshot.json │ └── _journal.json ├── package.json ├── renovate.json ├── src ├── config.ts ├── cors.ts ├── features.ts ├── handlers │ ├── accounts │ │ ├── accounts.handlers.ts │ │ └── accounts.methods.ts │ ├── auth │ │ ├── auth.handlers.ts │ │ └── auth.methods.ts │ ├── memberships │ │ ├── memberships.handlers.ts │ │ └── memberships.methods.ts │ ├── profiles │ │ ├── profiles.handlers.ts │ │ └── profiles.methods.ts │ └── workspaces │ │ ├── workspaces.handlers.ts │ │ └── workspaces.methods.ts ├── helpers │ ├── index.ts │ ├── logger.ts │ ├── permissions.ts │ ├── request.ts │ ├── response.ts │ └── strings │ │ ├── strings.test.ts │ │ └── strings.ts ├── middleware │ ├── errorHandler.ts │ ├── isAuthenticated.ts │ └── isAuthorized.ts ├── routes │ └── index.ts ├── schema.ts ├── server.ts ├── services │ ├── db │ │ ├── drizzle.ts │ │ ├── migrations.ts │ │ ├── seed.ts │ │ └── seeds │ │ │ └── accounts.ts │ ├── sentry.ts │ └── supabase.ts └── types │ └── express.d.ts ├── supabase ├── .gitignore └── config.toml ├── tsconfig.json └── vitest.config.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .git 3 | dist -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | APP_URL=http://localhost:4000 3 | PORT=4000 4 | SENTRY_DSN= 5 | 6 | ### 7 | ## Database 8 | ### 9 | POSTGRES_HOST=db 10 | POSTGRES_PORT=5432 11 | POSTGRES_USER=postgres 12 | POSTGRES_PASSWORD=example 13 | POSTGRES_DB=postgres 14 | 15 | ### 16 | ## Supabase config 17 | ### 18 | SUPABASE_URL=https://example.supabase.co 19 | SUPABASE_PK=example-key 20 | SUPABASE_AUTH_JWT_SECRET=abcdefghijklmnopqrstuvwxzyz1234567890 21 | 22 | ### 23 | # Feature Flags 24 | ### 25 | FEATURE_FLAG_SENTRY_DEVELOPMENT=true -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | env.d.ts 4 | prisma/generated/zod -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | es2022: true, 5 | node: true 6 | }, 7 | parser: "@typescript-eslint/parser", 8 | extends: [ 9 | // By extending from a plugin config, we can get recommended rules without having to add them manually. 10 | "eslint:recommended", 11 | "plugin:import/recommended", 12 | "plugin:@typescript-eslint/recommended", 13 | "plugin:import/errors", 14 | "plugin:import/warnings", 15 | "plugin:import/typescript", 16 | // This disables the formatting rules in ESLint that Prettier is going to be responsible for handling. 17 | // Make sure it's always the last config, so it gets the chance to override other configs. 18 | "eslint-config-prettier", 19 | "prettier" 20 | ], 21 | plugins: ["@typescript-eslint", "import"], 22 | settings: { 23 | // Tells eslint how to resolve imports 24 | "import/resolver": { 25 | // see: https://www.npmjs.com/package/eslint-import-resolver-typescript 26 | node: { 27 | extensions: [".js", ".jsx", ".ts", ".tsx", ".d.ts"] 28 | }, 29 | typescript: { 30 | alwaysTryTypes: true, 31 | directory: "./tsconfig.json" 32 | } 33 | }, 34 | "import/parsers": { 35 | "@typescript-eslint/parser": [".ts", ".tsx"] 36 | } 37 | }, 38 | rules: { 39 | "@typescript-eslint/no-explicit-any": "warn", 40 | "arrow-parens": ["error", "always"], 41 | "@typescript-eslint/explicit-function-return-type": "warn", 42 | "@typescript-eslint/no-unused-vars": ["warn", { args: "all", argsIgnorePattern: "^_", varsIgnorePattern: "^_" }] 43 | }, 44 | overrides: [ 45 | { 46 | files: ["**/__mocks__/*", "**/*.{test,tests}.{ts,tsx}"], 47 | rules: { 48 | "@typescript-eslint/no-unused-vars": 0, 49 | "@typescript-eslint/no-explicit-any": 0 50 | } 51 | } 52 | ] 53 | }; 54 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: test-and-build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | test-and-build: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | node-version: [18, 20] 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: volta-cli/action@v4 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | - run: npm i 20 | - run: npm run lint 21 | - run: npm run format:check 22 | - run: npm run tsc:check 23 | - run: npm run build 24 | - run: npm run test 25 | # TODO uncomment the deployment step when you have configured the secrets. 26 | # deploy-dev: 27 | # needs: test-and-build 28 | # runs-on: ubuntu-latest 29 | # steps: 30 | # - uses: actions/checkout@v4 31 | # - uses: volta-cli/action@v3 32 | # with: 33 | # node-version: 20 34 | 35 | # - name: Install doctl 36 | # uses: digitalocean/action-doctl@v2 37 | # with: 38 | # token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} 39 | 40 | # - name: Build container image 41 | # run: docker build -t ${{ secrets.REGISTRY_NAME }}/${{secrets.IMAGE_NAME}}-dev:$(echo $GITHUB_SHA | head -c7) . 42 | 43 | # - name: Log in to DigitalOcean Container Registry with short-lived credentials 44 | # run: doctl registry login --expiry-seconds 1200 45 | 46 | # - name: tag image with latest tag 47 | # run: docker tag ${{ secrets.REGISTRY_NAME }}/${{secrets.IMAGE_NAME}}-dev:$(echo $GITHUB_SHA | head -c7) ${{ secrets.REGISTRY_NAME }}/${{secrets.IMAGE_NAME}}-dev:latest 48 | 49 | # - name: Push image to DigitalOcean Container Registry 50 | # run: docker push ${{ secrets.REGISTRY_NAME }}/${{secrets.IMAGE_NAME}}-dev:latest 51 | -------------------------------------------------------------------------------- /.github/workflows/pull-request.yml: -------------------------------------------------------------------------------- 1 | name: test-and-build 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | test-and-build: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | node-version: [18, 20] 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: volta-cli/action@v4 15 | with: 16 | node-version: ${{ matrix.node-version }} 17 | - run: npm i 18 | - run: npm run lint 19 | - run: npm run format:check 20 | - run: npm run tsc:check 21 | - run: npm run build 22 | - run: npm run test 23 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: test-and-build 2 | 3 | on: 4 | release: 5 | types: 6 | - released 7 | 8 | jobs: 9 | test-and-build: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | node-version: [18, 20] 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: volta-cli/action@v4 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | - run: npm i 20 | - run: npm run lint 21 | - run: npm run format:check 22 | - run: npm run tsc:check 23 | - run: npm run build 24 | - run: npm run test 25 | # TODO uncomment the deployment step when you have configured the secrets. 26 | # deploy-prod: 27 | # needs: test-and-build 28 | # runs-on: ubuntu-latest 29 | # steps: 30 | # - uses: actions/checkout@v4 31 | # - uses: volta-cli/action@v3 32 | # with: 33 | # node-version: 20 34 | 35 | # - name: Install doctl 36 | # uses: digitalocean/action-doctl@v2 37 | # with: 38 | # token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} 39 | 40 | # - name: Build container image 41 | # run: docker build -t ${{ secrets.REGISTRY_NAME }}/${{secrets.IMAGE_NAME}}-prod:$(echo $GITHUB_SHA | head -c7) . 42 | 43 | # - name: Log in to DigitalOcean Container Registry with short-lived credentials 44 | # run: doctl registry login --expiry-seconds 1200 45 | 46 | # - name: tag image with latest tag 47 | # run: docker tag ${{ secrets.REGISTRY_NAME }}/${{secrets.IMAGE_NAME}}-prod:$(echo $GITHUB_SHA | head -c7) ${{ secrets.REGISTRY_NAME }}/${{secrets.IMAGE_NAME}}-prod:latest 48 | 49 | # - name: Push image to DigitalOcean Container Registry 50 | # run: docker push ${{ secrets.REGISTRY_NAME }}/${{secrets.IMAGE_NAME}}-prod:latest 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | .env 5 | pnpm-lock.yaml 6 | logs 7 | *.log 8 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | coverage 3 | dist 4 | node_modules 5 | /prisma/generated/zod 6 | /drizzle/* 7 | renovate.json -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: "none", 3 | tabWidth: 2, 4 | semi: true, 5 | singleQuote: false, 6 | printWidth: 120, 7 | bracketSpacing: true, 8 | endOfLine: "lf" 9 | }; 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "workbench.colorTheme": "Darcula", 3 | "workbench.iconTheme": "material-icon-theme", 4 | "editor.fontFamily": "JetBrains Mono", 5 | "editor.defaultFormatter": "esbenp.prettier-vscode", 6 | "editor.formatOnSave": true, 7 | "typescript.tsdk": "node_modules/typescript/lib" 8 | } 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine AS base 2 | 3 | ENV PNPM_VERSION=9.1.0 4 | 5 | RUN npm install -g pnpm@$PNPM_VERSION 6 | 7 | WORKDIR /app 8 | 9 | ARG NODE_ENV 10 | ENV NODE_ENV=${NODE_ENV} 11 | 12 | # install deps first so we can cache them 13 | COPY package*.json pnpm-lock.yaml ./ 14 | RUN pnpm install --frozen-lockfile 15 | 16 | FROM base AS builder 17 | 18 | WORKDIR /app 19 | 20 | COPY . . 21 | 22 | RUN mkdir dist 23 | 24 | RUN pnpm build 25 | 26 | FROM base AS runner 27 | 28 | WORKDIR /app 29 | 30 | RUN addgroup --system --gid 1001 express 31 | RUN adduser --system --uid 1001 express 32 | 33 | COPY --from=builder --chown=express:express /app/node_modules /app/node_modules 34 | COPY --from=builder --chown=express:express /app/dist /app/dist 35 | COPY --from=builder --chown=express:express /app/package.json /app/package.json 36 | 37 | USER express 38 | 39 | EXPOSE 4000 40 | 41 | FROM runner AS dev 42 | 43 | WORKDIR /app 44 | 45 | COPY --from=builder --chown=express:express /app/.env /app/.env 46 | 47 | CMD ["node", "./dist/server.mjs"] 48 | 49 | FROM runner AS prod 50 | 51 | WORKDIR /app 52 | 53 | CMD ["node", "./dist/server.mjs"] 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # supabase-express-api 2 | 3 | - [tsx](https://github.com/esbuild-kit/tsx) 4 | - [pkgroll](https://github.com/privatenumber/pkgroll) 5 | - [eslint](https://eslint.org/) 6 | - [prettier](https://prettier.io/) 7 | - [typescript](https://www.typescriptlang.org/) 8 | - [vitest](https://vitest.dev/) 9 | - [zod](https://zod.dev/) 10 | - [drizzle](https://orm.drizzle.team/) 11 | - [drizzle with supabase](https://orm.drizzle.team/docs/get-started-postgresql#supabase) 12 | - [drizzle-zod](https://orm.drizzle.team/docs/zod) 13 | - [drizzle-kit](https://orm.drizzle.team/kit-docs/overview) 14 | - [postgres](https://www.postgresql.org/) 15 | - [supabase](https://supabase.io/) 16 | - [supabase-js](https://supabase.com/docs/reference/javascript/introduction) 17 | - [helmet](https://helmetjs.github.io/) 18 | - [cookie-parser](https://www.npmjs.com/package/cookie-parser) 19 | - [@sentry/node](https://docs.sentry.io/platforms/javascript/guides/node/) 20 | 21 | A node/express backend API template for getting started with a new project that includes authentication, permissions, and a database configured to 22 | use [Supabase](https://supabase.io/) or a local/cloud Postgres database. 23 | 24 | This comes pre-defined with a workspaces model that allows accounts (users) to create workspaces and invite other profiles (users presence within a workspace) to access the workspace (membership). see the [Permissions](#permissions) section for more information on how permissions are defined. 25 | 26 | The contents of a workspace is not defined in this template and can be customized to suit the needs of the project. 27 | 28 | ## ESM Node 29 | 30 | https://www.typescriptlang.org/docs/handbook/esm-node.html 31 | 32 | This project has been setup to use ESM Node. This allows us to use ES6 imports in Node. 33 | 34 | This uses [tsx](https://github.com/esbuild-kit/tsx) as a dev server and [pkgroll](https://github.com/privatenumber/pkgroll) to bundle and build the project. 35 | 36 | ## Requirements 37 | 38 | This project requires node.js to be installed. This project uses volta to manage node versions. 39 | 40 | To install volta run the following command in the terminal. 41 | 42 | ```bash 43 | curl https://get.volta.sh | bash 44 | ``` 45 | 46 | You will need a Postgres database to run this project. You can use Docker to run a Postgres database or use a service like [Supabase](https://supabase.com/). The auth provider can be replaced with any other Auth providers eg Firebase, Auth0, Keycloak you just need to implement the authentication middleware to verify the token and decode the claims and modify the auth handlers to use your provider. 47 | 48 | See the [Database](#database) section for more information on how to configure the database connection. 49 | 50 | Authentication is handled by [Supabase](https://supabase.com/) and requires a Supabase account. You can sign up for a free account at [supabase.com](https://supabase.com/). 51 | 52 | ### Install PNPM 53 | 54 | If using volta we can install corepack globally and let volta manage the binary, corepack will let us use the correct version of pnpm based on the `packageManager` field in the package.json file. 55 | 56 | ```bash 57 | npm install --global corepack@latest 58 | 59 | corepack enable pnpm 60 | ``` 61 | 62 | see the [installation docs for pnpm](https://pnpm.io/installation) and the [corepack docs](https://github.com/nodejs/corepack) 63 | for more information on how to install pnpm and corepack. 64 | 65 | ### ENV 66 | 67 | Create a .env file in the root of the project and copy the contents of .env.example into it. 68 | 69 | ```bash 70 | cp .env.example .env 71 | ``` 72 | 73 | see the section on [Deployment with DigitalOcean](#deployment-with-digitalocean) for more information on how to configure the environment variables for deployment in different environments (eg. development and production). 74 | 75 | ### Install dependencies 76 | 77 | ```bash 78 | pnpm i 79 | ``` 80 | 81 | ### Setup the database 82 | 83 | The first time setting up the database you will need to run the `migrate` command to create the database tables and apply the migrations. 84 | 85 | ```bash 86 | pnpm run migrate 87 | ``` 88 | 89 | ## Testing 90 | 91 | This project uses [vitest](https://vitest.dev/) for unit testing. 92 | 93 | Run the unit tests with `npm run test` 94 | 95 | It's also recommended to install the [vitest extension for vscode](https://marketplace.visualstudio.com/items?itemName=ZixuanChen.vitest-explorer). 96 | 97 | ## Supabase CLI 98 | 99 | You can install the supabase cli for local development. 100 | 101 | - https://supabase.com/docs/guides/cli/getting-started 102 | - https://supabase.com/docs/guides/cli/local-development 103 | 104 | ## Database 105 | 106 | You can view the database with `pnpx drizzle-kit studio` or `pnpm run studio`. 107 | 108 | You can spin up a local copy of the database and application with `docker-compose` but this is not required when using the Supabase db. 109 | 110 | When using the supabase cli we can run a local copy of the db with `supabase start`. 111 | 112 | ### Developing locally with supabase 113 | 114 | This will provide you with a connection string, you can update the local environment variables in the .env file with the details from the connection string. 115 | 116 | `postgresql://postgres:postgres@localhost:54322/postgres` 117 | 118 | Visit the Supabase dashboard: http://localhost:54323 and manage your database locally. Note: It appears that the database name needs to be `postgres` to be able to work with the Supabase dashboard. 119 | 120 | ### Local Postgres with Docker 121 | 122 | You can spin up a local database and application with `docker-compose` but this is not required when using the Supabase db or cli. 123 | 124 | ```bash 125 | docker compose up -d 126 | ``` 127 | 128 | Alternatively you can create a local network and connect the containers to the network. 129 | 130 | ```bash 131 | docker network create mynetwork 132 | 133 | docker run --network mynetwork --name mypostgres -d -p 5432:5432 -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=example -e POSTGRES_DB=postgres postgres:15 134 | ``` 135 | 136 | Then when running the application in docker you can connect to the database with the container name. 137 | 138 | ```bash 139 | POSTGRES_HOST=mypostgres 140 | ``` 141 | 142 | Then run the application in docker and connect to the same network. 143 | 144 | ```bash 145 | docker run --network mynetwork --name node-express -d -p 4000:4000 node-express 146 | ``` 147 | 148 | Note: If you are using a local database and running the application within docker on the host machine you will need to set `POSTGRES_HOST=host.docker.internal` in the .env file. [Read the docs for more info](https://docs.docker.com/desktop/networking/#i-want-to-connect-from-a-container-to-a-service-on-the-host) 149 | 150 | ### Migrations 151 | 152 | When running the migrations for the first time on a new database run: 153 | 154 | ```bash 155 | pnpm run migrate 156 | ``` 157 | 158 | When the schema/model is changed make sure to create a new migration and run it against the db. 159 | 160 | ### 1. Create a new migration 161 | 162 | ```bash 163 | pnpm run migrate:create 164 | 165 | ``` 166 | 167 | ### 2. Run the migrations 168 | 169 | ```bash 170 | # first run the migrations 171 | pnpm run migrate:up 172 | 173 | # then run 174 | pnpm migrate:push 175 | ``` 176 | 177 | ### Seeds 178 | 179 | You can run the seeds to populate the database with initial data. 180 | 181 | Before seeding the db make sure to run the migrations. If you want to populate the seeds with specific user email, password or id's related to the users created in Supabase. You can update the seeds in `./src/seeds/` with the required data. 182 | 183 | You will need to add these users to supabase auth and confirm the email addresses. 184 | 185 | 188 | 189 | ```bash 190 | pnpm run seed 191 | ``` 192 | 193 | Be sure to update the seeds as new migrations are added. 194 | 195 | ## Build with docker 196 | 197 | ```bash 198 | # build the app 199 | npm run build 200 | 201 | # build with docker 202 | docker build . --tag node-express 203 | 204 | # or to build with a specific platform 205 | docker build . --tag node-express --platform linux/amd64 206 | 207 | # or build a specific stage eg dev 208 | docker build . --target dev --tag node-express 209 | 210 | # start the docker container 211 | docker run -d -p 4000:4000 node-express 212 | 213 | # view it running on localhost 214 | curl localhost:4000 215 | ``` 216 | 217 | ## Import aliases 218 | 219 | Aliases can be configured in the import map, defined in package.json#imports. 220 | 221 | see: https://github.com/privatenumber/pkgroll#aliases 222 | 223 | ## Authentication 224 | 225 | This project uses JWT bearer token for authentication. The claims, id and sub must be set on the token and the token can be verified and decoded using the configured auth provider. 226 | 227 | ## Permissions 228 | 229 | How permissions work. 230 | 231 | A resource will have a permission level for each route method based on users role within the workspace. Workspace permissions can be defined in `./src/helpers/permissions.ts`. 232 | 233 | Workspace level permissions: 234 | Admin: Highest level of access to all resources within the workspace. 235 | User: Regular user with limited permissions. 236 | 237 | Resource level permissions: 238 | Owner: Has access to their own resources 239 | 240 | Account level permissions: 241 | SuperAdmin: Has access to all super only resources. 242 | 243 | ### Workspace route permission levels 244 | 245 | Ensure every request that requires workspace permissions includes a workspace context. 246 | 247 | This can be done by passing the `x-workspace-id` header when making a request. 248 | 249 | This will allow the user to access the workspace resources if they are a member of the workspace with a sufficient role. 250 | 251 | A role/claim is defined when the account is added to the workspace as a member. 252 | 253 | 1. User - Can access all resources with user permissions. 254 | 2. Admin - Can access all resources within the workspace. 255 | 256 | ## Supabase Auth 257 | 258 | see the [documentation for more information](https://supabase.com/docs/reference/javascript/auth-api) on how to use Supabase Auth with this project. 259 | 260 | ## Deployment with DigitalOcean 261 | 262 | A docker image can be built and deployed to a [container registry](https://docs.digitalocean.com/products/container-registry/getting-started/quickstart/). We can configure DigitalOcean to deploy the image once the registry updates using their [App Platform](https://docs.digitalocean.com/products/app-platform/) 263 | 264 | The following secrets will need to be added to Github Actions for a successful deployment to DigitalOcean. 265 | 266 | ### Environment variables for deployment 267 | 268 | - `DIGITALOCEAN_ACCESS_TOKEN` https://docs.digitalocean.com/reference/api/create-personal-access-token/ 269 | - `REGISTRY_NAME` eg registry.digitalocean.com/my-container-registry 270 | - `IMAGE_NAME` the name of the image we are pushing to the repository eg `express-api` it will be tagged with the latest version and a github sha. 271 | 272 | ### App level environment variables 273 | 274 | For information on confguring the app level environment variables see [How to use environment variables in DigitalOcean App Platform](https://docs.digitalocean.com/products/app-platform/how-to/use-environment-variables/) 275 | 276 | - `NODE_ENV`: `production` 277 | - `APP_URL`: `https://api.example.com` 278 | - `POSTGRES_HOST`: `.pooler.supabase.com` 279 | - `POSTGRES_USER`: `postgres.` 280 | - `POSTGRES_PASSWORD`: `example` 281 | - `POSTGRES_DB`: `postgres` 282 | - `SUPABASE_URL`: `https://.supabase.co` 283 | - `SUPABASE_PK`: `abcdefghijklm` 284 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | app: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | env_file: 7 | - .env 8 | ports: 9 | - ${PORT}:${PORT} 10 | db: 11 | image: postgres:17 12 | restart: always 13 | environment: 14 | POSTGRES_USER: ${POSTGRES_USER} 15 | POSTGRES_DB: ${POSTGRES_DB} 16 | POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} 17 | ports: 18 | - ${POSTGRES_PORT:-5432}:${POSTGRES_PORT:-5432} 19 | volumes: 20 | - db:/var/lib/postgresql/data 21 | volumes: 22 | db: 23 | driver: local 24 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "drizzle-kit"; 2 | // drizzle requires the ts extension to import config. 3 | import { config } from "./src/config.ts"; 4 | import { logger } from "./src/helpers/index.ts"; 5 | 6 | logger.info({ msg: "config", config }); 7 | 8 | export default { 9 | dialect: "postgresql", 10 | schema: "./src/schema.ts", 11 | out: "./drizzle", 12 | dbCredentials: { 13 | host: config.db_host, 14 | user: config.db_user, 15 | port: config.db_port, 16 | password: config.db_password, 17 | database: config.db_name, 18 | ssl: config.env !== "development" 19 | } 20 | } satisfies Config; 21 | -------------------------------------------------------------------------------- /drizzle/0000_striped_warbird.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "accounts" ( 2 | "uuid" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, 3 | "full_name" text NOT NULL, 4 | "phone" varchar(256), 5 | "created_at" timestamp (6) with time zone DEFAULT now(), 6 | "email" text NOT NULL, 7 | CONSTRAINT "accounts_email_unique" UNIQUE("email") 8 | ); 9 | --> statement-breakpoint 10 | CREATE TABLE IF NOT EXISTS "profiles" ( 11 | "uuid" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, 12 | "name" text NOT NULL, 13 | "created_at" timestamp (6) with time zone DEFAULT now(), 14 | "workspace_id" uuid NOT NULL, 15 | "account_id" uuid DEFAULT '00000000-0000-0000-0000-000000000000' NOT NULL 16 | ); 17 | --> statement-breakpoint 18 | CREATE TABLE IF NOT EXISTS "workspace_memberships" ( 19 | "uuid" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, 20 | "workspace_id" uuid NOT NULL, 21 | "account_id" uuid NOT NULL, 22 | "role" text NOT NULL 23 | ); 24 | --> statement-breakpoint 25 | CREATE TABLE IF NOT EXISTS "workspaces" ( 26 | "uuid" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, 27 | "name" text NOT NULL, 28 | "description" text, 29 | "created_at" timestamp (6) with time zone DEFAULT now(), 30 | "account_id" uuid NOT NULL 31 | ); 32 | -------------------------------------------------------------------------------- /drizzle/0001_good_the_liberteens.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "accounts" ADD COLUMN "is_super_admin" boolean DEFAULT false; -------------------------------------------------------------------------------- /drizzle/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "postgresql", 4 | "tables": { 5 | "public.accounts": { 6 | "name": "accounts", 7 | "schema": "", 8 | "columns": { 9 | "uuid": { 10 | "name": "uuid", 11 | "type": "uuid", 12 | "primaryKey": true, 13 | "notNull": true, 14 | "default": "gen_random_uuid()" 15 | }, 16 | "full_name": { 17 | "name": "full_name", 18 | "type": "text", 19 | "primaryKey": false, 20 | "notNull": true 21 | }, 22 | "phone": { 23 | "name": "phone", 24 | "type": "varchar(256)", 25 | "primaryKey": false, 26 | "notNull": false 27 | }, 28 | "created_at": { 29 | "name": "created_at", 30 | "type": "timestamp (6) with time zone", 31 | "primaryKey": false, 32 | "notNull": false, 33 | "default": "now()" 34 | }, 35 | "email": { 36 | "name": "email", 37 | "type": "text", 38 | "primaryKey": false, 39 | "notNull": true 40 | } 41 | }, 42 | "indexes": {}, 43 | "foreignKeys": {}, 44 | "compositePrimaryKeys": {}, 45 | "uniqueConstraints": { 46 | "accounts_email_unique": { 47 | "name": "accounts_email_unique", 48 | "columns": [ 49 | "email" 50 | ], 51 | "nullsNotDistinct": false 52 | } 53 | } 54 | }, 55 | "public.profiles": { 56 | "name": "profiles", 57 | "schema": "", 58 | "columns": { 59 | "uuid": { 60 | "name": "uuid", 61 | "type": "uuid", 62 | "primaryKey": true, 63 | "notNull": true, 64 | "default": "gen_random_uuid()" 65 | }, 66 | "name": { 67 | "name": "name", 68 | "type": "text", 69 | "primaryKey": false, 70 | "notNull": true 71 | }, 72 | "created_at": { 73 | "name": "created_at", 74 | "type": "timestamp (6) with time zone", 75 | "primaryKey": false, 76 | "notNull": false, 77 | "default": "now()" 78 | }, 79 | "workspace_id": { 80 | "name": "workspace_id", 81 | "type": "uuid", 82 | "primaryKey": false, 83 | "notNull": true 84 | }, 85 | "account_id": { 86 | "name": "account_id", 87 | "type": "uuid", 88 | "primaryKey": false, 89 | "notNull": true, 90 | "default": "'00000000-0000-0000-0000-000000000000'" 91 | } 92 | }, 93 | "indexes": {}, 94 | "foreignKeys": {}, 95 | "compositePrimaryKeys": {}, 96 | "uniqueConstraints": {} 97 | }, 98 | "public.workspace_memberships": { 99 | "name": "workspace_memberships", 100 | "schema": "", 101 | "columns": { 102 | "uuid": { 103 | "name": "uuid", 104 | "type": "uuid", 105 | "primaryKey": true, 106 | "notNull": true, 107 | "default": "gen_random_uuid()" 108 | }, 109 | "workspace_id": { 110 | "name": "workspace_id", 111 | "type": "uuid", 112 | "primaryKey": false, 113 | "notNull": true 114 | }, 115 | "account_id": { 116 | "name": "account_id", 117 | "type": "uuid", 118 | "primaryKey": false, 119 | "notNull": true 120 | }, 121 | "role": { 122 | "name": "role", 123 | "type": "text", 124 | "primaryKey": false, 125 | "notNull": true 126 | } 127 | }, 128 | "indexes": {}, 129 | "foreignKeys": {}, 130 | "compositePrimaryKeys": {}, 131 | "uniqueConstraints": {} 132 | }, 133 | "public.workspaces": { 134 | "name": "workspaces", 135 | "schema": "", 136 | "columns": { 137 | "uuid": { 138 | "name": "uuid", 139 | "type": "uuid", 140 | "primaryKey": true, 141 | "notNull": true, 142 | "default": "gen_random_uuid()" 143 | }, 144 | "name": { 145 | "name": "name", 146 | "type": "text", 147 | "primaryKey": false, 148 | "notNull": true 149 | }, 150 | "description": { 151 | "name": "description", 152 | "type": "text", 153 | "primaryKey": false, 154 | "notNull": false 155 | }, 156 | "created_at": { 157 | "name": "created_at", 158 | "type": "timestamp (6) with time zone", 159 | "primaryKey": false, 160 | "notNull": false, 161 | "default": "now()" 162 | }, 163 | "account_id": { 164 | "name": "account_id", 165 | "type": "uuid", 166 | "primaryKey": false, 167 | "notNull": true 168 | } 169 | }, 170 | "indexes": {}, 171 | "foreignKeys": {}, 172 | "compositePrimaryKeys": {}, 173 | "uniqueConstraints": {} 174 | } 175 | }, 176 | "enums": {}, 177 | "schemas": {}, 178 | "_meta": { 179 | "schemas": {}, 180 | "tables": {}, 181 | "columns": {} 182 | }, 183 | "id": "19a6b5e6-4b93-4f5e-bbfd-b2e76a4f84ed", 184 | "prevId": "00000000-0000-0000-0000-000000000000" 185 | } -------------------------------------------------------------------------------- /drizzle/meta/0001_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "postgresql", 4 | "tables": { 5 | "public.accounts": { 6 | "name": "accounts", 7 | "schema": "", 8 | "columns": { 9 | "uuid": { 10 | "name": "uuid", 11 | "type": "uuid", 12 | "primaryKey": true, 13 | "notNull": true, 14 | "default": "gen_random_uuid()" 15 | }, 16 | "full_name": { 17 | "name": "full_name", 18 | "type": "text", 19 | "primaryKey": false, 20 | "notNull": true 21 | }, 22 | "phone": { 23 | "name": "phone", 24 | "type": "varchar(256)", 25 | "primaryKey": false, 26 | "notNull": false 27 | }, 28 | "created_at": { 29 | "name": "created_at", 30 | "type": "timestamp (6) with time zone", 31 | "primaryKey": false, 32 | "notNull": false, 33 | "default": "now()" 34 | }, 35 | "email": { 36 | "name": "email", 37 | "type": "text", 38 | "primaryKey": false, 39 | "notNull": true 40 | }, 41 | "is_super_admin": { 42 | "name": "is_super_admin", 43 | "type": "boolean", 44 | "primaryKey": false, 45 | "notNull": false, 46 | "default": false 47 | } 48 | }, 49 | "indexes": {}, 50 | "foreignKeys": {}, 51 | "compositePrimaryKeys": {}, 52 | "uniqueConstraints": { 53 | "accounts_email_unique": { 54 | "name": "accounts_email_unique", 55 | "columns": [ 56 | "email" 57 | ], 58 | "nullsNotDistinct": false 59 | } 60 | } 61 | }, 62 | "public.profiles": { 63 | "name": "profiles", 64 | "schema": "", 65 | "columns": { 66 | "uuid": { 67 | "name": "uuid", 68 | "type": "uuid", 69 | "primaryKey": true, 70 | "notNull": true, 71 | "default": "gen_random_uuid()" 72 | }, 73 | "name": { 74 | "name": "name", 75 | "type": "text", 76 | "primaryKey": false, 77 | "notNull": true 78 | }, 79 | "created_at": { 80 | "name": "created_at", 81 | "type": "timestamp (6) with time zone", 82 | "primaryKey": false, 83 | "notNull": false, 84 | "default": "now()" 85 | }, 86 | "workspace_id": { 87 | "name": "workspace_id", 88 | "type": "uuid", 89 | "primaryKey": false, 90 | "notNull": true 91 | }, 92 | "account_id": { 93 | "name": "account_id", 94 | "type": "uuid", 95 | "primaryKey": false, 96 | "notNull": true, 97 | "default": "'00000000-0000-0000-0000-000000000000'" 98 | } 99 | }, 100 | "indexes": {}, 101 | "foreignKeys": {}, 102 | "compositePrimaryKeys": {}, 103 | "uniqueConstraints": {} 104 | }, 105 | "public.workspace_memberships": { 106 | "name": "workspace_memberships", 107 | "schema": "", 108 | "columns": { 109 | "uuid": { 110 | "name": "uuid", 111 | "type": "uuid", 112 | "primaryKey": true, 113 | "notNull": true, 114 | "default": "gen_random_uuid()" 115 | }, 116 | "workspace_id": { 117 | "name": "workspace_id", 118 | "type": "uuid", 119 | "primaryKey": false, 120 | "notNull": true 121 | }, 122 | "account_id": { 123 | "name": "account_id", 124 | "type": "uuid", 125 | "primaryKey": false, 126 | "notNull": true 127 | }, 128 | "role": { 129 | "name": "role", 130 | "type": "text", 131 | "primaryKey": false, 132 | "notNull": true 133 | } 134 | }, 135 | "indexes": {}, 136 | "foreignKeys": {}, 137 | "compositePrimaryKeys": {}, 138 | "uniqueConstraints": {} 139 | }, 140 | "public.workspaces": { 141 | "name": "workspaces", 142 | "schema": "", 143 | "columns": { 144 | "uuid": { 145 | "name": "uuid", 146 | "type": "uuid", 147 | "primaryKey": true, 148 | "notNull": true, 149 | "default": "gen_random_uuid()" 150 | }, 151 | "name": { 152 | "name": "name", 153 | "type": "text", 154 | "primaryKey": false, 155 | "notNull": true 156 | }, 157 | "description": { 158 | "name": "description", 159 | "type": "text", 160 | "primaryKey": false, 161 | "notNull": false 162 | }, 163 | "created_at": { 164 | "name": "created_at", 165 | "type": "timestamp (6) with time zone", 166 | "primaryKey": false, 167 | "notNull": false, 168 | "default": "now()" 169 | }, 170 | "account_id": { 171 | "name": "account_id", 172 | "type": "uuid", 173 | "primaryKey": false, 174 | "notNull": true 175 | } 176 | }, 177 | "indexes": {}, 178 | "foreignKeys": {}, 179 | "compositePrimaryKeys": {}, 180 | "uniqueConstraints": {} 181 | } 182 | }, 183 | "enums": {}, 184 | "schemas": {}, 185 | "_meta": { 186 | "schemas": {}, 187 | "tables": {}, 188 | "columns": {} 189 | }, 190 | "id": "0273ed27-7b1f-47d4-ae9b-82481ad77e7c", 191 | "prevId": "19a6b5e6-4b93-4f5e-bbfd-b2e76a4f84ed" 192 | } -------------------------------------------------------------------------------- /drizzle/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "pg", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "5", 8 | "when": 1716151842436, 9 | "tag": "0000_striped_warbird", 10 | "breakpoints": true 11 | }, 12 | { 13 | "idx": 1, 14 | "version": "5", 15 | "when": 1716190000271, 16 | "tag": "0001_good_the_liberteens", 17 | "breakpoints": true 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-express-backend-component", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "dist/server.mjs", 6 | "module": "dist/server.mjs", 7 | "type": "module", 8 | "imports": { 9 | "@": "./src" 10 | }, 11 | "packageManager": "pnpm@9.1.0+sha512.67f5879916a9293e5cf059c23853d571beaf4f753c707f40cb22bed5fb1578c6aad3b6c4107ccb3ba0b35be003eb621a16471ac836c87beb53f9d54bb4612724", 12 | "scripts": { 13 | "lint": "eslint . --ext .ts,.tsx --max-warnings 0", 14 | "format": "prettier -w '**/*.{js,ts,mjs,cjs,json,tsx,jsx}'", 15 | "format:check": "prettier --check '**/*.{js,ts,mjs,cjs,json,tsx,jsx}'", 16 | "tsc:check": "tsc -p tsconfig.json --noEmit", 17 | "dev": "tsx watch ./src/server.ts | pino-pretty", 18 | "build": "pkgroll", 19 | "build:tsc": "rm -rf dist && tsc -p tsconfig.json", 20 | "test": "vitest --run --coverage", 21 | "migrate:create": "npx drizzle-kit generate", 22 | "migrate": "npx drizzle-kit migrate", 23 | "migrate:up": "npx drizzle-kit up", 24 | "migrate:push": "npx drizzle-kit push", 25 | "seed": "tsx ./src/services/db/seed.ts --supabase=$npm_config_supabase", 26 | "studio": "npx drizzle-kit studio" 27 | }, 28 | "keywords": [], 29 | "author": "", 30 | "license": "ISC", 31 | "devDependencies": { 32 | "@types/jsonwebtoken": "^9.0.6", 33 | "@typescript-eslint/eslint-plugin": "^8.29.0", 34 | "@typescript-eslint/parser": "^8.29.0", 35 | "@vitest/coverage-v8": "^2.0.0", 36 | "dotenv": "^16.4.5", 37 | "drizzle-kit": "^0.31.0", 38 | "eslint": "^8.57.0", 39 | "eslint-config-prettier": "^9.1.0", 40 | "eslint-import-resolver-node": "^0.3.9", 41 | "eslint-import-resolver-typescript": "^3.6.1", 42 | "eslint-plugin-import": "^2.31.0", 43 | "pino-pretty": "^13.0.0", 44 | "pkgroll": "^2.1.1", 45 | "prettier": "^3.2.5", 46 | "tsx": "^4.19.3", 47 | "typescript": "^5.8.3", 48 | "vitest": "^2.0.0", 49 | "zod": "^3.24.2" 50 | }, 51 | "files": [ 52 | "dist" 53 | ], 54 | "volta": { 55 | "node": "22.15.0" 56 | }, 57 | "dependencies": { 58 | "@sentry/node": "^9.11.0", 59 | "@sentry/profiling-node": "^9.11.0", 60 | "@supabase/supabase-js": "^2.43.4", 61 | "@types/bcrypt": "^5.0.2", 62 | "@types/cookie-parser": "^1.4.7", 63 | "@types/cors": "^2.8.17", 64 | "@types/express": "^4.17.21", 65 | "@types/node": "^20.12.13", 66 | "@types/pg": "^8.11.6", 67 | "bcrypt": "^6.0.0", 68 | "cookie-parser": "^1.4.6", 69 | "cors": "^2.8.5", 70 | "drizzle-orm": "^0.43.0", 71 | "drizzle-zod": "^0.8.0", 72 | "express": "^4.19.2", 73 | "helmet": "^8.0.0", 74 | "jsonwebtoken": "^9.0.2", 75 | "pg": "^8.14.1", 76 | "pino": "^9.6.0", 77 | "pino-http": "^10.4.0" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import dotenv from "dotenv"; 2 | import { logger } from "@/helpers/index.ts"; 3 | 4 | export const stages = ["development", "production", "test"] as const; 5 | 6 | export type Stage = (typeof stages)[number]; 7 | 8 | export const ENV = process.env.NODE_ENV ?? "development"; 9 | 10 | export const getStage = (env: string): Stage => { 11 | if (!stages.includes(env as Stage)) { 12 | throw new Error(`Invalid environment: ${ENV}`); 13 | } 14 | return env as Stage; 15 | }; 16 | 17 | export const STAGE = getStage(ENV); 18 | 19 | logger.info(`running in env: ${STAGE}`); 20 | 21 | if (STAGE !== "production") { 22 | dotenv.config(); 23 | } 24 | 25 | export const config = { 26 | env: STAGE, 27 | port: process.env.PORT || 4000, 28 | db_host: process.env.POSTGRES_HOST || "localhost", 29 | db_port: Number(process.env.POSTGRES_PORT) || 5432, 30 | db_user: process.env.POSTGRES_USER || "postgres", 31 | db_password: process.env.POSTGRES_PASSWORD || "postgres", 32 | db_name: process.env.POSTGRES_DB || "postgres", 33 | supabaseUrl: process.env.SUPABASE_URL || "https://example.supabase.co", 34 | supabaseKey: process.env.SUPABASE_PK || "example-key", 35 | jwtSecret: process.env.SUPABASE_AUTH_JWT_SECRET || "super-secret-key-that-should-be-replaced" 36 | }; 37 | -------------------------------------------------------------------------------- /src/cors.ts: -------------------------------------------------------------------------------- 1 | import { logger } from "./helpers/index.ts"; 2 | 3 | export const whitelist: RegExp[] = [ 4 | /^https?:\/\/localhost:3000$/, 5 | /^https?:\/\/example\.com$/, 6 | /^https?:\/\/subdomain\.example\.com$/ 7 | // Add more patterns as needed 8 | ]; 9 | 10 | export const corsOptions = { 11 | origin: function (origin: string | undefined, callback: (a: null | Error, b?: boolean) => void): void { 12 | // Allows an undefined origin. 13 | const isOriginAllowed = origin ? whitelist.some((pattern) => pattern.test(origin)) : true; 14 | logger.debug("cors Origin:", origin); 15 | 16 | if (isOriginAllowed) { 17 | callback(null, true); 18 | } else { 19 | callback(new Error("Not allowed by CORS")); 20 | } 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /src/features.ts: -------------------------------------------------------------------------------- 1 | import type { Stage } from "./config.ts"; 2 | import { STAGE } from "./config.ts"; 3 | import { logger } from "./helpers/index.ts"; 4 | 5 | const FEATURE_FLAGS = { 6 | Example: "Example", 7 | Sentry: "Sentry" 8 | } as const; 9 | 10 | export type FeatureFlag = keyof typeof FEATURE_FLAGS; 11 | export type FeatureFlagValue = (typeof FEATURE_FLAGS)[FeatureFlag]; 12 | 13 | const getFeatureFlag = (feature: FeatureFlag, stage: Stage): boolean => { 14 | const envVar = `FEATURE_FLAG_${feature.toUpperCase()}_${stage.toUpperCase()}`; 15 | return process.env[envVar] === "true"; 16 | }; 17 | 18 | export const hasFeatureFlag = (feature: FeatureFlag, stage: Stage): boolean => { 19 | return getFeatureFlag(feature, stage); 20 | }; 21 | 22 | export const isSentryEnabled = hasFeatureFlag(FEATURE_FLAGS.Sentry, STAGE); 23 | logger.info(`FEATURE FLAG - ${FEATURE_FLAGS.Sentry} is enabled: ${isSentryEnabled}`); 24 | -------------------------------------------------------------------------------- /src/handlers/accounts/accounts.handlers.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response } from "express"; 2 | import { db } from "@/services/db/drizzle.ts"; 3 | import { logger, gatewayResponse } from "@/helpers/index.ts"; 4 | import { accounts, uuidSchema, type AccountSelectType, type AccountWithRelations } from "@/schema.ts"; 5 | import { createDbAccount, getAccountWithRelations } from "./accounts.methods.ts"; 6 | 7 | export async function getAccounts(_req: Request, res: Response): Promise { 8 | const result = await db.select().from(accounts).execute(); 9 | 10 | logger.info({ msg: `Fetched accounts: ${result.length}` }); 11 | 12 | const response = gatewayResponse().success(200, result); 13 | 14 | res.status(response.code).send(response); 15 | } 16 | 17 | export async function getAccount(req: Request, res: Response): Promise { 18 | uuidSchema.parse({ uuid: req.params.id }); 19 | 20 | if (!req.params.id) { 21 | throw new Error("UUID is required"); 22 | } 23 | 24 | const result = await getAccountWithRelations(req.params.id); 25 | 26 | if (!result) { 27 | throw new Error("Account not found"); 28 | } 29 | 30 | logger.info({ msg: `Fetched account with UUID ${req.params.id}` }); 31 | 32 | const response = gatewayResponse().success(200, result); 33 | 34 | res.status(response.code).send(response); 35 | } 36 | 37 | export async function createAccount(req: Request, res: Response): Promise { 38 | const { fullName, phone, email } = req.body; 39 | 40 | logger.info({ msg: `Creating account...` }); 41 | 42 | const accountId = await createDbAccount({ fullName, phone, email }); 43 | 44 | const response = gatewayResponse().success(200, accountId); 45 | 46 | res.status(response.code).send(response); 47 | } 48 | 49 | export async function updateAccount(_req: Request, res: Response): Promise { 50 | res.status(200).send("updated account"); 51 | } 52 | -------------------------------------------------------------------------------- /src/handlers/accounts/accounts.methods.ts: -------------------------------------------------------------------------------- 1 | import { logger } from "@/helpers/index.ts"; 2 | import { 3 | accountInsertSchema, 4 | accounts, 5 | accountSelectSchema, 6 | uuidSchema, 7 | type AccountInsertType, 8 | type AccountSelectType, 9 | type AccountWithRelations 10 | } from "@/schema.ts"; 11 | import { db } from "@/services/db/drizzle.ts"; 12 | import { eq } from "drizzle-orm"; 13 | 14 | export async function createDbAccount(account: AccountInsertType): Promise { 15 | accountInsertSchema.parse(account); 16 | 17 | const response = await db.insert(accounts).values(account).returning(); 18 | 19 | const result = response[0]; 20 | 21 | if (!result) { 22 | throw new Error("Unable to create account"); 23 | } 24 | 25 | logger.info(`Created account with UUID: ${result.uuid}`); 26 | 27 | return result.uuid; 28 | } 29 | /** 30 | * Get account by ID. 31 | * @param accountId - The UUID of the account to retrieve. 32 | * @returns The account object. 33 | */ 34 | export async function getAccountById(accountId: string): Promise { 35 | uuidSchema.parse({ uuid: accountId }); 36 | 37 | const equals = eq(accounts.uuid, accountId); 38 | const result = await db.select().from(accounts).where(equals).execute(); 39 | 40 | accountSelectSchema.parse(result); 41 | logger.info(`Retrieved account with UUID: ${accountId}`); 42 | 43 | if (result.length === 0) { 44 | throw new Error("Account not found"); 45 | } 46 | 47 | if (result.length > 1) { 48 | throw new Error("Multiple accounts found"); 49 | } 50 | 51 | return result; 52 | } 53 | 54 | export async function getAccountWithRelations(accountId: string): Promise { 55 | uuidSchema.parse({ uuid: accountId }); 56 | 57 | const equals = eq(accounts.uuid, accountId); 58 | 59 | const result = await db.query.accounts.findFirst({ 60 | where: equals, 61 | with: { 62 | workspaces: true, 63 | profiles: { 64 | columns: { 65 | uuid: true, 66 | name: true, 67 | workspaceId: true 68 | } 69 | } 70 | } 71 | }); 72 | 73 | if (!result) { 74 | throw new Error("Account not found"); 75 | } 76 | 77 | return result; 78 | } 79 | -------------------------------------------------------------------------------- /src/handlers/auth/auth.handlers.ts: -------------------------------------------------------------------------------- 1 | import { supabase } from "@/services/supabase.ts"; 2 | import type { NextFunction, Request, Response } from "express"; 3 | import { logger, gatewayResponse } from "@/helpers/index.ts"; 4 | import { createDbAccount } from "@/handlers/accounts/accounts.methods.ts"; 5 | import type { User } from "@supabase/supabase-js"; 6 | import { asyncHandler } from "@/helpers/request.ts"; 7 | 8 | export const signUpWithSupabase = async (email: string, password: string): Promise => { 9 | const { data, error } = await supabase.auth.signUp({ 10 | email, 11 | password 12 | }); 13 | 14 | if (error || !data?.user) { 15 | logger.error({ error }, "Unable to sign up with Supabase"); 16 | return new Error("Unable to sign up", { 17 | cause: error 18 | }); 19 | } 20 | 21 | return data.user; 22 | }; 23 | 24 | export const signUp = asyncHandler(async (req: Request, res: Response): Promise => { 25 | const { email, password, fullName, phone } = req.body; 26 | 27 | const user = await signUpWithSupabase(email, password); 28 | 29 | if (!user || user instanceof Error) { 30 | // Don't expose the original error to the client. This is a security risk. 31 | // eg "code": "user_already_exists" 32 | // We also don't want to throw errors in the handler, because they will be caught by the asyncHandler and reported to Sentry. 33 | const response = gatewayResponse().error(401, Error("Unable to sign up"), "Unable to sign up"); 34 | res.status(response.code).send(response); 35 | return; 36 | } 37 | 38 | logger.info({ msg: `User signed up with id: ${user.id}` }); 39 | 40 | // Let Supabase provide us the UUID for the account. 41 | const dbAccountId = await createDbAccount({ email, fullName, phone, uuid: user.id }); 42 | 43 | const response = gatewayResponse().success(200, `Account created in DB with id: ${dbAccountId}`); 44 | 45 | res.status(response.code).send(response); 46 | }); 47 | 48 | export const signInWithPassword = asyncHandler( 49 | async (req: Request, res: Response, next: NextFunction): Promise => { 50 | try { 51 | const { email, password } = req.body; 52 | 53 | const { data, error } = await supabase.auth.signInWithPassword({ 54 | email, 55 | password 56 | }); 57 | 58 | if (error) { 59 | // Don't expose the original error to the client. This is a security risk. 60 | // We also don't want to throw errors in the handler, because they will be caught by the asyncHandler and reported to Sentry. 61 | logger.error({ error }, "Unable to signInWithPassword"); 62 | const response = gatewayResponse().error(401, Error("Unable to sign in"), "Unable to sign in"); 63 | res.status(response.code).send(response); 64 | return; 65 | } 66 | 67 | logger.info("User signed in", 200, data.user.id); 68 | const response = gatewayResponse().success(200, data); 69 | 70 | res.status(response.code).send(response); 71 | } catch (err) { 72 | next(err); 73 | } 74 | } 75 | ); 76 | -------------------------------------------------------------------------------- /src/handlers/auth/auth.methods.ts: -------------------------------------------------------------------------------- 1 | import jwt from "jsonwebtoken"; 2 | import { config } from "../../config.ts"; 3 | import { logger } from "@/helpers/index.ts"; 4 | 5 | export const verifyToken = async ( 6 | token: string 7 | ): Promise<{ 8 | sub: string; 9 | } | null> => { 10 | try { 11 | return jwt.verify(token, config.jwtSecret) as { sub: string }; 12 | } catch (err) { 13 | logger.error("Token validation failed:", err); 14 | return null; 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /src/handlers/memberships/memberships.handlers.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response } from "express"; 2 | import { gatewayResponse, logger } from "@/helpers/index.ts"; 3 | import { createMembership } from "./memberships.methods.ts"; 4 | import { asyncHandler } from "@/helpers/request.ts"; 5 | 6 | export const createMembershipHandler = asyncHandler(async (req: Request, res: Response): Promise => { 7 | const { workspaceId, accountId, role } = req.body; 8 | 9 | logger.info({ msg: `Creating membership for ${accountId} in ${workspaceId} as ${role}` }); 10 | 11 | const membership = await createMembership(workspaceId, accountId, role); 12 | 13 | const response = gatewayResponse().success(200, membership, "Membership created"); 14 | 15 | res.status(response.code).send(response); 16 | }); 17 | -------------------------------------------------------------------------------- /src/handlers/memberships/memberships.methods.ts: -------------------------------------------------------------------------------- 1 | import { logger } from "@/helpers/index.ts"; 2 | import type { Role } from "@/helpers/permissions.ts"; 3 | import { workspaceMemberships, type WorkspaceMembershipInsertType } from "@/schema.ts"; 4 | import { db } from "@/services/db/drizzle.ts"; 5 | import { and, eq } from "drizzle-orm"; 6 | 7 | // Type guard function 8 | export function isValidRole(role: string): role is "admin" | "user" { 9 | return ["admin", "user"].includes(role); 10 | } 11 | export async function createMembership( 12 | workspaceId: string, 13 | accountId: string, 14 | role: Role 15 | ): Promise { 16 | if (!isValidRole(role)) { 17 | logger.warn({ msg: `Invalid role provided: ${role}` }); 18 | throw new Error("Invalid role"); 19 | } 20 | 21 | logger.info(`Creating membership for account: ${accountId} in workspace: ${workspaceId} as role: ${role}`); 22 | 23 | const [membership] = await db 24 | .insert(workspaceMemberships) 25 | .values({ 26 | role, 27 | workspaceId, 28 | accountId 29 | }) 30 | .returning(); 31 | 32 | if (!membership) { 33 | throw new Error("Unable to create membership"); 34 | } 35 | 36 | logger.info({ msg: `Created membership for account: ${accountId} in workspace: ${workspaceId} as role: ${role}` }); 37 | 38 | return membership; 39 | } 40 | 41 | /** 42 | * Check if the account is a member of the workspace. 43 | */ 44 | export async function checkMembership(accountId: string, workspaceId: string): Promise<[boolean, string]> { 45 | logger.info(`Checking membership for account: ${accountId} in workspace: ${workspaceId}`); 46 | 47 | if (!accountId || !workspaceId) { 48 | return [false, ""]; 49 | } 50 | 51 | const [result] = await db 52 | .select() 53 | .from(workspaceMemberships) 54 | .where(and(eq(workspaceMemberships.accountId, accountId), eq(workspaceMemberships.workspaceId, workspaceId))) 55 | .execute(); 56 | 57 | const isMember = (result?.accountId === accountId && result?.workspaceId === workspaceId) || false; 58 | 59 | logger.info(`Checked membership for ${accountId} in ${workspaceId}. User is [${isMember}, ${result?.role ?? ""}]`); 60 | 61 | return [isMember, result?.role ?? ""]; 62 | } 63 | -------------------------------------------------------------------------------- /src/handlers/profiles/profiles.handlers.ts: -------------------------------------------------------------------------------- 1 | import { gatewayResponse, logger } from "@/helpers/index.ts"; 2 | import { profiles, profileInsertSchema } from "@/schema.ts"; 3 | import { db } from "@/services/db/drizzle.ts"; 4 | import { eq } from "drizzle-orm"; 5 | import type { Request, Response } from "express"; 6 | import { getProfilesByAccountId } from "./profiles.methods.ts"; 7 | import { asyncHandler } from "@/helpers/request.ts"; 8 | 9 | export const getProfiles = asyncHandler(async (req: Request, res: Response): Promise => { 10 | try { 11 | const { accountId } = req; 12 | logger.info(`Fetching profiles for account: ${accountId}`); 13 | 14 | if (!accountId) { 15 | throw new Error("Account id is required"); 16 | } 17 | 18 | logger.info({ msg: `Fetching profiles for account: ${accountId}` }); 19 | 20 | const result = await getProfilesByAccountId(accountId); 21 | 22 | const response = gatewayResponse().success(200, result, `Fetched profiles for account: ${accountId}`); 23 | 24 | res.status(response.code).send(response); 25 | return; 26 | } catch (err) { 27 | const response = gatewayResponse().error(400, err as Error, "Unable to fetch profiles for account"); 28 | 29 | res.status(response.code).send(response); 30 | return; 31 | } 32 | }); 33 | 34 | export const getAllProfiles = async (_req: Request, res: Response): Promise => { 35 | try { 36 | const result = await db.select().from(profiles).execute(); 37 | 38 | logger.info({ msg: `Fetching profiles: ${result.length}` }); 39 | 40 | const response = gatewayResponse().success(200, result, "Fetched profiles"); 41 | 42 | res.status(response.code).send(response); 43 | return; 44 | } catch (err) { 45 | const response = gatewayResponse().error(400, err as Error, "Unable to fetch all profiles"); 46 | 47 | res.status(response.code).send(response); 48 | return; 49 | } 50 | }; 51 | 52 | export const getProfile = async (req: Request, res: Response): Promise => { 53 | try { 54 | if (!req.params.id) { 55 | throw new Error("Profile id is required"); 56 | } 57 | 58 | logger.info({ msg: `Fetching profile: ${req.params.id}` }); 59 | 60 | const equals = eq(profiles.uuid, req.params.id); 61 | 62 | const profile = await db.select().from(profiles).where(equals).execute(); 63 | 64 | const response = gatewayResponse().success(200, profile, "Fetched profile"); 65 | 66 | res.status(response.code).send(response); 67 | return; 68 | } catch (err) { 69 | const response = gatewayResponse().error(400, err as Error, "Unable to fetch profile"); 70 | 71 | res.status(response.code).send(response); 72 | return; 73 | } 74 | }; 75 | 76 | export const updateProfile = async (req: Request, res: Response): Promise => { 77 | try { 78 | if (!req.params.id) { 79 | throw new Error("Profile id is required"); 80 | } 81 | 82 | profileInsertSchema.parse(req.body); 83 | 84 | const equals = eq(profiles.uuid, req.params.id); 85 | 86 | const result = await db.update(profiles).set(req.body).where(equals).execute(); 87 | 88 | logger.info({ msg: `Updating profile: ${req.params.id}` }); 89 | 90 | const response = gatewayResponse().success(200, result, "Updated profile"); 91 | 92 | res.status(response.code).send(response); 93 | return; 94 | } catch (err) { 95 | const response = gatewayResponse().error(400, err as Error, "Unable to update profile"); 96 | 97 | res.status(response.code).send(response); 98 | return; 99 | } 100 | }; 101 | -------------------------------------------------------------------------------- /src/handlers/profiles/profiles.methods.ts: -------------------------------------------------------------------------------- 1 | import { logger } from "@/helpers/index.ts"; 2 | import { profileInsertSchema, profiles, uuidSchema, type ProfileInsertType, type ProfileSelectType } from "@/schema.ts"; 3 | import { db } from "@/services/db/drizzle.ts"; 4 | import { eq, type InferInsertModel } from "drizzle-orm"; 5 | 6 | export async function createDbProfile(profile: InferInsertModel): Promise { 7 | logger.info({ msg: `Creating profile for workspace id: ${profile.workspaceId}` }); 8 | 9 | profileInsertSchema.parse(profile); 10 | 11 | const [result] = await db.insert(profiles).values(profile).returning(); 12 | 13 | if (!result) { 14 | throw new Error("Unable to create profile"); 15 | } 16 | 17 | logger.info({ msg: `Created profile for workspace id: ${profile.workspaceId}` }); 18 | 19 | return result; 20 | } 21 | 22 | export async function getProfileById(profileId: string): Promise { 23 | uuidSchema.parse({ uuid: profileId }); 24 | 25 | const equals = eq(profiles.uuid, profileId); 26 | 27 | const result = await db.select().from(profiles).where(equals).execute(); 28 | 29 | return result; 30 | } 31 | 32 | export async function getProfilesByAccountId(accountId: string): Promise { 33 | uuidSchema.parse({ uuid: accountId }); 34 | 35 | const equals = eq(profiles.accountId, accountId); 36 | 37 | const relations = await db.query.profiles.findMany({ 38 | where: equals, 39 | with: { 40 | account: true, 41 | workspace: true 42 | } 43 | }); 44 | 45 | return relations; 46 | } 47 | 48 | export const hasExistingProfile = async ({ 49 | accountId, 50 | workspaceId 51 | }: { 52 | accountId: string; 53 | workspaceId: string; 54 | }): Promise => { 55 | const profiles = await getProfilesByAccountId(accountId); 56 | 57 | const result = profiles.find((profile) => profile.workspaceId === workspaceId); 58 | 59 | logger.debug({ msg: `Profile exists for workspace: ${!!result}`, result }); 60 | 61 | return !!result; 62 | }; 63 | -------------------------------------------------------------------------------- /src/handlers/workspaces/workspaces.handlers.ts: -------------------------------------------------------------------------------- 1 | import { workspaces, uuidSchema, accounts } from "@/schema.ts"; 2 | import type { Request, Response } from "express"; 3 | import { db } from "@/services/db/drizzle.ts"; 4 | import { logger, gatewayResponse } from "@/helpers/index.ts"; 5 | import { eq } from "drizzle-orm"; 6 | import { createDbWorkspace } from "./workspaces.methods.ts"; 7 | import { createMembership } from "../memberships/memberships.methods.ts"; 8 | import { createDbProfile } from "../profiles/profiles.methods.ts"; 9 | import { asyncHandler } from "@/helpers/request.ts"; 10 | 11 | /** 12 | * Creates a new workspace for the current account and 13 | * creates a workspace membership for the account with an admin role. 14 | */ 15 | export const createWorkspace = asyncHandler(async (req: Request, res: Response): Promise => { 16 | const { name, description } = req.body; 17 | const { accountId } = req; 18 | 19 | if (!accountId) { 20 | throw new Error("Account id is required"); 21 | } 22 | 23 | logger.info({ msg: `Creating workspace ${name} for ${accountId}` }); 24 | 25 | const workspace = await createDbWorkspace({ name, accountId, description }); 26 | 27 | const membership = await createMembership(workspace.uuid, accountId, "admin"); 28 | 29 | const [account] = await db.select().from(accounts).where(eq(accounts.uuid, accountId)).execute(); 30 | 31 | if (!account) { 32 | throw new Error("DB User not found"); 33 | } 34 | 35 | const profile = await createDbProfile({ 36 | name: account.fullName, 37 | accountId, 38 | workspaceId: workspace.uuid 39 | }); 40 | 41 | const response = gatewayResponse().success( 42 | 200, 43 | { 44 | workspace, 45 | profile, 46 | membership 47 | }, 48 | "Created workspace" 49 | ); 50 | res.status(response.code).send(response); 51 | }); 52 | 53 | export const fetchWorkspace = asyncHandler(async (req: Request, res: Response): Promise => { 54 | uuidSchema.parse({ uuid: req.params.id }); 55 | 56 | logger.info({ msg: `Fetching workspace: ${req.params.id}` }); 57 | 58 | if (!req.params.id) { 59 | throw new Error("Workspace id is required"); 60 | } 61 | 62 | const equals = eq(workspaces.uuid, req.params.id); 63 | 64 | const relations = await db.query.workspaces.findFirst({ 65 | where: equals, 66 | with: { 67 | profiles: { 68 | columns: { 69 | uuid: true, 70 | name: true 71 | } 72 | } 73 | } 74 | }); 75 | 76 | const response = gatewayResponse().success(200, relations, "Fetched workspace"); 77 | 78 | res.status(response.code).send(response); 79 | }); 80 | 81 | export const fetchWorkspaces = asyncHandler(async (_req: Request, res: Response): Promise => { 82 | const result = await db.select().from(workspaces).execute(); 83 | 84 | logger.info({ msg: `Fetching workspaces: ${result.length}` }); 85 | 86 | const response = gatewayResponse().success(200, result, "Fetched workspaces"); 87 | 88 | res.status(response.code).send(response); 89 | }); 90 | 91 | export const fetchWorkspacesByAccountId = asyncHandler(async (req: Request, res: Response): Promise => { 92 | const { accountId } = req; 93 | 94 | if (!accountId) { 95 | throw new Error("Account id is required"); 96 | } 97 | 98 | logger.info({ msg: `Fetching workspaces for account: ${accountId}` }); 99 | 100 | const equals = eq(workspaces.accountId, accountId); 101 | const result = await db.select().from(workspaces).where(equals).execute(); 102 | 103 | if (result.length === 0) { 104 | const response = gatewayResponse().error(400, new Error("No workspaces found"), "Unable to fetch workspaces"); 105 | res.status(response.code).send(response); 106 | return; 107 | } 108 | 109 | const response = gatewayResponse().success(200, result, `Fetched workspaces: ${result.length}`); 110 | 111 | res.status(response.code).send(response); 112 | }); 113 | 114 | export async function updateWorkspace(_req: Request, res: Response): Promise { 115 | res.status(200).send("updateWorkspace"); 116 | } 117 | 118 | export async function inviteMembers(_req: Request, res: Response): Promise { 119 | // TODO have to be existing users; it's just adding a user with a role. 120 | // TODO check the person making the request has the correct permissions to add users and set roles. 121 | res.status(200).send("inviteMembers"); 122 | } 123 | -------------------------------------------------------------------------------- /src/handlers/workspaces/workspaces.methods.ts: -------------------------------------------------------------------------------- 1 | import { logger } from "@/helpers/index.ts"; 2 | import { workspaceInsertSchema, workspaces, type WorkspaceInsertType, type WorkspaceSelectType } from "@/schema.ts"; 3 | import { db } from "@/services/db/drizzle.ts"; 4 | 5 | export const createDbWorkspace = async (workspace: WorkspaceInsertType): Promise => { 6 | const { name, accountId, description } = workspace; 7 | 8 | workspaceInsertSchema.parse({ name, accountId, description }); 9 | 10 | const [result] = await db.insert(workspaces).values({ name, accountId, description }).returning(); 11 | 12 | if (!result) { 13 | throw new Error("Unable to create workspace"); 14 | } 15 | 16 | logger.info({ msg: `Created workspace ${name} for ${accountId}` }); 17 | 18 | return result; 19 | }; 20 | -------------------------------------------------------------------------------- /src/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export const test = 1234; 2 | 3 | export { logger } from "./logger.ts"; 4 | 5 | export * as permissions from "./permissions.ts"; 6 | export type { Route } from "./permissions.ts"; 7 | 8 | export { gatewayResponse } from "./response.ts"; 9 | 10 | export * as strings from "./strings/strings.ts"; 11 | -------------------------------------------------------------------------------- /src/helpers/logger.ts: -------------------------------------------------------------------------------- 1 | import { pino } from "pino"; 2 | 3 | export const logger = pino({ level: "debug" }, process.stdout); 4 | -------------------------------------------------------------------------------- /src/helpers/permissions.ts: -------------------------------------------------------------------------------- 1 | import { logger } from "./index.ts"; 2 | 3 | export const API_ROUTES = { 4 | root: "/", 5 | login: "/login", 6 | signUp: "/signup", 7 | accounts: "/accounts", 8 | accountById: "/accounts/:id", 9 | profiles: "/profiles", 10 | profileById: "/profiles/:id", 11 | workspaces: "/workspaces", 12 | workspaceById: "/workspaces/:id" 13 | } as const; 14 | 15 | export type RouteName = keyof typeof API_ROUTES; 16 | 17 | export type Route = (typeof API_ROUTES)[RouteName]; 18 | 19 | export type Routes = Route[]; 20 | 21 | export const ROLES = { 22 | Admin: "admin", 23 | User: "user", 24 | Owner: "owner" 25 | } as const; 26 | 27 | export type Role = (typeof ROLES)[keyof typeof ROLES] | ""; 28 | 29 | export type Method = "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; 30 | 31 | export type Claims = [Role, Method]; 32 | 33 | export type ResourcePermissions = { 34 | [Method: string]: Role; 35 | }; 36 | 37 | export type ResourceMetadata = { 38 | // Defines if the route requires authentication. 39 | // If true, the route is only accessible to authenticated users. 40 | authenticated: boolean; 41 | // A super user only route. 42 | // If true, the route is only accessible to super users. 43 | super?: boolean; 44 | }; 45 | 46 | export type ResourceWithMeta = { 47 | permissions: ResourcePermissions; 48 | } & ResourceMetadata; 49 | 50 | export type PermissionsMap = Map; 51 | 52 | export const permissions: PermissionsMap = new Map(); 53 | 54 | permissions.set(API_ROUTES.root, { permissions: {}, authenticated: false }); 55 | permissions.set(API_ROUTES.login, { permissions: {}, authenticated: false }); 56 | permissions.set(API_ROUTES.signUp, { permissions: {}, authenticated: false }); 57 | 58 | permissions.set(API_ROUTES.accounts, { 59 | permissions: { GET: "", POST: "" }, 60 | authenticated: true, 61 | super: true 62 | }); 63 | 64 | permissions.set(API_ROUTES.accountById, { 65 | permissions: { GET: ROLES.Owner, POST: ROLES.Owner, PATCH: ROLES.Owner }, 66 | authenticated: true 67 | }); 68 | 69 | permissions.set(API_ROUTES.profiles, { 70 | permissions: { GET: "" }, 71 | authenticated: true 72 | }); 73 | 74 | permissions.set(API_ROUTES.profileById, { 75 | permissions: { GET: ROLES.Owner, POST: ROLES.Owner, PATCH: ROLES.Owner }, 76 | authenticated: true 77 | }); 78 | 79 | permissions.set(API_ROUTES.workspaces, { 80 | permissions: { GET: "", POST: "" }, 81 | authenticated: true 82 | }); 83 | 84 | permissions.set(API_ROUTES.workspaceById, { 85 | permissions: { GET: ROLES.User }, 86 | authenticated: true 87 | }); 88 | 89 | logger.info(permissions, "route permissions set"); 90 | 91 | /** 92 | * This validates that permissions are set for all routes 93 | * in the permissions map. 94 | */ 95 | export const hasRoutesWithNoPermissionsSet = (routes: Routes, permissions: PermissionsMap): boolean => { 96 | const permissionRoutes = [...permissions.keys()]; 97 | 98 | const hasInvalidRoute = routes.some((route) => { 99 | return !permissionRoutes.includes(route); 100 | }); 101 | 102 | return hasInvalidRoute; 103 | }; 104 | 105 | const hasInvalidRoute = hasRoutesWithNoPermissionsSet(Object.values(API_ROUTES), permissions); 106 | 107 | if (hasInvalidRoute) { 108 | throw new Error("There are routes without permissions set."); 109 | } 110 | -------------------------------------------------------------------------------- /src/helpers/request.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response, NextFunction } from "express"; 2 | 3 | // An async handler that passes any error to the next function 4 | // to be handled by global middleware. 5 | export const asyncHandler = 6 | (fn: (req: Request, res: Response, next: NextFunction) => Promise) => 7 | (req: Request, res: Response, next: NextFunction): void => { 8 | fn(req, res, next).catch(next); 9 | }; 10 | -------------------------------------------------------------------------------- /src/helpers/response.ts: -------------------------------------------------------------------------------- 1 | import { logger } from "./logger.ts"; 2 | 3 | interface ErrorResponse { 4 | code: number; 5 | error: string; 6 | message: string; 7 | } 8 | 9 | interface SuccessResponse { 10 | code: number; 11 | data: T; 12 | message: string; 13 | } 14 | 15 | interface ReturnType { 16 | error: (code: number, error: Error, message: string) => ErrorResponse; 17 | success: (code: number, data: T, message?: string) => SuccessResponse; 18 | } 19 | 20 | /** 21 | * 22 | * Create a response object with error and success methods 23 | * 24 | * @example success response 25 | * const response = gatewayResponse<{ data: string }>().success(200, { data: "data" }); 26 | * 27 | * @example error response 28 | * const response = gatewayResponse().error(500, new Error("error"), "Internal Server Error"); 29 | * 30 | */ 31 | export const gatewayResponse = (): ReturnType => { 32 | return { 33 | error: (code: number, error: Error, message: string): ErrorResponse => { 34 | logger.error({ code, msg: message, err: error }); 35 | 36 | return { 37 | code, 38 | error: error.message, 39 | message 40 | }; 41 | }, 42 | success: (code: number, data: T, message = "success"): SuccessResponse => { 43 | logger.info({ code, msg: message }); 44 | 45 | return { 46 | code, 47 | data, 48 | message 49 | }; 50 | } 51 | }; 52 | }; 53 | -------------------------------------------------------------------------------- /src/helpers/strings/strings.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import { camelCase, capitalize, pascalCase, titleCase } from "./strings.ts"; 4 | 5 | describe("camelCase", () => { 6 | it("should convert a string to camelCase", () => { 7 | expect(camelCase("this is a string")).toBe("thisIsAString"); 8 | }); 9 | }); 10 | 11 | describe("capitalize", () => { 12 | it("should capitalize the first letter of a string", () => { 13 | expect(capitalize("this is a string")).toBe("This is a string"); 14 | }); 15 | }); 16 | 17 | describe("pascalCase", () => { 18 | it("should convert a string to PascalCase", () => { 19 | expect(pascalCase("this is a string")).toBe("ThisIsAString"); 20 | }); 21 | }); 22 | 23 | describe("titleCase", () => { 24 | it("should convert a string to Title Case", () => { 25 | expect(titleCase("this is a string")).toBe("This Is A String"); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/helpers/strings/strings.ts: -------------------------------------------------------------------------------- 1 | export const camelCase = (str: string): string => { 2 | return str 3 | .split(" ") 4 | .map((word, index) => { 5 | if (index === 0) { 6 | return word.toLowerCase(); 7 | } 8 | return word.charAt(0).toUpperCase() + word.slice(1); 9 | }) 10 | .join(""); 11 | }; 12 | 13 | export const capitalize = (str: string): string => { 14 | return str.charAt(0).toUpperCase() + str.slice(1); 15 | }; 16 | 17 | export const pascalCase = (str: string): string => { 18 | return capitalize(camelCase(str)); 19 | }; 20 | 21 | export const titleCase = (str: string): string => { 22 | return str 23 | .split(" ") 24 | .map((word) => capitalize(word)) 25 | .join(" "); 26 | }; 27 | -------------------------------------------------------------------------------- /src/middleware/errorHandler.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from "@sentry/node"; 2 | import type { NextFunction, Request, Response } from "express"; 3 | import { gatewayResponse } from "@/helpers/index.ts"; 4 | 5 | // Global error handling middleware. 6 | export const errorHandler = (err: Error, _req: Request, res: Response, _next: NextFunction): void => { 7 | Sentry.captureException(err); 8 | 9 | const response = gatewayResponse().error(500, err, err.message || "Internal Server Error"); 10 | 11 | res.status(response.code).send(response); 12 | }; 13 | -------------------------------------------------------------------------------- /src/middleware/isAuthenticated.ts: -------------------------------------------------------------------------------- 1 | import { logger, gatewayResponse, permissions } from "@/helpers/index.ts"; 2 | import type { Route } from "@/helpers/index.ts"; 3 | import type { NextFunction, Request, Response } from "express"; 4 | import type { Method } from "@/helpers/permissions.ts"; 5 | import { verifyToken } from "@/handlers/auth/auth.methods.ts"; 6 | 7 | const getIpFromRequest = (req: Request): string | undefined => { 8 | const ips = 9 | req.headers["cf-connecting-ip"] ?? req.headers["x-real-ip"] ?? req.headers["x-forwarded-for"] ?? req.ip ?? ""; 10 | 11 | const res = ips instanceof Array ? ips : ips.split(","); 12 | const result = res[0]?.trim(); 13 | return result; 14 | }; 15 | 16 | export const isAuthenticated = async (req: Request, res: Response, next: NextFunction): Promise => { 17 | const authHeader = req.headers["authorization"]; 18 | const token = authHeader && authHeader.split(" ")[1]; 19 | 20 | const routeKey = (req.baseUrl + req.route.path) as Route; 21 | 22 | const routeMethod = req.method as Method; 23 | 24 | const resourcePermissions = permissions.permissions.get(routeKey); 25 | const requiresAuth = (resourcePermissions && resourcePermissions.authenticated) || false; 26 | 27 | const ips = getIpFromRequest(req); 28 | 29 | logger.debug( 30 | { 31 | ips, 32 | routeKey, 33 | routeMethod, 34 | resourcePermissions, 35 | requiresAuth 36 | }, 37 | "isAuthenticated middleware" 38 | ); 39 | 40 | if (!requiresAuth) { 41 | logger.debug({ msg: "isAuthenticated: No authentication required", routeKey }); 42 | return next(); 43 | } 44 | 45 | if (!token) { 46 | logger.error({ msg: "isAuthenticated: A token is required for authentication", routeKey, routeMethod }); 47 | res.status(403).send("A token is required for authentication"); 48 | return; 49 | } 50 | 51 | try { 52 | const verifiedToken = await verifyToken(token); 53 | console.log("verifiedToken", verifiedToken); 54 | 55 | if (!verifiedToken) { 56 | throw new Error("Invalid token"); 57 | } 58 | 59 | const { sub } = verifiedToken; 60 | 61 | logger.debug({ msg: `Verified user token for accountId: ${sub}` }); 62 | 63 | // Attach user and workspace to request and verify permissions in isAuthorized middleware or to be used within route handlers. 64 | req.accountId = sub; 65 | req.workspaceId = (req.headers["x-workspace-id"] as string) ?? ""; 66 | 67 | return next(); 68 | } catch (err) { 69 | const response = gatewayResponse().error(401, err as Error, "Not Authorized"); 70 | 71 | res.status(response.code).send(response); 72 | return; 73 | } 74 | }; 75 | -------------------------------------------------------------------------------- /src/middleware/isAuthorized.ts: -------------------------------------------------------------------------------- 1 | import type { NextFunction, Request, Response } from "express"; 2 | import type { Route } from "@/helpers/index.ts"; 3 | import { ROLES, type Method } from "@/helpers/permissions.ts"; 4 | import { gatewayResponse, logger, permissions } from "@/helpers/index.ts"; 5 | import { checkMembership } from "@/handlers/memberships/memberships.methods.ts"; 6 | import { getProfileById } from "@/handlers/profiles/profiles.methods.ts"; 7 | import { db } from "@/services/db/drizzle.ts"; 8 | import { accounts } from "@/schema.ts"; 9 | import { eq } from "drizzle-orm"; 10 | 11 | const ResourceType = { 12 | ACCOUNT: "account", 13 | PROFILE: "profile" 14 | } as const; 15 | 16 | export function determineResourceType(route: Route): "" | (typeof ResourceType)[keyof typeof ResourceType] { 17 | const keys = Object.values(ResourceType); 18 | const resourceType = keys.find((key) => route.includes(key)); 19 | return resourceType ?? ""; 20 | } 21 | 22 | /** 23 | * Check if the user is the owner of a resource. 24 | */ 25 | const isOwner = async (id: string, resourceId: string, resourceType: string): Promise => { 26 | switch (resourceType) { 27 | case ResourceType.ACCOUNT: 28 | return id === resourceId; 29 | // Needs to verify the accountId associated with the profile. 30 | case ResourceType.PROFILE: { 31 | const [profile] = await getProfileById(resourceId); 32 | 33 | if (profile) { 34 | logger.debug({ msg: "isOwner: profile", id, resourceId, accountId: profile.accountId }); 35 | 36 | return profile.accountId === id; 37 | } 38 | 39 | return false; 40 | } 41 | 42 | default: 43 | return false; 44 | } 45 | }; 46 | 47 | export const isAuthorized = async (req: Request, res: Response, next: NextFunction): Promise => { 48 | try { 49 | const { accountId, workspaceId } = req; 50 | 51 | const routeMethod = req.method as Method; 52 | const routeKey = (req.baseUrl + req.route.path) as Route; 53 | 54 | logger.debug(`Authorizing for workspace id: ${workspaceId}`); 55 | logger.debug(req, "isAuthorized: req"); 56 | 57 | const resourcePermissions = permissions.permissions.get(routeKey); 58 | const resourcePermission = resourcePermissions && resourcePermissions.permissions[routeMethod]; 59 | const requiresAuth = (resourcePermissions && resourcePermissions.authenticated) || false; 60 | 61 | logger.debug( 62 | { 63 | routeKey, 64 | routeMethod, 65 | workspaceId, 66 | resourcePermissions, 67 | resourcePermission 68 | }, 69 | "isAuthorized: middleware" 70 | ); 71 | 72 | if (requiresAuth && !accountId) { 73 | logger.error({ accountId, routeKey, resourcePermission, routeMethod, workspaceId }, "Unauthorized user"); 74 | 75 | res.status(401).send("Unauthorized"); 76 | return; 77 | } 78 | 79 | // Super admin only has access to routes that have super admin permissions enabled. 80 | if (resourcePermissions?.super && accountId) { 81 | const [account] = await db.select().from(accounts).where(eq(accounts.uuid, accountId)).execute(); 82 | 83 | if (!account) { 84 | throw new Error("DB User not found"); 85 | } 86 | 87 | const { isSuperAdmin } = account; 88 | 89 | if (!isSuperAdmin) { 90 | logger.error({ routeKey, accountId, workspaceId }, "isAuthorized: Not a super admin"); 91 | 92 | throw new Error(`Forbidden: account id: ${accountId} is not a super admin`); 93 | } 94 | 95 | logger.debug({ routeKey, workspaceId, isSuperAdmin }, `isAuthorized: Super admin for account id: ${accountId}`); 96 | 97 | return next(); 98 | } 99 | 100 | if (!resourcePermission) { 101 | logger.debug({ routeKey, workspaceId }, "isAuthorized: No permissions required"); 102 | 103 | return next(); 104 | } 105 | 106 | // An owner has access to all resources they own regardless of the workspace. 107 | if (resourcePermission.includes(ROLES.Owner) && accountId) { 108 | // Check if the user is the owner of the resource 109 | const resourceId = req.params?.id || ""; 110 | const resourceType = determineResourceType(routeKey); 111 | 112 | // Some resources require a db call to check if the user is the owner. 113 | const isUserOwner = await isOwner(accountId, resourceId, resourceType); 114 | 115 | logger.debug({ routeKey, accountId, workspaceId, isUserOwner }, "isAuthorized: Owner"); 116 | 117 | if (isUserOwner) { 118 | return next(); 119 | } 120 | 121 | logger.error({ accountId, resourceId, routeKey, workspaceId }, "isAuthorized: Not the owner of the resource"); 122 | 123 | throw new Error(`Forbidden: Not the owner of the resource with id: ${req.params?.id}`); 124 | } 125 | 126 | // Ensure the user is a member of the workspace and has the required role by validating the x-workspace-id header. 127 | if (workspaceId && accountId) { 128 | const [isMember, role] = await checkMembership(accountId, workspaceId); 129 | 130 | logger.debug({ isMember, role }, "isAuthorized: checkMembership"); 131 | 132 | if (!isMember) { 133 | throw new Error(`Forbidden: Not a member of the workspace with id: ${workspaceId}`); 134 | } 135 | 136 | if (isMember && (resourcePermission.includes(role) || role === ROLES.Admin)) { 137 | return next(); 138 | } 139 | } 140 | 141 | res.status(403).json({ message: "Forbidden" }); 142 | return; 143 | } catch (err) { 144 | const response = gatewayResponse().error(403, err as Error, "Not Authorized"); 145 | 146 | res.status(response.code).json(response); 147 | return; 148 | } 149 | }; 150 | -------------------------------------------------------------------------------- /src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import type { Application } from "express"; 2 | import { isAuthenticated } from "@/middleware/isAuthenticated.ts"; 3 | import { isAuthorized } from "@/middleware/isAuthorized.ts"; 4 | import { test, permissions } from "@/helpers/index.ts"; 5 | import { signInWithPassword, signUp } from "@/handlers/auth/auth.handlers.js"; 6 | import { 7 | createWorkspace, 8 | fetchWorkspace, 9 | fetchWorkspacesByAccountId, 10 | updateWorkspace 11 | } from "@/handlers/workspaces/workspaces.handlers.ts"; 12 | import { getAccount, getAccounts, createAccount, updateAccount } from "@/handlers/accounts/accounts.handlers.ts"; 13 | import { getProfile, getProfiles } from "@/handlers/profiles/profiles.handlers.ts"; 14 | 15 | const { API_ROUTES } = permissions; 16 | 17 | export function routes(app: Application): void { 18 | app.get(API_ROUTES.root, isAuthenticated, isAuthorized, (_req, res) => { 19 | res.send(`Routes are active! route: ${API_ROUTES.root} with test ${test}`); 20 | }); 21 | app.post(API_ROUTES.login, isAuthenticated, isAuthorized, signInWithPassword); 22 | app.post(API_ROUTES.signUp, isAuthenticated, isAuthorized, signUp); 23 | 24 | app.get(API_ROUTES.accounts, isAuthenticated, isAuthorized, getAccounts); 25 | app.post(API_ROUTES.accounts, isAuthenticated, isAuthorized, createAccount); 26 | 27 | app.get(API_ROUTES.accountById, isAuthenticated, isAuthorized, getAccount); 28 | app.patch(API_ROUTES.accountById, isAuthenticated, isAuthorized, updateAccount); 29 | 30 | app.get(API_ROUTES.profiles, isAuthenticated, isAuthorized, getProfiles); 31 | 32 | app.get(API_ROUTES.profileById, isAuthenticated, isAuthorized, getProfile); 33 | 34 | app.get(API_ROUTES.workspaces, isAuthenticated, isAuthorized, fetchWorkspacesByAccountId); 35 | app.post(API_ROUTES.workspaces, isAuthenticated, isAuthorized, createWorkspace); 36 | 37 | app.get(API_ROUTES.workspaceById, isAuthenticated, isAuthorized, fetchWorkspace); 38 | app.patch(API_ROUTES.workspaceById, isAuthenticated, isAuthorized, updateWorkspace); 39 | } 40 | -------------------------------------------------------------------------------- /src/schema.ts: -------------------------------------------------------------------------------- 1 | import { pgTable, uuid, text, timestamp, varchar, boolean } from "drizzle-orm/pg-core"; 2 | import { createInsertSchema, createSelectSchema, createUpdateSchema } from "drizzle-zod"; 3 | import { z } from "zod"; 4 | import { relations } from "drizzle-orm/relations"; 5 | 6 | // Validate UUID 7 | export const uuidSchema = z.object({ uuid: z.string().uuid() }); 8 | 9 | export const accounts = pgTable("accounts", { 10 | uuid: uuid("uuid").defaultRandom().primaryKey(), 11 | fullName: text("full_name").notNull(), 12 | phone: varchar("phone", { length: 256 }), 13 | createdAt: timestamp("created_at", { precision: 6, withTimezone: true }).defaultNow(), 14 | email: text("email").unique().notNull(), 15 | isSuperAdmin: boolean("is_super_admin").default(false) 16 | }); 17 | 18 | export const accountRelations = relations(accounts, ({ many }) => ({ 19 | workspaces: many(workspaceMemberships), 20 | profiles: many(profiles) 21 | })); 22 | 23 | export const accountInsertSchema = createInsertSchema(accounts); 24 | export const accountSelectSchema = createSelectSchema(accounts); 25 | export const accountUpdateSchema = createUpdateSchema(accounts); 26 | 27 | export type AccountInsertType = z.infer; 28 | export type AccountSelectType = z.infer; 29 | 30 | export type AccountWithRelations = AccountSelectType & { 31 | workspaces: WorkspaceMembershipSelectType[]; 32 | profiles: Pick[]; 33 | }; 34 | 35 | // Workspaces belong to a user/account, has many profiles. 36 | export const workspaces = pgTable("workspaces", { 37 | uuid: uuid("uuid").defaultRandom().primaryKey(), 38 | name: text("name").notNull(), 39 | description: text("description"), 40 | createdAt: timestamp("created_at", { precision: 6, withTimezone: true }).defaultNow(), 41 | accountId: uuid("account_id").notNull() 42 | }); 43 | 44 | export const workspaceRelations = relations(workspaces, ({ one, many }) => ({ 45 | account: one(accounts, { 46 | fields: [workspaces.accountId], 47 | references: [accounts.uuid] 48 | }), 49 | profiles: many(profiles) 50 | })); 51 | 52 | export const workspaceInsertSchema = createInsertSchema(workspaces); 53 | export const workspaceSelectSchema = createSelectSchema(workspaces); 54 | 55 | export type WorkspaceInsertType = z.infer; 56 | export type WorkspaceSelectType = z.infer; 57 | 58 | export const profiles = pgTable("profiles", { 59 | uuid: uuid("uuid").defaultRandom().primaryKey(), 60 | name: text("name").notNull(), 61 | createdAt: timestamp("created_at", { precision: 6, withTimezone: true }).defaultNow(), 62 | workspaceId: uuid("workspace_id").notNull(), 63 | accountId: uuid("account_id").notNull().default("00000000-0000-0000-0000-000000000000") 64 | }); 65 | 66 | export const profileRelations = relations(profiles, ({ one }) => ({ 67 | account: one(accounts, { 68 | fields: [profiles.accountId], 69 | references: [accounts.uuid] 70 | }), 71 | workspace: one(workspaces, { 72 | fields: [profiles.workspaceId], 73 | references: [workspaces.uuid] 74 | }) 75 | })); 76 | 77 | export const profileInsertSchema = createInsertSchema(profiles); 78 | export const profileSelectSchema = createSelectSchema(profiles); 79 | 80 | export type ProfileInsertType = z.infer; 81 | export type ProfileSelectType = z.infer; 82 | 83 | export const workspaceMemberships = pgTable("workspace_memberships", { 84 | uuid: uuid("uuid").defaultRandom().primaryKey(), 85 | workspaceId: uuid("workspace_id").notNull(), 86 | accountId: uuid("account_id").notNull(), 87 | role: text("role", { enum: ["admin", "user"] }).notNull() 88 | }); 89 | 90 | export const workspaceMembershipsRelations = relations(workspaceMemberships, ({ one }) => ({ 91 | workspace: one(workspaces, { 92 | fields: [workspaceMemberships.workspaceId], 93 | references: [workspaces.uuid] 94 | }), 95 | account: one(accounts, { 96 | fields: [workspaceMemberships.accountId], 97 | references: [accounts.uuid] 98 | }) 99 | })); 100 | 101 | export const workspaceMembershipInsertSchema = createInsertSchema(workspaceMemberships); 102 | export const workspaceMembershipSelectSchema = createSelectSchema(workspaceMemberships); 103 | 104 | export type WorkspaceMembershipInsertType = z.infer; 105 | export type WorkspaceMembershipSelectType = z.infer; 106 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import cors from "cors"; 3 | import helmet from "helmet"; 4 | import cookieParser from "cookie-parser"; 5 | import { config } from "./config.ts"; 6 | import { pinoHttp } from "pino-http"; 7 | import { routes } from "./routes/index.ts"; 8 | import { logger } from "./helpers/index.ts"; 9 | import { corsOptions } from "./cors.ts"; 10 | import { errorHandler } from "./middleware/errorHandler.ts"; 11 | import { randomUUID } from "crypto"; 12 | 13 | import "./helpers/permissions.ts"; 14 | 15 | import type { Request, Response } from "express"; 16 | 17 | import "./services/sentry.ts"; // Initialize Sentry if enabled. 18 | 19 | const checkConfigIsValid = (): void => { 20 | Object.values(config).forEach((value) => { 21 | if (!value) { 22 | logger.error({ msg: "config is invalid", config }); 23 | throw new Error("config is invalid"); 24 | } 25 | }); 26 | }; 27 | 28 | checkConfigIsValid(); 29 | 30 | const app = express(); 31 | 32 | app.use( 33 | helmet({ 34 | contentSecurityPolicy: false, 35 | xDownloadOptions: false 36 | }) 37 | ); 38 | 39 | app.use(cookieParser()); 40 | 41 | app.use( 42 | pinoHttp({ 43 | logger: logger, 44 | // Logs every request. 45 | autoLogging: true, 46 | genReqId: (req) => req.headers["x-request-id"] || randomUUID(), 47 | customProps: (req, _res) => ({ 48 | accountId: (req as Request).accountId, 49 | workspaceId: req.headers["x-workspace-id"] 50 | }) 51 | }) 52 | ); 53 | 54 | // parse application/x-www-form-urlencoded 55 | app.use(express.urlencoded({ extended: true })); 56 | 57 | // parse application/json 58 | app.use(express.json()); 59 | 60 | // Apply CORS middleware to all routes before defining them 61 | app.use(cors(corsOptions)); 62 | app.options("*", cors(corsOptions)); // Pre-flight requests 63 | 64 | app.get("/health", (_req: Request, res: Response) => { 65 | const data = { 66 | uptime: process.uptime(), 67 | message: "Ok", 68 | date: new Date() 69 | }; 70 | res.status(200).send(data); 71 | }); 72 | 73 | // Define routes 74 | routes(app); 75 | 76 | // Use the global error handler after defining routes to make sure it's called last. 77 | app.use(errorHandler); 78 | 79 | export const server = app.listen(config.port, () => { 80 | logger.info(`[server]: Server is running on port: ${config.port}`); 81 | }); 82 | 83 | // Graceful shutdown 84 | process.on("SIGTERM", () => { 85 | logger.debug("SIGTERM signal received: closing HTTP server"); 86 | server.close(() => { 87 | logger.debug("HTTP server closed"); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /src/services/db/drizzle.ts: -------------------------------------------------------------------------------- 1 | import { drizzle } from "drizzle-orm/node-postgres"; 2 | import pg from "pg"; 3 | import { config } from "../../config.ts"; 4 | import { logger } from "@/helpers/index.ts"; 5 | import * as schema from "../../schema.ts"; 6 | 7 | const client = new pg.Client({ 8 | host: config.db_host, 9 | port: config.db_port, 10 | user: config.db_user, 11 | password: config.db_password, 12 | database: config.db_name 13 | }); 14 | 15 | client 16 | .connect() 17 | .then(() => { 18 | logger.info("connected to database"); 19 | }) 20 | .catch((err) => { 21 | logger.error({ msg: "database connection error", error: err }); 22 | process.exit(1); 23 | }); 24 | 25 | export const db = drizzle(client, { schema }); 26 | -------------------------------------------------------------------------------- /src/services/db/migrations.ts: -------------------------------------------------------------------------------- 1 | import { migrate } from "drizzle-orm/node-postgres/migrator"; 2 | import { db } from "./drizzle.ts"; 3 | import { logger } from "../../helpers/index.ts"; 4 | 5 | // this will automatically run needed migrations on the database 6 | // https://tone-row.com/blog/drizzle-orm-quickstart-tutorial-first-impressions 7 | migrate(db, { migrationsFolder: "./drizzle" }) 8 | .then(() => { 9 | logger.info("Migrations complete!"); 10 | process.exit(0); 11 | }) 12 | .catch((err) => { 13 | logger.error("Migrations failed!", err); 14 | process.exit(1); 15 | }); 16 | -------------------------------------------------------------------------------- /src/services/db/seed.ts: -------------------------------------------------------------------------------- 1 | import { seedAccounts } from "./seeds/accounts.ts"; 2 | import { logger } from "../../helpers/index.ts"; 3 | 4 | async function main(): Promise { 5 | logger.info("Seeding accounts..."); 6 | 7 | const args = process.argv.slice(2); 8 | 9 | const options = args 10 | .map((str) => str.replace(/^-+/, "").split("=")) 11 | .reduce<{ [key: string]: string }>((acc, curr) => { 12 | const [key, value] = curr; 13 | 14 | if (!key || !value) return acc; 15 | 16 | acc[key] = value; 17 | 18 | return acc; 19 | }, {}); 20 | 21 | // Note if you are using supabase you will need to confirm the email addresses. 22 | // Unless you add them manually and check auto confirm; 23 | // Seed users created on the local db will be auto confirmed. 24 | if (options?.supabase) { 25 | // TODO create accounts with supabase. 26 | // logger.info("Seeding users with supabase..."); 27 | // await seedAccounts(true); 28 | return; 29 | } 30 | 31 | await seedAccounts(); 32 | } 33 | 34 | try { 35 | logger.info("Seeding database..."); 36 | await main(); 37 | process.exit(0); 38 | } catch (err) { 39 | logger.error({ msg: "Error seeding database", error: err }); 40 | process.exit(1); 41 | } 42 | -------------------------------------------------------------------------------- /src/services/db/seeds/accounts.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/services/db/drizzle.ts"; 2 | import { 3 | profiles, 4 | workspaces, 5 | workspaceMemberships, 6 | accounts, 7 | type AccountInsertType, 8 | type AccountSelectType, 9 | accountInsertSchema, 10 | type WorkspaceSelectType, 11 | type ProfileSelectType, 12 | type WorkspaceMembershipInsertType 13 | } from "@/schema.ts"; 14 | import type { InferInsertModel } from "drizzle-orm"; 15 | 16 | const accountsArray: AccountInsertType[] = [ 17 | { 18 | fullName: "James D", 19 | phone: "555-555-5555", 20 | email: "james.d@example.com", 21 | isSuperAdmin: true 22 | }, 23 | { 24 | fullName: "Jane D", 25 | phone: "555-555-5555", 26 | email: "jane.d@example.com" 27 | } 28 | ]; 29 | 30 | async function createAccount(account: AccountInsertType): Promise { 31 | accountInsertSchema.parse(account); 32 | 33 | const [result]: AccountSelectType[] = await db.insert(accounts).values(account).returning(); 34 | console.log("created account: ", result); 35 | 36 | if (!result) { 37 | throw new Error("Unable to create account"); 38 | } 39 | 40 | return result; 41 | } 42 | 43 | async function createWorkspace(accountId: string, index: number): Promise { 44 | const [workspace] = await db 45 | .insert(workspaces) 46 | .values({ 47 | name: `My Workspace ${index}`, 48 | description: "This is a test workspace.", 49 | accountId: accountId 50 | }) 51 | .returning(); 52 | 53 | console.log("created workspace: ", workspace); 54 | 55 | if (!workspace) { 56 | throw new Error("Unable to create workspace"); 57 | } 58 | 59 | return workspace; 60 | } 61 | 62 | async function createProfile(workspaceId: string, accountId: string): Promise { 63 | const [profile]: ProfileSelectType[] = await db 64 | .insert(profiles) 65 | .values({ 66 | name: "JDIZM", 67 | workspaceId, 68 | accountId 69 | }) 70 | .returning(); 71 | 72 | if (!profile) { 73 | throw new Error("Unable to create profile"); 74 | } 75 | 76 | console.log("created profile: ", profile); 77 | 78 | return profile; 79 | } 80 | 81 | async function createMembership(workspaceId: string, accountId: string): Promise { 82 | const [membership] = await db 83 | .insert(workspaceMemberships) 84 | .values({ 85 | role: "admin", 86 | workspaceId, 87 | accountId 88 | }) 89 | .returning(); 90 | 91 | if (!membership) { 92 | throw new Error("Unable to create membership"); 93 | } 94 | console.log("created membership: ", membership); 95 | 96 | return membership; 97 | } 98 | 99 | export async function seedAccounts(): Promise { 100 | async function createAccounts(acc: InferInsertModel, index: number): Promise { 101 | if (!acc) { 102 | throw new Error("no account specified"); 103 | } 104 | 105 | const account = await createAccount(acc); 106 | 107 | if (!account) { 108 | throw new Error("Unable to create account"); 109 | } 110 | 111 | const workspace = await createWorkspace(account.uuid, index); 112 | 113 | if (!workspace) { 114 | throw new Error("Unable to create workspace"); 115 | } 116 | 117 | const profile = await createProfile(workspace.uuid, account.uuid); 118 | 119 | if (!profile) { 120 | throw new Error("Unable to create profile"); 121 | } 122 | 123 | const membership = await createMembership(workspace.uuid, account.uuid); 124 | 125 | if (!membership) { 126 | throw new Error("Unable to create membership"); 127 | } 128 | } 129 | 130 | await Promise.all(accountsArray.map((account, index) => createAccounts(account, index))); 131 | } 132 | -------------------------------------------------------------------------------- /src/services/sentry.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from "@sentry/node"; 2 | import { nodeProfilingIntegration } from "@sentry/profiling-node"; 3 | import { config } from "@/config.ts"; 4 | import { isSentryEnabled } from "@/features.ts"; 5 | 6 | if (isSentryEnabled) { 7 | Sentry.init({ 8 | dsn: process.env.SENTRY_DSN, 9 | integrations: [nodeProfilingIntegration()], 10 | tracesSampleRate: 1.0, 11 | profilesSampleRate: 1.0, 12 | environment: config.env 13 | }); 14 | } 15 | -------------------------------------------------------------------------------- /src/services/supabase.ts: -------------------------------------------------------------------------------- 1 | import { config } from "../config.ts"; 2 | import { createClient } from "@supabase/supabase-js"; 3 | 4 | export const supabase = createClient(config.supabaseUrl, config.supabaseKey); 5 | -------------------------------------------------------------------------------- /src/types/express.d.ts: -------------------------------------------------------------------------------- 1 | import "express"; 2 | 3 | declare module "express" { 4 | interface Request { 5 | accountId?: string; 6 | workspaceId?: string; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /supabase/.gitignore: -------------------------------------------------------------------------------- 1 | # Supabase 2 | .branches 3 | .temp 4 | .env 5 | -------------------------------------------------------------------------------- /supabase/config.toml: -------------------------------------------------------------------------------- 1 | # A string used to distinguish different Supabase projects on the same host. Defaults to the 2 | # working directory name when running `supabase init`. 3 | project_id = "supabase-express-api" 4 | 5 | [api] 6 | enabled = true 7 | # Port to use for the API URL. 8 | port = 54321 9 | # Schemas to expose in your API. Tables, views and stored procedures in this schema will get API 10 | # endpoints. `public` is always included. 11 | schemas = ["public", "graphql_public"] 12 | # Extra schemas to add to the search_path of every request. `public` is always included. 13 | extra_search_path = ["public", "extensions"] 14 | # The maximum number of rows returns from a view, table, or stored procedure. Limits payload size 15 | # for accidental or malicious requests. 16 | max_rows = 1000 17 | 18 | [api.tls] 19 | enabled = false 20 | 21 | [db] 22 | # Port to use for the local database URL. 23 | port = 54322 24 | # Port used by db diff command to initialize the shadow database. 25 | shadow_port = 54320 26 | # The database major version to use. This has to be the same as your remote database's. Run `SHOW 27 | # server_version;` on the remote database to check. 28 | major_version = 15 29 | 30 | [db.pooler] 31 | enabled = false 32 | # Port to use for the local connection pooler. 33 | port = 54329 34 | # Specifies when a server connection can be reused by other clients. 35 | # Configure one of the supported pooler modes: `transaction`, `session`. 36 | pool_mode = "transaction" 37 | # How many server connections to allow per user/database pair. 38 | default_pool_size = 20 39 | # Maximum number of client connections allowed. 40 | max_client_conn = 100 41 | 42 | [realtime] 43 | enabled = true 44 | # Bind realtime via either IPv4 or IPv6. (default: IPv4) 45 | # ip_version = "IPv6" 46 | # The maximum length in bytes of HTTP request headers. (default: 4096) 47 | # max_header_length = 4096 48 | 49 | [studio] 50 | enabled = true 51 | # Port to use for Supabase Studio. 52 | port = 54323 53 | # External URL of the API server that frontend connects to. 54 | api_url = "http://127.0.0.1" 55 | # OpenAI API Key to use for Supabase AI in the Supabase Studio. 56 | openai_api_key = "env(OPENAI_API_KEY)" 57 | 58 | # Email testing server. Emails sent with the local dev setup are not actually sent - rather, they 59 | # are monitored, and you can view the emails that would have been sent from the web interface. 60 | [inbucket] 61 | enabled = true 62 | # Port to use for the email testing server web interface. 63 | port = 54324 64 | # Uncomment to expose additional ports for testing user applications that send emails. 65 | # smtp_port = 54325 66 | # pop3_port = 54326 67 | 68 | [storage] 69 | enabled = true 70 | # The maximum file size allowed (e.g. "5MB", "500KB"). 71 | file_size_limit = "50MiB" 72 | 73 | [storage.image_transformation] 74 | enabled = true 75 | 76 | # Uncomment to configure local storage buckets 77 | # [storage.buckets.images] 78 | # public = false 79 | # file_size_limit = "50MiB" 80 | # allowed_mime_types = ["image/png", "image/jpeg"] 81 | 82 | [auth] 83 | enabled = true 84 | # The base URL of your website. Used as an allow-list for redirects and for constructing URLs used 85 | # in emails. 86 | site_url = "http://127.0.0.1:4000" 87 | # A list of *exact* URLs that auth providers are permitted to redirect to post authentication. 88 | additional_redirect_urls = ["https://127.0.0.1:4000"] 89 | # How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). 90 | jwt_expiry = 3600 91 | # If disabled, the refresh token will never expire. 92 | enable_refresh_token_rotation = true 93 | # Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. 94 | # Requires enable_refresh_token_rotation = true. 95 | refresh_token_reuse_interval = 10 96 | # Allow/disallow new user signups to your project. 97 | enable_signup = true 98 | # Allow/disallow anonymous sign-ins to your project. 99 | enable_anonymous_sign_ins = false 100 | # Allow/disallow testing manual linking of accounts 101 | enable_manual_linking = false 102 | 103 | [auth.email] 104 | # Allow/disallow new user signups via email to your project. 105 | enable_signup = true 106 | # If enabled, a user will be required to confirm any email change on both the old, and new email 107 | # addresses. If disabled, only the new email is required to confirm. 108 | double_confirm_changes = true 109 | # If enabled, users need to confirm their email address before signing in. 110 | enable_confirmations = false 111 | # Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. 112 | max_frequency = "1s" 113 | 114 | # Use a production-ready SMTP server 115 | # [auth.email.smtp] 116 | # host = "smtp.sendgrid.net" 117 | # port = 587 118 | # user = "apikey" 119 | # pass = "env(SENDGRID_API_KEY)" 120 | # admin_email = "admin@email.com" 121 | # sender_name = "Admin" 122 | 123 | # Uncomment to customize email template 124 | # [auth.email.template.invite] 125 | # subject = "You have been invited" 126 | # content_path = "./supabase/templates/invite.html" 127 | 128 | [auth.sms] 129 | # Allow/disallow new user signups via SMS to your project. 130 | enable_signup = true 131 | # If enabled, users need to confirm their phone number before signing in. 132 | enable_confirmations = false 133 | # Template for sending OTP to users 134 | template = "Your code is {{ .Code }} ." 135 | # Controls the minimum amount of time that must pass before sending another sms otp. 136 | max_frequency = "5s" 137 | 138 | # Use pre-defined map of phone number to OTP for testing. 139 | # [auth.sms.test_otp] 140 | # 4152127777 = "123456" 141 | 142 | # Configure logged in session timeouts. 143 | # [auth.sessions] 144 | # Force log out after the specified duration. 145 | # timebox = "24h" 146 | # Force log out if the user has been inactive longer than the specified duration. 147 | # inactivity_timeout = "8h" 148 | 149 | # This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. 150 | # [auth.hook.custom_access_token] 151 | # enabled = true 152 | # uri = "pg-functions:////" 153 | 154 | # Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. 155 | [auth.sms.twilio] 156 | enabled = false 157 | account_sid = "" 158 | message_service_sid = "" 159 | # DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: 160 | auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" 161 | 162 | # Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, 163 | # `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, 164 | # `twitter`, `slack`, `spotify`, `workos`, `zoom`. 165 | [auth.external.apple] 166 | enabled = false 167 | client_id = "" 168 | # DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: 169 | secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" 170 | # Overrides the default auth redirectUrl. 171 | redirect_uri = "" 172 | # Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, 173 | # or any other third-party OIDC providers. 174 | url = "" 175 | # If enabled, the nonce check will be skipped. Required for local sign in with Google auth. 176 | skip_nonce_check = false 177 | 178 | [auth.external.github] 179 | enabled = true 180 | client_id = "env(SUPABASE_AUTH_GITHUB_CLIENT_ID)" 181 | secret = "env(SUPABASE_AUTH_GITHUB_SECRET)" 182 | redirect_uri = "http://localhost:54321/auth/v1/callback" 183 | 184 | 185 | [edge_runtime] 186 | enabled = true 187 | # Configure one of the supported request policies: `oneshot`, `per_worker`. 188 | # Use `oneshot` for hot reload, or `per_worker` for load testing. 189 | policy = "oneshot" 190 | inspector_port = 8083 191 | 192 | [analytics] 193 | enabled = true 194 | port = 54327 195 | # Configure one of the supported backends: `postgres`, `bigquery`. 196 | backend = "postgres" 197 | 198 | # Experimental features may be deprecated any time 199 | [experimental] 200 | # Configures Postgres storage engine to use OrioleDB (S3) 201 | orioledb_version = "" 202 | # Configures S3 bucket URL, eg. .s3-.amazonaws.com 203 | s3_host = "env(S3_HOST)" 204 | # Configures S3 bucket region, eg. us-east-1 205 | s3_region = "env(S3_REGION)" 206 | # Configures AWS_ACCESS_KEY_ID for S3 bucket 207 | s3_access_key = "env(S3_ACCESS_KEY)" 208 | # Configures AWS_SECRET_ACCESS_KEY for S3 bucket 209 | s3_secret_key = "env(S3_SECRET_KEY)" 210 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Base options 4 | "esModuleInterop": true, 5 | "skipLibCheck": true, 6 | "allowJs": true, 7 | "resolveJsonModule": true, 8 | "moduleDetection": "force", 9 | "isolatedModules": true, 10 | "verbatimModuleSyntax": true, 11 | 12 | // Build 13 | "target": "es2022", // recommended over ESNext 14 | "module": "NodeNext", 15 | "moduleResolution": "NodeNext", 16 | "lib": ["es2022"], 17 | 18 | // Library 19 | "declaration": false, 20 | "allowImportingTsExtensions": true, // disabled when emitting declaration files 21 | "allowSyntheticDefaultImports": true, 22 | "noEmit": true, 23 | "removeComments": true, 24 | "sourceMap": true, 25 | "inlineSources": true, 26 | 27 | // Strictness 28 | "strict": true, 29 | "noUncheckedIndexedAccess": true, 30 | "noImplicitOverride": true, 31 | "noImplicitAny": true, 32 | "strictNullChecks": true, 33 | "noImplicitThis": true, 34 | "alwaysStrict": true, 35 | "noUnusedLocals": false, 36 | "noUnusedParameters": true, 37 | "noImplicitReturns": true, 38 | "noFallthroughCasesInSwitch": false, 39 | "strictPropertyInitialization": false, 40 | 41 | // Directories 42 | "outDir": "./dist", 43 | "baseUrl": "./", 44 | "paths": { 45 | "@/*": ["src/*"] 46 | }, 47 | "typeRoots": ["./src/types", "./node_modules/@types"] 48 | }, 49 | "exclude": ["node_modules", "dist", "test-package"] 50 | } 51 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from "node:url"; 2 | import { configDefaults, defineConfig } from "vitest/config"; 3 | 4 | export default defineConfig({ 5 | resolve: { 6 | alias: { 7 | "@": fileURLToPath(new URL("./src", import.meta.url)) 8 | } 9 | }, 10 | test: { 11 | environment: "node", 12 | exclude: [...configDefaults.exclude], 13 | root: fileURLToPath(new URL("./src", import.meta.url)), 14 | coverage: { 15 | provider: "v8" 16 | } 17 | } 18 | }); 19 | --------------------------------------------------------------------------------