Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions api/transactions-delete/index.js
Original file line number Diff line number Diff line change
@@ -1,27 +1,41 @@
import { CosmosClient } from "@azure/cosmos";

function getClientPrincipal(req) {
const v = req.headers["x-ms-client-principal"];
if (!v) return null;
return JSON.parse(Buffer.from(v, "base64").toString("utf8"));
}

const endpoint = process.env.COSMOS_ENDPOINT;
const key = process.env.COSMOS_KEY;
const databaseId = process.env.COSMOS_DB || "finance";
const containerId = process.env.COSMOS_CONTAINER || "transactions";

export default async function (context, req) {
try {
const id = context.bindingData.id;
const userId = req.query.userId; // partition key
const principal = getClientPrincipal(req);
if (!principal) {
context.res = { status: 401, jsonBody: { error: "Unauthorized" } };
return;
}
const userId = principal.userId;

if (!id || !userId) {
context.res = { status: 400, jsonBody: { error: "id and userId (partition key) required" } };
// id por ruta: /api/transactions/{id}
const id = context.bindingData?.id || req.query?.id;
if (!id) {
context.res = { status: 400, jsonBody: { error: "id requerido en ruta o query" } };
return;
}

const client = new CosmosClient({ endpoint, key });
const container = client.database(databaseId).container(containerId);

// importante: siempre pasar partitionKey = userId
await container.item(id, userId).delete();

context.res = { status: 204 };
} catch (e) {
context.log.error(e);
context.res = { status: 500, jsonBody: { error: e.message } };
// Si el doc no existe o no pertenece al usuario, devolvemos 404
context.res = { status: 404, jsonBody: { error: "Not found" } };
}
}
37 changes: 22 additions & 15 deletions api/transactions-get/index.js
Original file line number Diff line number Diff line change
@@ -1,40 +1,47 @@
import { CosmosClient } from "@azure/cosmos";

function getClientPrincipal(req) {
const v = req.headers["x-ms-client-principal"];
if (!v) return null;
return JSON.parse(Buffer.from(v, "base64").toString("utf8"));
}

const endpoint = process.env.COSMOS_ENDPOINT;
const key = process.env.COSMOS_KEY;
const databaseId = process.env.COSMOS_DB || "finance";
const containerId = process.env.COSMOS_CONTAINER || "transactions";

export default async function (context, req) {
try {
const userId = req.query.userId; // opcional
const month = req.query.month; // opcional: "YYYY-MM"
const principal = getClientPrincipal(req);
if (!principal) {
context.res = { status: 401, jsonBody: { error: "Unauthorized" } };
return;
}
const userId = principal.userId; // <- el dueño de la partición

const month = req.query?.month || ""; // "YYYY-MM" u "" para histórico
const client = new CosmosClient({ endpoint, key });
const container = client.database(databaseId).container(containerId);

const where = [];
const params = [];
if (userId) { where.push("c.userId = @userId"); params.push({ name: "@userId", value: userId }); }
if (month) { where.push("STARTSWITH(c.date, @month)"); params.push({ name: "@month", value: month }); }
const where = ["c.userId = @userId"];
const params = [{ name: "@userId", value: userId }];

const query = `SELECT * FROM c ${where.length ? "WHERE " + where.join(" AND ") : ""} ORDER BY c.date DESC`;
context.log(`Query: ${query} | Params: ${JSON.stringify(params)}`);
if (month) {
where.push("STARTSWITH(c.date, @month)");
params.push({ name: "@month", value: month });
}

const query = `SELECT * FROM c WHERE ${where.join(" AND ")} ORDER BY c.date DESC`;
const { resources } = await container.items.query({ query, parameters: params }).fetchAll();

// >>> Fuerza JSON siempre <<<
context.res = {
status: 200,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(resources ?? [])
body: JSON.stringify(resources ?? []),
};
} catch (e) {
context.log.error(e);
context.res = {
status: 500,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ error: String(e?.message || e) })
};
context.res = { status: 500, jsonBody: { error: String(e.message || e) } };
}
}
65 changes: 38 additions & 27 deletions api/transactions-post/index.js
Original file line number Diff line number Diff line change
@@ -1,43 +1,54 @@
import { CosmosClient } from "@azure/cosmos";
import { randomUUID } from "crypto";
const { COSMOS_ENDPOINT, COSMOS_KEY, COSMOS_DB = "finance", COSMOS_CONTAINER = "transactions" } = process.env;

function getClientPrincipal(req) {
const v = req.headers["x-ms-client-principal"];
if (!v) return null;
return JSON.parse(Buffer.from(v, "base64").toString("utf8"));
}

const endpoint = process.env.COSMOS_ENDPOINT;
const key = process.env.COSMOS_KEY;
const databaseId = process.env.COSMOS_DB || "finance";
const containerId = process.env.COSMOS_CONTAINER || "transactions";

