From 909e9c7e6087c6089f54157d88aa211974cdea27 Mon Sep 17 00:00:00 2001 From: Luis Hernandez Date: Sat, 6 Dec 2025 18:39:23 -0300 Subject: [PATCH 01/34] feat(database): add payment status to expenses and initialize database scripts - Added a new column `payment_status` to the `expenses` table with default value 'pendiente' and a check constraint. - Updated existing expenses to set the default payment status. - Created an index on `payment_status` for improved query performance. - Developed initialization scripts for setting up the database schema, including tables for categories, expenses, budgets, and incomes. - Included example categories and verification queries in the setup script. - Extended TypeScript definitions for NextAuth to include user ID in session and JWT. --- .env.example | 15 +- .vscode/mcp.json | 8 + GITHUB_OAUTH_SETUP.md | 44 + SUPABASE_SETUP.md | 223 + app/(dashboard)/actions.ts | 164 +- .../categorias/add-category-dialog.tsx | 169 + app/(dashboard)/categorias/category-card.tsx | 88 + app/(dashboard)/categorias/page.tsx | 54 + app/(dashboard)/customers/page.tsx | 19 - app/(dashboard)/gastos/add-expense-dialog.tsx | 249 + .../gastos/edit-expense-dialog.tsx | 261 + app/(dashboard)/gastos/expenses-table.tsx | 245 + app/(dashboard)/gastos/page.tsx | 97 + app/(dashboard)/layout.tsx | 90 +- app/(dashboard)/page.tsx | 86 +- app/(dashboard)/product.tsx | 60 - app/(dashboard)/products-table.tsx | 113 - app/(dashboard)/user.tsx | 25 +- app/api/seed/route.ts | 112 - app/layout.tsx | 9 +- app/login/page.tsx | 91 +- components/ui/dialog.tsx | 122 + components/ui/label.tsx | 26 + components/ui/select.tsx | 160 + components/ui/textarea.tsx | 22 + get-user-id.sql | 12 + insert-categories.sql | 28 + lib/auth-actions.ts | 22 + lib/auth.ts | 53 +- lib/db.ts | 445 +- lib/supabase/middleware.ts | 60 + lib/supabase/server.ts | 36 + middleware.ts | 19 +- package-lock.json | 4803 +++++++++++++++++ package.json | 10 +- pnpm-lock.yaml | 1669 +++--- supabase-add-payment-status.sql | 15 + supabase-init.sql | 99 + supabase-setup.sql | 91 + types/next-auth.d.ts | 22 + 40 files changed, 8549 insertions(+), 1387 deletions(-) create mode 100644 .vscode/mcp.json create mode 100644 GITHUB_OAUTH_SETUP.md create mode 100644 SUPABASE_SETUP.md create mode 100644 app/(dashboard)/categorias/add-category-dialog.tsx create mode 100644 app/(dashboard)/categorias/category-card.tsx create mode 100644 app/(dashboard)/categorias/page.tsx delete mode 100644 app/(dashboard)/customers/page.tsx create mode 100644 app/(dashboard)/gastos/add-expense-dialog.tsx create mode 100644 app/(dashboard)/gastos/edit-expense-dialog.tsx create mode 100644 app/(dashboard)/gastos/expenses-table.tsx create mode 100644 app/(dashboard)/gastos/page.tsx delete mode 100644 app/(dashboard)/product.tsx delete mode 100644 app/(dashboard)/products-table.tsx delete mode 100644 app/api/seed/route.ts create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/select.tsx create mode 100644 components/ui/textarea.tsx create mode 100644 get-user-id.sql create mode 100644 insert-categories.sql create mode 100644 lib/auth-actions.ts create mode 100644 lib/supabase/middleware.ts create mode 100644 lib/supabase/server.ts create mode 100644 package-lock.json create mode 100644 supabase-add-payment-status.sql create mode 100644 supabase-init.sql create mode 100644 supabase-setup.sql create mode 100644 types/next-auth.d.ts diff --git a/.env.example b/.env.example index 41c72f48..0c31e5c7 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,8 @@ -# https://vercel.com/docs/storage/vercel-postgres -POSTGRES_URL= +# Supabase Configuration (SOLO SERVIDOR - MÁS SEGURO) +# Obtén estos valores en https://app.supabase.com/project/[your-project]/settings/api +# IMPORTANTE: NO uses NEXT_PUBLIC_ para mantener las credenciales en el servidor +SUPABASE_URL=https://your-project.supabase.co +SUPABASE_ANON_KEY=your-anon-key-here -NEXTAUTH_URL=http://localhost:3000 -AUTH_SECRET= # https://generate-secret.vercel.app/32 - -# https://authjs.dev/getting-started/providers/github -AUTH_GITHUB_ID= -AUTH_GITHUB_SECRET= \ No newline at end of file +# Analytics (Opcional) +NEXT_PUBLIC_ANALYTICS_ID= \ No newline at end of file diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 00000000..21d702fa --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,8 @@ +{ + "servers": { + "supabase": { + "type": "http", + "url": "https://mcp.supabase.com/mcp?project_ref=xrxaodhlaeghikhkwpjx" + } + } +} \ No newline at end of file diff --git a/GITHUB_OAUTH_SETUP.md b/GITHUB_OAUTH_SETUP.md new file mode 100644 index 00000000..ef44c75d --- /dev/null +++ b/GITHUB_OAUTH_SETUP.md @@ -0,0 +1,44 @@ +# Configurar GitHub OAuth + +## Paso 1: Crear OAuth App en GitHub + +1. Ve a https://github.com/settings/developers +2. Click en "New OAuth App" +3. Llena el formulario: + - **Application name**: Control de Gastos (o el nombre que quieras) + - **Homepage URL**: `http://localhost:3000` + - **Authorization callback URL**: `http://localhost:3000/api/auth/callback/github` +4. Click en "Register application" + +## Paso 2: Obtener credenciales + +1. Copia el **Client ID** +2. Click en "Generate a new client secret" +3. Copia el **Client Secret** (solo se muestra una vez) + +## Paso 3: Actualizar .env + +Reemplaza en tu archivo `.env`: + +```bash +GITHUB_ID=tu-client-id-aqui +GITHUB_SECRET=tu-client-secret-aqui +``` + +## Paso 4: Generar NEXTAUTH_SECRET + +```bash +openssl rand -base64 32 +``` + +Copia el resultado y reemplázalo en `.env`: + +```bash +NEXTAUTH_SECRET=el-secreto-generado +``` + +## Paso 5: Reiniciar servidor + +```bash +pnpm dev +``` diff --git a/SUPABASE_SETUP.md b/SUPABASE_SETUP.md new file mode 100644 index 00000000..a27410df --- /dev/null +++ b/SUPABASE_SETUP.md @@ -0,0 +1,223 @@ +# 🔧 Configuración de Supabase (Modo Seguro) + +## 🔒 Enfoque de Seguridad + +Esta aplicación usa un enfoque **más seguro** donde: +- ✅ **Cero credenciales expuestas al navegador** +- ✅ **Todas las operaciones pasan por el servidor** +- ✅ **Variables de entorno privadas** (sin `NEXT_PUBLIC_`) + +## Paso 1: Obtener credenciales de Supabase + +1. Ve a [app.supabase.com](https://app.supabase.com) +2. Inicia sesión o crea una cuenta +3. Crea un nuevo proyecto +4. Ve a **Settings → API** +5. Copia los siguientes valores en tu archivo `.env`: + - `SUPABASE_URL` → URL del proyecto (sin `NEXT_PUBLIC_`) + - `SUPABASE_ANON_KEY` → Anon Key (sin `NEXT_PUBLIC_`) + +## Paso 2: Configurar autenticación + +1. Ve a **Authentication → Providers** en Supabase +2. Habilita **Email** como proveedor +3. Configura las opciones de email (puedes usar las predeterminadas para desarrollo) + +## Paso 3: Crear usuario administrador + +1. Ve a **Authentication → Users** en Supabase +2. Haz clic en **Add user** → **Create new user** +3. Ingresa tu email y contraseña +4. Confirma el usuario (o usa el link de confirmación del email) + +## Paso 4: Crear las tablas + +Ve a **SQL Editor** en Supabase y ejecuta el siguiente SQL: + +```sql +-- Crear tabla de categorías +CREATE TABLE categories ( + id SERIAL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + color TEXT DEFAULT '#3B82F6', + icon TEXT, + description TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, name) +); + +-- Crear tabla de gastos +CREATE TABLE expenses ( + id SERIAL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE, + amount NUMERIC(10, 2) NOT NULL, + description TEXT, + date DATE NOT NULL, + payment_method TEXT DEFAULT 'efectivo', + notes TEXT, + is_recurring INTEGER DEFAULT 0, -- 0 = único, 1 = recurrente + recurrence_frequency TEXT, -- 'monthly', 'weekly', 'yearly', null + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Crear tabla de presupuestos +CREATE TABLE budgets ( + id SERIAL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE, + amount NUMERIC(10, 2) NOT NULL, + month INTEGER NOT NULL, + year INTEGER NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, category_id, month, year) +); + +-- Crear tabla de ingresos +CREATE TABLE incomes ( + id SERIAL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + source TEXT NOT NULL, + amount NUMERIC(10, 2) NOT NULL, + date DATE NOT NULL, + description TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Crear tabla de estadísticas +CREATE TABLE statistics ( + id SERIAL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + month INTEGER NOT NULL, + year INTEGER NOT NULL, + total_expenses NUMERIC(10, 2) DEFAULT 0, + total_income NUMERIC(10, 2) DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, month, year) +); + +-- Crear índices para mejor performance +CREATE INDEX expenses_user_id_idx ON expenses(user_id); +CREATE INDEX expenses_date_idx ON expenses(date); +CREATE INDEX expenses_category_id_idx ON expenses(category_id); +CREATE INDEX categories_user_id_idx ON categories(user_id); +CREATE INDEX budgets_user_id_idx ON budgets(user_id); +CREATE INDEX incomes_user_id_idx ON incomes(user_id); + +-- Habilitar Row Level Security (RLS) +ALTER TABLE categories ENABLE ROW LEVEL SECURITY; +ALTER TABLE expenses ENABLE ROW LEVEL SECURITY; +ALTER TABLE budgets ENABLE ROW LEVEL SECURITY; +ALTER TABLE incomes ENABLE ROW LEVEL SECURITY; +ALTER TABLE statistics ENABLE ROW LEVEL SECURITY; + +-- Crear políticas RLS para categories +CREATE POLICY "Users can view their own categories" ON categories + FOR SELECT USING (auth.uid() = user_id); + +CREATE POLICY "Users can insert their own categories" ON categories + FOR INSERT WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can update their own categories" ON categories + FOR UPDATE USING (auth.uid() = user_id); + +CREATE POLICY "Users can delete their own categories" ON categories + FOR DELETE USING (auth.uid() = user_id); + +-- Crear políticas RLS para expenses +CREATE POLICY "Users can view their own expenses" ON expenses + FOR SELECT USING (auth.uid() = user_id); + +CREATE POLICY "Users can insert their own expenses" ON expenses + FOR INSERT WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can update their own expenses" ON expenses + FOR UPDATE USING (auth.uid() = user_id); + +CREATE POLICY "Users can delete their own expenses" ON expenses + FOR DELETE USING (auth.uid() = user_id); + +-- Crear políticas RLS para budgets +CREATE POLICY "Users can view their own budgets" ON budgets + FOR SELECT USING (auth.uid() = user_id); + +CREATE POLICY "Users can insert their own budgets" ON budgets + FOR INSERT WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can update their own budgets" ON budgets + FOR UPDATE USING (auth.uid() = user_id); + +CREATE POLICY "Users can delete their own budgets" ON budgets + FOR DELETE USING (auth.uid() = user_id); + +-- Crear políticas RLS para incomes +CREATE POLICY "Users can view their own incomes" ON incomes + FOR SELECT USING (auth.uid() = user_id); + +CREATE POLICY "Users can insert their own incomes" ON incomes + FOR INSERT WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can update their own incomes" ON incomes + FOR UPDATE USING (auth.uid() = user_id); + +CREATE POLICY "Users can delete their own incomes" ON incomes + FOR DELETE USING (auth.uid() = user_id); + +-- Crear políticas RLS para statistics +CREATE POLICY "Users can view their own statistics" ON statistics + FOR SELECT USING (auth.uid() = user_id); + +CREATE POLICY "Users can insert their own statistics" ON statistics + FOR INSERT WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can update their own statistics" ON statistics + FOR UPDATE USING (auth.uid() = user_id); +``` + +## Paso 5: Instalar dependencias + +```bash +pnpm install +``` + +## Paso 6: Ejecutar la app + +```bash +pnpm dev +``` + +La app estará disponible en `http://localhost:3000` + +## 📝 Variables de entorno requeridas + +**IMPORTANTE:** No uses `NEXT_PUBLIC_` para mantener las credenciales seguras en el servidor. + +```env +# Variables privadas (SOLO servidor) +SUPABASE_URL=https://tu-proyecto.supabase.co +SUPABASE_ANON_KEY=tu-anon-key-aqui + +# Variables públicas opcionales +NEXT_PUBLIC_ANALYTICS_ID=opcional +``` + +## ✅ Verificar que todo funciona + +1. Abre [localhost:3000](http://localhost:3000) +2. Inicia sesión con el usuario que creaste en Supabase +3. Crea una categoría +4. Registra un gasto +5. Verifica los datos en Supabase + +## 🔒 Ventajas de este enfoque de seguridad + +- **Cero exposición de credenciales:** Las credenciales NUNCA llegan al navegador +- **Mejor seguridad:** Todo pasa por Server Actions con validaciones del servidor +- **Control total:** Todas las queries se ejecutan en el servidor +- **Compatible con RLS:** Funciona perfectamente con Row Level Security de Supabase +- **Prevención de abuso:** No se pueden hacer llamadas directas a Supabase desde el cliente + +¡Listo! 🎉 diff --git a/app/(dashboard)/actions.ts b/app/(dashboard)/actions.ts index b16f9056..2a01e3ba 100644 --- a/app/(dashboard)/actions.ts +++ b/app/(dashboard)/actions.ts @@ -1,10 +1,164 @@ 'use server'; -import { deleteProductById } from '@/lib/db'; +import { + deleteExpenseById, + deleteCategoryById, + createExpense, + updateExpense as updateExpenseInDb, + createCategory, + updateCategory as updateCategoryInDb +} from '@/lib/db'; import { revalidatePath } from 'next/cache'; +import { getUser } from '@/lib/auth'; -export async function deleteProduct(formData: FormData) { - // let id = Number(formData.get('id')); - // await deleteProductById(id); - // revalidatePath('/'); +export async function saveExpense(formData: FormData) { + try { + const user = await getUser(); + + if (!user) { + return { error: 'No estás autenticado' }; + } + + const userId = user.id; + + const description = formData.get('description') as string; + const amount = formData.get('amount') as string; + const date = formData.get('date') as string; + const categoryId = formData.get('categoryId') as string; + const paymentMethod = formData.get('paymentMethod') as string; + const paymentStatus = formData.get('paymentStatus') as string; + const isRecurring = formData.get('isRecurring') === 'true'; + const recurrenceFrequency = formData.get('recurrenceFrequency') as string; + const notes = formData.get('notes') as string; + + await createExpense({ + user_id: userId, + category_id: parseInt(categoryId), + amount, + description, + date, + payment_method: paymentMethod, + payment_status: paymentStatus as any, + is_recurring: isRecurring ? 1 : 0, + recurrence_frequency: isRecurring ? recurrenceFrequency : null, + notes + }); + + revalidatePath('/gastos'); + return { success: true }; + } catch (error) { + console.error('Error al guardar gasto:', error); + return { error: 'Error al guardar el gasto' }; + } +} + +export async function updateExpense(formData: FormData) { + try { + const user = await getUser(); + + if (!user) { + return { error: 'No estás autenticado' }; + } + + const id = Number(formData.get('id')); + const description = formData.get('description') as string; + const amount = formData.get('amount') as string; + const date = formData.get('date') as string; + const categoryId = formData.get('categoryId') as string; + const paymentMethod = formData.get('paymentMethod') as string; + const paymentStatus = formData.get('paymentStatus') as string; + const isRecurring = formData.get('isRecurring') === 'true'; + const recurrenceFrequency = formData.get('recurrenceFrequency') as string; + const notes = formData.get('notes') as string; + + await updateExpenseInDb(id, { + category_id: parseInt(categoryId), + amount, + description, + date, + payment_method: paymentMethod, + payment_status: paymentStatus as any, + is_recurring: isRecurring ? 1 : 0, + recurrence_frequency: isRecurring ? recurrenceFrequency : null, + notes + }); + + revalidatePath('/gastos'); + return { success: true }; + } catch (error) { + console.error('Error al actualizar gasto:', error); + return { error: 'Error al actualizar el gasto' }; + } +} + +export async function deleteExpense(formData: FormData) { + let id = Number(formData.get('id')); + await deleteExpenseById(id); + revalidatePath('/gastos'); +} + +export async function deleteCategory(formData: FormData) { + let id = Number(formData.get('id')); + await deleteCategoryById(id); + revalidatePath('/categorias'); +} + +export async function updateCategory(formData: FormData) { + try { + const user = await getUser(); + + if (!user) { + return { error: 'No estás autenticado' }; + } + + const id = Number(formData.get('id')); + const name = formData.get('name') as string; + const color = formData.get('color') as string; + const icon = formData.get('icon') as string; + const description = formData.get('description') as string; + + await updateCategoryInDb(id, { + name, + color, + icon, + description + }); + + revalidatePath('/categorias'); + return { success: true }; + } catch (error) { + console.error('Error al actualizar categoría:', error); + return { error: 'Error al actualizar la categoría' }; + } +} + +export async function saveCategory(formData: FormData) { + try { + const user = await getUser(); + + if (!user) { + return { error: 'No estás autenticado' }; + } + + const userId = user.id; + + const name = formData.get('name') as string; + const color = formData.get('color') as string; + const icon = formData.get('icon') as string; + const description = formData.get('description') as string; + + await createCategory({ + user_id: userId, + name, + color, + icon, + description + }); + + revalidatePath('/categorias'); + return { success: true }; + } catch (error) { + console.error('Error al guardar categoría:', error); + return { error: 'Error al guardar la categoría' }; + } } diff --git a/app/(dashboard)/categorias/add-category-dialog.tsx b/app/(dashboard)/categorias/add-category-dialog.tsx new file mode 100644 index 00000000..41aeefa3 --- /dev/null +++ b/app/(dashboard)/categorias/add-category-dialog.tsx @@ -0,0 +1,169 @@ +'use client'; + +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + 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 { PlusCircle } from 'lucide-react'; +import { saveCategory } from '../actions'; +import { useRouter } from 'next/navigation'; + +const COLORS = [ + { name: 'Azul', value: '#3B82F6' }, + { name: 'Verde', value: '#10B981' }, + { name: 'Amarillo', value: '#F59E0B' }, + { name: 'Púrpura', value: '#8B5CF6' }, + { name: 'Rojo', value: '#EF4444' }, + { name: 'Rosa', value: '#EC4899' }, + { name: 'Índigo', value: '#6366F1' }, + { name: 'Gris', value: '#6B7280' } +]; + +const ICONS = ['🍔', '🚗', '⚡', '🎮', '❤️', '📚', '🏠', '📦', '💰', '🎬', '🏋️', '✈️']; + +export function AddCategoryDialog() { + const [open, setOpen] = useState(false); + const [selectedColor, setSelectedColor] = useState(COLORS[0].value); + const [selectedIcon, setSelectedIcon] = useState('📦'); + const [isSubmitting, setIsSubmitting] = useState(false); + const router = useRouter(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsSubmitting(true); + + const formData = new FormData(e.currentTarget); + formData.set('color', selectedColor); + formData.set('icon', selectedIcon); + + const result = await saveCategory(formData); + + if (result?.error) { + alert(result.error); + } else { + setOpen(false); + router.refresh(); + // Reset form + e.currentTarget.reset(); + setSelectedColor(COLORS[0].value); + setSelectedIcon('📦'); + } + + setIsSubmitting(false); + }; + + return ( + + + + + + + Agregar Nueva Categoría + + Crea una categoría para organizar tus gastos. + + +
+
+ + +
+ +
+ +
+ {COLORS.map((color) => ( +
+
+ +
+ +
+ {ICONS.map((icon) => ( + + ))} +
+
+ +
+ +