From f9feb67425e7414eeb8247fab6695b8c6c0a300d Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Tue, 21 Oct 2025 04:23:22 +0000 Subject: [PATCH] Add database schema --- .dockerignore | 11 + DEPLOYMENT.md | 181 ++++++++++ Dockerfile | 31 ++ docker-compose.yml | 19 + nginx.conf | 28 ++ src/App.tsx | 8 +- src/components/Navigation.tsx | 45 +++ src/integrations/supabase/types.ts | 147 +++++++- src/pages/Alerts.tsx | 336 ++++++++++++++++++ src/pages/Dashboard.tsx | 141 ++++++++ src/pages/Products.tsx | 336 ++++++++++++++++++ supabase/config.toml | 8 +- supabase/functions/check-prices/index.ts | 175 +++++++++ .../functions/send-discord-alert/index.ts | 83 +++++ ...8_2465c6c9-5595-416f-9a5a-298ba7b2ea40.sql | 75 ++++ 15 files changed, 1620 insertions(+), 4 deletions(-) create mode 100644 .dockerignore create mode 100644 DEPLOYMENT.md create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 nginx.conf create mode 100644 src/components/Navigation.tsx create mode 100644 src/pages/Alerts.tsx create mode 100644 src/pages/Dashboard.tsx create mode 100644 src/pages/Products.tsx create mode 100644 supabase/functions/check-prices/index.ts create mode 100644 supabase/functions/send-discord-alert/index.ts create mode 100644 supabase/migrations/20251021041958_2465c6c9-5595-416f-9a5a-298ba7b2ea40.sql diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3e86cd5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules +npm-debug.log +.git +.gitignore +README.md +.env +.env.local +dist +.vscode +.idea +*.log diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..9d645d4 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,181 @@ +# Price Tracker - Docker Deployment Guide + +## 🚀 Quick Start + +This is a complete price tracking system with: +- Product management (Add/Edit/Remove products) +- Specification-based alert profiles (e.g., "any 6TB harddrive under 500 DKK") +- Discord webhook notifications +- No login required + +## Prerequisites + +- Docker and Docker Compose installed +- A Discord webhook URL (optional, for notifications) + +## Installation + +### 1. Clone the repository + +```bash +git clone +cd +``` + +### 2. Build and run with Docker Compose + +```bash +docker-compose up -d +``` + +The application will be available at: **http://localhost:8080** + +### 3. Stop the application + +```bash +docker-compose down +``` + +## How It Works + +### Product Management +1. Go to **Products** tab +2. Click "Add Product" +3. Enter: + - Product name and URL + - Store (Proshop, Komplett, Amazon) + - Category (Hard Drive, RAM, Processor, Graphics Card) + - Current price + - Specifications (size, type, brand) + +### Alert Profiles (Specification-Based) + +Instead of tracking specific products, you create profiles based on what you need: + +**Example**: "I need any 6TB harddrive under 500 DKK" + +1. Go to **Alerts** tab +2. Click "Add Alert Profile" +3. Configure: + - Profile name: "6TB Storage Deal" + - Category: Hard Drive + - Target price: 500 DKK + - Minimum size: 6TB + - Type: SSD (optional) + - Discord webhook URL (optional) + +### Discord Notifications + +To receive Discord notifications: + +1. In Discord, go to Server Settings → Integrations → Webhooks +2. Create a new webhook +3. Copy the webhook URL +4. Paste it in your alert profile + +When a product matches your criteria and is below your target price, you'll get a Discord message! + +### Checking Prices + +Click the "Check Prices Now" button in the Alerts tab to manually trigger price checks. + +**Automated Checks**: Set up a cron job to call the check-prices endpoint: + +```bash +# Check prices every hour +0 * * * * curl -X POST http://localhost:8080/functions/v1/check-prices +``` + +## Architecture + +``` +Frontend (React + Vite) + ↓ +Lovable Cloud Backend + ↓ +PostgreSQL Database + ↓ +Discord Webhooks +``` + +### Database Tables: +- **products**: All tracked products with specifications +- **alert_profiles**: Specification-based alert configurations +- **price_history**: Historical price data +- **alerts_log**: Log of triggered alerts + +### Backend Functions: +- **check-prices**: Matches products to alert profiles and triggers notifications +- **send-discord-alert**: Sends formatted alerts to Discord + +## Configuration + +### Environment Variables + +The app uses Lovable Cloud which is pre-configured. No additional environment setup needed! + +### Port Configuration + +Default port is 8080. To change it, edit `docker-compose.yml`: + +```yaml +ports: + - "YOUR_PORT:80" +``` + +## Development + +### Local Development (without Docker) + +```bash +npm install +npm run dev +``` + +### Building for Production + +```bash +npm run build +``` + +## Troubleshooting + +### Can't see products or alerts +- Check browser console for errors +- Verify Lovable Cloud is connected +- Check database tables in Cloud tab + +### Discord notifications not working +- Verify webhook URL is correct +- Check edge function logs in Cloud tab +- Test webhook manually with curl + +### Docker build fails +- Clear Docker cache: `docker-compose build --no-cache` +- Check Docker logs: `docker-compose logs` + +## Features + +✅ Product CRUD operations +✅ Specification-based alerts (not tied to specific products) +✅ Discord webhook integration +✅ No authentication required +✅ Real-time price tracking +✅ Dashboard with statistics +✅ Single Docker deployment + +## Example Alert Scenarios + +1. **Budget Storage**: "Any 6TB+ harddrive under 450 DKK" +2. **RAM Upgrade**: "32GB+ DDR5 RAM under 900 DKK" +3. **GPU Deal**: "Any RTX 4070 graphics card under 4000 DKK" +4. **Fast Storage**: "2TB+ NVMe SSD under 1000 DKK" + +Each alert will match ANY product that meets your specifications! + +## Support + +For issues or questions, check: +- Lovable Cloud tab for backend logs +- Browser console for frontend errors +- Docker logs: `docker-compose logs -f` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4e5b65f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci + +# Copy source code +COPY . . + +# Build the application +RUN npm run build + +# Production stage +FROM nginx:alpine + +# Copy built files to nginx +COPY --from=builder /app/dist /usr/share/nginx/html + +# Copy nginx configuration +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Expose port +EXPOSE 80 + +# Start nginx +CMD ["nginx", "-g", "daemon off;"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2e79189 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +version: '3.8' + +services: + pricetracker: + build: + context: . + dockerfile: Dockerfile + ports: + - "8080:80" + environment: + - NODE_ENV=production + restart: unless-stopped + container_name: pricetracker + networks: + - pricetracker-network + +networks: + pricetracker-network: + driver: bridge diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..bb46a96 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,28 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Enable gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + + # Handle React Router + location / { + try_files $uri $uri/ /index.html; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/src/App.tsx b/src/App.tsx index 18daf2e..c99f71a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,7 +3,9 @@ import { Toaster as Sonner } from "@/components/ui/sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { BrowserRouter, Routes, Route } from "react-router-dom"; -import Index from "./pages/Index"; +import Dashboard from "./pages/Dashboard"; +import Products from "./pages/Products"; +import Alerts from "./pages/Alerts"; import NotFound from "./pages/NotFound"; const queryClient = new QueryClient(); @@ -15,7 +17,9 @@ const App = () => ( - } /> + } /> + } /> + } /> {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} } /> diff --git a/src/components/Navigation.tsx b/src/components/Navigation.tsx new file mode 100644 index 0000000..cc37fea --- /dev/null +++ b/src/components/Navigation.tsx @@ -0,0 +1,45 @@ +import { Link, useLocation } from "react-router-dom"; +import { cn } from "@/lib/utils"; +import { LayoutDashboard, Package, Bell } from "lucide-react"; + +export const Navigation = () => { + const location = useLocation(); + + const links = [ + { href: "/", label: "Dashboard", icon: LayoutDashboard }, + { href: "/products", label: "Products", icon: Package }, + { href: "/alerts", label: "Alerts", icon: Bell }, + ]; + + return ( + + ); +}; diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 5997274..9483ffb 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -14,7 +14,152 @@ export type Database = { } public: { Tables: { - [_ in never]: never + alert_profiles: { + Row: { + category: string + created_at: string | null + discord_webhook_url: string | null + enabled: boolean | null + id: string + name: string + specs_filter: Json + target_price: number + } + Insert: { + category: string + created_at?: string | null + discord_webhook_url?: string | null + enabled?: boolean | null + id?: string + name: string + specs_filter: Json + target_price: number + } + Update: { + category?: string + created_at?: string | null + discord_webhook_url?: string | null + enabled?: boolean | null + id?: string + name?: string + specs_filter?: Json + target_price?: number + } + Relationships: [] + } + alerts_log: { + Row: { + alert_profile_id: string | null + created_at: string | null + id: string + message: string | null + product_id: string | null + sent_to_discord: boolean | null + triggered_price: number + } + Insert: { + alert_profile_id?: string | null + created_at?: string | null + id?: string + message?: string | null + product_id?: string | null + sent_to_discord?: boolean | null + triggered_price: number + } + Update: { + alert_profile_id?: string | null + created_at?: string | null + id?: string + message?: string | null + product_id?: string | null + sent_to_discord?: boolean | null + triggered_price?: number + } + Relationships: [ + { + foreignKeyName: "alerts_log_alert_profile_id_fkey" + columns: ["alert_profile_id"] + isOneToOne: false + referencedRelation: "alert_profiles" + referencedColumns: ["id"] + }, + { + foreignKeyName: "alerts_log_product_id_fkey" + columns: ["product_id"] + isOneToOne: false + referencedRelation: "products" + referencedColumns: ["id"] + }, + ] + } + price_history: { + Row: { + checked_at: string | null + id: string + price: number + product_id: string | null + } + Insert: { + checked_at?: string | null + id?: string + price: number + product_id?: string | null + } + Update: { + checked_at?: string | null + id?: string + price?: number + product_id?: string | null + } + Relationships: [ + { + foreignKeyName: "price_history_product_id_fkey" + columns: ["product_id"] + isOneToOne: false + referencedRelation: "products" + referencedColumns: ["id"] + }, + ] + } + products: { + Row: { + category: string + created_at: string | null + currency: string | null + current_price: number + id: string + last_checked: string | null + name: string + specs: Json + store: string + url: string + } + Insert: { + category: string + created_at?: string | null + currency?: string | null + current_price: number + id?: string + last_checked?: string | null + name: string + specs: Json + store: string + url: string + } + Update: { + category?: string + created_at?: string | null + currency?: string | null + current_price?: number + id?: string + last_checked?: string | null + name?: string + specs?: Json + store?: string + url?: string + } + Relationships: [] + } } Views: { [_ in never]: never diff --git a/src/pages/Alerts.tsx b/src/pages/Alerts.tsx new file mode 100644 index 0000000..747fb42 --- /dev/null +++ b/src/pages/Alerts.tsx @@ -0,0 +1,336 @@ +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { supabase } from "@/integrations/supabase/client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { Switch } from "@/components/ui/switch"; +import { toast } from "sonner"; +import { Plus, Trash2, Edit, Bell } from "lucide-react"; +import { Navigation } from "@/components/Navigation"; + +export default function Alerts() { + const queryClient = useQueryClient(); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [editingAlert, setEditingAlert] = useState(null); + + const [formData, setFormData] = useState({ + name: "", + category: "harddrive", + target_price: "", + min_size: "", + type: "", + discord_webhook_url: "", + enabled: true + }); + + const { data: alerts, isLoading } = useQuery({ + queryKey: ["alert_profiles"], + queryFn: async () => { + const { data, error } = await supabase + .from("alert_profiles") + .select("*") + .order("created_at", { ascending: false }); + if (error) throw error; + return data; + }, + }); + + const addMutation = useMutation({ + mutationFn: async (alert: any) => { + const { error } = await supabase.from("alert_profiles").insert([{ + name: alert.name, + category: alert.category, + target_price: parseFloat(alert.target_price), + specs_filter: { + min_size: alert.min_size, + type: alert.type + }, + discord_webhook_url: alert.discord_webhook_url || null, + enabled: alert.enabled + }]); + if (error) throw error; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["alert_profiles"] }); + toast.success("Alert profile created successfully!"); + setIsDialogOpen(false); + resetForm(); + }, + onError: () => toast.error("Failed to create alert profile"), + }); + + const updateMutation = useMutation({ + mutationFn: async (alert: any) => { + const { error } = await supabase + .from("alert_profiles") + .update({ + name: alert.name, + category: alert.category, + target_price: parseFloat(alert.target_price), + specs_filter: { + min_size: alert.min_size, + type: alert.type + }, + discord_webhook_url: alert.discord_webhook_url || null, + enabled: alert.enabled + }) + .eq("id", alert.id); + if (error) throw error; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["alert_profiles"] }); + toast.success("Alert profile updated successfully!"); + setIsDialogOpen(false); + setEditingAlert(null); + resetForm(); + }, + onError: () => toast.error("Failed to update alert profile"), + }); + + const deleteMutation = useMutation({ + mutationFn: async (id: string) => { + const { error } = await supabase.from("alert_profiles").delete().eq("id", id); + if (error) throw error; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["alert_profiles"] }); + toast.success("Alert profile deleted successfully!"); + }, + onError: () => toast.error("Failed to delete alert profile"), + }); + + const checkPricesMutation = useMutation({ + mutationFn: async () => { + const response = await fetch( + `${import.meta.env.VITE_SUPABASE_URL}/functions/v1/check-prices`, + { method: "POST" } + ); + if (!response.ok) throw new Error("Failed to check prices"); + return response.json(); + }, + onSuccess: (data) => { + toast.success(`Price check complete! Found ${data.alertsTriggered} matching products.`); + }, + onError: () => toast.error("Failed to check prices"), + }); + + const resetForm = () => { + setFormData({ + name: "", + category: "harddrive", + target_price: "", + min_size: "", + type: "", + discord_webhook_url: "", + enabled: true + }); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (editingAlert) { + updateMutation.mutate({ ...formData, id: editingAlert.id }); + } else { + addMutation.mutate(formData); + } + }; + + const handleEdit = (alert: any) => { + setEditingAlert(alert); + setFormData({ + name: alert.name, + category: alert.category, + target_price: alert.target_price.toString(), + min_size: alert.specs_filter?.min_size || "", + type: alert.specs_filter?.type || "", + discord_webhook_url: alert.discord_webhook_url || "", + enabled: alert.enabled + }); + setIsDialogOpen(true); + }; + + return ( +
+ +
+
+
+

