Add database schema
This commit is contained in:
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
dist
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.log
|
||||||
181
DEPLOYMENT.md
Normal file
181
DEPLOYMENT.md
Normal file
@@ -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 <your-repo-url>
|
||||||
|
cd <repo-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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`
|
||||||
31
Dockerfile
Normal file
31
Dockerfile
Normal file
@@ -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;"]
|
||||||
19
docker-compose.yml
Normal file
19
docker-compose.yml
Normal file
@@ -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
|
||||||
28
nginx.conf
Normal file
28
nginx.conf
Normal file
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,9 @@ import { Toaster as Sonner } from "@/components/ui/sonner";
|
|||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
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";
|
import NotFound from "./pages/NotFound";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
@@ -15,7 +17,9 @@ const App = () => (
|
|||||||
<Sonner />
|
<Sonner />
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Index />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/products" element={<Products />} />
|
||||||
|
<Route path="/alerts" element={<Alerts />} />
|
||||||
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
45
src/components/Navigation.tsx
Normal file
45
src/components/Navigation.tsx
Normal file
@@ -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 (
|
||||||
|
<nav className="border-b bg-card">
|
||||||
|
<div className="max-w-7xl mx-auto px-6">
|
||||||
|
<div className="flex h-16 items-center gap-8">
|
||||||
|
<Link to="/" className="text-xl font-bold">
|
||||||
|
PriceTracker
|
||||||
|
</Link>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{links.map((link) => {
|
||||||
|
const Icon = link.icon;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
to={link.href}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors",
|
||||||
|
location.pathname === link.href
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -14,7 +14,152 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
public: {
|
public: {
|
||||||
Tables: {
|
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: {
|
Views: {
|
||||||
[_ in never]: never
|
[_ in never]: never
|
||||||
|
|||||||
336
src/pages/Alerts.tsx
Normal file
336
src/pages/Alerts.tsx
Normal file
@@ -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<any>(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 (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<Navigation />
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<h1 className="text-4xl font-bold">Alert Profiles</h1>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => checkPricesMutation.mutate()}
|
||||||
|
disabled={checkPricesMutation.isPending}
|
||||||
|
>
|
||||||
|
<Bell className="mr-2 h-4 w-4" />
|
||||||
|
{checkPricesMutation.isPending ? "Checking..." : "Check Prices Now"}
|
||||||
|
</Button>
|
||||||
|
<Dialog open={isDialogOpen} onOpenChange={(open) => {
|
||||||
|
setIsDialogOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setEditingAlert(null);
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Alert Profile
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingAlert ? "Edit Alert Profile" : "Create Alert Profile"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="name">Profile Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
placeholder="e.g., 6TB Storage Deal"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="category">Category</Label>
|
||||||
|
<Select value={formData.category} onValueChange={(value) => setFormData({ ...formData, category: value })}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="harddrive">Hard Drive</SelectItem>
|
||||||
|
<SelectItem value="ram">RAM</SelectItem>
|
||||||
|
<SelectItem value="processor">Processor</SelectItem>
|
||||||
|
<SelectItem value="graphics_card">Graphics Card</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="target_price">Target Price (DKK)</Label>
|
||||||
|
<Input
|
||||||
|
id="target_price"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={formData.target_price}
|
||||||
|
onChange={(e) => setFormData({ ...formData, target_price: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="min_size">Minimum Size (e.g., 6TB, 32GB)</Label>
|
||||||
|
<Input
|
||||||
|
id="min_size"
|
||||||
|
value={formData.min_size}
|
||||||
|
onChange={(e) => setFormData({ ...formData, min_size: e.target.value })}
|
||||||
|
placeholder="6TB"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="type">Type (e.g., SSD, HDD, DDR5)</Label>
|
||||||
|
<Input
|
||||||
|
id="type"
|
||||||
|
value={formData.type}
|
||||||
|
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
|
||||||
|
placeholder="SSD"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="discord_webhook_url">Discord Webhook URL (Optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="discord_webhook_url"
|
||||||
|
type="url"
|
||||||
|
value={formData.discord_webhook_url}
|
||||||
|
onChange={(e) => setFormData({ ...formData, discord_webhook_url: e.target.value })}
|
||||||
|
placeholder="https://discord.com/api/webhooks/..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="enabled"
|
||||||
|
checked={formData.enabled}
|
||||||
|
onCheckedChange={(checked) => setFormData({ ...formData, enabled: checked })}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="enabled">Alert Enabled</Label>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="w-full">
|
||||||
|
{editingAlert ? "Update Alert Profile" : "Create Alert Profile"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<div>Loading alert profiles...</div>
|
||||||
|
) : alerts?.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-8 text-center text-muted-foreground">
|
||||||
|
No alert profiles yet. Create your first alert to get notified when prices drop!
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
alerts?.map((alert: any) => (
|
||||||
|
<Card key={alert.id} className={!alert.enabled ? "opacity-50" : ""}>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
{alert.name}
|
||||||
|
{alert.enabled && <span className="text-sm text-green-500">●</span>}
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{alert.category} • Target: {alert.target_price} DKK
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => handleEdit(alert)}>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => deleteMutation.mutate(alert.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Min Size</p>
|
||||||
|
<p className="font-semibold">{alert.specs_filter?.min_size || "Any"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Type</p>
|
||||||
|
<p className="font-semibold">{alert.specs_filter?.type || "Any"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Discord</p>
|
||||||
|
<p className="font-semibold">{alert.discord_webhook_url ? "✓ Enabled" : "✗ Disabled"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
src/pages/Dashboard.tsx
Normal file
141
src/pages/Dashboard.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<Navigation />
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<h1 className="text-4xl font-bold mb-8">Dashboard</h1>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Total Products</CardTitle>
|
||||||
|
<Package className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{totalProducts}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Products being tracked</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Active Alerts</CardTitle>
|
||||||
|
<Bell className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{activeAlerts}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Alert profiles enabled</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Triggered Today</CardTitle>
|
||||||
|
<TrendingDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{triggeredToday}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Price alerts triggered</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Total Alerts</CardTitle>
|
||||||
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{recentAlerts?.length || 0}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">All time alerts</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Recent Price Alerts</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{!recentAlerts || recentAlerts.length === 0 ? (
|
||||||
|
<p className="text-center text-muted-foreground py-8">
|
||||||
|
No alerts triggered yet. Add products and alert profiles to start tracking!
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{recentAlerts.map((alert: any) => (
|
||||||
|
<div
|
||||||
|
key={alert.id}
|
||||||
|
className="flex items-start gap-4 p-4 rounded-lg border border-border bg-card hover:bg-accent/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="font-medium mb-1">{alert.profile?.name}</h4>
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">{alert.product?.name}</p>
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<span>Price: {alert.triggered_price} DKK</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{alert.product?.store}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{new Date(alert.created_at).toLocaleString()}</span>
|
||||||
|
{alert.sent_to_discord && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="text-green-500">✓ Sent to Discord</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
336
src/pages/Products.tsx
Normal file
336
src/pages/Products.tsx
Normal file
@@ -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<any>(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 (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<Navigation />
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<h1 className="text-4xl font-bold">Product Management</h1>
|
||||||
|
<Dialog open={isDialogOpen} onOpenChange={(open) => {
|
||||||
|
setIsDialogOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setEditingProduct(null);
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Product
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingProduct ? "Edit Product" : "Add New Product"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="name">Product Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="url">Product URL</Label>
|
||||||
|
<Input
|
||||||
|
id="url"
|
||||||
|
type="url"
|
||||||
|
value={formData.url}
|
||||||
|
onChange={(e) => setFormData({ ...formData, url: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="store">Store</Label>
|
||||||
|
<Select value={formData.store} onValueChange={(value) => setFormData({ ...formData, store: value })}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="proshop">Proshop</SelectItem>
|
||||||
|
<SelectItem value="komplett">Komplett</SelectItem>
|
||||||
|
<SelectItem value="amazon">Amazon</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="category">Category</Label>
|
||||||
|
<Select value={formData.category} onValueChange={(value) => setFormData({ ...formData, category: value })}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="harddrive">Hard Drive</SelectItem>
|
||||||
|
<SelectItem value="ram">RAM</SelectItem>
|
||||||
|
<SelectItem value="processor">Processor</SelectItem>
|
||||||
|
<SelectItem value="graphics_card">Graphics Card</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="price">Current Price (DKK)</Label>
|
||||||
|
<Input
|
||||||
|
id="price"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={formData.current_price}
|
||||||
|
onChange={(e) => setFormData({ ...formData, current_price: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="size">Size (e.g., 6TB, 32GB)</Label>
|
||||||
|
<Input
|
||||||
|
id="size"
|
||||||
|
value={formData.size}
|
||||||
|
onChange={(e) => setFormData({ ...formData, size: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="type">Type (e.g., SSD, HDD, DDR5)</Label>
|
||||||
|
<Input
|
||||||
|
id="type"
|
||||||
|
value={formData.type}
|
||||||
|
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="brand">Brand</Label>
|
||||||
|
<Input
|
||||||
|
id="brand"
|
||||||
|
value={formData.brand}
|
||||||
|
onChange={(e) => setFormData({ ...formData, brand: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="w-full">
|
||||||
|
{editingProduct ? "Update Product" : "Add Product"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<div>Loading products...</div>
|
||||||
|
) : products?.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-8 text-center text-muted-foreground">
|
||||||
|
No products yet. Add your first product to start tracking prices!
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
products?.map((product: any) => (
|
||||||
|
<Card key={product.id}>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>{product.name}</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{product.store} • {product.category}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => handleEdit(product)}>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => deleteMutation.mutate(product.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Price</p>
|
||||||
|
<p className="font-semibold">{product.current_price} {product.currency}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Size</p>
|
||||||
|
<p className="font-semibold">{product.specs?.size || "N/A"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Type</p>
|
||||||
|
<p className="font-semibold">{product.specs?.type || "N/A"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Brand</p>
|
||||||
|
<p className="font-semibold">{product.specs?.brand || "N/A"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={product.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm text-primary hover:underline mt-2 inline-block"
|
||||||
|
>
|
||||||
|
View on {product.store} →
|
||||||
|
</a>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1 +1,7 @@
|
|||||||
project_id = "hhwrnnerlwmstyqhmtrr"
|
project_id = "hhwrnnerlwmstyqhmtrr"
|
||||||
|
|
||||||
|
[functions.check-prices]
|
||||||
|
verify_jwt = false
|
||||||
|
|
||||||
|
[functions.send-discord-alert]
|
||||||
|
verify_jwt = false
|
||||||
175
supabase/functions/check-prices/index.ts
Normal file
175
supabase/functions/check-prices/index.ts
Normal file
@@ -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' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
83
supabase/functions/send-discord-alert/index.ts
Normal file
83
supabase/functions/send-discord-alert/index.ts
Normal file
@@ -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' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user