fix: backfill symbol_demand for sidebar-added symbols + analyst ratings schema fix

- Add await ctx.cache.subscribe() to addSymbol mutation so symbols
  added via the sidebar get registered in symbol_demand and yfinance
  jobs are queued immediately
- Backfill PEP, WYNN, STZ, CELH into symbol_demand + adapter_queue
- Upgrade yahoo-finance2 3.15.3 -> 3.15.4 and pass validateResult:false
  to quoteSummary() to handle Yahoo schema drift
- Add error detail logging for analyst ratings schema failures
- Update .gitignore with common ignores
This commit is contained in:
Investor Flow Build
2026-07-23 18:02:24 -04:00
parent 5ef2b2f060
commit e262187c3c
204 changed files with 25014 additions and 2934 deletions
+38
View File
@@ -0,0 +1,38 @@
// AES-256-GCM encryption / decryption for credentials at rest in x_credentials table.
// Key is derived from IFLOW_CRYPTO_KEY env var (hex-encoded 32-byte key, 64 hex chars)
// or falls back to a dev-only constant when unset — never used outside local dev.
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
const KEY_HEX = process.env.IFLOW_CRYPTO_KEY ??
'00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff';
const KEY = Buffer.from(KEY_HEX.slice(0, 64), 'hex');
if (KEY.length !== 32) {
throw new Error('IFLOW_CRYPTO_KEY must be a 64-character hex string (32 bytes).');
}
function encrypt(text: string): string {
const iv = randomBytes(12); // GCM recommends 96-bit IVs
const cipher = createCipheriv('aes-256-gcm', KEY, iv);
const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
// Format: iv (12 bytes hex) + tag (16 bytes hex) + ciphertext
return `${iv.toString('hex')}:${tag.toString('hex')}:${encrypted.toString('base64')}`;
}
function decrypt(cipherText: string): string {
const parts = cipherText.split(':');
if (parts.length !== 3) throw new Error('Invalid encrypted payload format.');
const iv = Buffer.from(parts[0], 'hex');
const tag = Buffer.from(parts[1], 'hex');
const encrypted = Buffer.from(parts[2], 'base64');
const decipher = createDecipheriv('aes-256-gcm', KEY, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
}
const cryptoModule = ((text: string): string => encrypt(text)) as typeof encrypt & { decrypt: typeof decrypt };
cryptoModule.decrypt = decrypt;
export default cryptoModule;
+78
View File
@@ -0,0 +1,78 @@
// Server self-restart helper (admin console "Restart servers" action).
// The backend is a single long-lived node process, so we restart it by either
// exiting (when run under `node --watch`, the watcher respawns us) or by
// re-executing a detached copy of ourselves. The frontend (Next dev) is a
// separate process tree; we restart it best-effort via child_process so the
// admin action can bring both halves of the app back up.
import { spawn } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url)); // app/server/src/lib
const APP_DIR = path.resolve(HERE, '..', '..', '..'); // app/
const NEXT_BIN = path.join(APP_DIR, 'node_modules', '.bin', 'next');
export type RestartTarget = 'backend' | 'frontend' | 'all';
function restartBackend(): void {
// Under `node --watch` the watcher respawn the child when it exits.
if (process.argv.includes('--watch')) {
process.exit(0);
return;
}
// Otherwise re-exec a detached copy of the current process, then exit.
const child = spawn(process.execPath, process.argv.slice(1), {
env: process.env,
cwd: process.cwd(),
stdio: 'inherit',
detached: true,
});
child.unref();
process.exit(0);
}
function restartFrontend(): void {
try {
spawn('pkill', ['-f', 'next dev'], { stdio: 'ignore' });
const child = spawn(
process.execPath,
[NEXT_BIN, 'dev', '--port', '3000'],
{
env: { ...process.env, HOSTNAME: '0.0.0.0', NODE_ENV: process.env.NODE_ENV ?? 'development' },
cwd: APP_DIR,
stdio: 'ignore',
detached: true,
},
);
child.on('error', (e) => console.error('[restart] frontend spawn error:', e));
child.on('exit', (code, sig) => {
if (code !== 0) console.error(`[restart] frontend exited code=${code} signal=${sig}`);
});
child.unref();
} catch (e) {
console.error('[restart] failed to restart frontend:', e);
}
}
// Respond first, then restart after a short beat so the HTTP 200 is flushed
// before the process that owns the socket goes away.
export function restartServers(target: RestartTarget): { backend: string; frontend: string } {
const status = { backend: 'skipped', frontend: 'skipped' };
const restartable = target === 'backend' || target === 'all' || target === 'frontend';
if (!restartable) return status;
setTimeout(() => {
// Spawn the frontend child first (it is detached and survives our exit),
// then restart the backend last so this process is the one that dies.
if (target === 'frontend' || target === 'all') {
status.frontend = 'restarting';
restartFrontend();
}
if (target === 'backend' || target === 'all') {
status.backend = 'restarting';
restartBackend();
}
}, 600);
return status;
}