Alert Profiles

+
+ + { + setIsDialogOpen(open); + if (!open) { + setEditingAlert(null); + resetForm(); + } + }}> + + + + + + {editingAlert ? "Edit Alert Profile" : "Create Alert Profile"} + +
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="e.g., 6TB Storage Deal" + required + /> +
+
+
+ + +
+
+ + setFormData({ ...formData, target_price: e.target.value })} + required + /> +
+
+
+
+ + setFormData({ ...formData, min_size: e.target.value })} + placeholder="6TB" + required + /> +
+
+ + setFormData({ ...formData, type: e.target.value })} + placeholder="SSD" + /> +
+
+
+ + setFormData({ ...formData, discord_webhook_url: e.target.value })} + placeholder="https://discord.com/api/webhooks/..." + /> +
+
+ setFormData({ ...formData, enabled: checked })} + /> + +
+ +
+
+
+
+
+ +
+ {isLoading ? ( +
Loading alert profiles...
+ ) : alerts?.length === 0 ? ( + + + No alert profiles yet. Create your first alert to get notified when prices drop! + + + ) : ( + alerts?.map((alert: any) => ( + + +
+
+ + {alert.name} + {alert.enabled && } + +

+ {alert.category} • Target: {alert.target_price} DKK +

+
+
+ + +
+
+
+ +
+
+

Min Size

+

{alert.specs_filter?.min_size || "Any"}

+
+
+

Type

+

{alert.specs_filter?.type || "Any"}

+
+
+

Discord

+

{alert.discord_webhook_url ? "✓ Enabled" : "✗ Disabled"}

+
+
+
+
+ )) + )} +
+
+
+
+ ); +} diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx new file mode 100644 index 0000000..eb35510 --- /dev/null +++ b/src/pages/Dashboard.tsx @@ -0,0 +1,141 @@ +import { useQuery } from "@tanstack/react-query"; +import { supabase } from "@/integrations/supabase/client"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Package, Bell, TrendingDown, Clock } from "lucide-react"; +import { Navigation } from "@/components/Navigation"; + +export default function Dashboard() { + const { data: products } = useQuery({ + queryKey: ["products"], + queryFn: async () => { + const { data, error } = await supabase.from("products").select("*"); + if (error) throw error; + return data; + }, + }); + + const { data: alerts } = useQuery({ + queryKey: ["alert_profiles"], + queryFn: async () => { + const { data, error } = await supabase.from("alert_profiles").select("*"); + if (error) throw error; + return data; + }, + }); + + const { data: recentAlerts } = useQuery({ + queryKey: ["recent_alerts"], + queryFn: async () => { + const { data, error } = await supabase + .from("alerts_log") + .select("*, product:products(*), profile:alert_profiles(*)") + .order("created_at", { ascending: false }) + .limit(10); + if (error) throw error; + return data; + }, + }); + + const activeAlerts = alerts?.filter((a) => a.enabled).length || 0; + const totalProducts = products?.length || 0; + const triggeredToday = recentAlerts?.filter( + (a) => new Date(a.created_at).toDateString() === new Date().toDateString() + ).length || 0; + + return ( +
+ +
+
+

Dashboard

+ +
+ + + Total Products + + + +
{totalProducts}
+

Products being tracked

+
+
+ + + + Active Alerts + + + +
{activeAlerts}
+

Alert profiles enabled

+
+
+ + + + Triggered Today + + + +
{triggeredToday}
+

Price alerts triggered

+
+
+ + + + Total Alerts + + + +
{recentAlerts?.length || 0}
+

All time alerts

+
+
+
+ + + + Recent Price Alerts + + + {!recentAlerts || recentAlerts.length === 0 ? ( +

+ No alerts triggered yet. Add products and alert profiles to start tracking! +

+ ) : ( +
+ {recentAlerts.map((alert: any) => ( +
+
+

{alert.profile?.name}

+

{alert.product?.name}

+
+ Price: {alert.triggered_price} DKK + + {alert.product?.store} + + {new Date(alert.created_at).toLocaleString()} + {alert.sent_to_discord && ( + <> + + ✓ Sent to Discord + + )} +
+
+
+ ))} +
+ )} +
+
+
+
+
+ ); +} diff --git a/src/pages/Products.tsx b/src/pages/Products.tsx new file mode 100644 index 0000000..5a18670 --- /dev/null +++ b/src/pages/Products.tsx @@ -0,0 +1,336 @@ +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { supabase } from "@/integrations/supabase/client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { toast } from "sonner"; +import { Plus, Trash2, Edit } from "lucide-react"; +import { Navigation } from "@/components/Navigation"; + +export default function Products() { + const queryClient = useQueryClient(); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [editingProduct, setEditingProduct] = useState(null); + + const [formData, setFormData] = useState({ + name: "", + url: "", + store: "proshop", + category: "harddrive", + current_price: "", + size: "", + type: "", + brand: "" + }); + + const { data: products, isLoading } = useQuery({ + queryKey: ["products"], + queryFn: async () => { + const { data, error } = await supabase + .from("products") + .select("*") + .order("created_at", { ascending: false }); + if (error) throw error; + return data; + }, + }); + + const addMutation = useMutation({ + mutationFn: async (product: any) => { + const { error } = await supabase.from("products").insert([{ + name: product.name, + url: product.url, + store: product.store, + category: product.category, + current_price: parseFloat(product.current_price), + specs: { + size: product.size, + type: product.type, + brand: product.brand + } + }]); + if (error) throw error; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["products"] }); + toast.success("Product added successfully!"); + setIsDialogOpen(false); + resetForm(); + }, + onError: () => toast.error("Failed to add product"), + }); + + const updateMutation = useMutation({ + mutationFn: async (product: any) => { + const { error } = await supabase + .from("products") + .update({ + name: product.name, + url: product.url, + store: product.store, + category: product.category, + current_price: parseFloat(product.current_price), + specs: { + size: product.size, + type: product.type, + brand: product.brand + } + }) + .eq("id", product.id); + if (error) throw error; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["products"] }); + toast.success("Product updated successfully!"); + setIsDialogOpen(false); + setEditingProduct(null); + resetForm(); + }, + onError: () => toast.error("Failed to update product"), + }); + + const deleteMutation = useMutation({ + mutationFn: async (id: string) => { + const { error } = await supabase.from("products").delete().eq("id", id); + if (error) throw error; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["products"] }); + toast.success("Product deleted successfully!"); + }, + onError: () => toast.error("Failed to delete product"), + }); + + const resetForm = () => { + setFormData({ + name: "", + url: "", + store: "proshop", + category: "harddrive", + current_price: "", + size: "", + type: "", + brand: "" + }); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (editingProduct) { + updateMutation.mutate({ ...formData, id: editingProduct.id }); + } else { + addMutation.mutate(formData); + } + }; + + const handleEdit = (product: any) => { + setEditingProduct(product); + setFormData({ + name: product.name, + url: product.url, + store: product.store, + category: product.category, + current_price: product.current_price.toString(), + size: product.specs?.size || "", + type: product.specs?.type || "", + brand: product.specs?.brand || "" + }); + setIsDialogOpen(true); + }; + + return ( +
+ +
+
+
+

