feat(settings): add per-user fmp api key
This commit is contained in:
parent
adfadd824b
commit
39456473cb
@ -139,6 +139,16 @@ const getConfiguredFmpApiKey = (): string => {
|
|||||||
return apiKey;
|
return apiKey;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function getUserFmpApiKey(userId: string): Promise<string> {
|
||||||
|
const profile = await getCurrentUserProfile(userId);
|
||||||
|
const profileApiKey = String(profile.FMP_API_KEY || '').trim();
|
||||||
|
if (profileApiKey) {
|
||||||
|
return profileApiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getConfiguredFmpApiKey();
|
||||||
|
}
|
||||||
|
|
||||||
class MissingServiceConfigError extends Error {
|
class MissingServiceConfigError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
super(message);
|
super(message);
|
||||||
@ -2867,9 +2877,11 @@ RULES:
|
|||||||
// ── Research: company profile from FMP ───────────────────────────────
|
// ── Research: company profile from FMP ───────────────────────────────
|
||||||
this.app.get('/api/research/profile', this.requireAuth, async (req, res) => {
|
this.app.get('/api/research/profile', this.requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
const authUserId = (req as AuthenticatedRequest).authUserId;
|
||||||
|
if (!authUserId) return res.status(401).json({ error: 'Unauthorized' });
|
||||||
const symbol = String(req.query.symbol || '').trim().toUpperCase();
|
const symbol = String(req.query.symbol || '').trim().toUpperCase();
|
||||||
if (!symbol) return res.status(400).json({ error: 'symbol required' });
|
if (!symbol) return res.status(400).json({ error: 'symbol required' });
|
||||||
const apiKey = getConfiguredFmpApiKey();
|
const apiKey = await getUserFmpApiKey(authUserId);
|
||||||
const url = `https://financialmodelingprep.com/api/v3/profile/${symbol}?apikey=${apiKey}`;
|
const url = `https://financialmodelingprep.com/api/v3/profile/${symbol}?apikey=${apiKey}`;
|
||||||
const data = await fetchFmpJson(url) as any;
|
const data = await fetchFmpJson(url) as any;
|
||||||
res.json(Array.isArray(data) ? data[0] ?? {} : data);
|
res.json(Array.isArray(data) ? data[0] ?? {} : data);
|
||||||
@ -2887,9 +2899,11 @@ RULES:
|
|||||||
// ── Research: key metrics (P/E, ROE, etc.) from FMP ──────────────────
|
// ── Research: key metrics (P/E, ROE, etc.) from FMP ──────────────────
|
||||||
this.app.get('/api/research/metrics', this.requireAuth, async (req, res) => {
|
this.app.get('/api/research/metrics', this.requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
const authUserId = (req as AuthenticatedRequest).authUserId;
|
||||||
|
if (!authUserId) return res.status(401).json({ error: 'Unauthorized' });
|
||||||
const symbol = String(req.query.symbol || '').trim().toUpperCase();
|
const symbol = String(req.query.symbol || '').trim().toUpperCase();
|
||||||
if (!symbol) return res.status(400).json({ error: 'symbol required' });
|
if (!symbol) return res.status(400).json({ error: 'symbol required' });
|
||||||
const apiKey = getConfiguredFmpApiKey();
|
const apiKey = await getUserFmpApiKey(authUserId);
|
||||||
const url = `https://financialmodelingprep.com/api/v3/key-metrics/${symbol}?limit=4&apikey=${apiKey}`;
|
const url = `https://financialmodelingprep.com/api/v3/key-metrics/${symbol}?limit=4&apikey=${apiKey}`;
|
||||||
const data = await fetchFmpJson(url) as any;
|
const data = await fetchFmpJson(url) as any;
|
||||||
res.json(Array.isArray(data) ? data[0] ?? {} : data);
|
res.json(Array.isArray(data) ? data[0] ?? {} : data);
|
||||||
@ -2907,9 +2921,11 @@ RULES:
|
|||||||
// ── Research: earnings calendar from FMP ──────────────────────────────
|
// ── Research: earnings calendar from FMP ──────────────────────────────
|
||||||
this.app.get('/api/research/earnings', this.requireAuth, async (req, res) => {
|
this.app.get('/api/research/earnings', this.requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
const authUserId = (req as AuthenticatedRequest).authUserId;
|
||||||
|
if (!authUserId) return res.status(401).json({ error: 'Unauthorized' });
|
||||||
const symbol = String(req.query.symbol || '').trim().toUpperCase();
|
const symbol = String(req.query.symbol || '').trim().toUpperCase();
|
||||||
if (!symbol) return res.status(400).json({ error: 'symbol required' });
|
if (!symbol) return res.status(400).json({ error: 'symbol required' });
|
||||||
const apiKey = getConfiguredFmpApiKey();
|
const apiKey = await getUserFmpApiKey(authUserId);
|
||||||
const url = `https://financialmodelingprep.com/api/v3/historical/earning_calendar/${symbol}?limit=8&apikey=${apiKey}`;
|
const url = `https://financialmodelingprep.com/api/v3/historical/earning_calendar/${symbol}?limit=8&apikey=${apiKey}`;
|
||||||
const data = await fetchFmpJson(url) as any;
|
const data = await fetchFmpJson(url) as any;
|
||||||
res.json({ earnings: Array.isArray(data) ? data : [] });
|
res.json({ earnings: Array.isArray(data) ? data : [] });
|
||||||
@ -2927,7 +2943,11 @@ RULES:
|
|||||||
// ── Screener: stock screener from FMP ────────────────────────────────
|
// ── Screener: stock screener from FMP ────────────────────────────────
|
||||||
this.app.get('/api/screener', this.requireAuth, async (req, res) => {
|
this.app.get('/api/screener', this.requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const apiKey = getConfiguredFmpApiKey();
|
const authUserId = (req as AuthenticatedRequest).authUserId;
|
||||||
|
if (!authUserId) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
const apiKey = await getUserFmpApiKey(authUserId);
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
const sector = String(req.query.sector || '').trim();
|
const sector = String(req.query.sector || '').trim();
|
||||||
if (sector && sector !== 'All') {
|
if (sector && sector !== 'All') {
|
||||||
|
|||||||
@ -11,6 +11,7 @@ export interface TradingUserProfile {
|
|||||||
email: string;
|
email: string;
|
||||||
role: string;
|
role: string;
|
||||||
trade_enable: boolean;
|
trade_enable: boolean;
|
||||||
|
FMP_API_KEY?: string;
|
||||||
ALPACA_API_KEY?: string;
|
ALPACA_API_KEY?: string;
|
||||||
ALPACA_SECRET_KEY?: string;
|
ALPACA_SECRET_KEY?: string;
|
||||||
REAL_ALPACA_API_KEY?: string;
|
REAL_ALPACA_API_KEY?: string;
|
||||||
@ -132,6 +133,7 @@ function normalizeTradingUserProfile(
|
|||||||
email: String(row?.email || ''),
|
email: String(row?.email || ''),
|
||||||
role: String(row?.role || 'member'),
|
role: String(row?.role || 'member'),
|
||||||
trade_enable: Boolean(row?.trade_enable ?? true),
|
trade_enable: Boolean(row?.trade_enable ?? true),
|
||||||
|
FMP_API_KEY: row?.FMP_API_KEY,
|
||||||
ALPACA_API_KEY: row?.ALPACA_API_KEY,
|
ALPACA_API_KEY: row?.ALPACA_API_KEY,
|
||||||
ALPACA_SECRET_KEY: row?.ALPACA_SECRET_KEY,
|
ALPACA_SECRET_KEY: row?.ALPACA_SECRET_KEY,
|
||||||
REAL_ALPACA_API_KEY: row?.REAL_ALPACA_API_KEY,
|
REAL_ALPACA_API_KEY: row?.REAL_ALPACA_API_KEY,
|
||||||
@ -565,7 +567,7 @@ export async function getCurrentUserProfile(
|
|||||||
try {
|
try {
|
||||||
const { data, error } = await client
|
const { data, error } = await client
|
||||||
.from('users')
|
.from('users')
|
||||||
.select('user_id,first_name,last_name,email,role,trade_enable,ALPACA_API_KEY,ALPACA_SECRET_KEY,REAL_ALPACA_API_KEY,REAL_ALPACA_SECRET_KEY,drop_threshold_for_buy,gain_threshold_for_sell,market_poll_interval_in_seconds')
|
.select('user_id,first_name,last_name,email,role,trade_enable,FMP_API_KEY,ALPACA_API_KEY,ALPACA_SECRET_KEY,REAL_ALPACA_API_KEY,REAL_ALPACA_SECRET_KEY,drop_threshold_for_buy,gain_threshold_for_sell,market_poll_interval_in_seconds')
|
||||||
.eq('user_id', userId)
|
.eq('user_id', userId)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
@ -577,6 +579,7 @@ export async function getCurrentUserProfile(
|
|||||||
email: String((data as any).email || fallback.email || ''),
|
email: String((data as any).email || fallback.email || ''),
|
||||||
role: String((data as any).role || fallback.role || 'member'),
|
role: String((data as any).role || fallback.role || 'member'),
|
||||||
trade_enable: Boolean((data as any).trade_enable ?? fallback.trade_enable ?? true),
|
trade_enable: Boolean((data as any).trade_enable ?? fallback.trade_enable ?? true),
|
||||||
|
FMP_API_KEY: (data as any).FMP_API_KEY || fallback.FMP_API_KEY,
|
||||||
ALPACA_API_KEY: (data as any).ALPACA_API_KEY || fallback.ALPACA_API_KEY,
|
ALPACA_API_KEY: (data as any).ALPACA_API_KEY || fallback.ALPACA_API_KEY,
|
||||||
ALPACA_SECRET_KEY: (data as any).ALPACA_SECRET_KEY || fallback.ALPACA_SECRET_KEY,
|
ALPACA_SECRET_KEY: (data as any).ALPACA_SECRET_KEY || fallback.ALPACA_SECRET_KEY,
|
||||||
REAL_ALPACA_API_KEY: (data as any).REAL_ALPACA_API_KEY || fallback.REAL_ALPACA_API_KEY,
|
REAL_ALPACA_API_KEY: (data as any).REAL_ALPACA_API_KEY || fallback.REAL_ALPACA_API_KEY,
|
||||||
@ -600,6 +603,7 @@ export async function getCurrentUserProfile(
|
|||||||
email: String(fallback.email || ''),
|
email: String(fallback.email || ''),
|
||||||
role: String(fallback.role || 'member'),
|
role: String(fallback.role || 'member'),
|
||||||
trade_enable: Boolean(fallback.trade_enable ?? true),
|
trade_enable: Boolean(fallback.trade_enable ?? true),
|
||||||
|
FMP_API_KEY: fallback.FMP_API_KEY,
|
||||||
ALPACA_API_KEY: fallback.ALPACA_API_KEY,
|
ALPACA_API_KEY: fallback.ALPACA_API_KEY,
|
||||||
ALPACA_SECRET_KEY: fallback.ALPACA_SECRET_KEY,
|
ALPACA_SECRET_KEY: fallback.ALPACA_SECRET_KEY,
|
||||||
REAL_ALPACA_API_KEY: fallback.REAL_ALPACA_API_KEY,
|
REAL_ALPACA_API_KEY: fallback.REAL_ALPACA_API_KEY,
|
||||||
@ -648,6 +652,7 @@ export async function saveCurrentUserProfile(
|
|||||||
email: merged.email,
|
email: merged.email,
|
||||||
role: merged.role,
|
role: merged.role,
|
||||||
trade_enable: merged.trade_enable,
|
trade_enable: merged.trade_enable,
|
||||||
|
FMP_API_KEY: merged.FMP_API_KEY ?? null,
|
||||||
ALPACA_API_KEY: merged.ALPACA_API_KEY ?? null,
|
ALPACA_API_KEY: merged.ALPACA_API_KEY ?? null,
|
||||||
ALPACA_SECRET_KEY: merged.ALPACA_SECRET_KEY ?? null,
|
ALPACA_SECRET_KEY: merged.ALPACA_SECRET_KEY ?? null,
|
||||||
REAL_ALPACA_API_KEY: merged.REAL_ALPACA_API_KEY ?? null,
|
REAL_ALPACA_API_KEY: merged.REAL_ALPACA_API_KEY ?? null,
|
||||||
|
|||||||
@ -7,6 +7,7 @@ export interface UserConfig {
|
|||||||
first_name: string;
|
first_name: string;
|
||||||
last_name: string;
|
last_name: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
FMP_API_KEY: string;
|
||||||
ALPACA_API_KEY: string;
|
ALPACA_API_KEY: string;
|
||||||
ALPACA_SECRET_KEY: string;
|
ALPACA_SECRET_KEY: string;
|
||||||
REAL_ALPACA_API_KEY: string;
|
REAL_ALPACA_API_KEY: string;
|
||||||
|
|||||||
@ -27,6 +27,7 @@ function normalizeUser(row: Partial<UserConfig> | null | undefined): UserConfig
|
|||||||
first_name: String(row?.first_name || ''),
|
first_name: String(row?.first_name || ''),
|
||||||
last_name: String(row?.last_name || ''),
|
last_name: String(row?.last_name || ''),
|
||||||
email: String(row?.email || ''),
|
email: String(row?.email || ''),
|
||||||
|
FMP_API_KEY: String(row?.FMP_API_KEY || ''),
|
||||||
ALPACA_API_KEY: String(row?.ALPACA_API_KEY || ''),
|
ALPACA_API_KEY: String(row?.ALPACA_API_KEY || ''),
|
||||||
ALPACA_SECRET_KEY: String(row?.ALPACA_SECRET_KEY || ''),
|
ALPACA_SECRET_KEY: String(row?.ALPACA_SECRET_KEY || ''),
|
||||||
REAL_ALPACA_API_KEY: String(row?.REAL_ALPACA_API_KEY || ''),
|
REAL_ALPACA_API_KEY: String(row?.REAL_ALPACA_API_KEY || ''),
|
||||||
|
|||||||
@ -16,6 +16,9 @@ export interface UserProfile {
|
|||||||
email: string;
|
email: string;
|
||||||
role: string;
|
role: string;
|
||||||
|
|
||||||
|
// FMP Settings
|
||||||
|
FMP_API_KEY?: string;
|
||||||
|
|
||||||
// Alpaca Settings
|
// Alpaca Settings
|
||||||
ALPACA_API_KEY?: string;
|
ALPACA_API_KEY?: string;
|
||||||
ALPACA_SECRET_KEY?: string;
|
ALPACA_SECRET_KEY?: string;
|
||||||
|
|||||||
@ -22,6 +22,7 @@ export interface CurrentUserProfile {
|
|||||||
email: string;
|
email: string;
|
||||||
role: string;
|
role: string;
|
||||||
trade_enable: boolean;
|
trade_enable: boolean;
|
||||||
|
FMP_API_KEY?: string;
|
||||||
ALPACA_API_KEY?: string;
|
ALPACA_API_KEY?: string;
|
||||||
ALPACA_SECRET_KEY?: string;
|
ALPACA_SECRET_KEY?: string;
|
||||||
REAL_ALPACA_API_KEY?: string;
|
REAL_ALPACA_API_KEY?: string;
|
||||||
|
|||||||
@ -10,6 +10,7 @@ interface SettingsTabProps {
|
|||||||
export interface SettingsFormData {
|
export interface SettingsFormData {
|
||||||
first_name: string;
|
first_name: string;
|
||||||
last_name: string;
|
last_name: string;
|
||||||
|
FMP_API_KEY: string;
|
||||||
ALPACA_API_KEY: string;
|
ALPACA_API_KEY: string;
|
||||||
ALPACA_SECRET_KEY: string;
|
ALPACA_SECRET_KEY: string;
|
||||||
REAL_ALPACA_API_KEY: string;
|
REAL_ALPACA_API_KEY: string;
|
||||||
@ -23,6 +24,7 @@ export interface SettingsFormData {
|
|||||||
export const DEFAULT_SETTINGS_FORM_DATA: SettingsFormData = {
|
export const DEFAULT_SETTINGS_FORM_DATA: SettingsFormData = {
|
||||||
first_name: '',
|
first_name: '',
|
||||||
last_name: '',
|
last_name: '',
|
||||||
|
FMP_API_KEY: '',
|
||||||
ALPACA_API_KEY: '',
|
ALPACA_API_KEY: '',
|
||||||
ALPACA_SECRET_KEY: '',
|
ALPACA_SECRET_KEY: '',
|
||||||
REAL_ALPACA_API_KEY: '',
|
REAL_ALPACA_API_KEY: '',
|
||||||
@ -36,6 +38,7 @@ export const DEFAULT_SETTINGS_FORM_DATA: SettingsFormData = {
|
|||||||
export const mapProfileToFormData = (profile?: any): SettingsFormData => ({
|
export const mapProfileToFormData = (profile?: any): SettingsFormData => ({
|
||||||
first_name: profile?.first_name || '',
|
first_name: profile?.first_name || '',
|
||||||
last_name: profile?.last_name || '',
|
last_name: profile?.last_name || '',
|
||||||
|
FMP_API_KEY: profile?.FMP_API_KEY || '',
|
||||||
ALPACA_API_KEY: profile?.ALPACA_API_KEY || '',
|
ALPACA_API_KEY: profile?.ALPACA_API_KEY || '',
|
||||||
ALPACA_SECRET_KEY: profile?.ALPACA_SECRET_KEY || '',
|
ALPACA_SECRET_KEY: profile?.ALPACA_SECRET_KEY || '',
|
||||||
REAL_ALPACA_API_KEY: profile?.REAL_ALPACA_API_KEY || '',
|
REAL_ALPACA_API_KEY: profile?.REAL_ALPACA_API_KEY || '',
|
||||||
@ -92,6 +95,7 @@ export const SettingsTab = ({ botState }: SettingsTabProps) => {
|
|||||||
await updateCurrentUserProfile({
|
await updateCurrentUserProfile({
|
||||||
first_name: formData.first_name,
|
first_name: formData.first_name,
|
||||||
last_name: formData.last_name,
|
last_name: formData.last_name,
|
||||||
|
FMP_API_KEY: formData.FMP_API_KEY,
|
||||||
ALPACA_API_KEY: formData.ALPACA_API_KEY,
|
ALPACA_API_KEY: formData.ALPACA_API_KEY,
|
||||||
ALPACA_SECRET_KEY: formData.ALPACA_SECRET_KEY,
|
ALPACA_SECRET_KEY: formData.ALPACA_SECRET_KEY,
|
||||||
REAL_ALPACA_API_KEY: formData.REAL_ALPACA_API_KEY,
|
REAL_ALPACA_API_KEY: formData.REAL_ALPACA_API_KEY,
|
||||||
@ -214,6 +218,36 @@ export const SettingsTab = ({ botState }: SettingsTabProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Break */}
|
{/* Break */}
|
||||||
|
<div className="form-group full-width" style={{ marginTop: '10px', opacity: 0.5 }}>
|
||||||
|
<hr style={{ border: '0', borderTop: '1px solid rgba(255,255,255,0.1)' }} />
|
||||||
|
<small>Research & Screener Credentials</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group full-width">
|
||||||
|
<label>Financial Modeling Prep API Key</label>
|
||||||
|
<input
|
||||||
|
name="FMP_API_KEY"
|
||||||
|
value={formData.FMP_API_KEY}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={!editing}
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group full-width">
|
||||||
|
<div className="settings-hint">
|
||||||
|
<span>
|
||||||
|
Used for screener, company profile, key metrics, and earnings data.
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href="https://site.financialmodelingprep.com/developer/docs"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
Get your Financial Modeling Prep API key
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="form-group full-width" style={{ marginTop: '10px', opacity: 0.5 }}>
|
<div className="form-group full-width" style={{ marginTop: '10px', opacity: 0.5 }}>
|
||||||
<hr style={{ border: '0', borderTop: '1px solid rgba(255,255,255,0.1)' }} />
|
<hr style={{ border: '0', borderTop: '1px solid rgba(255,255,255,0.1)' }} />
|
||||||
<small>Alpaca Credentials</small>
|
<small>Alpaca Credentials</small>
|
||||||
@ -330,6 +364,21 @@ export const SettingsTab = ({ botState }: SettingsTabProps) => {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
}
|
}
|
||||||
|
.settings-hint {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
color: #9aa0a6;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.settings-hint a {
|
||||||
|
color: #00ff88;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.settings-hint a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user