Chat interno 1-a-1 entre usuarios de la plataforma (addon skeen_chat + pagina Chat con badge de no leidos)

This commit is contained in:
2026-08-28 08:23:56 +00:00
parent d47c054ffc
commit 681c0a9173
14 changed files with 607 additions and 2 deletions

View File

@@ -22,6 +22,7 @@ import Cumpleanos from './pages/Cumpleanos';
import Inventario from './pages/Inventario';
import WacrmMessages from './pages/WacrmMessages';
import WacrmLeads from './pages/WacrmLeads';
import Chat from './pages/Chat';
import Login from './pages/Login';
import './App.css';
@@ -81,6 +82,7 @@ const App: FC = () => {
<Route path="/usuarios" element={<Navigate to="/configuracion?tab=usuarios" replace />} />
<Route path="/cumpleanos" element={<RequireAuth href="/cumpleanos"><Cumpleanos /></RequireAuth>} />
<Route path="/inventario" element={<RequireAuth href="/inventario"><Inventario /></RequireAuth>} />
<Route path="/chat" element={<RequireAuth href="/chat"><Chat /></RequireAuth>} />
<Route path="/wacrm/messages" element={<RequireAuth href="/wacrm/messages"><WacrmMessages /></RequireAuth>} />
<Route path="/wacrm/leads" element={<RequireAuth href="/wacrm/leads"><WacrmLeads /></RequireAuth>} />
<Route path="*" element={<Navigate to="/" replace />} />

View File

@@ -35,6 +35,7 @@ import GlobalSearch from './GlobalSearch';
import ThemePicker from './ThemePicker';
import NotificationBell from './NotificationBell';
import { useAuth, ROLE_LABELS } from '../lib/auth';
import { useChatUnread } from '../lib/chat';
import { useQuickLinks } from '../lib/quicklinks';
import { odooApi } from '../services/odoo';
@@ -114,6 +115,7 @@ const menuTree: MenuEntry[] = [
{
label: 'Comunicación', icon: MessageSquare,
children: [
{ label: 'Chat', href: '/chat' },
{ label: 'Cumpleañeros', href: '/cumpleanos' },
{ label: 'Mensajes', href: '/wacrm/messages' },
],
@@ -143,6 +145,7 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
const navigate = useNavigate();
const { user, canSee, hasRole, logout } = useAuth();
const [sidebarOpen, setSidebarOpen] = useState(false);
const chatUnread = useChatUnread();
const visibleLeaves = useMemo(
() => menuLeaves.filter((it) => canSee(it.href)),
@@ -383,7 +386,12 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
>
<Icon size={16} />
<span>{entry.label}</span>
<ChevronDown size={14} className={`ml-auto transition-transform ${open ? '' : '-rotate-90'}`} />
{entry.children?.some((c) => c.href === '/chat') && chatUnread > 0 && (
<span className="ml-auto inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-brand-coral text-white text-[10px] font-bold">
{chatUnread}
</span>
)}
<ChevronDown size={14} className={`${entry.children?.some((c) => c.href === '/chat') && chatUnread > 0 ? '' : 'ml-auto'} transition-transform ${open ? '' : '-rotate-90'}`} />
</button>
{open && (
<ul style={{ backgroundColor: 'rgba(0,0,0,0.15)' }}>
@@ -405,6 +413,11 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
>
<span className="inline-block w-1.5 h-1.5 rounded-full mr-2 shrink-0" style={{ backgroundColor: active ? 'var(--sidebar-accent)' : 'var(--sidebar-text)' }} />
<span>{child.label}</span>
{child.href === '/chat' && chatUnread > 0 && (
<span className="ml-auto inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-brand-coral text-white text-[10px] font-bold">
{chatUnread}
</span>
)}
</Link>
<button
type="button"

View File

@@ -31,6 +31,7 @@ import {
Star,
Pencil,
ChevronUp,
MessageCircle,
} from 'lucide-react';
import { Button, Modal, Input } from './ui';
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
@@ -38,6 +39,7 @@ import GlobalSearch from './GlobalSearch';
import ThemePicker from './ThemePicker';
import NotificationBell from './NotificationBell';
import { useAuth } from '../lib/auth';
import { useChatUnread } from '../lib/chat';
import { useQuickLinks } from '../lib/quicklinks';
import { odooApi } from '../services/odoo';
@@ -85,6 +87,7 @@ const menuSections = [
items: [
{ label: 'Reportes', icon: BarChart3, href: '/reportes' },
{ label: 'Comisiones', icon: Percent, href: '/reportes?tab=comisiones' },
{ label: 'Chat', icon: MessageCircle, href: '/chat' },
{ label: 'Cumpleañeros', icon: Cake, href: '/cumpleanos' },
{ label: 'Configuración', icon: Settings, href: '/configuracion' },
],
@@ -104,6 +107,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
const { user, canSee, hasRole, logout } = useAuth();
const [menuOpen, setMenuOpen] = useState(false);
const [expanded, setExpanded] = useState<string[]>(['Clínica']);
const chatUnread = useChatUnread();
const visibleSections = useMemo(
() =>
@@ -287,6 +291,11 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<Icon size={16} /> {item.label}
{item.href === '/chat' && chatUnread > 0 && (
<span className="ml-auto inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-brand-coral text-white text-[10px] font-bold">
{chatUnread}
</span>
)}
</Link>
);
})}
@@ -458,6 +467,11 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
>
<Icon size={16} />
{item.label}
{item.href === '/chat' && chatUnread > 0 && (
<span className="ml-auto inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-brand-coral text-white text-[10px] font-bold">
{chatUnread}
</span>
)}
</Link>
<button
type="button"
@@ -556,6 +570,11 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
}}
>
<Icon size={18} /> {item.label}
{item.href === '/chat' && chatUnread > 0 && (
<span className="ml-auto inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-brand-coral text-white text-[10px] font-bold">
{chatUnread}
</span>
)}
</Link>
);
})}

