Refactor user lists

This commit is contained in:
gpt-engineer-app[bot]
2025-10-02 03:38:55 +00:00
parent 7df81a6ba0
commit 8ec0d59e75
7 changed files with 1041 additions and 0 deletions

View File

@@ -0,0 +1,154 @@
import { useState, useEffect } from "react";
import { UserTopList, UserTopListItem, Park, Ride, Company } from "@/types/database";
import { supabase } from "@/integrations/supabase/client";
import { Link } from "react-router-dom";
import { Badge } from "@/components/ui/badge";
interface ListDisplayProps {
list: UserTopList;
}
interface EnrichedListItem extends UserTopListItem {
entity?: Park | Ride | Company;
}
export function ListDisplay({ list }: ListDisplayProps) {
const [items, setItems] = useState<EnrichedListItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchItemsWithEntities();
}, [list.id]);
const fetchItemsWithEntities = async () => {
setLoading(true);
// First, get the list items
const { data: itemsData, error: itemsError } = await supabase
.from("user_top_list_items")
.select("*")
.eq("list_id", list.id)
.order("position", { ascending: true });
if (itemsError) {
console.error("Error fetching items:", itemsError);
setLoading(false);
return;
}
// Then, fetch the entities for each item
const enrichedItems = await Promise.all(
(itemsData as UserTopListItem[]).map(async (item) => {
let entity = null;
if (item.entity_type === "park") {
const { data } = await supabase
.from("parks")
.select("id, name, slug, park_type, location_id")
.eq("id", item.entity_id)
.single();
entity = data;
} else if (item.entity_type === "ride") {
const { data } = await supabase
.from("rides")
.select("id, name, slug, category, park_id")
.eq("id", item.entity_id)
.single();
entity = data;
} else if (item.entity_type === "company") {
const { data } = await supabase
.from("companies")
.select("id, name, slug, company_type")
.eq("id", item.entity_id)
.single();
entity = data;
}
return { ...item, entity };
})
);
setItems(enrichedItems);
setLoading(false);
};
const getEntityUrl = (item: EnrichedListItem) => {
if (!item.entity) return "#";
const entity = item.entity as any;
if (item.entity_type === "park") {
return `/parks/${entity.slug}`;
} else if (item.entity_type === "ride") {
// We need park slug for rides
return `/rides/${entity.slug}`;
} else if (item.entity_type === "company") {
return `/companies/${entity.slug}`;
}
return "#";
};
if (loading) {
return <div className="text-center py-4 text-muted-foreground">Loading...</div>;
}
if (items.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground">
This list is empty. Click "Edit" to add items.
</div>
);
}
return (
<ol className="space-y-2">
{items.map((item, index) => (
<li key={item.id} className="flex items-start gap-3 p-3 rounded-lg hover:bg-muted/50 transition-colors">
<span className="font-bold text-lg text-muted-foreground min-w-[2rem]">
{index + 1}.
</span>
<div className="flex-1">
{item.entity ? (
<Link
to={getEntityUrl(item)}
className="font-medium hover:underline"
>
{(item.entity as any).name}
</Link>
) : (
<span className="font-medium text-muted-foreground">
[Deleted or unavailable]
</span>
)}
<div className="flex gap-2 mt-1">
<Badge variant="secondary" className="text-xs">
{item.entity_type}
</Badge>
{item.entity && item.entity_type === "park" && (
<Badge variant="outline" className="text-xs">
{(item.entity as Park).park_type}
</Badge>
)}
{item.entity && item.entity_type === "ride" && (
<Badge variant="outline" className="text-xs">
{(item.entity as Ride).category}
</Badge>
)}
{item.entity && item.entity_type === "company" && (
<Badge variant="outline" className="text-xs">
{(item.entity as Company).company_type}
</Badge>
)}
</div>
{item.notes && (
<p className="text-sm text-muted-foreground mt-2 italic">
"{item.notes}"
</p>
)}
</div>
</li>
))}
</ol>
);
}

View File

