diff --git a/packages/backend/src/db/index.ts b/packages/backend/src/db/index.ts index 3d68ee2..97dc55e 100644 --- a/packages/backend/src/db/index.ts +++ b/packages/backend/src/db/index.ts @@ -1,3 +1,4 @@ export * from './schema/stores.js' export * from './schema/users.js' export * from './schema/accounts.js' +export * from './schema/inventory.js' diff --git a/packages/backend/src/db/migrations/0004_peaceful_wendell_rand.sql b/packages/backend/src/db/migrations/0004_peaceful_wendell_rand.sql new file mode 100644 index 0000000..b72d049 --- /dev/null +++ b/packages/backend/src/db/migrations/0004_peaceful_wendell_rand.sql @@ -0,0 +1,30 @@ +CREATE TABLE "category" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "parent_id" uuid, + "name" varchar(255) NOT NULL, + "description" text, + "sort_order" integer DEFAULT 0 NOT NULL, + "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 +CREATE TABLE "supplier" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "name" varchar(255) NOT NULL, + "contact_name" varchar(255), + "email" varchar(255), + "phone" varchar(50), + "website" varchar(255), + "account_number" varchar(100), + "payment_terms" varchar(100), + "notes" text, + "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 "category" ADD CONSTRAINT "category_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 "supplier" ADD CONSTRAINT "supplier_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/0004_snapshot.json b/packages/backend/src/db/migrations/meta/0004_snapshot.json new file mode 100644 index 0000000..952f462 --- /dev/null +++ b/packages/backend/src/db/migrations/meta/0004_snapshot.json @@ -0,0 +1,823 @@ +{ + "id": "eb655a87-6e46-4ea1-a129-c10f522fb093", + "prevId": "906895d3-deba-442a-991d-d9245c960d18", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.category": { + "name": "category", + "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 + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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": { + "category_company_id_company_id_fk": { + "name": "category_company_id_company_id_fk", + "tableFrom": "category", + "tableTo": "company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.supplier": { + "name": "supplier", + "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 + }, + "contact_name": { + "name": "contact_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "account_number": { + "name": "account_number", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "payment_terms": { + "name": "payment_terms", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "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": { + "supplier_company_id_company_id_fk": { + "name": "supplier_company_id_company_id_fk", + "tableFrom": "supplier", + "tableTo": "company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "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 ee71e4b..d472338 100644 --- a/packages/backend/src/db/migrations/meta/_journal.json +++ b/packages/backend/src/db/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1774651222033, "tag": "0003_round_captain_midlands", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1774652800605, + "tag": "0004_peaceful_wendell_rand", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/backend/src/db/schema/inventory.ts b/packages/backend/src/db/schema/inventory.ts new file mode 100644 index 0000000..16a5385 --- /dev/null +++ b/packages/backend/src/db/schema/inventory.ts @@ -0,0 +1,39 @@ +import { pgTable, uuid, varchar, text, timestamp, boolean, integer } from 'drizzle-orm/pg-core' +import { companies } from './stores.js' + +export const categories = pgTable('category', { + id: uuid('id').primaryKey().defaultRandom(), + companyId: uuid('company_id') + .notNull() + .references(() => companies.id), + parentId: uuid('parent_id'), + name: varchar('name', { length: 255 }).notNull(), + description: text('description'), + sortOrder: integer('sort_order').notNull().default(0), + isActive: boolean('is_active').notNull().default(true), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}) + +export const suppliers = pgTable('supplier', { + id: uuid('id').primaryKey().defaultRandom(), + companyId: uuid('company_id') + .notNull() + .references(() => companies.id), + name: varchar('name', { length: 255 }).notNull(), + contactName: varchar('contact_name', { length: 255 }), + email: varchar('email', { length: 255 }), + phone: varchar('phone', { length: 50 }), + website: varchar('website', { length: 255 }), + accountNumber: varchar('account_number', { length: 100 }), + paymentTerms: varchar('payment_terms', { length: 100 }), + notes: text('notes'), + 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 Category = typeof categories.$inferSelect +export type CategoryInsert = typeof categories.$inferInsert +export type Supplier = typeof suppliers.$inferSelect +export type SupplierInsert = typeof suppliers.$inferInsert diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts index 49ae0f1..7a70ca2 100644 --- a/packages/backend/src/main.ts +++ b/packages/backend/src/main.ts @@ -8,6 +8,7 @@ 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' +import { inventoryRoutes } from './routes/v1/inventory.js' export async function buildApp() { const app = Fastify({ @@ -35,6 +36,7 @@ export async function buildApp() { await app.register(healthRoutes, { prefix: '/v1' }) await app.register(authRoutes, { prefix: '/v1' }) await app.register(accountRoutes, { prefix: '/v1' }) + await app.register(inventoryRoutes, { prefix: '/v1' }) return app } diff --git a/packages/backend/src/plugins/database.ts b/packages/backend/src/plugins/database.ts index 9cde59b..4f853de 100644 --- a/packages/backend/src/plugins/database.ts +++ b/packages/backend/src/plugins/database.ts @@ -4,8 +4,9 @@ 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' +import * as inventorySchema from '../db/schema/inventory.js' -const schema = { ...storeSchema, ...userSchema, ...accountSchema } +const schema = { ...storeSchema, ...userSchema, ...accountSchema, ...inventorySchema } declare module 'fastify' { interface FastifyInstance { diff --git a/packages/backend/src/routes/v1/inventory.test.ts b/packages/backend/src/routes/v1/inventory.test.ts new file mode 100644 index 0000000..6044120 --- /dev/null +++ b/packages/backend/src/routes/v1/inventory.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'bun:test' +import type { FastifyInstance } from 'fastify' +import { + createTestApp, + cleanDb, + seedTestCompany, + registerAndLogin, +} from '../../test/helpers.js' + +describe('Category 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() + }) + + it('creates a category', async () => { + const response = await app.inject({ + method: 'POST', + url: '/v1/categories', + headers: { authorization: `Bearer ${token}` }, + payload: { name: 'Guitars', description: 'All guitars', sortOrder: 1 }, + }) + + expect(response.statusCode).toBe(201) + expect(response.json().name).toBe('Guitars') + expect(response.json().sortOrder).toBe(1) + }) + + it('creates a child category', async () => { + const parentRes = await app.inject({ + method: 'POST', + url: '/v1/categories', + headers: { authorization: `Bearer ${token}` }, + payload: { name: 'Instruments' }, + }) + const parentId = parentRes.json().id + + const childRes = await app.inject({ + method: 'POST', + url: '/v1/categories', + headers: { authorization: `Bearer ${token}` }, + payload: { name: 'Brass', parentId }, + }) + + expect(childRes.statusCode).toBe(201) + expect(childRes.json().parentId).toBe(parentId) + }) + + it('lists categories sorted by sortOrder', async () => { + await app.inject({ + method: 'POST', + url: '/v1/categories', + headers: { authorization: `Bearer ${token}` }, + payload: { name: 'Zzz Last', sortOrder: 99 }, + }) + await app.inject({ + method: 'POST', + url: '/v1/categories', + headers: { authorization: `Bearer ${token}` }, + payload: { name: 'Aaa First', sortOrder: 1 }, + }) + + const response = await app.inject({ + method: 'GET', + url: '/v1/categories', + headers: { authorization: `Bearer ${token}` }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json() + expect(body.length).toBe(2) + expect(body[0].name).toBe('Aaa First') + }) + + it('soft-deletes a category', async () => { + const createRes = await app.inject({ + method: 'POST', + url: '/v1/categories', + headers: { authorization: `Bearer ${token}` }, + payload: { name: 'To Delete' }, + }) + + const delRes = await app.inject({ + method: 'DELETE', + url: `/v1/categories/${createRes.json().id}`, + headers: { authorization: `Bearer ${token}` }, + }) + expect(delRes.json().isActive).toBe(false) + + const listRes = await app.inject({ + method: 'GET', + url: '/v1/categories', + headers: { authorization: `Bearer ${token}` }, + }) + expect(listRes.json().length).toBe(0) + }) +}) + +describe('Supplier 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, { email: `supplier-${Date.now()}@test.com` }) + token = auth.token + }) + + afterAll(async () => { + await app.close() + }) + + it('creates a supplier', async () => { + const response = await app.inject({ + method: 'POST', + url: '/v1/suppliers', + headers: { authorization: `Bearer ${token}` }, + payload: { + name: 'RS Musical', + contactName: 'Bob Smith', + email: 'bob@rsmusical.com', + paymentTerms: 'Net 30', + }, + }) + + expect(response.statusCode).toBe(201) + expect(response.json().name).toBe('RS Musical') + expect(response.json().paymentTerms).toBe('Net 30') + }) + + it('searches suppliers by name', async () => { + await app.inject({ + method: 'POST', + url: '/v1/suppliers', + headers: { authorization: `Bearer ${token}` }, + payload: { name: "Ferree's Tools" }, + }) + await app.inject({ + method: 'POST', + url: '/v1/suppliers', + headers: { authorization: `Bearer ${token}` }, + payload: { name: 'Allied Supply' }, + }) + + const response = await app.inject({ + method: 'GET', + url: '/v1/suppliers/search?q=ferree', + headers: { authorization: `Bearer ${token}` }, + }) + + expect(response.statusCode).toBe(200) + expect(response.json().length).toBe(1) + expect(response.json()[0].name).toBe("Ferree's Tools") + }) + + it('updates a supplier', async () => { + const createRes = await app.inject({ + method: 'POST', + url: '/v1/suppliers', + headers: { authorization: `Bearer ${token}` }, + payload: { name: 'Old Name' }, + }) + + const updateRes = await app.inject({ + method: 'PATCH', + url: `/v1/suppliers/${createRes.json().id}`, + headers: { authorization: `Bearer ${token}` }, + payload: { name: 'New Name', paymentTerms: 'COD' }, + }) + + expect(updateRes.statusCode).toBe(200) + expect(updateRes.json().name).toBe('New Name') + expect(updateRes.json().paymentTerms).toBe('COD') + }) +}) diff --git a/packages/backend/src/routes/v1/inventory.ts b/packages/backend/src/routes/v1/inventory.ts new file mode 100644 index 0000000..3800d1d --- /dev/null +++ b/packages/backend/src/routes/v1/inventory.ts @@ -0,0 +1,99 @@ +import type { FastifyPluginAsync } from 'fastify' +import { + CategoryCreateSchema, + CategoryUpdateSchema, + SupplierCreateSchema, + SupplierUpdateSchema, +} from '@forte/shared/schemas' +import { CategoryService, SupplierService } from '../../services/inventory.service.js' + +export const inventoryRoutes: FastifyPluginAsync = async (app) => { + // --- Categories --- + + app.post('/categories', { preHandler: [app.authenticate] }, async (request, reply) => { + const parsed = CategoryCreateSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + const category = await CategoryService.create(app.db, request.companyId, parsed.data) + return reply.status(201).send(category) + }) + + app.get('/categories', { preHandler: [app.authenticate] }, async (request, reply) => { + const list = await CategoryService.list(app.db, request.companyId) + return reply.send(list) + }) + + app.get('/categories/:id', { preHandler: [app.authenticate] }, async (request, reply) => { + const { id } = request.params as { id: string } + const category = await CategoryService.getById(app.db, request.companyId, id) + if (!category) return reply.status(404).send({ error: { message: 'Category not found', statusCode: 404 } }) + return reply.send(category) + }) + + app.patch('/categories/:id', { preHandler: [app.authenticate] }, async (request, reply) => { + const { id } = request.params as { id: string } + const parsed = CategoryUpdateSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + const category = await CategoryService.update(app.db, request.companyId, id, parsed.data) + if (!category) return reply.status(404).send({ error: { message: 'Category not found', statusCode: 404 } }) + return reply.send(category) + }) + + app.delete('/categories/:id', { preHandler: [app.authenticate] }, async (request, reply) => { + const { id } = request.params as { id: string } + const category = await CategoryService.softDelete(app.db, request.companyId, id) + if (!category) return reply.status(404).send({ error: { message: 'Category not found', statusCode: 404 } }) + return reply.send(category) + }) + + // --- Suppliers --- + + app.post('/suppliers', { preHandler: [app.authenticate] }, async (request, reply) => { + const parsed = SupplierCreateSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + const supplier = await SupplierService.create(app.db, request.companyId, parsed.data) + return reply.status(201).send(supplier) + }) + + app.get('/suppliers', { preHandler: [app.authenticate] }, async (request, reply) => { + const list = await SupplierService.list(app.db, request.companyId) + return reply.send(list) + }) + + app.get('/suppliers/search', { preHandler: [app.authenticate] }, async (request, reply) => { + const { q } = request.query as { q?: string } + if (!q) return reply.status(400).send({ error: { message: 'Query parameter q is required', statusCode: 400 } }) + const results = await SupplierService.search(app.db, request.companyId, q) + return reply.send(results) + }) + + app.get('/suppliers/:id', { preHandler: [app.authenticate] }, async (request, reply) => { + const { id } = request.params as { id: string } + const supplier = await SupplierService.getById(app.db, request.companyId, id) + if (!supplier) return reply.status(404).send({ error: { message: 'Supplier not found', statusCode: 404 } }) + return reply.send(supplier) + }) + + app.patch('/suppliers/:id', { preHandler: [app.authenticate] }, async (request, reply) => { + const { id } = request.params as { id: string } + const parsed = SupplierUpdateSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + const supplier = await SupplierService.update(app.db, request.companyId, id, parsed.data) + if (!supplier) return reply.status(404).send({ error: { message: 'Supplier not found', statusCode: 404 } }) + return reply.send(supplier) + }) + + app.delete('/suppliers/:id', { preHandler: [app.authenticate] }, async (request, reply) => { + const { id } = request.params as { id: string } + const supplier = await SupplierService.softDelete(app.db, request.companyId, id) + if (!supplier) return reply.status(404).send({ error: { message: 'Supplier not found', statusCode: 404 } }) + return reply.send(supplier) + }) +} diff --git a/packages/backend/src/services/inventory.service.ts b/packages/backend/src/services/inventory.service.ts new file mode 100644 index 0000000..c289f55 --- /dev/null +++ b/packages/backend/src/services/inventory.service.ts @@ -0,0 +1,109 @@ +import { eq, and, ilike } from 'drizzle-orm' +import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js' +import { categories, suppliers } from '../db/schema/inventory.js' +import type { + CategoryCreateInput, + CategoryUpdateInput, + SupplierCreateInput, + SupplierUpdateInput, +} from '@forte/shared/schemas' + +export const CategoryService = { + async create(db: PostgresJsDatabase, companyId: string, input: CategoryCreateInput) { + const [category] = await db + .insert(categories) + .values({ companyId, ...input }) + .returning() + return category + }, + + async getById(db: PostgresJsDatabase, companyId: string, id: string) { + const [category] = await db + .select() + .from(categories) + .where(and(eq(categories.id, id), eq(categories.companyId, companyId))) + .limit(1) + return category ?? null + }, + + async list(db: PostgresJsDatabase, companyId: string) { + return db + .select() + .from(categories) + .where(and(eq(categories.companyId, companyId), eq(categories.isActive, true))) + .orderBy(categories.sortOrder) + }, + + async update(db: PostgresJsDatabase, companyId: string, id: string, input: CategoryUpdateInput) { + const [category] = await db + .update(categories) + .set({ ...input, updatedAt: new Date() }) + .where(and(eq(categories.id, id), eq(categories.companyId, companyId))) + .returning() + return category ?? null + }, + + async softDelete(db: PostgresJsDatabase, companyId: string, id: string) { + const [category] = await db + .update(categories) + .set({ isActive: false, updatedAt: new Date() }) + .where(and(eq(categories.id, id), eq(categories.companyId, companyId))) + .returning() + return category ?? null + }, +} + +export const SupplierService = { + async create(db: PostgresJsDatabase, companyId: string, input: SupplierCreateInput) { + const [supplier] = await db + .insert(suppliers) + .values({ companyId, ...input }) + .returning() + return supplier + }, + + async getById(db: PostgresJsDatabase, companyId: string, id: string) { + const [supplier] = await db + .select() + .from(suppliers) + .where(and(eq(suppliers.id, id), eq(suppliers.companyId, companyId))) + .limit(1) + return supplier ?? null + }, + + async list(db: PostgresJsDatabase, companyId: string) { + return db + .select() + .from(suppliers) + .where(and(eq(suppliers.companyId, companyId), eq(suppliers.isActive, true))) + }, + + async search(db: PostgresJsDatabase, companyId: string, query: string) { + const pattern = `%${query}%` + return db + .select() + .from(suppliers) + .where( + and(eq(suppliers.companyId, companyId), eq(suppliers.isActive, true), ilike(suppliers.name, pattern)), + ) + .limit(50) + }, + + async update(db: PostgresJsDatabase, companyId: string, id: string, input: SupplierUpdateInput) { + const [supplier] = await db + .update(suppliers) + .set({ ...input, updatedAt: new Date() }) + .where(and(eq(suppliers.id, id), eq(suppliers.companyId, companyId))) + .returning() + return supplier ?? null + }, + + async softDelete(db: PostgresJsDatabase, companyId: string, id: string) { + const [supplier] = await db + .update(suppliers) + .set({ isActive: false, updatedAt: new Date() }) + .where(and(eq(suppliers.id, id), eq(suppliers.companyId, companyId))) + .returning() + return supplier ?? null + }, +} diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index 9a4078e..5fe4a0a 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -15,3 +15,16 @@ export type { MemberCreateInput, MemberUpdateInput, } from './account.schema.js' + +export { + CategoryCreateSchema, + CategoryUpdateSchema, + SupplierCreateSchema, + SupplierUpdateSchema, +} from './inventory.schema.js' +export type { + CategoryCreateInput, + CategoryUpdateInput, + SupplierCreateInput, + SupplierUpdateInput, +} from './inventory.schema.js' diff --git a/packages/shared/src/schemas/inventory.schema.ts b/packages/shared/src/schemas/inventory.schema.ts new file mode 100644 index 0000000..a634ed5 --- /dev/null +++ b/packages/shared/src/schemas/inventory.schema.ts @@ -0,0 +1,27 @@ +import { z } from 'zod' + +export const CategoryCreateSchema = z.object({ + name: z.string().min(1).max(255), + description: z.string().optional(), + parentId: z.string().uuid().optional(), + sortOrder: z.number().int().default(0), +}) +export type CategoryCreateInput = z.infer + +export const CategoryUpdateSchema = CategoryCreateSchema.partial() +export type CategoryUpdateInput = z.infer + +export const SupplierCreateSchema = z.object({ + name: z.string().min(1).max(255), + contactName: z.string().max(255).optional(), + email: z.string().email().optional(), + phone: z.string().max(50).optional(), + website: z.string().max(255).optional(), + accountNumber: z.string().max(100).optional(), + paymentTerms: z.string().max(100).optional(), + notes: z.string().optional(), +}) +export type SupplierCreateInput = z.infer + +export const SupplierUpdateSchema = SupplierCreateSchema.partial() +export type SupplierUpdateInput = z.infer