diff --git a/database-novo/migrations/20260521000007_list_my_signatures_rpc.sql b/database-novo/migrations/20260521000007_list_my_signatures_rpc.sql new file mode 100644 index 0000000..bcc05e7 --- /dev/null +++ b/database-novo/migrations/20260521000007_list_my_signatures_rpc.sql @@ -0,0 +1,102 @@ +-- ============================================================================ +-- Compliance CFP #7 — RPC list_my_signatures (portal do paciente) +-- ---------------------------------------------------------------------------- +-- Retorna as solicitações de assinatura do paciente logado (auth.uid() +-- associado a patients.user_id). SECURITY DEFINER pra bypassar a RLS de +-- document_signatures (que hoje só libera pra tenant_members). +-- +-- Cada item já vem com o share_link.token associado, pra que o portal +-- aponte direto pra /shared/document/:token onde o usuário vai assinar. +-- O link público é gerado quando o terapeuta solicita a assinatura. +-- ============================================================================ + +BEGIN; + +CREATE OR REPLACE FUNCTION public.list_my_signatures( + p_status text[] DEFAULT NULL +) RETURNS TABLE ( + signature_id uuid, + documento_id uuid, + tenant_id uuid, + signatario_tipo text, + status text, + ordem smallint, + assinado_em timestamptz, + criado_em timestamptz, + -- Documento + nome_original text, + tipo_documento text, + mime_type text, + -- Share link (primeiro válido encontrado pro doc) + share_token text, + share_expira_em timestamptz +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_uid uuid; +BEGIN + v_uid := auth.uid(); + IF v_uid IS NULL THEN + RAISE EXCEPTION 'Sessão inválida' USING ERRCODE = '28000'; + END IF; + + RETURN QUERY + SELECT + s.id AS signature_id, + s.documento_id AS documento_id, + s.tenant_id AS tenant_id, + s.signatario_tipo AS signatario_tipo, + s.status AS status, + s.ordem AS ordem, + s.assinado_em AS assinado_em, + s.criado_em AS criado_em, + d.nome_original AS nome_original, + d.tipo_documento AS tipo_documento, + d.mime_type AS mime_type, + sl.token AS share_token, + sl.expira_em AS share_expira_em + FROM public.document_signatures s + JOIN public.documents d ON d.id = s.documento_id AND d.deleted_at IS NULL + LEFT JOIN LATERAL ( + SELECT token, expira_em + FROM public.document_share_links + WHERE documento_id = d.id + AND ativo = true + AND expira_em > now() + AND usos < usos_max + ORDER BY criado_em DESC + LIMIT 1 + ) sl ON true + WHERE ( + -- signatario_id direto (quando registrado) + s.signatario_id = v_uid + OR + -- Fallback: paciente pelo email (quando signatario_id veio NULL) + s.signatario_email = (SELECT email FROM auth.users WHERE id = v_uid) + OR + -- Fallback: paciente pelo patient_id (documents.patient_id -> patients.user_id) + d.patient_id IN (SELECT p.id FROM public.patients p WHERE p.user_id = v_uid) + ) + AND (p_status IS NULL OR s.status = ANY (p_status)) + ORDER BY + CASE s.status + WHEN 'pendente' THEN 0 + WHEN 'enviado' THEN 1 + WHEN 'assinado' THEN 2 + WHEN 'recusado' THEN 3 + WHEN 'expirado' THEN 4 + ELSE 99 + END, + s.criado_em DESC; +END; +$$; + +COMMENT ON FUNCTION public.list_my_signatures(text[]) IS + 'Lista signatures do paciente logado (auth.uid()) cruzando por signatario_id, email ou patient.user_id. Inclui share_token pra link de assinatura.'; + +GRANT EXECUTE ON FUNCTION public.list_my_signatures(text[]) TO authenticated; + +COMMIT; diff --git a/src/features/documents/composables/useDocumentSignatures.js b/src/features/documents/composables/useDocumentSignatures.js index 21a2c0f..0e6a96a 100644 --- a/src/features/documents/composables/useDocumentSignatures.js +++ b/src/features/documents/composables/useDocumentSignatures.js @@ -15,6 +15,7 @@ import { signByPortal, signByToken, getSignableDocumentByToken, + listMySignatures, hashDocument } from '@/services/DocumentSignatures.service'; @@ -110,6 +111,22 @@ export function useDocumentSignatures() { } } + async function loadMine(statusFilter = null) { + loading.value = true; + error.value = ''; + try { + const rows = await listMySignatures(statusFilter); + signatures.value = Array.isArray(rows) ? rows : []; + return signatures.value; + } catch (e) { + error.value = e?.message || 'Falha ao carregar minhas assinaturas.'; + signatures.value = []; + throw e; + } finally { + loading.value = false; + } + } + async function loadByToken(token) { loading.value = true; error.value = ''; @@ -144,6 +161,7 @@ export function useDocumentSignatures() { refuse, signWithToken, loadByToken, + loadMine, hashDocument }; } diff --git a/src/navigation/menus/portal.menu.js b/src/navigation/menus/portal.menu.js index 6b80e2f..3f014f6 100644 --- a/src/navigation/menus/portal.menu.js +++ b/src/navigation/menus/portal.menu.js @@ -26,6 +26,11 @@ export default [ items: [{ label: 'Sessões', icon: 'pi pi-fw pi-calendar', to: '/portal/sessoes' }] }, + { + label: 'Documentos', + items: [{ label: 'Para assinar', icon: 'pi pi-fw pi-file-edit', to: '/portal/documentos' }] + }, + { label: 'Conta', items: [ diff --git a/src/router/routes.portal.js b/src/router/routes.portal.js index a35862b..442ce0c 100644 --- a/src/router/routes.portal.js +++ b/src/router/routes.portal.js @@ -27,6 +27,12 @@ export default { name: 'portal-sessoes', component: () => import('@/views/pages/portal/MinhasSessoes.vue') }, + { + path: 'documentos', + name: 'portal-documentos', + component: () => import('@/views/pages/portal/PortalDocumentos.vue'), + meta: { area: 'portal', requiresAuth: true } + }, // ====================================================== // 💳 MEU PLANO (assinatura pessoal do paciente) diff --git a/src/services/DocumentSignatures.service.js b/src/services/DocumentSignatures.service.js index 7b1fce4..37107ed 100644 --- a/src/services/DocumentSignatures.service.js +++ b/src/services/DocumentSignatures.service.js @@ -208,6 +208,21 @@ export async function signByToken(token, signatureId = null, hashDocumento = nul return data; } +// ── Listar minhas assinaturas (portal do paciente) ────────── +// +// Wrapper sobre RPC list_my_signatures. Resolve auth.uid() server-side +// e cruza por signatario_id / email / patient.user_id pra encontrar +// todas as assinaturas que pertencem ao usuário logado. Inclui +// share_token p/ apontar direto pra /shared/document/:token. +// +export async function listMySignatures(statusFilter = null) { + const { data, error } = await supabase.rpc('list_my_signatures', { + p_status: Array.isArray(statusFilter) ? statusFilter : null + }); + if (error) throw error; + return data || []; +} + // ── Pré-visualizar documento por token (sem assinar) ──────── // // Usado pela página pública pra carregar info do documento + diff --git a/src/views/pages/portal/PortalDocumentos.vue b/src/views/pages/portal/PortalDocumentos.vue new file mode 100644 index 0000000..742563d --- /dev/null +++ b/src/views/pages/portal/PortalDocumentos.vue @@ -0,0 +1,197 @@ + + + + diff --git a/src/views/pages/public/SharedDocumentPage.vue b/src/views/pages/public/SharedDocumentPage.vue index 5e1ea8d..6a3f1ec 100644 --- a/src/views/pages/public/SharedDocumentPage.vue +++ b/src/views/pages/public/SharedDocumentPage.vue @@ -9,9 +9,10 @@ |-------------------------------------------------------------------------- --> @@ -128,6 +231,113 @@ const isImage = () => String(doc.value?.mime_type || '').startsWith('image/') + +
+ +
+ +
+
Assinatura registrada com sucesso!
+
Sua assinatura foi registrada com IP, data/hora e hash do documento. O(a) terapeuta foi notificado(a).
+
+
+ + +
+ +
+
Assinatura recusada
+
Sua recusa foi registrada. O(a) terapeuta será informado(a) e poderá entrar em contato.
+
+
+ + +
+
+ +
Assinatura solicitada
+
+ + +
+ + +
+ + +
+ + Ao assinar, ficarão registrados: data/hora, seu endereço IP, navegador e hash criptográfico (SHA-256) do documento. Esses dados garantem integridade e autenticidade conforme a LGPD (Lei nº 13.709/2018) e o Código de Ética do Psicólogo. +
+ + + + + +
+ {{ signError }} +
+ + +
+ + +
+
+ + +
+ +
+
Documento já assinado
+
+ + +
+
+
+
+
Compartilhado com segurança via AgênciaPSI