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
+90
View File
@@ -0,0 +1,90 @@
<script lang="ts">
import type { CompanyRole } from '$lib/types/index.js';
interface Props {
user: { id: string; email: string; displayName: string | null; isSystemAdmin: boolean };
companies: Array<{ companyId: string; companyName: string; role: CompanyRole }>;
open: boolean;
onToggle: () => void;
}
let { user, companies, open, onToggle }: Props = $props();
</script>
<aside
class="flex w-64 flex-col border-r border-gray-200 bg-white transition-transform duration-200 {open
? 'translate-x-0'
: '-translate-x-full'} fixed inset-y-0 left-0 z-30 lg:relative lg:translate-x-0"
>
<!-- Logo -->
<div class="flex h-14 items-center border-b border-gray-200 px-4">
<a href="/dashboard" class="text-lg font-bold text-gray-900">B4L Budget</a>
</div>
<!-- Navigation -->
<nav class="flex-1 overflow-y-auto px-3 py-4">
<a
href="/dashboard"
class="mb-1 flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
Dashboard
</a>
{#if companies.length > 0}
<div class="mt-4 mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-gray-400">
Companies
</div>
{#each companies as company}
<a
href="/companies/{company.companyId}"
class="mb-0.5 flex items-center gap-2 rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
<span
class="flex h-5 w-5 items-center justify-center rounded bg-blue-100 text-xs font-medium text-blue-700"
>
{company.companyName[0]?.toUpperCase()}
</span>
<span class="truncate">{company.companyName}</span>
<span class="ml-auto text-xs text-gray-400">{company.role}</span>
</a>
{/each}
{/if}
{#if user.isSystemAdmin}
<div class="mt-4 mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-gray-400">
Administration
</div>
<a
href="/admin/users"
class="mb-0.5 flex items-center gap-2 rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
Manage Users
</a>
<a
href="/admin/settings"
class="mb-0.5 flex items-center gap-2 rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Settings
</a>
{/if}
</nav>
</aside>
<!-- Backdrop for mobile -->
{#if open}
<button
class="fixed inset-0 z-20 bg-black/30 lg:hidden"
onclick={onToggle}
aria-label="Close sidebar"
></button>
{/if}
+20
View File
@@ -0,0 +1,20 @@
import { db } from './db/index.js';
import { companyLog } from './db/schema.js';
type LogEvent = typeof companyLog.$inferInsert['event'];
export async function logCompanyEvent(
companyId: string,
userId: string | null,
event: LogEvent,
description: string,
metadata?: Record<string, unknown>
): Promise<void> {
await db.insert(companyLog).values({
companyId,
userId,
event,
description,
metadata: metadata ? JSON.stringify(metadata) : null
});
}
+124
View File
@@ -0,0 +1,124 @@
import {
encodeBase32LowerCaseNoPadding,
encodeHexLowerCase
} from '@oslojs/encoding';
import { sha256 } from '@oslojs/crypto/sha2';
import { db } from '../db/index.js';
import { sessions, users } from '../db/schema.js';
import { eq } from 'drizzle-orm';
import type { RequestEvent } from '@sveltejs/kit';
const SESSION_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
const SESSION_REFRESH_MS = 15 * 24 * 60 * 60 * 1000; // 15 days
export function generateSessionToken(): string {
const bytes = new Uint8Array(20);
crypto.getRandomValues(bytes);
return encodeBase32LowerCaseNoPadding(bytes);
}
export function generateUserId(): string {
const bytes = new Uint8Array(15);
crypto.getRandomValues(bytes);
return encodeBase32LowerCaseNoPadding(bytes);
}
function hashToken(token: string): string {
const encoded = new TextEncoder().encode(token);
return encodeHexLowerCase(sha256(encoded));
}
export async function createSession(
token: string,
userId: string
): Promise<{ id: string; userId: string; expiresAt: Date; fresh: boolean }> {
const sessionId = hashToken(token);
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS);
await db.insert(sessions).values({
id: sessionId,
userId,
expiresAt
});
return { id: sessionId, userId, expiresAt, fresh: true };
}
export async function validateSession(
token: string
): Promise<{
session: { id: string; expiresAt: Date; fresh: boolean } | null;
user: App.Locals['user'];
}> {
const sessionId = hashToken(token);
const result = await db
.select({
session: sessions,
user: {
id: users.id,
email: users.email,
username: users.username,
displayName: users.displayName,
isSystemAdmin: users.isSystemAdmin
}
})
.from(sessions)
.innerJoin(users, eq(sessions.userId, users.id))
.where(eq(sessions.id, sessionId))
.limit(1);
if (result.length === 0) {
return { session: null, user: null };
}
const { session, user } = result[0];
// Session expired
if (Date.now() >= session.expiresAt.getTime()) {
await db.delete(sessions).where(eq(sessions.id, sessionId));
return { session: null, user: null };
}
// Extend session if within refresh window
let fresh = false;
if (Date.now() >= session.expiresAt.getTime() - SESSION_REFRESH_MS) {
const newExpiry = new Date(Date.now() + SESSION_DURATION_MS);
await db
.update(sessions)
.set({ expiresAt: newExpiry })
.where(eq(sessions.id, sessionId));
session.expiresAt = newExpiry;
fresh = true;
}
return {
session: { id: session.id, expiresAt: session.expiresAt, fresh },
user
};
}
export async function invalidateSession(token: string): Promise<void> {
const sessionId = hashToken(token);
await db.delete(sessions).where(eq(sessions.id, sessionId));
}
export function setSessionCookie(event: RequestEvent, token: string, expiresAt: Date): void {
event.cookies.set('session', token, {
httpOnly: true,
sameSite: 'lax',
expires: expiresAt,
path: '/',
secure: event.url.protocol === 'https:'
});
}
export function deleteSessionCookie(event: RequestEvent): void {
event.cookies.set('session', '', {
httpOnly: true,
sameSite: 'lax',
maxAge: 0,
path: '/',
secure: event.url.protocol === 'https:'
});
}
+132
View File
@@ -0,0 +1,132 @@
import { env } from '$env/dynamic/private';
interface OIDCConfig {
issuerUrl: string;
clientId: string;
clientSecret: string;
redirectUri: string;
authorizationEndpoint: string;
tokenEndpoint: string;
userinfoEndpoint: string;
}
let oidcConfig: OIDCConfig | null = null;
export function isOIDCEnabled(): boolean {
return !!(env.OIDC_ISSUER_URL && env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET);
}
export async function getOIDCConfig(): Promise<OIDCConfig> {
if (oidcConfig) return oidcConfig;
if (!isOIDCEnabled()) {
throw new Error('OIDC is not configured');
}
const issuerUrl = env.OIDC_ISSUER_URL!.replace(/\/$/, '');
const discoveryUrl = `${issuerUrl}/.well-known/openid-configuration`;
const res = await fetch(discoveryUrl);
if (!res.ok) {
throw new Error(`Failed to fetch OIDC discovery: ${res.status}`);
}
const discovery = await res.json();
oidcConfig = {
issuerUrl,
clientId: env.OIDC_CLIENT_ID!,
clientSecret: env.OIDC_CLIENT_SECRET!,
redirectUri: env.OIDC_REDIRECT_URI || `${env.ORIGIN}/oidc/callback`,
authorizationEndpoint: discovery.authorization_endpoint,
tokenEndpoint: discovery.token_endpoint,
userinfoEndpoint: discovery.userinfo_endpoint
};
return oidcConfig;
}
export function generateState(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
export function generateCodeVerifier(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
export async function generateCodeChallenge(verifier: string): Promise<string> {
const encoded = new TextEncoder().encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', encoded);
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
export async function getAuthorizationUrl(state: string, codeVerifier: string): Promise<string> {
const config = await getOIDCConfig();
const codeChallenge = await generateCodeChallenge(codeVerifier);
const params = new URLSearchParams({
response_type: 'code',
client_id: config.clientId,
redirect_uri: config.redirectUri,
scope: 'openid email profile',
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256'
});
return `${config.authorizationEndpoint}?${params}`;
}
export async function exchangeCode(
code: string,
codeVerifier: string
): Promise<{ accessToken: string; idToken?: string }> {
const config = await getOIDCConfig();
const res = await fetch(config.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: config.redirectUri,
client_id: config.clientId,
client_secret: config.clientSecret,
code_verifier: codeVerifier
})
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Token exchange failed: ${res.status} ${text}`);
}
const data = await res.json();
return { accessToken: data.access_token, idToken: data.id_token };
}
export async function getUserInfo(
accessToken: string
): Promise<{ sub: string; email: string; name?: string }> {
const config = await getOIDCConfig();
const res = await fetch(config.userinfoEndpoint, {
headers: { Authorization: `Bearer ${accessToken}` }
});
if (!res.ok) {
throw new Error(`UserInfo request failed: ${res.status}`);
}
return res.json();
}
+16
View File
@@ -0,0 +1,16 @@
import { hash, verify } from '@node-rs/argon2';
const ARGON2_OPTIONS = {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1
};
export async function hashPassword(password: string): Promise<string> {
return hash(password, ARGON2_OPTIONS);
}
export async function verifyPassword(passwordHash: string, password: string): Promise<boolean> {
return verify(passwordHash, password, ARGON2_OPTIONS);
}
+59
View File
@@ -0,0 +1,59 @@
import { error } from '@sveltejs/kit';
import { db } from './db/index.js';
import { companyMembers } from './db/schema.js';
import { and, eq } from 'drizzle-orm';
import { ROLE_HIERARCHY, type CompanyRole } from '$lib/types/index.js';
export function requireAuth(locals: App.Locals): NonNullable<App.Locals['user']> {
if (!locals.user) {
error(401, 'Authentication required');
}
return locals.user;
}
export function requireSystemAdmin(locals: App.Locals): NonNullable<App.Locals['user']> {
const user = requireAuth(locals);
if (!user.isSystemAdmin) {
error(403, 'System admin access required');
}
return user;
}
export async function getCompanyRole(
userId: string,
companyId: string
): Promise<CompanyRole | null> {
const result = await db
.select({ role: companyMembers.role })
.from(companyMembers)
.where(and(eq(companyMembers.userId, userId), eq(companyMembers.companyId, companyId)))
.limit(1);
if (result.length === 0) return null;
return result[0].role;
}
export async function requireCompanyRole(
locals: App.Locals,
companyId: string,
minRole: CompanyRole
): Promise<{ user: NonNullable<App.Locals['user']>; role: CompanyRole }> {
const user = requireAuth(locals);
// System admins bypass company role checks
if (user.isSystemAdmin) {
return { user, role: 'admin' };
}
const role = await getCompanyRole(user.id, companyId);
if (!role) {
error(403, 'Not a member of this company');
}
if (ROLE_HIERARCHY[role] < ROLE_HIERARCHY[minRole]) {
error(403, `Requires ${minRole} role or higher`);
}
return { user, role };
}
+12
View File
@@ -0,0 +1,12 @@
import { drizzle } from 'drizzle-orm/node-postgres';
import pg from 'pg';
import * as schema from './schema.js';
import { env } from '$env/dynamic/private';
const pool = new pg.Pool({
connectionString: env.DATABASE_URL
});
export const db = drizzle(pool, { schema });
export type Database = typeof db;
+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] })
}));
@@ -0,0 +1,20 @@
import { pgTable, uuid, text, numeric, timestamp } from 'drizzle-orm/pg-core';
import { companies } from './companies.js';
import { projects } from './projects.js';
import { users } from './users.js';
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()
});
+16
View File
@@ -0,0 +1,16 @@
import { pgTable, uuid, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { companies } from './companies.js';
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)]
);
+11
View File
@@ -0,0 +1,11 @@
import { pgTable, uuid, text, numeric, timestamp } from 'drizzle-orm/pg-core';
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()
});
@@ -0,0 +1,21 @@
import { pgTable, uuid, text, timestamp, uniqueIndex, pgEnum } from 'drizzle-orm/pg-core';
import { users } from './users.js';
import { companies } from './companies.js';
export const companyRoleEnum = pgEnum('company_role', ['admin', 'manager', 'user', 'viewer']);
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)]
);
+46
View File
@@ -0,0 +1,46 @@
import {
pgTable,
uuid,
text,
numeric,
date,
timestamp,
index,
pgEnum
} from 'drizzle-orm/pg-core';
import { projects } from './projects.js';
import { categories } from './categories.js';
import { users } from './users.js';
export const expenseStatusEnum = pgEnum('expense_status', ['pending', 'approved', 'rejected']);
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)
]
);
+9
View File
@@ -0,0 +1,9 @@
export { users } from './users.js';
export { sessions } from './sessions.js';
export { companies } from './companies.js';
export { companyRoleEnum, companyMembers } from './company-members.js';
export { projects } from './projects.js';
export { categories } from './categories.js';
export { expenseStatusEnum, expenses } from './expenses.js';
export { tags, expenseTags } from './tags.js';
export { budgetAllocations } from './budget-allocations.js';
+15
View File
@@ -0,0 +1,15 @@
import { pgTable, uuid, text, numeric, boolean, timestamp } from 'drizzle-orm/pg-core';
import { companies } from './companies.js';
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()
});
+83
View File
@@ -0,0 +1,83 @@
import { relations } from 'drizzle-orm';
import { users } from './users.js';
import { sessions } from './sessions.js';
import { companies } from './companies.js';
import { companyMembers } from './company-members.js';
import { projects } from './projects.js';
import { categories } from './categories.js';
import { expenses } from './expenses.js';
import { tags, expenseTags } from './tags.js';
import { budgetAllocations } from './budget-allocations.js';
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)
}));
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 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] })
}));
+10
View File
@@ -0,0 +1,10 @@
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';
import { users } from './users.js';
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()
});
+28
View File
@@ -0,0 +1,28 @@
import { pgTable, uuid, text, uniqueIndex, primaryKey } from 'drizzle-orm/pg-core';
import { companies } from './companies.js';
import { expenses } from './expenses.js';
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] })]
);
+23
View File
@@ -0,0 +1,23 @@
import { pgTable, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
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`)
]
);
+9
View File
@@ -0,0 +1,9 @@
export type CompanyRole = 'admin' | 'manager' | 'user' | 'viewer';
export type ExpenseStatus = 'pending' | 'approved' | 'rejected';
export const ROLE_HIERARCHY: Record<CompanyRole, number> = {
admin: 4,
manager: 3,
user: 2,
viewer: 1
};
+12
View File
@@ -0,0 +1,12 @@
export function budgetPercent(spent: string | number, budget: string | number): number {
const s = typeof spent === 'string' ? parseFloat(spent) : spent;
const b = typeof budget === 'string' ? parseFloat(budget) : budget;
if (b <= 0) return 0;
return Math.min((s / b) * 100, 100);
}
export function budgetColor(pct: number): string {
if (pct > 90) return 'bg-red-500';
if (pct > 70) return 'bg-amber-500';
return 'bg-blue-500';
}
+18
View File
@@ -0,0 +1,18 @@
const CURRENCY_FORMATS: Record<string, { locale: string; minimumFractionDigits: number }> = {
THB: { locale: 'th-TH', minimumFractionDigits: 2 },
USD: { locale: 'en-US', minimumFractionDigits: 2 },
EUR: { locale: 'de-DE', minimumFractionDigits: 2 }
};
export function formatCurrency(amount: string | number, currency: string = 'THB'): string {
const num = typeof amount === 'string' ? parseFloat(amount) : amount;
if (isNaN(num)) return `0.00 ${currency}`;
const fmt = CURRENCY_FORMATS[currency] ?? { locale: 'en-US', minimumFractionDigits: 2 };
return new Intl.NumberFormat(fmt.locale, {
style: 'currency',
currency,
minimumFractionDigits: fmt.minimumFractionDigits
}).format(num);
}
+16
View File
@@ -0,0 +1,16 @@
import { format, formatDistanceToNow } from 'date-fns';
export function formatDate(date: string | Date): string {
const d = typeof date === 'string' ? new Date(date) : date;
return format(d, 'MMM d, yyyy');
}
export function formatDateTime(date: string | Date): string {
const d = typeof date === 'string' ? new Date(date) : date;
return format(d, 'MMM d, yyyy HH:mm');
}
export function timeAgo(date: string | Date): string {
const d = typeof date === 'string' ? new Date(date) : date;
return formatDistanceToNow(d, { addSuffix: true });
}