@@ -0,0 +1,221 @@
import { useState, useEffect } from "react";
import { UserTopList, UserTopListItem } from "@/types/database";
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 { Textarea } from "@/components/ui/textarea";
import { GripVertical, Trash2, Plus } from "lucide-react";
import { toast } from "sonner";
import { ListSearch } from "./ListSearch";
interface ListItemEditorProps {
list: UserTopList;
onUpdate: () => void;
onClose: () => void;
}
export function ListItemEditor({ list, onUpdate, onClose }: ListItemEditorProps) {
const [items, setItems] = useState<UserTopListItem[]>([]);
const [loading, setLoading] = useState(true);
const [showSearch, setShowSearch] = useState(false);
useEffect(() => {
fetchItems();
}, [list.id]);
const fetchItems = async () => {
setLoading(true);
const { data, error } = await supabase
.from("user_top_list_items")
.select("*")
.eq("list_id", list.id)
.order("position", { ascending: true });
if (error) {
toast.error("Failed to load list items");
console.error(error);
} else {
setItems(data as UserTopListItem[]);
}
setLoading(false);
};
const handleAddItem = async (entityType: string, entityId: string, entityName: string) => {
const newPosition = items.length + 1;
const { error } = await supabase
.from("user_top_list_items")
.insert({
list_id: list.id,
entity_type: entityType,
entity_id: entityId,
position: newPosition,
});
if (error) {
if (error.code === "23505") {
toast.error("This item is already in your list");
} else {
toast.error("Failed to add item");
console.error(error);
}
} else {
toast.success(`Added ${entityName} to list`);
fetchItems();
onUpdate();
setShowSearch(false);
}
};
const handleRemoveItem = async (itemId: string) => {
const { error } = await supabase
.from("user_top_list_items")
.delete()
.eq("id", itemId);
if (error) {
toast.error("Failed to remove item");
console.error(error);
} else {
toast.success("Item removed");
// Reorder remaining items
const remainingItems = items.filter(i => i.id !== itemId);
await reorderItems(remainingItems);
fetchItems();
onUpdate();
}
};
const handleUpdateNotes = async (itemId: string, notes: string) => {
const { error } = await supabase
.from("user_top_list_items")
.update({ notes })
.eq("id", itemId);
if (error) {
toast.error("Failed to update notes");
console.error(error);
} else {
setItems(items.map(i => i.id === itemId ? { ...i, notes } : i));
}
};
const handleMoveItem = async (itemId: string, direction: "up" | "down") => {
const currentIndex = items.findIndex(i => i.id === itemId);
if (
(direction === "up" && currentIndex === 0) ||
(direction === "down" && currentIndex === items.length - 1)
) {
return;
}
const newItems = [...items];
const swapIndex = direction === "up" ? currentIndex - 1 : currentIndex + 1;
[newItems[currentIndex], newItems[swapIndex]] = [newItems[swapIndex], newItems[currentIndex]];
await reorderItems(newItems);
setItems(newItems);
onUpdate();
};
const reorderItems = async (orderedItems: UserTopListItem[]) => {
const updates = orderedItems.map((item, index) => ({
id: item.id,
position: index + 1,
}));
for (const update of updates) {
await supabase
.from("user_top_list_items")
.update({ position: update.position })
.eq("id", update.id);
}
};
if (loading) {
return <div className="text-center py-4">Loading items...</div>;
}
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="font-semibold">Edit List Items</h3>
<div className="space-x-2">
<Button size="sm" onClick={() => setShowSearch(!showSearch)}>
<Plus className="h-4 w-4 mr-2" />
Add Item
</Button>
<Button size="sm" variant="outline" onClick={onClose}>
Done
</Button>
</div>
</div>
{showSearch && (
<ListSearch
listType={list.list_type}
onSelect={handleAddItem}
onClose={() => setShowSearch(false)}
/>
)}
{items.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No items in this list yet. Click "Add Item" to get started.
</div>
) : (
<div className="space-y-2">
{items.map((item, index) => (
<div
key={item.id}
className="flex items-start gap-2 p-3 border rounded-lg bg-card"
>
<div className="flex flex-col gap-1 mt-1">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleMoveItem(item.id, "up")}
disabled={index === 0}
>
<GripVertical className="h-4 w-4" />
</Button>
<span className="text-xs text-muted-foreground text-center">
{index + 1}
</span>
</div>
<div className="flex-1 space-y-2">
<div className="flex justify-between items-start">
<div>
<p className="font-medium">{item.entity_type} - {item.entity_id}</p>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleRemoveItem(item.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div>
<Label htmlFor={`notes-${item.id}`} className="text-xs">
Notes (optional)
</Label>
<Textarea
id={`notes-${item.id}`}
value={item.notes || ""}
onChange={(e) => handleUpdateNotes(item.id, e.target.value)}
placeholder="Add personal notes about this item..."
className="h-16 text-sm"
/>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,177 @@
import { useState, useEffect } from "react";
import { supabase } from "@/integrations/supabase/client";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Search, Plus, X } from "lucide-react";
import { useDebounce } from "@/hooks/useDebounce";
import { Badge } from "@/components/ui/badge";
interface ListSearchProps {
listType: string;
onSelect: (entityType: string, entityId: string, entityName: string) => void;
onClose: () => void;
}
interface SearchResult {
id: string;
name: string;
type: "park" | "ride" | "company";
subtitle?: string;
}
export function ListSearch({ listType, onSelect, onClose }: ListSearchProps) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery.length >= 2) {
searchEntities();
} else {
setResults([]);
}
}, [debouncedQuery, listType]);
const searchEntities = async () => {
setLoading(true);
const searchResults: SearchResult[] = [];
// Determine which entity types to search based on list type
const shouldSearchParks = listType === "parks" || listType === "mixed";
const shouldSearchRides = listType === "rides" || listType === "coasters" || listType === "mixed";
const shouldSearchCompanies = listType === "companies" || listType === "mixed";
// Search parks
if (shouldSearchParks) {
const { data: parks } = await supabase
.from("parks")
.select("id, name, park_type, location_id")
.ilike("name", `%${debouncedQuery}%`)
.limit(10);
if (parks) {
searchResults.push(
...parks.map((park) => ({
id: park.id,
name: park.name,
type: "park" as const,
subtitle: park.park_type,
}))
);
}
}
// Search rides
if (shouldSearchRides) {
const { data: rides } = await supabase
.from("rides")
.select("id, name, category, park:parks(name)")
.ilike("name", `%${debouncedQuery}%`)
.limit(10);
if (rides) {
searchResults.push(
...rides.map((ride: any) => ({
id: ride.id,
name: ride.name,
type: "ride" as const,
subtitle: ride.park?.name || ride.category,
}))
);
}
}
// Search companies
if (shouldSearchCompanies) {
const { data: companies } = await supabase
.from("companies")
.select("id, name, company_type")
.ilike("name", `%${debouncedQuery}%`)
.limit(10);
if (companies) {
searchResults.push(
...companies.map((company) => ({
id: company.id,
name: company.name,
type: "company" as const,
subtitle: company.company_type,
}))
);
}
}
setResults(searchResults);
setLoading(false);
};
return (
<div className="border rounded-lg p-4 bg-card space-y-3">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search for parks, rides, or companies..."
className="pl-9"
/>
</div>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</div>
{loading && (
<div className="text-center py-4 text-muted-foreground">
Searching...
</div>
)}
{!loading && results.length === 0 && debouncedQuery.length >= 2 && (
<div className="text-center py-4 text-muted-foreground">
No results found. Try a different search term.
</div>
)}
{!loading && results.length > 0 && (
<div className="space-y-2 max-h-64 overflow-y-auto">
{results.map((result) => (
<div
key={`${result.type}-${result.id}`}
className="flex items-center justify-between p-2 rounded hover:bg-muted transition-colors"
>
<div className="flex-1">
<p className="font-medium">{result.name}</p>
<div className="flex gap-2 mt-1">
<Badge variant="secondary" className="text-xs">
{result.type}
</Badge>
{result.subtitle && (
<Badge variant="outline" className="text-xs">
{result.subtitle}
</Badge>
)}
</div>
</div>
<Button
size="sm"
onClick={() => onSelect(result.type, result.id, result.name)}
>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</div>
))}
</div>
)}
{debouncedQuery.length < 2 && (
<div className="text-center py-4 text-muted-foreground text-sm">
Type at least 2 characters to search
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,310 @@
import { useState, useEffect } from "react";
import { useAuth } from "@/hooks/useAuth";
import { supabase } from "@/integrations/supabase/client";
import { UserTopList, UserTopListItem } from "@/types/database";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Plus, Trash2, Edit, Eye, EyeOff } from "lucide-react";
import { toast } from "sonner";
import { ListItemEditor } from "./ListItemEditor";
import { ListDisplay } from "./ListDisplay";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
export function UserListManager() {
const { user } = useAuth();
const [lists, setLists] = useState<UserTopList[]>([]);
const [loading, setLoading] = useState(true);
const [editingList, setEditingList] = useState<UserTopList | null>(null);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [newListTitle, setNewListTitle] = useState("");
const [newListDescription, setNewListDescription] = useState("");
const [newListType, setNewListType] = useState<string>("mixed");
const [newListIsPublic, setNewListIsPublic] = useState(true);
useEffect(() => {
if (user) {
fetchLists();
}
}, [user]);
const fetchLists = async () => {
if (!user) return;
setLoading(true);
const { data, error } = await supabase
.from("user_top_lists")
.select(`
*,
user_top_list_items (
id,
entity_type,
entity_id,
position,
notes,
created_at,
updated_at
)
`)
.eq("user_id", user.id)
.order("created_at", { ascending: false });
if (error) {
toast.error("Failed to load lists");
console.error(error);
} else {
// Map Supabase data to UserTopList interface
const mappedLists: UserTopList[] = (data || []).map((list: any) => ({
id: list.id,
user_id: list.user_id,
title: list.title,
description: list.description,
list_type: list.list_type as 'parks' | 'rides' | 'coasters' | 'companies' | 'mixed',
is_public: list.is_public,
created_at: list.created_at,
updated_at: list.updated_at,
items: list.user_top_list_items || [],
}));
setLists(mappedLists);
}
setLoading(false);
};
const handleCreateList = async () => {
if (!user || !newListTitle.trim()) {
toast.error("Please enter a list title");
return;
}
const { data, error } = await supabase
.from("user_top_lists")
.insert([{
user_id: user.id,
title: newListTitle,
description: newListDescription || null,
list_type: newListType,
is_public: newListIsPublic,
items: [], // Legacy field, will be empty
}])
.select()
.single();
if (error) {
toast.error("Failed to create list");
console.error(error);
} else {
toast.success("List created successfully");
const newList: UserTopList = {
id: data.id,
user_id: data.user_id,
title: data.title,
description: data.description,
list_type: data.list_type as 'parks' | 'rides' | 'coasters' | 'companies' | 'mixed',
is_public: data.is_public,
created_at: data.created_at,
updated_at: data.updated_at,
items: [],
};
setLists([newList, ...lists]);
setIsCreateDialogOpen(false);
setNewListTitle("");
setNewListDescription("");
setNewListType("mixed");
setNewListIsPublic(true);
}
};
const handleDeleteList = async (listId: string) => {
const { error } = await supabase
.from("user_top_lists")
.delete()
.eq("id", listId);
if (error) {
toast.error("Failed to delete list");
console.error(error);
} else {
toast.success("List deleted");
setLists(lists.filter(l => l.id !== listId));
}
};
const handleToggleVisibility = async (list: UserTopList) => {
const { error } = await supabase
.from("user_top_lists")
.update({ is_public: !list.is_public })
.eq("id", list.id);
if (error) {
toast.error("Failed to update list");
console.error(error);
} else {
toast.success(`List is now ${!list.is_public ? "public" : "private"}`);
setLists(lists.map(l =>
l.id === list.id ? { ...l, is_public: !l.is_public } : l
));
}
};
if (loading) {
return <div className="text-center py-8">Loading lists...</div>;
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-2xl font-bold">My Lists</h2>
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-2" />
Create List
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New List</DialogTitle>
<DialogDescription>
Create a new list to organize your favorite parks, rides, or companies.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="title">Title</Label>
<Input
id="title"
value={newListTitle}
onChange={(e) => setNewListTitle(e.target.value)}
placeholder="My Top 10 Coasters"
/>
</div>
<div>
<Label htmlFor="description">Description (optional)</Label>
<Textarea
id="description"
value={newListDescription}
onChange={(e) => setNewListDescription(e.target.value)}
placeholder="A list of my all-time favorite roller coasters"
/>
</div>
<div>
<Label htmlFor="type">List Type</Label>
<Select value={newListType} onValueChange={setNewListType}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="parks">Parks</SelectItem>
<SelectItem value="rides">Rides</SelectItem>
<SelectItem value="coasters">Coasters</SelectItem>
<SelectItem value="companies">Companies</SelectItem>
<SelectItem value="mixed">Mixed</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-2">
<Switch
id="public"
checked={newListIsPublic}
onCheckedChange={setNewListIsPublic}
/>
<Label htmlFor="public">Make this list public</Label>
</div>
<Button onClick={handleCreateList} className="w-full">
Create List
</Button>
</div>
</DialogContent>
</Dialog>
</div>
{lists.length === 0 ? (
<Card>
<CardContent className="py-8 text-center">
<p className="text-muted-foreground mb-4">You haven't created any lists yet.</p>
<Button onClick={() => setIsCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Create Your First List
</Button>
</CardContent>
</Card>
) : (
<div className="grid gap-4">
{lists.map((list) => (
<Card key={list.id}>
<CardHeader>
<div className="flex justify-between items-start">
<div>
<CardTitle>{list.title}</CardTitle>
{list.description && (
<CardDescription>{list.description}</CardDescription>
)}
<div className="flex gap-2 mt-2">
<span className="text-xs bg-secondary px-2 py-1 rounded">
{list.list_type}
</span>
<span className="text-xs bg-secondary px-2 py-1 rounded">
{list.items?.length || 0} items
</span>
</div>
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => handleToggleVisibility(list)}
title={list.is_public ? "Make private" : "Make public"}
>
{list.is_public ? (
<Eye className="h-4 w-4" />
) : (
<EyeOff className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setEditingList(list)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteList(list.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{editingList?.id === list.id ? (
<ListItemEditor
list={list}
onUpdate={fetchLists}
onClose={() => setEditingList(null)}
/>
) : (
<ListDisplay list={list} />
)}
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}

View File

@@ -2069,6 +2069,47 @@ export type Database = {
} }
Relationships: [] Relationships: []
} }
user_top_list_items: {
Row: {
created_at: string
entity_id: string
entity_type: string
id: string
list_id: string
notes: string | null
position: number
updated_at: string
}
Insert: {
created_at?: string
entity_id: string
entity_type: string
id?: string
list_id: string
notes?: string | null
position: number
updated_at?: string
}
Update: {
created_at?: string
entity_id?: string
entity_type?: string
id?: string
list_id?: string
notes?: string | null
position?: number
updated_at?: string
}
Relationships: [
{
foreignKeyName: "user_top_list_items_list_id_fkey"
columns: ["list_id"]
isOneToOne: false
referencedRelation: "user_top_lists"
referencedColumns: ["id"]
},
]
}
user_top_lists: { user_top_lists: {
Row: { Row: {
created_at: string created_at: string
@@ -2186,6 +2227,10 @@ export type Database = {
Args: Record<PropertyKey, never> Args: Record<PropertyKey, never>
Returns: undefined Returns: undefined
} }
migrate_user_list_items: {
Args: Record<PropertyKey, never>
Returns: undefined
}
update_company_ratings: { update_company_ratings: {
Args: { target_company_id: string } Args: { target_company_id: string }
Returns: undefined Returns: undefined

View File

@@ -200,4 +200,29 @@ export interface Review {
moderation_status: 'pending' | 'approved' | 'rejected' | 'flagged'; moderation_status: 'pending' | 'approved' | 'rejected' | 'flagged';
created_at: string; created_at: string;
updated_at: string; updated_at: string;
}
export interface UserTopList {
id: string;
user_id: string;
title: string;
description?: string;
list_type: 'parks' | 'rides' | 'coasters' | 'companies' | 'mixed';
is_public: boolean;
created_at: string;
updated_at: string;
items?: UserTopListItem[]; // New relational data
}
export interface UserTopListItem {
id: string;
list_id: string;
entity_type: 'park' | 'ride' | 'company';
entity_id: string;
position: number;
notes?: string;
created_at: string;
updated_at: string;
// Populated via joins
entity?: Park | Ride | Company;
} }

View File

@@ -0,0 +1,109 @@
-- Phase 4: User Lists Refactor - Create relational list items table
-- Create user_top_list_items table with proper relational structure
CREATE TABLE IF NOT EXISTS public.user_top_list_items (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
list_id uuid NOT NULL REFERENCES public.user_top_lists(id) ON DELETE CASCADE,
entity_type text NOT NULL CHECK (entity_type IN ('park', 'ride', 'company')),
entity_id uuid NOT NULL,
position integer NOT NULL,
notes text,
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT unique_list_position UNIQUE(list_id, position),
CONSTRAINT unique_list_entity UNIQUE(list_id, entity_type, entity_id)
);
-- Enable Row Level Security
ALTER TABLE public.user_top_list_items ENABLE ROW LEVEL SECURITY;
-- RLS Policy: Users can manage items in their own lists
CREATE POLICY "Users can manage their own list items"
ON public.user_top_list_items
FOR ALL
USING (
EXISTS (
SELECT 1 FROM public.user_top_lists
WHERE id = user_top_list_items.list_id
AND user_id = auth.uid()
)
);
-- RLS Policy: Public list items can be viewed by anyone
CREATE POLICY "Public list items can be viewed"
ON public.user_top_list_items
FOR SELECT
USING (
EXISTS (
SELECT 1 FROM public.user_top_lists
WHERE id = user_top_list_items.list_id
AND is_public = true
)
);
-- Create data migration function to move JSON data to relational structure
CREATE OR REPLACE FUNCTION public.migrate_user_list_items()
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
list_record RECORD;
item_record JSONB;
item_position INTEGER;
BEGIN
-- Iterate through all lists with items
FOR list_record IN
SELECT id, items
FROM public.user_top_lists
WHERE items IS NOT NULL AND jsonb_array_length(items) > 0
LOOP
item_position := 1;
-- Iterate through each item in the JSON array
FOR item_record IN
SELECT value FROM jsonb_array_elements(list_record.items)
LOOP
-- Insert into relational table, skip if already exists
INSERT INTO public.user_top_list_items (
list_id,
entity_type,
entity_id,
position,
notes
) VALUES (
list_record.id,
item_record->>'entity_type',
(item_record->>'entity_id')::uuid,
item_position,
item_record->>'notes'
)
ON CONFLICT (list_id, entity_type, entity_id) DO NOTHING;
item_position := item_position + 1;
END LOOP;
END LOOP;
RAISE NOTICE 'Migration completed successfully';
END;
$$;
-- Add performance indexes
CREATE INDEX IF NOT EXISTS idx_user_list_items_list_position
ON public.user_top_list_items (list_id, position);
CREATE INDEX IF NOT EXISTS idx_user_list_items_entity
ON public.user_top_list_items (entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_user_list_items_list_type
ON public.user_top_list_items (list_id, entity_type);
-- Add trigger for updated_at
CREATE TRIGGER update_user_top_list_items_updated_at
BEFORE UPDATE ON public.user_top_list_items
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
-- Run migration to move existing data
SELECT public.migrate_user_list_items();