chore: commit all changes
This commit is contained in:
172
lib/server/repos/filings.ts
Normal file
172
lib/server/repos/filings.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { desc, eq } from 'drizzle-orm';
|
||||
import type { Filing } from '@/lib/types';
|
||||
import { db } from '@/lib/server/db';
|
||||
import { filing, filingLink } from '@/lib/server/db/schema';
|
||||
|
||||
type FilingRow = typeof filing.$inferSelect;
|
||||
|
||||
type FilingLinkInput = {
|
||||
link_type: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type UpsertFilingInput = {
|
||||
ticker: string;
|
||||
filing_type: Filing['filing_type'];
|
||||
filing_date: string;
|
||||
accession_number: string;
|
||||
cik: string;
|
||||
company_name: string;
|
||||
filing_url: string | null;
|
||||
submission_url: string | null;
|
||||
primary_document: string | null;
|
||||
metrics: Filing['metrics'];
|
||||
links: FilingLinkInput[];
|
||||
};
|
||||
|
||||
function toFiling(row: FilingRow): Filing {
|
||||
return {
|
||||
id: row.id,
|
||||
ticker: row.ticker,
|
||||
filing_type: row.filing_type,
|
||||
filing_date: row.filing_date,
|
||||
accession_number: row.accession_number,
|
||||
cik: row.cik,
|
||||
company_name: row.company_name,
|
||||
filing_url: row.filing_url,
|
||||
submission_url: row.submission_url,
|
||||
primary_document: row.primary_document,
|
||||
metrics: row.metrics ?? null,
|
||||
analysis: row.analysis ?? null,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
function dedupeLinks(links: FilingLinkInput[]) {
|
||||
const unique = new Map<string, FilingLinkInput>();
|
||||
|
||||
for (const link of links) {
|
||||
const url = link.url.trim();
|
||||
if (!url) {
|
||||
continue;
|
||||
}
|
||||
|
||||
unique.set(`${link.link_type}::${url}`, { ...link, url });
|
||||
}
|
||||
|
||||
return [...unique.values()];
|
||||
}
|
||||
|
||||
export async function listFilingsRecords(query?: { ticker?: string; limit?: number }) {
|
||||
const safeLimit = Math.min(Math.max(Math.trunc(query?.limit ?? 50), 1), 250);
|
||||
|
||||
const rows = query?.ticker
|
||||
? await db
|
||||
.select()
|
||||
.from(filing)
|
||||
.where(eq(filing.ticker, query.ticker))
|
||||
.orderBy(desc(filing.filing_date), desc(filing.updated_at))
|
||||
.limit(safeLimit)
|
||||
: await db
|
||||
.select()
|
||||
.from(filing)
|
||||
.orderBy(desc(filing.filing_date), desc(filing.updated_at))
|
||||
.limit(safeLimit);
|
||||
|
||||
return rows.map(toFiling);
|
||||
}
|
||||
|
||||
export async function getFilingByAccession(accessionNumber: string) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(filing)
|
||||
.where(eq(filing.accession_number, accessionNumber))
|
||||
.limit(1);
|
||||
|
||||
return row ? toFiling(row) : null;
|
||||
}
|
||||
|
||||
export async function upsertFilingsRecords(items: UpsertFilingInput[]) {
|
||||
let inserted = 0;
|
||||
let updated = 0;
|
||||
|
||||
for (const item of items) {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const existing = await getFilingByAccession(item.accession_number);
|
||||
|
||||
const [saved] = await db
|
||||
.insert(filing)
|
||||
.values({
|
||||
ticker: item.ticker,
|
||||
filing_type: item.filing_type,
|
||||
filing_date: item.filing_date,
|
||||
accession_number: item.accession_number,
|
||||
cik: item.cik,
|
||||
company_name: item.company_name,
|
||||
filing_url: item.filing_url,
|
||||
submission_url: item.submission_url,
|
||||
primary_document: item.primary_document,
|
||||
metrics: item.metrics,
|
||||
analysis: existing?.analysis ?? null,
|
||||
created_at: existing?.created_at ?? now,
|
||||
updated_at: now
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: filing.accession_number,
|
||||
set: {
|
||||
ticker: item.ticker,
|
||||
filing_type: item.filing_type,
|
||||
filing_date: item.filing_date,
|
||||
cik: item.cik,
|
||||
company_name: item.company_name,
|
||||
filing_url: item.filing_url,
|
||||
submission_url: item.submission_url,
|
||||
primary_document: item.primary_document,
|
||||
metrics: item.metrics,
|
||||
updated_at: now
|
||||
}
|
||||
})
|
||||
.returning({ id: filing.id });
|
||||
|
||||
const links = dedupeLinks(item.links);
|
||||
|
||||
for (const link of links) {
|
||||
await db
|
||||
.insert(filingLink)
|
||||
.values({
|
||||
filing_id: saved.id,
|
||||
link_type: link.link_type,
|
||||
url: link.url,
|
||||
source: 'sec',
|
||||
created_at: now
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
updated += 1;
|
||||
} else {
|
||||
inserted += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { inserted, updated };
|
||||
}
|
||||
|
||||
export async function saveFilingAnalysis(
|
||||
accessionNumber: string,
|
||||
analysis: Filing['analysis']
|
||||
) {
|
||||
const [updated] = await db
|
||||
.update(filing)
|
||||
.set({
|
||||
analysis,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.where(eq(filing.accession_number, accessionNumber))
|
||||
.returning();
|
||||
|
||||
return updated ? toFiling(updated) : null;
|
||||
}
|
||||
260
lib/server/repos/holdings.ts
Normal file
260
lib/server/repos/holdings.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import type { Holding } from '@/lib/types';
|
||||
import { recalculateHolding } from '@/lib/server/portfolio';
|
||||
import { db } from '@/lib/server/db';
|
||||
import { holding } from '@/lib/server/db/schema';
|
||||
|
||||
type HoldingRow = typeof holding.$inferSelect;
|
||||
|
||||
function toHolding(row: HoldingRow): Holding {
|
||||
return {
|
||||
id: row.id,
|
||||
user_id: row.user_id,
|
||||
ticker: row.ticker,
|
||||
shares: row.shares,
|
||||
avg_cost: row.avg_cost,
|
||||
current_price: row.current_price,
|
||||
market_value: row.market_value,
|
||||
gain_loss: row.gain_loss,
|
||||
gain_loss_pct: row.gain_loss_pct,
|
||||
last_price_at: row.last_price_at,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
function sortByMarketValueDesc(rows: Holding[]) {
|
||||
return rows.slice().sort((a, b) => Number(b.market_value) - Number(a.market_value));
|
||||
}
|
||||
|
||||
function normalizeHoldingInput(input: { ticker: string; shares: number; avgCost: number; currentPrice: number }) {
|
||||
return {
|
||||
ticker: input.ticker.trim().toUpperCase(),
|
||||
shares: input.shares.toFixed(6),
|
||||
avg_cost: input.avgCost.toFixed(6),
|
||||
current_price: input.currentPrice.toFixed(6)
|
||||
};
|
||||
}
|
||||
|
||||
export async function listUserHoldings(userId: string) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(holding)
|
||||
.where(eq(holding.user_id, userId));
|
||||
|
||||
return sortByMarketValueDesc(rows.map(toHolding));
|
||||
}
|
||||
|
||||
export async function upsertHoldingRecord(input: {
|
||||
userId: string;
|
||||
ticker: string;
|
||||
shares: number;
|
||||
avgCost: number;
|
||||
currentPrice?: number;
|
||||
}) {
|
||||
const ticker = input.ticker.trim().toUpperCase();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(holding)
|
||||
.where(and(eq(holding.user_id, input.userId), eq(holding.ticker, ticker)))
|
||||
.limit(1);
|
||||
|
||||
const currentPrice = Number.isFinite(input.currentPrice)
|
||||
? Number(input.currentPrice)
|
||||
: input.avgCost;
|
||||
|
||||
if (existing) {
|
||||
const normalized = normalizeHoldingInput({
|
||||
ticker,
|
||||
shares: input.shares,
|
||||
avgCost: input.avgCost,
|
||||
currentPrice
|
||||
});
|
||||
|
||||
const next = recalculateHolding({
|
||||
...toHolding(existing),
|
||||
...normalized,
|
||||
updated_at: now,
|
||||
last_price_at: now
|
||||
});
|
||||
|
||||
const [updated] = await db
|
||||
.update(holding)
|
||||
.set({
|
||||
ticker: next.ticker,
|
||||
shares: next.shares,
|
||||
avg_cost: next.avg_cost,
|
||||
current_price: next.current_price,
|
||||
market_value: next.market_value,
|
||||
gain_loss: next.gain_loss,
|
||||
gain_loss_pct: next.gain_loss_pct,
|
||||
updated_at: next.updated_at,
|
||||
last_price_at: next.last_price_at
|
||||
})
|
||||
.where(eq(holding.id, existing.id))
|
||||
.returning();
|
||||
|
||||
return toHolding(updated);
|
||||
}
|
||||
|
||||
const normalized = normalizeHoldingInput({
|
||||
ticker,
|
||||
shares: input.shares,
|
||||
avgCost: input.avgCost,
|
||||
currentPrice
|
||||
});
|
||||
|
||||
const createdBase: Holding = {
|
||||
id: 0,
|
||||
user_id: input.userId,
|
||||
ticker: normalized.ticker,
|
||||
shares: normalized.shares,
|
||||
avg_cost: normalized.avg_cost,
|
||||
current_price: normalized.current_price,
|
||||
market_value: '0',
|
||||
gain_loss: '0',
|
||||
gain_loss_pct: '0',
|
||||
last_price_at: now,
|
||||
created_at: now,
|
||||
updated_at: now
|
||||
};
|
||||
|
||||
const created = recalculateHolding(createdBase);
|
||||
|
||||
const [inserted] = await db
|
||||
.insert(holding)
|
||||
.values({
|
||||
user_id: created.user_id,
|
||||
ticker: created.ticker,
|
||||
shares: created.shares,
|
||||
avg_cost: created.avg_cost,
|
||||
current_price: created.current_price,
|
||||
market_value: created.market_value,
|
||||
gain_loss: created.gain_loss,
|
||||
gain_loss_pct: created.gain_loss_pct,
|
||||
last_price_at: created.last_price_at,
|
||||
created_at: created.created_at,
|
||||
updated_at: created.updated_at
|
||||
})
|
||||
.returning();
|
||||
|
||||
return toHolding(inserted);
|
||||
}
|
||||
|
||||
export async function updateHoldingByIdRecord(input: {
|
||||
userId: string;
|
||||
id: number;
|
||||
shares?: number;
|
||||
avgCost?: number;
|
||||
currentPrice?: number;
|
||||
}) {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(holding)
|
||||
.where(and(eq(holding.id, input.id), eq(holding.user_id, input.userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const current = toHolding(existing);
|
||||
const shares = Number.isFinite(input.shares)
|
||||
? Number(input.shares)
|
||||
: Number(current.shares);
|
||||
const avgCost = Number.isFinite(input.avgCost)
|
||||
? Number(input.avgCost)
|
||||
: Number(current.avg_cost);
|
||||
const currentPrice = Number.isFinite(input.currentPrice)
|
||||
? Number(input.currentPrice)
|
||||
: Number(current.current_price ?? current.avg_cost);
|
||||
|
||||
const next = recalculateHolding({
|
||||
...current,
|
||||
shares: shares.toFixed(6),
|
||||
avg_cost: avgCost.toFixed(6),
|
||||
current_price: currentPrice.toFixed(6),
|
||||
updated_at: new Date().toISOString(),
|
||||
last_price_at: new Date().toISOString()
|
||||
});
|
||||
|
||||
const [updated] = await db
|
||||
.update(holding)
|
||||
.set({
|
||||
shares: next.shares,
|
||||
avg_cost: next.avg_cost,
|
||||
current_price: next.current_price,
|
||||
market_value: next.market_value,
|
||||
gain_loss: next.gain_loss,
|
||||
gain_loss_pct: next.gain_loss_pct,
|
||||
updated_at: next.updated_at,
|
||||
last_price_at: next.last_price_at
|
||||
})
|
||||
.where(eq(holding.id, existing.id))
|
||||
.returning();
|
||||
|
||||
return toHolding(updated);
|
||||
}
|
||||
|
||||
export async function deleteHoldingByIdRecord(userId: string, id: number) {
|
||||
const rows = await db
|
||||
.delete(holding)
|
||||
.where(and(eq(holding.user_id, userId), eq(holding.id, id)))
|
||||
.returning({ id: holding.id });
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
export async function listHoldingsForPriceRefresh(userId: string) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(holding)
|
||||
.where(eq(holding.user_id, userId));
|
||||
|
||||
return rows.map(toHolding);
|
||||
}
|
||||
|
||||
export async function applyRefreshedPrices(
|
||||
userId: string,
|
||||
quotes: Map<string, number>,
|
||||
updateTime: string
|
||||
) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(holding)
|
||||
.where(eq(holding.user_id, userId));
|
||||
|
||||
let updatedCount = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const quote = quotes.get(row.ticker);
|
||||
if (quote === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const next = recalculateHolding({
|
||||
...toHolding(row),
|
||||
current_price: quote.toFixed(6),
|
||||
last_price_at: updateTime,
|
||||
updated_at: updateTime
|
||||
});
|
||||
|
||||
await db
|
||||
.update(holding)
|
||||
.set({
|
||||
current_price: next.current_price,
|
||||
market_value: next.market_value,
|
||||
gain_loss: next.gain_loss,
|
||||
gain_loss_pct: next.gain_loss_pct,
|
||||
last_price_at: next.last_price_at,
|
||||
updated_at: next.updated_at
|
||||
})
|
||||
.where(eq(holding.id, row.id));
|
||||
|
||||
updatedCount += 1;
|
||||
}
|
||||
|
||||
return updatedCount;
|
||||
}
|
||||
48
lib/server/repos/insights.ts
Normal file
48
lib/server/repos/insights.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { desc, eq } from 'drizzle-orm';
|
||||
import type { PortfolioInsight } from '@/lib/types';
|
||||
import { db } from '@/lib/server/db';
|
||||
import { portfolioInsight } from '@/lib/server/db/schema';
|
||||
|
||||
type InsightRow = typeof portfolioInsight.$inferSelect;
|
||||
|
||||
function toInsight(row: InsightRow): PortfolioInsight {
|
||||
return {
|
||||
id: row.id,
|
||||
user_id: row.user_id,
|
||||
provider: row.provider,
|
||||
model: row.model,
|
||||
content: row.content,
|
||||
created_at: row.created_at
|
||||
};
|
||||
}
|
||||
|
||||
export async function createPortfolioInsight(input: {
|
||||
userId: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
content: string;
|
||||
}) {
|
||||
const [created] = await db
|
||||
.insert(portfolioInsight)
|
||||
.values({
|
||||
user_id: input.userId,
|
||||
provider: input.provider,
|
||||
model: input.model,
|
||||
content: input.content,
|
||||
created_at: new Date().toISOString()
|
||||
})
|
||||
.returning();
|
||||
|
||||
return toInsight(created);
|
||||
}
|
||||
|
||||
export async function getLatestPortfolioInsight(userId: string) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(portfolioInsight)
|
||||
.where(eq(portfolioInsight.user_id, userId))
|
||||
.orderBy(desc(portfolioInsight.created_at))
|
||||
.limit(1);
|
||||
|
||||
return row ? toInsight(row) : null;
|
||||
}
|
||||
195
lib/server/repos/tasks.ts
Normal file
195
lib/server/repos/tasks.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { and, desc, eq, inArray, sql } from 'drizzle-orm';
|
||||
import type { Task, TaskStatus, TaskType } from '@/lib/types';
|
||||
import { db } from '@/lib/server/db';
|
||||
import { taskRun } from '@/lib/server/db/schema';
|
||||
|
||||
type TaskRow = typeof taskRun.$inferSelect;
|
||||
|
||||
type CreateTaskInput = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
task_type: TaskType;
|
||||
payload: Record<string, unknown>;
|
||||
priority: number;
|
||||
max_attempts: number;
|
||||
};
|
||||
|
||||
function toTask(row: TaskRow): Task {
|
||||
return {
|
||||
id: row.id,
|
||||
user_id: row.user_id,
|
||||
task_type: row.task_type,
|
||||
status: row.status,
|
||||
priority: row.priority,
|
||||
payload: row.payload,
|
||||
result: row.result,
|
||||
error: row.error,
|
||||
attempts: row.attempts,
|
||||
max_attempts: row.max_attempts,
|
||||
workflow_run_id: row.workflow_run_id,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
finished_at: row.finished_at
|
||||
};
|
||||
}
|
||||
|
||||
export async function createTaskRunRecord(input: CreateTaskInput) {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const [row] = await db
|
||||
.insert(taskRun)
|
||||
.values({
|
||||
id: input.id,
|
||||
user_id: input.user_id,
|
||||
task_type: input.task_type,
|
||||
status: 'queued',
|
||||
priority: input.priority,
|
||||
payload: input.payload,
|
||||
result: null,
|
||||
error: null,
|
||||
attempts: 0,
|
||||
max_attempts: input.max_attempts,
|
||||
workflow_run_id: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
finished_at: null
|
||||
})
|
||||
.returning();
|
||||
|
||||
return toTask(row);
|
||||
}
|
||||
|
||||
export async function setTaskWorkflowRunId(taskId: string, workflowRunId: string) {
|
||||
await db
|
||||
.update(taskRun)
|
||||
.set({
|
||||
workflow_run_id: workflowRunId,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.where(eq(taskRun.id, taskId));
|
||||
}
|
||||
|
||||
export async function getTaskByIdForUser(taskId: string, userId: string) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(taskRun)
|
||||
.where(and(eq(taskRun.id, taskId), eq(taskRun.user_id, userId)))
|
||||
.limit(1);
|
||||
|
||||
return row ? toTask(row) : null;
|
||||
}
|
||||
|
||||
export async function listRecentTasksForUser(
|
||||
userId: string,
|
||||
limit = 20,
|
||||
statuses?: TaskStatus[]
|
||||
) {
|
||||
const safeLimit = Math.min(Math.max(Math.trunc(limit), 1), 200);
|
||||
|
||||
const rows = statuses && statuses.length > 0
|
||||
? await db
|
||||
.select()
|
||||
.from(taskRun)
|
||||
.where(and(eq(taskRun.user_id, userId), inArray(taskRun.status, statuses)))
|
||||
.orderBy(desc(taskRun.created_at))
|
||||
.limit(safeLimit)
|
||||
: await db
|
||||
.select()
|
||||
.from(taskRun)
|
||||
.where(eq(taskRun.user_id, userId))
|
||||
.orderBy(desc(taskRun.created_at))
|
||||
.limit(safeLimit);
|
||||
|
||||
return rows.map(toTask);
|
||||
}
|
||||
|
||||
export async function countTasksByStatus() {
|
||||
const rows = await db
|
||||
.select({
|
||||
status: taskRun.status,
|
||||
count: sql<string>`count(*)`
|
||||
})
|
||||
.from(taskRun)
|
||||
.groupBy(taskRun.status);
|
||||
|
||||
const queue: Record<string, number> = {};
|
||||
|
||||
for (const row of rows) {
|
||||
queue[row.status] = Number(row.count);
|
||||
}
|
||||
|
||||
return queue;
|
||||
}
|
||||
|
||||
export async function claimQueuedTask(taskId: string) {
|
||||
const [row] = await db
|
||||
.update(taskRun)
|
||||
.set({
|
||||
status: 'running',
|
||||
attempts: sql`${taskRun.attempts} + 1`,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.where(and(eq(taskRun.id, taskId), eq(taskRun.status, 'queued')))
|
||||
.returning();
|
||||
|
||||
return row ? toTask(row) : null;
|
||||
}
|
||||
|
||||
export async function completeTask(taskId: string, result: Record<string, unknown>) {
|
||||
const [row] = await db
|
||||
.update(taskRun)
|
||||
.set({
|
||||
status: 'completed',
|
||||
result,
|
||||
error: null,
|
||||
updated_at: new Date().toISOString(),
|
||||
finished_at: new Date().toISOString()
|
||||
})
|
||||
.where(eq(taskRun.id, taskId))
|
||||
.returning();
|
||||
|
||||
return row ? toTask(row) : null;
|
||||
}
|
||||
|
||||
export async function markTaskFailure(taskId: string, reason: string) {
|
||||
const [current] = await db
|
||||
.select()
|
||||
.from(taskRun)
|
||||
.where(eq(taskRun.id, taskId))
|
||||
.limit(1);
|
||||
|
||||
if (!current) {
|
||||
return {
|
||||
task: null,
|
||||
shouldRetry: false
|
||||
};
|
||||
}
|
||||
|
||||
const shouldRetry = current.attempts < current.max_attempts;
|
||||
|
||||
const [updated] = await db
|
||||
.update(taskRun)
|
||||
.set({
|
||||
status: shouldRetry ? 'queued' : 'failed',
|
||||
error: reason,
|
||||
updated_at: new Date().toISOString(),
|
||||
finished_at: shouldRetry ? null : new Date().toISOString()
|
||||
})
|
||||
.where(eq(taskRun.id, taskId))
|
||||
.returning();
|
||||
|
||||
return {
|
||||
task: updated ? toTask(updated) : null,
|
||||
shouldRetry
|
||||
};
|
||||
}
|
||||
|
||||
export async function getTaskById(taskId: string) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(taskRun)
|
||||
.where(eq(taskRun.id, taskId))
|
||||
.limit(1);
|
||||
|
||||
return row ? toTask(row) : null;
|
||||
}
|
||||
63
lib/server/repos/watchlist.ts
Normal file
63
lib/server/repos/watchlist.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { and, desc, eq } from 'drizzle-orm';
|
||||
import type { WatchlistItem } from '@/lib/types';
|
||||
import { db } from '@/lib/server/db';
|
||||
import { watchlistItem } from '@/lib/server/db/schema';
|
||||
|
||||
type WatchlistRow = typeof watchlistItem.$inferSelect;
|
||||
|
||||
function toWatchlistItem(row: WatchlistRow): WatchlistItem {
|
||||
return {
|
||||
id: row.id,
|
||||
user_id: row.user_id,
|
||||
ticker: row.ticker,
|
||||
company_name: row.company_name,
|
||||
sector: row.sector,
|
||||
created_at: row.created_at
|
||||
};
|
||||
}
|
||||
|
||||
export async function listWatchlistItems(userId: string) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(watchlistItem)
|
||||
.where(eq(watchlistItem.user_id, userId))
|
||||
.orderBy(desc(watchlistItem.created_at));
|
||||
|
||||
return rows.map(toWatchlistItem);
|
||||
}
|
||||
|
||||
export async function upsertWatchlistItemRecord(input: {
|
||||
userId: string;
|
||||
ticker: string;
|
||||
companyName: string;
|
||||
sector?: string;
|
||||
}) {
|
||||
const [row] = await db
|
||||
.insert(watchlistItem)
|
||||
.values({
|
||||
user_id: input.userId,
|
||||
ticker: input.ticker,
|
||||
company_name: input.companyName,
|
||||
sector: input.sector?.trim() ? input.sector.trim() : null,
|
||||
created_at: new Date().toISOString()
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [watchlistItem.user_id, watchlistItem.ticker],
|
||||
set: {
|
||||
company_name: input.companyName,
|
||||
sector: input.sector?.trim() ? input.sector.trim() : null
|
||||
}
|
||||
})
|
||||
.returning();
|
||||
|
||||
return toWatchlistItem(row);
|
||||
}
|
||||
|
||||
export async function deleteWatchlistItemRecord(userId: string, id: number) {
|
||||
const removed = await db
|
||||
.delete(watchlistItem)
|
||||
.where(and(eq(watchlistItem.user_id, userId), eq(watchlistItem.id, id)))
|
||||
.returning({ id: watchlistItem.id });
|
||||
|
||||
return removed.length > 0;
|
||||
}
|
||||
Reference in New Issue
Block a user