feat(crm): Prospecto — medio de contacto preferido + fix visualización de documentos (Fase C)

- Prospecto (lead): se conserva "Origen" y se agrega "Medio de contacto preferido"
  (catálogo medio_contacto). Backend leads.preferred_contact_method + migración
  f0a1b2c3d4e5 reversible.
- Bug documentos: endpoint proxy GET /v1/crm/uploads/download transmite el archivo
  por el backend (valida aislamiento tenant/company) — evita la URL prefirmada al
  host interno minio:9000. RelatedManager.openDoc usa blob→objectURL.

Suite backend en verde (109). svelte-check sin errores nuevos.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ernesto Herrera
2026-08-07 07:37:30 -06:00
parent 8c7aeef1a6
commit 8431132b10
8 changed files with 89 additions and 6 deletions

View File

@@ -0,0 +1,25 @@
"""Medio de contacto preferido en el prospecto (lead)
Revision ID: f0a1b2c3d4e5
Revises: e4f5a6b7c8d9
Create Date: 2026-08-07 01:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "f0a1b2c3d4e5"
down_revision: Union[str, None] = "e4f5a6b7c8d9"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
SCHEMA = "crm"
def upgrade() -> None:
op.add_column("leads", sa.Column("preferred_contact_method", sa.String(length=20), nullable=True), schema=SCHEMA)
def downgrade() -> None:
op.drop_column("leads", "preferred_contact_method", schema=SCHEMA)

View File

@@ -11,6 +11,7 @@ class LeadCreate(BaseModel):
phone: str | None = Field(None, max_length=40) phone: str | None = Field(None, max_length=40)
company_name: str | None = Field(None, max_length=255) company_name: str | None = Field(None, max_length=255)
source: str | None = Field(None, max_length=60) source: str | None = Field(None, max_length=60)
preferred_contact_method: str | None = Field(None, max_length=20)
status: str = Field("new", max_length=20) status: str = Field("new", max_length=20)
estimated_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2) estimated_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
owner_user_id: str | None = Field(None, max_length=64) owner_user_id: str | None = Field(None, max_length=64)
@@ -24,6 +25,7 @@ class LeadUpdate(BaseModel):
phone: str | None = Field(None, max_length=40) phone: str | None = Field(None, max_length=40)
company_name: str | None = Field(None, max_length=255) company_name: str | None = Field(None, max_length=255)
source: str | None = Field(None, max_length=60) source: str | None = Field(None, max_length=60)
preferred_contact_method: str | None = Field(None, max_length=20)
status: str | None = Field(None, max_length=20) status: str | None = Field(None, max_length=20)
estimated_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2) estimated_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
owner_user_id: str | None = Field(None, max_length=64) owner_user_id: str | None = Field(None, max_length=64)
@@ -50,6 +52,7 @@ class LeadResponse(BaseModel):
phone: str | None phone: str | None
company_name: str | None company_name: str | None
source: str | None source: str | None
preferred_contact_method: str | None = None
status: str status: str
estimated_value: Decimal | None estimated_value: Decimal | None
owner_user_id: str | None owner_user_id: str | None

View File

@@ -19,6 +19,8 @@ class Lead(Base, TenantScopedMixin, TimestampMixin):
company_name: Mapped[str | None] = mapped_column(String(255), nullable=True) company_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Origen: web | referido | evento | llamada | email | otro # Origen: web | referido | evento | llamada | email | otro
source: Mapped[str | None] = mapped_column(String(60), nullable=True) source: Mapped[str | None] = mapped_column(String(60), nullable=True)
# Medio de contacto preferido (catálogo medio_contacto): llamada|correo|whatsapp|…
preferred_contact_method: Mapped[str | None] = mapped_column(String(20), nullable=True)
# Estado: new | contacted | qualified | unqualified | converted # Estado: new | contacted | qualified | unqualified | converted
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'new'"), index=True) status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'new'"), index=True)
estimated_value: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True) estimated_value: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)

View File

@@ -7,10 +7,10 @@ pide una URL firmada fresca en ``/uploads/url`` (las presignadas expiran).
import re import re
import uuid import uuid
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile, status
from core.security import get_current_user from core.security import get_current_user
from core.storage_s3 import presigned_get_url, put_object_bytes from core.storage_s3 import get_object_bytes, presigned_get_url, put_object_bytes
router = APIRouter() router = APIRouter()
@@ -61,3 +61,29 @@ def get_upload_url(
if not key.startswith(prefix): if not key.startswith(prefix):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance")
return {"url": presigned_get_url(key)} return {"url": presigned_get_url(key)}
@router.get("/uploads/download")
def download_file(
key: str = Query(..., description="Object key del archivo en el almacén"),
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
):
"""Transmite el archivo por el backend (sin exponer MinIO al navegador).
Evita el bug de la URL prefirmada que apunta al host interno ``minio:9000``.
"""
tenant_id = current_user["tenant_id"]
prefix = f"tenants/{tenant_id}/companies/{company_id}/"
if not key.startswith(prefix):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance")
try:
data = get_object_bytes(key)
except Exception:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Archivo no encontrado")
filename = key.rsplit("/", 1)[-1]
return Response(
content=data,
media_type="application/octet-stream",
headers={"Content-Disposition": f'inline; filename="{filename}"'},
)

View File

@@ -192,6 +192,7 @@ export interface Lead {
phone: string | null; phone: string | null;
company_name: string | null; company_name: string | null;
source: string | null; source: string | null;
preferred_contact_method: string | null;
status: LeadStatus; status: LeadStatus;
estimated_value: number | null; estimated_value: number | null;
owner_user_id: string | null; owner_user_id: string | null;

View File

@@ -31,3 +31,10 @@ export async function uploadUrl(fileKey: string, companyId: number): Promise<str
if (res.error) throw new Error(res.error); if (res.error) throw new Error(res.error);
return res.data!.url; return res.data!.url;
} }
/** Descarga el archivo por el backend (sin exponer MinIO) y devuelve un blob. */
export async function downloadBlob(fileKey: string, companyId: number): Promise<Blob> {
return (api as any).getBlob(
`/v1/crm/uploads/download?key=${encodeURIComponent(fileKey)}&company_id=${companyId}`
) as Promise<Blob>;
}

View File

@@ -11,7 +11,7 @@
import { DOC_TYPES, labelOf } from '$lib/components/crm/format'; import { DOC_TYPES, labelOf } from '$lib/components/crm/format';
import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte'; import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { uploadFile, uploadUrl } from '$lib/api/uploads'; import { uploadFile, downloadBlob } from '$lib/api/uploads';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
onMount(() => { onMount(() => {
@@ -62,9 +62,17 @@
async function openDoc(d: Document) { async function openDoc(d: Document) {
if (!companyId) return; if (!companyId) return;
try { try {
const url = d.file_key ? await uploadUrl(d.file_key, companyId) : d.file_url; if (d.file_key) {
if (url) window.open(url, '_blank', 'noopener'); // Descarga por el backend (evita exponer MinIO / host interno)
else toast.error('El documento no tiene archivo'); const blob = await downloadBlob(d.file_key, companyId);
const url = URL.createObjectURL(blob);
window.open(url, '_blank', 'noopener');
setTimeout(() => URL.revokeObjectURL(url), 60000);
} else if (d.file_url) {
window.open(d.file_url, '_blank', 'noopener');
} else {
toast.error('El documento no tiene archivo');
}
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo abrir el archivo'); toast.error(e instanceof Error ? e.message : 'No se pudo abrir el archivo');
} }

View File

@@ -4,10 +4,14 @@
import * as Table from '$lib/components/ui/table'; import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte'; import { companyStore } from '$lib/stores/company.svelte';
import { onMount } from 'svelte';
import { leadsAPI, type Lead, type LeadInput } from '$lib/api/crm'; import { leadsAPI, type Lead, type LeadInput } from '$lib/api/crm';
import { LEAD_SOURCES, LEAD_STATUS, labelOf, formatMoney } from '$lib/components/crm/format'; import { LEAD_SOURCES, LEAD_STATUS, labelOf, formatMoney } from '$lib/components/crm/format';
import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
onMount(() => void crmCatalogs.ensure('medio_contacto'));
let items = $state<Lead[]>([]); let items = $state<Lead[]>([]);
let loading = $state(false); let loading = $state(false);
let search = $state(''); let search = $state('');
@@ -233,6 +237,13 @@
{#each LEAD_SOURCES as s (s.value)}<option value={s.value}>{s.label}</option>{/each} {#each LEAD_SOURCES as s (s.value)}<option value={s.value}>{s.label}</option>{/each}
</select> </select>
</label> </label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Medio de contacto preferido</span>
<select class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" bind:value={form.preferred_contact_method}>
<option value={undefined}>—</option>
{#each crmCatalogs.options('medio_contacto') as m (m.value)}<option value={m.value}>{m.label}</option>{/each}
</select>
</label>
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Estado</span> <span class="font-medium">Estado</span>
<select class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" bind:value={form.status}> <select class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" bind:value={form.status}>