feat: rebuild fiscal clone architecture and harden coolify deployment
This commit is contained in:
144
frontend/lib/api.ts
Normal file
144
frontend/lib/api.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import type {
|
||||
Filing,
|
||||
Holding,
|
||||
PortfolioInsight,
|
||||
PortfolioSummary,
|
||||
Task,
|
||||
User,
|
||||
WatchlistItem
|
||||
} from './types';
|
||||
import { resolveApiBaseURL } from './runtime-url';
|
||||
|
||||
const API_BASE = resolveApiBaseURL(process.env.NEXT_PUBLIC_API_URL);
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const headers = new Headers(init?.headers);
|
||||
if (!headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...init,
|
||||
credentials: 'include',
|
||||
headers,
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
const body = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok) {
|
||||
const message = typeof body?.error === 'string' ? body.error : `Request failed (${response.status})`;
|
||||
throw new ApiError(message, response.status);
|
||||
}
|
||||
|
||||
return body as T;
|
||||
}
|
||||
|
||||
export async function getMe() {
|
||||
return await apiFetch<{ user: User }>('/api/me');
|
||||
}
|
||||
|
||||
export async function listWatchlist() {
|
||||
return await apiFetch<{ items: WatchlistItem[] }>('/api/watchlist');
|
||||
}
|
||||
|
||||
export async function upsertWatchlistItem(input: { ticker: string; companyName: string; sector?: string }) {
|
||||
return await apiFetch<{ item: WatchlistItem }>('/api/watchlist', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteWatchlistItem(id: number) {
|
||||
return await apiFetch<{ success: boolean }>(`/api/watchlist/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
export async function listHoldings() {
|
||||
return await apiFetch<{ holdings: Holding[] }>('/api/portfolio/holdings');
|
||||
}
|
||||
|
||||
export async function getPortfolioSummary() {
|
||||
return await apiFetch<{ summary: PortfolioSummary }>('/api/portfolio/summary');
|
||||
}
|
||||
|
||||
export async function upsertHolding(input: {
|
||||
ticker: string;
|
||||
shares: number;
|
||||
avgCost: number;
|
||||
currentPrice?: number;
|
||||
}) {
|
||||
return await apiFetch<{ holding: Holding }>('/api/portfolio/holdings', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteHolding(id: number) {
|
||||
return await apiFetch<{ success: boolean }>(`/api/portfolio/holdings/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
export async function queuePriceRefresh() {
|
||||
return await apiFetch<{ task: Task }>('/api/portfolio/refresh-prices', {
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
|
||||
export async function queuePortfolioInsights() {
|
||||
return await apiFetch<{ task: Task }>('/api/portfolio/insights/generate', {
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
|
||||
export async function getLatestPortfolioInsight() {
|
||||
return await apiFetch<{ insight: PortfolioInsight | null }>('/api/portfolio/insights/latest');
|
||||
}
|
||||
|
||||
export async function listFilings(query?: { ticker?: string; limit?: number }) {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (query?.ticker) {
|
||||
params.set('ticker', query.ticker);
|
||||
}
|
||||
|
||||
if (query?.limit) {
|
||||
params.set('limit', String(query.limit));
|
||||
}
|
||||
|
||||
const suffix = params.size > 0 ? `?${params.toString()}` : '';
|
||||
return await apiFetch<{ filings: Filing[] }>(`/api/filings${suffix}`);
|
||||
}
|
||||
|
||||
export async function queueFilingSync(input: { ticker: string; limit?: number }) {
|
||||
return await apiFetch<{ task: Task }>('/api/filings/sync', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export async function queueFilingAnalysis(accessionNumber: string) {
|
||||
return await apiFetch<{ task: Task }>(`/api/filings/${accessionNumber}/analyze`, {
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
|
||||
export async function getTask(taskId: string) {
|
||||
return await apiFetch<{ task: Task }>(`/api/tasks/${taskId}`);
|
||||
}
|
||||
|
||||
export async function listRecentTasks(limit = 20) {
|
||||
return await apiFetch<{ tasks: Task[] }>(`/api/tasks?limit=${limit}`);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { authClient } from '@/lib/better-auth';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export async function requireAuth() {
|
||||
const { data: session } = await authClient.getSession();
|
||||
|
||||
if (!session || !session.user) {
|
||||
redirect('/auth/signin');
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import { createAuthClient } from 'better-auth/react';
|
||||
import { resolveApiBaseURL } from '@/lib/runtime-url';
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'
|
||||
baseURL: resolveApiBaseURL(process.env.NEXT_PUBLIC_API_URL),
|
||||
fetchOptions: {
|
||||
credentials: 'include'
|
||||
}
|
||||
});
|
||||
|
||||
export const { signIn, signUp, signOut, useSession } = authClient;
|
||||
|
||||
29
frontend/lib/format.ts
Normal file
29
frontend/lib/format.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export function asNumber(value: string | number | null | undefined) {
|
||||
if (value === null || value === undefined) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const parsed = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
export function formatCurrency(value: string | number | null | undefined) {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 2
|
||||
}).format(asNumber(value));
|
||||
}
|
||||
|
||||
export function formatPercent(value: string | number | null | undefined) {
|
||||
return `${asNumber(value).toFixed(2)}%`;
|
||||
}
|
||||
|
||||
export function formatCompactCurrency(value: string | number | null | undefined) {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 2
|
||||
}).format(asNumber(value));
|
||||
}
|
||||
45
frontend/lib/runtime-url.ts
Normal file
45
frontend/lib/runtime-url.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
function trimTrailingSlash(value: string) {
|
||||
return value.endsWith('/') ? value.slice(0, -1) : value;
|
||||
}
|
||||
|
||||
function isInternalHost(hostname: string) {
|
||||
return hostname === 'backend'
|
||||
|| hostname === 'localhost'
|
||||
|| hostname === '127.0.0.1'
|
||||
|| hostname.endsWith('.internal');
|
||||
}
|
||||
|
||||
function parseUrl(url: string) {
|
||||
try {
|
||||
return new URL(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveApiBaseURL(configuredBaseURL: string | undefined) {
|
||||
const fallbackLocal = 'http://localhost:3001';
|
||||
const candidate = configuredBaseURL?.trim() || fallbackLocal;
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return trimTrailingSlash(candidate);
|
||||
}
|
||||
|
||||
const parsed = parseUrl(candidate);
|
||||
|
||||
if (!parsed) {
|
||||
return `${window.location.origin}`;
|
||||
}
|
||||
|
||||
const browserHost = window.location.hostname;
|
||||
const browserIsLocal = browserHost === 'localhost' || browserHost === '127.0.0.1';
|
||||
|
||||
if (!browserIsLocal && isInternalHost(parsed.hostname)) {
|
||||
console.warn(
|
||||
`[fiscal] NEXT_PUBLIC_API_URL is internal (${parsed.hostname}); falling back to https://api.${browserHost}`
|
||||
);
|
||||
return trimTrailingSlash(`https://api.${browserHost}`);
|
||||
}
|
||||
|
||||
return trimTrailingSlash(parsed.toString());
|
||||
}
|
||||
90
frontend/lib/types.ts
Normal file
90
frontend/lib/types.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
export type User = {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string | null;
|
||||
image: string | null;
|
||||
};
|
||||
|
||||
export type WatchlistItem = {
|
||||
id: number;
|
||||
user_id: number;
|
||||
ticker: string;
|
||||
company_name: string;
|
||||
sector: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type Holding = {
|
||||
id: number;
|
||||
user_id: number;
|
||||
ticker: string;
|
||||
shares: string;
|
||||
avg_cost: string;
|
||||
current_price: string | null;
|
||||
market_value: string;
|
||||
gain_loss: string;
|
||||
gain_loss_pct: string;
|
||||
last_price_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type PortfolioSummary = {
|
||||
positions: number;
|
||||
total_value: string;
|
||||
total_gain_loss: string;
|
||||
total_cost_basis: string;
|
||||
avg_return_pct: string;
|
||||
};
|
||||
|
||||
export type Filing = {
|
||||
id: number;
|
||||
ticker: string;
|
||||
filing_type: '10-K' | '10-Q' | '8-K';
|
||||
filing_date: string;
|
||||
accession_number: string;
|
||||
cik: string;
|
||||
company_name: string;
|
||||
filing_url: string | null;
|
||||
metrics: {
|
||||
revenue: number | null;
|
||||
netIncome: number | null;
|
||||
totalAssets: number | null;
|
||||
cash: number | null;
|
||||
debt: number | null;
|
||||
} | null;
|
||||
analysis: {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
text?: string;
|
||||
legacyInsights?: string;
|
||||
} | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type TaskStatus = 'queued' | 'running' | 'completed' | 'failed';
|
||||
|
||||
export type Task = {
|
||||
id: string;
|
||||
task_type: 'sync_filings' | 'refresh_prices' | 'analyze_filing' | 'portfolio_insights';
|
||||
status: TaskStatus;
|
||||
priority: number;
|
||||
payload: Record<string, unknown>;
|
||||
result: Record<string, unknown> | null;
|
||||
error: string | null;
|
||||
attempts: number;
|
||||
max_attempts: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
finished_at: string | null;
|
||||
};
|
||||
|
||||
export type PortfolioInsight = {
|
||||
id: number;
|
||||
user_id: number;
|
||||
provider: string;
|
||||
model: string;
|
||||
content: string;
|
||||
created_at: string;
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
export function cn(...values: ClassValue[]) {
|
||||
return clsx(values);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user