View File

@@ -31,6 +31,7 @@ import {
Pencil,
ChevronUp,
ChevronDown,
MessageCircle,
} from 'lucide-react';
import { Button, Modal, Input } from './ui';
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
@@ -38,6 +39,7 @@ import GlobalSearch from './GlobalSearch';
import ThemePicker from './ThemePicker';
import NotificationBell from './NotificationBell';
import { useAuth, ROLE_LABELS } from '../lib/auth';
import { useChatUnread } from '../lib/chat';
import { useQuickLinks } from '../lib/quicklinks';
import { odooApi } from '../services/odoo';
@@ -91,6 +93,7 @@ const menuGroups = [
{
label: 'Comunicación',
items: [
{ label: 'Chat', icon: MessageCircle, href: '/chat' },
{ label: 'Cumpleañeros', icon: Cake, href: '/cumpleanos' },
],
},
@@ -108,6 +111,7 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
const navigate = useNavigate();
const { user, canSee, hasRole, logout } = useAuth();
const [sidebarOpen, setSidebarOpen] = useState(false);
const chatUnread = useChatUnread();
const visibleGroups = useMemo(
() =>
@@ -293,7 +297,12 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
>
<Icon size={18} />
<span>{item.label}</span>
{active && <ChevronRight size={14} className="ml-auto" />}
{item.href === '/chat' && chatUnread > 0 && (
<span className="ml-auto inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-brand-coral text-white text-[10px] font-bold">
{chatUnread}
</span>
)}
{active && <ChevronRight size={14} className={item.href === '/chat' && chatUnread > 0 ? '' : 'ml-auto'} />}
</Link>
<button
type="button"

27
frontend/src/lib/chat.ts Normal file
View File

@@ -0,0 +1,27 @@
import { useEffect, useState } from 'react';
import { odooApi } from '../services/odoo';
import { useAuth } from './auth';
/** Conteo global de mensajes de chat no leídos, con polling. */
export const useChatUnread = (intervalMs = 15000): number => {
const { user } = useAuth();
const [unread, setUnread] = useState(0);
useEffect(() => {
if (!user) {
setUnread(0);
return;
}
let alive = true;
const fetchCount = () => {
odooApi.getChatUnreadCount()
.then((res) => { if (alive && res.status === 'success') setUnread(res.unread); })
.catch(() => {});
};
fetchCount();
const id = setInterval(fetchCount, intervalMs);
return () => { alive = false; clearInterval(id); };
}, [user, intervalMs]);
return unread;
};

273
frontend/src/pages/Chat.tsx Normal file
View File

@@ -0,0 +1,273 @@
import type { FC, FormEvent, KeyboardEvent } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Send, Search, ArrowLeft } from 'lucide-react';
import Layout from '../components/Layout';
import { Card, Button, EmptyState, toast } from '../components/ui';
import { ROLE_LABELS } from '../lib/auth';import { odooApi, type ChatUser, type ChatMessage } from '../services/odoo';
const POLL_MS = 5000;
// Las fechas llegan en UTC ("YYYY-MM-DD HH:MM:SS"); se muestran en hora local
const fmtTime = (d: string | null): string => {
if (!d) return '';
const dt = new Date(d.replace(' ', 'T') + 'Z');
return dt.toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' });
};
const fmtDay = (d: string | null): string => {
if (!d) return '';
const dt = new Date(d.replace(' ', 'T') + 'Z');
const today = new Date();
const sameDay = dt.toDateString() === today.toDateString();
return sameDay
? fmtTime(d)
: dt.toLocaleDateString('es-MX', { day: 'numeric', month: 'short' });
};
const initials = (name: string) =>
(name || '?').split(' ').filter(Boolean).slice(0, 2).map((w) => w[0]).join('').toUpperCase();
const Chat: FC = () => {
const [users, setUsers] = useState<ChatUser[]>([]);
const [loadingUsers, setLoadingUsers] = useState(true);
const [activeId, setActiveId] = useState<number | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [loadingThread, setLoadingThread] = useState(false);
const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false);
const [search, setSearch] = useState('');
const lastIdRef = useRef(0);
const bottomRef = useRef<HTMLDivElement>(null);
const activeUser = users.find((u) => u.id === activeId) || null;
const fetchUsers = useCallback(async () => {
try {
const res = await odooApi.getChatUsers();
if (res.status === 'success') setUsers(res.users);
} catch {
// silencioso en polling
} finally {
setLoadingUsers(false);
}
}, []);
const fetchThread = useCallback(async (otherId: number, incremental: boolean) => {
try {
const res = await odooApi.getChatMessages(otherId, incremental ? lastIdRef.current : undefined);
if (res.status !== 'success') return;
if (incremental) {
if (res.messages.length === 0) return;
setMessages((prev) => {
const known = new Set(prev.map((m) => m.id));
return [...prev, ...res.messages.filter((m) => !known.has(m.id))];
});
} else {
setMessages(res.messages);
}
lastIdRef.current = Math.max(...res.messages.map((m) => m.id), lastIdRef.current);
setTimeout(() => bottomRef.current?.scrollIntoView({ behavior: 'smooth' }), 50);
} catch {
// silencioso en polling
}
}, []);
const openConversation = useCallback(async (id: number) => {
setActiveId(id);
setMessages([]);
lastIdRef.current = 0;
setLoadingThread(true);
await fetchThread(id, false);
setLoadingThread(false);
// El servidor ya marcó leídos: refrescar conteos de la lista
fetchUsers();
}, [fetchThread, fetchUsers]);
// Carga inicial + polling de lista y hilo abierto
useEffect(() => {
fetchUsers();
const id = setInterval(() => {
fetchUsers();
if (activeId) fetchThread(activeId, true);
}, POLL_MS);
return () => clearInterval(id);
}, [fetchUsers, fetchThread, activeId]);
const send = async (e?: FormEvent) => {
e?.preventDefault();
const body = draft.trim();
if (!body || !activeId || sending) return;
try {
setSending(true);
const res = await odooApi.sendChatMessage(activeId, body);
if (res.status === 'success') {
setMessages((prev) => [...prev, res.message]);
lastIdRef.current = Math.max(lastIdRef.current, res.message.id);
setDraft('');
setTimeout(() => bottomRef.current?.scrollIntoView({ behavior: 'smooth' }), 50);
fetchUsers();
}
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo enviar el mensaje');
} finally {
setSending(false);
}
};
const onDraftKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
send();
}
};
const filteredUsers = users.filter((u) =>
!search || u.name.toLowerCase().includes(search.toLowerCase()));
return (
<Layout title="Chat" subtitle="Mensajería interna del equipo">
<Card>
<Card.Body noPadding>
<div className="flex h-[calc(100vh-14rem)] min-h-[420px]">
{/* Columna izquierda: conversaciones */}
<div className={`w-full md:w-80 md:shrink-0 border-r border-theme-border flex flex-col ${activeId ? 'hidden md:flex' : 'flex'}`}>
<div className="p-3 border-b border-theme-border">
<div className="relative">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
<input
type="text"
placeholder="Buscar usuario..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full rounded-xl border border-theme-border-strong bg-theme-surface pl-9 pr-3 py-2 text-sm text-theme-heading focus:outline-none focus:ring-2 focus:ring-theme-accent/30 focus:border-theme-accent transition"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{loadingUsers ? (
<p className="p-4 text-sm text-theme-muted">Cargando...</p>
) : filteredUsers.length === 0 ? (
<p className="p-4 text-sm text-theme-muted">No hay usuarios para mostrar.</p>
) : (
<ul>
{filteredUsers.map((u) => (
<li key={u.id}>
<button
type="button"
onClick={() => openConversation(u.id)}
className={`w-full flex items-center gap-3 px-3 py-3 text-left transition-colors hover:bg-theme-accent-bg ${activeId === u.id ? 'bg-theme-accent-bg' : ''}`}
>
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-brand-lilac text-xs font-bold text-theme-heading">
{initials(u.name)}
</span>
<span className="flex-1 min-w-0">
<span className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-medium text-theme-heading">{u.name}</span>
{u.last_date && (
<span className="shrink-0 text-[10px] text-theme-muted">{fmtDay(u.last_date)}</span>
)}
</span>
<span className="flex items-center justify-between gap-2">
<span className="truncate text-xs text-theme-muted">
{u.last_message
? `${u.last_mine ? 'Tú: ' : ''}${u.last_message}`
: ROLE_LABELS[u.role] || u.role}
</span>
{u.unread > 0 && (
<span className="shrink-0 inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-theme-accent text-theme-inverse text-[10px] font-bold">
{u.unread}
</span>
)}
</span>
</span>
</button>
</li>
))}
</ul>
)}
</div>
</div>
{/* Columna derecha: hilo */}
<div className={`flex-1 flex-col min-w-0 ${activeId ? 'flex' : 'hidden md:flex'}`}>
{!activeUser ? (
<div className="flex-1 flex items-center justify-center">
<EmptyState
title="Selecciona una conversación"
subtitle="Elige un usuario de la lista para iniciar o continuar un chat."
/>
</div>
) : (
<>
<div className="flex items-center gap-3 px-4 py-3 border-b border-theme-border">
<button
type="button"
onClick={() => setActiveId(null)}
className="md:hidden p-1.5 rounded-lg text-theme-muted hover:bg-theme-accent-bg"
aria-label="Volver a la lista"
>
<ArrowLeft size={18} />
</button>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-lilac text-xs font-bold text-theme-heading">
{initials(activeUser.name)}
</span>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-theme-heading">{activeUser.name}</p>
<p className="text-xs text-theme-muted">{ROLE_LABELS[activeUser.role] || activeUser.role}</p>
</div>
</div>
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-2 bg-theme-bg">
{loadingThread ? (
<p className="text-sm text-theme-muted text-center py-6">Cargando mensajes...</p>
) : messages.length === 0 ? (
<p className="text-sm text-theme-muted text-center py-6">
Aún no hay mensajes. Escribe el primero.
</p>
) : (
messages.map((m) => (
<div key={m.id} className={`flex ${m.mine ? 'justify-end' : 'justify-start'}`}>
<div
className={`max-w-[75%] rounded-2xl px-3.5 py-2 text-sm shadow-card ${
m.mine
? 'bg-theme-accent text-theme-inverse rounded-br-md'
: 'bg-theme-surface text-theme-heading border border-theme-border rounded-bl-md'
}`}
>
<p className="whitespace-pre-wrap break-words">{m.body}</p>
<p className={`mt-1 text-[10px] text-right ${m.mine ? 'text-theme-inverse/70' : 'text-theme-muted'}`}>
{fmtTime(m.date)}
</p>
</div>
</div>
))
)}
<div ref={bottomRef} />
</div>
<form onSubmit={send} className="flex items-end gap-2 p-3 border-t border-theme-border">
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onDraftKeyDown}
placeholder={`Mensaje para ${activeUser.name}... (Enter envía)`}
rows={1}
maxLength={2000}
className="flex-1 resize-none rounded-xl border border-theme-border-strong bg-theme-surface px-3 py-2.5 text-sm text-theme-heading focus:outline-none focus:ring-2 focus:ring-theme-accent/30 focus:border-theme-accent transition"
/>
<Button type="submit" loading={sending} disabled={!draft.trim()} aria-label="Enviar mensaje">
<Send size={16} />
</Button>
</form>
</>
)}
</div>
</div>
</Card.Body>
</Card>
</Layout>
);
};
export default Chat;

