feat: add TallyNote local reimbursement ledger
TallyNote release / linux-x64 (push) Failing after 2m41s
TallyNote release / linux-x64 (push) Failing after 2m41s
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle, type BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { chmodSync, existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { AppConfig } from "../config.js";
|
||||
import * as schema from "./schema.js";
|
||||
|
||||
export type DatabaseContext = {
|
||||
sqlite: Database.Database;
|
||||
db: BetterSQLite3Database<typeof schema>;
|
||||
};
|
||||
|
||||
function migrate(sqlite: Database.Database, migrationsDir: string): void {
|
||||
sqlite.exec("CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY, applied_at INTEGER NOT NULL) STRICT");
|
||||
const applied = new Set(
|
||||
(sqlite.prepare("SELECT name FROM schema_migrations").all() as Array<{ name: string }>).map((row) => row.name),
|
||||
);
|
||||
const files = readdirSync(migrationsDir).filter((name) => name.endsWith(".sql")).sort();
|
||||
const apply = sqlite.transaction((name: string, sqlText: string) => {
|
||||
sqlite.exec(sqlText);
|
||||
sqlite.prepare("INSERT INTO schema_migrations(name, applied_at) VALUES (?, ?)").run(name, Date.now());
|
||||
});
|
||||
for (const name of files) {
|
||||
if (!applied.has(name)) apply(name, readFileSync(path.join(migrationsDir, name), "utf8"));
|
||||
}
|
||||
}
|
||||
|
||||
export function openDatabase(config: AppConfig): DatabaseContext {
|
||||
const sqlite = new Database(config.dbPath);
|
||||
sqlite.pragma("foreign_keys = ON");
|
||||
sqlite.pragma("journal_mode = WAL");
|
||||
sqlite.pragma("synchronous = FULL");
|
||||
sqlite.pragma("busy_timeout = 5000");
|
||||
sqlite.pragma("temp_store = MEMORY");
|
||||
migrate(sqlite, config.migrationsDir);
|
||||
// SQLite creates the database and journal files after the initial directory
|
||||
// preparation. Enforce private permissions again after opening so a broad
|
||||
// process umask can never expose financial data to other local users.
|
||||
for (const filePath of [config.dbPath, `${config.dbPath}-wal`, `${config.dbPath}-shm`]) {
|
||||
if (existsSync(filePath)) chmodSync(filePath, 0o600);
|
||||
}
|
||||
const foreignKeys = sqlite.pragma("foreign_keys", { simple: true });
|
||||
if (foreignKeys !== 1) throw new Error("SQLite 外键未启用");
|
||||
return { sqlite, db: drizzle(sqlite, { schema }) };
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { blob, check, index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const admins = sqliteTable("admins", {
|
||||
id: text("id").primaryKey(),
|
||||
username: text("username").notNull(),
|
||||
usernameNorm: text("username_norm").notNull(),
|
||||
displayName: text("display_name").notNull(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
status: text("status", { enum: ["active", "disabled"] }).notNull().default("active"),
|
||||
mustChangePassword: integer("must_change_password", { mode: "boolean" }).notNull().default(true),
|
||||
authVersion: integer("auth_version").notNull().default(1),
|
||||
version: integer("version").notNull().default(1),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
createdBy: text("created_by"),
|
||||
passwordChangedAt: integer("password_changed_at"),
|
||||
lastLoginAt: integer("last_login_at"),
|
||||
disabledAt: integer("disabled_at"),
|
||||
disabledBy: text("disabled_by"),
|
||||
}, (table) => [
|
||||
uniqueIndex("admins_username_norm_uq").on(table.usernameNorm),
|
||||
check("admins_status_ck", sql`${table.status} in ('active','disabled')`),
|
||||
check("admins_versions_ck", sql`${table.version} >= 1 and ${table.authVersion} >= 1`),
|
||||
]);
|
||||
|
||||
export const sessions = sqliteTable("sessions", {
|
||||
tokenHash: text("token_hash").primaryKey(),
|
||||
adminId: text("admin_id").notNull().references(() => admins.id, { onDelete: "cascade" }),
|
||||
csrfHash: text("csrf_hash").notNull(),
|
||||
authVersion: integer("auth_version").notNull(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
lastSeenAt: integer("last_seen_at").notNull(),
|
||||
idleExpiresAt: integer("idle_expires_at").notNull(),
|
||||
absoluteExpiresAt: integer("absolute_expires_at").notNull(),
|
||||
}, (table) => [index("sessions_admin_idx").on(table.adminId), index("sessions_expiry_idx").on(table.idleExpiresAt)]);
|
||||
|
||||
export const expenses = sqliteTable("expenses", {
|
||||
id: text("id").primaryKey(),
|
||||
paidAt: integer("paid_at").notNull(),
|
||||
amountCents: integer("amount_cents").notNull(),
|
||||
note: text("note").notNull().default(""),
|
||||
invoiceMissingReason: text("invoice_missing_reason"),
|
||||
status: text("status", { enum: ["unreimbursed", "reimbursed"] }).notNull().default("unreimbursed"),
|
||||
version: integer("version").notNull().default(1),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
createdBy: text("created_by").notNull().references(() => admins.id, { onDelete: "restrict" }),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
updatedBy: text("updated_by").notNull().references(() => admins.id, { onDelete: "restrict" }),
|
||||
reimbursedAt: integer("reimbursed_at"),
|
||||
reimbursedBy: text("reimbursed_by").references(() => admins.id, { onDelete: "restrict" }),
|
||||
deletedAt: integer("deleted_at"),
|
||||
deletedBy: text("deleted_by").references(() => admins.id, { onDelete: "restrict" }),
|
||||
}, (table) => [
|
||||
index("expenses_list_idx").on(table.deletedAt, table.status, table.paidAt),
|
||||
check("expenses_amount_ck", sql`${table.amountCents} > 0 and ${table.amountCents} <= 999999999999`),
|
||||
check("expenses_status_ck", sql`${table.status} in ('unreimbursed','reimbursed')`),
|
||||
check("expenses_version_ck", sql`${table.version} >= 1`),
|
||||
]);
|
||||
|
||||
export const attachments = sqliteTable("attachments", {
|
||||
id: text("id").primaryKey(),
|
||||
expenseId: text("expense_id").notNull().references(() => expenses.id, { onDelete: "cascade" }),
|
||||
kind: text("kind", { enum: ["payment_proof", "invoice"] }).notNull(),
|
||||
storagePath: text("storage_path").notNull(),
|
||||
originalName: text("original_name").notNull(),
|
||||
mimeType: text("mime_type").notNull(),
|
||||
sizeBytes: integer("size_bytes").notNull(),
|
||||
sha256: text("sha256").notNull(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
createdBy: text("created_by").notNull().references(() => admins.id, { onDelete: "restrict" }),
|
||||
}, (table) => [
|
||||
uniqueIndex("attachments_path_uq").on(table.storagePath),
|
||||
index("attachments_expense_idx").on(table.expenseId),
|
||||
check("attachments_kind_ck", sql`${table.kind} in ('payment_proof','invoice')`),
|
||||
check("attachments_size_ck", sql`${table.sizeBytes} > 0`),
|
||||
]);
|
||||
|
||||
export const auditEvents = sqliteTable("audit_events", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
occurredAt: integer("occurred_at").notNull(),
|
||||
requestId: text("request_id").notNull(),
|
||||
actorAdminId: text("actor_admin_id"),
|
||||
actorUsername: text("actor_username"),
|
||||
action: text("action").notNull(),
|
||||
targetType: text("target_type").notNull(),
|
||||
targetId: text("target_id"),
|
||||
outcome: text("outcome", { enum: ["success", "denied", "failure"] }).notNull(),
|
||||
beforeJson: text("before_json"),
|
||||
afterJson: text("after_json"),
|
||||
metadataJson: text("metadata_json"),
|
||||
}, (table) => [index("audit_time_idx").on(table.occurredAt), index("audit_target_idx").on(table.targetType, table.targetId)]);
|
||||
|
||||
export const systemSettings = sqliteTable("system_settings", {
|
||||
key: text("key").primaryKey(),
|
||||
value: text("value").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const exportJobs = sqliteTable("export_jobs", {
|
||||
id: text("id").primaryKey(),
|
||||
adminId: text("admin_id").notNull().references(() => admins.id, { onDelete: "cascade" }),
|
||||
sessionHash: text("session_hash").notNull(),
|
||||
status: text("status", { enum: ["queued", "building", "ready", "failed", "expired"] }).notNull(),
|
||||
selectionJson: text("selection_json").notNull(),
|
||||
snapshotJson: text("snapshot_json").notNull(),
|
||||
filePath: text("file_path"),
|
||||
fileName: text("file_name").notNull(),
|
||||
sizeBytes: integer("size_bytes"),
|
||||
sha256: text("sha256"),
|
||||
errorMessage: text("error_message"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
readyAt: integer("ready_at"),
|
||||
expiresAt: integer("expires_at").notNull(),
|
||||
}, (table) => [index("exports_expiry_idx").on(table.expiresAt), index("exports_session_idx").on(table.sessionHash)]);
|
||||
|
||||
export const loginAttempts = sqliteTable("login_attempts", {
|
||||
keyHash: text("key_hash").primaryKey(),
|
||||
windowStart: integer("window_start").notNull(),
|
||||
failures: integer("failures").notNull(),
|
||||
blockedUntil: integer("blocked_until"),
|
||||
});
|
||||
|
||||
export const fileDeletions = sqliteTable("file_deletions", {
|
||||
id: text("id").primaryKey(),
|
||||
storagePath: text("storage_path").notNull(),
|
||||
reason: text("reason").notNull(),
|
||||
status: text("status", { enum: ["pending", "complete", "failed"] }).notNull().default("pending"),
|
||||
attempts: integer("attempts").notNull().default(0),
|
||||
lastError: text("last_error"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
completedAt: integer("completed_at"),
|
||||
}, (table) => [index("file_deletions_status_idx").on(table.status)]);
|
||||
|
||||
export const updateJobs = sqliteTable("update_jobs", {
|
||||
id: text("id").primaryKey(),
|
||||
adminId: text("admin_id").references(() => admins.id, { onDelete: "set null" }),
|
||||
sessionHash: text("session_hash"),
|
||||
requestId: text("request_id"),
|
||||
status: text("status", { enum: ["queued", "downloading", "verifying", "staged", "backing_up", "applying", "completed", "failed", "cancelled"] }).notNull(),
|
||||
version: text("version").notNull(),
|
||||
platform: text("platform").notNull(),
|
||||
releaseUrl: text("release_url"),
|
||||
assetName: text("asset_name"),
|
||||
assetUrl: text("asset_url").notNull(),
|
||||
expectedSha256: text("expected_sha256"),
|
||||
actualSha256: text("actual_sha256"),
|
||||
downloadPath: text("download_path"),
|
||||
backupPath: text("backup_path"),
|
||||
sizeBytes: integer("size_bytes"),
|
||||
errorMessage: text("error_message"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
requestedAt: integer("requested_at"),
|
||||
startedAt: integer("started_at"),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
completedAt: integer("completed_at"),
|
||||
}, (table) => [
|
||||
index("update_jobs_status_idx").on(table.status, table.createdAt),
|
||||
index("update_jobs_admin_idx").on(table.adminId, table.createdAt),
|
||||
index("update_jobs_session_idx").on(table.sessionHash),
|
||||
]);
|
||||
Reference in New Issue
Block a user