← back to Govarbitrage
prisma/schema.prisma
489 lines
// GovArbitrage — data model
// Money is stored as Decimal(14,2). Scores are Float 0..100. Probabilities Float 0..1.
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ------------------------------------------------------------------ enums
enum AuctionSource {
GOVDEALS
PUBLIC_SURPLUS
GSA_AUCTIONS
COUNTY
STATE_SURPLUS
UNIVERSITY_SURPLUS
MUNICIBID
BID4ASSETS
GOINDUSTRY
NETWORK_INTL
GRAYS_AU
GOVPLANET
CSV
EXTENSION
OTHER
}
enum Condition {
NEW
LIKE_NEW
USED_GOOD
USED_FAIR
FOR_PARTS
UNKNOWN
}
// Lifecycle of a listing on its SOURCE site (not Steve's buyer workflow —
// that's AuctionOutcome). ACTIVE = still live/biddable; ENDED = auction closed
// (past closingAt); REMOVED = pulled/sold/404 on the source. Only ACTIVE
// listings show on the live grid.
enum ListingStatus {
ACTIVE
ENDED
REMOVED
}
enum ResearchStatus {
PENDING
QUEUED
IN_PROGRESS
COMPLETE
FAILED
}
enum RiskLevel {
LOW
MEDIUM
HIGH
}
enum DropShipFeasibility {
EASY
MODERATE
DIFFICULT
INFEASIBLE
}
enum ScoreProfile {
OVERALL_OPPORTUNITY
BEST_ARBITRAGE
QUICK_FLIP
COLLECTOR
LOCAL_PICKUP
EASY_FREIGHT
PARTS_ONLY
HIGH_CONFIDENCE
HIGH_PROFIT
}
enum ComparableKind {
SOLD
ACTIVE
RETAIL
}
enum OutcomeStatus {
WATCHING
BID_PLACED
WON
LOST
ABANDONED
}
enum Role {
ADMIN
ANALYST
VIEWER
}
// Subscription tier for the paid-alerts SaaS. FREE is the default for any new
// account; STANDARD/PREMIUM are set by a (TEST) Stripe subscription webhook.
enum Tier {
FREE
STANDARD
PREMIUM
}
// Double-opt-in newsletter lifecycle. PENDING until the confirm link is
// clicked; UNSUBSCRIBED rows are kept (suppression list) so a re-subscribe
// re-issues a fresh confirm rather than silently re-adding.
enum SubscriberStatus {
PENDING
CONFIRMED
UNSUBSCRIBED
}
enum EventType {
IMPORTED
RESEARCH_STARTED
RESEARCH_COMPLETED
RESEARCH_FAILED
SCORED
PRICE_CHANGED
NOTE_ADDED
BUYER_LEAD_ADDED
OUTCOME_CHANGED
STATUS_CHANGE
HOT_ALERTED
MANUAL_EDIT
}
// ------------------------------------------------------------------ core
model Listing {
id String @id @default(cuid())
source AuctionSource
sourceAuctionId String // the auction/lot number on the source site
sourceUrl String?
title String
description String?
category String?
// AI/OCR/vision-identified product attributes
manufacturer String?
model String?
serialNumber String?
sku String?
year Int?
condition Condition @default(UNKNOWN)
quantity Int @default(1)
accessories String?
missingParts String?
weightLbs Float?
dimensions String? // "L x W x H in"
auctionTerms String?
// location + logistics
locationCity String?
locationState String?
locationZip String?
// live auction state
currentBid Decimal @default(0) @db.Decimal(14, 2)
bidCount Int @default(0)
closingAt DateTime?
imageUrls String[] @default([])
identifiedBy String? // "ollama:qwen2.5vl" | "manual" | "extension"
identifyRaw Json? // raw model output for auditability
researchStatus ResearchStatus @default(PENDING)
// source-liveness lifecycle + fast removal
listingStatus ListingStatus @default(ACTIVE)
endedAt DateTime? // when marked ENDED/REMOVED
lastSeenAt DateTime @default(now()) // last time confirmed live on source
livenessCheckedAt DateTime? // last Tier-2 network re-verify
// HOT DEAL alerting (dedup so we alert each deal once)
hotAlertedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
research Research?
costBreakdown CostBreakdown?
scores Score[]
comparables Comparable[]
notes Note[]
events ListingEvent[]
buyerPage BuyerInterestPage?
buyerLeads BuyerLead[]
outcome AuctionOutcome?
@@unique([source, sourceAuctionId])
@@index([researchStatus])
@@index([category])
@@index([closingAt])
@@index([listingStatus])
@@index([hotAlertedAt])
}
// Valuation research output for a listing
model Research {
id String @id @default(cuid())
listingId String @unique
listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
// retail estimates
newRetail Decimal? @db.Decimal(14, 2)
newReplacement Decimal? @db.Decimal(14, 2)
avgRetail Decimal? @db.Decimal(14, 2)
// used market
usedSoldPrice Decimal? @db.Decimal(14, 2)
usedAskingPrice Decimal? @db.Decimal(14, 2)
usedLow Decimal? @db.Decimal(14, 2)
usedHigh Decimal? @db.Decimal(14, 2)
// channel values
wholesaleValue Decimal? @db.Decimal(14, 2)
liquidationValue Decimal? @db.Decimal(14, 2)
sellTodayValue Decimal? @db.Decimal(14, 2)
// time-horizon values
value7Day Decimal? @db.Decimal(14, 2)
value30Day Decimal? @db.Decimal(14, 2)
value90Day Decimal? @db.Decimal(14, 2)
// outcome estimates
expectedSalePrice Decimal? @db.Decimal(14, 2)
probabilityOfSale Float? // 0..1
daysUntilSold Int?
confidenceScore Float? // 0..100
sources Json? // [{name,url,kind,price}]
summary String?
model String? // which model produced this
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// All acquisition + resale costs for a listing
model CostBreakdown {
id String @id @default(cuid())
listingId String @unique
listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
winningBid Decimal @default(0) @db.Decimal(14, 2)
buyerPremium Decimal @default(0) @db.Decimal(14, 2)
salesTax Decimal @default(0) @db.Decimal(14, 2)
shipping Decimal @default(0) @db.Decimal(14, 2)
freight Decimal @default(0) @db.Decimal(14, 2)
insurance Decimal @default(0) @db.Decimal(14, 2)
packing Decimal @default(0) @db.Decimal(14, 2)
pickupLabor Decimal @default(0) @db.Decimal(14, 2)
testing Decimal @default(0) @db.Decimal(14, 2)
repairs Decimal @default(0) @db.Decimal(14, 2)
certification Decimal @default(0) @db.Decimal(14, 2)
marketplaceFees Decimal @default(0) @db.Decimal(14, 2)
paymentFees Decimal @default(0) @db.Decimal(14, 2)
storage Decimal @default(0) @db.Decimal(14, 2)
photography Decimal @default(0) @db.Decimal(14, 2)
listingLabor Decimal @default(0) @db.Decimal(14, 2)
expectedReturns Decimal @default(0) @db.Decimal(14, 2)
totalInvestment Decimal @default(0) @db.Decimal(14, 2)
expectedNetProfit Decimal @default(0) @db.Decimal(14, 2)
roi Float @default(0) // ratio, e.g. 0.42 = 42%
annualizedReturn Float @default(0)
recommendedMaxBid Decimal @default(0) @db.Decimal(14, 2)
assumptions Json? // basis for each estimate (freight table row, fee %, etc.)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// One row per scoring profile for a listing, each with a human explanation
model Score {
id String @id @default(cuid())
listingId String
listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
profile ScoreProfile
value Float // 0..100
// component sub-scores (0..100) the profile is composed from
arbitrage Float @default(0)
demand Float @default(0)
velocity Float @default(0)
logistics Float @default(0)
condition Float @default(0)
competition Float @default(0)
buyer Float @default(0)
risk RiskLevel @default(MEDIUM)
dropShip DropShipFeasibility @default(MODERATE)
explanation String // "why it got this score", plain English
factors Json? // structured [{label, weight, contribution}]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([listingId, profile])
@@index([profile, value])
}
model Comparable {
id String @id @default(cuid())
listingId String
listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
kind ComparableKind
title String
price Decimal @db.Decimal(14, 2)
url String?
source String? // "ebay" | "amazon" | manufacturer domain
soldAt DateTime?
createdAt DateTime @default(now())
@@index([listingId, kind])
}
// ------------------------------------------------------------------ buyer workflow (compliant)
model BuyerInterestPage {
id String @id @default(cuid())
listingId String @unique
listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
slug String @unique
headline String
// Compliance: contingent offers only, no ownership representation pre-win.
contingent Boolean @default(true)
published Boolean @default(false)
estDelivered Decimal? @db.Decimal(14, 2)
disclaimer String @default("This item is not yet owned. Any offer is contingent on winning the government auction and is non-binding until the auction is won and the item is in hand.")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model BuyerLead {
id String @id @default(cuid())
listingId String
listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
name String
email String
phone String?
offer Decimal? @db.Decimal(14, 2)
contingent Boolean @default(true)
notes String?
createdAt DateTime @default(now())
@@index([listingId])
}
model AuctionOutcome {
id String @id @default(cuid())
listingId String @unique
listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
status OutcomeStatus @default(WATCHING)
maxBidSet Decimal? @db.Decimal(14, 2)
wonPrice Decimal? @db.Decimal(14, 2)
actualSale Decimal? @db.Decimal(14, 2)
soldAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ------------------------------------------------------------------ workspace
model Note {
id String @id @default(cuid())
listingId String
listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
body String
author String?
createdAt DateTime @default(now())
@@index([listingId])
}
model ListingEvent {
id String @id @default(cuid())
listingId String
listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
type EventType
message String
meta Json?
createdAt DateTime @default(now())
@@index([listingId, createdAt])
}
model SavedView {
id String @id @default(cuid())
name String
ownerId String?
config Json // { filters, sort, columns, pinned, grouping }
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ------------------------------------------------------------------ security
model User {
id String @id @default(cuid())
email String @unique
name String?
passwordHash String
role Role @default(ANALYST)
// Paid-alerts SaaS subscription
tier Tier @default(FREE)
stripeCustomerId String?
stripeSubscriptionId String?
createdAt DateTime @default(now())
credentials ApiCredential[]
auditLogs AuditLog[]
}
// Public newsletter subscriber for the twice-daily Top-10 digest.
// email is stored lowercase; tokens are single-purpose URL-safe secrets.
model Subscriber {
id String @id @default(cuid())
email String @unique
status SubscriberStatus @default(PENDING)
confirmToken String @unique
unsubscribeToken String @unique
source String @default("newsletter_page")
createdAt DateTime @default(now())
confirmedAt DateTime?
unsubscribedAt DateTime?
@@index([status])
}
model ApiCredential {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
provider String // "anthropic" | "ebay" | ...
// Stored encrypted at rest (AES-256-GCM). Never returned to the client.
ciphertext String
iv String
authTag String
createdAt DateTime @default(now())
@@unique([userId, provider])
}
model AuditLog {
id String @id @default(cuid())
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
action String
entity String?
entityId String?
ip String?
meta Json?
createdAt DateTime @default(now())
@@index([createdAt])
@@index([action])
}
// ------------------------------------------------------------------ digest archive
enum DigestSlot {
AM
PM
}
// Frozen record of each digest run. The public /deals archive pages render
// these snapshots so archive URLs stay stable and indexable instead of
// re-querying live listings that expire out from under a crawled page.
model DigestSnapshot {
id String @id @default(cuid())
date String // YYYY-MM-DD in America/Los_Angeles (the digest's home timezone)
slot DigestSlot
dealsJson Json // public-safe serialized Top-10 (see src/lib/digest-snapshot.ts)
dealCount Int
isSeed Boolean @default(false) // fabricated demo data — excluded from all public reads
createdAt DateTime @default(now())
@@unique([date, slot])
@@index([date])
}