View File

@@ -544,6 +544,27 @@ export interface Receta {
contenido: string;
}
// ---- Chat interno ----
export interface ChatUser {
id: number;
name: string;
role: FrontendRole;
unread: number;
last_message: string | null;
last_date: string | null;
last_mine: boolean | null;
}
export interface ChatMessage {
id: number;
sender_id: number;
recipient_id: number;
body: string;
is_read: boolean;
mine: boolean;
date: string | null;
}
export interface Diagnostico {
id: number;
name: string;
@@ -894,6 +915,27 @@ export const odooApi = {
return data;
},
// Chat interno entre usuarios de la plataforma
async getChatUsers(): Promise<{ status: string; users: ChatUser[] }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/chat/users`);
return data;
},
async getChatMessages(userId: number, sinceId?: number): Promise<{ status: string; messages: ChatMessage[] }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/chat/messages`, { user_id: userId, since_id: sinceId || undefined });
return data;
},
async sendChatMessage(userId: number, message: string): Promise<{ status: string; message: ChatMessage }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/chat/send`, { user_id: userId, message });
return data;
},
async getChatUnreadCount(): Promise<{ status: string; unread: number }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/chat/unread_count`);
return data;
},
async getAvailableSlots(date: string, servicio_id: number): Promise<{ status: string; slots: { time: number; time_str: string }[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/appointments/slots`, { params: { date, servicio_id } });
return data;

View File

@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import models
from . import controllers

View File

@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
{
'name': 'SKEEN Chat Interno',
'version': '1.0.0',
'category': 'Healthcare',
'summary': 'Chat interno 1-a-1 entre usuarios del frontend SKEEN',
'description': """
Mensajería directa entre usuarios de skeen.frontend.user.
Sin websockets: el frontend React consulta por polling.
""",
'author': 'Consultoria Alcaraz Salazar, S.A.S.',
'website': 'https://skeen.mx',
'depends': ['skeen_whatsapp'],
'data': [
'security/ir.model.access.csv',
],
'installable': True,
'application': False,
'auto_install': False,
'license': 'LGPL-3',
}

View File

@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import chat

View File

@@ -0,0 +1,165 @@
# -*- coding: utf-8 -*-
"""Chat interno 1-a-1 entre usuarios del frontend React (skeen.frontend.user).
Mismo esquema de auth HMAC que el resto de la API. Todos los endpoints están
restringidos al usuario autenticado: solo puede listar/leer conversaciones
donde él es sender o recipient, y solo puede enviar como él mismo.
"""
from odoo import http
from odoo.http import request
from odoo.addons.skeen_whatsapp.controllers.auth import require_role
from odoo.addons.skeen_whatsapp.controllers.frontend import json_response, _parse_json_body
MAX_BODY = 2000
HISTORY_LIMIT = 50
LIST_SCAN_LIMIT = 500
class SkeenChatController(http.Controller):
def _message_to_dict(self, msg, me_id):
return {
'id': msg.id,
'sender_id': msg.sender_id.id,
'recipient_id': msg.recipient_id.id,
'body': msg.body,
'is_read': msg.is_read,
'mine': msg.sender_id.id == me_id,
'date': msg.create_date.strftime('%Y-%m-%d %H:%M:%S') if msg.create_date else None,
}
@http.route('/skeen/frontend/v1/chat/users', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('lectura')
def chat_users(self, **kw):
"""Usuarios activos (menos yo) con conteo de no leídos y último mensaje.
Orden: primero conversaciones con actividad (más reciente primero),
luego el resto alfabéticamente.
"""
try:
me = request.frontend_user
User = request.env['skeen.frontend.user'].sudo()
Msg = request.env['skeen.chat.message'].sudo()
users = User.search([('active', '=', True), ('id', '!=', me.id)], order='name')
unread = {}
for g in Msg.read_group(
[('recipient_id', '=', me.id), ('is_read', '=', False)],
['sender_id'], ['sender_id']):
unread[g['sender_id'][0]] = g['sender_id_count']
last = {}
recent = Msg.search([
'|', ('sender_id', '=', me.id), ('recipient_id', '=', me.id),
], order='id desc', limit=LIST_SCAN_LIMIT)
for m in recent:
other_id = m.recipient_id.id if m.sender_id.id == me.id else m.sender_id.id
if other_id not in last:
last[other_id] = {
'body': m.body or '',
'date': m.create_date.strftime('%Y-%m-%d %H:%M:%S') if m.create_date else None,
'mine': m.sender_id.id == me.id,
}
# Conversaciones con actividad primero (más reciente arriba)
conversations = sorted(
(u for u in users if u.id in last),
key=lambda u: last[u.id]['date'] or '', reverse=True)
rest = [u for u in users if u.id not in last]
return json_response({
'status': 'success',
'users': [{
'id': u.id,
'name': u.name,
'role': u.role,
'unread': unread.get(u.id, 0),
'last_message': last.get(u.id, {}).get('body'),
'last_date': last.get(u.id, {}).get('date'),
'last_mine': last.get(u.id, {}).get('mine'),
} for u in conversations + rest],
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/chat/messages', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('lectura')
def chat_messages(self, **kw):
"""Historial con otro usuario (últimos 50; since_id para incremental).
Marca como leídos los mensajes recibidos de ese usuario.
"""
try:
me = request.frontend_user
data = _parse_json_body()
other_id = int(data.get('user_id') or 0)
if not other_id or other_id == me.id:
return json_response({'status': 'error', 'message': 'user_id inválido'}, 400)
User = request.env['skeen.frontend.user'].sudo()
if not User.search([('id', '=', other_id), ('active', '=', True)], limit=1):
return json_response({'status': 'error', 'message': 'Usuario no encontrado'}, 404)
domain = [
'|', '&', ('sender_id', '=', me.id), ('recipient_id', '=', other_id),
'&', ('sender_id', '=', other_id), ('recipient_id', '=', me.id),
]
since_id = int(data.get('since_id') or 0)
if since_id:
domain.append(('id', '>', since_id))
Msg = request.env['skeen.chat.message'].sudo()
msgs = Msg.search(domain, order='id desc', limit=HISTORY_LIMIT).sorted('id')
# Marcar leídos los recibidos en esta conversación
incoming = msgs.filtered(lambda m: m.recipient_id.id == me.id and not m.is_read)
if incoming:
incoming.write({'is_read': True})
return json_response({
'status': 'success',
'messages': [self._message_to_dict(m, me.id) for m in msgs],
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/chat/send', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('lectura')
def chat_send(self, **kw):
"""Enviar mensaje directo a otro usuario."""
try:
me = request.frontend_user
data = _parse_json_body()
other_id = int(data.get('user_id') or 0)
body = (data.get('message') or '').strip()
if not other_id or other_id == me.id:
return json_response({'status': 'error', 'message': 'user_id inválido'}, 400)
if not body:
return json_response({'status': 'error', 'message': 'El mensaje no puede estar vacío'}, 400)
if len(body) > MAX_BODY:
return json_response(
{'status': 'error', 'message': f'El mensaje excede {MAX_BODY} caracteres'}, 400)
User = request.env['skeen.frontend.user'].sudo()
if not User.search([('id', '=', other_id), ('active', '=', True)], limit=1):
return json_response({'status': 'error', 'message': 'Usuario no encontrado'}, 404)
msg = request.env['skeen.chat.message'].sudo().create({
'sender_id': me.id,
'recipient_id': other_id,
'body': body,
})
return json_response({
'status': 'success',
'message': self._message_to_dict(msg, me.id),
}, 201)
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/chat/unread_count', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('lectura')
def chat_unread_count(self, **kw):
"""Total global de mensajes no leídos del usuario (badge del menú)."""
try:
me = request.frontend_user
total = request.env['skeen.chat.message'].sudo().search_count([
('recipient_id', '=', me.id), ('is_read', '=', False),
])
return json_response({'status': 'success', 'unread': total})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)

View File

@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import chat_message

View File

@@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
from odoo import models, fields
class SkeenChatMessage(models.Model):
"""Mensaje directo 1-a-1 entre usuarios del frontend React.
La seguridad real está en el controlador: un usuario solo puede leer o
crear mensajes donde él es sender_id o recipient_id.
"""
_name = 'skeen.chat.message'
_description = 'Mensaje de Chat Interno'
_order = 'id'
sender_id = fields.Many2one('skeen.frontend.user', string='Remitente',
required=True, index=True, ondelete='cascade')
recipient_id = fields.Many2one('skeen.frontend.user', string='Destinatario',
required=True, index=True, ondelete='cascade')
body = fields.Text(string='Mensaje', required=True)
is_read = fields.Boolean(string='Leído', default=False, index=True)
# create_date (heredado) = fecha/hora de envío

View File

@@ -0,0 +1,2 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_skeen_chat_message_user,Acceso a Mensajes de Chat SKEEN,model_skeen_chat_message,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_skeen_chat_message_user Acceso a Mensajes de Chat SKEEN model_skeen_chat_message base.group_user 1 1 1 1