diff --git a/src/db/schema.ts b/src/db/schema.ts index b93f042..492c389 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,4 +1,5 @@ import { + boolean, doublePrecision, integer, pgTable, @@ -56,6 +57,7 @@ export const items = pgTable("items", { quantity: integer("quantity").notNull().default(1), globalItemId: integer("global_item_id").references(() => globalItems.id), purchasePriceCents: integer("purchase_price_cents"), + priceCurrency: text("price_currency").default("EUR"), brand: text("brand"), dominantColor: text("dominant_color"), cropZoom: doublePrecision("crop_zoom"), @@ -108,6 +110,9 @@ export const threadCandidates = pgTable("thread_candidates", { cons: text("cons"), sortOrder: doublePrecision("sort_order").notNull().default(0), globalItemId: integer("global_item_id").references(() => globalItems.id), + foundPriceCents: integer("found_price_cents"), + foundPriceCurrency: text("found_price_currency"), + foundPriceDate: timestamp("found_price_date"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }); @@ -268,3 +273,43 @@ export const oauthTokens = pgTable("oauth_tokens", { refreshExpiresAt: timestamp("refresh_expires_at").notNull(), createdAt: timestamp("created_at").defaultNow().notNull(), }); + +// ── Market Prices ────────────────────────────────────────────────── + +export const marketPrices = pgTable( + "market_prices", + { + id: serial("id").primaryKey(), + globalItemId: integer("global_item_id") + .notNull() + .references(() => globalItems.id, { onDelete: "cascade" }), + market: text("market").notNull(), + currency: text("currency").notNull(), + priceCents: integer("price_cents").notNull(), + source: text("source"), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (table) => [unique().on(table.globalItemId, table.market, table.currency)], +); + +// ── Community Prices ─────────────────────────────────────────────── + +export const communityPrices = pgTable( + "community_prices", + { + id: serial("id").primaryKey(), + globalItemId: integer("global_item_id") + .notNull() + .references(() => globalItems.id, { onDelete: "cascade" }), + userId: integer("user_id") + .notNull() + .references(() => users.id), + market: text("market").notNull(), + currency: text("currency").notNull(), + priceCents: integer("price_cents").notNull(), + priceDate: timestamp("price_date"), + sourceType: text("source_type").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (table) => [unique().on(table.globalItemId, table.userId, table.sourceType)], +); diff --git a/src/shared/schemas.ts b/src/shared/schemas.ts index af8d967..4c9b33b 100644 --- a/src/shared/schemas.ts +++ b/src/shared/schemas.ts @@ -12,6 +12,7 @@ export const createItemSchema = z.object({ quantity: z.number().int().positive().optional(), globalItemId: z.number().int().positive().optional(), purchasePriceCents: z.number().int().nonnegative().optional(), + priceCurrency: z.string().max(3).optional(), brand: z.string().optional(), dominantColor: z.string().nullable().optional(), cropZoom: z.number().nullable().optional(), @@ -70,6 +71,9 @@ export const createCandidateSchema = z.object({ cropZoom: z.number().nullable().optional(), cropX: z.number().nullable().optional(), cropY: z.number().nullable().optional(), + foundPriceCents: z.number().int().nonnegative().optional(), + foundPriceCurrency: z.string().max(3).optional(), + foundPriceDate: z.string().optional(), }); export const updateCandidateSchema = createCandidateSchema.partial(); @@ -167,3 +171,21 @@ export const deleteAccountSchema = z.object({ export const completeOnboardingSchema = z.object({ globalItemIds: z.array(z.number().int().positive()).max(50), }); + +// Market price schemas +export const upsertMarketPriceSchema = z.object({ + market: z.string().min(1).max(10), + currency: z.string().min(1).max(3), + priceCents: z.number().int().nonnegative(), + source: z.string().optional(), +}); + +// Community price schemas +export const submitCommunityPriceSchema = z.object({ + globalItemId: z.number().int().positive(), + market: z.string().min(1).max(10), + currency: z.string().min(1).max(3), + priceCents: z.number().int().nonnegative(), + priceDate: z.string().optional(), + sourceType: z.enum(["purchased", "researched"]), +}); diff --git a/src/shared/types.ts b/src/shared/types.ts index 0b64d51..972cad5 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,9 +1,11 @@ import type { z } from "zod"; import type { categories, + communityPrices, globalItems, globalItemTags, items, + marketPrices, setupItems, setups, tags, @@ -21,6 +23,8 @@ import type { createThreadSchema, deleteAccountSchema, reorderCandidatesSchema, + submitCommunityPriceSchema, + upsertMarketPriceSchema, resolveThreadSchema, searchGlobalItemsSchema, syncSetupItemsSchema, @@ -75,3 +79,9 @@ export type GlobalItemTag = typeof globalItemTags.$inferSelect; export type ChangePassword = z.infer; export type ChangeEmail = z.infer; export type DeleteAccount = z.infer; + +// Market & community price types +export type MarketPrice = typeof marketPrices.$inferSelect; +export type CommunityPrice = typeof communityPrices.$inferSelect; +export type UpsertMarketPrice = z.infer; +export type SubmitCommunityPrice = z.infer;