├── .npmrc ├── apps └── backend │ ├── biome.json │ ├── turbo │ └── generators │ │ ├── package.json │ │ ├── templates │ │ ├── service │ │ │ └── service.hbs │ │ ├── api-route │ │ │ ├── resource-registration.hbs │ │ │ ├── api-test.hbs │ │ │ └── operation.hbs │ │ └── database-table │ │ │ ├── db-types.hbs │ │ │ ├── migration.hbs │ │ │ └── repository.hbs │ │ ├── config.ts │ │ └── actions │ │ ├── service.generator.ts │ │ ├── database-table.generator.ts │ │ └── api-route.generator.ts │ ├── src │ ├── db │ │ ├── migrations │ │ │ ├── README.md │ │ │ └── 0001-init.ts │ │ ├── repositories │ │ │ ├── base.repository.ts │ │ │ ├── README.md │ │ │ ├── index.ts │ │ │ ├── user-providers.repository.ts │ │ │ └── users.repository.ts │ │ ├── types │ │ │ ├── index.ts │ │ │ ├── users.db-types.ts │ │ │ └── user-providers.db-types.ts │ │ └── index.ts │ ├── services │ │ ├── index.ts │ │ ├── README.md │ │ ├── base.service.ts │ │ └── users.service.ts │ ├── utils │ │ ├── async-local-storage.ts │ │ ├── remove-query-params.ts │ │ └── logger.ts │ ├── plugins │ │ ├── index.ts │ │ └── context.plugin.ts │ ├── test-utils │ │ ├── global-teardown.ts │ │ ├── plugins │ │ │ ├── index.ts │ │ │ ├── test-headers.plugin.ts │ │ │ └── enable-test-logger.plugin.ts │ │ ├── ts-migration-transpiler.ts │ │ ├── global-setup.ts │ │ ├── test-framework │ │ │ └── index.ts │ │ └── test-server.ts │ ├── types.ts │ ├── api-lib │ │ ├── ajv-plugins.ts │ │ ├── types │ │ │ ├── enums.type.ts │ │ │ ├── index.ts │ │ │ ├── user.type.ts │ │ │ └── user-provider.type.ts │ │ ├── context.ts │ │ └── error-handler.ts │ ├── api │ │ ├── routes.ts │ │ ├── users │ │ │ ├── index.ts │ │ │ ├── __tests__ │ │ │ │ └── create-email-user.route.test.ts │ │ │ └── create-email-user.route.ts │ │ └── index.ts │ ├── index.ts │ ├── constants.ts │ └── server.ts │ ├── .env.example │ ├── tsconfig.json │ ├── kysely.config.js │ ├── vitest.config.ts │ ├── scripts │ └── generate-client-yaml.ts │ └── package.json ├── tsconfig.json ├── packages ├── backend-errors │ ├── biome.json │ ├── src │ │ ├── index.ts │ │ ├── error-codes.ts │ │ └── lib.ts │ ├── tsconfig.json │ ├── .hash-runnerrc.json │ ├── tsup.config.json │ └── package.json ├── backend-client │ ├── tsconfig.json │ ├── src │ │ ├── index.ts │ │ ├── client.gen.ts │ │ ├── sdk.gen.ts │ │ └── types.gen.ts │ ├── .hash-runnerrc.json │ ├── openapi-ts.config.ts │ ├── tsup.config.json │ ├── package.json │ └── openapi.yml └── tsconfig │ ├── package.json │ └── tsconfig.json ├── pnpm-workspace.yaml ├── .changeset ├── config.json └── README.md ├── pgadmin.json ├── commitlint.config.js ├── syncpack.config.js ├── biome.json ├── .github └── workflows │ ├── lint.yml │ ├── test.yml │ ├── release-snapshot.yml │ └── release.yml ├── lefthook.yml ├── LICENSE ├── docker-compose.yaml ├── turbo.json ├── package.json ├── .gitignore ├── CHANGELOG.md └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | side-effects-cache = false 2 | -------------------------------------------------------------------------------- /apps/backend/biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../biome.json"] 3 | } 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@internal/tsconfig/tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /packages/backend-errors/biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../biome.json"] 3 | } 4 | -------------------------------------------------------------------------------- /apps/backend/turbo/generators/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "commonjs", 3 | "private": true 4 | } 5 | -------------------------------------------------------------------------------- /apps/backend/src/db/migrations/README.md: -------------------------------------------------------------------------------- 1 | # Kysely migrations 2 | 3 | https://kysely.dev/docs/migrations 4 | -------------------------------------------------------------------------------- /packages/backend-errors/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./lib"; 2 | export { BackendErrorCodes } from "./error-codes"; 3 | -------------------------------------------------------------------------------- /packages/backend-client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@internal/tsconfig/tsconfig.json", 3 | "include": ["./src/**/*"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/backend-errors/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@internal/tsconfig/tsconfig.json", 3 | "include": ["./src/**/*"] 4 | } 5 | -------------------------------------------------------------------------------- /apps/backend/.env.example: -------------------------------------------------------------------------------- 1 | DB_HOST=localhost 2 | DB_PORT=5432 3 | DB_NAME=backend 4 | DB_USER=admin 5 | DB_PASS=testing123 6 | SERVER_PORT=3080 7 | -------------------------------------------------------------------------------- /packages/backend-client/src/index.ts: -------------------------------------------------------------------------------- 1 | // This file is auto-generated by @hey-api/openapi-ts 2 | export * from './types.gen'; 3 | export * from './sdk.gen'; -------------------------------------------------------------------------------- /apps/backend/src/services/index.ts: -------------------------------------------------------------------------------- 1 | import type { UsersService } from "@/services/users.service.js"; 2 | 3 | export interface Services { 4 | users: UsersService; 5 | } 6 | -------------------------------------------------------------------------------- /packages/backend-client/.hash-runnerrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["openapi.yml", "package.json"], 3 | "execOnChange": "pnpm run build", 4 | "hashFile": ".hashes.json" 5 | } -------------------------------------------------------------------------------- /packages/backend-errors/.hash-runnerrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**", "package.json"], 3 | "execOnChange": "pnpm run build", 4 | "hashFile": ".hashes.json" 5 | } 6 | -------------------------------------------------------------------------------- /packages/tsconfig/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@internal/tsconfig", 3 | "version": "1.0.0", 4 | "private": true, 5 | "files": [ 6 | "tsconfig.json" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /apps/backend/turbo/generators/templates/service/service.hbs: -------------------------------------------------------------------------------- 1 | import { BaseService } from "@/services/base.service.js"; 2 | 3 | export class {{name}}Service extends BaseService { 4 | // Add your service methods here 5 | } -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - apps/* 3 | - packages/* 4 | onlyBuiltDependencies: 5 | - bcrypt 6 | - better-sqlite3 7 | - core-js-pure 8 | - cpu-features 9 | - esbuild 10 | - protobufjs 11 | - ssh2 12 | -------------------------------------------------------------------------------- /apps/backend/src/utils/async-local-storage.ts: -------------------------------------------------------------------------------- 1 | import { AsyncLocalStorage } from "node:async_hooks"; 2 | import type { ILogLayer } from "loglayer"; 3 | 4 | export const asyncLocalStorage = new AsyncLocalStorage<{ logger: ILogLayer }>(); 5 | -------------------------------------------------------------------------------- /packages/backend-client/openapi-ts.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from '@hey-api/openapi-ts'; 2 | 3 | export default defineConfig({ 4 | plugins: ['@hey-api/client-fetch'], 5 | input: 'openapi.yml', 6 | output: 'src/', 7 | }); 8 | -------------------------------------------------------------------------------- /apps/backend/src/db/repositories/base.repository.ts: -------------------------------------------------------------------------------- 1 | import type { ILogLayer } from "loglayer"; 2 | 3 | export class BaseRepository { 4 | log: ILogLayer; 5 | 6 | constructor({ log }: { log: ILogLayer }) { 7 | this.log = log; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /apps/backend/src/plugins/index.ts: -------------------------------------------------------------------------------- 1 | import type { FastifyInstance } from "fastify"; 2 | import { contextPlugin } from "./context.plugin.js"; 3 | 4 | export async function plugins(fastify: FastifyInstance, _opts) { 5 | fastify.register(contextPlugin); 6 | } 7 | -------------------------------------------------------------------------------- /apps/backend/src/test-utils/global-teardown.ts: -------------------------------------------------------------------------------- 1 | export async function teardown() { 2 | if (global.dbPool) { 3 | await global.dbPool.end(); 4 | } 5 | await Promise.all(global.containers.map((container) => container.stop({ timeout: 10000 }))); 6 | } 7 | -------------------------------------------------------------------------------- /apps/backend/src/types.ts: -------------------------------------------------------------------------------- 1 | import { type Static, type TSchema, Type } from "@sinclair/typebox"; 2 | 3 | // biome-ignore lint/style/noNonNullAssertion: 4 | export const TypeRef = (schema: T) => Type.Unsafe>(Type.Ref(schema.$id!)); 5 | -------------------------------------------------------------------------------- /packages/backend-client/tsup.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "outDir": "dist", 3 | "format": ["esm", "cjs"], 4 | "splitting": true, 5 | "sourcemap": true, 6 | "clean": true, 7 | "target": ["ESNext"], 8 | "minify": false, 9 | "dts": true 10 | } 11 | -------------------------------------------------------------------------------- /packages/backend-errors/tsup.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "outDir": "dist", 3 | "format": ["esm", "cjs"], 4 | "splitting": true, 5 | "sourcemap": true, 6 | "clean": true, 7 | "target": ["ESNext"], 8 | "minify": false, 9 | "dts": true 10 | } 11 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", 3 | "changelog": ["@changesets/changelog-github", { "repo": "theogravity/fastify-starter-turbo-monorepo" }], 4 | "access": "public", 5 | "baseBranch": "main" 6 | } 7 | -------------------------------------------------------------------------------- /apps/backend/src/utils/remove-query-params.ts: -------------------------------------------------------------------------------- 1 | export function removeQueryParametersFromPath(path: string): string { 2 | // If the path includes a '?' character, split and return the first part 3 | // Otherwise, return the path as is 4 | return path.split("?")[0]; 5 | } 6 | -------------------------------------------------------------------------------- /apps/backend/src/api-lib/ajv-plugins.ts: -------------------------------------------------------------------------------- 1 | import type { Ajv } from "ajv"; 2 | import { fullFormats } from "ajv-formats/dist/formats.js"; 3 | 4 | export const ajvPlugins = [ 5 | (ajv: Ajv) => { 6 | for (const format in fullFormats) { 7 | ajv.addFormat(format, fullFormats[format]); 8 | } 9 | }, 10 | ]; 11 | -------------------------------------------------------------------------------- /apps/backend/src/services/README.md: -------------------------------------------------------------------------------- 1 | # Services 2 | 3 | Services contains the business logic of the application. They would be used by 4 | route controllers to perform operations on the database or other services. 5 | 6 | https://www.coreycleary.me/what-is-the-difference-between-controllers-and-services-in-node-rest-apis 7 | 8 | -------------------------------------------------------------------------------- /apps/backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@internal/tsconfig/tsconfig.json", 3 | "include": ["src/**/*"], 4 | "compilerOptions": { 5 | "outDir": "./dist", 6 | "module": "NodeNext", 7 | "moduleResolution": "node16", 8 | "baseUrl": "./src", 9 | "paths": { 10 | "@/*": ["*"] 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /pgadmin.json: -------------------------------------------------------------------------------- 1 | { 2 | "Servers": { 3 | "1": { 4 | "Group": "Servers", 5 | "Name": "Local", 6 | "Host": "postgres", 7 | "Port": 5432, 8 | "MaintenanceDB": "postgres", 9 | "Username": "admin", 10 | "Password": "testing123", 11 | "SSLMode": "prefer", 12 | "Favorite": true 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /apps/backend/src/db/types/index.ts: -------------------------------------------------------------------------------- 1 | // Do not remove this comment: database-table-import 2 | import type { UserProvidersTable } from "@/db/types/user-providers.db-types.js"; 3 | import type { UsersTable } from "@/db/types/users.db-types.js"; 4 | 5 | export interface Database { 6 | // Do not remove this comment: database-table-interface 7 | users: UsersTable; 8 | userProviders: UserProvidersTable; 9 | } 10 | -------------------------------------------------------------------------------- /apps/backend/src/db/repositories/README.md: -------------------------------------------------------------------------------- 1 | # Database repositories 2 | 3 | This directory contains the database repositories. Each repository is responsible for a specific entity in the database. 4 | 5 | Services would use and combine these repositories to perform operations on the database. 6 | 7 | You should not use repositories in a controller. Instead, use services to implement the behavior you need using the 8 | repositories. 9 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | rules: { 4 | 'type-enum': [ 5 | 2, 6 | 'always', 7 | [ 8 | 'feat', 9 | 'fix', 10 | 'docs', 11 | 'chore', 12 | 'style', 13 | 'refactor', 14 | 'ci', 15 | 'test', 16 | 'revert', 17 | 'perf' 18 | ], 19 | ], 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /apps/backend/src/db/repositories/index.ts: -------------------------------------------------------------------------------- 1 | // Do not remove this comment: database-table-import 2 | import type { UserProvidersRepository } from "@/db/repositories/user-providers.repository.js"; 3 | import type { UsersRepository } from "@/db/repositories/users.repository.js"; 4 | 5 | export interface Repositories { 6 | // Do not remove this comment: database-table-repository 7 | users: UsersRepository; 8 | userProviders: UserProvidersRepository; 9 | } 10 | -------------------------------------------------------------------------------- /apps/backend/src/db/types/users.db-types.ts: -------------------------------------------------------------------------------- 1 | import type { Generated, GeneratedAlways, Insertable, Selectable, Updateable } from "kysely"; 2 | 3 | export interface UsersTable { 4 | id: Generated; 5 | givenName: string; 6 | familyName: string; 7 | createdAt: GeneratedAlways; 8 | updatedAt: Generated; 9 | } 10 | 11 | export type UserDb = Selectable; 12 | export type NewUser = Insertable; 13 | export type UserUpdate = Updateable; 14 | -------------------------------------------------------------------------------- /apps/backend/src/api-lib/types/enums.type.ts: -------------------------------------------------------------------------------- 1 | import { UserProviderType } from "@/db/types/user-providers.db-types.js"; 2 | import { Type } from "@sinclair/typebox"; 3 | 4 | // Don't use Type.Enum. You won't get proper typescript types in 5 | // the client generation or Swagger UI. 6 | export const UserProviderTypeSchema = Type.String({ 7 | $id: "UserProviderType", 8 | enum: Object.values(UserProviderType), 9 | title: "Auth provider type", 10 | description: "The type of the auth provider", 11 | }); 12 | -------------------------------------------------------------------------------- /apps/backend/src/api-lib/types/index.ts: -------------------------------------------------------------------------------- 1 | import { UserProviderTypeSchema } from "@/api-lib/types/enums.type.js"; 2 | import { UserProviderSchema } from "@/api-lib/types/user-provider.type.js"; 3 | import { UserSchema } from "@/api-lib/types/user.type.js"; 4 | import type { FastifyInstance } from "fastify"; 5 | 6 | export async function apiTypes(fastify: FastifyInstance) { 7 | fastify.addSchema(UserProviderTypeSchema); 8 | fastify.addSchema(UserSchema); 9 | fastify.addSchema(UserProviderSchema); 10 | } 11 | -------------------------------------------------------------------------------- /packages/tsconfig/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2023", 4 | "lib": ["ES2023"], 5 | "sourceMap": true, 6 | "strict": true, 7 | "noImplicitAny": false, 8 | "module": "esnext", 9 | "moduleResolution": "node", 10 | "isolatedModules": true, 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "resolveJsonModule": true 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /apps/backend/src/test-utils/plugins/index.ts: -------------------------------------------------------------------------------- 1 | import { contextPlugin } from "@/plugins/context.plugin.js"; 2 | import type { FastifyInstance } from "fastify"; 3 | import { enableTestLoggerPlugin } from "./enable-test-logger.plugin.js"; 4 | import { testHeadersPlugin } from "./test-headers.plugin.js"; 5 | 6 | export async function testPlugins(fastify: FastifyInstance, _opts) { 7 | fastify.register(contextPlugin); 8 | fastify.register(enableTestLoggerPlugin); 9 | fastify.register(testHeadersPlugin); 10 | } 11 | -------------------------------------------------------------------------------- /apps/backend/src/api/routes.ts: -------------------------------------------------------------------------------- 1 | import type { FastifyInstance } from "fastify"; 2 | 3 | // Do not remove this comment: resource-imports 4 | 5 | import { registerUserRoutes } from "@/api/users/index.js"; 6 | 7 | export async function registerResourceRoutes(fastify: FastifyInstance, _opts) { 8 | fastify.register(routes, { prefix: "/" }); 9 | } 10 | 11 | async function routes(fastify: FastifyInstance) { 12 | // Do not remove this comment: resource-register 13 | 14 | fastify.register(registerUserRoutes); 15 | } 16 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /apps/backend/src/api/users/index.ts: -------------------------------------------------------------------------------- 1 | import { createEMailUserRoute } from "@/api/users/create-email-user.route.js"; 2 | import type { FastifyInstance } from "fastify"; 3 | 4 | // Do not remove this comment: route-imports 5 | 6 | export async function registerUserRoutes(fastify: FastifyInstance, _opts) { 7 | fastify.register(userRoutes, { prefix: "/users" }); 8 | } 9 | 10 | async function userRoutes(fastify: FastifyInstance) { 11 | // Do not remove this comment: route-register 12 | 13 | fastify.register(createEMailUserRoute); 14 | } 15 | -------------------------------------------------------------------------------- /apps/backend/turbo/generators/templates/api-route/resource-registration.hbs: -------------------------------------------------------------------------------- 1 | import type { FastifyInstance } from "fastify"; 2 | 3 | // Do not remove this comment: route-imports 4 | 5 | export async function register{{properCase routeResource}}Routes(fastify: FastifyInstance, _opts) { 6 | fastify.register({{routeResource}}Routes, { prefix: "/{{routeResource}}" }); 7 | } 8 | 9 | // All resource CRUD methods get registered here 10 | async function {{routeResource}}Routes(fastify: FastifyInstance) { 11 | // Do not remove this comment: route-register 12 | } 13 | -------------------------------------------------------------------------------- /apps/backend/src/api-lib/types/user.type.ts: -------------------------------------------------------------------------------- 1 | import { type Static, Type } from "@sinclair/typebox"; 2 | 3 | export const UserSchema = Type.Object( 4 | { 5 | id: Type.String({ 6 | description: "ID of the user", 7 | format: "uuid", 8 | }), 9 | givenName: Type.String({ 10 | description: "Given name of the user", 11 | }), 12 | familyName: Type.String({ 13 | description: "Family name of the user", 14 | }), 15 | }, 16 | { 17 | $id: "User", 18 | }, 19 | ); 20 | 21 | export type User = Static; 22 | -------------------------------------------------------------------------------- /apps/backend/src/test-utils/plugins/test-headers.plugin.ts: -------------------------------------------------------------------------------- 1 | import type { FastifyInstance } from "fastify"; 2 | import fp from "fastify-plugin"; 3 | 4 | async function plugin(fastify: FastifyInstance, _opts) { 5 | fastify.decorateRequest("userId"); 6 | 7 | fastify.addHook("preHandler", async (request) => { 8 | // Items marked test- are not part of our normal headers, and is used 9 | // purely for mocking purposes. 10 | 11 | // @ts-ignore 12 | request.userId = request.headers["test-user-id"]; 13 | }); 14 | } 15 | 16 | export const testHeadersPlugin = fp(plugin); 17 | -------------------------------------------------------------------------------- /apps/backend/turbo/generators/templates/database-table/db-types.hbs: -------------------------------------------------------------------------------- 1 | import type { ColumnType, Generated, GeneratedAlways, Insertable, Selectable, Updateable } from "kysely"; 2 | 3 | export interface {{properCase tableName}}sTable { 4 | id: Generated; 5 | createdAt: GeneratedAlways; 6 | updatedAt: Generated; 7 | } 8 | 9 | export type {{properCase tableName}}Db = Selectable<{{properCase tableName}}sTable>; 10 | export type New{{properCase tableName}} = Insertable<{{properCase tableName}}sTable>; 11 | export type {{properCase tableName}}Update = Updateable<{{properCase tableName}}sTable>; 12 | -------------------------------------------------------------------------------- /apps/backend/src/api-lib/types/user-provider.type.ts: -------------------------------------------------------------------------------- 1 | import { UserProviderTypeSchema } from "@/api-lib/types/enums.type.js"; 2 | import { type Static, Type } from "@sinclair/typebox"; 3 | 4 | export const UserProviderSchema = Type.Object( 5 | { 6 | userId: Type.String({ 7 | description: "ID of the user", 8 | format: "uuid", 9 | }), 10 | providerType: UserProviderTypeSchema, 11 | accountId: Type.String({ 12 | description: "The account id associated with the provider", 13 | }), 14 | }, 15 | { 16 | $id: "UserProvider", 17 | }, 18 | ); 19 | 20 | export type UserProvider = Static; 21 | -------------------------------------------------------------------------------- /apps/backend/src/db/index.ts: -------------------------------------------------------------------------------- 1 | import { DB_HOST, DB_NAME, DB_PASS, DB_PORT, DB_USER } from "@/constants.js"; 2 | import type { Database } from "@/db/types/index.js"; 3 | import { CamelCasePlugin, Kysely, PostgresDialect } from "kysely"; 4 | import pg from "pg"; 5 | 6 | export const kyselyDialect = new PostgresDialect({ 7 | pool: new pg.Pool({ 8 | database: DB_NAME, 9 | host: DB_HOST, 10 | user: DB_USER, 11 | port: DB_PORT, 12 | password: DB_PASS, 13 | }), 14 | }); 15 | 16 | export const kyselyPlugins = [new CamelCasePlugin()]; 17 | 18 | export const db = new Kysely({ 19 | dialect: kyselyDialect, 20 | plugins: kyselyPlugins, 21 | }); 22 | -------------------------------------------------------------------------------- /apps/backend/turbo/generators/templates/database-table/migration.hbs: -------------------------------------------------------------------------------- 1 | import { type Kysely, sql } from "kysely"; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema 5 | .createTable("{{snakeCase tableName}}s") 6 | .addColumn("id", "uuid", (col) => col.defaultTo(sql`gen_random_uuid()`).primaryKey()) 7 | .addColumn("created_at", "timestamptz", (col) => col.defaultTo(sql`now()`)) 8 | .addColumn("updated_at", "timestamptz", (col) => col.defaultTo(sql`now()`)) 9 | .execute(); 10 | } 11 | 12 | export async function down(db: Kysely): Promise { 13 | await db.schema.dropTable("{{snakeCase tableName}}s").execute(); 14 | } 15 | -------------------------------------------------------------------------------- /apps/backend/src/test-utils/plugins/enable-test-logger.plugin.ts: -------------------------------------------------------------------------------- 1 | import type { FastifyInstance } from "fastify"; 2 | import fp from "fastify-plugin"; 3 | 4 | async function plugin(fastify: FastifyInstance, _opts) { 5 | fastify.addHook("onRequest", (request, reply, done) => { 6 | // Because we're running a server in a test environment, if we want to enable logging 7 | // we need to set a header to enable it. 8 | if (request.headers["test-logging-enabled"] === "true") { 9 | request.ctx.log.enableLogging(); 10 | request.log.enableLogging(); 11 | fastify.log.enableLogging(); 12 | } 13 | done(); 14 | }); 15 | } 16 | 17 | export const enableTestLoggerPlugin = fp(plugin); 18 | -------------------------------------------------------------------------------- /syncpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "semverRange": "exact", 3 | "sortFirst": ["name", "description", "version", "type", "private", "main", "exports", "types", "author", "keywords", "scripts", "dependencies", "devDependencies", "peerDependencies", "resolutions"], 4 | "sortAz": [], 5 | "semverGroups": [{ 6 | "range": "", 7 | "dependencyTypes": ["prod", "dev", "resolutions", "overrides"], 8 | "dependencies": ["**"], 9 | "packages": ["**"] 10 | }], 11 | "versionGroups": [ 12 | { 13 | "label": "use workspace protocol for local packages", 14 | "dependencies": ["$LOCAL"], 15 | "dependencyTypes": ["!local"], 16 | "pinVersion": "workspace:*" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /apps/backend/src/index.ts: -------------------------------------------------------------------------------- 1 | import { SERVER_PORT } from "@/constants.js"; 2 | import { startServer } from "@/server.js"; 3 | import { getLogger } from "@/utils/logger.js"; 4 | 5 | process.on("unhandledRejection", (reason, promise) => { 6 | const log = getLogger().withPrefix("[Unhandled Rejection]"); 7 | log 8 | .withError(reason) 9 | .withMetadata({ 10 | promise, 11 | }) 12 | .fatal("Unhandled Rejection"); 13 | process.exit(1); 14 | }); 15 | 16 | process.on("uncaughtException", (error) => { 17 | const log = getLogger().withPrefix("[Uncaught Exception]"); 18 | log.withError(error).fatal("Uncaught Exception"); 19 | process.exit(1); 20 | }); 21 | 22 | await startServer({ port: SERVER_PORT }); 23 | -------------------------------------------------------------------------------- /apps/backend/src/db/types/user-providers.db-types.ts: -------------------------------------------------------------------------------- 1 | import type { Generated, GeneratedAlways, Insertable, Selectable, Updateable } from "kysely"; 2 | 3 | export enum UserProviderType { 4 | EMail = "EMail", 5 | } 6 | 7 | export enum PasswordAlgo { 8 | BCrypt12 = "BCrypt12", 9 | } 10 | 11 | export interface UserProvidersTable { 12 | id: Generated; 13 | userId: string; 14 | providerType: UserProviderType; 15 | providerAccountId: string; 16 | passwordAlgo?: PasswordAlgo; 17 | passwordHash?: string; 18 | createdAt: GeneratedAlways; 19 | updatedAt: Generated; 20 | } 21 | 22 | export type UserProviderDb = Selectable; 23 | export type NewUserProvider = Insertable; 24 | export type UserProviderUpdate = Updateable; 25 | -------------------------------------------------------------------------------- /apps/backend/src/db/repositories/user-providers.repository.ts: -------------------------------------------------------------------------------- 1 | import { BaseRepository } from "@/db/repositories/base.repository.js"; 2 | import type { Database } from "@/db/types/index.js"; 3 | import type { NewUserProvider, UserProviderDb } from "@/db/types/user-providers.db-types.js"; 4 | import type { Kysely } from "kysely"; 5 | 6 | /** 7 | * Stores user authentication provider information. Allows usage of multiple 8 | * authentication providers per user. 9 | */ 10 | export class UserProvidersRepository extends BaseRepository { 11 | async createUserProvider({ 12 | db, 13 | userProvider, 14 | }: { 15 | db: Kysely; 16 | userProvider: NewUserProvider; 17 | }): Promise { 18 | return db.insertInto("userProviders").values(userProvider).returningAll().executeTakeFirstOrThrow(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /apps/backend/src/services/base.service.ts: -------------------------------------------------------------------------------- 1 | import type { Repositories } from "@/db/repositories/index.js"; 2 | import type { Database } from "@/db/types/index.js"; 3 | import type { Services } from "@/services/index.js"; 4 | import type { Kysely } from "kysely"; 5 | import type { ILogLayer } from "loglayer"; 6 | 7 | export interface CommonServiceParams { 8 | log: ILogLayer; 9 | db: Kysely; 10 | repos: Repositories; 11 | } 12 | 13 | export class BaseService { 14 | log: ILogLayer; 15 | db: Kysely; 16 | repos: Repositories; 17 | services: Services; 18 | 19 | constructor({ log, db, repos }: CommonServiceParams) { 20 | this.repos = repos; 21 | this.log = log; 22 | this.db = db; 23 | this.services = {} as Services; 24 | } 25 | 26 | withServices(services: Services) { 27 | this.services = services; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /apps/backend/kysely.config.js: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url'; 2 | import { createJiti } from 'jiti'; 3 | import { defineConfig } from 'kysely-ctl'; 4 | 5 | const moduleFileUrl = import.meta.url; 6 | 7 | // Use `jiti` to dynamically import the Kysely configuration 8 | // This allows us to use ES modules and TypeScript without needing to compile them beforehand 9 | const jiti = createJiti(fileURLToPath(moduleFileUrl), { 10 | interopDefault: true, 11 | alias: { '@': fileURLToPath(new URL('./src', moduleFileUrl)) }, 12 | }); 13 | 14 | const { kyselyDialect, kyselyPlugins, db } = jiti('./src/db/index.js'); 15 | 16 | export default defineConfig({ 17 | kysely: db, 18 | dialect: kyselyDialect, 19 | migrations: { 20 | migrationFolder: "src/db/migrations", 21 | }, 22 | plugins: kyselyPlugins, 23 | seeds: { 24 | seedFolder: "src/db/seeds" 25 | } 26 | }); 27 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "formatter": { 7 | "enabled": true, 8 | "indentStyle": "space", 9 | "lineWidth": 120, 10 | "ignore": ["node_modules/*", "*.config.*", "*.json", "tsconfig.json", ".turbo", "dist/*", ".pnpm-store/*"] 11 | }, 12 | "linter": { 13 | "enabled": true, 14 | "ignore": ["dist/*", ".pnpm-store/*", "packages/backend-client/*"], 15 | "rules": { 16 | "performance": { 17 | "noDelete": "off" 18 | }, 19 | "complexity": { 20 | "useLiteralKeys": "off" 21 | }, 22 | "correctness": { 23 | "noUnusedImports": "error" 24 | }, 25 | "suspicious": { 26 | "noImplicitAnyLet": "off", 27 | "noExplicitAny": "off" 28 | }, 29 | "recommended": true 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | lint: 8 | name: Linting 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout Repository 12 | uses: actions/checkout@v4 13 | 14 | - name: Use pnpm 15 | uses: pnpm/action-setup@v3 16 | with: 17 | version: 9.11.0 18 | 19 | - uses: actions/setup-node@v4 20 | with: 21 | node-version: 20 22 | cache: 'pnpm' 23 | 24 | - name: Install Dependencies 25 | run: pnpm install --frozen-lockfile --prefer-offline 26 | 27 | - name: Build workspace packages 28 | run: pnpm build 29 | 30 | - name: Run package checking 31 | run: pnpm run lint:packages 32 | 33 | - name: Run type checking 34 | run: pnpm run typecheck 35 | 36 | - name: Run linting 37 | run: pnpm run lint 38 | -------------------------------------------------------------------------------- /packages/backend-client/src/client.gen.ts: -------------------------------------------------------------------------------- 1 | // This file is auto-generated by @hey-api/openapi-ts 2 | 3 | import type { ClientOptions } from './types.gen'; 4 | import { type Config, type ClientOptions as DefaultClientOptions, createClient, createConfig } from '@hey-api/client-fetch'; 5 | 6 | /** 7 | * The `createClientConfig()` function will be called on client initialization 8 | * and the returned object will become the client's initial configuration. 9 | * 10 | * You may want to initialize your client this way instead of calling 11 | * `setConfig()`. This is useful for example if you're using Next.js 12 | * to ensure your client always has the correct values. 13 | */ 14 | export type CreateClientConfig = (override?: Config) => Config & T>; 15 | 16 | export const client = createClient(createConfig()); -------------------------------------------------------------------------------- /lefthook.yml: -------------------------------------------------------------------------------- 1 | pre-commit: 2 | commands: 3 | "lint and format files": 4 | glob: "*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}" 5 | run: pnpm run biome:check {staged_files} && pnpm run biome:format {staged_files} && pnpm run biome:lint {staged_files} 6 | stage_fixed: true 7 | tags: 8 | - lint 9 | - staged-lint 10 | exclude: 11 | - "packages/backend-client/src/*.ts" 12 | "check and format package.json files": 13 | glob: "**/package.json" 14 | run: pnpm run syncpack:format && pnpm run syncpack:lint 15 | stage_fixed: true 16 | tags: 17 | - package-lint 18 | "verify typescript types": 19 | glob: "*.{ts,mjs,d.cts,d.mts,tsx}" 20 | run: pnpm run verify-types 21 | tags: 22 | - verify-types 23 | "update lockfiles": 24 | glob: "**/package.json" 25 | run: pnpm install 26 | stage_fixed: true 27 | -------------------------------------------------------------------------------- /apps/backend/src/plugins/context.plugin.ts: -------------------------------------------------------------------------------- 1 | import { ApiContext } from "@/api-lib/context.js"; 2 | import { db } from "@/db/index.js"; 3 | import { removeQueryParametersFromPath } from "@/utils/remove-query-params.js"; 4 | import type { FastifyInstance } from "fastify"; 5 | import fp from "fastify-plugin"; 6 | import type { LogLayer } from "loglayer"; 7 | 8 | declare module "fastify" { 9 | interface FastifyRequest { 10 | ctx: ApiContext; 11 | userId: string; 12 | } 13 | } 14 | 15 | async function plugin(fastify: FastifyInstance, _opts) { 16 | fastify.addHook("onRequest", async (request) => { 17 | if (request.url) { 18 | // @ts-ignore 19 | request.log = request.log.withContext({ 20 | apiPath: removeQueryParametersFromPath(request.url), 21 | }); 22 | } 23 | 24 | request.ctx = new ApiContext({ 25 | db, 26 | log: request.log as unknown as LogLayer, 27 | }); 28 | }); 29 | } 30 | 31 | export const contextPlugin = fp(plugin); 32 | -------------------------------------------------------------------------------- /apps/backend/src/constants.ts: -------------------------------------------------------------------------------- 1 | import { config } from "@dotenvx/dotenvx"; 2 | import { default as envVar } from "env-var"; 3 | 4 | config({ 5 | quiet: true, 6 | }); 7 | 8 | const env = envVar.from(process.env, {}, () => {}); 9 | 10 | export const SERVER_PORT = env.get("SERVER_PORT").default("3080").asPortNumber(); 11 | export const DB_HOST = env.get("DB_HOST").required().asString(); 12 | export const DB_PORT = env.get("DB_PORT").default("5432").asPortNumber(); 13 | export const DB_NAME = env.get("DB_NAME").required().asString(); 14 | export const DB_USER = env.get("DB_USER").required().asString(); 15 | export const DB_PASS = env.get("DB_PASS").required().asString(); 16 | 17 | export const IS_GENERATING_CLIENT = env.get("IS_GENERATING_CLIENT").default("false").asBoolStrict(); 18 | export const IS_PROD = process.env.NODE_ENV === "production"; 19 | export const IS_TEST = process.env.NODE_ENV === "test"; 20 | export const BACKEND_LOG_LEVEL = env.get("BACKEND_LOG_LEVEL").default("debug").asString(); 21 | -------------------------------------------------------------------------------- /apps/backend/turbo/generators/config.ts: -------------------------------------------------------------------------------- 1 | import type { PlopTypes } from "@turbo/gen"; 2 | import { apiRouteGenerator } from "./actions/api-route.generator"; 3 | import { databaseTableGenerator } from "./actions/database-table.generator"; 4 | import { serviceGenerator } from "./actions/service.generator"; 5 | 6 | export default function generator(plop: PlopTypes.NodePlopAPI): void { 7 | helpers(plop); 8 | apiRouteGenerator(plop); 9 | databaseTableGenerator(plop); 10 | serviceGenerator(plop); 11 | } 12 | 13 | function helpers(plop: PlopTypes.NodePlopAPI) { 14 | plop.addHelper("ifEquals", function (a, b, options) { 15 | // @ts-ignore 16 | return a === b ? options.fn(this) : options.inverse(this); 17 | }); 18 | 19 | plop.setHelper("timestamp", () => Date.now().toString()); 20 | 21 | // Sets a template variable 22 | plop.addHelper("set", (name, value, options) => { 23 | if (!options.data.root) { 24 | options.data.root = {}; 25 | } 26 | options.data.root[name] = value; 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /apps/backend/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import { fileURLToPath } from "node:url"; 3 | import { defineConfig } from "vitest/config"; 4 | 5 | const apiPath = "src"; 6 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 7 | 8 | process.on("unhandledRejection", (reason) => { 9 | console.error("[Unhandled Rejection]"); 10 | console.error(reason); 11 | process.exit(1); 12 | }); 13 | 14 | process.on("uncaughtException", (error) => { 15 | console.error("[Uncaught Exception]"); 16 | console.error(error); 17 | process.exit(1); 18 | }); 19 | 20 | export default defineConfig({ 21 | test: { 22 | name: "api", 23 | include: [`${apiPath}/**/?(*.)+(spec|test).[jt]s?(x)`], 24 | environment: "node", 25 | globalSetup: [`${apiPath}/test-utils/global-setup.ts`, `${apiPath}/test-utils/global-teardown.ts`], 26 | }, 27 | resolve: { 28 | alias: { 29 | '@': path.resolve(__dirname, './src'), 30 | }, 31 | }, 32 | }); 33 | -------------------------------------------------------------------------------- /packages/backend-client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@internal/backend-client", 3 | "description": "Client to call backend.", 4 | "version": "1.0.0", 5 | "private": true, 6 | "main": "dist/index.js", 7 | "exports": { 8 | ".": { 9 | "types": "./dist/index.d.ts", 10 | "import": "./dist/index.mjs", 11 | "require": "./dist/index.js" 12 | } 13 | }, 14 | "types": "dist/index.d.ts", 15 | "scripts": { 16 | "build": "pnpm run generate:yaml-file && pnpm run build:client", 17 | "build:client": "openapi-ts && tsup src/index.ts", 18 | "build:dev": "pnpm run generate:yaml-file && hash-runner", 19 | "generate:yaml-file": "cd ../../apps/backend && pnpm run generate", 20 | "clean": "rm -rf .turbo node_modules dist", 21 | "typecheck": "tsc --noEmit" 22 | }, 23 | "devDependencies": { 24 | "@hey-api/openapi-ts": "0.67.6", 25 | "@hey-api/client-fetch": "0.10.1", 26 | "@internal/tsconfig": "workspace:*", 27 | "@internal/backend": "workspace:*", 28 | "hash-runner": "2.0.1", 29 | "tsup": "8.5.0" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | test: 8 | name: Testing 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout Repository 12 | uses: actions/checkout@v4 13 | with: 14 | fetch-depth: 2 15 | 16 | - name: Use pnpm 17 | uses: pnpm/action-setup@v3 18 | with: 19 | version: 9.11.0 20 | 21 | - uses: actions/setup-node@v4 22 | with: 23 | node-version: 20 24 | cache: 'pnpm' 25 | 26 | - name: Install Dependencies 27 | run: pnpm install --frozen-lockfile --prefer-offline 28 | 29 | - name: Build workspace packages 30 | run: pnpm build 31 | 32 | - name: Branch Information 33 | run: | 34 | echo "Git Branch: $(git branch)" 35 | echo "Git Log: $(git log --oneline)" 36 | echo "HEAD SHA: $(git rev-parse HEAD)" 37 | echo "HEAD^1 SHA: $(git rev-parse HEAD^1)" 38 | echo "Git Diff: $(git diff HEAD^1)" 39 | 40 | - name: Run Package(s) Tests 41 | run: | 42 | pnpm run test 43 | -------------------------------------------------------------------------------- /apps/backend/src/services/users.service.ts: -------------------------------------------------------------------------------- 1 | import { PasswordAlgo, UserProviderType } from "@/db/types/user-providers.db-types.js"; 2 | import type { NewUser, UserDb } from "@/db/types/users.db-types.js"; 3 | import { BaseService } from "@/services/base.service.js"; 4 | import bcrypt from "bcrypt"; 5 | 6 | export class UsersService extends BaseService { 7 | async createEMailUser({ 8 | user, 9 | email, 10 | password, 11 | }: { user: NewUser; email: string; password: string }): Promise { 12 | const pass = bcrypt.hash(password, 12); 13 | 14 | let u; 15 | 16 | await this.db.transaction().execute(async (db) => { 17 | u = await this.repos.users.createUser({ 18 | db, 19 | user, 20 | }); 21 | 22 | await this.repos.userProviders.createUserProvider({ 23 | db, 24 | userProvider: { 25 | providerType: UserProviderType.EMail, 26 | providerAccountId: email, 27 | passwordAlgo: PasswordAlgo.BCrypt12, 28 | passwordHash: pass, 29 | userId: u.id, 30 | }, 31 | }); 32 | }); 33 | 34 | return u; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Theo Gravity 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/backend-errors/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@internal/backend-errors", 3 | "description": "Error emission and handling for the backend", 4 | "version": "1.0.0", 5 | "private": true, 6 | "main": "dist/index.js", 7 | "exports": { 8 | ".": { 9 | "types": "./dist/index.d.ts", 10 | "import": "./dist/index.mjs", 11 | "require": "./dist/index.js" 12 | } 13 | }, 14 | "types": "dist/index.d.ts", 15 | "scripts": { 16 | "build": "tsup src/index.ts", 17 | "build:dev": "hash-runner", 18 | "clean": "rm -rf .turbo node_modules dist", 19 | "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", 20 | "verify-types": "tsc --project tsconfig.json --noEmit" 21 | }, 22 | "dependencies": { 23 | "clean-stack": "5.2.0", 24 | "nanoid": "5.1.5", 25 | "serialize-error": "12.0.0", 26 | "sprintf-js": "1.1.3" 27 | }, 28 | "devDependencies": { 29 | "@types/sprintf-js": "1.1.4", 30 | "@internal/tsconfig": "workspace:*", 31 | "ajv": "8.17.1", 32 | "hash-runner": "2.0.1", 33 | "tsup": "8.5.0" 34 | }, 35 | "peerDependencies": { 36 | "ajv": "8.17.1" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.5' 2 | 3 | services: 4 | postgres: 5 | container_name: postgres_container 6 | image: postgres 7 | environment: 8 | POSTGRES_DB: ${POSTGRES_DB:-backend} 9 | POSTGRES_USER: ${POSTGRES_USER:-admin} 10 | POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-testing123} 11 | PGDATA: /data/postgres 12 | volumes: 13 | - postgres:/data/postgres 14 | ports: 15 | - "5432:5432" 16 | networks: 17 | - postgres 18 | restart: unless-stopped 19 | 20 | pgadmin: 21 | container_name: pgadmin_container 22 | image: dpage/pgadmin4 23 | environment: 24 | PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:-pgadmin4@pgadmin.org} 25 | PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD:-admin} 26 | PGADMIN_CONFIG_SERVER_MODE: 'False' 27 | PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: 'False' 28 | volumes: 29 | - pgadmin:/var/lib/pgadmin 30 | - ./pgadmin.json:/pgadmin4/servers.json 31 | 32 | ports: 33 | - "${PGADMIN_PORT:-5050}:80" 34 | networks: 35 | - postgres 36 | restart: unless-stopped 37 | 38 | networks: 39 | postgres: 40 | driver: bridge 41 | 42 | volumes: 43 | postgres: 44 | pgadmin: 45 | -------------------------------------------------------------------------------- /packages/backend-errors/src/error-codes.ts: -------------------------------------------------------------------------------- 1 | export enum BackendErrorCodes { 2 | ACCESS_DENIED = "ACCESS_DENIED", 3 | BAD_REQUEST = "BAD_REQUEST", 4 | EXISTS_ERROR = "EXISTS_ERROR", 5 | INPUT_VALIDATION_ERROR = "INPUT_VALIDATION_ERROR", 6 | INTERNAL_SERVER_ERROR = "INTERNAL_SERVER_ERROR", 7 | INVALID_CREDENTIALS = "INVALID_CREDENTIALS", 8 | NOT_FOUND_ERROR = "NOT_FOUND_ERROR", 9 | } 10 | 11 | export const BackendErrorCodeDefs = { 12 | [BackendErrorCodes.ACCESS_DENIED]: { 13 | message: "Access denied", 14 | statusCode: 403, 15 | }, 16 | [BackendErrorCodes.BAD_REQUEST]: { 17 | message: "Bad request", 18 | statusCode: 400, 19 | }, 20 | [BackendErrorCodes.EXISTS_ERROR]: { 21 | message: "Resource already exists", 22 | statusCode: 409, 23 | }, 24 | [BackendErrorCodes.INPUT_VALIDATION_ERROR]: { 25 | message: "Invalid input", 26 | statusCode: 400, 27 | }, 28 | [BackendErrorCodes.INTERNAL_SERVER_ERROR]: { 29 | message: "Internal server error", 30 | statusCode: 500, 31 | }, 32 | [BackendErrorCodes.INVALID_CREDENTIALS]: { 33 | message: "Invalid credentials", 34 | statusCode: 401, 35 | }, 36 | [BackendErrorCodes.NOT_FOUND_ERROR]: { 37 | message: "Resource not found", 38 | statusCode: 404, 39 | }, 40 | }; 41 | -------------------------------------------------------------------------------- /apps/backend/src/api/users/__tests__/create-email-user.route.test.ts: -------------------------------------------------------------------------------- 1 | import { testFramework } from "@/test-utils/test-framework/index.js"; 2 | import { testFastify } from "@/test-utils/test-server.js"; 3 | import { faker } from "@faker-js/faker"; 4 | import { describe, expect, it } from "vitest"; 5 | import type { CreateEMailUserResponse } from "../create-email-user.route.js"; 6 | 7 | describe("Create e-mail user API", () => { 8 | it("should create an e-mail user", async () => { 9 | const { headers } = await testFramework.generateTestFacets({ 10 | // Enables server-side logging for this test 11 | withLogging: true, 12 | }); 13 | 14 | // This inject is a custom version of the Fastify inject function 15 | // that logs the response if it's not what we expected 16 | const response = await testFastify.inject({ 17 | method: "POST", 18 | url: "/users/email", 19 | payload: { 20 | givenName: faker.person.firstName(), 21 | familyName: faker.person.firstName(), 22 | email: faker.internet.email(), 23 | password: faker.internet.password(), 24 | }, 25 | expectedStatusCode: 200, 26 | headers, 27 | }); 28 | 29 | expect(response.json().user.id).toBeDefined(); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /apps/backend/turbo/generators/templates/api-route/api-test.hbs: -------------------------------------------------------------------------------- 1 | import { faker } from "@faker-js/faker"; 2 | import { describe, expect, it } from "vitest"; 3 | import { testFramework } from "../../../test-utils/test-framework.js"; 4 | import { testFastify } from "../../../test-utils/test-server.js"; 5 | import type { {{ properCase operationName }}Response } from "../create-email-user.route.js"; 6 | {{#ifEquals methodType "GET" }}{{set "responseCode" 200 }}{{/ifEquals}}{{#ifEquals methodType "POST" }}{{set "responseCode" 200 }}{{/ifEquals}}{{#ifEquals methodType "PUT" }}{{set "responseCode" 201 }}{{/ifEquals}}{{#ifEquals methodType "DELETE" }}{{set "responseCode" 204 }}{{/ifEquals}} 7 | 8 | describe("{{ properCase operationName }} {{ methodType }} API", () => { 9 | it("should test the endpoint", async () => { 10 | const facets = await testFramework.generateTestFacets() 11 | const response = await testFastify.inject({ 12 | method: "{{ methodType }}", 13 | url: "/{{ routeResource }}/{{ operationName }}", 14 | payload: { 15 | description: faker.lorem.sentence(), 16 | }, 17 | headers: {}, 18 | expectedStatusCode: {{ responseCode }}, 19 | }); 20 | 21 | expect(response.json<{{ properCase operationName }}Response>().id).toBeDefined(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /apps/backend/scripts/generate-client-yaml.ts: -------------------------------------------------------------------------------- 1 | import { writeFile } from "node:fs/promises"; 2 | import path from "node:path"; 3 | import { fileURLToPath } from "node:url"; 4 | import Fastify from "fastify"; 5 | import fp from "fastify-plugin"; 6 | import { stringify } from "yaml"; 7 | import routes from "../src/api/index.js"; 8 | import { ajvPlugins } from "../src/api-lib/ajv-plugins.js"; 9 | 10 | const __filename = fileURLToPath(import.meta.url); // get the resolved path to the file 11 | const __dirname = path.dirname(__filename); // get the name of the directory 12 | 13 | const fastifyOptions: any = { 14 | ajv: { 15 | plugins: ajvPlugins, 16 | }, 17 | }; 18 | 19 | const fastify = Fastify(fastifyOptions); 20 | 21 | await fastify.register(fp(routes)); 22 | await fastify.ready(); 23 | 24 | if (fastify.swagger === null || fastify.swagger === undefined) { 25 | throw new Error("@fastify/swagger plugin is not loaded"); 26 | } 27 | 28 | console.log("Generating backend-client OpenAPI schema..."); 29 | 30 | const schema = stringify(fastify.swagger()); 31 | await writeFile(path.join(__dirname, "..", "..", "..", "packages", "backend-client", "openapi.yml"), schema, { 32 | flag: "w+", 33 | }); 34 | 35 | await fastify.close(); 36 | 37 | console.log("Generation of backend-client OpenAPI schema completed."); 38 | -------------------------------------------------------------------------------- /.github/workflows/release-snapshot.yml: -------------------------------------------------------------------------------- 1 | name: Snapshot Release 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | env: 7 | PNPM_CACHE_FOLDER: .pnpm-store 8 | 9 | jobs: 10 | snapshot: 11 | name: Snapshot Release 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout Repository 15 | uses: actions/checkout@v4 16 | 17 | - name: Use pnpm 18 | uses: pnpm/action-setup@v3 19 | with: 20 | version: 9.11.0 21 | 22 | - uses: actions/setup-node@v4 23 | with: 24 | node-version: 20 25 | cache: 'pnpm' 26 | 27 | - name: setup pnpm config 28 | run: pnpm config set store-dir $PNPM_CACHE_FOLDER 29 | 30 | - name: Install Dependencies 31 | run: pnpm install --frozen-lockfile 32 | 33 | - name: Build workspace packages 34 | run: pnpm build 35 | 36 | - name: Run package checking 37 | run: pnpm run lint:packages 38 | 39 | - name: Run type checking 40 | run: pnpm run typecheck 41 | 42 | - name: Run linting 43 | run: pnpm run lint 44 | 45 | - name: Create Snapshot Release 46 | run: | 47 | pnpm run version-packages --snapshot "${{ github.ref_name }}" 48 | echo '---' 49 | echo 'Detected Changes:' 50 | git diff 51 | echo '---' 52 | pnpm run release --tag "${{ github.ref_name }}" --no-git-tag 53 | -------------------------------------------------------------------------------- /packages/backend-client/src/sdk.gen.ts: -------------------------------------------------------------------------------- 1 | // This file is auto-generated by @hey-api/openapi-ts 2 | 3 | import type { Options as ClientOptions, TDataShape, Client } from '@hey-api/client-fetch'; 4 | import type { CreateEMailUserData, CreateEMailUserResponse2 } from './types.gen'; 5 | import { client as _heyApiClient } from './client.gen'; 6 | 7 | export type Options = ClientOptions & { 8 | /** 9 | * You can provide a client instance returned by `createClient()` instead of 10 | * individual options. This might be also useful if you want to implement a 11 | * custom client. 12 | */ 13 | client?: Client; 14 | /** 15 | * You can pass arbitrary values through the `meta` object. This can be 16 | * used to access values that aren't defined as part of the SDK function. 17 | */ 18 | meta?: Record; 19 | }; 20 | 21 | /** 22 | * Create an e-mail-based account 23 | */ 24 | export const createEMailUser = (options?: Options) => { 25 | return (options?.client ?? _heyApiClient).post({ 26 | url: '/users/email', 27 | ...options, 28 | headers: { 29 | 'Content-Type': 'application/json', 30 | ...options?.headers 31 | } 32 | }); 33 | }; -------------------------------------------------------------------------------- /apps/backend/src/test-utils/ts-migration-transpiler.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from "node:fs"; 2 | import path from "node:path"; 3 | import { fileURLToPath } from "node:url"; 4 | import { createJiti } from "jiti"; 5 | import type { Migration, MigrationProvider } from "kysely"; 6 | import ts from "ts-node"; 7 | 8 | const moduleFileUrl = import.meta.url; 9 | 10 | // Create a jiti instance that can resolve path aliases 11 | const jitiInstance = createJiti(fileURLToPath(moduleFileUrl), { 12 | interopDefault: true, 13 | alias: { "@": fileURLToPath(new URL("./src", moduleFileUrl)) }, 14 | }); 15 | 16 | ts.register({ 17 | transpileOnly: true, 18 | }); 19 | 20 | export class TypeScriptFileMigrationProvider implements MigrationProvider { 21 | constructor(private absolutePath: string) {} 22 | 23 | async getMigrations(): Promise> { 24 | const migrations: Record = {}; 25 | const files = await fs.readdir(this.absolutePath); 26 | 27 | for (const fileName of files) { 28 | if (!fileName.endsWith(".ts")) { 29 | continue; 30 | } 31 | 32 | const importPath = path.join(this.absolutePath, fileName).replaceAll("\\", "/"); 33 | // Use jiti to resolve and import the migration file 34 | const { up, down } = jitiInstance(importPath); 35 | const migrationKey = fileName.substring(0, fileName.lastIndexOf(".")); 36 | 37 | migrations[migrationKey] = { up, down }; 38 | } 39 | 40 | return migrations; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /apps/backend/src/db/repositories/users.repository.ts: -------------------------------------------------------------------------------- 1 | import { BaseRepository } from "@/db/repositories/base.repository.js"; 2 | import type { Database } from "@/db/types/index.js"; 3 | import type { NewUser, UserDb, UserUpdate } from "@/db/types/users.db-types.js"; 4 | import type { DeleteResult, Kysely } from "kysely"; 5 | 6 | /** 7 | * Stores profile information for users. 8 | */ 9 | export class UsersRepository extends BaseRepository { 10 | async createUser({ 11 | db, 12 | user, 13 | }: { 14 | db: Kysely; 15 | user: NewUser; 16 | }): Promise { 17 | return db.insertInto("users").values(user).returningAll().executeTakeFirstOrThrow(); 18 | } 19 | 20 | async getUserById({ 21 | db, 22 | userId, 23 | }: { 24 | db: Kysely; 25 | userId: string; 26 | }): Promise { 27 | return db.selectFrom("users").selectAll().where("id", "=", userId).executeTakeFirst(); 28 | } 29 | 30 | async removeUserById({ 31 | db, 32 | userId, 33 | }: { 34 | db: Kysely; 35 | userId: string; 36 | }): Promise { 37 | return db.deleteFrom("users").where("id", "=", userId).execute(); 38 | } 39 | 40 | async updateUser({ 41 | db, 42 | userId, 43 | user, 44 | }: { 45 | db: Kysely; 46 | userId: string; 47 | user: UserUpdate; 48 | }): Promise { 49 | return db.updateTable("users").set(user).where("id", "=", userId).returningAll().executeTakeFirstOrThrow(); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | env: 9 | PNPM_CACHE_FOLDER: .pnpm-store 10 | 11 | concurrency: ${{ github.workflow }}-${{ github.ref }} 12 | 13 | jobs: 14 | release: 15 | name: Releasing 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout Repository 19 | uses: actions/checkout@v4 20 | 21 | - name: Use pnpm 22 | uses: pnpm/action-setup@v3 23 | with: 24 | version: 9.11.0 25 | 26 | - uses: actions/setup-node@v4 27 | with: 28 | node-version: 20 29 | cache: 'pnpm' 30 | 31 | - name: setup pnpm config 32 | run: pnpm config set store-dir $PNPM_CACHE_FOLDER 33 | 34 | - name: Install Dependencies 35 | run: pnpm install --frozen-lockfile 36 | 37 | - name: Build workspace packages 38 | run: pnpm build 39 | 40 | - name: Run package checking 41 | run: pnpm run lint:packages 42 | 43 | - name: Run type checking 44 | run: pnpm run typecheck 45 | 46 | - name: Run linting 47 | run: pnpm run lint 48 | 49 | - name: Create Release Pull Request / Publish Packages 50 | uses: changesets/action@v1 51 | with: 52 | publish: pnpm run release 53 | version: pnpm run version-packages 54 | commit: 'chore: release package(s)' 55 | title: 'chore: release package(s)' 56 | env: 57 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 59 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "ui": "tui", 4 | "globalDependencies": [ 5 | "**/.env.*local", 6 | "biome.json", 7 | "packages/tsconfig/**" 8 | ], 9 | "tasks": { 10 | "build:dev": { 11 | "cache": false 12 | }, 13 | "build": { 14 | "dependsOn": ["lint", "^build"], 15 | "outputs": ["dist/**", ".next/**", "!.next/cache/**"] 16 | }, 17 | "verify-types": {}, 18 | "lint": {}, 19 | "test:ci": { 20 | "dependsOn": ["@internal/backend-errors#build:dev"], 21 | "persistent": false, 22 | "cache": false 23 | }, 24 | "test": { 25 | "dependsOn": ["@internal/backend-errors#build:dev"], 26 | "interactive": true, 27 | "persistent": true, 28 | "cache": false 29 | }, 30 | "build:backend": { 31 | "dependsOn": ["@internal/backend#lint","@internal/backend#build:dev"], 32 | "cache": true, 33 | "inputs": ["apps/backend-api/**", "!apps/backend-api/dist/**"], 34 | "outputs": [ 35 | "apps/backend/dist/**" 36 | ] 37 | }, 38 | "generate:sdk": { 39 | "dependsOn": [ 40 | "@internal/backend-client#build:dev" 41 | ], 42 | "cache": true 43 | }, 44 | "dev": { 45 | "dependsOn": ["build:dev"], 46 | "cache": false, 47 | "persistent": true 48 | }, 49 | "prod": { 50 | "dependsOn": ["build"], 51 | "persistent": true, 52 | "cache": true, 53 | "inputs": ["$TURBO_DEFAULT$"], 54 | "outputs": ["dist/**", ".next/**", "!.next/cache/**"] 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /packages/backend-client/src/types.gen.ts: -------------------------------------------------------------------------------- 1 | // This file is auto-generated by @hey-api/openapi-ts 2 | 3 | /** 4 | * The type of the auth provider 5 | */ 6 | export type UserProviderType = 'EMail'; 7 | 8 | export type User = { 9 | /** 10 | * ID of the user 11 | */ 12 | id: string; 13 | /** 14 | * Given name of the user 15 | */ 16 | givenName: string; 17 | /** 18 | * Family name of the user 19 | */ 20 | familyName: string; 21 | }; 22 | 23 | export type UserProvider = { 24 | /** 25 | * ID of the user 26 | */ 27 | userId: string; 28 | /** 29 | * The type of the auth provider 30 | */ 31 | providerType: 'EMail'; 32 | /** 33 | * The account id associated with the provider 34 | */ 35 | accountId: string; 36 | }; 37 | 38 | export type CreateEMailUserRequest = { 39 | givenName: string; 40 | familyName: string; 41 | email: string; 42 | password: string; 43 | }; 44 | 45 | export type CreateEMailUserResponse = { 46 | user: User; 47 | provider: UserProvider; 48 | }; 49 | 50 | export type CreateEMailUserData = { 51 | body?: CreateEMailUserRequest; 52 | path?: never; 53 | query?: never; 54 | url: '/users/email'; 55 | }; 56 | 57 | export type CreateEMailUserResponses = { 58 | /** 59 | * Default Response 60 | */ 61 | 200: CreateEMailUserResponse; 62 | }; 63 | 64 | export type CreateEMailUserResponse2 = CreateEMailUserResponses[keyof CreateEMailUserResponses]; 65 | 66 | export type ClientOptions = { 67 | baseUrl: `${string}://openapi.yml` | (string & {}); 68 | }; -------------------------------------------------------------------------------- /apps/backend/turbo/generators/templates/database-table/repository.hbs: -------------------------------------------------------------------------------- 1 | import type { DeleteResult, Kysely } from "kysely"; 2 | import type { Database } from "../types.js"; 3 | import type { New{{ properCase tableName }}, {{ properCase tableName }}Db, {{ properCase tableName }}Update } from "../types/{{ dashCase tableName }}s.db-types.js"; 4 | import { BaseRepository } from "./base.repository.js"; 5 | 6 | export class {{ properCase tableName }}sRepository extends BaseRepository { 7 | async create{{ properCase tableName }}({ 8 | db, 9 | {{ tableName }}, 10 | }: { 11 | db: Kysely; 12 | {{ tableName }}: New{{ properCase tableName }}; 13 | }): Promise<{{ properCase tableName }}Db> { 14 | return db.insertInto("{{ tableName }}s").values({{ tableName }}).returningAll().executeTakeFirstOrThrow(); 15 | } 16 | 17 | async get{{ properCase tableName }}ById({ 18 | db, 19 | {{ tableName }}Id, 20 | }: { 21 | db: Kysely; 22 | {{ tableName }}Id: string; 23 | }): Promise<{{ properCase tableName }}Db | null> { 24 | return db.selectFrom("{{ tableName }}s").selectAll().where("id", "=", {{ tableName }}Id).executeTakeFirst(); 25 | } 26 | 27 | async remove{{ properCase tableName }}ById({ 28 | db, 29 | {{ tableName }}Id, 30 | }: { 31 | db: Kysely; 32 | {{ tableName }}Id: string; 33 | }): Promise { 34 | return db.deleteFrom("{{ tableName }}s").where("id", "=", {{ tableName }}Id).execute(); 35 | } 36 | 37 | async updateUser({ 38 | db, 39 | {{ tableName }}, 40 | }: { 41 | db: Kysely; 42 | {{ tableName }}: {{ properCase tableName }}Update; 43 | }): Promise<{{ properCase tableName }}Db> { 44 | return db.updateTable("{{ tableName }}s").set({{ tableName }}).where("id", "=", {{ tableName }}.id).returningAll().executeTakeFirstOrThrow(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /apps/backend/src/api/index.ts: -------------------------------------------------------------------------------- 1 | import { apiTypes } from "@/api-lib/types/index.js"; 2 | import { registerResourceRoutes } from "@/api/routes.js"; 3 | import fastifySwagger from "@fastify/swagger"; 4 | import fastifySwaggerUi from "@fastify/swagger-ui"; 5 | import type { FastifyInstance } from "fastify"; 6 | import fp from "fastify-plugin"; 7 | 8 | export default async function routes(fastify: FastifyInstance, _opts) { 9 | fastify.register(fastifySwagger, { 10 | mode: "dynamic", 11 | refResolver: { 12 | // This assigns the component name in the OpenAPI generated schema 13 | buildLocalReference(json, baseUri, fragment, i) { 14 | // Fallback if no title is present 15 | if (!json.title && json.$id) { 16 | json.title = json.$id; 17 | } 18 | // Fallback if no $id is present 19 | if (!json.$id) { 20 | return `def-${i}`; 21 | } 22 | 23 | return `${json.$id}`; 24 | }, 25 | }, 26 | openapi: { 27 | info: { 28 | title: "Backend API", 29 | version: "1.0.0", 30 | }, 31 | components: { 32 | securitySchemes: { 33 | bearerAuth: { 34 | type: "http", 35 | scheme: "bearer", 36 | bearerFormat: "JWT", 37 | }, 38 | }, 39 | }, 40 | }, 41 | }); 42 | 43 | fastify.register(fastifySwaggerUi, { 44 | routePrefix: "/docs", 45 | uiConfig: { 46 | docExpansion: "full", 47 | deepLinking: false, 48 | }, 49 | staticCSP: true, 50 | transformStaticCSP: (header) => header, 51 | transformSpecification: (swaggerObject, req, reply) => { 52 | // @ts-ignore 53 | swaggerObject.host = req.hostname; 54 | return swaggerObject; 55 | }, 56 | transformSpecificationClone: true, 57 | }); 58 | 59 | fastify.get("/", async (_, reply) => { 60 | reply.send("OK"); 61 | }); 62 | 63 | fastify.register(fp(apiTypes)); 64 | fastify.register(fp(registerResourceRoutes)); 65 | } 66 | -------------------------------------------------------------------------------- /apps/backend/src/test-utils/global-setup.ts: -------------------------------------------------------------------------------- 1 | import * as path from "node:path"; 2 | import { TypeScriptFileMigrationProvider } from "@/test-utils/ts-migration-transpiler.js"; 3 | import { PostgreSqlContainer } from "@testcontainers/postgresql"; 4 | import { Kysely, Migrator, PostgresDialect } from "kysely"; 5 | import { Pool } from "pg"; 6 | 7 | global.containers = []; 8 | global.dbPool = null; 9 | 10 | export async function setup(_config: any) { 11 | console.log("Setting up global environment"); 12 | const postgresContainer = initializePostgres(); 13 | 14 | const startedContainers = await Promise.all([postgresContainer]); 15 | 16 | global.containers.push(...startedContainers); 17 | } 18 | 19 | async function initializePostgres() { 20 | console.log("Starting postgres container"); 21 | const postgresContainer = await new PostgreSqlContainer().start(); 22 | 23 | process.env.DB_PORT = postgresContainer.getPort().toString(); 24 | process.env.DB_USER = postgresContainer.getUsername(); 25 | process.env.DB_PASS = postgresContainer.getPassword(); 26 | process.env.DB_NAME = postgresContainer.getDatabase(); 27 | 28 | const pool = new Pool({ 29 | host: "localhost", 30 | port: postgresContainer.getPort(), 31 | user: postgresContainer.getUsername(), 32 | password: postgresContainer.getPassword(), 33 | database: postgresContainer.getDatabase(), 34 | }); 35 | 36 | global.dbPool = pool; 37 | 38 | const db = new Kysely({ 39 | dialect: new PostgresDialect({ 40 | pool, 41 | }), 42 | }); 43 | 44 | // Run migrations 45 | const migrator = new Migrator({ 46 | db, 47 | provider: new TypeScriptFileMigrationProvider(path.join(__dirname, "..", "db", "migrations")), 48 | }); 49 | 50 | console.log("Migrating database"); 51 | 52 | const { error } = await migrator.migrateToLatest(); 53 | 54 | if (error) { 55 | console.error("failed to migrate"); 56 | console.error(error); 57 | process.exit(1); 58 | } 59 | 60 | return postgresContainer; 61 | } 62 | -------------------------------------------------------------------------------- /apps/backend/src/server.ts: -------------------------------------------------------------------------------- 1 | import { ajvPlugins } from "@/api-lib/ajv-plugins.js"; 2 | import { errorHandler } from "@/api-lib/error-handler.js"; 3 | import routes from "@/api/index.js"; 4 | import { plugins } from "@/plugins/index.js"; 5 | import { asyncLocalStorage } from "@/utils/async-local-storage.js"; 6 | import { getLogger } from "@/utils/logger.js"; 7 | import fastifyCors from "@fastify/cors"; 8 | import { type TypeBoxTypeProvider, TypeBoxValidatorCompiler } from "@fastify/type-provider-typebox"; 9 | import Fastify from "fastify"; 10 | import fp from "fastify-plugin"; 11 | import { nanoid } from "nanoid"; 12 | 13 | export async function startServer({ 14 | port, 15 | }: { 16 | port: number; 17 | }) { 18 | const logger = getLogger(); 19 | 20 | const fastify = Fastify({ 21 | // @ts-ignore 22 | loggerInstance: logger, 23 | disableRequestLogging: true, 24 | ajv: { 25 | plugins: ajvPlugins, 26 | }, 27 | genReqId: () => nanoid(12), 28 | }) 29 | .withTypeProvider() 30 | .setValidatorCompiler(TypeBoxValidatorCompiler); 31 | 32 | fastify.addHook("onRequest", (request, _reply, done) => { 33 | const logger = fastify.log.withContext({ reqId: request.id }); 34 | 35 | asyncLocalStorage.run({ logger }, done); 36 | }); 37 | 38 | fastify.register(fp(plugins)); 39 | 40 | fastify.setErrorHandler(errorHandler); 41 | 42 | // Function to validate CORS origin 43 | const corsOptions = { 44 | origin: (origin, cb) => { 45 | // @todo - Add logic to validate origin this is a secuity risk 46 | return cb(null, true); 47 | }, 48 | }; 49 | 50 | fastify.register(fastifyCors, corsOptions); 51 | fastify.register(routes); 52 | fastify.log.disableLogging(); 53 | 54 | fastify.listen({ port }, (err, address) => { 55 | if (err) { 56 | fastify.log.error(err); 57 | process.exit(1); 58 | } 59 | 60 | fastify.log.enableLogging(); 61 | 62 | fastify.log.info(`Server: ${address}`); 63 | fastify.log.info(`Server docs: ${address}/docs`); 64 | }); 65 | } 66 | -------------------------------------------------------------------------------- /apps/backend/turbo/generators/actions/service.generator.ts: -------------------------------------------------------------------------------- 1 | import type { PlopTypes } from "@turbo/gen"; 2 | 3 | export function serviceGenerator(plop: PlopTypes.NodePlopAPI): void { 4 | plop.setGenerator("service", { 5 | description: "Generate a new service", 6 | prompts: [ 7 | { 8 | type: "input", 9 | name: "name", 10 | message: "Service name (in PascalCase, e.g., UserProfiles):", 11 | validate: (input: string) => { 12 | if (!/^[A-Z][a-zA-Z0-9]*$/.test(input)) { 13 | return "Service name must be in PascalCase format (e.g., UserProfiles)"; 14 | } 15 | return true; 16 | }, 17 | }, 18 | ], 19 | actions: [ 20 | { 21 | type: "add", 22 | path: "src/services/{{kebabCase name}}.service.ts", 23 | templateFile: "templates/service/service.hbs", 24 | }, 25 | // Update services/index.ts 26 | { 27 | type: "modify", 28 | path: "src/services/index.ts", 29 | pattern: /export interface Services {/, 30 | template: "export interface Services {\n {{kebabCase name}}: {{name}}Service;", 31 | }, 32 | { 33 | type: "modify", 34 | path: "src/services/index.ts", 35 | pattern: /import type {/, 36 | template: 'import type { {{name}}Service } from "./{{kebabCase name}}.service.js";\nimport type {', 37 | }, 38 | // Update context.ts 39 | { 40 | type: "modify", 41 | path: "src/api-lib/context.ts", 42 | pattern: /\/\/ Do not remove this comment: service-import/, 43 | template: 44 | '// Do not remove this comment: service-import\nimport { {{name}}Service } from "@/services/{{kebabCase name}}.service.js";', 45 | }, 46 | { 47 | type: "modify", 48 | path: "src/api-lib/context.ts", 49 | pattern: /\/\/ Do not remove this comment: service-init/, 50 | template: 51 | "// Do not remove this comment: service-init\n {{kebabCase name}}: new {{name}}Service(serviceParams),", 52 | }, 53 | ], 54 | }); 55 | } 56 | -------------------------------------------------------------------------------- /apps/backend/src/db/migrations/0001-init.ts: -------------------------------------------------------------------------------- 1 | import { type Kysely, sql } from "kysely"; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema.createType("provider_type").asEnum(["EMail"]).execute(); 5 | await db.schema.createType("password_algo").asEnum(["BCrypt12"]).execute(); 6 | 7 | await db.schema 8 | .createTable("users") 9 | .addColumn("id", "uuid", (col) => col.defaultTo(sql`gen_random_uuid()`).primaryKey()) 10 | .addColumn("given_name", "varchar(50)", (col) => col.notNull()) 11 | .addColumn("family_name", "varchar(50)", (col) => col.notNull()) 12 | .addColumn("created_at", "timestamptz", (col) => col.defaultTo(sql`now()`)) 13 | .addColumn("updated_at", "timestamptz", (col) => col.defaultTo(sql`now()`)) 14 | .execute(); 15 | 16 | await db.schema 17 | .createTable("user_providers") 18 | .addColumn("id", "uuid", (col) => col.defaultTo(sql`gen_random_uuid()`).primaryKey()) 19 | .addColumn("user_id", "uuid", (col) => col.notNull()) 20 | .addColumn("provider_type", sql`provider_type`, (col) => col.notNull()) 21 | .addColumn("provider_account_id", "varchar(255)", (col) => col.notNull()) 22 | .addColumn("password_algo", sql`password_algo`) 23 | .addColumn("password_hash", "varchar(255)") 24 | .addColumn("created_at", "timestamptz", (col) => col.defaultTo(sql`now()`)) 25 | .addColumn("updated_at", "timestamptz", (col) => col.defaultTo(sql`now()`)) 26 | .addForeignKeyConstraint("fk_user_id", ["user_id"], "users", ["id"], (cb) => cb.onDelete("cascade")) 27 | .execute(); 28 | 29 | await db.schema 30 | .createIndex("idx_provider_type_account_id") 31 | .on("user_providers") 32 | .columns(["provider_type", "provider_account_id"]) 33 | .execute(); 34 | } 35 | 36 | export async function down(db: Kysely): Promise { 37 | await db.schema.dropIndex("idx_provider_type_account_id").execute(); 38 | await db.schema.dropTable("user_providers").execute(); 39 | await db.schema.dropTable("users").execute(); 40 | await db.schema.dropType("provider_type").execute(); 41 | await db.schema.dropType("password_algo").execute(); 42 | } 43 | -------------------------------------------------------------------------------- /apps/backend/src/api-lib/context.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/db/index.js"; 2 | // Do not remove this comment: repository-import 3 | import { UserProvidersRepository } from "@/db/repositories/user-providers.repository.js"; 4 | import { UsersRepository } from "@/db/repositories/users.repository.js"; 5 | import type { Database } from "@/db/types/index.js"; 6 | import type { Services } from "@/services/index.js"; 7 | // Do not remove this comment: service-import 8 | import { UsersService } from "@/services/users.service.js"; 9 | import { getLogger } from "@/utils/logger.js"; 10 | import type { Kysely } from "kysely"; 11 | import type { ILogLayer } from "loglayer"; 12 | 13 | export type ApiContextParams = { 14 | db: Kysely; 15 | log: ILogLayer; 16 | }; 17 | 18 | export class ApiContext { 19 | readonly log: ILogLayer; 20 | services: Services; 21 | private readonly params: ApiContextParams; 22 | 23 | constructor(params: ApiContextParams) { 24 | this.params = params; 25 | this.log = params.log; 26 | this.services = {} as Services; 27 | this.init(); 28 | } 29 | 30 | private init() { 31 | const params = this.params; 32 | 33 | const serviceParams = { 34 | log: params.log, 35 | db: params.db, 36 | repos: { 37 | // Do not remove this comment: database-table-repository 38 | users: new UsersRepository(params), 39 | userProviders: new UserProvidersRepository(params), 40 | }, 41 | }; 42 | 43 | this.services = { 44 | // Do not remove this comment: service-init 45 | users: new UsersService(serviceParams), 46 | }; 47 | 48 | for (const service of Object.values(this.services)) { 49 | service.withServices(this.services); 50 | } 51 | } 52 | } 53 | 54 | let requestlessContext: ApiContext; 55 | 56 | /** 57 | * This is a singleton context that can be used outside of a request. 58 | * It will not have anything request-specific attached to it. 59 | */ 60 | export function getRequestlessContext(): ApiContext { 61 | if (!requestlessContext) { 62 | requestlessContext = new ApiContext({ 63 | db, 64 | log: getLogger(), 65 | }); 66 | } 67 | 68 | return requestlessContext; 69 | } 70 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "monorepo-boilerplate", 3 | "description": "", 4 | "version": "1.0.0", 5 | "private": true, 6 | "scripts": { 7 | "start": "turbo watch dev", 8 | "add-changeset": "changeset add", 9 | "build": "turbo run build", 10 | "changeset": "changeset", 11 | "db:migrate:create": "cd apps/backend && pnpm run db:migrate:create", 12 | "db:migrate:latest": "cd apps/backend && pnpm run db:migrate:latest", 13 | "db:migrate:undo": "cd apps/backend && pnpm run db:migrate:undo", 14 | "clean:repo": "git add --all && git reset --hard", 15 | "clean:workspaces": "turbo clean", 16 | "clean:node_modules": "find . -name \"node_modules\" -type d -prune -exec rm -rf '{}' +\n", 17 | "lint": "turbo run lint --continue --", 18 | "biome:check": "biome check --no-errors-on-unmatched --files-ignore-unknown=true --write --unsafe", 19 | "biome:format": "biome format --no-errors-on-unmatched --files-ignore-unknown=true --write", 20 | "biome:lint": "biome lint --fix", 21 | "lint:packages": "pnpm run lint:packages:semver && pnpm run lint:packages:mismatches", 22 | "lint:packages:semver": "syncpack lint-semver-ranges", 23 | "lint:packages:mismatches": "syncpack list-mismatches", 24 | "publish-packages": "turbo run build && changeset version && changeset publish", 25 | "typecheck": "turbo run typecheck --continue --", 26 | "syncpack:update": "syncpack update && syncpack fix-mismatches && pnpm i", 27 | "syncpack:format": "syncpack format", 28 | "syncpack:lint": "syncpack lint", 29 | "release": "changeset publish", 30 | "version-packages": "changeset version", 31 | "verify-types": "turbo run verify-types --continue --" 32 | }, 33 | "devDependencies": { 34 | "@biomejs/biome": "1.9.4", 35 | "@changesets/changelog-github": "0.5.1", 36 | "@changesets/cli": "2.29.4", 37 | "@commitlint/cli": "19.8.1", 38 | "@commitlint/config-conventional": "19.8.1", 39 | "@internal/tsconfig": "workspace:*", 40 | "@types/node": "22.15.21", 41 | "lefthook": "1.11.13", 42 | "syncpack": "13.0.4", 43 | "turbo": "2.5.3", 44 | "typescript": "5.8.3" 45 | }, 46 | "engineStrict": true, 47 | "engines": { 48 | "node": ">=22.0.0" 49 | }, 50 | "packageManager": "pnpm@10.11.0" 51 | } 52 | -------------------------------------------------------------------------------- /apps/backend/src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import { BACKEND_LOG_LEVEL, IS_PROD, IS_TEST } from "@/constants.js"; 2 | import { asyncLocalStorage } from "@/utils/async-local-storage.js"; 3 | import { PinoTransport } from "@loglayer/transport-pino"; 4 | import { getPrettyTerminal } from "@loglayer/transport-pretty-terminal"; 5 | import { type ILogLayer, LogLayer, type LogLayerTransport, type PluginShouldSendToLoggerParams } from "loglayer"; 6 | import { pino } from "pino"; 7 | import { serializeError } from "serialize-error"; 8 | 9 | declare module "fastify" { 10 | interface FastifyBaseLogger extends LogLayer {} 11 | } 12 | 13 | const ignoreLogs = ["request completed", "incoming request"]; 14 | 15 | const transports: LogLayerTransport[] = []; 16 | 17 | if (IS_PROD) { 18 | transports.push( 19 | new PinoTransport({ 20 | logger: pino({ 21 | level: BACKEND_LOG_LEVEL, 22 | }), 23 | }), 24 | ); 25 | } else if (IS_TEST) { 26 | transports.push( 27 | getPrettyTerminal({ 28 | // Disabled since tests themselves are interactive 29 | disableInteractiveMode: true, 30 | }), 31 | ); 32 | } else { 33 | transports.push( 34 | getPrettyTerminal({ 35 | // Disabled since we tend to run multiple services via turbo watch 36 | disableInteractiveMode: true, 37 | }), 38 | ); 39 | } 40 | 41 | const logger = new LogLayer({ 42 | transport: transports, 43 | contextFieldName: "context", 44 | metadataFieldName: "metadata", 45 | errorFieldName: "err", 46 | errorSerializer: serializeError, 47 | copyMsgOnOnlyError: true, 48 | plugins: [ 49 | { 50 | shouldSendToLogger(params: PluginShouldSendToLoggerParams): boolean { 51 | if (params.messages?.[1] && ignoreLogs.some((log) => params.messages[1].includes(log))) { 52 | // Ignore logs that match the ignore list 53 | return false; 54 | } 55 | 56 | return true; 57 | }, 58 | }, 59 | ], 60 | }); 61 | 62 | if (IS_TEST) { 63 | logger.disableLogging(); 64 | } 65 | 66 | export function getLogger(): ILogLayer { 67 | if (IS_TEST) { 68 | return logger; 69 | } 70 | 71 | const store = asyncLocalStorage.getStore(); 72 | 73 | if (!store) { 74 | // Use non-request specific logger 75 | return logger; 76 | } 77 | 78 | return store.logger; 79 | } 80 | -------------------------------------------------------------------------------- /apps/backend/turbo/generators/templates/api-route/operation.hbs: -------------------------------------------------------------------------------- 1 | import { type Static, Type } from "@sinclair/typebox"; 2 | import { TypeRef } from "@/types.js"; 3 | import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; 4 | {{#ifEquals methodType "GET" }}{{set "responseCode" 200 }}{{/ifEquals}}{{#ifEquals methodType "POST" }}{{set "responseCode" 200 }}{{/ifEquals}}{{#ifEquals methodType "PUT" }}{{set "responseCode" 201 }}{{/ifEquals}}{{#ifEquals methodType "DELETE" }}{{set "responseCode" 204 }}{{/ifEquals}} 5 | const {{ properCase operationName }}RequestSchema = Type.Object( 6 | { 7 | description: Type.String({ 8 | description: "Define your request body here", 9 | }), 10 | }, 11 | { $id: "{{ properCase operationName }}Request" }, 12 | ); 13 | 14 | export type {{ properCase operationName }}Request = Static; 15 | 16 | const {{ properCase operationName }}ResponseSchema = Type.Object( 17 | { 18 | id: Type.Number({ 19 | description: "Define your response body here", 20 | }), 21 | }, 22 | { $id: "{{ properCase operationName }}Response" }, 23 | ); 24 | 25 | export type {{ properCase operationName }}Response = Static; 26 | 27 | export async function {{ operationName }}Route(fastify: FastifyInstance) { 28 | fastify.addSchema({{ properCase operationName }}RequestSchema); 29 | fastify.addSchema({{ properCase operationName }}ResponseSchema); 30 | 31 | const routeOpts = { 32 | schema: { 33 | operationId: "{{ operationName }}", 34 | tags: ["{{ routeResource }}"], 35 | description: "Add your route description here", 36 | body: TypeRef({{ properCase operationName }}RequestSchema), 37 | response: { 38 | "{{ responseCode }}": TypeRef({{ properCase operationName }}ResponseSchema), 39 | }, 40 | }, 41 | } 42 | 43 | fastify.{{ lowerCase methodType }}("{{ routePath }}", routeOpts, {{ operationName }}Controller); 44 | } 45 | 46 | export async function {{ operationName }}Controller( 47 | request: FastifyRequest<{ 48 | Body: {{ properCase operationName }}Request; 49 | }>, 50 | reply: FastifyReply, 51 | ) { 52 | const { description } = request.body; 53 | 54 | const response: {{ properCase operationName }}Response = { 55 | id: 1, 56 | }; 57 | 58 | reply.status({{ responseCode }}).send(response); 59 | } 60 | -------------------------------------------------------------------------------- /apps/backend/src/api-lib/error-handler.ts: -------------------------------------------------------------------------------- 1 | import { IS_PROD } from "@/constants.js"; 2 | import { removeQueryParametersFromPath } from "@/utils/remove-query-params.js"; 3 | import { ApiError, BackendErrorCodes, createApiError } from "@internal/backend-errors"; 4 | 5 | export function errorHandler(error: any, request, reply) { 6 | if (request.url) { 7 | request.log.withContext({ 8 | apiPath: removeQueryParametersFromPath(request.url), 9 | }); 10 | } 11 | 12 | if (error instanceof ApiError) { 13 | error.reqId = request.id; 14 | 15 | // If the error is not supposed to be logged, don't log it 16 | // Usually doNotLog = true means that the error has already been logged elsewhere 17 | if (!error.doNotLog) { 18 | request.log 19 | .withContext({ 20 | errId: error.errId, 21 | reqId: error.reqId, 22 | }) 23 | .errorOnly(error, { 24 | logLevel: error.logLevel, 25 | }); 26 | } 27 | 28 | if (error.isInternalError) { 29 | const e = createApiError({ 30 | code: BackendErrorCodes.INTERNAL_SERVER_ERROR, 31 | causedBy: error, 32 | ...(error?.validationError 33 | ? { 34 | validationError: error.validationError, 35 | } 36 | : {}), 37 | }); 38 | 39 | e.errId = error.errId; 40 | e.reqId = request.id; 41 | 42 | reply.status(e.statusCode).send(IS_PROD ? e.toJSONSafe() : e.toJSON()); 43 | } else { 44 | reply.status(error.statusCode).send(IS_PROD ? error.toJSONSafe() : error.toJSON()); 45 | } 46 | // https://github.com/fastify/fastify/blob/main/docs/Reference/Errors.md 47 | } else if (error?.code === "FST_ERR_VALIDATION") { 48 | const e = createApiError({ 49 | code: BackendErrorCodes.INPUT_VALIDATION_ERROR, 50 | validationError: { 51 | validation: error.validation, 52 | validationContext: error.validationContext, 53 | message: error.message, 54 | }, 55 | causedBy: error, 56 | }); 57 | 58 | e.reqId = request.id; 59 | 60 | reply.status(e.statusCode).send(IS_PROD ? e.toJSONSafe() : e.toJSON()); 61 | // https://github.com/fastify/fastify-jwt?tab=readme-ov-file#error-code 62 | } else { 63 | const e = createApiError({ 64 | code: BackendErrorCodes.INTERNAL_SERVER_ERROR, 65 | message: "An internal server error occurred.", 66 | causedBy: error, 67 | }); 68 | 69 | e.reqId = request.id; 70 | 71 | request.log 72 | .withContext({ 73 | errId: e.errId, 74 | reqId: e.reqId, 75 | }) 76 | .errorOnly(error); 77 | 78 | reply.status(500).send(IS_PROD ? e.toJSONSafe() : e.toJSON()); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /apps/backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@internal/backend", 3 | "description": "", 4 | "version": "1.0.0", 5 | "type": "module", 6 | "private": true, 7 | "scripts": { 8 | "db:migrate:create": "kysely migrate make", 9 | "db:migrate:latest": "kysely migrate latest", 10 | "db:migrate:undo": "kysely migrate down", 11 | "generate": "tsx scripts/generate-client-yaml.ts", 12 | "build": "rm -rf dist && tsc -p tsconfig.json && tsc-alias -p tsconfig.json", 13 | "clean": "rm -rf .turbo node_modules dist", 14 | "dev": "tsx watch --inspect=9230 --include \"../../packages/backend-errors/dist/*\" ./src/index.ts", 15 | "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", 16 | "prod": "node ./dist/index.js", 17 | "test": "NODE_OPTIONS=\"--no-deprecation\" IS_TEST=true TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 vitest --watch", 18 | "test:debug": "NODE_OPTIONS=\"--no-deprecation\" IS_TEST=true TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 vitest --inspect-brk --pool forks --poolOptions.forks.singleFork --no-file-parallelism", 19 | "test:ci": "NODE_OPTIONS=\"--no-deprecation\" IS_TEST=true TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 vitest --watch=false", 20 | "verify-types": "tsc --project tsconfig.json --noEmit" 21 | }, 22 | "dependencies": { 23 | "@dotenvx/dotenvx": "1.44.1", 24 | "@fastify/cors": "11.0.1", 25 | "@fastify/jwt": "9.1.0", 26 | "@fastify/swagger": "9.5.1", 27 | "@fastify/swagger-ui": "5.2.2", 28 | "@fastify/type-provider-typebox": "5.1.0", 29 | "@internal/backend-errors": "workspace:*", 30 | "@loglayer/transport-pino": "2.2.0", 31 | "@sinclair/typebox": "0.34.33", 32 | "ajv": "8.17.1", 33 | "ajv-formats": "3.0.1", 34 | "asyncforge": "0.5.0", 35 | "bcrypt": "6.0.0", 36 | "chalk": "5.4.1", 37 | "dotenv": "16.5.0", 38 | "env-var": "7.5.0", 39 | "fastify": "5.3.3", 40 | "fastify-cli": "7.4.0", 41 | "fastify-plugin": "5.0.1", 42 | "kysely": "0.28.2", 43 | "loglayer": "6.4.2", 44 | "nanoid": "5.1.5", 45 | "pg": "8.16.0", 46 | "pino": "9.7.0", 47 | "pino-pretty": "13.0.0", 48 | "serialize-error": "12.0.0", 49 | "uuid": "11.1.0", 50 | "yaml": "2.8.0" 51 | }, 52 | "devDependencies": { 53 | "@faker-js/faker": "9.8.0", 54 | "@internal/tsconfig": "workspace:*", 55 | "@loglayer/transport-pretty-terminal": "3.1.0", 56 | "@testcontainers/postgresql": "10.28.0", 57 | "@turbo/gen": "2.5.3", 58 | "@types/node": "22.15.21", 59 | "@types/pg": "8.15.2", 60 | "jiti": "2.4.2", 61 | "kysely-ctl": "0.13.0", 62 | "ts-node": "10.9.2", 63 | "tsc-alias": "1.8.16", 64 | "tsx": "4.19.4", 65 | "typescript": "5.8.3", 66 | "vitest": "3.1.4" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | .idea/ 132 | .turbo/ 133 | .hashes.json 134 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # fastify-starter-turbo-monorepo 2 | 3 | ## Mar-16-2025 4 | 5 | - Remove husky in favor of lefthook 6 | - Use the loglayer pretty terminal for dev and tests modes 7 | - Package updates. 8 | 9 | ## Jan-20-2025 10 | 11 | - Package updates. 12 | 13 | ## Jan-1-2025 14 | 15 | - Package updates. 16 | - Use version 5 of `loglayer`. 17 | 18 | ## Dec-7-2024 19 | 20 | - Package updates. 21 | 22 | ## Nov-23-2024 23 | 24 | - Client SDK generation no longer dependent on the backend build 25 | - Faster openapi.yml generation 26 | 27 | ## Nov-17-2024 28 | 29 | - Remove `lint-staged`. Wasn't being used. 30 | - Update `husky` `commit-msg` to run more linting and formatting checks 31 | - Update packages to latest versions 32 | * Pinned `@sinclair/typebox` to `0.33.22` and not `0.34.00` due to [a breaking change](https://github.com/sinclairzx81/typebox/blob/master/changelog/0.34.0.md) with `Static` 33 | - Removes query params from `apiPath` in the log context for security reasons 34 | - **Breaking:** Removes `strictNullChecks=false` in the tsconfig for better type safety 35 | - **Breaking**: `typechecks` script is now `verify-types` 36 | 37 | ## Sept-28-2024 38 | 39 | - Breaking: Change the format of `ApiError` to simplify validation errors 40 | - Update the error handler to use the new `ApiError` format 41 | - Package updates 42 | - Add url path to log context 43 | 44 | ## Sept-21-2024 45 | 46 | - Upgrade fastify to v5, all packages to latest versions 47 | - Better error handling for the backend 48 | - Fixed bugs around logs not showing in certain cases during tests 49 | - Add package publishing support 50 | - Add dev build caching for faster builds during dev 51 | - Removed AJV sanitize plugin. In real-world usage, you wouldn't want to sanitize the input to your API immediately, but later on. 52 | 53 | ## Sept-07-2024 54 | 55 | - Added better formatting for logs 56 | - Test response code failures now show the request and response outputs in the log 57 | - Fix updatedAt type 58 | - Disabled logging for tests by default now that we have proper error logging for request failures 59 | 60 | ## Sep-06-2024 61 | 62 | - Enabled logging for tests by default 63 | - Added auto-logging for test failures 64 | 65 | ## Sep-01-2024 66 | 67 | - Added type exports to Request / Response in the API definitions 68 | - Updated test to include Response typings 69 | - Updated logging to pretty print for non-prod 70 | - Fixed issue around error handling where the error was not being parsed properly 71 | - Updated packages to latest versions 72 | - Added linting / formatting to the backend-errors package 73 | - Removed --watch flag from backend dev script 74 | * Should now run `turbo watch dev` to watch for changes 75 | - Rename the `decorators` directories to `plugins` since not all items in it are decorators 76 | 77 | ## Aug-17-2024 78 | 79 | First version. 80 | -------------------------------------------------------------------------------- /apps/backend/src/test-utils/test-framework/index.ts: -------------------------------------------------------------------------------- 1 | import { ApiContext } from "@/api-lib/context.js"; 2 | import type { User } from "@/api-lib/types/user.type.js"; 3 | import { db } from "@/db/index.js"; 4 | import { getLogger } from "@/utils/logger.js"; 5 | import { faker } from "@faker-js/faker"; 6 | 7 | export interface TestHeaders extends Record { 8 | // test- prefix are test-specific headers 9 | "test-user-id": string; 10 | "test-logging-enabled"?: string; 11 | } 12 | 13 | export interface TestFacets { 14 | user: User; 15 | headers: TestHeaders; 16 | } 17 | 18 | export interface TestFacetParams { 19 | /** 20 | * Enables endpoint-level logging for the test by adding a test-specific header 21 | * to tell the server to enable logging. 22 | * 23 | * You can also use "request.log.enableLogging();" in the endpoint impl code 24 | * itself to enable logging during tests. 25 | */ 26 | withLogging?: boolean; 27 | } 28 | 29 | export class ApiTestingFramework { 30 | context: ApiContext; 31 | 32 | constructor() { 33 | this.context = new ApiContext({ 34 | db, 35 | log: getLogger(), 36 | }); 37 | } 38 | 39 | /** 40 | * Generates a set of test facets that can be used to test the API. 41 | * This includes an organization, an owner user, and an API key. 42 | */ 43 | async generateTestFacets(params?: TestFacetParams): Promise { 44 | const user = await this.context.services.users.createEMailUser({ 45 | email: faker.internet.email(), 46 | password: faker.internet.password(), 47 | user: { 48 | familyName: faker.person.lastName(), 49 | givenName: faker.person.firstName(), 50 | }, 51 | }); 52 | 53 | return { 54 | user, 55 | headers: this.generateTestHeaders( 56 | { 57 | user, 58 | }, 59 | params, 60 | ), 61 | }; 62 | } 63 | 64 | async generateNewUsers(count: number): Promise { 65 | const users: User[] = []; 66 | 67 | for (let i = 0; i < count; i++) { 68 | const user = await this.context.services.users.createEMailUser({ 69 | email: faker.internet.email(), 70 | password: faker.internet.password(), 71 | user: { 72 | familyName: faker.person.lastName(), 73 | givenName: faker.person.firstName(), 74 | }, 75 | }); 76 | users.push(user); 77 | } 78 | 79 | return users; 80 | } 81 | 82 | private generateTestHeaders(facets: Omit, params?: TestFacetParams): TestHeaders { 83 | return { 84 | "test-user-id": facets.user.id, 85 | ...(params?.withLogging ? { "test-logging-enabled": "true" } : { "test-logging-enabled": "false" }), 86 | }; 87 | } 88 | } 89 | 90 | export const testFramework: ApiTestingFramework = new ApiTestingFramework(); 91 | -------------------------------------------------------------------------------- /apps/backend/src/api/users/create-email-user.route.ts: -------------------------------------------------------------------------------- 1 | import { UserProviderSchema } from "@/api-lib/types/user-provider.type.js"; 2 | import { UserSchema } from "@/api-lib/types/user.type.js"; 3 | import { UserProviderType } from "@/db/types/user-providers.db-types.js"; 4 | import { TypeRef } from "@/types.js"; 5 | import { type Static, Type } from "@sinclair/typebox"; 6 | import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; 7 | 8 | const CreateEMailUserRequestSchema = Type.Object( 9 | { 10 | givenName: Type.String({ 11 | minLength: 1, 12 | maxLength: 50, 13 | }), 14 | familyName: Type.String({ 15 | minLength: 1, 16 | maxLength: 100, 17 | }), 18 | email: Type.String({ 19 | minLength: 3, 20 | maxLength: 255, 21 | format: "email", 22 | }), 23 | password: Type.String({ 24 | minLength: 8, 25 | maxLength: 64, 26 | }), 27 | }, 28 | { $id: "CreateEMailUserRequest" }, 29 | ); 30 | 31 | export type CreateEMailUserRequest = Static; 32 | 33 | const CreateEMailUserResponseSchema = Type.Object( 34 | { 35 | user: TypeRef(UserSchema), 36 | provider: TypeRef(UserProviderSchema), 37 | }, 38 | { $id: "CreateEMailUserResponse" }, 39 | ); 40 | 41 | export type CreateEMailUserResponse = Static; 42 | 43 | export async function createEMailUserRoute(fastify: FastifyInstance) { 44 | fastify.addSchema(CreateEMailUserRequestSchema); 45 | fastify.addSchema(CreateEMailUserResponseSchema); 46 | 47 | fastify.post( 48 | "/email", 49 | { 50 | schema: { 51 | operationId: "createEMailUser", 52 | tags: ["user"], 53 | description: "Create an e-mail-based account", 54 | body: Type.Ref(CreateEMailUserRequestSchema), 55 | response: { 56 | "200": Type.Ref(CreateEMailUserResponseSchema), 57 | }, 58 | }, 59 | }, 60 | createEMailUserController, 61 | ); 62 | } 63 | 64 | export async function createEMailUserController( 65 | request: FastifyRequest<{ 66 | Body: CreateEMailUserRequest; 67 | }>, 68 | reply: FastifyReply, 69 | ) { 70 | const { familyName, givenName, password, email } = request.body; 71 | 72 | request.log.info(`Creating e-mail user: ${email}`); 73 | 74 | const user = await request.ctx.services.users.createEMailUser({ 75 | user: { 76 | givenName, 77 | familyName, 78 | }, 79 | email, 80 | password, 81 | }); 82 | 83 | const response: CreateEMailUserResponse = { 84 | user: { 85 | id: user.id, 86 | givenName: user.givenName, 87 | familyName: user.familyName, 88 | }, 89 | provider: { 90 | userId: user.id, 91 | providerType: UserProviderType.EMail, 92 | accountId: email, 93 | }, 94 | }; 95 | 96 | reply.send(response); 97 | } 98 | -------------------------------------------------------------------------------- /apps/backend/src/test-utils/test-server.ts: -------------------------------------------------------------------------------- 1 | import { ajvPlugins } from "@/api-lib/ajv-plugins.js"; 2 | import { errorHandler } from "@/api-lib/error-handler.js"; 3 | import routes from "@/api/index.js"; 4 | import { testPlugins } from "@/test-utils/plugins/index.js"; 5 | import { getLogger } from "@/utils/logger.js"; 6 | import { type TypeBoxTypeProvider, TypeBoxValidatorCompiler } from "@fastify/type-provider-typebox"; 7 | import chalk from "chalk"; 8 | import Fastify, { type InjectOptions, type LightMyRequestResponse } from "fastify"; 9 | import fp from "fastify-plugin"; 10 | import { nanoid } from "nanoid"; 11 | import { expect } from "vitest"; 12 | import { afterAll, beforeAll } from "vitest"; 13 | 14 | declare module "fastify" { 15 | interface InjectOptions { 16 | /** 17 | * If true, the expected status code check will be disabled. 18 | */ 19 | disableExpectedStatusCodeCheck?: boolean; 20 | /** 21 | * The expected status code of the response. 22 | * Default is 200. 23 | * If the response status code does not match this value, the test will fail 24 | * and log out the request and response. 25 | */ 26 | expectedStatusCode?: number; 27 | } 28 | } 29 | 30 | export function serverTestSetup(routes: any) { 31 | const logger = getLogger(); 32 | 33 | const fastify = Fastify({ 34 | ajv: { 35 | plugins: ajvPlugins, 36 | }, 37 | // @ts-ignore 38 | loggerInstance: logger, 39 | genReqId: () => nanoid(12), 40 | }) 41 | .withTypeProvider() 42 | .setValidatorCompiler(TypeBoxValidatorCompiler); 43 | 44 | fastify.setErrorHandler(errorHandler); 45 | fastify.register(fp(testPlugins)); 46 | fastify.register(routes); 47 | 48 | return fastify; 49 | } 50 | 51 | export const testFastify = serverTestSetup(routes); 52 | 53 | type InjectFunction = typeof testFastify.inject; 54 | 55 | const originalInject: InjectFunction = testFastify.inject.bind(testFastify); 56 | 57 | // @ts-ignore 58 | testFastify.inject = async (opts: InjectOptions): Promise => { 59 | const expectedStatusCode = opts.expectedStatusCode ?? 200; 60 | 61 | const result = await originalInject(opts); 62 | let responsePayload; 63 | 64 | try { 65 | responsePayload = result.json(); 66 | } catch { 67 | responsePayload = result.payload.toString(); 68 | } 69 | 70 | // Log the response if it's not what we expected 71 | if (!opts.disableExpectedStatusCodeCheck && expectedStatusCode !== result.statusCode) { 72 | const expectMessage = ` 73 | 74 | ${chalk.red(`Failed request: ${opts.method} ${opts.url}`)} 75 | ${chalk.red(`Expected status code: ${expectedStatusCode}, got ${result.statusCode}`)} 76 | 77 | ${chalk.red("==== response ====")} 78 | ${chalk.red(JSON.stringify(responsePayload, null, 2))} 79 | 80 | ${chalk.blue("==== request ====")} 81 | ${chalk.blue(JSON.stringify(opts, null, 2))} 82 | `; 83 | 84 | expect(result.statusCode, expectMessage).toBe(expectedStatusCode); 85 | } 86 | 87 | return result; 88 | }; 89 | 90 | beforeAll(async () => { 91 | await testFastify.ready(); 92 | }); 93 | 94 | afterAll(async () => { 95 | await testFastify.close(); 96 | }); 97 | -------------------------------------------------------------------------------- /packages/backend-client/openapi.yml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.3 2 | info: 3 | title: Backend API 4 | version: 1.0.0 5 | components: 6 | securitySchemes: 7 | bearerAuth: 8 | type: http 9 | scheme: bearer 10 | bearerFormat: JWT 11 | schemas: 12 | UserProviderType: 13 | enum: 14 | - EMail 15 | title: Auth provider type 16 | description: The type of the auth provider 17 | type: string 18 | User: 19 | type: object 20 | properties: 21 | id: 22 | description: ID of the user 23 | format: uuid 24 | type: string 25 | givenName: 26 | description: Given name of the user 27 | type: string 28 | familyName: 29 | description: Family name of the user 30 | type: string 31 | required: 32 | - id 33 | - givenName 34 | - familyName 35 | title: User 36 | UserProvider: 37 | type: object 38 | properties: 39 | userId: 40 | description: ID of the user 41 | format: uuid 42 | type: string 43 | providerType: 44 | enum: 45 | - EMail 46 | title: Auth provider type 47 | description: The type of the auth provider 48 | type: string 49 | accountId: 50 | description: The account id associated with the provider 51 | type: string 52 | required: 53 | - userId 54 | - providerType 55 | - accountId 56 | title: UserProvider 57 | CreateEMailUserRequest: 58 | type: object 59 | properties: 60 | givenName: 61 | minLength: 1 62 | maxLength: 50 63 | type: string 64 | familyName: 65 | minLength: 1 66 | maxLength: 100 67 | type: string 68 | email: 69 | minLength: 3 70 | maxLength: 255 71 | format: email 72 | type: string 73 | password: 74 | minLength: 8 75 | maxLength: 64 76 | type: string 77 | required: 78 | - givenName 79 | - familyName 80 | - email 81 | - password 82 | title: CreateEMailUserRequest 83 | CreateEMailUserResponse: 84 | type: object 85 | properties: 86 | user: 87 | $ref: "#/components/schemas/User" 88 | provider: 89 | $ref: "#/components/schemas/UserProvider" 90 | required: 91 | - user 92 | - provider 93 | title: CreateEMailUserResponse 94 | paths: 95 | /users/email: 96 | post: 97 | operationId: createEMailUser 98 | tags: 99 | - user 100 | description: Create an e-mail-based account 101 | requestBody: 102 | content: 103 | application/json: 104 | schema: 105 | $ref: "#/components/schemas/CreateEMailUserRequest" 106 | responses: 107 | "200": 108 | description: Default Response 109 | content: 110 | application/json: 111 | schema: 112 | $ref: "#/components/schemas/CreateEMailUserResponse" 113 | -------------------------------------------------------------------------------- /apps/backend/turbo/generators/actions/database-table.generator.ts: -------------------------------------------------------------------------------- 1 | import type { PlopTypes } from "@turbo/gen"; 2 | 3 | const isSingularCamelCase = (input: string): boolean => { 4 | const camelCaseCheck = /^[a-z][a-zA-Z0-9]*$/; 5 | // Simple heuristic for singular words: check if the word ends in 's' 6 | const isSingular = !input.endsWith("s"); 7 | return camelCaseCheck.test(input) && isSingular; 8 | }; 9 | 10 | export function databaseTableGenerator(plop: PlopTypes.NodePlopAPI) { 11 | plop.setGenerator("db:table", { 12 | description: "Generates a database table", 13 | prompts: [ 14 | { 15 | type: "input", 16 | name: "tableName", 17 | message: "The table name (e.g. user or userAccount). This should be singular and camel-cased.", 18 | validate: (input) => { 19 | if (isSingularCamelCase(input)) { 20 | return true; 21 | } 22 | return "Table name must be singular and camel-cased."; 23 | }, 24 | }, 25 | ], 26 | actions: [ 27 | { 28 | type: "add", 29 | path: "{{ turbo.paths.root }}/apps/backend/src/db/repositories/{{dashCase tableName}}s.repository.ts", 30 | templateFile: "templates/database-table/repository.hbs", 31 | }, 32 | { 33 | type: "add", 34 | path: "{{ turbo.paths.root }}/apps/backend/src/db/types/{{dashCase tableName}}s.db-types.ts", 35 | templateFile: "templates/database-table/db-types.hbs", 36 | }, 37 | { 38 | type: "add", 39 | path: "{{ turbo.paths.root }}/apps/backend/src/db/migrations/{{timestamp}}-create-{{dashCase tableName}}s-table.ts", 40 | templateFile: "templates/database-table/migration.hbs", 41 | }, 42 | { 43 | type: "append", 44 | pattern: "database-table-import", 45 | path: "{{ turbo.paths.root }}/apps/backend/src/db/types/index.ts", 46 | template: `import type { {{properCase tableName}}sTable } from "./{{dashCase tableName}}s.db-types";`, 47 | }, 48 | { 49 | type: "append", 50 | pattern: "database-table-interface", 51 | path: "{{ turbo.paths.root }}/apps/backend/src/db/types/index.ts", 52 | template: " {{ tableName }}s: {{ properCase tableName }}sTable;", 53 | }, 54 | { 55 | type: "append", 56 | pattern: "database-table-import", 57 | path: "{{ turbo.paths.root }}/apps/backend/src/db/repositories/index.ts", 58 | template: `import type { {{properCase tableName}}sRepository } from "./{{dashCase tableName}}s.repository";`, 59 | }, 60 | { 61 | type: "append", 62 | pattern: "database-table-repository", 63 | path: "{{ turbo.paths.root }}/apps/backend/src/db/repositories/index.ts", 64 | template: " {{ tableName }}s: {{ properCase tableName }}sRepository;", 65 | }, 66 | { 67 | type: "append", 68 | pattern: "repository-import", 69 | path: "{{ turbo.paths.root }}/apps/backend/src/api-lib/context.ts", 70 | template: `import { {{ properCase tableName }}sRepository } from "../db/repositories/{{dashCase tableName}}s.repository";`, 71 | }, 72 | { 73 | type: "append", 74 | pattern: "database-table-repository", 75 | path: "{{ turbo.paths.root }}/apps/backend/src/api-lib/context.ts", 76 | template: " {{ tableName }}s: new {{ properCase tableName }}sRepository(params),", 77 | }, 78 | ], 79 | }); 80 | } 81 | -------------------------------------------------------------------------------- /apps/backend/turbo/generators/actions/api-route.generator.ts: -------------------------------------------------------------------------------- 1 | import type { PlopTypes } from "@turbo/gen"; 2 | 3 | export function apiRouteGenerator(plop: PlopTypes.NodePlopAPI) { 4 | plop.setGenerator("api:route", { 5 | description: "Generates an API route", 6 | prompts: [ 7 | { 8 | type: "input", 9 | name: "routeResource", 10 | message: "The resource folder name (e.g. users). This should be plural.", 11 | }, 12 | { 13 | type: "list", 14 | name: "methodType", 15 | message: "HTTP method type", 16 | choices: ["GET", "POST", "PUT", "DELETE", "PATCH"], 17 | }, 18 | { 19 | type: "input", 20 | name: "routePath", 21 | message: "Route path (e.g. /noun or /:id)", 22 | }, 23 | { 24 | type: "input", 25 | name: "operationName", 26 | message: "Operation name (e.g. createUser)", 27 | }, 28 | ], 29 | actions: [ 30 | { 31 | type: "add", 32 | path: "{{ turbo.paths.root }}/apps/backend/src/api/{{routeResource}}/{{ dashCase operationName }}.ts", 33 | templateFile: "templates/api-route/operation.hbs", 34 | }, 35 | { 36 | type: "add", 37 | // apps/backend/src/api/users/index.ts 38 | path: "{{ turbo.paths.root }}/apps/backend/src/api/{{routeResource}}/index.ts", 39 | templateFile: "templates/api-route/resource-registration.hbs", 40 | skipIfExists: true, 41 | }, 42 | { 43 | type: "append", 44 | pattern: "route-imports", 45 | path: "{{ turbo.paths.root }}/apps/backend/src/api/{{routeResource}}/index.ts", 46 | template: `import { {{ operationName }}Route } from "./{{ dashCase operationName }}";`, 47 | }, 48 | { 49 | type: "append", 50 | pattern: "route-register", 51 | path: "{{ turbo.paths.root }}/apps/backend/src/api/{{routeResource}}/index.ts", 52 | template: " fastify.register({{ operationName }}Route);", 53 | }, 54 | { 55 | type: "append", 56 | pattern: "resource-imports", 57 | path: "{{ turbo.paths.root }}/apps/backend/src/api/routes.ts", 58 | template: 'import { register{{ properCase routeResource }}Routes } from "./{{routeResource}}";', 59 | unique: true, 60 | }, 61 | // There's some kind of bug where unique: true 62 | // isn't working as expected, so we have to manually make sure to not 63 | // add in a duplicate line 64 | { 65 | type: "modify", 66 | path: "{{ turbo.paths.root }}/apps/backend/src/api/routes.ts", 67 | transform: (fileContent, data) => { 68 | const templateString = ` fastify.register(register${plop.renderString("{{ properCase routeResource }}", data)}Routes);`; 69 | if (fileContent.includes(templateString)) { 70 | return fileContent; // If template string already exists, return unchanged content 71 | } 72 | const lines = fileContent.split("\n"); 73 | const patternIndex = lines.findIndex((line) => line.includes("resource-register")); 74 | if (patternIndex !== -1) { 75 | lines.splice(patternIndex + 1, 0, templateString); 76 | return lines.join("\n"); 77 | } 78 | return fileContent; // If pattern not found, return unchanged content 79 | }, 80 | }, 81 | { 82 | type: "add", 83 | path: "{{ turbo.paths.root }}/apps/backend/src/api/{{routeResource}}/__tests__/{{ dashCase operationName }}.test.ts", 84 | templateFile: "templates/api-route/api-test.hbs", 85 | }, 86 | ], 87 | }); 88 | } 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fastify Turbo Monorepo Starter 2 | 3 | This is a starter project for building an API server using Typescript, Fastify v5 and Kysely with Postgres. 4 | 5 | ## Features 6 | 7 | - Fastify v5 with Typescript. 8 | - It is setup as a monorepo using [`turbo`](https://turbo.build/) and [`pnpm`](https://pnpm.io/). 9 | - Outputs OpenAPI schema for the API and has a web UI for viewing it. 10 | - A sample REST test is included using Vitest. 11 | - Sample database migrations / repositories are included using Kysely. 12 | - An client SDK package is included to generate typescript client code from the API schema. 13 | - An error handler package is included to handle errors and return a consistent response. 14 | - Code generators using `turbo gen` to create new API endpoints and database tables. 15 | - Publish packages to npm and generate changelogs and releases using [`changesets`](https://github.com/changesets/changesets). 16 | 17 | ## Libraries and tools used 18 | 19 | - [`typescript`](https://www.typescriptlang.org/) 20 | - [`pnpm`](https://pnpm.io/) for package management 21 | - [`changesets`](https://github.com/changesets/changesets) for version and changelogs 22 | - [`commitlint`](https://commitlint.js.org/) for commit message linting 23 | - [`turbo`](https://turbo.build/) for monorepo management 24 | - [`fastify`](https://www.fastify.io/) for the API server framework 25 | - [`hash-runner`](https://github.com/theogravity/hash-runner) for caching builds 26 | - [`kysely`](https://kysely.dev/) for the database query builder 27 | - [`postgres`](https://www.postgresql.org/) + pgAdmin for the database 28 | - [`testcontainers`](https://www.testcontainers.org/) for testing with a sandboxed postgres instance 29 | - [`vitest`](https://vitest.dev/) for endpoint testing 30 | - [`loglayer`](https://github.com/theogravity/loglayer) for formatted logging 31 | - [`biome`](https://biomejs.dev/) for linting and formatting 32 | - [`syncpack`](https://jamiemason.github.io/syncpack/) for keeping package versions in sync 33 | - [`Hey API`](https://heyapi.vercel.app/) for generating the backend SDK using the generated OpenAPI schema from the backend 34 | 35 | ## Setup 36 | 37 | - [Install docker](https://docs.docker.com/engine/install/) 38 | - [Install pnpm](https://pnpm.io/installation) 39 | - `pnpm install turbo --global` 40 | - `pnpm install` 41 | - Copy `apps/backend/.env.example` to `apps/backend/.env` 42 | 43 | Start local postgres server: 44 | 45 | `docker compose up -d` 46 | 47 | Perform database migrations: 48 | 49 | `pnpm db:migrate:lastest` 50 | 51 | In Github settings (to publish packages and changelogs): 52 | - Edit `.changeset/config.json` to your repository 53 | - `Code and Automation > Actions > Workflow permissions` 54 | * `Read and write permissions` 55 | * `Allow Github Actions to create and approve pull requests` 56 | - `Secrets and variables > Actions` 57 | * `Repository Secrets > Actions > create NPM_TOKEN > your npm publish token` 58 | 59 | ## Development 60 | 61 | `turbo watch dev` 62 | 63 | - API server: http://localhost:3080 64 | - OpenAPI docs: http://localhost:3080/docs 65 | - PGAdmin: http://localhost:5050 66 | 67 | ## Testing 68 | 69 | Make sure docker is running as it uses `testcontainers` to spin up a 70 | temporary postgres database. 71 | 72 | `turbo test` 73 | 74 | ## Build 75 | 76 | `turbo build` 77 | 78 | ## Generate scaffolding 79 | 80 | Generators for the following: 81 | 82 | - New API endpoints + tests 83 | - Database tables and repositories 84 | 85 | `turbo gen` 86 | 87 | ## Database migrations 88 | 89 | - Create a migration: `pnpm db:create` 90 | - Run migrations: `pnpm db:latest` 91 | - Rollback migrations: `pnpm db:undo` 92 | 93 | ## Update all dependencies 94 | 95 | `pnpm syncpack:update` 96 | 97 | ## Development workflow / Add a new CHANGELOG.md entry + package versioning 98 | 99 | - Create a branch and make changes. 100 | - Create a new changeset entry: `pnpm changeset` 101 | - Commit your changes and create a pull request. 102 | - Merge the pull request 103 | - A new PR will be created with the changeset entry/ies. 104 | - When the PR is merged, the package versions will be bumped and published and the changelog updated. 105 | 106 | **note: To publish a package, `private: false` must be set in the package.json** 107 | 108 | ## Troubleshooting 109 | 110 | ### turbo watch dev failing 111 | 112 | ``` 113 | • Packages in scope: @internal/backend, @internal/backend-client, @internal/backend-errors, @internal/tsconfig 114 | • Running dev in 4 packages 115 | • Remote caching disabled 116 | × failed to connect to daemon 117 | ╰─▶ server is unavailable: channel closed 118 | ``` 119 | 120 | Try: 121 | 122 | `turbo daemon clean` 123 | 124 | Then try running `turbo watch dev` again. 125 | 126 | If you get: 127 | 128 | ``` 129 | • Packages in scope: @internal/backend, @internal/backend-client, @internal/backend-errors, @internal/tsconfig 130 | • Running dev in 4 packages 131 | • Remote caching disabled 132 | × discovery failed: bad grpc status code: The operation was cancelled 133 | ``` 134 | 135 | Wait a few minutes and try again. 136 | 137 | Related: 138 | 139 | - https://github.com/vercel/turborepo/issues/8491 140 | -------------------------------------------------------------------------------- /packages/backend-errors/src/lib.ts: -------------------------------------------------------------------------------- 1 | import type { ErrorObject } from "ajv"; 2 | import cleanStack from "clean-stack"; 3 | import { nanoid } from "nanoid"; 4 | import { serializeError } from "serialize-error"; 5 | import sprintf from "sprintf-js"; 6 | import { BackendErrorCodeDefs, type BackendErrorCodes } from "./error-codes"; 7 | 8 | const stackFilter = (path) => !/backend-errors/.test(path); 9 | 10 | export interface ApiValidationError { 11 | validation: ErrorObject[]; 12 | validationContext: string; 13 | message: string; 14 | } 15 | 16 | export interface ApiErrorParams { 17 | /** 18 | * Error code 19 | */ 20 | code: BackendErrorCodes; 21 | /** 22 | * Error message 23 | */ 24 | message: string; 25 | /** 26 | * HTTP status code 27 | */ 28 | statusCode: number; 29 | /** 30 | * Additional unsafe metadata that won't return to the client 31 | */ 32 | metadata?: Record; 33 | /** 34 | * Additional safe metadata that can be returned to the client 35 | */ 36 | metadataSafe?: Record; 37 | /** 38 | * AJV-style validation errors 39 | * @see https://fastify.dev/docs/latest/Reference/Validation-and-Serialization/#validation-messages-with-other-validation-libraries 40 | */ 41 | validationError?: ApiValidationError; 42 | /** 43 | * Error object. 44 | */ 45 | causedBy?: any; 46 | /** 47 | * If set to true, the error will be treated as an internal error on output to the client side, but the original 48 | * error will still be logged by the fastify error handler. 49 | */ 50 | isInternalError?: boolean; 51 | /** 52 | * Log level to use for the error. Default is "error". 53 | */ 54 | logLevel?: "error" | "warn" | "info" | "debug" | "trace" | "fatal"; 55 | /** 56 | * Don't log the error in the error handler as it has been logged elsewhere. 57 | */ 58 | doNotLog?: boolean; 59 | } 60 | 61 | export class ApiError extends Error { 62 | /** 63 | * Error code 64 | */ 65 | code: BackendErrorCodes; 66 | /** 67 | * HTTP status code 68 | */ 69 | statusCode: number; 70 | /** 71 | * Additional unsafe metadata that won't return to the client 72 | */ 73 | metadata?: Record; 74 | /** 75 | * Additional safe metadata that can be returned to the client 76 | */ 77 | metadataSafe?: Record; 78 | /** 79 | * Error ID 80 | */ 81 | errId: string; 82 | /** 83 | * Request ID 84 | */ 85 | reqId?: string; 86 | /** 87 | * Error object 88 | */ 89 | causedBy?: any; 90 | /** 91 | * If set to true, the error will be treated as an internal error on output to the client side, but the original 92 | * error will still be logged by the fastify error handler. 93 | */ 94 | isInternalError?: boolean; 95 | /** 96 | * Log level to use for the error. Default is "error". 97 | */ 98 | logLevel?: "error" | "warn" | "info" | "debug" | "trace" | "fatal"; 99 | /** 100 | * AJV-style validation errors 101 | * @see https://fastify.dev/docs/latest/Reference/Validation-and-Serialization/#validation-messages-with-other-validation-libraries 102 | */ 103 | validationError?: ApiValidationError; 104 | /** 105 | * Don't log the error in the error handler as it has been logged elsewhere. 106 | */ 107 | doNotLog?: boolean; 108 | 109 | constructor({ 110 | code, 111 | message, 112 | statusCode, 113 | metadata, 114 | metadataSafe, 115 | validationError, 116 | causedBy, 117 | isInternalError, 118 | logLevel, 119 | doNotLog, 120 | }: ApiErrorParams) { 121 | super(message); 122 | this.errId = nanoid(12); 123 | this.code = code; 124 | this.statusCode = statusCode; 125 | this.metadata = metadata; 126 | this.metadataSafe = metadataSafe; 127 | this.validationError = validationError; 128 | this.causedBy = causedBy; 129 | this.isInternalError = isInternalError || false; 130 | this.logLevel = logLevel || "error"; 131 | this.doNotLog = doNotLog || false; 132 | 133 | if (Error.captureStackTrace) { 134 | Error.captureStackTrace(this, ApiError); 135 | } 136 | 137 | this.stack = cleanStack(this.stack, { pathFilter: stackFilter }); 138 | } 139 | 140 | /** 141 | * Uses sprintf to format the error message 142 | */ 143 | formatMessage(...params: any[]) { 144 | this.message = sprintf.sprintf(this.message, ...params); 145 | return this; 146 | } 147 | 148 | toJSON() { 149 | let causedBy: any = {}; 150 | 151 | if (this.causedBy instanceof ApiError) { 152 | causedBy = this.causedBy.toJSON(); 153 | } else if (this.causedBy instanceof Error) { 154 | causedBy = serializeError(this.causedBy); 155 | } else if (this.causedBy) { 156 | try { 157 | causedBy = JSON.stringify(this.causedBy); 158 | } catch (e) { 159 | causedBy = this.causedBy; 160 | } 161 | } 162 | 163 | let metadata: Record = {}; 164 | 165 | if (this.metadata || this.metadataSafe) { 166 | metadata = { 167 | ...(this.metadata ? { metadata: this.metadata } : {}), 168 | ...(this.metadataSafe ? { metadataSafe: this.metadataSafe } : {}), 169 | }; 170 | } 171 | 172 | return { 173 | ...(this.reqId ? { reqId: this.reqId } : {}), 174 | errId: this.errId, 175 | code: this.code, 176 | message: this.message, 177 | statusCode: this.statusCode, 178 | ...metadata, 179 | ...(causedBy ? { causedBy } : {}), 180 | ...(this.validationError ? { validationError: this.validationError } : {}), 181 | stack: this.stack, 182 | }; 183 | } 184 | 185 | /** 186 | * Use to output production-safe values. Omits the following: 187 | * 188 | * - Stack trace 189 | * - Caused by 190 | * - unsafe metadata 191 | */ 192 | toJSONSafe() { 193 | return { 194 | ...(this.reqId ? { reqId: this.reqId } : {}), 195 | errId: this.errId, 196 | code: this.code, 197 | message: this.message, 198 | statusCode: this.statusCode, 199 | ...(this.metadataSafe ? { metadata: this.metadataSafe } : {}), 200 | ...(this.validationError ? { validationError: this.validationError } : {}), 201 | }; 202 | } 203 | } 204 | 205 | export type ApiErrorShort = Pick< 206 | ApiErrorParams, 207 | "doNotLog" | "metadataSafe" | "logLevel" | "isInternalError" | "code" | "metadata" | "validationError" | "causedBy" 208 | > & { 209 | message?: string; 210 | }; 211 | 212 | /** 213 | * Creates an API error and throws it 214 | * @throws {ApiError} 215 | */ 216 | export function throwApiError({ 217 | code, 218 | metadata, 219 | metadataSafe, 220 | validationError, 221 | message, 222 | causedBy, 223 | isInternalError, 224 | logLevel, 225 | doNotLog, 226 | }: ApiErrorShort) { 227 | throw createApiError({ 228 | code, 229 | message, 230 | metadata, 231 | metadataSafe, 232 | validationError, 233 | causedBy, 234 | isInternalError, 235 | logLevel, 236 | doNotLog, 237 | }); 238 | } 239 | 240 | /** 241 | * Creates an API error 242 | * @throws {ApiError} 243 | */ 244 | export function createApiError({ 245 | code, 246 | message, 247 | metadata, 248 | metadataSafe, 249 | validationError, 250 | causedBy, 251 | isInternalError, 252 | logLevel, 253 | doNotLog, 254 | }: ApiErrorShort) { 255 | const { message: predefinedMessage, statusCode } = BackendErrorCodeDefs[code]; 256 | 257 | return new ApiError({ 258 | code, 259 | message: message || predefinedMessage, 260 | statusCode, 261 | metadata, 262 | metadataSafe, 263 | validationError, 264 | causedBy, 265 | isInternalError, 266 | logLevel, 267 | doNotLog, 268 | }); 269 | } 270 | 271 | export function getErrorStatusCode(code: BackendErrorCodes): number { 272 | return BackendErrorCodeDefs[code].statusCode; 273 | } 274 | 275 | export function getErrorMessage(code: BackendErrorCodes): string { 276 | return BackendErrorCodeDefs[code].message; 277 | } 278 | --------------------------------------------------------------------------------