SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
27.6 KB · 488 lines typescript
Raw Blame History
1'use server';23import { redirect } from 'next/navigation';4import { revalidatePath } from 'next/cache';5import { z } from 'zod';6import { and, eq, inArray, isNull, sql } from '@/lib/db';7import { db, collections, collectionItems, assets, assetStats, assetVariants, watchlistItems, alerts, notifications, savedSearches, priceTargets } from '@/lib/db';8import { newId, slugify } from '@rareindex/shared';9import { requireUser } from '@/lib/auth/session';10import { fieldErrorsFrom, type ActionState } from '@/lib/auth/state';11import { toUsdAt } from './fx';12import { getCollection, getOrCreateWatchlist } from './queries';13import { parseCsv } from './portfolio';14import { storeUpload, deleteUpload } from './uploads';1516const fail = (error: string, extra: Partial<ActionState> = {}): ActionState => ({ ok: false, error, ...extra });17const CURRENCIES = ['USD', 'CAD', 'EUR', 'GBP', 'JPY', 'CHF', 'AUD'] as const;1819// ---------------------------------------------------------------- collections2021const collectionSchema = z.object({22  name: z.string().trim().min(1, 'Name is required').max(80),23  description: z.string().trim().max(500).optional().or(z.literal('')),24  kind: z.enum(['collection', 'wishlist', 'vault', 'sold']).default('collection'),25  budgetUsd: z.coerce.number().nonnegative().optional().nullable(),26  color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional().or(z.literal('')),27});2829export async function createCollectionAction(_prev: ActionState, formData: FormData): Promise<ActionState> {30  const u = await requireUser('/collections');31  const parsed = collectionSchema.safeParse({ name: formData.get('name'), description: formData.get('description') ?? '', kind: formData.get('kind') ?? 'collection', budgetUsd: formData.get('budgetUsd') ? formData.get('budgetUsd') : null, color: formData.get('color') ?? '' });32  if (!parsed.success) return fail('Check the fields.', { fieldErrors: fieldErrorsFrom(parsed.error.issues) });33  const id = newId('collection');34  await db().insert(collections).values({ id, userId: u.id, name: parsed.data.name, description: parsed.data.description || null, kind: parsed.data.kind, budgetUsd: parsed.data.budgetUsd ?? null, color: parsed.data.color || null });35  revalidatePath('/collections');36  redirect(`/collections/${id}`);37}3839export async function updateCollectionAction(_prev: ActionState, formData: FormData): Promise<ActionState> {40  const u = await requireUser('/collections');41  const id = String(formData.get('collectionId') ?? '');42  const col = await getCollection(u.id, id);43  if (!col) return fail('Collection not found.');44  const parsed = collectionSchema.safeParse({ name: formData.get('name'), description: formData.get('description') ?? '', kind: formData.get('kind') ?? col.kind, budgetUsd: formData.get('budgetUsd') ? formData.get('budgetUsd') : null, color: formData.get('color') ?? '' });45  if (!parsed.success) return fail('Check the fields.', { fieldErrors: fieldErrorsFrom(parsed.error.issues) });46  await db().update(collections).set({ name: parsed.data.name, description: parsed.data.description || null, kind: parsed.data.kind, budgetUsd: parsed.data.budgetUsd ?? null, color: parsed.data.color || null, updatedAt: new Date() }).where(eq(collections.id, id));47  revalidatePath(`/collections/${id}`);48  return { ok: true, message: 'Collection updated.' };49}5051export async function toggleCollectionPublicAction(formData: FormData): Promise<void> {52  const u = await requireUser('/collections');53  const id = String(formData.get('collectionId') ?? '');54  const col = await getCollection(u.id, id);55  if (!col) return;56  const makePublic = formData.get('public') === 'on';57  let publicSlug = col.publicSlug;58  if (makePublic && !publicSlug) publicSlug = `${slugify(col.name) || 'collection'}-${id.slice(-6)}`;59  await db().update(collections).set({ isPublic: makePublic, publicSlug, updatedAt: new Date() }).where(eq(collections.id, id));60  revalidatePath(`/collections/${id}`);61}6263export async function deleteCollectionAction(formData: FormData): Promise<void> {64  const u = await requireUser('/collections');65  const id = String(formData.get('collectionId') ?? '');66  const col = await getCollection(u.id, id);67  if (!col) return;68  await db().delete(collectionItems).where(eq(collectionItems.collectionId, id));69  await db().delete(collections).where(eq(collections.id, id));70  revalidatePath('/collections');71  redirect('/collections');72}7374// ---------------------------------------------------------------- items7576const itemSchema = z.object({77  assetId: z.string().min(1, 'Pick an asset'),78  variantId: z.string().optional().or(z.literal('')),79  quantity: z.coerce.number().int().min(1).max(10_000).default(1),80  acquiredAt: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().or(z.literal('')),81  purchasePrice: z.coerce.number().nonnegative().optional().nullable(),82  purchaseCurrency: z.enum(CURRENCIES).default('USD'),83  source: z.string().trim().max(120).optional().or(z.literal('')),84  grader: z.string().trim().max(20).optional().or(z.literal('')),85  grade: z.string().trim().max(12).optional().or(z.literal('')),86  certificationNumber: z.string().trim().max(40).optional().or(z.literal('')),87  serial: z.string().trim().max(60).optional().or(z.literal('')),88  condition: z.string().trim().max(40).optional().or(z.literal('')),89  notes: z.string().trim().max(2000).optional().or(z.literal('')),90  tags: z.string().trim().max(200).optional().or(z.literal('')),91  manualValueUsd: z.coerce.number().nonnegative().optional().nullable(),92});9394function tagsFrom(s: string | undefined): string[] {95  return [...new Set((s ?? '').split(',').map((t) => t.trim().toLowerCase()).filter(Boolean))].slice(0, 12);96}9798async function parseItem(formData: FormData) {99  return itemSchema.safeParse({100    assetId: formData.get('assetId'),101    variantId: formData.get('variantId') ?? '',102    quantity: formData.get('quantity') || 1,103    acquiredAt: formData.get('acquiredAt') ?? '',104    purchasePrice: formData.get('purchasePrice') ? formData.get('purchasePrice') : null,105    purchaseCurrency: formData.get('purchaseCurrency') || 'USD',106    source: formData.get('source') ?? '',107    grader: formData.get('grader') ?? '',108    grade: formData.get('grade') ?? '',109    certificationNumber: formData.get('certificationNumber') ?? '',110    serial: formData.get('serial') ?? '',111    condition: formData.get('condition') ?? '',112    notes: formData.get('notes') ?? '',113    tags: formData.get('tags') ?? '',114    manualValueUsd: formData.get('manualValueUsd') ? formData.get('manualValueUsd') : null,115  });116}117118async function resolveVariant(assetId: string, variantId: string | undefined, grader: string | undefined, grade: string | undefined): Promise<{ variantId: string | null; grader: string | null; grade: string | null }> {119  if (variantId) {120    const v = await db().select().from(assetVariants).where(and(eq(assetVariants.id, variantId), eq(assetVariants.assetId, assetId))).limit(1);121    if (v[0]) return { variantId: v[0].id, grader: v[0].grader ?? (grader || null), grade: v[0].grade ?? (grade || null) };122  }123  if (grader && grade) {124    const v = await db().select().from(assetVariants).where(and(eq(assetVariants.assetId, assetId), eq(assetVariants.grader, grader.toLowerCase()), eq(assetVariants.grade, grade))).limit(1);125    if (v[0]) return { variantId: v[0].id, grader: v[0].grader, grade: v[0].grade };126  }127  return { variantId: null, grader: grader ? grader.toLowerCase() : null, grade: grade || null };128}129130export async function addItemAction(_prev: ActionState, formData: FormData): Promise<ActionState> {131  const u = await requireUser('/collections');132  const collectionId = String(formData.get('collectionId') ?? '');133  const col = await getCollection(u.id, collectionId);134  if (!col) return fail('Collection not found.');135  const parsed = await parseItem(formData);136  if (!parsed.success) return fail('Check the fields.', { fieldErrors: fieldErrorsFrom(parsed.error.issues) });137  const d = parsed.data;138  const asset = await db().select({ id: assets.id }).from(assets).where(eq(assets.id, d.assetId)).limit(1);139  if (!asset[0]) return fail('That asset does not exist.', { fieldErrors: { assetId: 'Unknown asset' } });140  const v = await resolveVariant(d.assetId, d.variantId || undefined, d.grader || undefined, d.grade || undefined);141  const fx = d.purchasePrice !== null && d.purchasePrice !== undefined ? await toUsdAt(d.purchasePrice, d.purchaseCurrency, d.acquiredAt || null) : null;142  const notes: string[] = [];143  if (d.purchasePrice && d.purchaseCurrency !== 'USD' && !fx) notes.push(`No ${d.purchaseCurrency} FX rate is loaded yet; the USD cost basis will be filled when rates arrive.`);144  const id = newId('collectionItem');145  await db().insert(collectionItems).values({146    id,147    collectionId,148    assetId: d.assetId,149    variantId: v.variantId,150    quantity: d.quantity,151    acquiredAt: d.acquiredAt || null,152    purchasePrice: d.purchasePrice ?? null,153    purchaseCurrency: d.purchasePrice !== null && d.purchasePrice !== undefined ? d.purchaseCurrency : null,154    purchasePriceUsd: fx?.usd ?? null,155    source: d.source || null,156    grader: v.grader,157    grade: v.grade,158    certificationNumber: d.certificationNumber || null,159    serial: d.serial || null,160    condition: d.condition || null,161    notes: d.notes || null,162    tags: tagsFrom(d.tags),163    manualValueUsd: d.manualValueUsd ?? null,164  });165  await db().update(collections).set({ updatedAt: new Date() }).where(eq(collections.id, collectionId));166  revalidatePath(`/collections/${collectionId}`);167  revalidatePath('/collections');168  return { ok: true, message: notes.length ? `Item added. ${notes.join(' ')}` : 'Item added to the collection.', data: { itemId: id } };169}170171export async function updateItemAction(_prev: ActionState, formData: FormData): Promise<ActionState> {172  const u = await requireUser('/collections');173  const itemId = String(formData.get('itemId') ?? '');174  const row = await ownedItem(u.id, itemId);175  if (!row) return fail('Item not found.');176  const parsed = await parseItem(formData);177  if (!parsed.success) return fail('Check the fields.', { fieldErrors: fieldErrorsFrom(parsed.error.issues) });178  const d = parsed.data;179  const v = await resolveVariant(row.item.assetId, d.variantId || undefined, d.grader || undefined, d.grade || undefined);180  const fx = d.purchasePrice !== null && d.purchasePrice !== undefined ? await toUsdAt(d.purchasePrice, d.purchaseCurrency, d.acquiredAt || null) : null;181  const soldAt = String(formData.get('soldAt') ?? '');182  const soldPrice = formData.get('soldPriceUsd') ? Number(formData.get('soldPriceUsd')) : null;183  await db()184    .update(collectionItems)185    .set({186      variantId: v.variantId,187      quantity: d.quantity,188      acquiredAt: d.acquiredAt || null,189      purchasePrice: d.purchasePrice ?? null,190      purchaseCurrency: d.purchasePrice !== null && d.purchasePrice !== undefined ? d.purchaseCurrency : null,191      purchasePriceUsd: fx?.usd ?? null,192      source: d.source || null,193      grader: v.grader,194      grade: v.grade,195      certificationNumber: d.certificationNumber || null,196      serial: d.serial || null,197      condition: d.condition || null,198      notes: d.notes || null,199      tags: tagsFrom(d.tags),200      manualValueUsd: d.manualValueUsd ?? null,201      soldAt: /^\d{4}-\d{2}-\d{2}$/.test(soldAt) ? soldAt : null,202      soldPriceUsd: soldPrice !== null && Number.isFinite(soldPrice) && soldPrice >= 0 ? soldPrice : null,203      updatedAt: new Date(),204    })205    .where(eq(collectionItems.id, itemId));206  revalidatePath(`/collections/${row.item.collectionId}`);207  return { ok: true, message: 'Item saved.' };208}209210async function ownedItem(userId: string, itemId: string) {211  const rows = await db().select({ item: collectionItems, col: collections }).from(collectionItems).innerJoin(collections, eq(collections.id, collectionItems.collectionId)).where(and(eq(collectionItems.id, itemId), eq(collections.userId, userId))).limit(1);212  return rows[0] ?? null;213}214215export async function deleteItemAction(formData: FormData): Promise<void> {216  const u = await requireUser('/collections');217  const itemId = String(formData.get('itemId') ?? '');218  const row = await ownedItem(u.id, itemId);219  if (!row) return;220  for (const p of row.item.photos ?? []) {221    const m = p.match(/\/api\/account\/uploads\/(img_[0-9a-z]+)/);222    if (m) await deleteUpload(u.id, m[1]!);223  }224  await db().delete(collectionItems).where(eq(collectionItems.id, itemId));225  revalidatePath(`/collections/${row.item.collectionId}`);226  redirect(`/collections/${row.item.collectionId}`);227}228229export async function moveItemAction(formData: FormData): Promise<void> {230  const u = await requireUser('/collections');231  const itemId = String(formData.get('itemId') ?? '');232  const to = String(formData.get('toCollectionId') ?? '');233  const row = await ownedItem(u.id, itemId);234  const dest = await getCollection(u.id, to);235  if (!row || !dest) return;236  await db().update(collectionItems).set({ collectionId: to, updatedAt: new Date() }).where(eq(collectionItems.id, itemId));237  revalidatePath(`/collections/${row.item.collectionId}`);238  revalidatePath(`/collections/${to}`);239}240241export async function addItemPhotoAction(_prev: ActionState, formData: FormData): Promise<ActionState> {242  const u = await requireUser('/collections');243  const itemId = String(formData.get('itemId') ?? '');244  const row = await ownedItem(u.id, itemId);245  if (!row) return fail('Item not found.');246  const files = formData.getAll('files').filter((f): f is File => f instanceof File && f.size > 0);247  if (!files.length) return fail('Choose at least one image.');248  if ((row.item.photos?.length ?? 0) + files.length > 12) return fail('At most 12 photos per item.');249  const urls: string[] = [];250  for (const f of files.slice(0, 12)) {251    const res = await storeUpload(u.id, 'item_photo', f);252    if ('error' in res) return fail(res.error);253    urls.push(res.url);254  }255  await db().update(collectionItems).set({ photos: [...(row.item.photos ?? []), ...urls], updatedAt: new Date() }).where(eq(collectionItems.id, itemId));256  revalidatePath(`/collections/${row.item.collectionId}/items/${itemId}`);257  return { ok: true, message: `${urls.length} photo${urls.length === 1 ? '' : 's'} added.` };258}259260export async function removeItemPhotoAction(formData: FormData): Promise<void> {261  const u = await requireUser('/collections');262  const itemId = String(formData.get('itemId') ?? '');263  const url = String(formData.get('url') ?? '');264  const row = await ownedItem(u.id, itemId);265  if (!row) return;266  const m = url.match(/\/api\/account\/uploads\/(img_[0-9a-z]+)/);267  if (m) await deleteUpload(u.id, m[1]!);268  await db().update(collectionItems).set({ photos: (row.item.photos ?? []).filter((p) => p !== url), updatedAt: new Date() }).where(eq(collectionItems.id, itemId));269  revalidatePath(`/collections/${row.item.collectionId}/items/${itemId}`);270}271272// ---------------------------------------------------------------- CSV import273274export async function importCsvAction(_prev: ActionState, formData: FormData): Promise<ActionState> {275  const u = await requireUser('/collections');276  const collectionId = String(formData.get('collectionId') ?? '');277  const col = await getCollection(u.id, collectionId);278  if (!col) return fail('Collection not found.');279  const file = formData.get('file');280  if (!(file instanceof File) || file.size === 0) return fail('Choose a CSV file.');281  if (file.size > 2 * 1024 * 1024) return fail('CSV too large (max 2 MB).');282  const rows = parseCsv(await file.text());283  if (!rows.length) return fail('No rows found. Use the template: asset_id or asset_slug, quantity, acquired_at, purchase_price, currency, grader, grade, cert, source, notes, tags.');284  let added = 0;285  const errors: string[] = [];286  for (const [i, r] of rows.slice(0, 500).entries()) {287    const ref = r.asset_id || r.asset_slug || r.asset || '';288    if (!ref) {289      errors.push(`row ${i + 2}: missing asset_id/asset_slug`);290      continue;291    }292    const a = await db().select({ id: assets.id }).from(assets).where(ref.startsWith('rare_') ? eq(assets.id, ref) : eq(assets.slug, ref)).limit(1);293    if (!a[0]) {294      errors.push(`row ${i + 2}: asset "${ref}" not found`);295      continue;296    }297    const price = r.purchase_price ? Number(String(r.purchase_price).replace(/[^0-9.]/g, '')) : null;298    const currency = (r.currency || 'USD').toUpperCase();299    const acquiredAt = /^\d{4}-\d{2}-\d{2}$/.test(r.acquired_at ?? '') ? r.acquired_at! : null;300    const fx = price !== null && Number.isFinite(price) ? await toUsdAt(price, (CURRENCIES as readonly string[]).includes(currency) ? currency : 'USD', acquiredAt) : null;301    const v = await resolveVariant(a[0].id, undefined, r.grader || undefined, r.grade || undefined);302    await db().insert(collectionItems).values({303      id: newId('collectionItem'),304      collectionId,305      assetId: a[0].id,306      variantId: v.variantId,307      quantity: Math.max(1, Number(r.quantity) || 1),308      acquiredAt,309      purchasePrice: price !== null && Number.isFinite(price) ? price : null,310      purchaseCurrency: price !== null ? currency : null,311      purchasePriceUsd: fx?.usd ?? null,312      source: r.source || null,313      grader: v.grader,314      grade: v.grade,315      certificationNumber: r.cert || r.certification_number || null,316      serial: r.serial || null,317      notes: r.notes || null,318      tags: tagsFrom(r.tags),319    });320    added++;321  }322  revalidatePath(`/collections/${collectionId}`);323  return { ok: true, message: `${added} item${added === 1 ? '' : 's'} imported${errors.length ? `; ${errors.length} row${errors.length === 1 ? '' : 's'} skipped` : ''}.`, data: { errors: errors.slice(0, 20) } };324}325326// ---------------------------------------------------------------- watchlist327328export async function toggleWatchAction(formData: FormData): Promise<void> {329  const u = await requireUser('/watchlist');330  const targetType = String(formData.get('targetType') ?? 'asset');331  const targetId = String(formData.get('targetId') ?? '');332  const back = String(formData.get('back') ?? '/watchlist');333  if (!targetId || !['asset', 'category', 'brand', 'set', 'source', 'auction'].includes(targetType)) return;334  const wl = await getOrCreateWatchlist(u.id);335  const existing = await db().select({ id: watchlistItems.id }).from(watchlistItems).where(and(eq(watchlistItems.watchlistId, wl.id), eq(watchlistItems.targetType, targetType), eq(watchlistItems.targetId, targetId))).limit(1);336  if (existing[0]) {337    await db().delete(watchlistItems).where(eq(watchlistItems.id, existing[0].id));338    if (targetType === 'asset') await db().update(assetStats).set({ watchers: sql`greatest(${assetStats.watchers} - 1, 0)` }).where(eq(assetStats.assetId, targetId));339  } else {340    let baseline: number | null = null;341    let label: string | null = null;342    if (targetType === 'asset') {343      const s = await db().select({ riv: assetStats.rivUsd, title: assets.title }).from(assets).leftJoin(assetStats, eq(assetStats.assetId, assets.id)).where(eq(assets.id, targetId)).limit(1);344      baseline = s[0]?.riv ?? null;345      label = s[0]?.title ?? null;346      await db().execute(sql`insert into asset_stats (asset_id, watchers) values (${targetId}, 1) on conflict (asset_id) do update set watchers = asset_stats.watchers + 1`);347    }348    await db().insert(watchlistItems).values({ id: newId('event'), watchlistId: wl.id, targetType, targetId, baselineUsd: baseline, label });349  }350  revalidatePath('/watchlist');351  if (back.startsWith('/') && !back.startsWith('//')) revalidatePath(back);352}353354export async function updateWatchItemAction(_prev: ActionState, formData: FormData): Promise<ActionState> {355  const u = await requireUser('/watchlist');356  const id = String(formData.get('itemId') ?? '');357  const wl = await getOrCreateWatchlist(u.id);358  const note = String(formData.get('note') ?? '').trim().slice(0, 500);359  const tp = formData.get('targetPriceUsd') ? Number(formData.get('targetPriceUsd')) : null;360  await db().update(watchlistItems).set({ note: note || null, targetPriceUsd: tp !== null && Number.isFinite(tp) && tp >= 0 ? tp : null }).where(and(eq(watchlistItems.id, id), eq(watchlistItems.watchlistId, wl.id)));361  revalidatePath('/watchlist');362  return { ok: true, message: 'Saved.' };363}364365// ---------------------------------------------------------------- alerts366367const ALERT_TYPES = ['price_below', 'price_above', 'new_listing', 'new_auction', 'auction_ending', 'auction_below_riv', 'record_sale', 'unusual_volume', 'market_move', 'rare_item', 'population_update'] as const;368const alertSchema = z.object({369  alertType: z.enum(ALERT_TYPES),370  targetType: z.enum(['asset', 'category', 'index']),371  targetId: z.string().min(1, 'Choose a target'),372  threshold: z.coerce.number().nonnegative().optional().nullable(),373  channel: z.enum(['inapp', 'email', 'both']).default('both'),374  name: z.string().trim().max(80).optional().or(z.literal('')),375  cooldownMinutes: z.coerce.number().int().min(15).max(43200).default(1440),376});377378export async function createAlertAction(_prev: ActionState, formData: FormData): Promise<ActionState> {379  const u = await requireUser('/alerts');380  const parsed = alertSchema.safeParse({ alertType: formData.get('alertType'), targetType: formData.get('targetType') ?? 'asset', targetId: formData.get('targetId'), threshold: formData.get('threshold') ? formData.get('threshold') : null, channel: formData.get('channel') ?? 'both', name: formData.get('name') ?? '', cooldownMinutes: formData.get('cooldownMinutes') || 1440 });381  if (!parsed.success) return fail('Check the fields.', { fieldErrors: fieldErrorsFrom(parsed.error.issues) });382  const d = parsed.data;383  if ((d.alertType === 'price_below' || d.alertType === 'price_above' || d.alertType === 'market_move' || d.alertType === 'unusual_volume') && (d.threshold === null || d.threshold === undefined)) return fail('This alert needs a threshold.', { fieldErrors: { threshold: 'Required' } });384  const existing = await db().select({ n: sql<number>`count(*)` }).from(alerts).where(and(eq(alerts.userId, u.id), eq(alerts.active, true)));385  if (Number(existing[0]?.n ?? 0) >= 200) return fail('You have reached the limit of 200 active alerts.');386  let name = d.name || null;387  if (!name && d.targetType === 'asset') {388    const a = await db().select({ title: assets.title }).from(assets).where(eq(assets.id, d.targetId)).limit(1);389    if (!a[0]) return fail('Asset not found.', { fieldErrors: { targetId: 'Unknown asset' } });390    name = a[0].title;391  }392  await db().insert(alerts).values({ id: newId('alert'), userId: u.id, alertType: d.alertType, targetType: d.targetType, targetId: d.targetId, threshold: d.threshold ?? null, currency: 'USD', channel: d.channel, name, cooldownMinutes: d.cooldownMinutes, params: {} });393  revalidatePath('/alerts');394  return { ok: true, message: 'Alert created.' };395}396397export async function toggleAlertAction(formData: FormData): Promise<void> {398  const u = await requireUser('/alerts');399  const id = String(formData.get('alertId') ?? '');400  await db().update(alerts).set({ active: sql`not ${alerts.active}` }).where(and(eq(alerts.id, id), eq(alerts.userId, u.id)));401  revalidatePath('/alerts');402}403404export async function deleteAlertAction(formData: FormData): Promise<void> {405  const u = await requireUser('/alerts');406  const id = String(formData.get('alertId') ?? '');407  await db().delete(alerts).where(and(eq(alerts.id, id), eq(alerts.userId, u.id)));408  revalidatePath('/alerts');409}410411// ---------------------------------------------------------------- notifications412413export async function markReadAction(formData: FormData): Promise<void> {414  const u = await requireUser('/notifications');415  const id = String(formData.get('id') ?? '');416  if (id === 'all') await db().update(notifications).set({ readAt: new Date() }).where(and(eq(notifications.userId, u.id), isNull(notifications.readAt)));417  else await db().update(notifications).set({ readAt: new Date() }).where(and(eq(notifications.id, id), eq(notifications.userId, u.id)));418  revalidatePath('/notifications');419}420421export async function clearNotificationsAction(): Promise<void> {422  const u = await requireUser('/notifications');423  await db().delete(notifications).where(and(eq(notifications.userId, u.id), sql`${notifications.readAt} is not null`));424  revalidatePath('/notifications');425}426427// ---------------------------------------------------------------- saved searches428429export async function saveSearchAction(_prev: ActionState, formData: FormData): Promise<ActionState> {430  const u = await requireUser('/saved');431  const name = z.string().trim().min(1).max(80).safeParse(formData.get('name'));432  const url = String(formData.get('url') ?? '').trim();433  if (!name.success) return fail('Give the search a name.', { fieldErrors: { name: 'Required' } });434  if (!url.startsWith('/') || url.startsWith('//') || url.length > 2000) return fail('Only RareIndex search URLs can be saved (start with /search or /explore).', { fieldErrors: { url: 'Invalid' } });435  const params = Object.fromEntries(new URL(url, 'https://x').searchParams.entries());436  const n = await db().select({ n: sql<number>`count(*)` }).from(savedSearches).where(eq(savedSearches.userId, u.id));437  if (Number(n[0]?.n ?? 0) >= 100) return fail('Limit of 100 saved searches reached.');438  await db().insert(savedSearches).values({ id: newId('event'), userId: u.id, name: name.data, url, params, notify: formData.get('notify') === 'on' });439  revalidatePath('/saved');440  return { ok: true, message: 'Search saved.' };441}442443export async function deleteSavedSearchAction(formData: FormData): Promise<void> {444  const u = await requireUser('/saved');445  await db().delete(savedSearches).where(and(eq(savedSearches.id, String(formData.get('id') ?? '')), eq(savedSearches.userId, u.id)));446  revalidatePath('/saved');447}448449export async function toggleSavedSearchNotifyAction(formData: FormData): Promise<void> {450  const u = await requireUser('/saved');451  await db().update(savedSearches).set({ notify: sql`not ${savedSearches.notify}` }).where(and(eq(savedSearches.id, String(formData.get('id') ?? '')), eq(savedSearches.userId, u.id)));452  revalidatePath('/saved');453}454455// ---------------------------------------------------------------- price targets456457export async function createTargetAction(_prev: ActionState, formData: FormData): Promise<ActionState> {458  const u = await requireUser('/targets');459  const assetId = String(formData.get('assetId') ?? '');460  const target = Number(formData.get('targetUsd'));461  const direction = formData.get('direction') === 'below' ? 'below' : 'above';462  const note = String(formData.get('note') ?? '').trim().slice(0, 300);463  if (!assetId) return fail('Pick an asset.', { fieldErrors: { assetId: 'Required' } });464  if (!Number.isFinite(target) || target <= 0) return fail('Enter a target value in USD.', { fieldErrors: { targetUsd: 'Invalid' } });465  const s = await db().select({ riv: assetStats.rivUsd, id: assets.id }).from(assets).leftJoin(assetStats, eq(assetStats.assetId, assets.id)).where(eq(assets.id, assetId)).limit(1);466  if (!s[0]) return fail('Asset not found.');467  await db().insert(priceTargets).values({ id: newId('event'), userId: u.id, assetId, direction, targetUsd: target, baselineUsd: s[0].riv ?? null, note: note || null });468  revalidatePath('/targets');469  return { ok: true, message: 'Target set.' };470}471472export async function deleteTargetAction(formData: FormData): Promise<void> {473  const u = await requireUser('/targets');474  await db().delete(priceTargets).where(and(eq(priceTargets.id, String(formData.get('id') ?? '')), eq(priceTargets.userId, u.id)));475  revalidatePath('/targets');476}477478/** Bulk: add every asset of a category to the watchlist is intentionally NOT offered (noise). */479export async function bulkDeleteItemsAction(formData: FormData): Promise<void> {480  const u = await requireUser('/collections');481  const ids = formData.getAll('itemIds').map(String).filter(Boolean);482  const collectionId = String(formData.get('collectionId') ?? '');483  const col = await getCollection(u.id, collectionId);484  if (!col || !ids.length) return;485  await db().delete(collectionItems).where(and(eq(collectionItems.collectionId, collectionId), inArray(collectionItems.id, ids)));486  revalidatePath(`/collections/${collectionId}`);487}488