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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ AUTH_RESET_URL=http://localhost:3000/reset-password
# SMTP_FROM=
# SMTP_USER=
# SMTP_PASSWORD=
# Production alternative: Resend HTTPS email delivery.
# RESEND_API_KEY=re_...
# AUTH_EMAIL_FROM=ClientFlow <no-reply@your-verified-domain.example>
# Admin is disabled by default; local development only.
ENABLE_DEV_ADMIN=0
# Set false to connect the authentication UI to the backend.
Expand Down
2 changes: 2 additions & 0 deletions README.es.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ Configura estos valores:
| `VITE_BACKEND_URL` | Dirección base de la API, sin `/api` ni `/api/login`. |
| `FRONTEND_ORIGIN` | Origen exacto del frontend autorizado por el backend. |
| `AUTH_RESET_URL` | Dirección de la página de recuperación de contraseña. |
| `RESEND_API_KEY` | Clave privada de Resend para enviar la recuperación de contraseña en producción. |
| `AUTH_EMAIL_FROM` | Remitente verificado, por ejemplo `ClientFlow <no-reply@example.com>`. |
| `ENABLE_DEV_ADMIN` | Mantén `0` salvo que habilites expresamente el administrador local de desarrollo. |

Genera un secreto JWT localmente:
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ Configure these values:
| `VITE_BACKEND_URL` | API server base address, without `/api` or `/api/login`. |
| `FRONTEND_ORIGIN` | Exact frontend origin allowed by the backend. |
| `AUTH_RESET_URL` | Frontend password-reset page address. |
| `RESEND_API_KEY` | Private Resend key used to deliver password-reset email in production. |
| `AUTH_EMAIL_FROM` | Verified sender, for example `ClientFlow <no-reply@example.com>`. |
| `ENABLE_DEV_ADMIN` | Keep `0` unless explicitly enabling the local development admin. |

Generate a JWT signing secret locally:
Expand Down
4 changes: 4 additions & 0 deletions render.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ services:
value: https://clientflow-staging.onrender.com
- key: AUTH_RESET_URL
value: https://clientflow-staging.onrender.com/reset-password
- key: RESEND_API_KEY
sync: false
- key: AUTH_EMAIL_FROM
sync: false
- key: JWT_SECRET_KEY
generateValue: true
- key: PLAN_SEED_KEY
Expand Down
43 changes: 43 additions & 0 deletions src/api/ai_fallback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Safe conversational clarifications when the private AI service is unavailable."""

import re
import unicodedata


def _normalized(value):
text = unicodedata.normalize("NFKD", (value or "").casefold())
return "".join(char for char in text if not unicodedata.combining(char))


def conversational_fallback(question):
"""Ask one useful question without making unsupported company claims."""
text = _normalized(question)
portuguese = bool(re.search(r"\b(ola|bom dia|boa tarde|boa noite|quero|preciso|consertar)\b", text))
english = bool(re.search(r"\b(hello|hi|good morning|good afternoon|want|need|repair|install)\b", text))
service_intent = bool(re.search(
r"\b(cambiar|instalar|comprar|arreglar|reparar|hacer|quiero|necesito|"
r"mudar|instalar|comprar|consertar|reparar|quero|preciso|"
r"change|install|buy|repair|fix|want|need)\b",
text,
))

if portuguese:
reply = ("Claro. Para começar, pode me dizer o que deseja fazer e em que localidade será o serviço?"
if service_intent else
"Olá! Como posso ajudar com o seu projeto hoje?")
elif english:
reply = ("Of course. To get started, what would you like done and where will the work take place?"
if service_intent else
"Hello! How can I help with your project today?")
else:
reply = ("Claro. Para empezar, ¿qué quieres hacer y en qué localidad se realizaría el trabajo?"
if service_intent else
"¡Hola! ¿En qué podemos ayudarte con tu proyecto?")

return {
"status": "pending_review",
"reply": reply,
"sources": [],
"requires_approval": True,
"reason": "local_clarification_fallback",
}
5 changes: 3 additions & 2 deletions src/api/ai_orchestration.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import os

from api.ai_context import build_conversation_context
from api.ai_fallback import conversational_fallback
from api.ai_prompt import build_agent_message
from api.ai_response import validate_agent_reply
from api.ai_service import AgentServiceError, request_agent_reply
Expand All @@ -14,7 +15,7 @@ def generate_reply_draft(company_id, conversation_id, question):
try:
context = build_conversation_context(company_id, conversation_id, question)
except EmbeddingServiceError:
return handoff_result("knowledge_service_unavailable")
return conversational_fallback(question)

if context.get("opening_reply"):
return {
Expand Down Expand Up @@ -49,7 +50,7 @@ def generate_reply_draft(company_id, conversation_id, question):
try:
raw_reply = request_agent_reply(prepared["message"], company_id=company_id)
except AgentServiceError:
return handoff_result("agent_service_unavailable")
return conversational_fallback(question)

try:
answer = validate_agent_reply(
Expand Down
25 changes: 25 additions & 0 deletions src/api/auth.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Authentication and tenant context shared by API modules (ticket #22)."""
import hashlib
import json
import os
import re
import secrets
Expand All @@ -11,6 +12,7 @@
from functools import wraps
from pathlib import Path
from urllib.parse import urlencode
from urllib.request import Request, urlopen

