diff --git a/packages/backend/src/db/index.ts b/packages/backend/src/db/index.ts index 5d0e29f..3d68ee2 100644 --- a/packages/backend/src/db/index.ts +++ b/packages/backend/src/db/index.ts @@ -1,2 +1,3 @@ export * from './schema/stores.js' export * from './schema/users.js' +export * from './schema/accounts.js' diff --git a/packages/backend/src/db/migrations/0003_round_captain_midlands.sql b/packages/backend/src/db/migrations/0003_round_captain_midlands.sql new file mode 100644 index 0000000..340a886 --- /dev/null +++ b/packages/backend/src/db/migrations/0003_round_captain_midlands.sql @@ -0,0 +1,51 @@ +CREATE TYPE "public"."billing_mode" AS ENUM('consolidated', 'split');--> statement-breakpoint +CREATE TYPE "public"."payment_processor" AS ENUM('stripe', 'global_payments');--> statement-breakpoint +CREATE TABLE "account_processor_link" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "account_id" uuid NOT NULL, + "company_id" uuid NOT NULL, + "processor" "payment_processor" NOT NULL, + "processor_customer_id" varchar(255) NOT NULL, + "is_active" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "account" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "account_number" varchar(50), + "name" varchar(255) NOT NULL, + "email" varchar(255), + "phone" varchar(50), + "address" jsonb, + "billing_mode" "billing_mode" DEFAULT 'consolidated' NOT NULL, + "notes" text, + "is_active" boolean DEFAULT true NOT NULL, + "legacy_id" varchar(255), + "legacy_source" varchar(50), + "migrated_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "member" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "account_id" uuid NOT NULL, + "company_id" uuid NOT NULL, + "first_name" varchar(100) NOT NULL, + "last_name" varchar(100) NOT NULL, + "date_of_birth" date, + "is_minor" boolean DEFAULT false NOT NULL, + "email" varchar(255), + "phone" varchar(50), + "notes" text, + "legacy_id" varchar(255), + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "account_processor_link" ADD CONSTRAINT "account_processor_link_account_id_account_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."account"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "account_processor_link" ADD CONSTRAINT "account_processor_link_company_id_company_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."company"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "account" ADD CONSTRAINT "account_company_id_company_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."company"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "member" ADD CONSTRAINT "member_account_id_account_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."account"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "member" ADD CONSTRAINT "member_company_id_company_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."company"("id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/packages/backend/src/db/migrations/meta/0003_snapshot.json b/packages/backend/src/db/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..5c9d3a7 --- /dev/null +++ b/packages/backend/src/db/migrations/meta/0003_snapshot.json @@ -0,0 +1,628 @@ +{ + "id": "906895d3-deba-442a-991d-d9245c960d18", + "prevId": "69ee3c88-af63-4f16-88e1-15e4ce355a87", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.user": { + "name": "user", + "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 + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'staff'" + }, + "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": { + "user_company_id_company_id_fk": { + "name": "user_company_id_company_id_fk", + "tableFrom": "user", + "tableTo": "company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "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 + }, + "public.account_processor_link": { + "name": "account_processor_link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "processor": { + "name": "processor", + "type": "payment_processor", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "processor_customer_id": { + "name": "processor_customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "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()" + } + }, + "indexes": {}, + "foreignKeys": { + "account_processor_link_account_id_account_id_fk": { + "name": "account_processor_link_account_id_account_id_fk", + "tableFrom": "account_processor_link", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "account_processor_link_company_id_company_id_fk": { + "name": "account_processor_link_company_id_company_id_fk", + "tableFrom": "account_processor_link", + "tableTo": "company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "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 + }, + "account_number": { + "name": "account_number", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "billing_mode": { + "name": "billing_mode", + "type": "billing_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'consolidated'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "legacy_id": { + "name": "legacy_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "legacy_source": { + "name": "legacy_source", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "migrated_at": { + "name": "migrated_at", + "type": "timestamp with time zone", + "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": { + "account_company_id_company_id_fk": { + "name": "account_company_id_company_id_fk", + "tableFrom": "account", + "tableTo": "company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "date_of_birth": { + "name": "date_of_birth", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "is_minor": { + "name": "is_minor", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "legacy_id": { + "name": "legacy_id", + "type": "varchar(255)", + "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": { + "member_account_id_account_id_fk": { + "name": "member_account_id_account_id_fk", + "tableFrom": "member", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "member_company_id_company_id_fk": { + "name": "member_company_id_company_id_fk", + "tableFrom": "member", + "tableTo": "company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "admin", + "manager", + "staff", + "technician", + "instructor" + ] + }, + "public.billing_mode": { + "name": "billing_mode", + "schema": "public", + "values": [ + "consolidated", + "split" + ] + }, + "public.payment_processor": { + "name": "payment_processor", + "schema": "public", + "values": [ + "stripe", + "global_payments" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/backend/src/db/migrations/meta/_journal.json b/packages/backend/src/db/migrations/meta/_journal.json index d491316..ee71e4b 100644 --- a/packages/backend/src/db/migrations/meta/_journal.json +++ b/packages/backend/src/db/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1774648659531, "tag": "0002_bumpy_mandarin", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1774651222033, + "tag": "0003_round_captain_midlands", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/backend/src/db/schema/accounts.ts b/packages/backend/src/db/schema/accounts.ts new file mode 100644 index 0000000..b940009 --- /dev/null +++ b/packages/backend/src/db/schema/accounts.ts @@ -0,0 +1,83 @@ +import { + pgTable, + uuid, + varchar, + text, + jsonb, + timestamp, + boolean, + date, + pgEnum, +} from 'drizzle-orm/pg-core' +import { companies } from './stores.js' + +export const billingModeEnum = pgEnum('billing_mode', ['consolidated', 'split']) + +export const accounts = pgTable('account', { + id: uuid('id').primaryKey().defaultRandom(), + companyId: uuid('company_id') + .notNull() + .references(() => companies.id), + accountNumber: varchar('account_number', { length: 50 }), + name: varchar('name', { length: 255 }).notNull(), + email: varchar('email', { length: 255 }), + phone: varchar('phone', { length: 50 }), + address: jsonb('address').$type<{ + street?: string + city?: string + state?: string + zip?: string + }>(), + billingMode: billingModeEnum('billing_mode').notNull().default('consolidated'), + notes: text('notes'), + isActive: boolean('is_active').notNull().default(true), + legacyId: varchar('legacy_id', { length: 255 }), + legacySource: varchar('legacy_source', { length: 50 }), + migratedAt: timestamp('migrated_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}) + +export const members = pgTable('member', { + id: uuid('id').primaryKey().defaultRandom(), + accountId: uuid('account_id') + .notNull() + .references(() => accounts.id), + companyId: uuid('company_id') + .notNull() + .references(() => companies.id), + firstName: varchar('first_name', { length: 100 }).notNull(), + lastName: varchar('last_name', { length: 100 }).notNull(), + dateOfBirth: date('date_of_birth'), + isMinor: boolean('is_minor').notNull().default(false), + email: varchar('email', { length: 255 }), + phone: varchar('phone', { length: 50 }), + notes: text('notes'), + legacyId: varchar('legacy_id', { length: 255 }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}) + +export const processorEnum = pgEnum('payment_processor', ['stripe', 'global_payments']) + +export const accountProcessorLinks = pgTable('account_processor_link', { + id: uuid('id').primaryKey().defaultRandom(), + accountId: uuid('account_id') + .notNull() + .references(() => accounts.id), + companyId: uuid('company_id') + .notNull() + .references(() => companies.id), + processor: processorEnum('processor').notNull(), + processorCustomerId: varchar('processor_customer_id', { length: 255 }).notNull(), + isActive: boolean('is_active').notNull().default(true), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}) + +export type AccountProcessorLink = typeof accountProcessorLinks.$inferSelect +export type AccountProcessorLinkInsert = typeof accountProcessorLinks.$inferInsert + +export type Account = typeof accounts.$inferSelect +export type AccountInsert = typeof accounts.$inferInsert +export type Member = typeof members.$inferSelect +export type MemberInsert = typeof members.$inferInsert diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts index abd7701..49ae0f1 100644 --- a/packages/backend/src/main.ts +++ b/packages/backend/src/main.ts @@ -7,6 +7,7 @@ import { authPlugin } from './plugins/auth.js' import { devAuthPlugin } from './plugins/dev-auth.js' import { healthRoutes } from './routes/v1/health.js' import { authRoutes } from './routes/v1/auth.js' +import { accountRoutes } from './routes/v1/accounts.js' export async function buildApp() { const app = Fastify({ @@ -33,6 +34,7 @@ export async function buildApp() { // Routes await app.register(healthRoutes, { prefix: '/v1' }) await app.register(authRoutes, { prefix: '/v1' }) + await app.register(accountRoutes, { prefix: '/v1' }) return app } diff --git a/packages/backend/src/plugins/database.ts b/packages/backend/src/plugins/database.ts index c8f673e..9cde59b 100644 --- a/packages/backend/src/plugins/database.ts +++ b/packages/backend/src/plugins/database.ts @@ -3,8 +3,9 @@ import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' import * as storeSchema from '../db/schema/stores.js' import * as userSchema from '../db/schema/users.js' +import * as accountSchema from '../db/schema/accounts.js' -const schema = { ...storeSchema, ...userSchema } +const schema = { ...storeSchema, ...userSchema, ...accountSchema } declare module 'fastify' { interface FastifyInstance { diff --git a/packages/backend/src/routes/v1/accounts.test.ts b/packages/backend/src/routes/v1/accounts.test.ts new file mode 100644 index 0000000..7bb7609 --- /dev/null +++ b/packages/backend/src/routes/v1/accounts.test.ts @@ -0,0 +1,302 @@ +import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'bun:test' +import type { FastifyInstance } from 'fastify' +import { + createTestApp, + cleanDb, + seedTestCompany, + registerAndLogin, + TEST_COMPANY_ID, +} from '../../test/helpers.js' + +describe('Account routes', () => { + let app: FastifyInstance + let token: string + + beforeAll(async () => { + app = await createTestApp() + }) + + beforeEach(async () => { + await cleanDb(app) + await seedTestCompany(app) + const auth = await registerAndLogin(app) + token = auth.token + }) + + afterAll(async () => { + await app.close() + }) + + describe('POST /v1/accounts', () => { + it('creates an account', async () => { + const response = await app.inject({ + method: 'POST', + url: '/v1/accounts', + headers: { authorization: `Bearer ${token}` }, + payload: { + name: 'Smith Family', + email: 'smith@example.com', + phone: '512-555-1234', + billingMode: 'consolidated', + }, + }) + + expect(response.statusCode).toBe(201) + const body = response.json() + expect(body.name).toBe('Smith Family') + expect(body.email).toBe('smith@example.com') + expect(body.companyId).toBe(TEST_COMPANY_ID) + expect(body.billingMode).toBe('consolidated') + }) + + it('rejects missing name', async () => { + const response = await app.inject({ + method: 'POST', + url: '/v1/accounts', + headers: { authorization: `Bearer ${token}` }, + payload: { email: 'noname@test.com' }, + }) + + expect(response.statusCode).toBe(400) + }) + }) + + describe('GET /v1/accounts', () => { + it('lists accounts for the company', async () => { + await app.inject({ + method: 'POST', + url: '/v1/accounts', + headers: { authorization: `Bearer ${token}` }, + payload: { name: 'Account One' }, + }) + await app.inject({ + method: 'POST', + url: '/v1/accounts', + headers: { authorization: `Bearer ${token}` }, + payload: { name: 'Account Two' }, + }) + + const response = await app.inject({ + method: 'GET', + url: '/v1/accounts', + headers: { authorization: `Bearer ${token}` }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json() + expect(body.length).toBe(2) + }) + }) + + describe('GET /v1/accounts/search', () => { + it('searches by name', async () => { + await app.inject({ + method: 'POST', + url: '/v1/accounts', + headers: { authorization: `Bearer ${token}` }, + payload: { name: 'Johnson Family', phone: '555-9999' }, + }) + await app.inject({ + method: 'POST', + url: '/v1/accounts', + headers: { authorization: `Bearer ${token}` }, + payload: { name: 'Williams Music' }, + }) + + const response = await app.inject({ + method: 'GET', + url: '/v1/accounts/search?q=johnson', + headers: { authorization: `Bearer ${token}` }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json() + expect(body.length).toBe(1) + expect(body[0].name).toBe('Johnson Family') + }) + + it('searches by phone', async () => { + await app.inject({ + method: 'POST', + url: '/v1/accounts', + headers: { authorization: `Bearer ${token}` }, + payload: { name: 'Phone Test', phone: '512-867-5309' }, + }) + + const response = await app.inject({ + method: 'GET', + url: '/v1/accounts/search?q=867-5309', + headers: { authorization: `Bearer ${token}` }, + }) + + expect(response.statusCode).toBe(200) + expect(response.json().length).toBe(1) + }) + }) + + describe('PATCH /v1/accounts/:id', () => { + it('updates an account', async () => { + const createRes = await app.inject({ + method: 'POST', + url: '/v1/accounts', + headers: { authorization: `Bearer ${token}` }, + payload: { name: 'Original Name' }, + }) + const id = createRes.json().id + + const response = await app.inject({ + method: 'PATCH', + url: `/v1/accounts/${id}`, + headers: { authorization: `Bearer ${token}` }, + payload: { name: 'Updated Name' }, + }) + + expect(response.statusCode).toBe(200) + expect(response.json().name).toBe('Updated Name') + }) + }) + + describe('DELETE /v1/accounts/:id', () => { + it('soft-deletes an account', async () => { + const createRes = await app.inject({ + method: 'POST', + url: '/v1/accounts', + headers: { authorization: `Bearer ${token}` }, + payload: { name: 'To Delete' }, + }) + const id = createRes.json().id + + const delRes = await app.inject({ + method: 'DELETE', + url: `/v1/accounts/${id}`, + headers: { authorization: `Bearer ${token}` }, + }) + expect(delRes.statusCode).toBe(200) + expect(delRes.json().isActive).toBe(false) + + // Should not appear in list + const listRes = await app.inject({ + method: 'GET', + url: '/v1/accounts', + headers: { authorization: `Bearer ${token}` }, + }) + expect(listRes.json().length).toBe(0) + }) + }) +}) + +describe('Member routes', () => { + let app: FastifyInstance + let token: string + let accountId: string + + beforeAll(async () => { + app = await createTestApp() + }) + + beforeEach(async () => { + await cleanDb(app) + await seedTestCompany(app) + const auth = await registerAndLogin(app, { email: `member-test-${Date.now()}@test.com` }) + token = auth.token + + const accountRes = await app.inject({ + method: 'POST', + url: '/v1/accounts', + headers: { authorization: `Bearer ${token}` }, + payload: { name: 'Test Family' }, + }) + accountId = accountRes.json().id + }) + + afterAll(async () => { + await app.close() + }) + + describe('POST /v1/accounts/:accountId/members', () => { + it('creates a member', async () => { + const response = await app.inject({ + method: 'POST', + url: `/v1/accounts/${accountId}/members`, + headers: { authorization: `Bearer ${token}` }, + payload: { + firstName: 'Emma', + lastName: 'Chen', + dateOfBirth: '2015-03-15', + email: 'emma@test.com', + }, + }) + + expect(response.statusCode).toBe(201) + const body = response.json() + expect(body.firstName).toBe('Emma') + expect(body.lastName).toBe('Chen') + expect(body.isMinor).toBe(true) + expect(body.accountId).toBe(accountId) + }) + + it('marks adult members as not minor', async () => { + const response = await app.inject({ + method: 'POST', + url: `/v1/accounts/${accountId}/members`, + headers: { authorization: `Bearer ${token}` }, + payload: { + firstName: 'John', + lastName: 'Chen', + dateOfBirth: '1985-06-20', + }, + }) + + expect(response.statusCode).toBe(201) + expect(response.json().isMinor).toBe(false) + }) + }) + + describe('GET /v1/accounts/:accountId/members', () => { + it('lists members for an account', async () => { + await app.inject({ + method: 'POST', + url: `/v1/accounts/${accountId}/members`, + headers: { authorization: `Bearer ${token}` }, + payload: { firstName: 'Child', lastName: 'One' }, + }) + await app.inject({ + method: 'POST', + url: `/v1/accounts/${accountId}/members`, + headers: { authorization: `Bearer ${token}` }, + payload: { firstName: 'Child', lastName: 'Two' }, + }) + + const response = await app.inject({ + method: 'GET', + url: `/v1/accounts/${accountId}/members`, + headers: { authorization: `Bearer ${token}` }, + }) + + expect(response.statusCode).toBe(200) + expect(response.json().length).toBe(2) + }) + }) + + describe('PATCH /members/:id', () => { + it('updates a member and recalculates isMinor', async () => { + const createRes = await app.inject({ + method: 'POST', + url: `/v1/accounts/${accountId}/members`, + headers: { authorization: `Bearer ${token}` }, + payload: { firstName: 'Kid', lastName: 'Test', dateOfBirth: '2015-01-01' }, + }) + const memberId = createRes.json().id + + const response = await app.inject({ + method: 'PATCH', + url: `/v1/members/${memberId}`, + headers: { authorization: `Bearer ${token}` }, + payload: { dateOfBirth: '1980-01-01' }, + }) + + expect(response.statusCode).toBe(200) + expect(response.json().isMinor).toBe(false) + }) + }) +}) diff --git a/packages/backend/src/routes/v1/accounts.ts b/packages/backend/src/routes/v1/accounts.ts new file mode 100644 index 0000000..14857b2 --- /dev/null +++ b/packages/backend/src/routes/v1/accounts.ts @@ -0,0 +1,148 @@ +import type { FastifyPluginAsync } from 'fastify' +import { + AccountCreateSchema, + AccountUpdateSchema, + MemberCreateSchema, + MemberUpdateSchema, + AccountSearchSchema, +} from '@forte/shared/schemas' +import { AccountService, MemberService } from '../../services/account.service.js' + +export const accountRoutes: FastifyPluginAsync = async (app) => { + // --- Accounts --- + + app.post( + '/accounts', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const parsed = AccountCreateSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + const account = await AccountService.create(app.db, request.companyId, parsed.data) + return reply.status(201).send(account) + }, + ) + + app.get( + '/accounts', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const accounts = await AccountService.list(app.db, request.companyId) + return reply.send(accounts) + }, + ) + + app.get( + '/accounts/search', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const parsed = AccountSearchSchema.safeParse(request.query) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Query parameter q is required', statusCode: 400 } }) + } + const results = await AccountService.search(app.db, request.companyId, parsed.data.q) + return reply.send(results) + }, + ) + + app.get( + '/accounts/:id', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const { id } = request.params as { id: string } + const account = await AccountService.getById(app.db, request.companyId, id) + if (!account) return reply.status(404).send({ error: { message: 'Account not found', statusCode: 404 } }) + return reply.send(account) + }, + ) + + app.patch( + '/accounts/:id', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const { id } = request.params as { id: string } + const parsed = AccountUpdateSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + const account = await AccountService.update(app.db, request.companyId, id, parsed.data) + if (!account) return reply.status(404).send({ error: { message: 'Account not found', statusCode: 404 } }) + return reply.send(account) + }, + ) + + app.delete( + '/accounts/:id', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const { id } = request.params as { id: string } + const account = await AccountService.softDelete(app.db, request.companyId, id) + if (!account) return reply.status(404).send({ error: { message: 'Account not found', statusCode: 404 } }) + return reply.send(account) + }, + ) + + // --- Members --- + + app.post( + '/accounts/:accountId/members', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const { accountId } = request.params as { accountId: string } + const parsed = MemberCreateSchema.safeParse({ ...request.body as object, accountId }) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + const member = await MemberService.create(app.db, request.companyId, parsed.data) + return reply.status(201).send(member) + }, + ) + + app.get( + '/accounts/:accountId/members', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const { accountId } = request.params as { accountId: string } + const membersList = await MemberService.listByAccount(app.db, request.companyId, accountId) + return reply.send(membersList) + }, + ) + + app.get( + '/members/:id', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const { id } = request.params as { id: string } + const member = await MemberService.getById(app.db, request.companyId, id) + if (!member) return reply.status(404).send({ error: { message: 'Member not found', statusCode: 404 } }) + return reply.send(member) + }, + ) + + app.patch( + '/members/:id', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const { id } = request.params as { id: string } + const parsed = MemberUpdateSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + const member = await MemberService.update(app.db, request.companyId, id, parsed.data) + if (!member) return reply.status(404).send({ error: { message: 'Member not found', statusCode: 404 } }) + return reply.send(member) + }, + ) + + app.delete( + '/members/:id', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const { id } = request.params as { id: string } + const member = await MemberService.delete(app.db, request.companyId, id) + if (!member) return reply.status(404).send({ error: { message: 'Member not found', statusCode: 404 } }) + return reply.send(member) + }, + ) +} diff --git a/packages/backend/src/services/account.service.ts b/packages/backend/src/services/account.service.ts new file mode 100644 index 0000000..aca6c81 --- /dev/null +++ b/packages/backend/src/services/account.service.ts @@ -0,0 +1,148 @@ +import { eq, and, or, ilike } from 'drizzle-orm' +import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js' +import { accounts, members } from '../db/schema/accounts.js' +import type { AccountCreateInput, AccountUpdateInput } from '@forte/shared/schemas' +import { isMinor } from '@forte/shared/utils' + +export const AccountService = { + async create(db: PostgresJsDatabase, companyId: string, input: AccountCreateInput) { + const [account] = await db + .insert(accounts) + .values({ + companyId, + name: input.name, + email: input.email, + phone: input.phone, + address: input.address, + billingMode: input.billingMode, + notes: input.notes, + }) + .returning() + + return account + }, + + async getById(db: PostgresJsDatabase, companyId: string, id: string) { + const [account] = await db + .select() + .from(accounts) + .where(and(eq(accounts.id, id), eq(accounts.companyId, companyId))) + .limit(1) + + return account ?? null + }, + + async update(db: PostgresJsDatabase, companyId: string, id: string, input: AccountUpdateInput) { + const [account] = await db + .update(accounts) + .set({ ...input, updatedAt: new Date() }) + .where(and(eq(accounts.id, id), eq(accounts.companyId, companyId))) + .returning() + + return account ?? null + }, + + async softDelete(db: PostgresJsDatabase, companyId: string, id: string) { + const [account] = await db + .update(accounts) + .set({ isActive: false, updatedAt: new Date() }) + .where(and(eq(accounts.id, id), eq(accounts.companyId, companyId))) + .returning() + + return account ?? null + }, + + async search(db: PostgresJsDatabase, companyId: string, query: string) { + const pattern = `%${query}%` + const results = await db + .select() + .from(accounts) + .where( + and( + eq(accounts.companyId, companyId), + eq(accounts.isActive, true), + or( + ilike(accounts.name, pattern), + ilike(accounts.email, pattern), + ilike(accounts.phone, pattern), + ilike(accounts.accountNumber, pattern), + ), + ), + ) + .limit(50) + + return results + }, + + async list(db: PostgresJsDatabase, companyId: string) { + return db + .select() + .from(accounts) + .where(and(eq(accounts.companyId, companyId), eq(accounts.isActive, true))) + .limit(100) + }, +} + +export const MemberService = { + async create(db: PostgresJsDatabase, companyId: string, input: { accountId: string; firstName: string; lastName: string; dateOfBirth?: string; email?: string; phone?: string; notes?: string }) { + const minor = input.dateOfBirth ? isMinor(input.dateOfBirth) : false + + const [member] = await db + .insert(members) + .values({ + companyId, + accountId: input.accountId, + firstName: input.firstName, + lastName: input.lastName, + dateOfBirth: input.dateOfBirth, + isMinor: minor, + email: input.email, + phone: input.phone, + notes: input.notes, + }) + .returning() + + return member + }, + + async getById(db: PostgresJsDatabase, companyId: string, id: string) { + const [member] = await db + .select() + .from(members) + .where(and(eq(members.id, id), eq(members.companyId, companyId))) + .limit(1) + + return member ?? null + }, + + async listByAccount(db: PostgresJsDatabase, companyId: string, accountId: string) { + return db + .select() + .from(members) + .where(and(eq(members.companyId, companyId), eq(members.accountId, accountId))) + }, + + async update(db: PostgresJsDatabase, companyId: string, id: string, input: { firstName?: string; lastName?: string; dateOfBirth?: string; email?: string; phone?: string; notes?: string }) { + const updates: Record = { ...input, updatedAt: new Date() } + if (input.dateOfBirth) { + updates.isMinor = isMinor(input.dateOfBirth) + } + + const [member] = await db + .update(members) + .set(updates) + .where(and(eq(members.id, id), eq(members.companyId, companyId))) + .returning() + + return member ?? null + }, + + async delete(db: PostgresJsDatabase, companyId: string, id: string) { + const [member] = await db + .delete(members) + .where(and(eq(members.id, id), eq(members.companyId, companyId))) + .returning() + + return member ?? null + }, +} diff --git a/packages/shared/src/schemas/account.schema.ts b/packages/shared/src/schemas/account.schema.ts new file mode 100644 index 0000000..d89d2d0 --- /dev/null +++ b/packages/shared/src/schemas/account.schema.ts @@ -0,0 +1,42 @@ +import { z } from 'zod' + +export const BillingMode = z.enum(['consolidated', 'split']) +export type BillingMode = z.infer + +export const AccountCreateSchema = z.object({ + name: z.string().min(1).max(255), + email: z.string().email().optional(), + phone: z.string().max(50).optional(), + address: z + .object({ + street: z.string().optional(), + city: z.string().optional(), + state: z.string().optional(), + zip: z.string().optional(), + }) + .optional(), + billingMode: BillingMode.default('consolidated'), + notes: z.string().optional(), +}) +export type AccountCreateInput = z.infer + +export const AccountUpdateSchema = AccountCreateSchema.partial() +export type AccountUpdateInput = z.infer + +export const MemberCreateSchema = z.object({ + accountId: z.string().uuid(), + firstName: z.string().min(1).max(100), + lastName: z.string().min(1).max(100), + dateOfBirth: z.string().date().optional(), + email: z.string().email().optional(), + phone: z.string().max(50).optional(), + notes: z.string().optional(), +}) +export type MemberCreateInput = z.infer + +export const MemberUpdateSchema = MemberCreateSchema.omit({ accountId: true }).partial() +export type MemberUpdateInput = z.infer + +export const AccountSearchSchema = z.object({ + q: z.string().min(1).max(255), +}) diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index 0090472..9a4078e 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -1,2 +1,17 @@ export { UserRole, RegisterSchema, LoginSchema } from './auth.schema.js' export type { RegisterInput, LoginInput } from './auth.schema.js' + +export { + BillingMode, + AccountCreateSchema, + AccountUpdateSchema, + MemberCreateSchema, + MemberUpdateSchema, + AccountSearchSchema, +} from './account.schema.js' +export type { + AccountCreateInput, + AccountUpdateInput, + MemberCreateInput, + MemberUpdateInput, +} from './account.schema.js'