diff --git a/bun.lock b/bun.lock index 169ea51..7f420c5 100644 --- a/bun.lock +++ b/bun.lock @@ -23,6 +23,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "dotenv": "^17.4.2", "drizzle-kit": "^0.31.10", "eslint": "^9", "eslint-config-next": "16.2.2", @@ -606,6 +607,8 @@ "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + "dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], + "drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="], "drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..e0f2b30 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,9 @@ +import 'dotenv/config' +import { defineConfig } from 'drizzle-kit' + +export default defineConfig({ + schema: './src/lib/db/schema.ts', + out: './drizzle', + dialect: 'postgresql', + dbCredentials: { url: process.env.DATABASE_URL! }, +}) diff --git a/drizzle/0000_motionless_beast.sql b/drizzle/0000_motionless_beast.sql new file mode 100644 index 0000000..736adbd --- /dev/null +++ b/drizzle/0000_motionless_beast.sql @@ -0,0 +1,38 @@ +CREATE TABLE "alerts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "product_id" uuid NOT NULL, + "type" text NOT NULL, + "config" jsonb NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "last_triggered_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "alert_type_check" CHECK ("alerts"."type" in ('target_price','all_time_low','percent_drop')) +); +--> statement-breakpoint +CREATE TABLE "price_snapshots" ( + "id" bigserial PRIMARY KEY NOT NULL, + "product_id" uuid NOT NULL, + "price" numeric(10, 2), + "currency" text DEFAULT 'EUR' NOT NULL, + "availability" text, + "error" text, + "scraped_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "products" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "url" text NOT NULL, + "shop" text NOT NULL, + "name" text NOT NULL, + "image_url" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "last_scraped_at" timestamp with time zone, + "consecutive_failures" integer DEFAULT 0 NOT NULL, + CONSTRAINT "products_url_unique" UNIQUE("url"), + CONSTRAINT "shop_check" CHECK ("products"."shop" in ('amazon','idealo','geizhals')) +); +--> statement-breakpoint +ALTER TABLE "alerts" ADD CONSTRAINT "alerts_product_id_products_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."products"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "price_snapshots" ADD CONSTRAINT "price_snapshots_product_id_products_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."products"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "snapshots_product_scraped_idx" ON "price_snapshots" USING btree ("product_id","scraped_at" DESC NULLS LAST); \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..a71c6fb --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,273 @@ +{ + "id": "17d857bc-1a3e-4c61-80cc-1d863410f24a", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.alerts": { + "name": "alerts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_triggered_at": { + "name": "last_triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "alerts_product_id_products_id_fk": { + "name": "alerts_product_id_products_id_fk", + "tableFrom": "alerts", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "alert_type_check": { + "name": "alert_type_check", + "value": "\"alerts\".\"type\" in ('target_price','all_time_low','percent_drop')" + } + }, + "isRLSEnabled": false + }, + "public.price_snapshots": { + "name": "price_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'EUR'" + }, + "availability": { + "name": "availability", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scraped_at": { + "name": "scraped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "snapshots_product_scraped_idx": { + "name": "snapshots_product_scraped_idx", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scraped_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "price_snapshots_product_id_products_id_fk": { + "name": "price_snapshots_product_id_products_id_fk", + "tableFrom": "price_snapshots", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.products": { + "name": "products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shop": { + "name": "shop", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_scraped_at": { + "name": "last_scraped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "products_url_unique": { + "name": "products_url_unique", + "nullsNotDistinct": false, + "columns": [ + "url" + ] + } + }, + "policies": {}, + "checkConstraints": { + "shop_check": { + "name": "shop_check", + "value": "\"products\".\"shop\" in ('amazon','idealo','geizhals')" + } + }, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..6d3cc3b --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1779716926721, + "tag": "0000_motionless_beast", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/package.json b/package.json index e96b121..3c3eb4c 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "dotenv": "^17.4.2", "drizzle-kit": "^0.31.10", "eslint": "^9", "eslint-config-next": "16.2.2", diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts new file mode 100644 index 0000000..6b42d83 --- /dev/null +++ b/src/lib/db/index.ts @@ -0,0 +1,10 @@ +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +import * as schema from './schema' + +const connectionString = process.env.DATABASE_URL +if (!connectionString) throw new Error('DATABASE_URL not set') + +const client = postgres(connectionString, { max: 5 }) +export const db = drizzle(client, { schema }) +export * from './schema' diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts new file mode 100644 index 0000000..8fe0560 --- /dev/null +++ b/src/lib/db/schema.ts @@ -0,0 +1,46 @@ +import { pgTable, uuid, text, timestamp, boolean, integer, numeric, bigserial, jsonb, index, check } from 'drizzle-orm/pg-core' +import { sql } from 'drizzle-orm' + +export const products = pgTable('products', { + id: uuid('id').primaryKey().defaultRandom(), + url: text('url').notNull().unique(), + shop: text('shop').notNull(), + name: text('name').notNull(), + imageUrl: text('image_url'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + enabled: boolean('enabled').notNull().default(true), + lastScrapedAt: timestamp('last_scraped_at', { withTimezone: true }), + consecutiveFailures: integer('consecutive_failures').notNull().default(0), +}, (t) => ({ + shopCheck: check('shop_check', sql`${t.shop} in ('amazon','idealo','geizhals')`), +})) + +export const priceSnapshots = pgTable('price_snapshots', { + id: bigserial('id', { mode: 'number' }).primaryKey(), + productId: uuid('product_id').notNull().references(() => products.id, { onDelete: 'cascade' }), + price: numeric('price', { precision: 10, scale: 2 }), + currency: text('currency').notNull().default('EUR'), + availability: text('availability'), + error: text('error'), + scrapedAt: timestamp('scraped_at', { withTimezone: true }).notNull().defaultNow(), +}, (t) => ({ + productScrapedIdx: index('snapshots_product_scraped_idx').on(t.productId, t.scrapedAt.desc()), +})) + +export const alerts = pgTable('alerts', { + id: uuid('id').primaryKey().defaultRandom(), + productId: uuid('product_id').notNull().references(() => products.id, { onDelete: 'cascade' }), + type: text('type').notNull(), + config: jsonb('config').notNull(), + enabled: boolean('enabled').notNull().default(true), + lastTriggeredAt: timestamp('last_triggered_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}, (t) => ({ + typeCheck: check('alert_type_check', sql`${t.type} in ('target_price','all_time_low','percent_drop')`), +})) + +export type Product = typeof products.$inferSelect +export type NewProduct = typeof products.$inferInsert +export type PriceSnapshot = typeof priceSnapshots.$inferSelect +export type Alert = typeof alerts.$inferSelect +export type NewAlert = typeof alerts.$inferInsert