Remove multi-tenant company_id scoping from entire codebase

Drop company_id column from all 22 domain tables via migration.
Remove companyId from JWT payload, auth plugins, all service method
signatures (~215 occurrences), all route handlers (~105 occurrences),
test runner, test suites, and frontend auth store/types.

The company table stays as store settings (name, timezone). Tenant
isolation in a SaaS deployment would be at the database level (one
DB per customer) not the application level.

All 107 API tests pass. Zero TSC errors across all packages.
This commit is contained in:
Ryan Moon
2026-03-29 14:58:33 -05:00
parent 55f8591cf1
commit d36c6f7135
35 changed files with 353 additions and 511 deletions

1
.gitignore vendored
View File

@@ -37,3 +37,4 @@ logs/
*.pem *.pem
*.key *.key
credentials.json credentials.json
packages/backend/data/

View File

@@ -11,7 +11,6 @@ interface User {
interface AuthState { interface AuthState {
token: string | null token: string | null
user: User | null user: User | null
companyId: string | null
permissions: Set<string> permissions: Set<string>
permissionsLoaded: boolean permissionsLoaded: boolean
setAuth: (token: string, user: User) => void setAuth: (token: string, user: User) => void
@@ -49,12 +48,7 @@ function expandPermissions(slugs: string[]): Set<string> {
return expanded return expanded
} }
function decodeJwtPayload(token: string): { id: string; companyId: string; role: string } { function loadSession(): { token: string; user: User; permissions?: string[] } | null {
const payload = token.split('.')[1]
return JSON.parse(atob(payload))
}
function loadSession(): { token: string; user: User; companyId: string; permissions?: string[] } | null {
try { try {
const raw = sessionStorage.getItem('forte-auth') const raw = sessionStorage.getItem('forte-auth')
if (!raw) return null if (!raw) return null
@@ -64,8 +58,8 @@ function loadSession(): { token: string; user: User; companyId: string; permissi
} }
} }
function saveSession(token: string, user: User, companyId: string, permissions?: string[]) { function saveSession(token: string, user: User, permissions?: string[]) {
sessionStorage.setItem('forte-auth', JSON.stringify({ token, user, companyId, permissions })) sessionStorage.setItem('forte-auth', JSON.stringify({ token, user, permissions }))
} }
function clearSession() { function clearSession() {
@@ -78,22 +72,19 @@ export const useAuthStore = create<AuthState>((set, get) => {
return { return {
token: initial?.token ?? null, token: initial?.token ?? null,
user: initial?.user ?? null, user: initial?.user ?? null,
companyId: initial?.companyId ?? null,
permissions: initialPerms, permissions: initialPerms,
permissionsLoaded: initialPerms.size > 0, permissionsLoaded: initialPerms.size > 0,
setAuth: (token, user) => { setAuth: (token, user) => {
const payload = decodeJwtPayload(token) saveSession(token, user)
saveSession(token, user, payload.companyId) set({ token, user })
set({ token, user, companyId: payload.companyId })
}, },
setPermissions: (slugs: string[]) => { setPermissions: (slugs: string[]) => {
const expanded = expandPermissions(slugs) const expanded = expandPermissions(slugs)
// Update session storage to include permissions const { token, user } = get()
const { token, user, companyId } = get() if (token && user) {
if (token && user && companyId) { saveSession(token, user, slugs)
saveSession(token, user, companyId, slugs)
} }
set({ permissions: expanded, permissionsLoaded: true }) set({ permissions: expanded, permissionsLoaded: true })
}, },
@@ -104,6 +95,6 @@ export const useAuthStore = create<AuthState>((set, get) => {
logout: () => { logout: () => {
clearSession() clearSession()
set({ token: null, user: null, companyId: null, permissions: new Set(), permissionsLoaded: false }) set({ token: null, user: null, permissions: new Set(), permissionsLoaded: false })
}, },
}}) }})

View File

@@ -1,6 +1,5 @@
export interface Account { export interface Account {
id: string id: string
companyId: string
accountNumber: string | null accountNumber: string | null
name: string name: string
primaryMemberId: string | null primaryMemberId: string | null
@@ -25,7 +24,6 @@ export interface Account {
export interface Member { export interface Member {
id: string id: string
accountId: string accountId: string
companyId: string
memberNumber: string | null memberNumber: string | null
firstName: string firstName: string
lastName: string lastName: string
@@ -42,7 +40,6 @@ export interface Member {
export interface ProcessorLink { export interface ProcessorLink {
id: string id: string
accountId: string accountId: string
companyId: string
processor: 'stripe' | 'global_payments' processor: 'stripe' | 'global_payments'
processorCustomerId: string processorCustomerId: string
isActive: boolean isActive: boolean
@@ -52,7 +49,6 @@ export interface ProcessorLink {
export interface PaymentMethod { export interface PaymentMethod {
id: string id: string
accountId: string accountId: string
companyId: string
processor: 'stripe' | 'global_payments' processor: 'stripe' | 'global_payments'
processorPaymentMethodId: string processorPaymentMethodId: string
cardBrand: string | null cardBrand: string | null
@@ -67,7 +63,6 @@ export interface PaymentMethod {
export interface MemberIdentifier { export interface MemberIdentifier {
id: string id: string
memberId: string memberId: string
companyId: string
type: 'drivers_license' | 'passport' | 'school_id' type: 'drivers_license' | 'passport' | 'school_id'
label: string | null label: string | null
value: string value: string
@@ -85,7 +80,6 @@ export interface MemberIdentifier {
export interface TaxExemption { export interface TaxExemption {
id: string id: string
accountId: string accountId: string
companyId: string
status: 'none' | 'pending' | 'approved' status: 'none' | 'pending' | 'approved'
certificateNumber: string certificateNumber: string
certificateType: string | null certificateType: string | null

View File

@@ -1,6 +1,5 @@
export interface RepairTicket { export interface RepairTicket {
id: string id: string
companyId: string
locationId: string | null locationId: string | null
repairBatchId: string | null repairBatchId: string | null
ticketNumber: string | null ticketNumber: string | null
@@ -42,7 +41,6 @@ export interface RepairLineItem {
export interface RepairBatch { export interface RepairBatch {
id: string id: string
companyId: string
locationId: string | null locationId: string | null
batchNumber: string | null batchNumber: string | null
accountId: string accountId: string
@@ -80,7 +78,6 @@ export interface RepairNote {
export interface RepairServiceTemplate { export interface RepairServiceTemplate {
id: string id: string
companyId: string
name: string name: string
instrumentType: string | null instrumentType: string | null
size: string | null size: string | null

View File

@@ -45,7 +45,6 @@ describe('Account routes', () => {
const body = response.json() const body = response.json()
expect(body.name).toBe('Smith Family') expect(body.name).toBe('Smith Family')
expect(body.email).toBe('smith@example.com') expect(body.email).toBe('smith@example.com')
expect(body.companyId).toBe(TEST_COMPANY_ID)
expect(body.billingMode).toBe('consolidated') expect(body.billingMode).toBe('consolidated')
}) })

View File

@@ -59,17 +59,17 @@ async function setupDatabase() {
END $$ END $$
`) `)
// Seed company + location // Seed company + location (company table stays as store settings)
await testSql`INSERT INTO company (id, name, timezone) VALUES (${COMPANY_ID}, 'Test Music Co.', 'America/Chicago')` await testSql`INSERT INTO company (id, name, timezone) VALUES (${COMPANY_ID}, 'Test Music Co.', 'America/Chicago')`
await testSql`INSERT INTO location (id, company_id, name) VALUES (${LOCATION_ID}, ${COMPANY_ID}, 'Test Location')` await testSql`INSERT INTO location (id, name) VALUES (${LOCATION_ID}, 'Test Location')`
// Seed lookup tables // Seed lookup tables
const { SYSTEM_UNIT_STATUSES, SYSTEM_ITEM_CONDITIONS } = await import('../src/db/schema/lookups.js') const { SYSTEM_UNIT_STATUSES, SYSTEM_ITEM_CONDITIONS } = await import('../src/db/schema/lookups.js')
for (const s of SYSTEM_UNIT_STATUSES) { for (const s of SYSTEM_UNIT_STATUSES) {
await testSql`INSERT INTO inventory_unit_status (company_id, name, slug, description, is_system, sort_order) VALUES (${COMPANY_ID}, ${s.name}, ${s.slug}, ${s.description}, true, ${s.sortOrder})` await testSql`INSERT INTO inventory_unit_status (name, slug, description, is_system, sort_order) VALUES (${s.name}, ${s.slug}, ${s.description}, true, ${s.sortOrder})`
} }
for (const c of SYSTEM_ITEM_CONDITIONS) { for (const c of SYSTEM_ITEM_CONDITIONS) {
await testSql`INSERT INTO item_condition (company_id, name, slug, description, is_system, sort_order) VALUES (${COMPANY_ID}, ${c.name}, ${c.slug}, ${c.description}, true, ${c.sortOrder})` await testSql`INSERT INTO item_condition (name, slug, description, is_system, sort_order) VALUES (${c.name}, ${c.slug}, ${c.description}, true, ${c.sortOrder})`
} }
// Seed RBAC permissions and default roles // Seed RBAC permissions and default roles
@@ -82,7 +82,7 @@ async function setupDatabase() {
const permMap = new Map(permRows.map((r: any) => [r.slug, r.id])) const permMap = new Map(permRows.map((r: any) => [r.slug, r.id]))
for (const roleDef of DEFAULT_ROLES) { for (const roleDef of DEFAULT_ROLES) {
const [role] = await testSql`INSERT INTO role (company_id, name, slug, description, is_system) VALUES (${COMPANY_ID}, ${roleDef.name}, ${roleDef.slug}, ${roleDef.description}, true) RETURNING id` const [role] = await testSql`INSERT INTO role (name, slug, description, is_system) VALUES (${roleDef.name}, ${roleDef.slug}, ${roleDef.description}, true) RETURNING id`
for (const permSlug of roleDef.permissions) { for (const permSlug of roleDef.permissions) {
const permId = permMap.get(permSlug) const permId = permMap.get(permSlug)
if (permId) { if (permId) {
@@ -149,8 +149,7 @@ async function startBackend(): Promise<Subprocess> {
async function registerTestUser(): Promise<string> { async function registerTestUser(): Promise<string> {
const testPassword = 'testpassword1234' const testPassword = 'testpassword1234'
// Register needs x-company-id header const headers = { 'Content-Type': 'application/json' }
const headers = { 'Content-Type': 'application/json', 'x-company-id': COMPANY_ID }
const registerRes = await fetch(`${BASE_URL}/v1/auth/register`, { const registerRes = await fetch(`${BASE_URL}/v1/auth/register`, {
method: 'POST', method: 'POST',
headers, headers,
@@ -167,7 +166,7 @@ async function registerTestUser(): Promise<string> {
// Assign admin role to the user via direct SQL // Assign admin role to the user via direct SQL
if (registerRes.status === 201 && registerData.user) { if (registerRes.status === 201 && registerData.user) {
const assignSql = postgres(`postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${TEST_DB}`) const assignSql = postgres(`postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${TEST_DB}`)
const [adminRole] = await assignSql`SELECT id FROM role WHERE company_id = ${COMPANY_ID} AND slug = 'admin' LIMIT 1` const [adminRole] = await assignSql`SELECT id FROM role WHERE slug = 'admin' LIMIT 1`
if (adminRole) { if (adminRole) {
await assignSql`INSERT INTO user_role_assignment (user_id, role_id) VALUES (${registerData.user.id}, ${adminRole.id}) ON CONFLICT DO NOTHING` await assignSql`INSERT INTO user_role_assignment (user_id, role_id) VALUES (${registerData.user.id}, ${adminRole.id}) ON CONFLICT DO NOTHING`
} }

View File

@@ -6,10 +6,10 @@ suite('RBAC', { tags: ['rbac', 'permissions'] }, (t) => {
const email = `restricted-${Date.now()}@test.com` const email = `restricted-${Date.now()}@test.com`
const password = 'testpassword1234' const password = 'testpassword1234'
// Register via raw fetch (needs x-company-id) // Register via raw fetch
const registerRes = await fetch(`${t.baseUrl}/v1/auth/register`, { const registerRes = await fetch(`${t.baseUrl}/v1/auth/register`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-company-id': 'a0000000-0000-0000-0000-000000000001' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, firstName: 'Restricted', lastName: 'User', role: 'staff' }), body: JSON.stringify({ email, password, firstName: 'Restricted', lastName: 'User', role: 'staff' }),
}) })
const registerData = await registerRes.json() as { token: string } const registerData = await registerRes.json() as { token: string }
@@ -31,7 +31,7 @@ suite('RBAC', { tags: ['rbac', 'permissions'] }, (t) => {
const registerRes = await fetch(`${t.baseUrl}/v1/auth/register`, { const registerRes = await fetch(`${t.baseUrl}/v1/auth/register`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-company-id': 'a0000000-0000-0000-0000-000000000001' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, firstName: roleSlug, lastName: 'User', role: 'staff' }), body: JSON.stringify({ email, password, firstName: roleSlug, lastName: 'User', role: 'staff' }),
}) })
const registerData = await registerRes.json() as { user: { id: string } } const registerData = await registerRes.json() as { user: { id: string } }
@@ -143,7 +143,7 @@ suite('RBAC', { tags: ['rbac', 'permissions'] }, (t) => {
const password = 'testpassword1234' const password = 'testpassword1234'
const regRes = await fetch(`${t.baseUrl}/v1/auth/register`, { const regRes = await fetch(`${t.baseUrl}/v1/auth/register`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-company-id': 'a0000000-0000-0000-0000-000000000001' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, firstName: 'Inherit', lastName: 'Test', role: 'staff' }), body: JSON.stringify({ email, password, firstName: 'Inherit', lastName: 'Test', role: 'staff' }),
}) })
const regData = await regRes.json() as { user: { id: string } } const regData = await regRes.json() as { user: { id: string } }
@@ -233,7 +233,7 @@ suite('RBAC', { tags: ['rbac', 'permissions'] }, (t) => {
// Create a user with a distinctive name // Create a user with a distinctive name
await fetch(`${t.baseUrl}/v1/auth/register`, { await fetch(`${t.baseUrl}/v1/auth/register`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-company-id': 'a0000000-0000-0000-0000-000000000001' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: `searchme-${Date.now()}@test.com`, password: 'testpassword1234', firstName: 'Searchable', lastName: 'Pessoa', role: 'staff' }), body: JSON.stringify({ email: `searchme-${Date.now()}@test.com`, password: 'testpassword1234', firstName: 'Searchable', lastName: 'Pessoa', role: 'staff' }),
}) })
@@ -257,7 +257,7 @@ suite('RBAC', { tags: ['rbac', 'permissions'] }, (t) => {
const password = 'testpassword1234' const password = 'testpassword1234'
const regRes = await fetch(`${t.baseUrl}/v1/auth/register`, { const regRes = await fetch(`${t.baseUrl}/v1/auth/register`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-company-id': 'a0000000-0000-0000-0000-000000000001' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, firstName: 'Disable', lastName: 'Me', role: 'staff' }), body: JSON.stringify({ email, password, firstName: 'Disable', lastName: 'Me', role: 'staff' }),
}) })
const regData = await regRes.json() as { user: { id: string } } const regData = await regRes.json() as { user: { id: string } }

View File

@@ -0,0 +1,27 @@
-- Remove company_id columns and FK constraints from all domain tables.
-- The company table stays as store settings, but tenant scoping is removed.
-- In a SaaS deployment, isolation is at the database level, not application level.
-- Drop company_id from each table (CASCADE drops the FK constraint automatically)
ALTER TABLE "account" DROP COLUMN IF EXISTS "company_id";
ALTER TABLE "member" DROP COLUMN IF EXISTS "company_id";
ALTER TABLE "member_identifier" DROP COLUMN IF EXISTS "company_id";
ALTER TABLE "account_processor_link" DROP COLUMN IF EXISTS "company_id";
ALTER TABLE "account_payment_method" DROP COLUMN IF EXISTS "company_id";
ALTER TABLE "tax_exemption" DROP COLUMN IF EXISTS "company_id";
ALTER TABLE "file" DROP COLUMN IF EXISTS "company_id";
ALTER TABLE "category" DROP COLUMN IF EXISTS "company_id";
ALTER TABLE "supplier" DROP COLUMN IF EXISTS "company_id";
ALTER TABLE "product" DROP COLUMN IF EXISTS "company_id";
ALTER TABLE "inventory_unit" DROP COLUMN IF EXISTS "company_id";
ALTER TABLE "stock_receipt" DROP COLUMN IF EXISTS "company_id";
ALTER TABLE "price_history" DROP COLUMN IF EXISTS "company_id";
ALTER TABLE "consignment_detail" DROP COLUMN IF EXISTS "company_id";
ALTER TABLE "inventory_unit_status" DROP COLUMN IF EXISTS "company_id";
ALTER TABLE "item_condition" DROP COLUMN IF EXISTS "company_id";
ALTER TABLE "role" DROP COLUMN IF EXISTS "company_id";
ALTER TABLE "repair_batch" DROP COLUMN IF EXISTS "company_id";
ALTER TABLE "repair_ticket" DROP COLUMN IF EXISTS "company_id";
ALTER TABLE "repair_service_template" DROP COLUMN IF EXISTS "company_id";
ALTER TABLE "location" DROP COLUMN IF EXISTS "company_id";
ALTER TABLE "user" DROP COLUMN IF EXISTS "company_id";

View File

@@ -148,6 +148,13 @@
"when": 1774800000000, "when": 1774800000000,
"tag": "0020_repair_default_new", "tag": "0020_repair_default_new",
"breakpoints": true "breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1774810000000,
"tag": "0021_remove_company_scoping",
"breakpoints": true
} }
] ]
} }

View File

@@ -10,16 +10,12 @@ import {
date, date,
pgEnum, pgEnum,
} from 'drizzle-orm/pg-core' } from 'drizzle-orm/pg-core'
import { companies } from './stores.js'
export const billingModeEnum = pgEnum('billing_mode', ['consolidated', 'split']) export const billingModeEnum = pgEnum('billing_mode', ['consolidated', 'split'])
export const taxExemptStatusEnum = pgEnum('tax_exempt_status', ['none', 'pending', 'approved']) export const taxExemptStatusEnum = pgEnum('tax_exempt_status', ['none', 'pending', 'approved'])
export const accounts = pgTable('account', { export const accounts = pgTable('account', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id),
accountNumber: varchar('account_number', { length: 50 }), accountNumber: varchar('account_number', { length: 50 }),
name: varchar('name', { length: 255 }).notNull(), name: varchar('name', { length: 255 }).notNull(),
email: varchar('email', { length: 255 }), email: varchar('email', { length: 255 }),
@@ -46,9 +42,6 @@ export const members = pgTable('member', {
accountId: uuid('account_id') accountId: uuid('account_id')
.notNull() .notNull()
.references(() => accounts.id), .references(() => accounts.id),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id),
memberNumber: varchar('member_number', { length: 50 }), memberNumber: varchar('member_number', { length: 50 }),
firstName: varchar('first_name', { length: 100 }).notNull(), firstName: varchar('first_name', { length: 100 }).notNull(),
lastName: varchar('last_name', { length: 100 }).notNull(), lastName: varchar('last_name', { length: 100 }).notNull(),
@@ -73,9 +66,6 @@ export const memberIdentifiers = pgTable('member_identifier', {
memberId: uuid('member_id') memberId: uuid('member_id')
.notNull() .notNull()
.references(() => members.id), .references(() => members.id),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id),
type: varchar('type', { length: 50 }).notNull(), type: varchar('type', { length: 50 }).notNull(),
label: varchar('label', { length: 100 }), label: varchar('label', { length: 100 }),
value: varchar('value', { length: 255 }).notNull(), value: varchar('value', { length: 255 }).notNull(),
@@ -100,9 +90,6 @@ export const accountProcessorLinks = pgTable('account_processor_link', {
accountId: uuid('account_id') accountId: uuid('account_id')
.notNull() .notNull()
.references(() => accounts.id), .references(() => accounts.id),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id),
processor: processorEnum('processor').notNull(), processor: processorEnum('processor').notNull(),
processorCustomerId: varchar('processor_customer_id', { length: 255 }).notNull(), processorCustomerId: varchar('processor_customer_id', { length: 255 }).notNull(),
isActive: boolean('is_active').notNull().default(true), isActive: boolean('is_active').notNull().default(true),
@@ -117,9 +104,6 @@ export const accountPaymentMethods = pgTable('account_payment_method', {
accountId: uuid('account_id') accountId: uuid('account_id')
.notNull() .notNull()
.references(() => accounts.id), .references(() => accounts.id),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id),
processor: processorEnum('processor').notNull(), processor: processorEnum('processor').notNull(),
processorPaymentMethodId: varchar('processor_payment_method_id', { length: 255 }).notNull(), processorPaymentMethodId: varchar('processor_payment_method_id', { length: 255 }).notNull(),
cardBrand: varchar('card_brand', { length: 50 }), cardBrand: varchar('card_brand', { length: 50 }),
@@ -139,9 +123,6 @@ export const taxExemptions = pgTable('tax_exemption', {
accountId: uuid('account_id') accountId: uuid('account_id')
.notNull() .notNull()
.references(() => accounts.id), .references(() => accounts.id),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id),
status: taxExemptStatusEnum('status').notNull().default('pending'), status: taxExemptStatusEnum('status').notNull().default('pending'),
certificateNumber: varchar('certificate_number', { length: 255 }).notNull(), certificateNumber: varchar('certificate_number', { length: 255 }).notNull(),
certificateType: varchar('certificate_type', { length: 100 }), certificateType: varchar('certificate_type', { length: 100 }),

View File

@@ -1,11 +1,7 @@
import { pgTable, uuid, varchar, integer, timestamp } from 'drizzle-orm/pg-core' import { pgTable, uuid, varchar, integer, timestamp } from 'drizzle-orm/pg-core'
import { companies } from './stores.js'
export const files = pgTable('file', { export const files = pgTable('file', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id),
path: varchar('path', { length: 1000 }).notNull(), path: varchar('path', { length: 1000 }).notNull(),
filename: varchar('filename', { length: 255 }).notNull(), filename: varchar('filename', { length: 255 }).notNull(),
contentType: varchar('content_type', { length: 100 }).notNull(), contentType: varchar('content_type', { length: 100 }).notNull(),

View File

@@ -9,13 +9,10 @@ import {
numeric, numeric,
date, date,
} from 'drizzle-orm/pg-core' } from 'drizzle-orm/pg-core'
import { companies, locations } from './stores.js' import { locations } from './stores.js'
export const categories = pgTable('category', { export const categories = pgTable('category', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id),
parentId: uuid('parent_id'), parentId: uuid('parent_id'),
name: varchar('name', { length: 255 }).notNull(), name: varchar('name', { length: 255 }).notNull(),
description: text('description'), description: text('description'),
@@ -27,9 +24,6 @@ export const categories = pgTable('category', {
export const suppliers = pgTable('supplier', { export const suppliers = pgTable('supplier', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id),
name: varchar('name', { length: 255 }).notNull(), name: varchar('name', { length: 255 }).notNull(),
contactName: varchar('contact_name', { length: 255 }), contactName: varchar('contact_name', { length: 255 }),
email: varchar('email', { length: 255 }), email: varchar('email', { length: 255 }),
@@ -49,9 +43,6 @@ export const suppliers = pgTable('supplier', {
export const products = pgTable('product', { export const products = pgTable('product', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id),
locationId: uuid('location_id').references(() => locations.id), locationId: uuid('location_id').references(() => locations.id),
sku: varchar('sku', { length: 100 }), sku: varchar('sku', { length: 100 }),
upc: varchar('upc', { length: 100 }), upc: varchar('upc', { length: 100 }),
@@ -79,9 +70,6 @@ export const inventoryUnits = pgTable('inventory_unit', {
productId: uuid('product_id') productId: uuid('product_id')
.notNull() .notNull()
.references(() => products.id), .references(() => products.id),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id),
locationId: uuid('location_id').references(() => locations.id), locationId: uuid('location_id').references(() => locations.id),
serialNumber: varchar('serial_number', { length: 255 }), serialNumber: varchar('serial_number', { length: 255 }),
condition: varchar('condition', { length: 100 }).notNull().default('new'), condition: varchar('condition', { length: 100 }).notNull().default('new'),
@@ -112,9 +100,6 @@ export type Supplier = typeof suppliers.$inferSelect
export type SupplierInsert = typeof suppliers.$inferInsert export type SupplierInsert = typeof suppliers.$inferInsert
export const stockReceipts = pgTable('stock_receipt', { export const stockReceipts = pgTable('stock_receipt', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id),
locationId: uuid('location_id').references(() => locations.id), locationId: uuid('location_id').references(() => locations.id),
productId: uuid('product_id') productId: uuid('product_id')
.notNull() .notNull()
@@ -136,9 +121,6 @@ export const priceHistory = pgTable('price_history', {
productId: uuid('product_id') productId: uuid('product_id')
.notNull() .notNull()
.references(() => products.id), .references(() => products.id),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id),
previousPrice: numeric('previous_price', { precision: 10, scale: 2 }), previousPrice: numeric('previous_price', { precision: 10, scale: 2 }),
newPrice: numeric('new_price', { precision: 10, scale: 2 }).notNull(), newPrice: numeric('new_price', { precision: 10, scale: 2 }).notNull(),
previousMinPrice: numeric('previous_min_price', { precision: 10, scale: 2 }), previousMinPrice: numeric('previous_min_price', { precision: 10, scale: 2 }),
@@ -158,9 +140,6 @@ export const consignmentDetails = pgTable('consignment_detail', {
productId: uuid('product_id') productId: uuid('product_id')
.notNull() .notNull()
.references(() => products.id), .references(() => products.id),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id),
consignorAccountId: uuid('consignor_account_id').notNull(), consignorAccountId: uuid('consignor_account_id').notNull(),
commissionPercent: numeric('commission_percent', { precision: 5, scale: 2 }).notNull(), commissionPercent: numeric('commission_percent', { precision: 5, scale: 2 }).notNull(),
minPrice: numeric('min_price', { precision: 10, scale: 2 }), minPrice: numeric('min_price', { precision: 10, scale: 2 }),

View File

@@ -1,5 +1,4 @@
import { pgTable, uuid, varchar, text, timestamp, boolean, integer } from 'drizzle-orm/pg-core' import { pgTable, uuid, varchar, text, timestamp, boolean, integer } from 'drizzle-orm/pg-core'
import { companies } from './stores.js'
/** /**
* Lookup tables replace hard-coded pgEnums for values that stores may want to customize. * Lookup tables replace hard-coded pgEnums for values that stores may want to customize.
@@ -9,9 +8,6 @@ import { companies } from './stores.js'
export const inventoryUnitStatuses = pgTable('inventory_unit_status', { export const inventoryUnitStatuses = pgTable('inventory_unit_status', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id),
name: varchar('name', { length: 100 }).notNull(), name: varchar('name', { length: 100 }).notNull(),
slug: varchar('slug', { length: 100 }).notNull(), slug: varchar('slug', { length: 100 }).notNull(),
description: text('description'), description: text('description'),
@@ -23,9 +19,6 @@ export const inventoryUnitStatuses = pgTable('inventory_unit_status', {
export const itemConditions = pgTable('item_condition', { export const itemConditions = pgTable('item_condition', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id),
name: varchar('name', { length: 100 }).notNull(), name: varchar('name', { length: 100 }).notNull(),
slug: varchar('slug', { length: 100 }).notNull(), slug: varchar('slug', { length: 100 }).notNull(),
description: text('description'), description: text('description'),

View File

@@ -1,5 +1,4 @@
import { pgTable, uuid, varchar, text, timestamp, boolean, uniqueIndex } from 'drizzle-orm/pg-core' import { pgTable, uuid, varchar, text, timestamp, boolean, uniqueIndex } from 'drizzle-orm/pg-core'
import { companies } from './stores.js'
import { users } from './users.js' import { users } from './users.js'
export const permissions = pgTable('permission', { export const permissions = pgTable('permission', {
@@ -13,9 +12,6 @@ export const permissions = pgTable('permission', {
export const roles = pgTable('role', { export const roles = pgTable('role', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id),
name: varchar('name', { length: 100 }).notNull(), name: varchar('name', { length: 100 }).notNull(),
slug: varchar('slug', { length: 100 }).notNull(), slug: varchar('slug', { length: 100 }).notNull(),
description: text('description'), description: text('description'),

View File

@@ -9,7 +9,7 @@ import {
numeric, numeric,
pgEnum, pgEnum,
} from 'drizzle-orm/pg-core' } from 'drizzle-orm/pg-core'
import { companies, locations } from './stores.js' import { locations } from './stores.js'
import { accounts } from './accounts.js' import { accounts } from './accounts.js'
import { inventoryUnits, products } from './inventory.js' import { inventoryUnits, products } from './inventory.js'
import { users } from './users.js' import { users } from './users.js'
@@ -66,9 +66,6 @@ export const repairBatchApprovalEnum = pgEnum('repair_batch_approval', [
// Defined before repairTickets because tickets FK to batches // Defined before repairTickets because tickets FK to batches
export const repairBatches = pgTable('repair_batch', { export const repairBatches = pgTable('repair_batch', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id),
locationId: uuid('location_id').references(() => locations.id), locationId: uuid('location_id').references(() => locations.id),
batchNumber: varchar('batch_number', { length: 50 }), batchNumber: varchar('batch_number', { length: 50 }),
accountId: uuid('account_id') accountId: uuid('account_id')
@@ -97,9 +94,6 @@ export const repairBatches = pgTable('repair_batch', {
export const repairTickets = pgTable('repair_ticket', { export const repairTickets = pgTable('repair_ticket', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id),
locationId: uuid('location_id').references(() => locations.id), locationId: uuid('location_id').references(() => locations.id),
repairBatchId: uuid('repair_batch_id').references(() => repairBatches.id), repairBatchId: uuid('repair_batch_id').references(() => repairBatches.id),
ticketNumber: varchar('ticket_number', { length: 50 }), ticketNumber: varchar('ticket_number', { length: 50 }),
@@ -163,9 +157,6 @@ export type RepairNoteInsert = typeof repairNotes.$inferInsert
export const repairServiceTemplates = pgTable('repair_service_template', { export const repairServiceTemplates = pgTable('repair_service_template', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id),
name: varchar('name', { length: 255 }).notNull(), name: varchar('name', { length: 255 }).notNull(),
instrumentType: varchar('instrument_type', { length: 100 }), instrumentType: varchar('instrument_type', { length: 100 }),
size: varchar('size', { length: 50 }), size: varchar('size', { length: 50 }),

View File

@@ -1,5 +1,4 @@
import { pgTable, uuid, varchar, timestamp, pgEnum, uniqueIndex, boolean } from 'drizzle-orm/pg-core' import { pgTable, uuid, varchar, timestamp, pgEnum, uniqueIndex, boolean } from 'drizzle-orm/pg-core'
import { companies } from './stores.js'
export const userRoleEnum = pgEnum('user_role', [ export const userRoleEnum = pgEnum('user_role', [
'admin', 'admin',
@@ -11,9 +10,6 @@ export const userRoleEnum = pgEnum('user_role', [
export const users = pgTable('user', { export const users = pgTable('user', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id),
email: varchar('email', { length: 255 }).notNull().unique(), email: varchar('email', { length: 255 }).notNull().unique(),
passwordHash: varchar('password_hash', { length: 255 }).notNull(), passwordHash: varchar('password_hash', { length: 255 }).notNull(),
firstName: varchar('first_name', { length: 100 }).notNull(), firstName: varchar('first_name', { length: 100 }).notNull(),

View File

@@ -39,7 +39,7 @@ async function seed() {
} }
// --- Admin user (if not exists) --- // --- Admin user (if not exists) ---
const [adminUser] = await sql`SELECT id FROM "user" WHERE email = 'admin@forte.dev'` const [adminUser] = await sql`SELECT id, company_id FROM "user" WHERE email = 'admin@forte.dev'`
if (!adminUser) { if (!adminUser) {
const bcrypt = await import('bcrypt') const bcrypt = await import('bcrypt')
const hashedPw = await (bcrypt.default || bcrypt).hash('admin1234', 10) const hashedPw = await (bcrypt.default || bcrypt).hash('admin1234', 10)
@@ -50,9 +50,14 @@ async function seed() {
} }
console.log(' Created admin user: admin@forte.dev / admin1234') console.log(' Created admin user: admin@forte.dev / admin1234')
} else { } else {
// Make sure admin role is assigned // Ensure admin is in the right company and has the admin role
if (adminUser.company_id !== COMPANY_ID) {
await sql`UPDATE "user" SET company_id = ${COMPANY_ID} WHERE id = ${adminUser.id}`
console.log(' Moved admin user to correct company')
}
const [adminRole] = await sql`SELECT id FROM role WHERE company_id = ${COMPANY_ID} AND slug = 'admin' LIMIT 1` const [adminRole] = await sql`SELECT id FROM role WHERE company_id = ${COMPANY_ID} AND slug = 'admin' LIMIT 1`
if (adminRole) { if (adminRole) {
await sql`DELETE FROM user_role_assignment WHERE user_id = ${adminUser.id}`
await sql`INSERT INTO user_role_assignment (user_id, role_id) VALUES (${adminUser.id}, ${adminRole.id}) ON CONFLICT DO NOTHING` await sql`INSERT INTO user_role_assignment (user_id, role_id) VALUES (${adminUser.id}, ${adminRole.id}) ON CONFLICT DO NOTHING`
} }
} }

View File

@@ -6,17 +6,16 @@ import { RbacService } from '../services/rbac.service.js'
declare module 'fastify' { declare module 'fastify' {
interface FastifyRequest { interface FastifyRequest {
companyId: string
locationId: string locationId: string
user: { id: string; companyId: string; role: string } user: { id: string; role: string }
permissions: Set<string> permissions: Set<string>
} }
} }
declare module '@fastify/jwt' { declare module '@fastify/jwt' {
interface FastifyJWT { interface FastifyJWT {
payload: { id: string; companyId: string; role: string } payload: { id: string; role: string }
user: { id: string; companyId: string; role: string } user: { id: string; role: string }
} }
} }
@@ -61,10 +60,7 @@ export const authPlugin = fp(async (app) => {
sign: { expiresIn: '24h' }, sign: { expiresIn: '24h' },
}) })
// Set companyId from header on all requests (for unauthenticated routes like register/login).
// Authenticated routes override this with the JWT payload via the authenticate decorator.
app.addHook('onRequest', async (request) => { app.addHook('onRequest', async (request) => {
request.companyId = (request.headers['x-company-id'] as string) ?? ''
request.locationId = (request.headers['x-location-id'] as string) ?? '' request.locationId = (request.headers['x-location-id'] as string) ?? ''
request.permissions = new Set() request.permissions = new Set()
}) })
@@ -72,7 +68,6 @@ export const authPlugin = fp(async (app) => {
app.decorate('authenticate', async function (request: any, reply: any) { app.decorate('authenticate', async function (request: any, reply: any) {
try { try {
await request.jwtVerify() await request.jwtVerify()
request.companyId = request.user.companyId
// Check if user account is active // Check if user account is active
const [dbUser] = await app.db const [dbUser] = await app.db

View File

@@ -2,9 +2,8 @@ import fp from 'fastify-plugin'
declare module 'fastify' { declare module 'fastify' {
interface FastifyRequest { interface FastifyRequest {
companyId: string
locationId: string locationId: string
user: { id: string; companyId: string; role: string } user: { id: string; role: string }
} }
} }
@@ -18,18 +17,14 @@ export const devAuthPlugin = fp(async (app) => {
} }
app.addHook('onRequest', async (request) => { app.addHook('onRequest', async (request) => {
const companyId =
(request.headers['x-company-id'] as string) ?? '00000000-0000-0000-0000-000000000001'
const locationId = const locationId =
(request.headers['x-location-id'] as string) ?? '00000000-0000-0000-0000-000000000010' (request.headers['x-location-id'] as string) ?? '00000000-0000-0000-0000-000000000010'
const userId = const userId =
(request.headers['x-user-id'] as string) ?? '00000000-0000-0000-0000-000000000001' (request.headers['x-user-id'] as string) ?? '00000000-0000-0000-0000-000000000001'
request.companyId = companyId
request.locationId = locationId request.locationId = locationId
request.user = { request.user = {
id: userId, id: userId,
companyId,
role: 'admin', role: 'admin',
} }
}) })

View File

@@ -31,19 +31,19 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) 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) const account = await AccountService.create(app.db, parsed.data)
return reply.status(201).send(account) return reply.status(201).send(account)
}) })
app.get('/accounts', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => { app.get('/accounts', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => {
const params = PaginationSchema.parse(request.query) const params = PaginationSchema.parse(request.query)
const result = await AccountService.list(app.db, request.companyId, params) const result = await AccountService.list(app.db, params)
return reply.send(result) return reply.send(result)
}) })
app.get('/accounts/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => { app.get('/accounts/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => {
const { id } = request.params as { id: string } const { id } = request.params as { id: string }
const account = await AccountService.getById(app.db, request.companyId, id) const account = await AccountService.getById(app.db, id)
if (!account) return reply.status(404).send({ error: { message: 'Account not found', statusCode: 404 } }) if (!account) return reply.status(404).send({ error: { message: 'Account not found', statusCode: 404 } })
return reply.send(account) return reply.send(account)
}) })
@@ -54,14 +54,14 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) 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) const account = await AccountService.update(app.db, id, parsed.data)
if (!account) return reply.status(404).send({ error: { message: 'Account not found', statusCode: 404 } }) if (!account) return reply.status(404).send({ error: { message: 'Account not found', statusCode: 404 } })
return reply.send(account) return reply.send(account)
}) })
app.delete('/accounts/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.admin')] }, async (request, reply) => { app.delete('/accounts/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string } const { id } = request.params as { id: string }
const account = await AccountService.softDelete(app.db, request.companyId, id) const account = await AccountService.softDelete(app.db, id)
if (!account) return reply.status(404).send({ error: { message: 'Account not found', statusCode: 404 } }) if (!account) return reply.status(404).send({ error: { message: 'Account not found', statusCode: 404 } })
request.log.info({ accountId: id, userId: request.user.id }, 'Account soft-deleted') request.log.info({ accountId: id, userId: request.user.id }, 'Account soft-deleted')
return reply.send(account) return reply.send(account)
@@ -71,7 +71,7 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
app.get('/members', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => { app.get('/members', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => {
const params = PaginationSchema.parse(request.query) const params = PaginationSchema.parse(request.query)
const result = await MemberService.list(app.db, request.companyId, params) const result = await MemberService.list(app.db, params)
return reply.send(result) return reply.send(result)
}) })
@@ -83,20 +83,20 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) 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) const member = await MemberService.create(app.db, parsed.data)
return reply.status(201).send(member) return reply.status(201).send(member)
}) })
app.get('/accounts/:accountId/members', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => { app.get('/accounts/:accountId/members', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => {
const { accountId } = request.params as { accountId: string } const { accountId } = request.params as { accountId: string }
const params = PaginationSchema.parse(request.query) const params = PaginationSchema.parse(request.query)
const result = await MemberService.listByAccount(app.db, request.companyId, accountId, params) const result = await MemberService.listByAccount(app.db, accountId, params)
return reply.send(result) return reply.send(result)
}) })
app.get('/members/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => { app.get('/members/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => {
const { id } = request.params as { id: string } const { id } = request.params as { id: string }
const member = await MemberService.getById(app.db, request.companyId, id) const member = await MemberService.getById(app.db, id)
if (!member) return reply.status(404).send({ error: { message: 'Member not found', statusCode: 404 } }) if (!member) return reply.status(404).send({ error: { message: 'Member not found', statusCode: 404 } })
return reply.send(member) return reply.send(member)
}) })
@@ -107,7 +107,7 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) 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) const member = await MemberService.update(app.db, id, parsed.data)
if (!member) return reply.status(404).send({ error: { message: 'Member not found', statusCode: 404 } }) if (!member) return reply.status(404).send({ error: { message: 'Member not found', statusCode: 404 } })
return reply.send(member) return reply.send(member)
}) })
@@ -120,16 +120,16 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
// If no accountId provided, create a new account from the member's name // If no accountId provided, create a new account from the member's name
if (!targetAccountId) { if (!targetAccountId) {
const member = await MemberService.getById(app.db, request.companyId, id) const member = await MemberService.getById(app.db, id)
if (!member) return reply.status(404).send({ error: { message: 'Member not found', statusCode: 404 } }) if (!member) return reply.status(404).send({ error: { message: 'Member not found', statusCode: 404 } })
const account = await AccountService.create(app.db, request.companyId, { const account = await AccountService.create(app.db, {
name: `${member.firstName} ${member.lastName}`, name: `${member.firstName} ${member.lastName}`,
billingMode: 'consolidated', billingMode: 'consolidated',
}) })
targetAccountId = account.id targetAccountId = account.id
} }
const member = await MemberService.move(app.db, request.companyId, id, targetAccountId) const member = await MemberService.move(app.db, id, targetAccountId)
if (!member) return reply.status(404).send({ error: { message: 'Member not found', statusCode: 404 } }) if (!member) return reply.status(404).send({ error: { message: 'Member not found', statusCode: 404 } })
request.log.info({ memberId: id, targetAccountId, userId: request.user.id }, 'Member moved to account') request.log.info({ memberId: id, targetAccountId, userId: request.user.id }, 'Member moved to account')
return reply.send(member) return reply.send(member)
@@ -143,14 +143,14 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
} }
const identifier = await MemberIdentifierService.create(app.db, request.companyId, parsed.data) const identifier = await MemberIdentifierService.create(app.db, parsed.data)
return reply.status(201).send(identifier) return reply.status(201).send(identifier)
}) })
app.get('/members/:memberId/identifiers', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => { app.get('/members/:memberId/identifiers', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => {
const { memberId } = request.params as { memberId: string } const { memberId } = request.params as { memberId: string }
const params = PaginationSchema.parse(request.query) const params = PaginationSchema.parse(request.query)
const result = await MemberIdentifierService.listByMember(app.db, request.companyId, memberId, params) const result = await MemberIdentifierService.listByMember(app.db, memberId, params)
return reply.send(result) return reply.send(result)
}) })
@@ -160,21 +160,21 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
} }
const identifier = await MemberIdentifierService.update(app.db, request.companyId, id, parsed.data) const identifier = await MemberIdentifierService.update(app.db, id, parsed.data)
if (!identifier) return reply.status(404).send({ error: { message: 'Identifier not found', statusCode: 404 } }) if (!identifier) return reply.status(404).send({ error: { message: 'Identifier not found', statusCode: 404 } })
return reply.send(identifier) return reply.send(identifier)
}) })
app.delete('/identifiers/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.admin')] }, async (request, reply) => { app.delete('/identifiers/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string } const { id } = request.params as { id: string }
const identifier = await MemberIdentifierService.delete(app.db, request.companyId, id) const identifier = await MemberIdentifierService.delete(app.db, id)
if (!identifier) return reply.status(404).send({ error: { message: 'Identifier not found', statusCode: 404 } }) if (!identifier) return reply.status(404).send({ error: { message: 'Identifier not found', statusCode: 404 } })
return reply.send(identifier) return reply.send(identifier)
}) })
app.delete('/members/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.admin')] }, async (request, reply) => { app.delete('/members/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string } const { id } = request.params as { id: string }
const member = await MemberService.delete(app.db, request.companyId, id) const member = await MemberService.delete(app.db, id)
if (!member) return reply.status(404).send({ error: { message: 'Member not found', statusCode: 404 } }) if (!member) return reply.status(404).send({ error: { message: 'Member not found', statusCode: 404 } })
return reply.send(member) return reply.send(member)
}) })
@@ -187,14 +187,14 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
} }
const link = await ProcessorLinkService.create(app.db, request.companyId, parsed.data) const link = await ProcessorLinkService.create(app.db, parsed.data)
return reply.status(201).send(link) return reply.status(201).send(link)
}) })
app.get('/accounts/:accountId/processor-links', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => { app.get('/accounts/:accountId/processor-links', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => {
const { accountId } = request.params as { accountId: string } const { accountId } = request.params as { accountId: string }
const params = PaginationSchema.parse(request.query) const params = PaginationSchema.parse(request.query)
const result = await ProcessorLinkService.listByAccount(app.db, request.companyId, accountId, params) const result = await ProcessorLinkService.listByAccount(app.db, accountId, params)
return reply.send(result) return reply.send(result)
}) })
@@ -204,14 +204,14 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
} }
const link = await ProcessorLinkService.update(app.db, request.companyId, id, parsed.data) const link = await ProcessorLinkService.update(app.db, id, parsed.data)
if (!link) return reply.status(404).send({ error: { message: 'Processor link not found', statusCode: 404 } }) if (!link) return reply.status(404).send({ error: { message: 'Processor link not found', statusCode: 404 } })
return reply.send(link) return reply.send(link)
}) })
app.delete('/processor-links/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.admin')] }, async (request, reply) => { app.delete('/processor-links/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string } const { id } = request.params as { id: string }
const link = await ProcessorLinkService.delete(app.db, request.companyId, id) const link = await ProcessorLinkService.delete(app.db, id)
if (!link) return reply.status(404).send({ error: { message: 'Processor link not found', statusCode: 404 } }) if (!link) return reply.status(404).send({ error: { message: 'Processor link not found', statusCode: 404 } })
return reply.send(link) return reply.send(link)
}) })
@@ -224,20 +224,20 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
} }
const method = await PaymentMethodService.create(app.db, request.companyId, parsed.data) const method = await PaymentMethodService.create(app.db, parsed.data)
return reply.status(201).send(method) return reply.status(201).send(method)
}) })
app.get('/accounts/:accountId/payment-methods', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => { app.get('/accounts/:accountId/payment-methods', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => {
const { accountId } = request.params as { accountId: string } const { accountId } = request.params as { accountId: string }
const params = PaginationSchema.parse(request.query) const params = PaginationSchema.parse(request.query)
const result = await PaymentMethodService.listByAccount(app.db, request.companyId, accountId, params) const result = await PaymentMethodService.listByAccount(app.db, accountId, params)
return reply.send(result) return reply.send(result)
}) })
app.get('/payment-methods/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => { app.get('/payment-methods/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => {
const { id } = request.params as { id: string } const { id } = request.params as { id: string }
const method = await PaymentMethodService.getById(app.db, request.companyId, id) const method = await PaymentMethodService.getById(app.db, id)
if (!method) return reply.status(404).send({ error: { message: 'Payment method not found', statusCode: 404 } }) if (!method) return reply.status(404).send({ error: { message: 'Payment method not found', statusCode: 404 } })
return reply.send(method) return reply.send(method)
}) })
@@ -248,14 +248,14 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
} }
const method = await PaymentMethodService.update(app.db, request.companyId, id, parsed.data) const method = await PaymentMethodService.update(app.db, id, parsed.data)
if (!method) return reply.status(404).send({ error: { message: 'Payment method not found', statusCode: 404 } }) if (!method) return reply.status(404).send({ error: { message: 'Payment method not found', statusCode: 404 } })
return reply.send(method) return reply.send(method)
}) })
app.delete('/payment-methods/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.admin')] }, async (request, reply) => { app.delete('/payment-methods/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string } const { id } = request.params as { id: string }
const method = await PaymentMethodService.delete(app.db, request.companyId, id) const method = await PaymentMethodService.delete(app.db, id)
if (!method) return reply.status(404).send({ error: { message: 'Payment method not found', statusCode: 404 } }) if (!method) return reply.status(404).send({ error: { message: 'Payment method not found', statusCode: 404 } })
return reply.send(method) return reply.send(method)
}) })
@@ -268,20 +268,20 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
} }
const exemption = await TaxExemptionService.create(app.db, request.companyId, parsed.data) const exemption = await TaxExemptionService.create(app.db, parsed.data)
return reply.status(201).send(exemption) return reply.status(201).send(exemption)
}) })
app.get('/accounts/:accountId/tax-exemptions', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => { app.get('/accounts/:accountId/tax-exemptions', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => {
const { accountId } = request.params as { accountId: string } const { accountId } = request.params as { accountId: string }
const params = PaginationSchema.parse(request.query) const params = PaginationSchema.parse(request.query)
const result = await TaxExemptionService.listByAccount(app.db, request.companyId, accountId, params) const result = await TaxExemptionService.listByAccount(app.db, accountId, params)
return reply.send(result) return reply.send(result)
}) })
app.get('/tax-exemptions/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => { app.get('/tax-exemptions/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => {
const { id } = request.params as { id: string } const { id } = request.params as { id: string }
const exemption = await TaxExemptionService.getById(app.db, request.companyId, id) const exemption = await TaxExemptionService.getById(app.db, id)
if (!exemption) return reply.status(404).send({ error: { message: 'Tax exemption not found', statusCode: 404 } }) if (!exemption) return reply.status(404).send({ error: { message: 'Tax exemption not found', statusCode: 404 } })
return reply.send(exemption) return reply.send(exemption)
}) })
@@ -292,14 +292,14 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
} }
const exemption = await TaxExemptionService.update(app.db, request.companyId, id, parsed.data) const exemption = await TaxExemptionService.update(app.db, id, parsed.data)
if (!exemption) return reply.status(404).send({ error: { message: 'Tax exemption not found', statusCode: 404 } }) if (!exemption) return reply.status(404).send({ error: { message: 'Tax exemption not found', statusCode: 404 } })
return reply.send(exemption) return reply.send(exemption)
}) })
app.post('/tax-exemptions/:id/approve', { preHandler: [app.authenticate, app.requirePermission('accounts.admin')] }, async (request, reply) => { app.post('/tax-exemptions/:id/approve', { preHandler: [app.authenticate, app.requirePermission('accounts.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string } const { id } = request.params as { id: string }
const exemption = await TaxExemptionService.approve(app.db, request.companyId, id, request.user.id) const exemption = await TaxExemptionService.approve(app.db, id, request.user.id)
if (!exemption) return reply.status(404).send({ error: { message: 'Tax exemption not found', statusCode: 404 } }) if (!exemption) return reply.status(404).send({ error: { message: 'Tax exemption not found', statusCode: 404 } })
request.log.info({ exemptionId: id, accountId: exemption.accountId, userId: request.user.id }, 'Tax exemption approved') request.log.info({ exemptionId: id, accountId: exemption.accountId, userId: request.user.id }, 'Tax exemption approved')
return reply.send(exemption) return reply.send(exemption)
@@ -311,7 +311,7 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
if (!reason) { if (!reason) {
return reply.status(400).send({ error: { message: 'Reason is required to revoke a tax exemption', statusCode: 400 } }) return reply.status(400).send({ error: { message: 'Reason is required to revoke a tax exemption', statusCode: 400 } })
} }
const exemption = await TaxExemptionService.revoke(app.db, request.companyId, id, request.user.id, reason) const exemption = await TaxExemptionService.revoke(app.db, id, request.user.id, reason)
if (!exemption) return reply.status(404).send({ error: { message: 'Tax exemption not found', statusCode: 404 } }) if (!exemption) return reply.status(404).send({ error: { message: 'Tax exemption not found', statusCode: 404 } })
request.log.warn({ exemptionId: id, accountId: exemption.accountId, userId: request.user.id, reason }, 'Tax exemption revoked') request.log.warn({ exemptionId: id, accountId: exemption.accountId, userId: request.user.id, reason }, 'Tax exemption revoked')
return reply.send(exemption) return reply.send(exemption)

View File

@@ -3,7 +3,6 @@ import { eq } from 'drizzle-orm'
import bcrypt from 'bcrypt' import bcrypt from 'bcrypt'
import { RegisterSchema, LoginSchema } from '@forte/shared/schemas' import { RegisterSchema, LoginSchema } from '@forte/shared/schemas'
import { users } from '../../db/schema/users.js' import { users } from '../../db/schema/users.js'
import { companies } from '../../db/schema/stores.js'
const SALT_ROUNDS = 10 const SALT_ROUNDS = 10
@@ -27,28 +26,8 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
} }
const { email, password, firstName, lastName, role } = parsed.data const { email, password, firstName, lastName, role } = parsed.data
const companyId = request.companyId
// Validate that the company exists // Email is globally unique
if (!companyId) {
return reply.status(400).send({
error: { message: 'Company ID is required (x-company-id header)', statusCode: 400 },
})
}
const [company] = await app.db
.select({ id: companies.id })
.from(companies)
.where(eq(companies.id, companyId))
.limit(1)
if (!company) {
return reply.status(400).send({
error: { message: 'Invalid company', statusCode: 400 },
})
}
// Email is globally unique across all companies
const existing = await app.db const existing = await app.db
.select({ id: users.id }) .select({ id: users.id })
.from(users) .from(users)
@@ -66,7 +45,6 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
const [user] = await app.db const [user] = await app.db
.insert(users) .insert(users)
.values({ .values({
companyId,
email, email,
passwordHash, passwordHash,
firstName, firstName,
@@ -84,11 +62,10 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
const token = app.jwt.sign({ const token = app.jwt.sign({
id: user.id, id: user.id,
companyId,
role: user.role, role: user.role,
}) })
request.log.info({ userId: user.id, email: user.email, companyId }, 'User registered') request.log.info({ userId: user.id, email: user.email }, 'User registered')
return reply.status(201).send({ user, token }) return reply.status(201).send({ user, token })
}) })
@@ -126,7 +103,6 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
const token = app.jwt.sign({ const token = app.jwt.sign({
id: user.id, id: user.id,
companyId: user.companyId,
role: user.role, role: user.role,
}) })

View File

@@ -18,8 +18,7 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
throw new ValidationError('entityType and entityId query params required') throw new ValidationError('entityType and entityId query params required')
} }
// Files are company-scoped in the service — companyId from JWT ensures access control const fileRecords = await FileService.listByEntity(app.db, entityType, entityId)
const fileRecords = await FileService.listByEntity(app.db, request.companyId, entityType, entityId)
const data = await Promise.all( const data = await Promise.all(
fileRecords.map(async (f) => ({ ...f, url: await app.storage.getUrl(f.path) })), fileRecords.map(async (f) => ({ ...f, url: await app.storage.getUrl(f.path) })),
) )
@@ -59,7 +58,7 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
const buffer = await data.toBuffer() const buffer = await data.toBuffer()
const file = await FileService.upload(app.db, app.storage, request.companyId, { const file = await FileService.upload(app.db, app.storage, {
data: buffer, data: buffer,
filename: data.filename, filename: data.filename,
contentType: data.mimetype, contentType: data.mimetype,
@@ -76,15 +75,14 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
}) })
// Serve file content (for local provider) // Serve file content (for local provider)
// Path traversal protection: validate the path starts with the requesting company's ID
app.get('/files/serve/*', { preHandler: [app.authenticate, app.requirePermission('files.view')] }, async (request, reply) => { app.get('/files/serve/*', { preHandler: [app.authenticate, app.requirePermission('files.view')] }, async (request, reply) => {
const filePath = (request.params as { '*': string })['*'] const filePath = (request.params as { '*': string })['*']
if (!filePath) { if (!filePath) {
throw new ValidationError('Path required') throw new ValidationError('Path required')
} }
// Path traversal protection: must start with company ID, no '..' allowed // Path traversal protection: no '..' allowed
if (filePath.includes('..') || !filePath.startsWith(request.companyId)) { if (filePath.includes('..')) {
return reply.status(403).send({ error: { message: 'Access denied', statusCode: 403 } }) return reply.status(403).send({ error: { message: 'Access denied', statusCode: 403 } })
} }
@@ -106,7 +104,7 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
// Get file metadata // Get file metadata
app.get('/files/:id', { preHandler: [app.authenticate, app.requirePermission('files.view')] }, async (request, reply) => { app.get('/files/:id', { preHandler: [app.authenticate, app.requirePermission('files.view')] }, async (request, reply) => {
const { id } = request.params as { id: string } const { id } = request.params as { id: string }
const file = await FileService.getById(app.db, request.companyId, id) const file = await FileService.getById(app.db, id)
if (!file) return reply.status(404).send({ error: { message: 'File not found', statusCode: 404 } }) if (!file) return reply.status(404).send({ error: { message: 'File not found', statusCode: 404 } })
const url = await app.storage.getUrl(file.path) const url = await app.storage.getUrl(file.path)
return reply.send({ ...file, url }) return reply.send({ ...file, url })
@@ -115,12 +113,12 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
// Generate signed URL for a file (short-lived token in query string) // Generate signed URL for a file (short-lived token in query string)
app.get('/files/signed-url/:id', { preHandler: [app.authenticate, app.requirePermission('files.view')] }, async (request, reply) => { app.get('/files/signed-url/:id', { preHandler: [app.authenticate, app.requirePermission('files.view')] }, async (request, reply) => {
const { id } = request.params as { id: string } const { id } = request.params as { id: string }
const file = await FileService.getById(app.db, request.companyId, id) const file = await FileService.getById(app.db, id)
if (!file) return reply.status(404).send({ error: { message: 'File not found', statusCode: 404 } }) if (!file) return reply.status(404).send({ error: { message: 'File not found', statusCode: 404 } })
// Sign a short-lived token with the file path // Sign a short-lived token with the file path
const token = app.jwt.sign( const token = app.jwt.sign(
{ path: file.path, companyId: request.companyId, purpose: 'file-access' } as any, { path: file.path, purpose: 'file-access' } as any,
{ expiresIn: '15m' }, { expiresIn: '15m' },
) )
@@ -139,14 +137,10 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
// Verify the signed token // Verify the signed token
try { try {
const payload = app.jwt.verify(token) as { path: string; companyId: string; purpose: string } const payload = app.jwt.verify(token) as { path: string; purpose: string }
if (payload.purpose !== 'file-access' || payload.path !== filePath) { if (payload.purpose !== 'file-access' || payload.path !== filePath) {
return reply.status(403).send({ error: { message: 'Invalid token', statusCode: 403 } }) return reply.status(403).send({ error: { message: 'Invalid token', statusCode: 403 } })
} }
// Validate company isolation — file path must start with the token's companyId
if (payload.companyId && !filePath.startsWith(payload.companyId)) {
return reply.status(403).send({ error: { message: 'Access denied', statusCode: 403 } })
}
} catch { } catch {
return reply.status(403).send({ error: { message: 'Token expired or invalid', statusCode: 403 } }) return reply.status(403).send({ error: { message: 'Token expired or invalid', statusCode: 403 } })
} }
@@ -174,7 +168,7 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
// Delete a file // Delete a file
app.delete('/files/:id', { preHandler: [app.authenticate, app.requirePermission('files.delete')] }, async (request, reply) => { app.delete('/files/:id', { preHandler: [app.authenticate, app.requirePermission('files.delete')] }, async (request, reply) => {
const { id } = request.params as { id: string } const { id } = request.params as { id: string }
const file = await FileService.delete(app.db, app.storage, request.companyId, id) const file = await FileService.delete(app.db, app.storage, id)
if (!file) return reply.status(404).send({ error: { message: 'File not found', statusCode: 404 } }) if (!file) return reply.status(404).send({ error: { message: 'File not found', statusCode: 404 } })
request.log.info({ fileId: id, path: file.path }, 'File deleted') request.log.info({ fileId: id, path: file.path }, 'File deleted')
return reply.send(file) return reply.send(file)

View File

@@ -16,19 +16,19 @@ export const inventoryRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) 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) const category = await CategoryService.create(app.db, parsed.data)
return reply.status(201).send(category) return reply.status(201).send(category)
}) })
app.get('/categories', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => { app.get('/categories', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
const params = PaginationSchema.parse(request.query) const params = PaginationSchema.parse(request.query)
const result = await CategoryService.list(app.db, request.companyId, params) const result = await CategoryService.list(app.db, params)
return reply.send(result) return reply.send(result)
}) })
app.get('/categories/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => { app.get('/categories/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
const { id } = request.params as { id: string } const { id } = request.params as { id: string }
const category = await CategoryService.getById(app.db, request.companyId, id) const category = await CategoryService.getById(app.db, id)
if (!category) return reply.status(404).send({ error: { message: 'Category not found', statusCode: 404 } }) if (!category) return reply.status(404).send({ error: { message: 'Category not found', statusCode: 404 } })
return reply.send(category) return reply.send(category)
}) })
@@ -39,14 +39,14 @@ export const inventoryRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) 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) const category = await CategoryService.update(app.db, id, parsed.data)
if (!category) return reply.status(404).send({ error: { message: 'Category not found', statusCode: 404 } }) if (!category) return reply.status(404).send({ error: { message: 'Category not found', statusCode: 404 } })
return reply.send(category) return reply.send(category)
}) })
app.delete('/categories/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.admin')] }, async (request, reply) => { app.delete('/categories/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string } const { id } = request.params as { id: string }
const category = await CategoryService.softDelete(app.db, request.companyId, id) const category = await CategoryService.softDelete(app.db, id)
if (!category) return reply.status(404).send({ error: { message: 'Category not found', statusCode: 404 } }) if (!category) return reply.status(404).send({ error: { message: 'Category not found', statusCode: 404 } })
return reply.send(category) return reply.send(category)
}) })
@@ -58,19 +58,19 @@ export const inventoryRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) 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) const supplier = await SupplierService.create(app.db, parsed.data)
return reply.status(201).send(supplier) return reply.status(201).send(supplier)
}) })
app.get('/suppliers', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => { app.get('/suppliers', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
const params = PaginationSchema.parse(request.query) const params = PaginationSchema.parse(request.query)
const result = await SupplierService.list(app.db, request.companyId, params) const result = await SupplierService.list(app.db, params)
return reply.send(result) return reply.send(result)
}) })
app.get('/suppliers/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => { app.get('/suppliers/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
const { id } = request.params as { id: string } const { id } = request.params as { id: string }
const supplier = await SupplierService.getById(app.db, request.companyId, id) const supplier = await SupplierService.getById(app.db, id)
if (!supplier) return reply.status(404).send({ error: { message: 'Supplier not found', statusCode: 404 } }) if (!supplier) return reply.status(404).send({ error: { message: 'Supplier not found', statusCode: 404 } })
return reply.send(supplier) return reply.send(supplier)
}) })
@@ -81,14 +81,14 @@ export const inventoryRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) 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) const supplier = await SupplierService.update(app.db, id, parsed.data)
if (!supplier) return reply.status(404).send({ error: { message: 'Supplier not found', statusCode: 404 } }) if (!supplier) return reply.status(404).send({ error: { message: 'Supplier not found', statusCode: 404 } })
return reply.send(supplier) return reply.send(supplier)
}) })
app.delete('/suppliers/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.admin')] }, async (request, reply) => { app.delete('/suppliers/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string } const { id } = request.params as { id: string }
const supplier = await SupplierService.softDelete(app.db, request.companyId, id) const supplier = await SupplierService.softDelete(app.db, id)
if (!supplier) return reply.status(404).send({ error: { message: 'Supplier not found', statusCode: 404 } }) if (!supplier) return reply.status(404).send({ error: { message: 'Supplier not found', statusCode: 404 } })
return reply.send(supplier) return reply.send(supplier)
}) })

View File

@@ -6,7 +6,7 @@ import { ConflictError, ValidationError } from '../../lib/errors.js'
function createLookupRoutes(prefix: string, service: typeof UnitStatusService) { function createLookupRoutes(prefix: string, service: typeof UnitStatusService) {
const routes: FastifyPluginAsync = async (app) => { const routes: FastifyPluginAsync = async (app) => {
app.get(`/${prefix}`, { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => { app.get(`/${prefix}`, { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
const data = await service.list(app.db, request.companyId) const data = await service.list(app.db)
return reply.send({ data }) return reply.send({ data })
}) })
@@ -16,12 +16,12 @@ function createLookupRoutes(prefix: string, service: typeof UnitStatusService) {
throw new ValidationError('Validation failed', parsed.error.flatten()) throw new ValidationError('Validation failed', parsed.error.flatten())
} }
const existing = await service.getBySlug(app.db, request.companyId, parsed.data.slug) const existing = await service.getBySlug(app.db, parsed.data.slug)
if (existing) { if (existing) {
throw new ConflictError(`Slug "${parsed.data.slug}" already exists`) throw new ConflictError(`Slug "${parsed.data.slug}" already exists`)
} }
const row = await service.create(app.db, request.companyId, parsed.data) const row = await service.create(app.db, parsed.data)
return reply.status(201).send(row) return reply.status(201).send(row)
}) })
@@ -31,14 +31,14 @@ function createLookupRoutes(prefix: string, service: typeof UnitStatusService) {
if (!parsed.success) { if (!parsed.success) {
throw new ValidationError('Validation failed', parsed.error.flatten()) throw new ValidationError('Validation failed', parsed.error.flatten())
} }
const row = await service.update(app.db, request.companyId, id, parsed.data) const row = await service.update(app.db, id, parsed.data)
if (!row) return reply.status(404).send({ error: { message: 'Not found', statusCode: 404 } }) if (!row) return reply.status(404).send({ error: { message: 'Not found', statusCode: 404 } })
return reply.send(row) return reply.send(row)
}) })
app.delete(`/${prefix}/:id`, { preHandler: [app.authenticate, app.requirePermission('inventory.admin')] }, async (request, reply) => { app.delete(`/${prefix}/:id`, { preHandler: [app.authenticate, app.requirePermission('inventory.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string } const { id } = request.params as { id: string }
const row = await service.delete(app.db, request.companyId, id) const row = await service.delete(app.db, id)
if (!row) return reply.status(404).send({ error: { message: 'Not found', statusCode: 404 } }) if (!row) return reply.status(404).send({ error: { message: 'Not found', statusCode: 404 } })
return reply.send(row) return reply.send(row)
}) })

View File

@@ -16,19 +16,19 @@ export const productRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
} }
const product = await ProductService.create(app.db, request.companyId, parsed.data) const product = await ProductService.create(app.db, parsed.data)
return reply.status(201).send(product) return reply.status(201).send(product)
}) })
app.get('/products', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => { app.get('/products', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
const params = PaginationSchema.parse(request.query) const params = PaginationSchema.parse(request.query)
const result = await ProductService.list(app.db, request.companyId, params) const result = await ProductService.list(app.db, params)
return reply.send(result) return reply.send(result)
}) })
app.get('/products/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => { app.get('/products/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
const { id } = request.params as { id: string } const { id } = request.params as { id: string }
const product = await ProductService.getById(app.db, request.companyId, id) const product = await ProductService.getById(app.db, id)
if (!product) return reply.status(404).send({ error: { message: 'Product not found', statusCode: 404 } }) if (!product) return reply.status(404).send({ error: { message: 'Product not found', statusCode: 404 } })
return reply.send(product) return reply.send(product)
}) })
@@ -39,14 +39,14 @@ export const productRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
} }
const product = await ProductService.update(app.db, request.companyId, id, parsed.data, request.user.id) const product = await ProductService.update(app.db, id, parsed.data, request.user.id)
if (!product) return reply.status(404).send({ error: { message: 'Product not found', statusCode: 404 } }) if (!product) return reply.status(404).send({ error: { message: 'Product not found', statusCode: 404 } })
return reply.send(product) return reply.send(product)
}) })
app.delete('/products/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.admin')] }, async (request, reply) => { app.delete('/products/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string } const { id } = request.params as { id: string }
const product = await ProductService.softDelete(app.db, request.companyId, id) const product = await ProductService.softDelete(app.db, id)
if (!product) return reply.status(404).send({ error: { message: 'Product not found', statusCode: 404 } }) if (!product) return reply.status(404).send({ error: { message: 'Product not found', statusCode: 404 } })
return reply.send(product) return reply.send(product)
}) })
@@ -59,20 +59,20 @@ export const productRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
} }
const unit = await InventoryUnitService.create(app.db, request.companyId, parsed.data) const unit = await InventoryUnitService.create(app.db, parsed.data)
return reply.status(201).send(unit) return reply.status(201).send(unit)
}) })
app.get('/products/:productId/units', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => { app.get('/products/:productId/units', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
const { productId } = request.params as { productId: string } const { productId } = request.params as { productId: string }
const params = PaginationSchema.parse(request.query) const params = PaginationSchema.parse(request.query)
const result = await InventoryUnitService.listByProduct(app.db, request.companyId, productId, params) const result = await InventoryUnitService.listByProduct(app.db, productId, params)
return reply.send(result) return reply.send(result)
}) })
app.get('/units/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => { app.get('/units/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
const { id } = request.params as { id: string } const { id } = request.params as { id: string }
const unit = await InventoryUnitService.getById(app.db, request.companyId, id) const unit = await InventoryUnitService.getById(app.db, id)
if (!unit) return reply.status(404).send({ error: { message: 'Unit not found', statusCode: 404 } }) if (!unit) return reply.status(404).send({ error: { message: 'Unit not found', statusCode: 404 } })
return reply.send(unit) return reply.send(unit)
}) })
@@ -83,7 +83,7 @@ export const productRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
} }
const unit = await InventoryUnitService.update(app.db, request.companyId, id, parsed.data) const unit = await InventoryUnitService.update(app.db, id, parsed.data)
if (!unit) return reply.status(404).send({ error: { message: 'Unit not found', statusCode: 404 } }) if (!unit) return reply.status(404).send({ error: { message: 'Unit not found', statusCode: 404 } })
return reply.send(unit) return reply.send(unit)
}) })

View File

@@ -1,5 +1,5 @@
import type { FastifyPluginAsync } from 'fastify' import type { FastifyPluginAsync } from 'fastify'
import { eq, and, count, sql, type Column } from 'drizzle-orm' import { eq, count, sql, type Column } from 'drizzle-orm'
import { PaginationSchema } from '@forte/shared/schemas' import { PaginationSchema } from '@forte/shared/schemas'
import { RbacService } from '../../services/rbac.service.js' import { RbacService } from '../../services/rbac.service.js'
import { ValidationError } from '../../lib/errors.js' import { ValidationError } from '../../lib/errors.js'
@@ -12,13 +12,12 @@ export const rbacRoutes: FastifyPluginAsync = async (app) => {
app.get('/users', { preHandler: [app.authenticate, app.requirePermission('users.view')] }, async (request, reply) => { app.get('/users', { preHandler: [app.authenticate, app.requirePermission('users.view')] }, async (request, reply) => {
const params = PaginationSchema.parse(request.query) const params = PaginationSchema.parse(request.query)
const baseWhere = eq(users.companyId, request.companyId)
const searchCondition = params.q const searchCondition = params.q
? buildSearchCondition(params.q, [users.firstName, users.lastName, users.email]) ? buildSearchCondition(params.q, [users.firstName, users.lastName, users.email])
: undefined : undefined
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere const where = searchCondition ?? undefined
const sortableColumns: Record<string, Column> = { const sortableColumns: Record<string, Column> = {
name: users.lastName, name: users.lastName,
@@ -96,7 +95,7 @@ export const rbacRoutes: FastifyPluginAsync = async (app) => {
const [updated] = await app.db const [updated] = await app.db
.update(users) .update(users)
.set({ isActive, updatedAt: new Date() }) .set({ isActive, updatedAt: new Date() })
.where(and(eq(users.id, userId), eq(users.companyId, request.companyId))) .where(eq(users.id, userId))
.returning({ id: users.id, isActive: users.isActive }) .returning({ id: users.id, isActive: users.isActive })
if (!updated) return reply.status(404).send({ error: { message: 'User not found', statusCode: 404 } }) if (!updated) return reply.status(404).send({ error: { message: 'User not found', statusCode: 404 } })
@@ -116,7 +115,7 @@ export const rbacRoutes: FastifyPluginAsync = async (app) => {
app.get('/roles', { preHandler: [app.authenticate, app.requirePermission('users.view')] }, async (request, reply) => { app.get('/roles', { preHandler: [app.authenticate, app.requirePermission('users.view')] }, async (request, reply) => {
const params = PaginationSchema.parse(request.query) const params = PaginationSchema.parse(request.query)
const result = await RbacService.listRoles(app.db, request.companyId, params) const result = await RbacService.listRoles(app.db, params)
return reply.send(result) return reply.send(result)
}) })
@@ -125,14 +124,14 @@ export const rbacRoutes: FastifyPluginAsync = async (app) => {
const data = await app.db const data = await app.db
.select() .select()
.from(roles) .from(roles)
.where(and(eq(roles.companyId, request.companyId), eq(roles.isActive, true))) .where(eq(roles.isActive, true))
.orderBy(roles.name) .orderBy(roles.name)
return reply.send({ data }) return reply.send({ data })
}) })
app.get('/roles/:id', { preHandler: [app.authenticate, app.requirePermission('users.view')] }, async (request, reply) => { app.get('/roles/:id', { preHandler: [app.authenticate, app.requirePermission('users.view')] }, async (request, reply) => {
const { id } = request.params as { id: string } const { id } = request.params as { id: string }
const role = await RbacService.getRoleWithPermissions(app.db, request.companyId, id) const role = await RbacService.getRoleWithPermissions(app.db, id)
if (!role) return reply.status(404).send({ error: { message: 'Role not found', statusCode: 404 } }) if (!role) return reply.status(404).send({ error: { message: 'Role not found', statusCode: 404 } })
return reply.send(role) return reply.send(role)
}) })
@@ -153,7 +152,7 @@ export const rbacRoutes: FastifyPluginAsync = async (app) => {
throw new ValidationError('slug must be lowercase alphanumeric with underscores') throw new ValidationError('slug must be lowercase alphanumeric with underscores')
} }
const role = await RbacService.createRole(app.db, request.companyId, { const role = await RbacService.createRole(app.db, {
name, name,
slug, slug,
description, description,
@@ -172,7 +171,7 @@ export const rbacRoutes: FastifyPluginAsync = async (app) => {
permissionSlugs?: string[] permissionSlugs?: string[]
} }
const role = await RbacService.updateRole(app.db, request.companyId, id, { const role = await RbacService.updateRole(app.db, id, {
name, name,
description, description,
permissionSlugs, permissionSlugs,
@@ -185,7 +184,7 @@ export const rbacRoutes: FastifyPluginAsync = async (app) => {
app.delete('/roles/:id', { preHandler: [app.authenticate, app.requirePermission('users.admin')] }, async (request, reply) => { app.delete('/roles/:id', { preHandler: [app.authenticate, app.requirePermission('users.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string } const { id } = request.params as { id: string }
const role = await RbacService.deleteRole(app.db, request.companyId, id) const role = await RbacService.deleteRole(app.db, id)
if (!role) return reply.status(404).send({ error: { message: 'Role not found', statusCode: 404 } }) if (!role) return reply.status(404).send({ error: { message: 'Role not found', statusCode: 404 } })
request.log.info({ roleId: id, roleName: role.name, userId: request.user.id }, 'Role deleted') request.log.info({ roleId: id, roleName: role.name, userId: request.user.id }, 'Role deleted')
return reply.send(role) return reply.send(role)

View File

@@ -23,7 +23,7 @@ export const repairRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
} }
const ticket = await RepairTicketService.create(app.db, request.companyId, parsed.data) const ticket = await RepairTicketService.create(app.db, parsed.data)
return reply.status(201).send(ticket) return reply.status(201).send(ticket)
}) })
@@ -44,13 +44,13 @@ export const repairRoutes: FastifyPluginAsync = async (app) => {
completedDateTo: query.completedDateTo, completedDateTo: query.completedDateTo,
} }
const result = await RepairTicketService.list(app.db, request.companyId, params, filters) const result = await RepairTicketService.list(app.db, params, filters)
return reply.send(result) return reply.send(result)
}) })
app.get('/repair-tickets/:id', { preHandler: [app.authenticate, app.requirePermission('repairs.view')] }, async (request, reply) => { app.get('/repair-tickets/:id', { preHandler: [app.authenticate, app.requirePermission('repairs.view')] }, async (request, reply) => {
const { id } = request.params as { id: string } const { id } = request.params as { id: string }
const ticket = await RepairTicketService.getById(app.db, request.companyId, id) const ticket = await RepairTicketService.getById(app.db, id)
if (!ticket) return reply.status(404).send({ error: { message: 'Repair ticket not found', statusCode: 404 } }) if (!ticket) return reply.status(404).send({ error: { message: 'Repair ticket not found', statusCode: 404 } })
return reply.send(ticket) return reply.send(ticket)
}) })
@@ -61,7 +61,7 @@ export const repairRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
} }
const ticket = await RepairTicketService.update(app.db, request.companyId, id, parsed.data) const ticket = await RepairTicketService.update(app.db, id, parsed.data)
if (!ticket) return reply.status(404).send({ error: { message: 'Repair ticket not found', statusCode: 404 } }) if (!ticket) return reply.status(404).send({ error: { message: 'Repair ticket not found', statusCode: 404 } })
return reply.send(ticket) return reply.send(ticket)
}) })
@@ -72,14 +72,14 @@ export const repairRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
} }
const ticket = await RepairTicketService.updateStatus(app.db, request.companyId, id, parsed.data.status) const ticket = await RepairTicketService.updateStatus(app.db, id, parsed.data.status)
if (!ticket) return reply.status(404).send({ error: { message: 'Repair ticket not found', statusCode: 404 } }) if (!ticket) return reply.status(404).send({ error: { message: 'Repair ticket not found', statusCode: 404 } })
return reply.send(ticket) return reply.send(ticket)
}) })
app.delete('/repair-tickets/:id', { preHandler: [app.authenticate, app.requirePermission('repairs.admin')] }, async (request, reply) => { app.delete('/repair-tickets/:id', { preHandler: [app.authenticate, app.requirePermission('repairs.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string } const { id } = request.params as { id: string }
const ticket = await RepairTicketService.delete(app.db, request.companyId, id) const ticket = await RepairTicketService.delete(app.db, id)
if (!ticket) return reply.status(404).send({ error: { message: 'Repair ticket not found', statusCode: 404 } }) if (!ticket) return reply.status(404).send({ error: { message: 'Repair ticket not found', statusCode: 404 } })
return reply.send(ticket) return reply.send(ticket)
}) })
@@ -109,14 +109,14 @@ export const repairRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
} }
const item = await RepairLineItemService.update(app.db, request.companyId, id, parsed.data) const item = await RepairLineItemService.update(app.db, id, parsed.data)
if (!item) return reply.status(404).send({ error: { message: 'Line item not found', statusCode: 404 } }) if (!item) return reply.status(404).send({ error: { message: 'Line item not found', statusCode: 404 } })
return reply.send(item) return reply.send(item)
}) })
app.delete('/repair-line-items/:id', { preHandler: [app.authenticate, app.requirePermission('repairs.admin')] }, async (request, reply) => { app.delete('/repair-line-items/:id', { preHandler: [app.authenticate, app.requirePermission('repairs.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string } const { id } = request.params as { id: string }
const item = await RepairLineItemService.delete(app.db, request.companyId, id) const item = await RepairLineItemService.delete(app.db, id)
if (!item) return reply.status(404).send({ error: { message: 'Line item not found', statusCode: 404 } }) if (!item) return reply.status(404).send({ error: { message: 'Line item not found', statusCode: 404 } })
return reply.send(item) return reply.send(item)
}) })
@@ -128,19 +128,19 @@ export const repairRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
} }
const batch = await RepairBatchService.create(app.db, request.companyId, parsed.data) const batch = await RepairBatchService.create(app.db, parsed.data)
return reply.status(201).send(batch) return reply.status(201).send(batch)
}) })
app.get('/repair-batches', { preHandler: [app.authenticate, app.requirePermission('repairs.view')] }, async (request, reply) => { app.get('/repair-batches', { preHandler: [app.authenticate, app.requirePermission('repairs.view')] }, async (request, reply) => {
const params = PaginationSchema.parse(request.query) const params = PaginationSchema.parse(request.query)
const result = await RepairBatchService.list(app.db, request.companyId, params) const result = await RepairBatchService.list(app.db, params)
return reply.send(result) return reply.send(result)
}) })
app.get('/repair-batches/:id', { preHandler: [app.authenticate, app.requirePermission('repairs.view')] }, async (request, reply) => { app.get('/repair-batches/:id', { preHandler: [app.authenticate, app.requirePermission('repairs.view')] }, async (request, reply) => {
const { id } = request.params as { id: string } const { id } = request.params as { id: string }
const batch = await RepairBatchService.getById(app.db, request.companyId, id) const batch = await RepairBatchService.getById(app.db, id)
if (!batch) return reply.status(404).send({ error: { message: 'Repair batch not found', statusCode: 404 } }) if (!batch) return reply.status(404).send({ error: { message: 'Repair batch not found', statusCode: 404 } })
return reply.send(batch) return reply.send(batch)
}) })
@@ -151,7 +151,7 @@ export const repairRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
} }
const batch = await RepairBatchService.update(app.db, request.companyId, id, parsed.data) const batch = await RepairBatchService.update(app.db, id, parsed.data)
if (!batch) return reply.status(404).send({ error: { message: 'Repair batch not found', statusCode: 404 } }) if (!batch) return reply.status(404).send({ error: { message: 'Repair batch not found', statusCode: 404 } })
return reply.send(batch) return reply.send(batch)
}) })
@@ -162,21 +162,21 @@ export const repairRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
} }
const batch = await RepairBatchService.updateStatus(app.db, request.companyId, id, parsed.data.status) const batch = await RepairBatchService.updateStatus(app.db, id, parsed.data.status)
if (!batch) return reply.status(404).send({ error: { message: 'Repair batch not found', statusCode: 404 } }) if (!batch) return reply.status(404).send({ error: { message: 'Repair batch not found', statusCode: 404 } })
return reply.send(batch) return reply.send(batch)
}) })
app.post('/repair-batches/:id/approve', { preHandler: [app.authenticate, app.requirePermission('repairs.admin')] }, async (request, reply) => { app.post('/repair-batches/:id/approve', { preHandler: [app.authenticate, app.requirePermission('repairs.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string } const { id } = request.params as { id: string }
const batch = await RepairBatchService.approve(app.db, request.companyId, id, request.user.id) const batch = await RepairBatchService.approve(app.db, id, request.user.id)
if (!batch) return reply.status(404).send({ error: { message: 'Repair batch not found', statusCode: 404 } }) if (!batch) return reply.status(404).send({ error: { message: 'Repair batch not found', statusCode: 404 } })
return reply.send(batch) return reply.send(batch)
}) })
app.post('/repair-batches/:id/reject', { preHandler: [app.authenticate, app.requirePermission('repairs.admin')] }, async (request, reply) => { app.post('/repair-batches/:id/reject', { preHandler: [app.authenticate, app.requirePermission('repairs.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string } const { id } = request.params as { id: string }
const batch = await RepairBatchService.reject(app.db, request.companyId, id) const batch = await RepairBatchService.reject(app.db, id)
if (!batch) return reply.status(404).send({ error: { message: 'Repair batch not found', statusCode: 404 } }) if (!batch) return reply.status(404).send({ error: { message: 'Repair batch not found', statusCode: 404 } })
return reply.send(batch) return reply.send(batch)
}) })
@@ -184,7 +184,7 @@ export const repairRoutes: FastifyPluginAsync = async (app) => {
app.get('/repair-batches/:batchId/tickets', { preHandler: [app.authenticate, app.requirePermission('repairs.view')] }, async (request, reply) => { app.get('/repair-batches/:batchId/tickets', { preHandler: [app.authenticate, app.requirePermission('repairs.view')] }, async (request, reply) => {
const { batchId } = request.params as { batchId: string } const { batchId } = request.params as { batchId: string }
const params = PaginationSchema.parse(request.query) const params = PaginationSchema.parse(request.query)
const result = await RepairTicketService.listByBatch(app.db, request.companyId, batchId, params) const result = await RepairTicketService.listByBatch(app.db, batchId, params)
return reply.send(result) return reply.send(result)
}) })
@@ -196,7 +196,7 @@ export const repairRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
} }
const ticket = await RepairTicketService.getById(app.db, request.companyId, ticketId) const ticket = await RepairTicketService.getById(app.db, ticketId)
if (!ticket) return reply.status(404).send({ error: { message: 'Repair ticket not found', statusCode: 404 } }) if (!ticket) return reply.status(404).send({ error: { message: 'Repair ticket not found', statusCode: 404 } })
// Look up author name from users table // Look up author name from users table
@@ -217,7 +217,7 @@ export const repairRoutes: FastifyPluginAsync = async (app) => {
app.delete('/repair-notes/:id', { preHandler: [app.authenticate, app.requirePermission('repairs.admin')] }, async (request, reply) => { app.delete('/repair-notes/:id', { preHandler: [app.authenticate, app.requirePermission('repairs.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string } const { id } = request.params as { id: string }
const note = await RepairNoteService.delete(app.db, request.companyId, id) const note = await RepairNoteService.delete(app.db, id)
if (!note) return reply.status(404).send({ error: { message: 'Note not found', statusCode: 404 } }) if (!note) return reply.status(404).send({ error: { message: 'Note not found', statusCode: 404 } })
return reply.send(note) return reply.send(note)
}) })
@@ -229,13 +229,13 @@ export const repairRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
} }
const template = await RepairServiceTemplateService.create(app.db, request.companyId, parsed.data) const template = await RepairServiceTemplateService.create(app.db, parsed.data)
return reply.status(201).send(template) return reply.status(201).send(template)
}) })
app.get('/repair-service-templates', { preHandler: [app.authenticate, app.requirePermission('repairs.view')] }, async (request, reply) => { app.get('/repair-service-templates', { preHandler: [app.authenticate, app.requirePermission('repairs.view')] }, async (request, reply) => {
const params = PaginationSchema.parse(request.query) const params = PaginationSchema.parse(request.query)
const result = await RepairServiceTemplateService.list(app.db, request.companyId, params) const result = await RepairServiceTemplateService.list(app.db, params)
return reply.send(result) return reply.send(result)
}) })
@@ -245,14 +245,14 @@ export const repairRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
} }
const template = await RepairServiceTemplateService.update(app.db, request.companyId, id, parsed.data) const template = await RepairServiceTemplateService.update(app.db, id, parsed.data)
if (!template) return reply.status(404).send({ error: { message: 'Template not found', statusCode: 404 } }) if (!template) return reply.status(404).send({ error: { message: 'Template not found', statusCode: 404 } })
return reply.send(template) return reply.send(template)
}) })
app.delete('/repair-service-templates/:id', { preHandler: [app.authenticate, app.requirePermission('repairs.admin')] }, async (request, reply) => { app.delete('/repair-service-templates/:id', { preHandler: [app.authenticate, app.requirePermission('repairs.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string } const { id } = request.params as { id: string }
const template = await RepairServiceTemplateService.delete(app.db, request.companyId, id) const template = await RepairServiceTemplateService.delete(app.db, id)
if (!template) return reply.status(404).send({ error: { message: 'Template not found', statusCode: 404 } }) if (!template) return reply.status(404).send({ error: { message: 'Template not found', statusCode: 404 } })
return reply.send(template) return reply.send(template)
}) })

View File

@@ -33,15 +33,13 @@ async function generateUniqueNumber(
db: PostgresJsDatabase<any>, db: PostgresJsDatabase<any>,
table: typeof accounts | typeof members, table: typeof accounts | typeof members,
column: typeof accounts.accountNumber | typeof members.memberNumber, column: typeof accounts.accountNumber | typeof members.memberNumber,
companyId: string,
companyIdColumn: Column,
): Promise<string> { ): Promise<string> {
for (let attempt = 0; attempt < 10; attempt++) { for (let attempt = 0; attempt < 10; attempt++) {
const num = String(Math.floor(100000 + Math.random() * 900000)) const num = String(Math.floor(100000 + Math.random() * 900000))
const [existing] = await db const [existing] = await db
.select({ id: table.id }) .select({ id: table.id })
.from(table) .from(table)
.where(and(eq(companyIdColumn, companyId), eq(column, num))) .where(eq(column, num))
.limit(1) .limit(1)
if (!existing) return num if (!existing) return num
} }
@@ -58,13 +56,12 @@ function normalizeAddress(address?: { street?: string; city?: string; state?: st
} }
export const AccountService = { export const AccountService = {
async create(db: PostgresJsDatabase<any>, companyId: string, input: AccountCreateInput) { async create(db: PostgresJsDatabase<any>, input: AccountCreateInput) {
const accountNumber = await generateUniqueNumber(db, accounts, accounts.accountNumber, companyId, accounts.companyId) const accountNumber = await generateUniqueNumber(db, accounts, accounts.accountNumber)
const [account] = await db const [account] = await db
.insert(accounts) .insert(accounts)
.values({ .values({
companyId,
accountNumber, accountNumber,
name: input.name, name: input.name,
email: input.email, email: input.email,
@@ -78,38 +75,38 @@ export const AccountService = {
return account return account
}, },
async getById(db: PostgresJsDatabase<any>, companyId: string, id: string) { async getById(db: PostgresJsDatabase<any>, id: string) {
const [account] = await db const [account] = await db
.select() .select()
.from(accounts) .from(accounts)
.where(and(eq(accounts.id, id), eq(accounts.companyId, companyId))) .where(eq(accounts.id, id))
.limit(1) .limit(1)
return account ?? null return account ?? null
}, },
async update(db: PostgresJsDatabase<any>, companyId: string, id: string, input: AccountUpdateInput) { async update(db: PostgresJsDatabase<any>, id: string, input: AccountUpdateInput) {
const [account] = await db const [account] = await db
.update(accounts) .update(accounts)
.set({ ...input, updatedAt: new Date() }) .set({ ...input, updatedAt: new Date() })
.where(and(eq(accounts.id, id), eq(accounts.companyId, companyId))) .where(eq(accounts.id, id))
.returning() .returning()
return account ?? null return account ?? null
}, },
async softDelete(db: PostgresJsDatabase<any>, companyId: string, id: string) { async softDelete(db: PostgresJsDatabase<any>, id: string) {
const [account] = await db const [account] = await db
.update(accounts) .update(accounts)
.set({ isActive: false, updatedAt: new Date() }) .set({ isActive: false, updatedAt: new Date() })
.where(and(eq(accounts.id, id), eq(accounts.companyId, companyId))) .where(eq(accounts.id, id))
.returning() .returning()
return account ?? null return account ?? null
}, },
async list(db: PostgresJsDatabase<any>, companyId: string, params: PaginationInput) { async list(db: PostgresJsDatabase<any>, params: PaginationInput) {
const baseWhere = and(eq(accounts.companyId, companyId), eq(accounts.isActive, true)) const baseWhere = eq(accounts.isActive, true)
const accountSearch = params.q const accountSearch = params.q
? buildSearchCondition(params.q, [accounts.name, accounts.email, accounts.phone, accounts.accountNumber]) ? buildSearchCondition(params.q, [accounts.name, accounts.email, accounts.phone, accounts.accountNumber])
@@ -156,7 +153,6 @@ export const AccountService = {
export const MemberService = { export const MemberService = {
async create( async create(
db: PostgresJsDatabase<any>, db: PostgresJsDatabase<any>,
companyId: string,
input: { input: {
accountId: string accountId: string
firstName: string firstName: string
@@ -171,7 +167,7 @@ export const MemberService = {
) { ) {
// isMinor: explicit flag wins, else derive from DOB, else false // isMinor: explicit flag wins, else derive from DOB, else false
const minor = input.isMinor ?? (input.dateOfBirth ? isMinor(input.dateOfBirth) : false) const minor = input.isMinor ?? (input.dateOfBirth ? isMinor(input.dateOfBirth) : false)
const memberNumber = await generateUniqueNumber(db, members, members.memberNumber, companyId, members.companyId) const memberNumber = await generateUniqueNumber(db, members, members.memberNumber)
// Inherit email, phone, address from account if not provided // Inherit email, phone, address from account if not provided
const [account] = await db const [account] = await db
@@ -187,7 +183,6 @@ export const MemberService = {
const [member] = await db const [member] = await db
.insert(members) .insert(members)
.values({ .values({
companyId,
memberNumber, memberNumber,
accountId: input.accountId, accountId: input.accountId,
firstName: input.firstName, firstName: input.firstName,
@@ -210,25 +205,21 @@ export const MemberService = {
return member return member
}, },
async getById(db: PostgresJsDatabase<any>, companyId: string, id: string) { async getById(db: PostgresJsDatabase<any>, id: string) {
const [member] = await db const [member] = await db
.select() .select()
.from(members) .from(members)
.where(and(eq(members.id, id), eq(members.companyId, companyId))) .where(eq(members.id, id))
.limit(1) .limit(1)
return member ?? null return member ?? null
}, },
async list(db: PostgresJsDatabase<any>, companyId: string, params: PaginationInput) { async list(db: PostgresJsDatabase<any>, params: PaginationInput) {
const baseWhere = eq(members.companyId, companyId)
const searchCondition = params.q const searchCondition = params.q
? buildSearchCondition(params.q, [members.firstName, members.lastName, members.email, members.phone]) ? buildSearchCondition(params.q, [members.firstName, members.lastName, members.email, members.phone])
: undefined : undefined
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
const sortableColumns: Record<string, Column> = { const sortableColumns: Record<string, Column> = {
first_name: members.firstName, first_name: members.firstName,
last_name: members.lastName, last_name: members.lastName,
@@ -239,7 +230,6 @@ export const MemberService = {
let query = db.select({ let query = db.select({
id: members.id, id: members.id,
accountId: members.accountId, accountId: members.accountId,
companyId: members.companyId,
firstName: members.firstName, firstName: members.firstName,
lastName: members.lastName, lastName: members.lastName,
dateOfBirth: members.dateOfBirth, dateOfBirth: members.dateOfBirth,
@@ -254,14 +244,14 @@ export const MemberService = {
}) })
.from(members) .from(members)
.leftJoin(accounts, eq(members.accountId, accounts.id)) .leftJoin(accounts, eq(members.accountId, accounts.id))
.where(where) .where(searchCondition)
.$dynamic() .$dynamic()
query = withSort(query, params.sort, params.order, sortableColumns, members.lastName) query = withSort(query, params.sort, params.order, sortableColumns, members.lastName)
query = withPagination(query, params.page, params.limit) query = withPagination(query, params.page, params.limit)
const [data, [{ total }]] = await Promise.all([ const [data, [{ total }]] = await Promise.all([
query, query,
db.select({ total: count() }).from(members).where(where), db.select({ total: count() }).from(members).where(searchCondition),
]) ])
return paginatedResponse(data, total, params.page, params.limit) return paginatedResponse(data, total, params.page, params.limit)
@@ -269,11 +259,10 @@ export const MemberService = {
async listByAccount( async listByAccount(
db: PostgresJsDatabase<any>, db: PostgresJsDatabase<any>,
companyId: string,
accountId: string, accountId: string,
params: PaginationInput, params: PaginationInput,
) { ) {
const where = and(eq(members.companyId, companyId), eq(members.accountId, accountId)) const where = eq(members.accountId, accountId)
const sortableColumns: Record<string, Column> = { const sortableColumns: Record<string, Column> = {
first_name: members.firstName, first_name: members.firstName,
@@ -295,7 +284,6 @@ export const MemberService = {
async update( async update(
db: PostgresJsDatabase<any>, db: PostgresJsDatabase<any>,
companyId: string,
id: string, id: string,
input: { input: {
firstName?: string firstName?: string
@@ -319,27 +307,27 @@ export const MemberService = {
const [member] = await db const [member] = await db
.update(members) .update(members)
.set(updates) .set(updates)
.where(and(eq(members.id, id), eq(members.companyId, companyId))) .where(eq(members.id, id))
.returning() .returning()
return member ?? null return member ?? null
}, },
async move(db: PostgresJsDatabase<any>, companyId: string, memberId: string, targetAccountId: string) { async move(db: PostgresJsDatabase<any>, memberId: string, targetAccountId: string) {
const member = await this.getById(db, companyId, memberId) const member = await this.getById(db, memberId)
if (!member) return null if (!member) return null
const [updated] = await db const [updated] = await db
.update(members) .update(members)
.set({ accountId: targetAccountId, updatedAt: new Date() }) .set({ accountId: targetAccountId, updatedAt: new Date() })
.where(and(eq(members.id, memberId), eq(members.companyId, companyId))) .where(eq(members.id, memberId))
.returning() .returning()
// If target account has no primary, set this member // If target account has no primary, set this member
const [targetAccount] = await db const [targetAccount] = await db
.select() .select()
.from(accounts) .from(accounts)
.where(and(eq(accounts.id, targetAccountId), eq(accounts.companyId, companyId))) .where(eq(accounts.id, targetAccountId))
.limit(1) .limit(1)
if (targetAccount && !targetAccount.primaryMemberId) { if (targetAccount && !targetAccount.primaryMemberId) {
await db await db
@@ -351,10 +339,10 @@ export const MemberService = {
return updated return updated
}, },
async delete(db: PostgresJsDatabase<any>, companyId: string, id: string) { async delete(db: PostgresJsDatabase<any>, id: string) {
const [member] = await db const [member] = await db
.delete(members) .delete(members)
.where(and(eq(members.id, id), eq(members.companyId, companyId))) .where(eq(members.id, id))
.returning() .returning()
return member ?? null return member ?? null
@@ -362,11 +350,10 @@ export const MemberService = {
} }
export const ProcessorLinkService = { export const ProcessorLinkService = {
async create(db: PostgresJsDatabase<any>, companyId: string, input: ProcessorLinkCreateInput) { async create(db: PostgresJsDatabase<any>, input: ProcessorLinkCreateInput) {
const [link] = await db const [link] = await db
.insert(accountProcessorLinks) .insert(accountProcessorLinks)
.values({ .values({
companyId,
accountId: input.accountId, accountId: input.accountId,
processor: input.processor, processor: input.processor,
processorCustomerId: input.processorCustomerId, processorCustomerId: input.processorCustomerId,
@@ -375,17 +362,17 @@ export const ProcessorLinkService = {
return link return link
}, },
async getById(db: PostgresJsDatabase<any>, companyId: string, id: string) { async getById(db: PostgresJsDatabase<any>, id: string) {
const [link] = await db const [link] = await db
.select() .select()
.from(accountProcessorLinks) .from(accountProcessorLinks)
.where(and(eq(accountProcessorLinks.id, id), eq(accountProcessorLinks.companyId, companyId))) .where(eq(accountProcessorLinks.id, id))
.limit(1) .limit(1)
return link ?? null return link ?? null
}, },
async listByAccount(db: PostgresJsDatabase<any>, companyId: string, accountId: string, params: PaginationInput) { async listByAccount(db: PostgresJsDatabase<any>, accountId: string, params: PaginationInput) {
const baseWhere = and(eq(accountProcessorLinks.companyId, companyId), eq(accountProcessorLinks.accountId, accountId)) const baseWhere = eq(accountProcessorLinks.accountId, accountId)
const searchCondition = params.q const searchCondition = params.q
? buildSearchCondition(params.q, [accountProcessorLinks.processorCustomerId, accountProcessorLinks.processor]) ? buildSearchCondition(params.q, [accountProcessorLinks.processorCustomerId, accountProcessorLinks.processor])
: undefined : undefined
@@ -408,26 +395,26 @@ export const ProcessorLinkService = {
return paginatedResponse(data, total, params.page, params.limit) return paginatedResponse(data, total, params.page, params.limit)
}, },
async update(db: PostgresJsDatabase<any>, companyId: string, id: string, input: ProcessorLinkUpdateInput) { async update(db: PostgresJsDatabase<any>, id: string, input: ProcessorLinkUpdateInput) {
const [link] = await db const [link] = await db
.update(accountProcessorLinks) .update(accountProcessorLinks)
.set(input) .set(input)
.where(and(eq(accountProcessorLinks.id, id), eq(accountProcessorLinks.companyId, companyId))) .where(eq(accountProcessorLinks.id, id))
.returning() .returning()
return link ?? null return link ?? null
}, },
async delete(db: PostgresJsDatabase<any>, companyId: string, id: string) { async delete(db: PostgresJsDatabase<any>, id: string) {
const [link] = await db const [link] = await db
.delete(accountProcessorLinks) .delete(accountProcessorLinks)
.where(and(eq(accountProcessorLinks.id, id), eq(accountProcessorLinks.companyId, companyId))) .where(eq(accountProcessorLinks.id, id))
.returning() .returning()
return link ?? null return link ?? null
}, },
} }
export const PaymentMethodService = { export const PaymentMethodService = {
async create(db: PostgresJsDatabase<any>, companyId: string, input: PaymentMethodCreateInput) { async create(db: PostgresJsDatabase<any>, input: PaymentMethodCreateInput) {
// If this is the default, unset any existing default for this account // If this is the default, unset any existing default for this account
if (input.isDefault) { if (input.isDefault) {
await db await db
@@ -435,7 +422,6 @@ export const PaymentMethodService = {
.set({ isDefault: false }) .set({ isDefault: false })
.where( .where(
and( and(
eq(accountPaymentMethods.companyId, companyId),
eq(accountPaymentMethods.accountId, input.accountId), eq(accountPaymentMethods.accountId, input.accountId),
eq(accountPaymentMethods.isDefault, true), eq(accountPaymentMethods.isDefault, true),
), ),
@@ -445,7 +431,6 @@ export const PaymentMethodService = {
const [method] = await db const [method] = await db
.insert(accountPaymentMethods) .insert(accountPaymentMethods)
.values({ .values({
companyId,
accountId: input.accountId, accountId: input.accountId,
processor: input.processor, processor: input.processor,
processorPaymentMethodId: input.processorPaymentMethodId, processorPaymentMethodId: input.processorPaymentMethodId,
@@ -459,17 +444,17 @@ export const PaymentMethodService = {
return method return method
}, },
async getById(db: PostgresJsDatabase<any>, companyId: string, id: string) { async getById(db: PostgresJsDatabase<any>, id: string) {
const [method] = await db const [method] = await db
.select() .select()
.from(accountPaymentMethods) .from(accountPaymentMethods)
.where(and(eq(accountPaymentMethods.id, id), eq(accountPaymentMethods.companyId, companyId))) .where(eq(accountPaymentMethods.id, id))
.limit(1) .limit(1)
return method ?? null return method ?? null
}, },
async listByAccount(db: PostgresJsDatabase<any>, companyId: string, accountId: string, params: PaginationInput) { async listByAccount(db: PostgresJsDatabase<any>, accountId: string, params: PaginationInput) {
const baseWhere = and(eq(accountPaymentMethods.companyId, companyId), eq(accountPaymentMethods.accountId, accountId)) const baseWhere = eq(accountPaymentMethods.accountId, accountId)
const searchCondition = params.q const searchCondition = params.q
? buildSearchCondition(params.q, [accountPaymentMethods.cardBrand, accountPaymentMethods.lastFour]) ? buildSearchCondition(params.q, [accountPaymentMethods.cardBrand, accountPaymentMethods.lastFour])
: undefined : undefined
@@ -493,17 +478,16 @@ export const PaymentMethodService = {
return paginatedResponse(data, total, params.page, params.limit) return paginatedResponse(data, total, params.page, params.limit)
}, },
async update(db: PostgresJsDatabase<any>, companyId: string, id: string, input: PaymentMethodUpdateInput) { async update(db: PostgresJsDatabase<any>, id: string, input: PaymentMethodUpdateInput) {
// If setting as default, unset existing default // If setting as default, unset existing default
if (input.isDefault) { if (input.isDefault) {
const existing = await this.getById(db, companyId, id) const existing = await this.getById(db, id)
if (existing) { if (existing) {
await db await db
.update(accountPaymentMethods) .update(accountPaymentMethods)
.set({ isDefault: false }) .set({ isDefault: false })
.where( .where(
and( and(
eq(accountPaymentMethods.companyId, companyId),
eq(accountPaymentMethods.accountId, existing.accountId), eq(accountPaymentMethods.accountId, existing.accountId),
eq(accountPaymentMethods.isDefault, true), eq(accountPaymentMethods.isDefault, true),
), ),
@@ -514,26 +498,25 @@ export const PaymentMethodService = {
const [method] = await db const [method] = await db
.update(accountPaymentMethods) .update(accountPaymentMethods)
.set(input) .set(input)
.where(and(eq(accountPaymentMethods.id, id), eq(accountPaymentMethods.companyId, companyId))) .where(eq(accountPaymentMethods.id, id))
.returning() .returning()
return method ?? null return method ?? null
}, },
async delete(db: PostgresJsDatabase<any>, companyId: string, id: string) { async delete(db: PostgresJsDatabase<any>, id: string) {
const [method] = await db const [method] = await db
.delete(accountPaymentMethods) .delete(accountPaymentMethods)
.where(and(eq(accountPaymentMethods.id, id), eq(accountPaymentMethods.companyId, companyId))) .where(eq(accountPaymentMethods.id, id))
.returning() .returning()
return method ?? null return method ?? null
}, },
} }
export const TaxExemptionService = { export const TaxExemptionService = {
async create(db: PostgresJsDatabase<any>, companyId: string, input: TaxExemptionCreateInput) { async create(db: PostgresJsDatabase<any>, input: TaxExemptionCreateInput) {
const [exemption] = await db const [exemption] = await db
.insert(taxExemptions) .insert(taxExemptions)
.values({ .values({
companyId,
accountId: input.accountId, accountId: input.accountId,
certificateNumber: input.certificateNumber, certificateNumber: input.certificateNumber,
certificateType: input.certificateType, certificateType: input.certificateType,
@@ -546,17 +529,17 @@ export const TaxExemptionService = {
return exemption return exemption
}, },
async getById(db: PostgresJsDatabase<any>, companyId: string, id: string) { async getById(db: PostgresJsDatabase<any>, id: string) {
const [exemption] = await db const [exemption] = await db
.select() .select()
.from(taxExemptions) .from(taxExemptions)
.where(and(eq(taxExemptions.id, id), eq(taxExemptions.companyId, companyId))) .where(eq(taxExemptions.id, id))
.limit(1) .limit(1)
return exemption ?? null return exemption ?? null
}, },
async listByAccount(db: PostgresJsDatabase<any>, companyId: string, accountId: string, params: PaginationInput) { async listByAccount(db: PostgresJsDatabase<any>, accountId: string, params: PaginationInput) {
const baseWhere = and(eq(taxExemptions.companyId, companyId), eq(taxExemptions.accountId, accountId)) const baseWhere = eq(taxExemptions.accountId, accountId)
const searchCondition = params.q const searchCondition = params.q
? buildSearchCondition(params.q, [taxExemptions.certificateNumber, taxExemptions.certificateType, taxExemptions.issuingState]) ? buildSearchCondition(params.q, [taxExemptions.certificateNumber, taxExemptions.certificateType, taxExemptions.issuingState])
: undefined : undefined
@@ -581,16 +564,16 @@ export const TaxExemptionService = {
return paginatedResponse(data, total, params.page, params.limit) return paginatedResponse(data, total, params.page, params.limit)
}, },
async update(db: PostgresJsDatabase<any>, companyId: string, id: string, input: TaxExemptionUpdateInput) { async update(db: PostgresJsDatabase<any>, id: string, input: TaxExemptionUpdateInput) {
const [exemption] = await db const [exemption] = await db
.update(taxExemptions) .update(taxExemptions)
.set({ ...input, updatedAt: new Date() }) .set({ ...input, updatedAt: new Date() })
.where(and(eq(taxExemptions.id, id), eq(taxExemptions.companyId, companyId))) .where(eq(taxExemptions.id, id))
.returning() .returning()
return exemption ?? null return exemption ?? null
}, },
async approve(db: PostgresJsDatabase<any>, companyId: string, id: string, approvedBy: string) { async approve(db: PostgresJsDatabase<any>, id: string, approvedBy: string) {
const [exemption] = await db const [exemption] = await db
.update(taxExemptions) .update(taxExemptions)
.set({ .set({
@@ -599,12 +582,12 @@ export const TaxExemptionService = {
approvedAt: new Date(), approvedAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(and(eq(taxExemptions.id, id), eq(taxExemptions.companyId, companyId))) .where(eq(taxExemptions.id, id))
.returning() .returning()
return exemption ?? null return exemption ?? null
}, },
async revoke(db: PostgresJsDatabase<any>, companyId: string, id: string, revokedBy: string, reason: string) { async revoke(db: PostgresJsDatabase<any>, id: string, revokedBy: string, reason: string) {
const [exemption] = await db const [exemption] = await db
.update(taxExemptions) .update(taxExemptions)
.set({ .set({
@@ -614,14 +597,14 @@ export const TaxExemptionService = {
revokedReason: reason, revokedReason: reason,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(and(eq(taxExemptions.id, id), eq(taxExemptions.companyId, companyId))) .where(eq(taxExemptions.id, id))
.returning() .returning()
return exemption ?? null return exemption ?? null
}, },
} }
export const MemberIdentifierService = { export const MemberIdentifierService = {
async create(db: PostgresJsDatabase<any>, companyId: string, input: MemberIdentifierCreateInput) { async create(db: PostgresJsDatabase<any>, input: MemberIdentifierCreateInput) {
// If setting as primary, unset existing primary for this member // If setting as primary, unset existing primary for this member
if (input.isPrimary) { if (input.isPrimary) {
await db await db
@@ -638,7 +621,6 @@ export const MemberIdentifierService = {
const [identifier] = await db const [identifier] = await db
.insert(memberIdentifiers) .insert(memberIdentifiers)
.values({ .values({
companyId,
memberId: input.memberId, memberId: input.memberId,
type: input.type, type: input.type,
label: input.label, label: input.label,
@@ -655,8 +637,8 @@ export const MemberIdentifierService = {
return identifier return identifier
}, },
async listByMember(db: PostgresJsDatabase<any>, companyId: string, memberId: string, params: PaginationInput) { async listByMember(db: PostgresJsDatabase<any>, memberId: string, params: PaginationInput) {
const baseWhere = and(eq(memberIdentifiers.companyId, companyId), eq(memberIdentifiers.memberId, memberId)) const baseWhere = eq(memberIdentifiers.memberId, memberId)
const searchCondition = params.q const searchCondition = params.q
? buildSearchCondition(params.q, [memberIdentifiers.value, memberIdentifiers.label, memberIdentifiers.issuingAuthority]) ? buildSearchCondition(params.q, [memberIdentifiers.value, memberIdentifiers.label, memberIdentifiers.issuingAuthority])
: undefined : undefined
@@ -679,18 +661,18 @@ export const MemberIdentifierService = {
return paginatedResponse(data, total, params.page, params.limit) return paginatedResponse(data, total, params.page, params.limit)
}, },
async getById(db: PostgresJsDatabase<any>, companyId: string, id: string) { async getById(db: PostgresJsDatabase<any>, id: string) {
const [identifier] = await db const [identifier] = await db
.select() .select()
.from(memberIdentifiers) .from(memberIdentifiers)
.where(and(eq(memberIdentifiers.id, id), eq(memberIdentifiers.companyId, companyId))) .where(eq(memberIdentifiers.id, id))
.limit(1) .limit(1)
return identifier ?? null return identifier ?? null
}, },
async update(db: PostgresJsDatabase<any>, companyId: string, id: string, input: MemberIdentifierUpdateInput) { async update(db: PostgresJsDatabase<any>, id: string, input: MemberIdentifierUpdateInput) {
if (input.isPrimary) { if (input.isPrimary) {
const existing = await this.getById(db, companyId, id) const existing = await this.getById(db, id)
if (existing) { if (existing) {
await db await db
.update(memberIdentifiers) .update(memberIdentifiers)
@@ -707,15 +689,15 @@ export const MemberIdentifierService = {
const [identifier] = await db const [identifier] = await db
.update(memberIdentifiers) .update(memberIdentifiers)
.set({ ...input, updatedAt: new Date() }) .set({ ...input, updatedAt: new Date() })
.where(and(eq(memberIdentifiers.id, id), eq(memberIdentifiers.companyId, companyId))) .where(eq(memberIdentifiers.id, id))
.returning() .returning()
return identifier ?? null return identifier ?? null
}, },
async delete(db: PostgresJsDatabase<any>, companyId: string, id: string) { async delete(db: PostgresJsDatabase<any>, id: string) {
const [identifier] = await db const [identifier] = await db
.delete(memberIdentifiers) .delete(memberIdentifiers)
.where(and(eq(memberIdentifiers.id, id), eq(memberIdentifiers.companyId, companyId))) .where(eq(memberIdentifiers.id, id))
.returning() .returning()
return identifier ?? null return identifier ?? null
}, },

View File

@@ -26,7 +26,6 @@ export const FileService = {
async upload( async upload(
db: PostgresJsDatabase<any>, db: PostgresJsDatabase<any>,
storage: StorageProvider, storage: StorageProvider,
companyId: string,
input: { input: {
data: Buffer data: Buffer
filename: string filename: string
@@ -54,7 +53,6 @@ export const FileService = {
.from(files) .from(files)
.where( .where(
and( and(
eq(files.companyId, companyId),
eq(files.entityType, input.entityType), eq(files.entityType, input.entityType),
eq(files.entityId, input.entityId), eq(files.entityId, input.entityId),
), ),
@@ -66,7 +64,7 @@ export const FileService = {
// Generate path // Generate path
const fileId = randomUUID() const fileId = randomUUID()
const ext = getExtension(input.contentType) const ext = getExtension(input.contentType)
const path = `${companyId}/${input.entityType}/${input.entityId}/${input.category}-${fileId}.${ext}` const path = `${input.entityType}/${input.entityId}/${input.category}-${fileId}.${ext}`
// Write to storage // Write to storage
await storage.put(path, input.data, input.contentType) await storage.put(path, input.data, input.contentType)
@@ -76,7 +74,6 @@ export const FileService = {
.insert(files) .insert(files)
.values({ .values({
id: fileId, id: fileId,
companyId,
path, path,
filename: input.filename, filename: input.filename,
contentType: input.contentType, contentType: input.contentType,
@@ -91,18 +88,17 @@ export const FileService = {
return file return file
}, },
async getById(db: PostgresJsDatabase<any>, companyId: string, id: string) { async getById(db: PostgresJsDatabase<any>, id: string) {
const [file] = await db const [file] = await db
.select() .select()
.from(files) .from(files)
.where(and(eq(files.id, id), eq(files.companyId, companyId))) .where(eq(files.id, id))
.limit(1) .limit(1)
return file ?? null return file ?? null
}, },
async listByEntity( async listByEntity(
db: PostgresJsDatabase<any>, db: PostgresJsDatabase<any>,
companyId: string,
entityType: string, entityType: string,
entityId: string, entityId: string,
) { ) {
@@ -111,7 +107,6 @@ export const FileService = {
.from(files) .from(files)
.where( .where(
and( and(
eq(files.companyId, companyId),
eq(files.entityType, entityType), eq(files.entityType, entityType),
eq(files.entityId, entityId), eq(files.entityId, entityId),
), ),
@@ -122,17 +117,16 @@ export const FileService = {
async delete( async delete(
db: PostgresJsDatabase<any>, db: PostgresJsDatabase<any>,
storage: StorageProvider, storage: StorageProvider,
companyId: string,
id: string, id: string,
) { ) {
const file = await this.getById(db, companyId, id) const file = await this.getById(db, id)
if (!file) return null if (!file) return null
await storage.delete(file.path) await storage.delete(file.path)
const [deleted] = await db const [deleted] = await db
.delete(files) .delete(files)
.where(and(eq(files.id, id), eq(files.companyId, companyId))) .where(eq(files.id, id))
.returning() .returning()
return deleted ?? null return deleted ?? null

View File

@@ -16,25 +16,25 @@ import {
} from '../utils/pagination.js' } from '../utils/pagination.js'
export const CategoryService = { export const CategoryService = {
async create(db: PostgresJsDatabase<any>, companyId: string, input: CategoryCreateInput) { async create(db: PostgresJsDatabase<any>, input: CategoryCreateInput) {
const [category] = await db const [category] = await db
.insert(categories) .insert(categories)
.values({ companyId, ...input }) .values({ ...input })
.returning() .returning()
return category return category
}, },
async getById(db: PostgresJsDatabase<any>, companyId: string, id: string) { async getById(db: PostgresJsDatabase<any>, id: string) {
const [category] = await db const [category] = await db
.select() .select()
.from(categories) .from(categories)
.where(and(eq(categories.id, id), eq(categories.companyId, companyId))) .where(eq(categories.id, id))
.limit(1) .limit(1)
return category ?? null return category ?? null
}, },
async list(db: PostgresJsDatabase<any>, companyId: string, params: PaginationInput) { async list(db: PostgresJsDatabase<any>, params: PaginationInput) {
const baseWhere = and(eq(categories.companyId, companyId), eq(categories.isActive, true)) const baseWhere = eq(categories.isActive, true)
const searchCondition = params.q const searchCondition = params.q
? buildSearchCondition(params.q, [categories.name]) ? buildSearchCondition(params.q, [categories.name])
@@ -60,45 +60,45 @@ export const CategoryService = {
return paginatedResponse(data, total, params.page, params.limit) return paginatedResponse(data, total, params.page, params.limit)
}, },
async update(db: PostgresJsDatabase<any>, companyId: string, id: string, input: CategoryUpdateInput) { async update(db: PostgresJsDatabase<any>, id: string, input: CategoryUpdateInput) {
const [category] = await db const [category] = await db
.update(categories) .update(categories)
.set({ ...input, updatedAt: new Date() }) .set({ ...input, updatedAt: new Date() })
.where(and(eq(categories.id, id), eq(categories.companyId, companyId))) .where(eq(categories.id, id))
.returning() .returning()
return category ?? null return category ?? null
}, },
async softDelete(db: PostgresJsDatabase<any>, companyId: string, id: string) { async softDelete(db: PostgresJsDatabase<any>, id: string) {
const [category] = await db const [category] = await db
.update(categories) .update(categories)
.set({ isActive: false, updatedAt: new Date() }) .set({ isActive: false, updatedAt: new Date() })
.where(and(eq(categories.id, id), eq(categories.companyId, companyId))) .where(eq(categories.id, id))
.returning() .returning()
return category ?? null return category ?? null
}, },
} }
export const SupplierService = { export const SupplierService = {
async create(db: PostgresJsDatabase<any>, companyId: string, input: SupplierCreateInput) { async create(db: PostgresJsDatabase<any>, input: SupplierCreateInput) {
const [supplier] = await db const [supplier] = await db
.insert(suppliers) .insert(suppliers)
.values({ companyId, ...input }) .values({ ...input })
.returning() .returning()
return supplier return supplier
}, },
async getById(db: PostgresJsDatabase<any>, companyId: string, id: string) { async getById(db: PostgresJsDatabase<any>, id: string) {
const [supplier] = await db const [supplier] = await db
.select() .select()
.from(suppliers) .from(suppliers)
.where(and(eq(suppliers.id, id), eq(suppliers.companyId, companyId))) .where(eq(suppliers.id, id))
.limit(1) .limit(1)
return supplier ?? null return supplier ?? null
}, },
async list(db: PostgresJsDatabase<any>, companyId: string, params: PaginationInput) { async list(db: PostgresJsDatabase<any>, params: PaginationInput) {
const baseWhere = and(eq(suppliers.companyId, companyId), eq(suppliers.isActive, true)) const baseWhere = eq(suppliers.isActive, true)
const searchCondition = params.q const searchCondition = params.q
? buildSearchCondition(params.q, [suppliers.name, suppliers.contactName, suppliers.email]) ? buildSearchCondition(params.q, [suppliers.name, suppliers.contactName, suppliers.email])
@@ -123,20 +123,20 @@ export const SupplierService = {
return paginatedResponse(data, total, params.page, params.limit) return paginatedResponse(data, total, params.page, params.limit)
}, },
async update(db: PostgresJsDatabase<any>, companyId: string, id: string, input: SupplierUpdateInput) { async update(db: PostgresJsDatabase<any>, id: string, input: SupplierUpdateInput) {
const [supplier] = await db const [supplier] = await db
.update(suppliers) .update(suppliers)
.set({ ...input, updatedAt: new Date() }) .set({ ...input, updatedAt: new Date() })
.where(and(eq(suppliers.id, id), eq(suppliers.companyId, companyId))) .where(eq(suppliers.id, id))
.returning() .returning()
return supplier ?? null return supplier ?? null
}, },
async softDelete(db: PostgresJsDatabase<any>, companyId: string, id: string) { async softDelete(db: PostgresJsDatabase<any>, id: string) {
const [supplier] = await db const [supplier] = await db
.update(suppliers) .update(suppliers)
.set({ isActive: false, updatedAt: new Date() }) .set({ isActive: false, updatedAt: new Date() })
.where(and(eq(suppliers.id, id), eq(suppliers.companyId, companyId))) .where(eq(suppliers.id, id))
.returning() .returning()
return supplier ?? null return supplier ?? null
}, },

View File

@@ -1,4 +1,4 @@
import { eq, and } from 'drizzle-orm' import { eq } from 'drizzle-orm'
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js' import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import { ForbiddenError } from '../lib/errors.js' import { ForbiddenError } from '../lib/errors.js'
import { import {
@@ -14,46 +14,44 @@ function createLookupService(
systemSeeds: ReadonlyArray<{ slug: string; name: string; description: string; sortOrder: number }>, systemSeeds: ReadonlyArray<{ slug: string; name: string; description: string; sortOrder: number }>,
) { ) {
return { return {
async seedForCompany(db: PostgresJsDatabase<any>, companyId: string) { async seedDefaults(db: PostgresJsDatabase<any>) {
const existing = await db const existing = await db
.select() .select()
.from(table) .from(table)
.where(and(eq(table.companyId, companyId), eq(table.isSystem, true))) .where(eq(table.isSystem, true))
.limit(1) .limit(1)
if (existing.length > 0) return // already seeded if (existing.length > 0) return // already seeded
await db.insert(table).values( await db.insert(table).values(
systemSeeds.map((seed) => ({ systemSeeds.map((seed) => ({
companyId,
...seed, ...seed,
isSystem: true, isSystem: true,
})), })),
) )
}, },
async list(db: PostgresJsDatabase<any>, companyId: string) { async list(db: PostgresJsDatabase<any>) {
return db return db
.select() .select()
.from(table) .from(table)
.where(and(eq(table.companyId, companyId), eq(table.isActive, true))) .where(eq(table.isActive, true))
.orderBy(table.sortOrder) .orderBy(table.sortOrder)
}, },
async getBySlug(db: PostgresJsDatabase<any>, companyId: string, slug: string) { async getBySlug(db: PostgresJsDatabase<any>, slug: string) {
const [row] = await db const [row] = await db
.select() .select()
.from(table) .from(table)
.where(and(eq(table.companyId, companyId), eq(table.slug, slug))) .where(eq(table.slug, slug))
.limit(1) .limit(1)
return row ?? null return row ?? null
}, },
async create(db: PostgresJsDatabase<any>, companyId: string, input: LookupCreateInput) { async create(db: PostgresJsDatabase<any>, input: LookupCreateInput) {
const [row] = await db const [row] = await db
.insert(table) .insert(table)
.values({ .values({
companyId,
name: input.name, name: input.name,
slug: input.slug, slug: input.slug,
description: input.description, description: input.description,
@@ -64,12 +62,12 @@ function createLookupService(
return row return row
}, },
async update(db: PostgresJsDatabase<any>, companyId: string, id: string, input: LookupUpdateInput) { async update(db: PostgresJsDatabase<any>, id: string, input: LookupUpdateInput) {
// Prevent modifying system rows' slug or system flag // Prevent modifying system rows' slug or system flag
const existing = await db const existing = await db
.select() .select()
.from(table) .from(table)
.where(and(eq(table.id, id), eq(table.companyId, companyId))) .where(eq(table.id, id))
.limit(1) .limit(1)
if (!existing[0]) return null if (!existing[0]) return null
@@ -80,16 +78,16 @@ function createLookupService(
const [row] = await db const [row] = await db
.update(table) .update(table)
.set(input) .set(input)
.where(and(eq(table.id, id), eq(table.companyId, companyId))) .where(eq(table.id, id))
.returning() .returning()
return row ?? null return row ?? null
}, },
async delete(db: PostgresJsDatabase<any>, companyId: string, id: string) { async delete(db: PostgresJsDatabase<any>, id: string) {
const existing = await db const existing = await db
.select() .select()
.from(table) .from(table)
.where(and(eq(table.id, id), eq(table.companyId, companyId))) .where(eq(table.id, id))
.limit(1) .limit(1)
if (!existing[0]) return null if (!existing[0]) return null
@@ -99,13 +97,13 @@ function createLookupService(
const [row] = await db const [row] = await db
.delete(table) .delete(table)
.where(and(eq(table.id, id), eq(table.companyId, companyId))) .where(eq(table.id, id))
.returning() .returning()
return row ?? null return row ?? null
}, },
async validateSlug(db: PostgresJsDatabase<any>, companyId: string, slug: string): Promise<boolean> { async validateSlug(db: PostgresJsDatabase<any>, slug: string): Promise<boolean> {
const row = await this.getBySlug(db, companyId, slug) const row = await this.getBySlug(db, slug)
return row !== null && row.isActive return row !== null && row.isActive
}, },
} }

View File

@@ -18,11 +18,10 @@ import {
import { UnitStatusService, ItemConditionService } from './lookup.service.js' import { UnitStatusService, ItemConditionService } from './lookup.service.js'
export const ProductService = { export const ProductService = {
async create(db: PostgresJsDatabase<any>, companyId: string, input: ProductCreateInput) { async create(db: PostgresJsDatabase<any>, input: ProductCreateInput) {
const [product] = await db const [product] = await db
.insert(products) .insert(products)
.values({ .values({
companyId,
...input, ...input,
price: input.price?.toString(), price: input.price?.toString(),
minPrice: input.minPrice?.toString(), minPrice: input.minPrice?.toString(),
@@ -32,17 +31,17 @@ export const ProductService = {
return product return product
}, },
async getById(db: PostgresJsDatabase<any>, companyId: string, id: string) { async getById(db: PostgresJsDatabase<any>, id: string) {
const [product] = await db const [product] = await db
.select() .select()
.from(products) .from(products)
.where(and(eq(products.id, id), eq(products.companyId, companyId))) .where(eq(products.id, id))
.limit(1) .limit(1)
return product ?? null return product ?? null
}, },
async list(db: PostgresJsDatabase<any>, companyId: string, params: PaginationInput) { async list(db: PostgresJsDatabase<any>, params: PaginationInput) {
const baseWhere = and(eq(products.companyId, companyId), eq(products.isActive, true)) const baseWhere = eq(products.isActive, true)
const searchCondition = params.q const searchCondition = params.q
? buildSearchCondition(params.q, [products.name, products.sku, products.upc, products.brand]) ? buildSearchCondition(params.q, [products.name, products.sku, products.upc, products.brand])
@@ -72,17 +71,15 @@ export const ProductService = {
async update( async update(
db: PostgresJsDatabase<any>, db: PostgresJsDatabase<any>,
companyId: string,
id: string, id: string,
input: ProductUpdateInput, input: ProductUpdateInput,
changedBy?: string, changedBy?: string,
) { ) {
if (input.price !== undefined || input.minPrice !== undefined) { if (input.price !== undefined || input.minPrice !== undefined) {
const existing = await this.getById(db, companyId, id) const existing = await this.getById(db, id)
if (existing) { if (existing) {
await db.insert(priceHistory).values({ await db.insert(priceHistory).values({
productId: id, productId: id,
companyId,
previousPrice: existing.price, previousPrice: existing.price,
newPrice: input.price?.toString() ?? existing.price ?? '0', newPrice: input.price?.toString() ?? existing.price ?? '0',
previousMinPrice: existing.minPrice, previousMinPrice: existing.minPrice,
@@ -101,36 +98,35 @@ export const ProductService = {
const [product] = await db const [product] = await db
.update(products) .update(products)
.set(updates) .set(updates)
.where(and(eq(products.id, id), eq(products.companyId, companyId))) .where(eq(products.id, id))
.returning() .returning()
return product ?? null return product ?? null
}, },
async softDelete(db: PostgresJsDatabase<any>, companyId: string, id: string) { async softDelete(db: PostgresJsDatabase<any>, id: string) {
const [product] = await db const [product] = await db
.update(products) .update(products)
.set({ isActive: false, updatedAt: new Date() }) .set({ isActive: false, updatedAt: new Date() })
.where(and(eq(products.id, id), eq(products.companyId, companyId))) .where(eq(products.id, id))
.returning() .returning()
return product ?? null return product ?? null
}, },
} }
export const InventoryUnitService = { export const InventoryUnitService = {
async create(db: PostgresJsDatabase<any>, companyId: string, input: InventoryUnitCreateInput) { async create(db: PostgresJsDatabase<any>, input: InventoryUnitCreateInput) {
if (input.condition) { if (input.condition) {
const valid = await ItemConditionService.validateSlug(db, companyId, input.condition) const valid = await ItemConditionService.validateSlug(db, input.condition)
if (!valid) throw new ValidationError(`Invalid condition: "${input.condition}"`) if (!valid) throw new ValidationError(`Invalid condition: "${input.condition}"`)
} }
if (input.status) { if (input.status) {
const valid = await UnitStatusService.validateSlug(db, companyId, input.status) const valid = await UnitStatusService.validateSlug(db, input.status)
if (!valid) throw new ValidationError(`Invalid status: "${input.status}"`) if (!valid) throw new ValidationError(`Invalid status: "${input.status}"`)
} }
const [unit] = await db const [unit] = await db
.insert(inventoryUnits) .insert(inventoryUnits)
.values({ .values({
companyId,
productId: input.productId, productId: input.productId,
locationId: input.locationId, locationId: input.locationId,
serialNumber: input.serialNumber, serialNumber: input.serialNumber,
@@ -144,25 +140,21 @@ export const InventoryUnitService = {
return unit return unit
}, },
async getById(db: PostgresJsDatabase<any>, companyId: string, id: string) { async getById(db: PostgresJsDatabase<any>, id: string) {
const [unit] = await db const [unit] = await db
.select() .select()
.from(inventoryUnits) .from(inventoryUnits)
.where(and(eq(inventoryUnits.id, id), eq(inventoryUnits.companyId, companyId))) .where(eq(inventoryUnits.id, id))
.limit(1) .limit(1)
return unit ?? null return unit ?? null
}, },
async listByProduct( async listByProduct(
db: PostgresJsDatabase<any>, db: PostgresJsDatabase<any>,
companyId: string,
productId: string, productId: string,
params: PaginationInput, params: PaginationInput,
) { ) {
const where = and( const where = eq(inventoryUnits.productId, productId)
eq(inventoryUnits.companyId, companyId),
eq(inventoryUnits.productId, productId),
)
const sortableColumns: Record<string, Column> = { const sortableColumns: Record<string, Column> = {
serial_number: inventoryUnits.serialNumber, serial_number: inventoryUnits.serialNumber,
@@ -185,16 +177,15 @@ export const InventoryUnitService = {
async update( async update(
db: PostgresJsDatabase<any>, db: PostgresJsDatabase<any>,
companyId: string,
id: string, id: string,
input: InventoryUnitUpdateInput, input: InventoryUnitUpdateInput,
) { ) {
if (input.condition) { if (input.condition) {
const valid = await ItemConditionService.validateSlug(db, companyId, input.condition) const valid = await ItemConditionService.validateSlug(db, input.condition)
if (!valid) throw new ValidationError(`Invalid condition: "${input.condition}"`) if (!valid) throw new ValidationError(`Invalid condition: "${input.condition}"`)
} }
if (input.status) { if (input.status) {
const valid = await UnitStatusService.validateSlug(db, companyId, input.status) const valid = await UnitStatusService.validateSlug(db, input.status)
if (!valid) throw new ValidationError(`Invalid status: "${input.status}"`) if (!valid) throw new ValidationError(`Invalid status: "${input.status}"`)
} }
@@ -204,7 +195,7 @@ export const InventoryUnitService = {
const [unit] = await db const [unit] = await db
.update(inventoryUnits) .update(inventoryUnits)
.set(updates) .set(updates)
.where(and(eq(inventoryUnits.id, id), eq(inventoryUnits.companyId, companyId))) .where(eq(inventoryUnits.id, id))
.returning() .returning()
return unit ?? null return unit ?? null
}, },

View File

@@ -18,16 +18,16 @@ export const RbacService = {
await db.insert(permissions).values(toInsert) await db.insert(permissions).values(toInsert)
}, },
/** Seed default roles for a company */ /** Seed default roles */
async seedRolesForCompany(db: PostgresJsDatabase<any>, companyId: string) { async seedDefaultRoles(db: PostgresJsDatabase<any>) {
const existingRoles = await db const existingRoles = await db
.select({ slug: roles.slug }) .select({ slug: roles.slug })
.from(roles) .from(roles)
.where(and(eq(roles.companyId, companyId), eq(roles.isSystem, true))) .where(eq(roles.isSystem, true))
if (existingRoles.length > 0) return // already seeded if (existingRoles.length > 0) return // already seeded
// Get all permission records for slug id mapping // Get all permission records for slug -> id mapping
const allPerms = await db.select().from(permissions) const allPerms = await db.select().from(permissions)
const permMap = new Map(allPerms.map((p) => [p.slug, p.id])) const permMap = new Map(allPerms.map((p) => [p.slug, p.id]))
@@ -35,7 +35,6 @@ export const RbacService = {
const [role] = await db const [role] = await db
.insert(roles) .insert(roles)
.values({ .values({
companyId,
name: roleDef.name, name: roleDef.name,
slug: roleDef.slug, slug: roleDef.slug,
description: roleDef.description, description: roleDef.description,
@@ -91,9 +90,9 @@ export const RbacService = {
return db.select().from(permissions).orderBy(permissions.domain, permissions.action) return db.select().from(permissions).orderBy(permissions.domain, permissions.action)
}, },
/** List roles for a company */ /** List roles */
async listRoles(db: PostgresJsDatabase<any>, companyId: string, params: PaginationInput) { async listRoles(db: PostgresJsDatabase<any>, params: PaginationInput) {
const baseWhere = and(eq(roles.companyId, companyId), eq(roles.isActive, true)) const baseWhere = eq(roles.isActive, true)
const searchCondition = params.q const searchCondition = params.q
? buildSearchCondition(params.q, [roles.name, roles.slug]) ? buildSearchCondition(params.q, [roles.name, roles.slug])
@@ -120,11 +119,11 @@ export const RbacService = {
}, },
/** Get role with its permissions */ /** Get role with its permissions */
async getRoleWithPermissions(db: PostgresJsDatabase<any>, companyId: string, roleId: string) { async getRoleWithPermissions(db: PostgresJsDatabase<any>, roleId: string) {
const [role] = await db const [role] = await db
.select() .select()
.from(roles) .from(roles)
.where(and(eq(roles.id, roleId), eq(roles.companyId, companyId))) .where(eq(roles.id, roleId))
.limit(1) .limit(1)
if (!role) return null if (!role) return null
@@ -141,13 +140,11 @@ export const RbacService = {
/** Create a custom role */ /** Create a custom role */
async createRole( async createRole(
db: PostgresJsDatabase<any>, db: PostgresJsDatabase<any>,
companyId: string,
input: { name: string; slug: string; description?: string; permissionSlugs: string[] }, input: { name: string; slug: string; description?: string; permissionSlugs: string[] },
) { ) {
const [role] = await db const [role] = await db
.insert(roles) .insert(roles)
.values({ .values({
companyId,
name: input.name, name: input.name,
slug: input.slug, slug: input.slug,
description: input.description, description: input.description,
@@ -156,7 +153,7 @@ export const RbacService = {
.returning() .returning()
await this.setRolePermissions(db, role.id, input.permissionSlugs) await this.setRolePermissions(db, role.id, input.permissionSlugs)
return this.getRoleWithPermissions(db, companyId, role.id) return this.getRoleWithPermissions(db, role.id)
}, },
/** Update role permissions (replace all) */ /** Update role permissions (replace all) */
@@ -182,7 +179,6 @@ export const RbacService = {
/** Update a role */ /** Update a role */
async updateRole( async updateRole(
db: PostgresJsDatabase<any>, db: PostgresJsDatabase<any>,
companyId: string,
roleId: string, roleId: string,
input: { name?: string; description?: string; permissionSlugs?: string[] }, input: { name?: string; description?: string; permissionSlugs?: string[] },
) { ) {
@@ -194,22 +190,22 @@ export const RbacService = {
...(input.description !== undefined ? { description: input.description } : {}), ...(input.description !== undefined ? { description: input.description } : {}),
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(and(eq(roles.id, roleId), eq(roles.companyId, companyId))) .where(eq(roles.id, roleId))
} }
if (input.permissionSlugs) { if (input.permissionSlugs) {
await this.setRolePermissions(db, roleId, input.permissionSlugs) await this.setRolePermissions(db, roleId, input.permissionSlugs)
} }
return this.getRoleWithPermissions(db, companyId, roleId) return this.getRoleWithPermissions(db, roleId)
}, },
/** Delete a custom role */ /** Delete a custom role */
async deleteRole(db: PostgresJsDatabase<any>, companyId: string, roleId: string) { async deleteRole(db: PostgresJsDatabase<any>, roleId: string) {
const [role] = await db const [role] = await db
.select() .select()
.from(roles) .from(roles)
.where(and(eq(roles.id, roleId), eq(roles.companyId, companyId))) .where(eq(roles.id, roleId))
.limit(1) .limit(1)
if (!role) return null if (!role) return null

View File

@@ -30,15 +30,13 @@ async function generateUniqueNumber(
db: PostgresJsDatabase<any>, db: PostgresJsDatabase<any>,
table: typeof repairTickets | typeof repairBatches, table: typeof repairTickets | typeof repairBatches,
column: typeof repairTickets.ticketNumber | typeof repairBatches.batchNumber, column: typeof repairTickets.ticketNumber | typeof repairBatches.batchNumber,
companyId: string,
companyIdColumn: Column,
): Promise<string> { ): Promise<string> {
for (let attempt = 0; attempt < 10; attempt++) { for (let attempt = 0; attempt < 10; attempt++) {
const num = String(Math.floor(100000 + Math.random() * 900000)) const num = String(Math.floor(100000 + Math.random() * 900000))
const [existing] = await db const [existing] = await db
.select({ id: table.id }) .select({ id: table.id })
.from(table) .from(table)
.where(and(eq(companyIdColumn, companyId), eq(column, num))) .where(eq(column, num))
.limit(1) .limit(1)
if (!existing) return num if (!existing) return num
} }
@@ -46,15 +44,14 @@ async function generateUniqueNumber(
} }
export const RepairTicketService = { export const RepairTicketService = {
async create(db: PostgresJsDatabase<any>, companyId: string, input: RepairTicketCreateInput) { async create(db: PostgresJsDatabase<any>, input: RepairTicketCreateInput) {
const ticketNumber = await generateUniqueNumber( const ticketNumber = await generateUniqueNumber(
db, repairTickets, repairTickets.ticketNumber, companyId, repairTickets.companyId, db, repairTickets, repairTickets.ticketNumber,
) )
const [ticket] = await db const [ticket] = await db
.insert(repairTickets) .insert(repairTickets)
.values({ .values({
companyId,
ticketNumber, ticketNumber,
customerName: input.customerName, customerName: input.customerName,
customerPhone: input.customerPhone, customerPhone: input.customerPhone,
@@ -76,16 +73,16 @@ export const RepairTicketService = {
return ticket return ticket
}, },
async getById(db: PostgresJsDatabase<any>, companyId: string, id: string) { async getById(db: PostgresJsDatabase<any>, id: string) {
const [ticket] = await db const [ticket] = await db
.select() .select()
.from(repairTickets) .from(repairTickets)
.where(and(eq(repairTickets.id, id), eq(repairTickets.companyId, companyId))) .where(eq(repairTickets.id, id))
.limit(1) .limit(1)
return ticket ?? null return ticket ?? null
}, },
async list(db: PostgresJsDatabase<any>, companyId: string, params: PaginationInput, filters?: { async list(db: PostgresJsDatabase<any>, params: PaginationInput, filters?: {
status?: string[] status?: string[]
conditionIn?: string[] conditionIn?: string[]
isBatch?: boolean isBatch?: boolean
@@ -97,7 +94,7 @@ export const RepairTicketService = {
completedDateFrom?: string completedDateFrom?: string
completedDateTo?: string completedDateTo?: string
}) { }) {
const conditions: SQL[] = [eq(repairTickets.companyId, companyId)] const conditions: SQL[] = []
if (params.q) { if (params.q) {
const search = buildSearchCondition(params.q, [ const search = buildSearchCondition(params.q, [
@@ -128,7 +125,7 @@ export const RepairTicketService = {
if (filters?.completedDateFrom) conditions.push(gte(repairTickets.completedDate, new Date(filters.completedDateFrom))) if (filters?.completedDateFrom) conditions.push(gte(repairTickets.completedDate, new Date(filters.completedDateFrom)))
if (filters?.completedDateTo) conditions.push(lte(repairTickets.completedDate, new Date(filters.completedDateTo))) if (filters?.completedDateTo) conditions.push(lte(repairTickets.completedDate, new Date(filters.completedDateTo)))
const where = and(...conditions) const where = conditions.length > 0 ? and(...conditions) : undefined
const sortableColumns: Record<string, Column> = { const sortableColumns: Record<string, Column> = {
ticket_number: repairTickets.ticketNumber, ticket_number: repairTickets.ticketNumber,
@@ -151,8 +148,8 @@ export const RepairTicketService = {
return paginatedResponse(data, total, params.page, params.limit) return paginatedResponse(data, total, params.page, params.limit)
}, },
async listByBatch(db: PostgresJsDatabase<any>, companyId: string, batchId: string, params: PaginationInput) { async listByBatch(db: PostgresJsDatabase<any>, batchId: string, params: PaginationInput) {
const baseWhere = and(eq(repairTickets.companyId, companyId), eq(repairTickets.repairBatchId, batchId)) const baseWhere = eq(repairTickets.repairBatchId, batchId)
const searchCondition = params.q const searchCondition = params.q
? buildSearchCondition(params.q, [repairTickets.ticketNumber, repairTickets.customerName, repairTickets.instrumentDescription]) ? buildSearchCondition(params.q, [repairTickets.ticketNumber, repairTickets.customerName, repairTickets.instrumentDescription])
: undefined : undefined
@@ -177,7 +174,7 @@ export const RepairTicketService = {
return paginatedResponse(data, total, params.page, params.limit) return paginatedResponse(data, total, params.page, params.limit)
}, },
async update(db: PostgresJsDatabase<any>, companyId: string, id: string, input: RepairTicketUpdateInput) { async update(db: PostgresJsDatabase<any>, id: string, input: RepairTicketUpdateInput) {
const values: Record<string, unknown> = { ...input, updatedAt: new Date() } const values: Record<string, unknown> = { ...input, updatedAt: new Date() }
if (input.estimatedCost !== undefined) values.estimatedCost = input.estimatedCost.toString() if (input.estimatedCost !== undefined) values.estimatedCost = input.estimatedCost.toString()
if (input.promisedDate !== undefined) values.promisedDate = input.promisedDate ? new Date(input.promisedDate) : null if (input.promisedDate !== undefined) values.promisedDate = input.promisedDate ? new Date(input.promisedDate) : null
@@ -185,12 +182,12 @@ export const RepairTicketService = {
const [ticket] = await db const [ticket] = await db
.update(repairTickets) .update(repairTickets)
.set(values) .set(values)
.where(and(eq(repairTickets.id, id), eq(repairTickets.companyId, companyId))) .where(eq(repairTickets.id, id))
.returning() .returning()
return ticket ?? null return ticket ?? null
}, },
async updateStatus(db: PostgresJsDatabase<any>, companyId: string, id: string, status: string) { async updateStatus(db: PostgresJsDatabase<any>, id: string, status: string) {
const updates: Record<string, unknown> = { status, updatedAt: new Date() } const updates: Record<string, unknown> = { status, updatedAt: new Date() }
if (status === 'ready' || status === 'picked_up' || status === 'delivered') { if (status === 'ready' || status === 'picked_up' || status === 'delivered') {
updates.completedDate = new Date() updates.completedDate = new Date()
@@ -199,17 +196,17 @@ export const RepairTicketService = {
const [ticket] = await db const [ticket] = await db
.update(repairTickets) .update(repairTickets)
.set(updates) .set(updates)
.where(and(eq(repairTickets.id, id), eq(repairTickets.companyId, companyId))) .where(eq(repairTickets.id, id))
.returning() .returning()
return ticket ?? null return ticket ?? null
}, },
async delete(db: PostgresJsDatabase<any>, companyId: string, id: string) { async delete(db: PostgresJsDatabase<any>, id: string) {
// Soft-cancel: set status to cancelled rather than hard delete // Soft-cancel: set status to cancelled rather than hard delete
const [ticket] = await db const [ticket] = await db
.update(repairTickets) .update(repairTickets)
.set({ status: 'cancelled', updatedAt: new Date() }) .set({ status: 'cancelled', updatedAt: new Date() })
.where(and(eq(repairTickets.id, id), eq(repairTickets.companyId, companyId))) .where(eq(repairTickets.id, id))
.returning() .returning()
return ticket ?? null return ticket ?? null
}, },
@@ -254,19 +251,7 @@ export const RepairLineItemService = {
return paginatedResponse(data, total, params.page, params.limit) return paginatedResponse(data, total, params.page, params.limit)
}, },
async verifyOwnership(db: PostgresJsDatabase<any>, companyId: string, lineItemId: string): Promise<boolean> { async update(db: PostgresJsDatabase<any>, id: string, input: RepairLineItemUpdateInput) {
const [item] = await db
.select({ ticketCompanyId: repairTickets.companyId })
.from(repairLineItems)
.innerJoin(repairTickets, eq(repairLineItems.repairTicketId, repairTickets.id))
.where(and(eq(repairLineItems.id, lineItemId), eq(repairTickets.companyId, companyId)))
.limit(1)
return !!item
},
async update(db: PostgresJsDatabase<any>, companyId: string, id: string, input: RepairLineItemUpdateInput) {
if (!(await this.verifyOwnership(db, companyId, id))) return null
const values: Record<string, unknown> = { ...input } const values: Record<string, unknown> = { ...input }
if (input.qty !== undefined) values.qty = input.qty.toString() if (input.qty !== undefined) values.qty = input.qty.toString()
if (input.unitPrice !== undefined) values.unitPrice = input.unitPrice.toString() if (input.unitPrice !== undefined) values.unitPrice = input.unitPrice.toString()
@@ -281,9 +266,7 @@ export const RepairLineItemService = {
return item ?? null return item ?? null
}, },
async delete(db: PostgresJsDatabase<any>, companyId: string, id: string) { async delete(db: PostgresJsDatabase<any>, id: string) {
if (!(await this.verifyOwnership(db, companyId, id))) return null
const [item] = await db const [item] = await db
.delete(repairLineItems) .delete(repairLineItems)
.where(eq(repairLineItems.id, id)) .where(eq(repairLineItems.id, id))
@@ -293,15 +276,14 @@ export const RepairLineItemService = {
} }
export const RepairBatchService = { export const RepairBatchService = {
async create(db: PostgresJsDatabase<any>, companyId: string, input: RepairBatchCreateInput) { async create(db: PostgresJsDatabase<any>, input: RepairBatchCreateInput) {
const batchNumber = await generateUniqueNumber( const batchNumber = await generateUniqueNumber(
db, repairBatches, repairBatches.batchNumber, companyId, repairBatches.companyId, db, repairBatches, repairBatches.batchNumber,
) )
const [batch] = await db const [batch] = await db
.insert(repairBatches) .insert(repairBatches)
.values({ .values({
companyId,
batchNumber, batchNumber,
accountId: input.accountId, accountId: input.accountId,
locationId: input.locationId, locationId: input.locationId,
@@ -317,21 +299,19 @@ export const RepairBatchService = {
return batch return batch
}, },
async getById(db: PostgresJsDatabase<any>, companyId: string, id: string) { async getById(db: PostgresJsDatabase<any>, id: string) {
const [batch] = await db const [batch] = await db
.select() .select()
.from(repairBatches) .from(repairBatches)
.where(and(eq(repairBatches.id, id), eq(repairBatches.companyId, companyId))) .where(eq(repairBatches.id, id))
.limit(1) .limit(1)
return batch ?? null return batch ?? null
}, },
async list(db: PostgresJsDatabase<any>, companyId: string, params: PaginationInput) { async list(db: PostgresJsDatabase<any>, params: PaginationInput) {
const baseWhere = eq(repairBatches.companyId, companyId)
const searchCondition = params.q const searchCondition = params.q
? buildSearchCondition(params.q, [repairBatches.batchNumber, repairBatches.contactName, repairBatches.contactEmail]) ? buildSearchCondition(params.q, [repairBatches.batchNumber, repairBatches.contactName, repairBatches.contactEmail])
: undefined : undefined
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
const sortableColumns: Record<string, Column> = { const sortableColumns: Record<string, Column> = {
batch_number: repairBatches.batchNumber, batch_number: repairBatches.batchNumber,
@@ -340,19 +320,19 @@ export const RepairBatchService = {
created_at: repairBatches.createdAt, created_at: repairBatches.createdAt,
} }
let query = db.select().from(repairBatches).where(where).$dynamic() let query = db.select().from(repairBatches).where(searchCondition).$dynamic()
query = withSort(query, params.sort, params.order, sortableColumns, repairBatches.createdAt) query = withSort(query, params.sort, params.order, sortableColumns, repairBatches.createdAt)
query = withPagination(query, params.page, params.limit) query = withPagination(query, params.page, params.limit)
const [data, [{ total }]] = await Promise.all([ const [data, [{ total }]] = await Promise.all([
query, query,
db.select({ total: count() }).from(repairBatches).where(where), db.select({ total: count() }).from(repairBatches).where(searchCondition),
]) ])
return paginatedResponse(data, total, params.page, params.limit) return paginatedResponse(data, total, params.page, params.limit)
}, },
async update(db: PostgresJsDatabase<any>, companyId: string, id: string, input: RepairBatchUpdateInput) { async update(db: PostgresJsDatabase<any>, id: string, input: RepairBatchUpdateInput) {
const values: Record<string, unknown> = { ...input, updatedAt: new Date() } const values: Record<string, unknown> = { ...input, updatedAt: new Date() }
if (input.pickupDate !== undefined) values.pickupDate = input.pickupDate ? new Date(input.pickupDate) : null if (input.pickupDate !== undefined) values.pickupDate = input.pickupDate ? new Date(input.pickupDate) : null
if (input.dueDate !== undefined) values.dueDate = input.dueDate ? new Date(input.dueDate) : null if (input.dueDate !== undefined) values.dueDate = input.dueDate ? new Date(input.dueDate) : null
@@ -360,12 +340,12 @@ export const RepairBatchService = {
const [batch] = await db const [batch] = await db
.update(repairBatches) .update(repairBatches)
.set(values) .set(values)
.where(and(eq(repairBatches.id, id), eq(repairBatches.companyId, companyId))) .where(eq(repairBatches.id, id))
.returning() .returning()
return batch ?? null return batch ?? null
}, },
async updateStatus(db: PostgresJsDatabase<any>, companyId: string, id: string, status: string) { async updateStatus(db: PostgresJsDatabase<any>, id: string, status: string) {
const updates: Record<string, unknown> = { status, updatedAt: new Date() } const updates: Record<string, unknown> = { status, updatedAt: new Date() }
if (status === 'completed') updates.completedDate = new Date() if (status === 'completed') updates.completedDate = new Date()
if (status === 'delivered') updates.deliveredDate = new Date() if (status === 'delivered') updates.deliveredDate = new Date()
@@ -373,12 +353,12 @@ export const RepairBatchService = {
const [batch] = await db const [batch] = await db
.update(repairBatches) .update(repairBatches)
.set(updates) .set(updates)
.where(and(eq(repairBatches.id, id), eq(repairBatches.companyId, companyId))) .where(eq(repairBatches.id, id))
.returning() .returning()
return batch ?? null return batch ?? null
}, },
async approve(db: PostgresJsDatabase<any>, companyId: string, id: string, approvedBy: string) { async approve(db: PostgresJsDatabase<any>, id: string, approvedBy: string) {
const [batch] = await db const [batch] = await db
.update(repairBatches) .update(repairBatches)
.set({ .set({
@@ -387,30 +367,29 @@ export const RepairBatchService = {
approvedAt: new Date(), approvedAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(and(eq(repairBatches.id, id), eq(repairBatches.companyId, companyId))) .where(eq(repairBatches.id, id))
.returning() .returning()
return batch ?? null return batch ?? null
}, },
async reject(db: PostgresJsDatabase<any>, companyId: string, id: string) { async reject(db: PostgresJsDatabase<any>, id: string) {
const [batch] = await db const [batch] = await db
.update(repairBatches) .update(repairBatches)
.set({ .set({
approvalStatus: 'rejected', approvalStatus: 'rejected',
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(and(eq(repairBatches.id, id), eq(repairBatches.companyId, companyId))) .where(eq(repairBatches.id, id))
.returning() .returning()
return batch ?? null return batch ?? null
}, },
} }
export const RepairServiceTemplateService = { export const RepairServiceTemplateService = {
async create(db: PostgresJsDatabase<any>, companyId: string, input: RepairServiceTemplateCreateInput) { async create(db: PostgresJsDatabase<any>, input: RepairServiceTemplateCreateInput) {
const [template] = await db const [template] = await db
.insert(repairServiceTemplates) .insert(repairServiceTemplates)
.values({ .values({
companyId,
name: input.name, name: input.name,
instrumentType: input.instrumentType, instrumentType: input.instrumentType,
size: input.size, size: input.size,
@@ -424,8 +403,8 @@ export const RepairServiceTemplateService = {
return template return template
}, },
async list(db: PostgresJsDatabase<any>, companyId: string, params: PaginationInput) { async list(db: PostgresJsDatabase<any>, params: PaginationInput) {
const baseWhere = and(eq(repairServiceTemplates.companyId, companyId), eq(repairServiceTemplates.isActive, true)) const baseWhere = eq(repairServiceTemplates.isActive, true)
const searchCondition = params.q const searchCondition = params.q
? buildSearchCondition(params.q, [repairServiceTemplates.name, repairServiceTemplates.instrumentType, repairServiceTemplates.size, repairServiceTemplates.description]) ? buildSearchCondition(params.q, [repairServiceTemplates.name, repairServiceTemplates.instrumentType, repairServiceTemplates.size, repairServiceTemplates.description])
: undefined : undefined
@@ -451,7 +430,7 @@ export const RepairServiceTemplateService = {
return paginatedResponse(data, total, params.page, params.limit) return paginatedResponse(data, total, params.page, params.limit)
}, },
async update(db: PostgresJsDatabase<any>, companyId: string, id: string, input: RepairServiceTemplateUpdateInput) { async update(db: PostgresJsDatabase<any>, id: string, input: RepairServiceTemplateUpdateInput) {
const values: Record<string, unknown> = { ...input, updatedAt: new Date() } const values: Record<string, unknown> = { ...input, updatedAt: new Date() }
if (input.defaultPrice !== undefined) values.defaultPrice = input.defaultPrice.toString() if (input.defaultPrice !== undefined) values.defaultPrice = input.defaultPrice.toString()
if (input.defaultCost !== undefined) values.defaultCost = input.defaultCost.toString() if (input.defaultCost !== undefined) values.defaultCost = input.defaultCost.toString()
@@ -459,16 +438,16 @@ export const RepairServiceTemplateService = {
const [template] = await db const [template] = await db
.update(repairServiceTemplates) .update(repairServiceTemplates)
.set(values) .set(values)
.where(and(eq(repairServiceTemplates.id, id), eq(repairServiceTemplates.companyId, companyId))) .where(eq(repairServiceTemplates.id, id))
.returning() .returning()
return template ?? null return template ?? null
}, },
async delete(db: PostgresJsDatabase<any>, companyId: string, id: string) { async delete(db: PostgresJsDatabase<any>, id: string) {
const [template] = await db const [template] = await db
.update(repairServiceTemplates) .update(repairServiceTemplates)
.set({ isActive: false, updatedAt: new Date() }) .set({ isActive: false, updatedAt: new Date() })
.where(and(eq(repairServiceTemplates.id, id), eq(repairServiceTemplates.companyId, companyId))) .where(eq(repairServiceTemplates.id, id))
.returning() .returning()
return template ?? null return template ?? null
}, },
@@ -514,16 +493,7 @@ export const RepairNoteService = {
return paginatedResponse(data, total, params.page, params.limit) return paginatedResponse(data, total, params.page, params.limit)
}, },
async delete(db: PostgresJsDatabase<any>, companyId: string, id: string) { async delete(db: PostgresJsDatabase<any>, id: string) {
// Verify note belongs to a ticket owned by this company
const [owned] = await db
.select({ id: repairNotes.id })
.from(repairNotes)
.innerJoin(repairTickets, eq(repairNotes.repairTicketId, repairTickets.id))
.where(and(eq(repairNotes.id, id), eq(repairTickets.companyId, companyId)))
.limit(1)
if (!owned) return null
const [note] = await db const [note] = await db
.delete(repairNotes) .delete(repairNotes)
.where(eq(repairNotes.id, id)) .where(eq(repairNotes.id, id))

View File

@@ -1,6 +1,6 @@
import type { FastifyInstance } from 'fastify' import type { FastifyInstance } from 'fastify'
import { buildApp } from '../main.js' import { buildApp } from '../main.js'
import { sql, eq, and } from 'drizzle-orm' import { sql, eq } from 'drizzle-orm'
import { companies, locations } from '../db/schema/stores.js' import { companies, locations } from '../db/schema/stores.js'
import { UnitStatusService, ItemConditionService } from '../services/lookup.service.js' import { UnitStatusService, ItemConditionService } from '../services/lookup.service.js'
import { RbacService } from '../services/rbac.service.js' import { RbacService } from '../services/rbac.service.js'
@@ -42,12 +42,12 @@ export async function seedTestCompany(app: FastifyInstance): Promise<void> {
name: 'Test Location', name: 'Test Location',
}) })
await UnitStatusService.seedForCompany(app.db, TEST_COMPANY_ID) await UnitStatusService.seedDefaults(app.db)
await ItemConditionService.seedForCompany(app.db, TEST_COMPANY_ID) await ItemConditionService.seedDefaults(app.db)
// Seed RBAC permissions and default roles // Seed RBAC permissions and default roles
await RbacService.seedPermissions(app.db) await RbacService.seedPermissions(app.db)
await RbacService.seedRolesForCompany(app.db, TEST_COMPANY_ID) await RbacService.seedDefaultRoles(app.db)
} }
export async function registerAndLogin( export async function registerAndLogin(
@@ -80,7 +80,7 @@ export async function registerAndLogin(
const [adminRole] = await app.db const [adminRole] = await app.db
.select() .select()
.from(roles) .from(roles)
.where(and(eq(roles.companyId, TEST_COMPANY_ID), eq(roles.slug, 'admin'))) .where(eq(roles.slug, 'admin'))
.limit(1) .limit(1)
if (adminRole) { if (adminRole) {