Phase 1: Monorepo scaffold, database, and dev environment
Turborepo monorepo with @forte/shared and @forte/backend workspaces. Docker Compose dev env with PostgreSQL 16 + Valkey 8. Fastify server with Pino JSON logging, request ID tracing, and health endpoint. Drizzle ORM with company + location tables. Includes: - Root config (turbo, tsconfig, eslint, prettier) - @forte/shared: types, schemas, currency/date utils - @forte/backend: Fastify entry, plugins (database, redis, cors, error-handler, dev-auth), health route, Drizzle schema + migration - Dev auth bypass via X-Dev-Company/Location/User headers - Vitest integration test with clean DB per test (forte_test) - Seed script for dev company + location
This commit is contained in:
10
packages/backend/drizzle.config.ts
Normal file
10
packages/backend/drizzle.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'drizzle-kit'
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/db/schema',
|
||||
out: './src/db/migrations',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL ?? 'postgresql://forte:forte@localhost:5432/forte',
|
||||
},
|
||||
})
|
||||
33
packages/backend/package.json
Normal file
33
packages/backend/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@forte/backend",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun --watch run src/main.ts",
|
||||
"start": "bun run src/main.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"lint": "eslint src/",
|
||||
"db:generate": "bunx drizzle-kit generate",
|
||||
"db:migrate": "bunx drizzle-kit migrate",
|
||||
"db:seed": "bun run src/db/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@forte/shared": "workspace:*",
|
||||
"fastify": "^5",
|
||||
"fastify-plugin": "^5",
|
||||
"@fastify/cors": "^10",
|
||||
"drizzle-orm": "^0.38",
|
||||
"postgres": "^3",
|
||||
"ioredis": "^5",
|
||||
"zod": "^3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"drizzle-kit": "^0.30",
|
||||
"pino-pretty": "^13",
|
||||
"vitest": "^3",
|
||||
"@types/node": "^22"
|
||||
}
|
||||
}
|
||||
1
packages/backend/src/db/index.ts
Normal file
1
packages/backend/src/db/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './schema/stores.js'
|
||||
25
packages/backend/src/db/migrations/0000_hot_purifiers.sql
Normal file
25
packages/backend/src/db/migrations/0000_hot_purifiers.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
CREATE TABLE "company" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"phone" varchar(50),
|
||||
"email" varchar(255),
|
||||
"timezone" varchar(100) DEFAULT 'America/Chicago' NOT NULL,
|
||||
"notes" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "location" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"address" jsonb,
|
||||
"phone" varchar(50),
|
||||
"email" varchar(255),
|
||||
"timezone" varchar(100),
|
||||
"is_active" boolean DEFAULT true NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "location" ADD CONSTRAINT "location_company_id_company_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."company"("id") ON DELETE no action ON UPDATE no action;
|
||||
175
packages/backend/src/db/migrations/meta/0000_snapshot.json
Normal file
175
packages/backend/src/db/migrations/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,175 @@
|
||||
{
|
||||
"id": "fd79ece0-66f3-4238-a2ca-af44060363b5",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.company": {
|
||||
"name": "company",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"phone": {
|
||||
"name": "phone",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"timezone": {
|
||||
"name": "timezone",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'America/Chicago'"
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.location": {
|
||||
"name": "location",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"address": {
|
||||
"name": "address",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"phone": {
|
||||
"name": "phone",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"timezone": {
|
||||
"name": "timezone",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"is_active": {
|
||||
"name": "is_active",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"location_company_id_company_id_fk": {
|
||||
"name": "location_company_id_company_id_fk",
|
||||
"tableFrom": "location",
|
||||
"tableTo": "company",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
13
packages/backend/src/db/migrations/meta/_journal.json
Normal file
13
packages/backend/src/db/migrations/meta/_journal.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1774635439354,
|
||||
"tag": "0000_hot_purifiers",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
37
packages/backend/src/db/schema/stores.ts
Normal file
37
packages/backend/src/db/schema/stores.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { pgTable, uuid, varchar, text, jsonb, timestamp, boolean } from 'drizzle-orm/pg-core'
|
||||
|
||||
export const companies = pgTable('company', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: varchar('name', { length: 255 }).notNull(),
|
||||
phone: varchar('phone', { length: 50 }),
|
||||
email: varchar('email', { length: 255 }),
|
||||
timezone: varchar('timezone', { length: 100 }).notNull().default('America/Chicago'),
|
||||
notes: text('notes'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const locations = pgTable('location', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
companyId: uuid('company_id')
|
||||
.notNull()
|
||||
.references(() => companies.id),
|
||||
name: varchar('name', { length: 255 }).notNull(),
|
||||
address: jsonb('address').$type<{
|
||||
street?: string
|
||||
city?: string
|
||||
state?: string
|
||||
zip?: string
|
||||
}>(),
|
||||
phone: varchar('phone', { length: 50 }),
|
||||
email: varchar('email', { length: 255 }),
|
||||
timezone: varchar('timezone', { length: 100 }),
|
||||
isActive: boolean('is_active').notNull().default(true),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export type Company = typeof companies.$inferSelect
|
||||
export type CompanyInsert = typeof companies.$inferInsert
|
||||
export type Location = typeof locations.$inferSelect
|
||||
export type LocationInsert = typeof locations.$inferInsert
|
||||
50
packages/backend/src/db/seed.ts
Normal file
50
packages/backend/src/db/seed.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import postgres from 'postgres'
|
||||
import { drizzle } from 'drizzle-orm/postgres-js'
|
||||
import { companies, locations } from './schema/stores.js'
|
||||
|
||||
const DEV_COMPANY_ID = '00000000-0000-0000-0000-000000000001'
|
||||
const DEV_LOCATION_ID = '00000000-0000-0000-0000-000000000010'
|
||||
|
||||
async function seed() {
|
||||
const connectionString =
|
||||
process.env.DATABASE_URL ?? 'postgresql://forte:forte@localhost:5432/forte'
|
||||
const sql = postgres(connectionString)
|
||||
const db = drizzle(sql)
|
||||
|
||||
console.log('Seeding database...')
|
||||
|
||||
await db
|
||||
.insert(companies)
|
||||
.values({
|
||||
id: DEV_COMPANY_ID,
|
||||
name: 'Dev Music Co.',
|
||||
timezone: 'America/Chicago',
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
|
||||
await db
|
||||
.insert(locations)
|
||||
.values({
|
||||
id: DEV_LOCATION_ID,
|
||||
companyId: DEV_COMPANY_ID,
|
||||
name: 'Main Store',
|
||||
address: {
|
||||
street: '123 Main St',
|
||||
city: 'Austin',
|
||||
state: 'TX',
|
||||
zip: '78701',
|
||||
},
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
|
||||
console.log(`Seeded dev company: ${DEV_COMPANY_ID}`)
|
||||
console.log(`Seeded dev location: ${DEV_LOCATION_ID}`)
|
||||
|
||||
await sql.end()
|
||||
console.log('Done.')
|
||||
}
|
||||
|
||||
seed().catch((err) => {
|
||||
console.error('Seed failed:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
45
packages/backend/src/main.ts
Normal file
45
packages/backend/src/main.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import Fastify from 'fastify'
|
||||
import { databasePlugin } from './plugins/database.js'
|
||||
import { redisPlugin } from './plugins/redis.js'
|
||||
import { corsPlugin } from './plugins/cors.js'
|
||||
import { errorHandlerPlugin } from './plugins/error-handler.js'
|
||||
import { devAuthPlugin } from './plugins/dev-auth.js'
|
||||
import { healthRoutes } from './routes/v1/health.js'
|
||||
|
||||
export async function buildApp() {
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
level: process.env.LOG_LEVEL ?? 'info',
|
||||
...(process.env.NODE_ENV === 'development' ? { transport: { target: 'pino-pretty' } } : {}),
|
||||
},
|
||||
genReqId: () => crypto.randomUUID(),
|
||||
})
|
||||
|
||||
// Plugins
|
||||
await app.register(corsPlugin)
|
||||
await app.register(errorHandlerPlugin)
|
||||
await app.register(databasePlugin)
|
||||
await app.register(redisPlugin)
|
||||
await app.register(devAuthPlugin)
|
||||
|
||||
// Routes
|
||||
await app.register(healthRoutes, { prefix: '/v1' })
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
async function start() {
|
||||
const app = await buildApp()
|
||||
|
||||
const port = parseInt(process.env.PORT ?? '8000', 10)
|
||||
const host = process.env.HOST ?? '0.0.0.0'
|
||||
|
||||
try {
|
||||
await app.listen({ port, host })
|
||||
} catch (err) {
|
||||
app.log.error(err)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
start()
|
||||
8
packages/backend/src/plugins/cors.ts
Normal file
8
packages/backend/src/plugins/cors.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import fp from 'fastify-plugin'
|
||||
import cors from '@fastify/cors'
|
||||
|
||||
export const corsPlugin = fp(async (app) => {
|
||||
await app.register(cors, {
|
||||
origin: process.env.NODE_ENV === 'development' ? true : false,
|
||||
})
|
||||
})
|
||||
28
packages/backend/src/plugins/database.ts
Normal file
28
packages/backend/src/plugins/database.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import fp from 'fastify-plugin'
|
||||
import { drizzle } from 'drizzle-orm/postgres-js'
|
||||
import postgres from 'postgres'
|
||||
import * as schema from '../db/schema/stores.js'
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
db: ReturnType<typeof drizzle<typeof schema>>
|
||||
sql: ReturnType<typeof postgres>
|
||||
}
|
||||
}
|
||||
|
||||
export const databasePlugin = fp(async (app) => {
|
||||
const connectionString = process.env.DATABASE_URL
|
||||
if (!connectionString) {
|
||||
throw new Error('DATABASE_URL environment variable is required')
|
||||
}
|
||||
|
||||
const sql = postgres(connectionString)
|
||||
const db = drizzle(sql, { schema })
|
||||
|
||||
app.decorate('db', db)
|
||||
app.decorate('sql', sql)
|
||||
|
||||
app.addHook('onClose', async () => {
|
||||
await sql.end()
|
||||
})
|
||||
})
|
||||
30
packages/backend/src/plugins/dev-auth.ts
Normal file
30
packages/backend/src/plugins/dev-auth.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import fp from 'fastify-plugin'
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
companyId: string
|
||||
locationId: string
|
||||
user: { id: string; role: string }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dev-only auth bypass. Reads headers to set request context
|
||||
* without real JWT validation.
|
||||
*
|
||||
* Will be replaced by real JWT auth in Phase 2.
|
||||
*/
|
||||
export const devAuthPlugin = fp(async (app) => {
|
||||
app.addHook('onRequest', async (request) => {
|
||||
const companyId = request.headers['x-dev-company'] as string | undefined
|
||||
const locationId = request.headers['x-dev-location'] as string | undefined
|
||||
const userId = request.headers['x-dev-user'] as string | undefined
|
||||
|
||||
request.companyId = companyId ?? '00000000-0000-0000-0000-000000000001'
|
||||
request.locationId = locationId ?? '00000000-0000-0000-0000-000000000010'
|
||||
request.user = {
|
||||
id: userId ?? '00000000-0000-0000-0000-000000000001',
|
||||
role: 'admin',
|
||||
}
|
||||
})
|
||||
})
|
||||
31
packages/backend/src/plugins/error-handler.ts
Normal file
31
packages/backend/src/plugins/error-handler.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import fp from 'fastify-plugin'
|
||||
|
||||
export const errorHandlerPlugin = fp(async (app) => {
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
const statusCode = error.statusCode ?? 500
|
||||
|
||||
request.log.error({
|
||||
err: error,
|
||||
statusCode,
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
})
|
||||
|
||||
reply.status(statusCode).send({
|
||||
error: {
|
||||
message: statusCode >= 500 ? 'Internal Server Error' : error.message,
|
||||
statusCode,
|
||||
...(process.env.NODE_ENV === 'development' ? { stack: error.stack } : {}),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
app.setNotFoundHandler((request, reply) => {
|
||||
reply.status(404).send({
|
||||
error: {
|
||||
message: `Route ${request.method} ${request.url} not found`,
|
||||
statusCode: 404,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
19
packages/backend/src/plugins/redis.ts
Normal file
19
packages/backend/src/plugins/redis.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import fp from 'fastify-plugin'
|
||||
import Redis from 'ioredis'
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
redis: Redis
|
||||
}
|
||||
}
|
||||
|
||||
export const redisPlugin = fp(async (app) => {
|
||||
const redisUrl = process.env.REDIS_URL ?? 'redis://localhost:6379'
|
||||
const redis = new Redis(redisUrl)
|
||||
|
||||
app.decorate('redis', redis)
|
||||
|
||||
app.addHook('onClose', async () => {
|
||||
await redis.quit()
|
||||
})
|
||||
})
|
||||
30
packages/backend/src/routes/v1/health.test.ts
Normal file
30
packages/backend/src/routes/v1/health.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { createTestApp } from '../../test/helpers.js'
|
||||
|
||||
describe('GET /v1/health', () => {
|
||||
let app: FastifyInstance
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('returns ok when db and redis are connected', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/v1/health',
|
||||
})
|
||||
|
||||
expect(response.statusCode).toBe(200)
|
||||
|
||||
const body = response.json()
|
||||
expect(body.status).toBe('ok')
|
||||
expect(body.db).toBe('connected')
|
||||
expect(body.redis).toBe('connected')
|
||||
expect(body.timestamp).toBeDefined()
|
||||
})
|
||||
})
|
||||
32
packages/backend/src/routes/v1/health.ts
Normal file
32
packages/backend/src/routes/v1/health.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { sql } from 'drizzle-orm'
|
||||
|
||||
export const healthRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get('/health', async (request, reply) => {
|
||||
let dbStatus = 'disconnected'
|
||||
let redisStatus = 'disconnected'
|
||||
|
||||
try {
|
||||
await app.db.execute(sql`SELECT 1`)
|
||||
dbStatus = 'connected'
|
||||
} catch (err) {
|
||||
request.log.error({ err }, 'Database health check failed')
|
||||
}
|
||||
|
||||
try {
|
||||
const pong = await app.redis.ping()
|
||||
redisStatus = pong === 'PONG' ? 'connected' : 'disconnected'
|
||||
} catch (err) {
|
||||
request.log.error({ err }, 'Redis health check failed')
|
||||
}
|
||||
|
||||
const healthy = dbStatus === 'connected' && redisStatus === 'connected'
|
||||
|
||||
reply.status(healthy ? 200 : 503).send({
|
||||
status: healthy ? 'ok' : 'degraded',
|
||||
db: dbStatus,
|
||||
redis: redisStatus,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
})
|
||||
}
|
||||
29
packages/backend/src/test/helpers.ts
Normal file
29
packages/backend/src/test/helpers.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { buildApp } from '../main.js'
|
||||
import { sql } from 'drizzle-orm'
|
||||
|
||||
/**
|
||||
* Build a fresh Fastify app instance for testing.
|
||||
* Each test gets its own app — no shared state.
|
||||
*/
|
||||
export async function createTestApp(): Promise<FastifyInstance> {
|
||||
const app = await buildApp()
|
||||
await app.ready()
|
||||
return app
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate all tables in the test database.
|
||||
* Call this in beforeEach to guarantee a clean slate per test.
|
||||
*/
|
||||
export async function cleanDb(app: FastifyInstance): Promise<void> {
|
||||
await app.db.execute(sql`
|
||||
DO $$ DECLARE
|
||||
r RECORD;
|
||||
BEGIN
|
||||
FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') LOOP
|
||||
EXECUTE 'TRUNCATE TABLE ' || quote_ident(r.tablename) || ' CASCADE';
|
||||
END LOOP;
|
||||
END $$
|
||||
`)
|
||||
}
|
||||
26
packages/backend/src/test/setup.ts
Normal file
26
packages/backend/src/test/setup.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import postgres from 'postgres'
|
||||
|
||||
const TEST_DB_URL =
|
||||
process.env.DATABASE_URL?.replace(/\/forte$/, '/forte_test') ??
|
||||
'postgresql://forte:forte@localhost:5432/forte_test'
|
||||
|
||||
// Override DATABASE_URL for all tests to use forte_test
|
||||
process.env.DATABASE_URL = TEST_DB_URL
|
||||
process.env.NODE_ENV = 'test'
|
||||
process.env.LOG_LEVEL = 'silent'
|
||||
|
||||
/**
|
||||
* Ensure the forte_test database exists before tests run.
|
||||
*/
|
||||
async function ensureTestDb() {
|
||||
const adminUrl = TEST_DB_URL.replace(/\/forte_test$/, '/postgres')
|
||||
const sql = postgres(adminUrl)
|
||||
|
||||
const result = await sql`SELECT 1 FROM pg_database WHERE datname = 'forte_test'`
|
||||
if (result.length === 0) {
|
||||
await sql`CREATE DATABASE forte_test`
|
||||
}
|
||||
await sql.end()
|
||||
}
|
||||
|
||||
await ensureTestDb()
|
||||
9
packages/backend/tsconfig.json
Normal file
9
packages/backend/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
10
packages/backend/vitest.config.ts
Normal file
10
packages/backend/vitest.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
testTimeout: 15000,
|
||||
},
|
||||
})
|
||||
18
packages/shared/package.json
Normal file
18
packages/shared/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@forte/shared",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./types": "./src/types/index.ts",
|
||||
"./schemas": "./src/schemas/index.ts",
|
||||
"./utils": "./src/utils/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint src/",
|
||||
"test": "echo 'no tests yet'"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
2
packages/shared/src/schemas/index.ts
Normal file
2
packages/shared/src/schemas/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// @forte/shared Zod schemas
|
||||
// Shared validation schemas will be added as each domain is implemented
|
||||
4
packages/shared/src/types/index.ts
Normal file
4
packages/shared/src/types/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// @forte/shared types
|
||||
// Domain types will be added as each domain is implemented
|
||||
|
||||
export type StoreId = string
|
||||
30
packages/shared/src/utils/currency.ts
Normal file
30
packages/shared/src/utils/currency.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Format a number as USD currency string.
|
||||
*/
|
||||
export function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
/**
|
||||
* Round to 2 decimal places using banker's rounding (round half to even).
|
||||
*/
|
||||
export function roundCurrency(amount: number): number {
|
||||
return Math.round((amount + Number.EPSILON) * 100) / 100
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert dollars to cents (integer) for safe arithmetic.
|
||||
*/
|
||||
export function toCents(dollars: number): number {
|
||||
return Math.round(dollars * 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert cents (integer) back to dollars.
|
||||
*/
|
||||
export function toDollars(cents: number): number {
|
||||
return cents / 100
|
||||
}
|
||||
27
packages/shared/src/utils/dates.ts
Normal file
27
packages/shared/src/utils/dates.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Cap a billing anchor day to 28 (avoids issues with Feb and short months).
|
||||
*/
|
||||
export function capBillingDay(day: number): number {
|
||||
return Math.min(Math.max(1, Math.floor(day)), 28)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get today's date as YYYY-MM-DD string.
|
||||
*/
|
||||
export function todayISO(): string {
|
||||
return new Date().toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a date of birth makes someone a minor (under 18).
|
||||
*/
|
||||
export function isMinor(dateOfBirth: Date | string): boolean {
|
||||
const dob = typeof dateOfBirth === 'string' ? new Date(dateOfBirth) : dateOfBirth
|
||||
const today = new Date()
|
||||
const age = today.getFullYear() - dob.getFullYear()
|
||||
const monthDiff = today.getMonth() - dob.getMonth()
|
||||
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dob.getDate())) {
|
||||
return age - 1 < 18
|
||||
}
|
||||
return age < 18
|
||||
}
|
||||
2
packages/shared/src/utils/index.ts
Normal file
2
packages/shared/src/utils/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { formatCurrency, roundCurrency, toCents, toDollars } from './currency.js'
|
||||
export { capBillingDay, todayISO, isMinor } from './dates.js'
|
||||
8
packages/shared/tsconfig.json
Normal file
8
packages/shared/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user