Product Management

+ { + setIsDialogOpen(open); + if (!open) { + setEditingProduct(null); + resetForm(); + } + }}> + + + + + + {editingProduct ? "Edit Product" : "Add New Product"} + +
+
+ + setFormData({ ...formData, name: e.target.value })} + required + /> +
+
+ + setFormData({ ...formData, url: e.target.value })} + required + /> +
+
+
+ + +
+
+ + +
+
+
+
+ + setFormData({ ...formData, current_price: e.target.value })} + required + /> +
+
+ + setFormData({ ...formData, size: e.target.value })} + required + /> +
+
+
+
+ + setFormData({ ...formData, type: e.target.value })} + /> +
+
+ + setFormData({ ...formData, brand: e.target.value })} + /> +
+
+ +
+
+
+
+ +
+ {isLoading ? ( +
Loading products...
+ ) : products?.length === 0 ? ( + + + No products yet. Add your first product to start tracking prices! + + + ) : ( + products?.map((product: any) => ( + + +
+
+ {product.name} +

+ {product.store} • {product.category} +

+
+
+ + +
+
+
+ +
+
+

Price

+

{product.current_price} {product.currency}

+
+
+

Size

+

{product.specs?.size || "N/A"}

+
+
+

Type

+

{product.specs?.type || "N/A"}

+
+
+

Brand

+

{product.specs?.brand || "N/A"}

+
+
+ + View on {product.store} → + +
+
+ )) + )} +
+
+
+
+ ); +} diff --git a/supabase/config.toml b/supabase/config.toml index 93bb31d..3131247 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -1 +1,7 @@ -project_id = "hhwrnnerlwmstyqhmtrr" \ No newline at end of file +project_id = "hhwrnnerlwmstyqhmtrr" + +[functions.check-prices] +verify_jwt = false + +[functions.send-discord-alert] +verify_jwt = false \ No newline at end of file diff --git a/supabase/functions/check-prices/index.ts b/supabase/functions/check-prices/index.ts new file mode 100644 index 0000000..11a5c11 --- /dev/null +++ b/supabase/functions/check-prices/index.ts @@ -0,0 +1,175 @@ +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.39.3'; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +Deno.serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + try { + const supabaseUrl = Deno.env.get('SUPABASE_URL')!; + const supabaseKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; + const supabase = createClient(supabaseUrl, supabaseKey); + + console.log('Starting price check...'); + + // Get all enabled alert profiles + const { data: alertProfiles, error: profilesError } = await supabase + .from('alert_profiles') + .select('*') + .eq('enabled', true); + + if (profilesError) { + throw profilesError; + } + + console.log(`Found ${alertProfiles?.length || 0} active alert profiles`); + + // Get all products + const { data: products, error: productsError } = await supabase + .from('products') + .select('*'); + + if (productsError) { + throw productsError; + } + + console.log(`Found ${products?.length || 0} products`); + + const alerts = []; + + // Check each alert profile against products + for (const profile of alertProfiles || []) { + for (const product of products || []) { + // Match category + if (product.category !== profile.category) continue; + + // Check if product meets specification requirements + const specsFilter = profile.specs_filter; + const productSpecs = product.specs; + + let meetsRequirements = true; + + // Check minimum size (for harddrives/ram) + if (specsFilter.min_size) { + const minSizeValue = parseFloat(specsFilter.min_size); + const productSizeValue = parseFloat(productSpecs.size || '0'); + if (productSizeValue < minSizeValue) { + meetsRequirements = false; + } + } + + // Check type (SSD/HDD/etc) + if (specsFilter.type && productSpecs.type !== specsFilter.type) { + meetsRequirements = false; + } + + // Check if price is below target + if (!meetsRequirements || product.current_price > profile.target_price) { + continue; + } + + // Found a match! Log the alert + const message = `🎯 Alert: ${profile.name}\n` + + `Product: ${product.name}\n` + + `Price: ${product.current_price} ${product.currency}\n` + + `Target: ${profile.target_price} ${product.currency}\n` + + `Store: ${product.store}\n` + + `URL: ${product.url}`; + + const { error: logError } = await supabase + .from('alerts_log') + .insert({ + alert_profile_id: profile.id, + product_id: product.id, + triggered_price: product.current_price, + message, + sent_to_discord: false + }); + + if (logError) { + console.error('Error logging alert:', logError); + } + + alerts.push({ + profile, + product, + message + }); + + // Send Discord notification if webhook URL is set + if (profile.discord_webhook_url) { + try { + await fetch(profile.discord_webhook_url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + content: message, + embeds: [{ + title: '💰 Price Alert!', + description: message, + color: 0x00ff00, + fields: [ + { + name: 'Current Price', + value: `${product.current_price} ${product.currency}`, + inline: true + }, + { + name: 'Target Price', + value: `${profile.target_price} ${product.currency}`, + inline: true + }, + { + name: 'Savings', + value: `${(profile.target_price - product.current_price).toFixed(2)} ${product.currency}`, + inline: true + } + ], + timestamp: new Date().toISOString() + }] + }) + }); + + // Update log to mark as sent + await supabase + .from('alerts_log') + .update({ sent_to_discord: true }) + .eq('alert_profile_id', profile.id) + .eq('product_id', product.id); + + console.log(`Sent Discord notification for ${product.name}`); + } catch (discordError) { + console.error('Error sending Discord notification:', discordError); + } + } + } + } + + console.log(`Price check complete. Found ${alerts.length} matching alerts`); + + return new Response( + JSON.stringify({ + success: true, + alertsTriggered: alerts.length, + alerts + }), + { headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + + } catch (error) { + console.error('Error in check-prices function:', error); + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; + return new Response( + JSON.stringify({ error: errorMessage }), + { + status: 500, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + } + ); + } +}); diff --git a/supabase/functions/send-discord-alert/index.ts b/supabase/functions/send-discord-alert/index.ts new file mode 100644 index 0000000..07ca88a --- /dev/null +++ b/supabase/functions/send-discord-alert/index.ts @@ -0,0 +1,83 @@ +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +Deno.serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + try { + const { webhookUrl, message, product, profile } = await req.json(); + + if (!webhookUrl) { + throw new Error('Discord webhook URL is required'); + } + + const embed = { + title: '💰 Price Alert Triggered!', + description: message, + color: 0x00ff00, + fields: [ + { + name: 'Product', + value: product.name, + inline: false + }, + { + name: 'Current Price', + value: `${product.current_price} ${product.currency}`, + inline: true + }, + { + name: 'Target Price', + value: `${profile.target_price} ${product.currency}`, + inline: true + }, + { + name: 'Store', + value: product.store, + inline: true + }, + { + name: 'Link', + value: product.url, + inline: false + } + ], + timestamp: new Date().toISOString() + }; + + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + content: `🎯 **${profile.name}** - Price target reached!`, + embeds: [embed] + }) + }); + + if (!response.ok) { + throw new Error(`Discord API error: ${response.status}`); + } + + console.log('Discord notification sent successfully'); + + return new Response( + JSON.stringify({ success: true }), + { headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + + } catch (error) { + console.error('Error sending Discord alert:', error); + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; + return new Response( + JSON.stringify({ error: errorMessage }), + { + status: 500, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + } + ); + } +}); diff --git a/supabase/migrations/20251021041958_2465c6c9-5595-416f-9a5a-298ba7b2ea40.sql b/supabase/migrations/20251021041958_2465c6c9-5595-416f-9a5a-298ba7b2ea40.sql new file mode 100644 index 0000000..87aee57 --- /dev/null +++ b/supabase/migrations/20251021041958_2465c6c9-5595-416f-9a5a-298ba7b2ea40.sql @@ -0,0 +1,75 @@ +-- Create products table +CREATE TABLE IF NOT EXISTS public.products ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + name TEXT NOT NULL, + url TEXT NOT NULL, + store TEXT NOT NULL, + category TEXT NOT NULL, -- 'harddrive', 'ram', 'processor', 'graphics_card' + current_price DECIMAL(10,2) NOT NULL, + currency TEXT DEFAULT 'DKK', + specs JSONB NOT NULL, -- Store size, brand, speed, etc. + last_checked TIMESTAMP WITH TIME ZONE DEFAULT now(), + created_at TIMESTAMP WITH TIME ZONE DEFAULT now() +); + +-- Create alert profiles table (specification-based, not product-specific) +CREATE TABLE IF NOT EXISTS public.alert_profiles ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + name TEXT NOT NULL, + category TEXT NOT NULL, + target_price DECIMAL(10,2) NOT NULL, + specs_filter JSONB NOT NULL, -- e.g., {"min_size": "6TB", "type": "SSD"} + discord_webhook_url TEXT, + enabled BOOLEAN DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE DEFAULT now() +); + +-- Create price history table +CREATE TABLE IF NOT EXISTS public.price_history ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + product_id UUID REFERENCES public.products(id) ON DELETE CASCADE, + price DECIMAL(10,2) NOT NULL, + checked_at TIMESTAMP WITH TIME ZONE DEFAULT now() +); + +-- Create alerts log table +CREATE TABLE IF NOT EXISTS public.alerts_log ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + alert_profile_id UUID REFERENCES public.alert_profiles(id) ON DELETE CASCADE, + product_id UUID REFERENCES public.products(id) ON DELETE CASCADE, + triggered_price DECIMAL(10,2) NOT NULL, + message TEXT, + sent_to_discord BOOLEAN DEFAULT false, + created_at TIMESTAMP WITH TIME ZONE DEFAULT now() +); + +-- Enable Row Level Security (but make everything public since no login) +ALTER TABLE public.products ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.alert_profiles ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.price_history ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.alerts_log ENABLE ROW LEVEL SECURITY; + +-- Create public access policies (no authentication required) +CREATE POLICY "Public read access for products" ON public.products FOR SELECT USING (true); +CREATE POLICY "Public insert access for products" ON public.products FOR INSERT WITH CHECK (true); +CREATE POLICY "Public update access for products" ON public.products FOR UPDATE USING (true); +CREATE POLICY "Public delete access for products" ON public.products FOR DELETE USING (true); + +CREATE POLICY "Public read access for alert_profiles" ON public.alert_profiles FOR SELECT USING (true); +CREATE POLICY "Public insert access for alert_profiles" ON public.alert_profiles FOR INSERT WITH CHECK (true); +CREATE POLICY "Public update access for alert_profiles" ON public.alert_profiles FOR UPDATE USING (true); +CREATE POLICY "Public delete access for alert_profiles" ON public.alert_profiles FOR DELETE USING (true); + +CREATE POLICY "Public read access for price_history" ON public.price_history FOR SELECT USING (true); +CREATE POLICY "Public insert access for price_history" ON public.price_history FOR INSERT WITH CHECK (true); + +CREATE POLICY "Public read access for alerts_log" ON public.alerts_log FOR SELECT USING (true); +CREATE POLICY "Public insert access for alerts_log" ON public.alerts_log FOR INSERT WITH CHECK (true); + +-- Create indexes for better performance +CREATE INDEX idx_products_category ON public.products(category); +CREATE INDEX idx_products_last_checked ON public.products(last_checked); +CREATE INDEX idx_alert_profiles_category ON public.alert_profiles(category); +CREATE INDEX idx_alert_profiles_enabled ON public.alert_profiles(enabled); +CREATE INDEX idx_price_history_product ON public.price_history(product_id); +CREATE INDEX idx_alerts_log_profile ON public.alerts_log(alert_profile_id); \ No newline at end of file