'use server'; import { redirect } from 'next/navigation'; import { revalidatePath } from 'next/cache'; import { z } from 'zod'; import { and, eq, inArray, isNull, sql } from '@/lib/db'; import { db, collections, collectionItems, assets, assetStats, assetVariants, watchlistItems, alerts, notifications, savedSearches, priceTargets } from '@/lib/db'; import { newId, slugify } from '@rareindex/shared'; import { requireUser } from '@/lib/auth/session'; import { fieldErrorsFrom, type ActionState } from '@/lib/auth/state'; import { toUsdAt } from './fx'; import { getCollection, getOrCreateWatchlist } from './queries'; import { parseCsv } from './portfolio'; import { storeUpload, deleteUpload } from './uploads'; const fail = (error: string, extra: Partial = {}): ActionState => ({ ok: false, error, ...extra }); const CURRENCIES = ['USD', 'CAD', 'EUR', 'GBP', 'JPY', 'CHF', 'AUD'] as const; // ---------------------------------------------------------------- collections const collectionSchema = z.object({ name: z.string().trim().min(1, 'Name is required').max(80), description: z.string().trim().max(500).optional().or(z.literal('')), kind: z.enum(['collection', 'wishlist', 'vault', 'sold']).default('collection'), budgetUsd: z.coerce.number().nonnegative().optional().nullable(), color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional().or(z.literal('')), }); export async function createCollectionAction(_prev: ActionState, formData: FormData): Promise { const u = await requireUser('/collections'); 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') ?? '' }); if (!parsed.success) return fail('Check the fields.', { fieldErrors: fieldErrorsFrom(parsed.error.issues) }); const id = newId('collection'); 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 }); revalidatePath('/collections'); redirect(`/collections/${id}`); } export async function updateCollectionAction(_prev: ActionState, formData: FormData): Promise { const u = await requireUser('/collections'); const id = String(formData.get('collectionId') ?? ''); const col = await getCollection(u.id, id); if (!col) return fail('Collection not found.'); 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') ?? '' }); if (!parsed.success) return fail('Check the fields.', { fieldErrors: fieldErrorsFrom(parsed.error.issues) }); 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)); revalidatePath(`/collections/${id}`); return { ok: true, message: 'Collection updated.' }; } export async function toggleCollectionPublicAction(formData: FormData): Promise { const u = await requireUser('/collections'); const id = String(formData.get('collectionId') ?? ''); const col = await getCollection(u.id, id); if (!col) return; const makePublic = formData.get('public') === 'on'; let publicSlug = col.publicSlug; if (makePublic && !publicSlug) publicSlug = `${slugify(col.name) || 'collection'}-${id.slice(-6)}`; await db().update(collections).set({ isPublic: makePublic, publicSlug, updatedAt: new Date() }).where(eq(collections.id, id)); revalidatePath(`/collections/${id}`); } export async function deleteCollectionAction(formData: FormData): Promise { const u = await requireUser('/collections'); const id = String(formData.get('collectionId') ?? ''); const col = await getCollection(u.id, id); if (!col) return; await db().delete(collectionItems).where(eq(collectionItems.collectionId, id)); await db().delete(collections).where(eq(collections.id, id)); revalidatePath('/collections'); redirect('/collections'); } // ---------------------------------------------------------------- items const itemSchema = z.object({ assetId: z.string().min(1, 'Pick an asset'), variantId: z.string().optional().or(z.literal('')), quantity: z.coerce.number().int().min(1).max(10_000).default(1), acquiredAt: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().or(z.literal('')), purchasePrice: z.coerce.number().nonnegative().optional().nullable(), purchaseCurrency: z.enum(CURRENCIES).default('USD'), source: z.string().trim().max(120).optional().or(z.literal('')), grader: z.string().trim().max(20).optional().or(z.literal('')), grade: z.string().trim().max(12).optional().or(z.literal('')), certificationNumber: z.string().trim().max(40).optional().or(z.literal('')), serial: z.string().trim().max(60).optional().or(z.literal('')), condition: z.string().trim().max(40).optional().or(z.literal('')), notes: z.string().trim().max(2000).optional().or(z.literal('')), tags: z.string().trim().max(200).optional().or(z.literal('')), manualValueUsd: z.coerce.number().nonnegative().optional().nullable(), }); function tagsFrom(s: string | undefined): string[] { return [...new Set((s ?? '').split(',').map((t) => t.trim().toLowerCase()).filter(Boolean))].slice(0, 12); } async function parseItem(formData: FormData) { return itemSchema.safeParse({ assetId: formData.get('assetId'), variantId: formData.get('variantId') ?? '', quantity: formData.get('quantity') || 1, acquiredAt: formData.get('acquiredAt') ?? '', purchasePrice: formData.get('purchasePrice') ? formData.get('purchasePrice') : null, purchaseCurrency: formData.get('purchaseCurrency') || 'USD', source: formData.get('source') ?? '', grader: formData.get('grader') ?? '', grade: formData.get('grade') ?? '', certificationNumber: formData.get('certificationNumber') ?? '', serial: formData.get('serial') ?? '', condition: formData.get('condition') ?? '', notes: formData.get('notes') ?? '', tags: formData.get('tags') ?? '', manualValueUsd: formData.get('manualValueUsd') ? formData.get('manualValueUsd') : null, }); } async function resolveVariant(assetId: string, variantId: string | undefined, grader: string | undefined, grade: string | undefined): Promise<{ variantId: string | null; grader: string | null; grade: string | null }> { if (variantId) { const v = await db().select().from(assetVariants).where(and(eq(assetVariants.id, variantId), eq(assetVariants.assetId, assetId))).limit(1); if (v[0]) return { variantId: v[0].id, grader: v[0].grader ?? (grader || null), grade: v[0].grade ?? (grade || null) }; } if (grader && grade) { const v = await db().select().from(assetVariants).where(and(eq(assetVariants.assetId, assetId), eq(assetVariants.grader, grader.toLowerCase()), eq(assetVariants.grade, grade))).limit(1); if (v[0]) return { variantId: v[0].id, grader: v[0].grader, grade: v[0].grade }; } return { variantId: null, grader: grader ? grader.toLowerCase() : null, grade: grade || null }; } export async function addItemAction(_prev: ActionState, formData: FormData): Promise { const u = await requireUser('/collections'); const collectionId = String(formData.get('collectionId') ?? ''); const col = await getCollection(u.id, collectionId); if (!col) return fail('Collection not found.'); const parsed = await parseItem(formData); if (!parsed.success) return fail('Check the fields.', { fieldErrors: fieldErrorsFrom(parsed.error.issues) }); const d = parsed.data; const asset = await db().select({ id: assets.id }).from(assets).where(eq(assets.id, d.assetId)).limit(1); if (!asset[0]) return fail('That asset does not exist.', { fieldErrors: { assetId: 'Unknown asset' } }); const v = await resolveVariant(d.assetId, d.variantId || undefined, d.grader || undefined, d.grade || undefined); const fx = d.purchasePrice !== null && d.purchasePrice !== undefined ? await toUsdAt(d.purchasePrice, d.purchaseCurrency, d.acquiredAt || null) : null; const notes: string[] = []; 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.`); const id = newId('collectionItem'); await db().insert(collectionItems).values({ id, collectionId, assetId: d.assetId, variantId: v.variantId, quantity: d.quantity, acquiredAt: d.acquiredAt || null, purchasePrice: d.purchasePrice ?? null, purchaseCurrency: d.purchasePrice !== null && d.purchasePrice !== undefined ? d.purchaseCurrency : null, purchasePriceUsd: fx?.usd ?? null, source: d.source || null, grader: v.grader, grade: v.grade, certificationNumber: d.certificationNumber || null, serial: d.serial || null, condition: d.condition || null, notes: d.notes || null, tags: tagsFrom(d.tags), manualValueUsd: d.manualValueUsd ?? null, }); await db().update(collections).set({ updatedAt: new Date() }).where(eq(collections.id, collectionId)); revalidatePath(`/collections/${collectionId}`); revalidatePath('/collections'); return { ok: true, message: notes.length ? `Item added. ${notes.join(' ')}` : 'Item added to the collection.', data: { itemId: id } }; } export async function updateItemAction(_prev: ActionState, formData: FormData): Promise { const u = await requireUser('/collections'); const itemId = String(formData.get('itemId') ?? ''); const row = await ownedItem(u.id, itemId); if (!row) return fail('Item not found.'); const parsed = await parseItem(formData); if (!parsed.success) return fail('Check the fields.', { fieldErrors: fieldErrorsFrom(parsed.error.issues) }); const d = parsed.data; const v = await resolveVariant(row.item.assetId, d.variantId || undefined, d.grader || undefined, d.grade || undefined); const fx = d.purchasePrice !== null && d.purchasePrice !== undefined ? await toUsdAt(d.purchasePrice, d.purchaseCurrency, d.acquiredAt || null) : null; const soldAt = String(formData.get('soldAt') ?? ''); const soldPrice = formData.get('soldPriceUsd') ? Number(formData.get('soldPriceUsd')) : null; await db() .update(collectionItems) .set({ variantId: v.variantId, quantity: d.quantity, acquiredAt: d.acquiredAt || null, purchasePrice: d.purchasePrice ?? null, purchaseCurrency: d.purchasePrice !== null && d.purchasePrice !== undefined ? d.purchaseCurrency : null, purchasePriceUsd: fx?.usd ?? null, source: d.source || null, grader: v.grader, grade: v.grade, certificationNumber: d.certificationNumber || null, serial: d.serial || null, condition: d.condition || null, notes: d.notes || null, tags: tagsFrom(d.tags), manualValueUsd: d.manualValueUsd ?? null, soldAt: /^\d{4}-\d{2}-\d{2}$/.test(soldAt) ? soldAt : null, soldPriceUsd: soldPrice !== null && Number.isFinite(soldPrice) && soldPrice >= 0 ? soldPrice : null, updatedAt: new Date(), }) .where(eq(collectionItems.id, itemId)); revalidatePath(`/collections/${row.item.collectionId}`); return { ok: true, message: 'Item saved.' }; } async function ownedItem(userId: string, itemId: string) { 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); return rows[0] ?? null; } export async function deleteItemAction(formData: FormData): Promise { const u = await requireUser('/collections'); const itemId = String(formData.get('itemId') ?? ''); const row = await ownedItem(u.id, itemId); if (!row) return; for (const p of row.item.photos ?? []) { const m = p.match(/\/api\/account\/uploads\/(img_[0-9a-z]+)/); if (m) await deleteUpload(u.id, m[1]!); } await db().delete(collectionItems).where(eq(collectionItems.id, itemId)); revalidatePath(`/collections/${row.item.collectionId}`); redirect(`/collections/${row.item.collectionId}`); } export async function moveItemAction(formData: FormData): Promise { const u = await requireUser('/collections'); const itemId = String(formData.get('itemId') ?? ''); const to = String(formData.get('toCollectionId') ?? ''); const row = await ownedItem(u.id, itemId); const dest = await getCollection(u.id, to); if (!row || !dest) return; await db().update(collectionItems).set({ collectionId: to, updatedAt: new Date() }).where(eq(collectionItems.id, itemId)); revalidatePath(`/collections/${row.item.collectionId}`); revalidatePath(`/collections/${to}`); } export async function addItemPhotoAction(_prev: ActionState, formData: FormData): Promise { const u = await requireUser('/collections'); const itemId = String(formData.get('itemId') ?? ''); const row = await ownedItem(u.id, itemId); if (!row) return fail('Item not found.'); const files = formData.getAll('files').filter((f): f is File => f instanceof File && f.size > 0); if (!files.length) return fail('Choose at least one image.'); if ((row.item.photos?.length ?? 0) + files.length > 12) return fail('At most 12 photos per item.'); const urls: string[] = []; for (const f of files.slice(0, 12)) { const res = await storeUpload(u.id, 'item_photo', f); if ('error' in res) return fail(res.error); urls.push(res.url); } await db().update(collectionItems).set({ photos: [...(row.item.photos ?? []), ...urls], updatedAt: new Date() }).where(eq(collectionItems.id, itemId)); revalidatePath(`/collections/${row.item.collectionId}/items/${itemId}`); return { ok: true, message: `${urls.length} photo${urls.length === 1 ? '' : 's'} added.` }; } export async function removeItemPhotoAction(formData: FormData): Promise { const u = await requireUser('/collections'); const itemId = String(formData.get('itemId') ?? ''); const url = String(formData.get('url') ?? ''); const row = await ownedItem(u.id, itemId); if (!row) return; const m = url.match(/\/api\/account\/uploads\/(img_[0-9a-z]+)/); if (m) await deleteUpload(u.id, m[1]!); await db().update(collectionItems).set({ photos: (row.item.photos ?? []).filter((p) => p !== url), updatedAt: new Date() }).where(eq(collectionItems.id, itemId)); revalidatePath(`/collections/${row.item.collectionId}/items/${itemId}`); } // ---------------------------------------------------------------- CSV import export async function importCsvAction(_prev: ActionState, formData: FormData): Promise { const u = await requireUser('/collections'); const collectionId = String(formData.get('collectionId') ?? ''); const col = await getCollection(u.id, collectionId); if (!col) return fail('Collection not found.'); const file = formData.get('file'); if (!(file instanceof File) || file.size === 0) return fail('Choose a CSV file.'); if (file.size > 2 * 1024 * 1024) return fail('CSV too large (max 2 MB).'); const rows = parseCsv(await file.text()); 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.'); let added = 0; const errors: string[] = []; for (const [i, r] of rows.slice(0, 500).entries()) { const ref = r.asset_id || r.asset_slug || r.asset || ''; if (!ref) { errors.push(`row ${i + 2}: missing asset_id/asset_slug`); continue; } const a = await db().select({ id: assets.id }).from(assets).where(ref.startsWith('rare_') ? eq(assets.id, ref) : eq(assets.slug, ref)).limit(1); if (!a[0]) { errors.push(`row ${i + 2}: asset "${ref}" not found`); continue; } const price = r.purchase_price ? Number(String(r.purchase_price).replace(/[^0-9.]/g, '')) : null; const currency = (r.currency || 'USD').toUpperCase(); const acquiredAt = /^\d{4}-\d{2}-\d{2}$/.test(r.acquired_at ?? '') ? r.acquired_at! : null; const fx = price !== null && Number.isFinite(price) ? await toUsdAt(price, (CURRENCIES as readonly string[]).includes(currency) ? currency : 'USD', acquiredAt) : null; const v = await resolveVariant(a[0].id, undefined, r.grader || undefined, r.grade || undefined); await db().insert(collectionItems).values({ id: newId('collectionItem'), collectionId, assetId: a[0].id, variantId: v.variantId, quantity: Math.max(1, Number(r.quantity) || 1), acquiredAt, purchasePrice: price !== null && Number.isFinite(price) ? price : null, purchaseCurrency: price !== null ? currency : null, purchasePriceUsd: fx?.usd ?? null, source: r.source || null, grader: v.grader, grade: v.grade, certificationNumber: r.cert || r.certification_number || null, serial: r.serial || null, notes: r.notes || null, tags: tagsFrom(r.tags), }); added++; } revalidatePath(`/collections/${collectionId}`); 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) } }; } // ---------------------------------------------------------------- watchlist export async function toggleWatchAction(formData: FormData): Promise { const u = await requireUser('/watchlist'); const targetType = String(formData.get('targetType') ?? 'asset'); const targetId = String(formData.get('targetId') ?? ''); const back = String(formData.get('back') ?? '/watchlist'); if (!targetId || !['asset', 'category', 'brand', 'set', 'source', 'auction'].includes(targetType)) return; const wl = await getOrCreateWatchlist(u.id); 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); if (existing[0]) { await db().delete(watchlistItems).where(eq(watchlistItems.id, existing[0].id)); if (targetType === 'asset') await db().update(assetStats).set({ watchers: sql`greatest(${assetStats.watchers} - 1, 0)` }).where(eq(assetStats.assetId, targetId)); } else { let baseline: number | null = null; let label: string | null = null; if (targetType === 'asset') { 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); baseline = s[0]?.riv ?? null; label = s[0]?.title ?? null; 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`); } await db().insert(watchlistItems).values({ id: newId('event'), watchlistId: wl.id, targetType, targetId, baselineUsd: baseline, label }); } revalidatePath('/watchlist'); if (back.startsWith('/') && !back.startsWith('//')) revalidatePath(back); } export async function updateWatchItemAction(_prev: ActionState, formData: FormData): Promise { const u = await requireUser('/watchlist'); const id = String(formData.get('itemId') ?? ''); const wl = await getOrCreateWatchlist(u.id); const note = String(formData.get('note') ?? '').trim().slice(0, 500); const tp = formData.get('targetPriceUsd') ? Number(formData.get('targetPriceUsd')) : null; 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))); revalidatePath('/watchlist'); return { ok: true, message: 'Saved.' }; } // ---------------------------------------------------------------- alerts const 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; const alertSchema = z.object({ alertType: z.enum(ALERT_TYPES), targetType: z.enum(['asset', 'category', 'index']), targetId: z.string().min(1, 'Choose a target'), threshold: z.coerce.number().nonnegative().optional().nullable(), channel: z.enum(['inapp', 'email', 'both']).default('both'), name: z.string().trim().max(80).optional().or(z.literal('')), cooldownMinutes: z.coerce.number().int().min(15).max(43200).default(1440), }); export async function createAlertAction(_prev: ActionState, formData: FormData): Promise { const u = await requireUser('/alerts'); 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 }); if (!parsed.success) return fail('Check the fields.', { fieldErrors: fieldErrorsFrom(parsed.error.issues) }); const d = parsed.data; 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' } }); const existing = await db().select({ n: sql`count(*)` }).from(alerts).where(and(eq(alerts.userId, u.id), eq(alerts.active, true))); if (Number(existing[0]?.n ?? 0) >= 200) return fail('You have reached the limit of 200 active alerts.'); let name = d.name || null; if (!name && d.targetType === 'asset') { const a = await db().select({ title: assets.title }).from(assets).where(eq(assets.id, d.targetId)).limit(1); if (!a[0]) return fail('Asset not found.', { fieldErrors: { targetId: 'Unknown asset' } }); name = a[0].title; } 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: {} }); revalidatePath('/alerts'); return { ok: true, message: 'Alert created.' }; } export async function toggleAlertAction(formData: FormData): Promise { const u = await requireUser('/alerts'); const id = String(formData.get('alertId') ?? ''); await db().update(alerts).set({ active: sql`not ${alerts.active}` }).where(and(eq(alerts.id, id), eq(alerts.userId, u.id))); revalidatePath('/alerts'); } export async function deleteAlertAction(formData: FormData): Promise { const u = await requireUser('/alerts'); const id = String(formData.get('alertId') ?? ''); await db().delete(alerts).where(and(eq(alerts.id, id), eq(alerts.userId, u.id))); revalidatePath('/alerts'); } // ---------------------------------------------------------------- notifications export async function markReadAction(formData: FormData): Promise { const u = await requireUser('/notifications'); const id = String(formData.get('id') ?? ''); if (id === 'all') await db().update(notifications).set({ readAt: new Date() }).where(and(eq(notifications.userId, u.id), isNull(notifications.readAt))); else await db().update(notifications).set({ readAt: new Date() }).where(and(eq(notifications.id, id), eq(notifications.userId, u.id))); revalidatePath('/notifications'); } export async function clearNotificationsAction(): Promise { const u = await requireUser('/notifications'); await db().delete(notifications).where(and(eq(notifications.userId, u.id), sql`${notifications.readAt} is not null`)); revalidatePath('/notifications'); } // ---------------------------------------------------------------- saved searches export async function saveSearchAction(_prev: ActionState, formData: FormData): Promise { const u = await requireUser('/saved'); const name = z.string().trim().min(1).max(80).safeParse(formData.get('name')); const url = String(formData.get('url') ?? '').trim(); if (!name.success) return fail('Give the search a name.', { fieldErrors: { name: 'Required' } }); 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' } }); const params = Object.fromEntries(new URL(url, 'https://x').searchParams.entries()); const n = await db().select({ n: sql`count(*)` }).from(savedSearches).where(eq(savedSearches.userId, u.id)); if (Number(n[0]?.n ?? 0) >= 100) return fail('Limit of 100 saved searches reached.'); await db().insert(savedSearches).values({ id: newId('event'), userId: u.id, name: name.data, url, params, notify: formData.get('notify') === 'on' }); revalidatePath('/saved'); return { ok: true, message: 'Search saved.' }; } export async function deleteSavedSearchAction(formData: FormData): Promise { const u = await requireUser('/saved'); await db().delete(savedSearches).where(and(eq(savedSearches.id, String(formData.get('id') ?? '')), eq(savedSearches.userId, u.id))); revalidatePath('/saved'); } export async function toggleSavedSearchNotifyAction(formData: FormData): Promise { const u = await requireUser('/saved'); await db().update(savedSearches).set({ notify: sql`not ${savedSearches.notify}` }).where(and(eq(savedSearches.id, String(formData.get('id') ?? '')), eq(savedSearches.userId, u.id))); revalidatePath('/saved'); } // ---------------------------------------------------------------- price targets export async function createTargetAction(_prev: ActionState, formData: FormData): Promise { const u = await requireUser('/targets'); const assetId = String(formData.get('assetId') ?? ''); const target = Number(formData.get('targetUsd')); const direction = formData.get('direction') === 'below' ? 'below' : 'above'; const note = String(formData.get('note') ?? '').trim().slice(0, 300); if (!assetId) return fail('Pick an asset.', { fieldErrors: { assetId: 'Required' } }); if (!Number.isFinite(target) || target <= 0) return fail('Enter a target value in USD.', { fieldErrors: { targetUsd: 'Invalid' } }); 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); if (!s[0]) return fail('Asset not found.'); await db().insert(priceTargets).values({ id: newId('event'), userId: u.id, assetId, direction, targetUsd: target, baselineUsd: s[0].riv ?? null, note: note || null }); revalidatePath('/targets'); return { ok: true, message: 'Target set.' }; } export async function deleteTargetAction(formData: FormData): Promise { const u = await requireUser('/targets'); await db().delete(priceTargets).where(and(eq(priceTargets.id, String(formData.get('id') ?? '')), eq(priceTargets.userId, u.id))); revalidatePath('/targets'); } /** Bulk: add every asset of a category to the watchlist is intentionally NOT offered (noise). */ export async function bulkDeleteItemsAction(formData: FormData): Promise { const u = await requireUser('/collections'); const ids = formData.getAll('itemIds').map(String).filter(Boolean); const collectionId = String(formData.get('collectionId') ?? ''); const col = await getCollection(u.id, collectionId); if (!col || !ids.length) return; await db().delete(collectionItems).where(and(eq(collectionItems.collectionId, collectionId), inArray(collectionItems.id, ids))); revalidatePath(`/collections/${collectionId}`); }