import click
from flask import Blueprint, current_app, g, jsonify, request
Expand Down Expand Up @@ -289,6 +291,7 @@ def me():
"name": membership.company.name,
"membership_id": membership.id,
"role": membership.role.value,
"primary_colour": membership.company.primary_colour,
}
for membership in memberships
],
Expand All @@ -314,6 +317,7 @@ def logout():
def delivery_available():
return (current_app.config.get('AUTH_RESET_SENDER') is not None
or (current_app.debug and current_app.config.get('AUTH_RESET_OUTBOX'))
or (os.getenv('RESEND_API_KEY') and os.getenv('AUTH_EMAIL_FROM'))
or (os.getenv('SMTP_HOST') and os.getenv('SMTP_FROM')))


Expand All @@ -331,6 +335,27 @@ def deliver_reset(email, token):
with os.fdopen(fd, 'w') as out:
out.write(f'To: {email}\n\n{link}\n')
return
if os.getenv('RESEND_API_KEY') and os.getenv('AUTH_EMAIL_FROM'):
message = json.dumps({
'from': os.environ['AUTH_EMAIL_FROM'],
'to': [email],
'subject': 'Restablecer contraseña — ClientFlow',
'text': f'Abre este enlace para cambiar tu contraseña (válido 30 minutos):\n{link}',
}).encode('utf-8')
api_request = Request(
'https://api.resend.com/emails',
data=message,
headers={
'Authorization': f"Bearer {os.environ['RESEND_API_KEY']}",
'Content-Type': 'application/json',
'User-Agent': 'ClientFlow/1.0',
},
method='POST',
)
with urlopen(api_request, timeout=10) as response:
if response.status not in {200, 201, 202}:
raise RuntimeError('Password reset provider rejected the message.')
return
message = EmailMessage()
message['From'] = os.environ['SMTP_FROM']
message['To'] = email
Expand Down
55 changes: 29 additions & 26 deletions src/api/members.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,11 +169,6 @@ def build_invitation(data):
@members.route("/members/invitations", methods=["POST"])
@team_admin_required
def create_invitation():
if not current_app.debug:
return jsonify(
message="Invitation delivery is not configured."
), 503

data = request.get_json(silent=True)

if not isinstance(data, dict):
Expand All @@ -185,42 +180,50 @@ def create_invitation():
db.session.rollback()
return jsonify(message=str(error)), 400

outbox = Path(current_app.config.get("MEMBER_INVITE_OUTBOX") or
Path(current_app.root_path).parent / ".local" / "invite-outbox")
file_path = outbox / f"{invitation.token_hash}.json"
invitation_url = (
os.getenv("FRONTEND_ORIGIN", "http://localhost:3000").rstrip("/")
+ "/accept-invitation#" + urlencode({"token": raw_token})
)
file_path = None

try:
outbox.mkdir(parents=True, exist_ok=True, mode=0o700)

