TypeScript 61.9%
HTML 37.2%
SQL 0.7%
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