Files
TallyNote/server/db/schema.ts
T
Qiufeng 9719429f4a
TallyNote release / linux-x64 (push) Failing after 2m41s
feat: add TallyNote local reimbursement ledger
2026-08-29 01:02:29 +08:00

161 lines
7.6 KiB
TypeScript

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),
]);