with os.fdopen(os.open(file_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "w", encoding="utf-8") as file:
json.dump(
{
"to": invitation.email,
"subject": "Invitación a ClientFlow",
"token": raw_token,
"url": (os.getenv("FRONTEND_ORIGIN", "http://localhost:3000").rstrip("/")
+ "/accept-invitation#" + urlencode({"token": raw_token})),
"expires_at": invitation.expires_at.isoformat(),
},
file,
ensure_ascii=False,
indent=2,
)
if current_app.debug:
outbox = Path(current_app.config.get("MEMBER_INVITE_OUTBOX") or
Path(current_app.root_path).parent / ".local" / "invite-outbox")
outbox.mkdir(parents=True, exist_ok=True, mode=0o700)
file_path = outbox / f"{invitation.token_hash}.json"
with os.fdopen(os.open(file_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "w", encoding="utf-8") as file:
json.dump(
{
"to": invitation.email,
"subject": "Invitación a ClientFlow",
"token": raw_token,
"url": invitation_url,
"expires_at": invitation.expires_at.isoformat(),
},
file,
ensure_ascii=False,
indent=2,
)

db.session.commit()
except (OSError, SQLAlchemyError):
db.session.rollback()

try:
file_path.unlink(missing_ok=True)
if file_path is not None:
file_path.unlink(missing_ok=True)
except OSError:
pass

return jsonify(message="Unable to create the invitation."), 503

return jsonify(
message="Invitation saved to the local outbox.",
message=("Invitation saved to the local outbox."
if current_app.debug else "Invitation created. Share the secure link with the invited member."),
invitation_id=invitation.id,
invitation_url=invitation_url,
delivery="local_outbox" if current_app.debug else "manual",
expires_at=invitation.expires_at.isoformat(),
), 201

Expand Down
47 changes: 47 additions & 0 deletions src/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2320,6 +2320,53 @@ def update_job(job_id):
return jsonify({"error": "Unable to update job"}), 503


@api.route('/jobs/<int:job_id>/stages', methods=['POST'])
@tenant_required
def create_job_stage(job_id):
job = db.session.scalar(
select(Job).where(Job.id == job_id, Job.company_id == g.company_id)
)
if job is None:
return jsonify({"error": "Job not found"}), 404

body = request.get_json(silent=True)
if not isinstance(body, dict):
return jsonify({"error": "Expected a JSON object"}), 400

title = body.get('title')
if not isinstance(title, str) or not title.strip() or len(title.strip()) > 160:
return jsonify({"error": "Invalid stage title"}), 400

description = body.get('description')
if description is not None and (not isinstance(description, str) or len(description) > 4000):
return jsonify({"error": "Invalid stage description"}), 400

due_at = None
if body.get('due_at') not in (None, ''):
try:
due_at = _job_schedule_value(body['due_at'])
except (TypeError, ValueError):
return jsonify({"error": "Invalid stage due date"}), 400

next_position = (db.session.scalar(
select(func.max(JobStage.position)).where(JobStage.job_id == job_id)
) or 0) + 1
stage = JobStage(
job_id=job_id,
title=title.strip(),
description=description.strip() if isinstance(description, str) and description.strip() else None,
position=next_position,
status=JobStageStatus.PENDING,
due_at=due_at,
)
db.session.add(stage)
db.session.commit()
updated_job = db.session.execute(
_job_select().where(Job.id == job_id, Job.company_id == g.company_id)
).one()
return jsonify(_job_to_dict(updated_job, include_details=True)), 201


@api.route('/jobs/<int:job_id>', methods=['DELETE'])
@tenant_required
def delete_job(job_id):
Expand Down
4 changes: 2 additions & 2 deletions src/front/components/Dashboard/InteractiveCharts.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,11 @@ export const InteractiveCharts = ({ token, companyId, currentLang = "es" }) => {
<div className="col-12 col-lg-4 d-flex flex-column gap-3">
<div className="card border-0 shadow-sm p-3 bg-white rounded-3">
<h6 className="fw-bold text-dark mb-3">{t.jobStatusTitle}</h6>
{data.job_status.length ? data.job_status.map((item) => <button key={item.status} type="button" className="btn btn-light d-flex justify-content-between align-items-center py-2 px-3 mb-2 border-0 text-start w-100" onClick={() => navigate(`/jobs?status=${item.status}`)}><span className="d-flex align-items-center gap-2"><span className="rounded-circle" style={{ width: "10px", height: "10px", backgroundColor: statusColours[item.status] || "#64748b" }}></span><span className="small fw-semibold">{t[item.status] || item.status}</span></span><span className="badge bg-secondary rounded-pill">{item.count}</span></button>) : <span className="text-muted small">{t.empty}</span>}
{data.job_status.length ? data.job_status.map((item) => <button key={item.status} type="button" className="btn btn-light dashboard-breakdown-item d-flex justify-content-between align-items-center py-2 px-3 mb-2 border-0 text-start w-100" onClick={() => navigate(`/jobs?status=${item.status}`)}><span className="d-flex align-items-center gap-2"><span className="rounded-circle" style={{ width: "10px", height: "10px", backgroundColor: statusColours[item.status] || "#64748b" }}></span><span className="small fw-semibold">{t[item.status] || item.status}</span></span><span className="badge bg-secondary rounded-pill">{item.count}</span></button>) : <span className="text-muted small">{t.empty}</span>}
</div>
<div className="card border-0 shadow-sm p-3 bg-white rounded-3">
<h6 className="fw-bold text-dark mb-3">{t.leadSourcesTitle}</h6>
{data.lead_sources.length ? data.lead_sources.map((source) => <button key={source.source} type="button" className="btn btn-light d-flex justify-content-between align-items-center py-2 px-3 mb-2 border-0 text-start w-100" onClick={() => navigate(`/leads?source=${encodeURIComponent(source.source.toLowerCase())}`)}><span className="small fw-semibold">{source.source}</span><span className="badge bg-primary rounded-pill">{source.percentage}%</span></button>) : <span className="text-muted small">{t.empty}</span>}
{data.lead_sources.length ? data.lead_sources.map((source) => <button key={source.source} type="button" className="btn btn-light dashboard-breakdown-item d-flex justify-content-between align-items-center py-2 px-3 mb-2 border-0 text-start w-100" onClick={() => navigate(`/leads?source=${encodeURIComponent(source.source.toLowerCase())}`)}><span className="small fw-semibold">{source.source}</span><span className="badge bg-primary rounded-pill">{source.percentage}%</span></button>) : <span className="text-muted small">{t.empty}</span>}
</div>
</div>
</div>
Expand Down
9 changes: 7 additions & 2 deletions src/front/components/Sidebar.jsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { useEffect, useState } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { useLanguage } from "../context/LanguageContext";
import { useApp } from "../context/AppContext";
import { readApiJson } from "../services/response.mjs";

export const Sidebar = ({ isOpen, onClose }) => {
const location = useLocation();
const navigate = useNavigate();
const { t, ui } = useLanguage();
const { setBrandColour } = useApp();
const [account, setAccount] = useState({ user: null, company: null });

useEffect(() => {
Expand All @@ -23,11 +26,13 @@ export const Sidebar = ({ isOpen, onClose }) => {
});
if (!response.ok) return;

const data = await response.json();
const data = await readApiJson(response);
setAccount({
user: data.user || null,
company: data.companies?.[0] || null,
});
const company = data.companies?.[0];
if (company?.primary_colour) setBrandColour(company.primary_colour);
} catch (error) {
if (error.name !== "AbortError") {
console.error("Unable to load sidebar account context.");
Expand Down Expand Up @@ -114,7 +119,7 @@ export const Sidebar = ({ isOpen, onClose }) => {
{/* Logo y Marca con botón de cierre para móvil integrado */}
<div className="d-flex align-items-center justify-content-between mb-3 mb-md-0 px-2">
<Link to="/dashboard" onClick={handleLinkClick} className="d-flex align-items-center gap-2 text-white text-decoration-none">
<div className="rounded-2 d-flex align-items-center justify-content-center text-white fw-bold shadow-sm" style={{ width: "36px", height: "36px", backgroundColor: "#635bff" }}>
<div className="rounded-2 d-flex align-items-center justify-content-center text-white fw-bold shadow-sm" style={{ width: "36px", height: "36px", backgroundColor: "var(--cf-brand)" }}>
<span style={{ fontSize: "1rem" }}>C</span>
</div>
<div>
Expand Down
Loading
Loading