export default async function (context, req) {
try {
// Asegura que obtengamos el body en Node v4:
let b = req.body;
if (!b) {
try { b = await req.json(); } catch { b = null; }
const principal = getClientPrincipal(req);
if (!principal) {
context.res = { status: 401, jsonBody: { error: "Unauthorized" } };
return;
}
const userId = principal.userId;

const errs = [];
if (!b) errs.push("body required");
if (!b?.userId) errs.push("userId required");
if (!b?.date) errs.push("date (YYYY-MM-DD) required");
if (typeof b?.amount !== "number") errs.push("amount must be number");
if (!b?.category) errs.push("category required");
if (errs.length) return (context.res = { status: 400, jsonBody: { errors: errs } });
const body = req.body || {};
// ignoramos body.userId si llegara; usamos el del token
const { date, amount, category, note = "", account = "Other", tags = [] } = body;

if (!date || typeof amount !== "number" || !category) {
context.res = { status: 400, jsonBody: { error: "date, amount (number) y category son requeridos" } };
return;
}

const now = new Date().toISOString();
const item = {
id: randomUUID(),
userId: b.userId,
date: b.date,
amount: b.amount,
category: b.category,
note: b.note ?? "",
account: b.account ?? "Other",
tags: Array.isArray(b.tags) ? b.tags : [],
createdAt: now,
updatedAt: now
id: body.id || randomUUID(),
userId, // <- partición
date, // YYYY-MM-DD
amount, // negativo egreso / positivo ingreso
category,
note,
account,
tags,
createdAt: new Date().toISOString(),
};

const client = new CosmosClient({ endpoint: COSMOS_ENDPOINT, key: COSMOS_KEY });
const container = client.database(COSMOS_DB).container(COSMOS_CONTAINER);
const { resource } = await container.items.create(item);
const client = new CosmosClient({ endpoint, key });
const container = client.database(databaseId).container(containerId);
const { resource } = await container.items.create(item, { disableAutomaticIdGeneration: true });

context.res = { status: 201, jsonBody: resource };
} catch (e) {
context.log.error(e);
context.res = { status: 500, jsonBody: { error: e.message } };
context.res = { status: 500, jsonBody: { error: String(e.message || e) } };
}
}
33 changes: 31 additions & 2 deletions src/App.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { useState, useMemo } from 'react';
import React, { useState, useMemo, useEffect } from 'react';
import { getUser, login, logout } from './auth';
import { PlusCircle, Trash2, CreditCard, TrendingUp, TrendingDown, DollarSign, Download, Upload } from 'lucide-react';
import { PieChart, Pie, Cell, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { useTransactions } from './hooks/useTransactions'; // API hacia Azure Functions/Cosmos
Expand All @@ -11,8 +12,28 @@ const FinanceTracker = () => {
// Para pedir datos: si histórico => pasamos "" como month para traer todo
const queryMonth = rangeMode === 'all' ? '' : month;

// --- Sesión Entra ID (SWA) ---
const [user, setUser] = useState(null);
useEffect(() => { getUser().then(setUser); }, []);

// Si no hay usuario aún, puedes mostrar un loader o un botón de login.
// Con tu staticwebapp.config.json ya configurado, SWA redirige 401→/login,
// pero este fallback es útil por si accedes directo a /login o en local.
if (!user) {
return (
<div className="p-6 max-w-xl mx-auto">
<h1 className="text-2xl font-bold mb-2">Finance Tracker</h1>
<p className="mb-4">Necesitas iniciar sesión.</p>
<button onClick={login} className="border px-3 py-2 rounded">
Entrar con Microsoft
</button>
</div>
);
}


// === Datos desde Cosmos vía hook ===
const userId = 'mario'; // luego tomar de Auth
const userId = user.userId; // o user.userDetails si prefieres particionar por email
const { items, loading, error, create, remove } = useTransactions(userId, queryMonth);

// Helpers para navegar meses
Expand Down Expand Up @@ -187,6 +208,14 @@ const FinanceTracker = () => {
</div>
</div>

<div className="flex items-center gap-3">
<span className="text-sm text-slate-600 hidden sm:inline">{user.userDetails}</span>
<button onClick={logout} className="border px-3 py-2 rounded hover:bg-slate-50">
Cerrar sesión
</button>
</div>


{/* Tabs */}
<div className="flex gap-2 mb-4 border-b border-slate-300">
{['dashboard', 'transacciones', 'tarjetas'].map(tab => (
Expand Down
9 changes: 9 additions & 0 deletions src/auth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// src/auth.js
export async function getUser() {
const res = await fetch('/.auth/me', { credentials: 'include' });
if (!res.ok) return null;
const data = await res.json();
return data?.clientPrincipal || null; // { userId, userDetails, userRoles, identityProvider }
}
export const login = () => (window.location.href = '/login');
export const logout = () => (window.location.href = '/logout');
Loading