Initial commit: Buildfor Life Budget app

Multi-company budget/project tracking tool built with SvelteKit 5,
PostgreSQL (Drizzle ORM), and Tailwind CSS v4.

Features:
- Auth: local (email/password with Argon2) + generic OIDC
- 4 roles per company: admin, manager, user, viewer
- Multi-company with per-company user membership
- Projects with budget allocation from company pool
- Expense submission with approval workflow
- Categories and tags for expense organization
- Reports with spending breakdowns (by category, project, time)
- CSV import for Actual Budget migration
- Company audit log tracking all budget and admin actions
- Remaining budget hero display on overview and budget pages
- Admin-only company creation; new users wait for invitation
- Deployment configs for systemd + nginx (bare metal/Proxmox)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 11:51:32 +07:00
commit 7a4ba0537f
86 changed files with 8963 additions and 0 deletions
+309
View File
@@ -0,0 +1,309 @@
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']);
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),
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'),
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' }),
role: companyRoleEnum('role').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' }),
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()
});
// ── 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'
]);
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] })
}));