This commit is contained in:
2026-07-23 17:48:44 -05:00
commit 5830cd7ab5
33 changed files with 6866 additions and 0 deletions

125
src/business-scan.ts Normal file
View File

@@ -0,0 +1,125 @@
import { readdir, stat } from 'node:fs/promises';
import path from 'node:path';
import { config } from './config.js';
import { query } from './db.js';
export type BusinessStatus = 'ACTIVE' | 'SOLD' | 'INACTIVE';
export interface ScanResult {
scanned: number;
inserted: number;
updated: number;
missing: number;
}
interface BusinessRow {
id: string;
name: string;
nas_path: string;
status: string;
}
/** The three status directories directly below NAS_ROOT. */
function statusDirs(): { status: BusinessStatus; dir: string }[] {
return [
{ status: 'ACTIVE', dir: config.nasDirActive },
{ status: 'SOLD', dir: config.nasDirSold },
{ status: 'INACTIVE', dir: config.nasDirInactive },
];
}
interface FoundBusiness {
name: string;
nasPath: string;
status: BusinessStatus;
}
/** Reads the three status directories; every immediate subdirectory is one business. */
async function readFromDisk(): Promise<FoundBusiness[]> {
const found: FoundBusiness[] = [];
for (const { status, dir } of statusDirs()) {
const full = path.join(config.nasRoot, dir);
let entries;
try {
entries = await readdir(full, { withFileTypes: true });
} catch (err) {
throw new Error(
`NAS directory not found or not readable: ${full} (${(err as Error).message})`,
);
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
found.push({ name: entry.name, nasPath: path.join(full, entry.name), status });
}
}
return found;
}
/**
* Scans the NAS and upserts the businesses. Idempotent: existing rows are matched
* by name and only touched when nas_path or status actually changed. Rows that no
* longer exist on disk are kept and only reported via the logger.
*/
export async function scanBusinesses(
log: { warn: (msg: string) => void } = console,
): Promise<ScanResult> {
const found = await readFromDisk();
const existing = await query<BusinessRow>('SELECT id, name, nas_path, status FROM business');
const byName = new Map(existing.map((row) => [row.name, row]));
let inserted = 0;
let updated = 0;
const seen = new Set<string>();
for (const business of found) {
seen.add(business.name);
const row = byName.get(business.name);
if (!row) {
await query(
'INSERT INTO business (name, nas_path, status) VALUES ($1, $2, $3)',
[business.name, business.nasPath, business.status],
);
inserted += 1;
} else if (row.nas_path !== business.nasPath || row.status !== business.status) {
await query('UPDATE business SET nas_path = $1, status = $2 WHERE id = $3', [
business.nasPath,
business.status,
row.id,
]);
updated += 1;
}
}
const missing = existing.filter((row) => !seen.has(row.name));
if (missing.length > 0) {
log.warn(
`[business-scan] ${missing.length} business(es) in the database no longer found on disk: ` +
missing.map((row) => row.name).join(', '),
);
}
return { scanned: found.length, inserted, updated, missing: missing.length };
}
export interface BusinessFile {
name: string;
size: number;
mtime: string;
}
/** Live, non-recursive listing of a business directory: PDFs first, then the rest, alphabetical. */
export async function listBusinessFiles(nasPath: string): Promise<BusinessFile[]> {
const entries = await readdir(nasPath, { withFileTypes: true });
const files: BusinessFile[] = [];
for (const entry of entries) {
if (!entry.isFile()) continue;
const info = await stat(path.join(nasPath, entry.name));
files.push({ name: entry.name, size: info.size, mtime: info.mtime.toISOString() });
}
const isPdf = (name: string) => name.toLowerCase().endsWith('.pdf');
return files.sort((a, b) => {
if (isPdf(a.name) !== isPdf(b.name)) return isPdf(a.name) ? -1 : 1;
return a.name.localeCompare(b.name);
});
}