Files
buildfor_life_budget/src/lib/server/db/schema.ts
T
grabowski 0bfbcef043 Add accountant role and financial_exported audit event
- New 'accountant' role in companyRoleEnum (orthogonal like 'hr')
- meetsMinRole and requireCompanyRole now exclude accountant from
  hierarchy along with hr
- Settings UI exposes accountant in the role checkbox lists for both
  add-member and edit-member forms
- New 'financial_exported' value added to companyLogEventEnum, ready
  for the upcoming export feature

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:39:18 +07:00

800 lines
30 KiB
TypeScript

import { relations, sql } from 'drizzle-orm';
import {
pgTable,
pgEnum,
text,
boolean,
timestamp,
uniqueIndex,
uuid,
numeric,
date,
index,
primaryKey
} from 'drizzle-orm/pg-core';
// ── Enums ──────────────────────────────────────────────
export const companyRoleEnum = pgEnum('company_role', ['admin', 'manager', 'user', 'viewer', 'hr', 'accountant']);
export const expenseStatusEnum = pgEnum('expense_status', ['pending', 'approved', 'rejected']);
// ── Users ──────────────────────────────────────────────
export const users = pgTable(
'users',
{
id: text('id').primaryKey(),
email: text('email').notNull().unique(),
username: text('username').unique(),
displayName: text('display_name'),
passwordHash: text('password_hash'),
oidcProvider: text('oidc_provider'),
oidcSubject: text('oidc_subject'),
isSystemAdmin: boolean('is_system_admin').notNull().default(false),
disabledAt: timestamp('disabled_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
uniqueIndex('users_oidc_idx')
.on(table.oidcProvider, table.oidcSubject)
.where(sql`${table.oidcProvider} IS NOT NULL AND ${table.oidcSubject} IS NOT NULL`)
]
);
// ── Sessions ───────────────────────────────────────────
export const sessions = pgTable('sessions', {
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull()
});
// ── Companies ──────────────────────────────────────────
export const companies = pgTable('companies', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
description: text('description'),
totalBudget: numeric('total_budget', { precision: 15, scale: 2 }).notNull().default('0'),
currency: text('currency').notNull().default('THB'),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
});
// ── Company Members ────────────────────────────────────
export const companyMembers = pgTable(
'company_members',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
roles: companyRoleEnum('roles').array().notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [uniqueIndex('company_members_user_company_idx').on(table.userId, table.companyId)]
);
// ── Projects ───────────────────────────────────────────
export const projects = pgTable('projects', {
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
description: text('description'),
allocatedBudget: numeric('allocated_budget', { precision: 15, scale: 2 }).notNull().default('0'),
isActive: boolean('is_active').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
});
// ── Categories ─────────────────────────────────────────
export const categories = pgTable(
'categories',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
color: text('color'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [uniqueIndex('categories_company_name_idx').on(table.companyId, table.name)]
);
// ── Expenses ───────────────────────────────────────────
export const expenses = pgTable(
'expenses',
{
id: uuid('id').primaryKey().defaultRandom(),
projectId: uuid('project_id')
.notNull()
.references(() => projects.id, { onDelete: 'cascade' }),
categoryId: uuid('category_id').references(() => categories.id, { onDelete: 'set null' }),
partyId: uuid('party_id').references((): any => parties.id, { onDelete: 'set null' }),
submittedBy: text('submitted_by')
.notNull()
.references(() => users.id),
approvedBy: text('approved_by').references(() => users.id),
title: text('title').notNull(),
description: text('description'),
amount: numeric('amount', { precision: 15, scale: 2 }).notNull(),
currency: text('currency').notNull(),
receiptUrl: text('receipt_url'),
expenseDate: date('expense_date').notNull(),
status: expenseStatusEnum('status').notNull().default('pending'),
reviewedAt: timestamp('reviewed_at', { withTimezone: true }),
rejectionReason: text('rejection_reason'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
index('expenses_project_status_idx').on(table.projectId, table.status),
index('expenses_submitted_by_idx').on(table.submittedBy),
index('expenses_date_idx').on(table.expenseDate)
]
);
// ── Tags ───────────────────────────────────────────────
export const tags = pgTable(
'tags',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
name: text('name').notNull()
},
(table) => [uniqueIndex('tags_company_name_idx').on(table.companyId, table.name)]
);
export const expenseTags = pgTable(
'expense_tags',
{
expenseId: uuid('expense_id')
.notNull()
.references(() => expenses.id, { onDelete: 'cascade' }),
tagId: uuid('tag_id')
.notNull()
.references(() => tags.id, { onDelete: 'cascade' })
},
(table) => [primaryKey({ columns: [table.expenseId, table.tagId] })]
);
// ── Budget Allocations ─────────────────────────────────
export const budgetAllocations = pgTable('budget_allocations', {
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
projectId: uuid('project_id')
.notNull()
.references(() => projects.id, { onDelete: 'cascade' }),
amount: numeric('amount', { precision: 15, scale: 2 }).notNull(),
allocatedBy: text('allocated_by')
.notNull()
.references(() => users.id),
note: text('note'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
});
// ── Employees ──────────────────────────────────────────
export const employees = pgTable('employees', {
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
firstName: text('first_name').notNull(),
lastName: text('last_name').notNull(),
displayName: text('display_name'),
email: text('email'),
phone: text('phone'),
employeeCode: text('employee_code'),
position: text('position'),
department: text('department'),
hireDate: date('hire_date').notNull(),
terminationDate: date('termination_date'),
nationalId: text('national_id'),
taxId: text('tax_id'),
bankName: text('bank_name'),
bankAccount: text('bank_account'),
isActive: boolean('is_active').notNull().default(true),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
});
export const salaryHistory = pgTable('salary_history', {
id: uuid('id').primaryKey().defaultRandom(),
employeeId: uuid('employee_id')
.notNull()
.references(() => employees.id, { onDelete: 'cascade' }),
effectiveFrom: date('effective_from').notNull(),
grossSalary: numeric('gross_salary', { precision: 15, scale: 2 }).notNull(),
currency: text('currency').notNull().default('THB'),
note: text('note'),
setBy: text('set_by')
.notNull()
.references(() => users.id),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
});
// ── Public Holidays ────────────────────────────────────
export const publicHolidays = pgTable(
'public_holidays',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
date: date('date').notNull(),
name: text('name').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [uniqueIndex('public_holidays_company_date_idx').on(table.companyId, table.date)]
);
// ── Leave ──────────────────────────────────────────────
export const leaveStatusEnum = pgEnum('leave_status', ['pending', 'approved', 'rejected']);
export const leaveTypes = pgTable(
'leave_types',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
defaultDaysPerYear: numeric('default_days_per_year', { precision: 5, scale: 2 }),
isPaid: boolean('is_paid').notNull().default(true),
color: text('color'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [uniqueIndex('leave_types_company_name_idx').on(table.companyId, table.name)]
);
export const leaveBalances = pgTable(
'leave_balances',
{
id: uuid('id').primaryKey().defaultRandom(),
employeeId: uuid('employee_id')
.notNull()
.references(() => employees.id, { onDelete: 'cascade' }),
leaveTypeId: uuid('leave_type_id')
.notNull()
.references(() => leaveTypes.id, { onDelete: 'cascade' }),
year: numeric('year', { precision: 4, scale: 0 }).notNull(),
allocated: numeric('allocated', { precision: 5, scale: 2 }).notNull().default('0'),
used: numeric('used', { precision: 5, scale: 2 }).notNull().default('0'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
uniqueIndex('leave_balances_emp_type_year_idx').on(
table.employeeId,
table.leaveTypeId,
table.year
)
]
);
export const leaveRequests = pgTable('leave_requests', {
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
employeeId: uuid('employee_id')
.notNull()
.references(() => employees.id, { onDelete: 'cascade' }),
leaveTypeId: uuid('leave_type_id')
.notNull()
.references(() => leaveTypes.id, { onDelete: 'cascade' }),
startDate: date('start_date').notNull(),
endDate: date('end_date').notNull(),
days: numeric('days', { precision: 5, scale: 2 }).notNull(),
reason: text('reason'),
status: leaveStatusEnum('status').notNull().default('pending'),
reviewedBy: text('reviewed_by').references(() => users.id),
reviewedAt: timestamp('reviewed_at', { withTimezone: true }),
rejectionReason: text('rejection_reason'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
});
// ── Payslips ───────────────────────────────────────────
export const payslipStatusEnum = pgEnum('payslip_status', ['draft', 'finalized', 'paid']);
export const payslipLineTypeEnum = pgEnum('payslip_line_type', ['earning', 'deduction']);
export const payslips = pgTable(
'payslips',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
employeeId: uuid('employee_id')
.notNull()
.references(() => employees.id, { onDelete: 'cascade' }),
periodYear: numeric('period_year', { precision: 4, scale: 0 }).notNull(),
periodMonth: numeric('period_month', { precision: 2, scale: 0 }).notNull(),
grossSalary: numeric('gross_salary', { precision: 15, scale: 2 }).notNull(),
overtime: numeric('overtime', { precision: 15, scale: 2 }).notNull().default('0'),
bonus: numeric('bonus', { precision: 15, scale: 2 }).notNull().default('0'),
otherEarnings: numeric('other_earnings', { precision: 15, scale: 2 }).notNull().default('0'),
ssoEmployee: numeric('sso_employee', { precision: 15, scale: 2 }).notNull().default('0'),
ssoEmployer: numeric('sso_employer', { precision: 15, scale: 2 }).notNull().default('0'),
incomeTax: numeric('income_tax', { precision: 15, scale: 2 }).notNull().default('0'),
otherDeductions: numeric('other_deductions', { precision: 15, scale: 2 }).notNull().default('0'),
netPay: numeric('net_pay', { precision: 15, scale: 2 }).notNull(),
currency: text('currency').notNull().default('THB'),
status: payslipStatusEnum('status').notNull().default('draft'),
finalizedAt: timestamp('finalized_at', { withTimezone: true }),
paidAt: timestamp('paid_at', { withTimezone: true }),
generatedBy: text('generated_by')
.notNull()
.references(() => users.id),
pdfPath: text('pdf_path'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
uniqueIndex('payslips_emp_period_idx').on(table.employeeId, table.periodYear, table.periodMonth)
]
);
export const payslipLineItems = pgTable('payslip_line_items', {
id: uuid('id').primaryKey().defaultRandom(),
payslipId: uuid('payslip_id')
.notNull()
.references(() => payslips.id, { onDelete: 'cascade' }),
type: payslipLineTypeEnum('type').notNull(),
label: text('label').notNull(),
amount: numeric('amount', { precision: 15, scale: 2 }).notNull(),
isStatutory: boolean('is_statutory').notNull().default(false)
});
// ── Parties (Customers / Suppliers) ────────────────────
export const partyTypeEnum = pgEnum('party_type', ['customer', 'supplier', 'both']);
export const parties = pgTable('parties', {
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
type: partyTypeEnum('type').notNull().default('customer'),
name: text('name').notNull(),
contactPerson: text('contact_person'),
email: text('email'),
phone: text('phone'),
website: text('website'),
taxId: text('tax_id'),
addressLine1: text('address_line_1'),
addressLine2: text('address_line_2'),
city: text('city'),
postalCode: text('postal_code'),
country: text('country'),
paymentTerms: text('payment_terms'),
notes: text('notes'),
isActive: boolean('is_active').notNull().default(true),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
});
// ── Invoices ───────────────────────────────────────────
export const invoiceDirectionEnum = pgEnum('invoice_direction', ['incoming', 'outgoing']);
export const invoiceStatusEnum = pgEnum('invoice_status', [
'draft',
'sent',
'paid',
'overdue',
'cancelled'
]);
export const invoices = pgTable(
'invoices',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
partyId: uuid('party_id')
.notNull()
.references(() => parties.id, { onDelete: 'restrict' }),
direction: invoiceDirectionEnum('direction').notNull(),
invoiceNumber: text('invoice_number').notNull(),
issueDate: date('issue_date').notNull(),
dueDate: date('due_date'),
subtotal: numeric('subtotal', { precision: 15, scale: 2 }).notNull(),
vat: numeric('vat', { precision: 15, scale: 2 }).notNull().default('0'),
total: numeric('total', { precision: 15, scale: 2 }).notNull(),
currency: text('currency').notNull().default('THB'),
status: invoiceStatusEnum('status').notNull().default('draft'),
expenseId: uuid('expense_id').references(() => expenses.id, { onDelete: 'set null' }),
notes: text('notes'),
pdfPath: text('pdf_path'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
uniqueIndex('invoices_company_direction_number_idx').on(
table.companyId,
table.direction,
table.invoiceNumber
)
]
);
export const invoiceLineItems = pgTable('invoice_line_items', {
id: uuid('id').primaryKey().defaultRandom(),
invoiceId: uuid('invoice_id')
.notNull()
.references(() => invoices.id, { onDelete: 'cascade' }),
description: text('description').notNull(),
quantity: numeric('quantity', { precision: 10, scale: 2 }).notNull().default('1'),
unitPrice: numeric('unit_price', { precision: 15, scale: 2 }).notNull(),
total: numeric('total', { precision: 15, scale: 2 }).notNull()
});
// ── External Integrations ──────────────────────────────
export const integrationProviderEnum = pgEnum('integration_provider', [
'kasikorn_kbiz',
'etherfi',
'manual'
]);
export const txDirectionEnum = pgEnum('tx_direction', ['credit', 'debit']);
export const externalAccounts = pgTable('external_accounts', {
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
provider: integrationProviderEnum('provider').notNull(),
displayName: text('display_name').notNull(),
accountIdentifier: text('account_identifier').notNull(),
credentialsEncrypted: text('credentials_encrypted'),
isActive: boolean('is_active').notNull().default(true),
lastSyncedAt: timestamp('last_synced_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
});
export const externalTransactions = pgTable(
'external_transactions',
{
id: uuid('id').primaryKey().defaultRandom(),
accountId: uuid('account_id')
.notNull()
.references(() => externalAccounts.id, { onDelete: 'cascade' }),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
externalId: text('external_id').notNull(),
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(),
amount: numeric('amount', { precision: 15, scale: 2 }).notNull(),
currency: text('currency').notNull(),
direction: txDirectionEnum('direction').notNull(),
description: text('description'),
counterparty: text('counterparty'),
matchedExpenseId: uuid('matched_expense_id').references(() => expenses.id, {
onDelete: 'set null'
}),
rawPayload: text('raw_payload'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [uniqueIndex('external_tx_account_extid_idx').on(table.accountId, table.externalId)]
);
// ── Package Tracking ───────────────────────────────────
export const packageDirectionEnum = pgEnum('package_direction', ['incoming', 'outgoing']);
export const packageStatusEnum = pgEnum('package_status', [
'pending',
'in_transit',
'out_for_delivery',
'delivered',
'exception',
'returned',
'cancelled'
]);
export const carrierEnum = pgEnum('carrier', [
'ups',
'fedex',
'dhl',
'usps',
'flash_express',
'kerry_th',
'jnt_express',
'thailand_post',
'other'
]);
export const packages = pgTable(
'packages',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
direction: packageDirectionEnum('direction').notNull(),
carrier: carrierEnum('carrier').notNull(),
trackingNumber: text('tracking_number').notNull(),
status: packageStatusEnum('status').notNull().default('pending'),
currentLocation: text('current_location'),
description: text('description'),
recipientName: text('recipient_name'),
estimatedDelivery: date('estimated_delivery'),
shippedAt: timestamp('shipped_at', { withTimezone: true }),
deliveredAt: timestamp('delivered_at', { withTimezone: true }),
weightKg: numeric('weight_kg', { precision: 10, scale: 3 }),
shippingCost: numeric('shipping_cost', { precision: 15, scale: 2 }),
currency: text('currency').notNull().default('THB'),
invoiceId: uuid('invoice_id').references(() => invoices.id, { onDelete: 'set null' }),
customsInvoiceId: uuid('customs_invoice_id').references(() => invoices.id, { onDelete: 'set null' }),
expenseId: uuid('expense_id').references(() => expenses.id, { onDelete: 'set null' }),
partyId: uuid('party_id').references(() => parties.id, { onDelete: 'set null' }),
notes: text('notes'),
lastRefreshedAt: timestamp('last_refreshed_at', { withTimezone: true }),
createdBy: text('created_by')
.notNull()
.references(() => users.id),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
uniqueIndex('packages_company_carrier_tracking_idx').on(
table.companyId,
table.carrier,
table.trackingNumber
),
index('packages_company_status_idx').on(table.companyId, table.status)
]
);
export const packageEvents = pgTable(
'package_events',
{
id: uuid('id').primaryKey().defaultRandom(),
packageId: uuid('package_id')
.notNull()
.references(() => packages.id, { onDelete: 'cascade' }),
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(),
status: packageStatusEnum('status'),
location: text('location'),
description: text('description'),
source: text('source').notNull().default('manual'),
rawPayload: text('raw_payload'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [index('package_events_package_idx').on(table.packageId, table.occurredAt)]
);
export const shippingAccounts = pgTable(
'shipping_accounts',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
carrier: carrierEnum('carrier').notNull(),
displayName: text('display_name'),
credentialsEncrypted: text('credentials_encrypted'),
isActive: boolean('is_active').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [uniqueIndex('shipping_accounts_company_carrier_idx').on(table.companyId, table.carrier)]
);
// ── Feature Requests ───────────────────────────────────
export const featureRequestStatusEnum = pgEnum('feature_request_status', [
'open',
'in_review',
'waiting_for_checks',
'in_progress',
'resolved',
'closed'
]);
export const featureRequests = pgTable(
'feature_requests',
{
id: uuid('id').primaryKey().defaultRandom(),
title: text('title').notNull(),
description: text('description'),
status: featureRequestStatusEnum('status').notNull().default('open'),
submittedBy: text('submitted_by')
.notNull()
.references(() => users.id, { onDelete: 'set null' }),
statusChangedBy: text('status_changed_by').references(() => users.id, { onDelete: 'set null' }),
statusChangedAt: timestamp('status_changed_at', { withTimezone: true }),
statusNote: text('status_note'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [index('feature_requests_status_idx').on(table.status)]
);
export const featureRequestVotes = pgTable(
'feature_request_votes',
{
id: uuid('id').primaryKey().defaultRandom(),
requestId: uuid('request_id')
.notNull()
.references(() => featureRequests.id, { onDelete: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [uniqueIndex('feature_request_votes_request_user_idx').on(table.requestId, table.userId)]
);
// ── Company Log (Audit Trail) ──────────────────────────
export const companyLogEventEnum = pgEnum('company_log_event', [
'company_created',
'company_updated',
'budget_initial',
'budget_added',
'budget_allocated',
'budget_deallocated',
'project_created',
'project_updated',
'member_added',
'member_removed',
'member_role_changed',
'expense_submitted',
'expense_approved',
'expense_rejected',
'category_created',
'import_completed',
'employee_created',
'employee_updated',
'employee_terminated',
'salary_changed',
'holiday_added',
'leave_type_created',
'leave_submitted',
'leave_approved',
'leave_rejected',
'payslip_generated',
'payslip_finalized',
'payslip_paid',
'party_created',
'invoice_created',
'invoice_sent',
'invoice_paid',
'integration_connected',
'integration_disconnected',
'transaction_matched',
'package_created',
'package_updated',
'package_delivered',
'package_status_refreshed',
'shipping_account_added',
'shipping_account_removed',
'financial_exported'
]);
export const companyLog = pgTable(
'company_log',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
userId: text('user_id').references(() => users.id),
event: companyLogEventEnum('event').notNull(),
description: text('description').notNull(),
metadata: text('metadata'), // JSON string for extra context (amounts, names, etc.)
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [index('company_log_company_idx').on(table.companyId, table.createdAt)]
);
// ── Relations ──────────────────────────────────────────
export const usersRelations = relations(users, ({ many }) => ({
sessions: many(sessions),
companyMemberships: many(companyMembers),
submittedExpenses: many(expenses, { relationName: 'submittedExpenses' }),
approvedExpenses: many(expenses, { relationName: 'approvedExpenses' })
}));
export const sessionsRelations = relations(sessions, ({ one }) => ({
user: one(users, { fields: [sessions.userId], references: [users.id] })
}));
export const companiesRelations = relations(companies, ({ many }) => ({
members: many(companyMembers),
projects: many(projects),
categories: many(categories),
tags: many(tags),
budgetAllocations: many(budgetAllocations),
logs: many(companyLog)
}));
export const companyMembersRelations = relations(companyMembers, ({ one }) => ({
user: one(users, { fields: [companyMembers.userId], references: [users.id] }),
company: one(companies, { fields: [companyMembers.companyId], references: [companies.id] })
}));
export const projectsRelations = relations(projects, ({ one, many }) => ({
company: one(companies, { fields: [projects.companyId], references: [companies.id] }),
expenses: many(expenses),
budgetAllocations: many(budgetAllocations)
}));
export const categoriesRelations = relations(categories, ({ one, many }) => ({
company: one(companies, { fields: [categories.companyId], references: [companies.id] }),
expenses: many(expenses)
}));
export const expensesRelations = relations(expenses, ({ one, many }) => ({
project: one(projects, { fields: [expenses.projectId], references: [projects.id] }),
category: one(categories, { fields: [expenses.categoryId], references: [categories.id] }),
submitter: one(users, {
fields: [expenses.submittedBy],
references: [users.id],
relationName: 'submittedExpenses'
}),
approver: one(users, {
fields: [expenses.approvedBy],
references: [users.id],
relationName: 'approvedExpenses'
}),
expenseTags: many(expenseTags)
}));
export const tagsRelations = relations(tags, ({ one, many }) => ({
company: one(companies, { fields: [tags.companyId], references: [companies.id] }),
expenseTags: many(expenseTags)
}));
export const expenseTagsRelations = relations(expenseTags, ({ one }) => ({
expense: one(expenses, { fields: [expenseTags.expenseId], references: [expenses.id] }),
tag: one(tags, { fields: [expenseTags.tagId], references: [tags.id] })
}));
export const companyLogRelations = relations(companyLog, ({ one }) => ({
company: one(companies, { fields: [companyLog.companyId], references: [companies.id] }),
user: one(users, { fields: [companyLog.userId], references: [users.id] })
}));
export const budgetAllocationsRelations = relations(budgetAllocations, ({ one }) => ({
company: one(companies, {
fields: [budgetAllocations.companyId],
references: [companies.id]
}),
project: one(projects, {
fields: [budgetAllocations.projectId],
references: [projects.id]
}),
allocator: one(users, { fields: [budgetAllocations.allocatedBy], references: [users.id] })
}));