Sessoes 6cont-10: hardening em 6 areas + scan completo do SaaS
Continuacao de 7c20b51. Esta etapa fechou TODA revisao senior do SaaS
(15 areas auditadas) + refator parcial de pacientes.
Ver commit.md para descricao completa por sessao.
# Estado final do projeto
- A# auditoria abertos: 1 (A#31 Deploy real)
- V# verificacoes abertos: 14 (todos medios/baixos adiados com plano)
- Criticos: 0
- Altos: 0
- Vitest: 208/208 (era 192, +16 nos novos composables)
- SQL integration: 33/33
- E2E (Playwright): 5/5
- Areas auditadas: 15
# Highlights
- Documentos 100% fechado (V#50/51/52: portal-paciente policy + content_sha256 + 4 cron jobs retention)
- Tenants V#1 P0: tenant_invites com RLS off + 0 policies (mesmo padrao A#30)
- Calendario 100% fechado: feriados WITH CHECK
- Addons V#1 P0 (dinheiro): addon_transactions WITH CHECK saas_admin
- Central SaaS V#1: faq write so saas_admin (era tenant_admin)
- Servicos/Prontuarios 100% fechado: services/medicos/insurance_plans + cascades
- Pacientes V#9: 2 composables novos (useCep, usePatientSupportContacts) + repo estendido + script extraido (template intocado, fica para quando houver E2E)
# 8 migrations novas neste commit
- 20260419000011_documents_portal_patient_policy.sql
- 20260419000012_documents_content_hash.sql
- 20260419000013_cron_retention_jobs.sql
- 20260419000014_financial_security_hardening.sql
- 20260419000015_communication_security_hardening.sql
- 20260419000016_tenants_calendario_hardening.sql
- 20260419000017_addons_central_saas_hardening.sql
- 20260419000018_servicos_prontuarios_hardening.sql
Total acumulado: 18 migrations (Sessoes 1-10).
# A#31 reformulado pra proxima sessao
"Deploy real" muda escopo: como nao ha cloud Supabase nem secrets reais
ainda (MVP), proxima sessao vira "Preparacao completa pra deploy" (DEPLOY.md,
validar migrations num container limpo, audit edge functions, listar env vars,
script db.cjs deploy-check).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,13 @@
|
||||
-->
|
||||
<script setup>
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import {
|
||||
listGroupsByPatient,
|
||||
listTagsByPatient,
|
||||
listGroups as repoListGroups,
|
||||
listTags as repoListTags,
|
||||
getSessionCounts
|
||||
} from '@/features/patients/services/patientsRepository';
|
||||
import Menu from 'primevue/menu';
|
||||
import MultiSelect from 'primevue/multiselect';
|
||||
import Popover from 'primevue/popover';
|
||||
@@ -568,43 +575,22 @@ async function hydrateAssociationsSupabase() {
|
||||
const ids = (patients.value || []).map((p) => p.id).filter(Boolean);
|
||||
if (!ids.length) return;
|
||||
|
||||
// V#8 — Fase 1 (paralelo): vínculos grupo/tag + agregado de sessões via RPC
|
||||
// (substitui .limit(1000) arbitrário por get_patient_session_counts agregada)
|
||||
const pgQ = withTenantFilter(supabase.from('patient_group_patient').select('patient_id, patient_group_id').in('patient_id', ids));
|
||||
const ptQ = withTenantFilter(supabase.from('patient_patient_tag').select('patient_id, tag_id').in('patient_id', ids));
|
||||
const evtQ = supabase.rpc('get_patient_session_counts', { p_patient_ids: ids });
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
|
||||
const [pgRes, ptRes, evtRes] = await Promise.all([pgQ, ptQ, evtQ]);
|
||||
if (pgRes.error) throw pgRes.error;
|
||||
if (ptRes.error) throw ptRes.error;
|
||||
if (evtRes.error) throw evtRes.error;
|
||||
const pg = pgRes.data || [];
|
||||
const pt = ptRes.data || [];
|
||||
const sessionCounts = evtRes.data || []; // [{patient_id, session_count, last_session_at}]
|
||||
// V#3 — Fase 1: vínculos + sessions via repo (paralelo).
|
||||
const [pg, pt, sessionCounts] = await Promise.all([
|
||||
listGroupsByPatient(ids, { tenantId }),
|
||||
listTagsByPatient(ids, { tenantId }),
|
||||
getSessionCounts(ids)
|
||||
]);
|
||||
|
||||
// Fase 2 (paralelo): catálogos de grupos e tags — dependem dos ids derivados
|
||||
const groupIds = Array.from(new Set(pg.map((r) => r.patient_group_id).filter(Boolean)));
|
||||
const tagIds = Array.from(new Set(pt.map((r) => r.tag_id).filter(Boolean)));
|
||||
|
||||
const groupCatalogQ = groupIds.length
|
||||
? (() => {
|
||||
let q = withTenantFilter(supabase.from('patient_groups').select('id, nome, cor, is_system, owner_id, is_active').in('id', groupIds).eq('is_active', true));
|
||||
if (uid.value) q = q.or(`is_system.eq.true,owner_id.eq.${uid.value}`);
|
||||
else q = q.eq('is_system', true);
|
||||
return q;
|
||||
})()
|
||||
: Promise.resolve({ data: [], error: null });
|
||||
|
||||
const tagCatalogQ = tagIds.length
|
||||
? withOwnerFilter(withTenantFilter(supabase.from('patient_tags').select('id, nome, cor').in('id', tagIds)))
|
||||
: Promise.resolve({ data: [], error: null });
|
||||
|
||||
const [gcatRes, tcatRes] = await Promise.all([groupCatalogQ, tagCatalogQ]);
|
||||
if (gcatRes.error) throw gcatRes.error;
|
||||
if (tcatRes.error) throw tcatRes.error;
|
||||
|
||||
const groupCatalog = (gcatRes.data || []).map((g) => ({ id: g.id, name: g.nome, color: g.cor }));
|
||||
const tagCatalog = (tcatRes.data || []).map((t) => ({ id: t.id, name: t.nome, color: t.cor }));
|
||||
// Fase 2: catálogos completos (grupos + tags) também via repo.
|
||||
// Lista TUDO do tenant pra deixar mapas prontos (volume baixo); repo já
|
||||
// filtra is_system OR owner do uid.
|
||||
const [groupCatalog, tagCatalog] = await Promise.all([
|
||||
repoListGroups({ tenantId, ownerId: uid.value || null }),
|
||||
repoListTags({ tenantId, ownerId: uid.value || null })
|
||||
]);
|
||||
|
||||
// Monta mapas finais
|
||||
const gById = new Map(groupCatalog.map((g) => [g.id, g]));
|
||||
|
||||
@@ -70,6 +70,20 @@ import { digitsOnly, fmtCPF, fmtRG, fmtPhone, toISODate, generateCPF } from '@/u
|
||||
import CadastroRapidoConvenio from '@/components/CadastroRapidoConvenio.vue'
|
||||
import CadastroRapidoMedico from '@/components/CadastroRapidoMedico.vue'
|
||||
|
||||
// V#9 — composables/repo da feature pacientes (extração da página gigante)
|
||||
import { useCep } from '@/features/patients/composables/useCep'
|
||||
import { usePatientSupportContacts } from '@/features/patients/composables/usePatientSupportContacts'
|
||||
import {
|
||||
listGroups as repoListGroups,
|
||||
listTags as repoListTags,
|
||||
getPatientById as repoGetPatientById,
|
||||
createPatient as repoCreatePatient,
|
||||
updatePatient as repoUpdatePatient,
|
||||
getPatientRelations as repoGetPatientRelations,
|
||||
replacePatientGroup as repoReplacePatientGroup,
|
||||
replacePatientTags as repoReplacePatientTags
|
||||
} from '@/features/patients/services/patientsRepository'
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Props / emits
|
||||
// ─────────────────────────────────────────────────────────
|
||||
@@ -446,83 +460,33 @@ function sanitizePayload (raw, ownerId) {
|
||||
// Entidade separada que alimenta o card "Contatos & rede de suporte" do detalhe
|
||||
// is_primario = true → badge vermelho "emergência" no detalhe
|
||||
// ─────────────────────────────────────────────────────────
|
||||
const contatosSuporte = ref([])
|
||||
const novoContato = () => ({ _k: Date.now()+Math.random(), nome:'', relacao:'', tipo:'', telefone:'', email:'', is_primario:false })
|
||||
// V#9 — contatos de suporte agora vêm do composable (script -300 linhas)
|
||||
const _supportContacts = usePatientSupportContacts()
|
||||
const contatosSuporte = _supportContacts.contatos
|
||||
const addContato = () => _supportContacts.add()
|
||||
const removeContato = (i) => _supportContacts.remove(i)
|
||||
const iniciaisFor = (n) => _supportContacts.iniciaisFor(n)
|
||||
const loadContatosSuporte = (pid) => _supportContacts.load(pid)
|
||||
const saveContatosSuporte = (pid, tenantId, ownerId) => _supportContacts.save(pid, tenantId, ownerId)
|
||||
|
||||
function addContato () { contatosSuporte.value.push(novoContato()) }
|
||||
function removeContato (i) { contatosSuporte.value.splice(i,1) }
|
||||
function iniciaisFor (n) { return (n||'').split(' ').filter(Boolean).map(w=>w[0].toUpperCase()).slice(0,2).join('') }
|
||||
|
||||
async function saveContatosSuporte (pid, tenantId, ownerId) {
|
||||
const { error: del } = await supabase.from('patient_support_contacts')
|
||||
.delete().eq('patient_id', pid).eq('owner_id', ownerId)
|
||||
if (del) throw del
|
||||
const rows = contatosSuporte.value.filter(c=>c.nome.trim()).map(c=>({
|
||||
patient_id: pid, owner_id: ownerId, tenant_id: tenantId,
|
||||
nome: c.nome.trim()||null,
|
||||
relacao: c.relacao||null,
|
||||
tipo: c.tipo||null,
|
||||
telefone: c.telefone ? digitsOnly(c.telefone) : null,
|
||||
email: c.email||null,
|
||||
is_primario: !!c.is_primario,
|
||||
}))
|
||||
if (!rows.length) return
|
||||
const { error: ins } = await supabase.from('patient_support_contacts').insert(rows)
|
||||
if (ins) throw ins
|
||||
}
|
||||
async function loadContatosSuporte (pid) {
|
||||
try {
|
||||
const { data, error } = await supabase.from('patient_support_contacts')
|
||||
.select('*').eq('patient_id', pid).order('is_primario', { ascending:false })
|
||||
if (error) throw error
|
||||
contatosSuporte.value = (data||[]).map(c=>({
|
||||
_k: c.id, nome: c.nome||'', relacao: c.relacao||'', tipo: c.tipo||'',
|
||||
telefone: fmtPhone(c.telefone||''), email: c.email||'', is_primario: !!c.is_primario,
|
||||
}))
|
||||
} catch (_) { contatosSuporte.value = [] }
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// DB calls
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// V#9 — DB calls delegadas ao patientsRepository (V#3 fundação)
|
||||
async function listGroups () {
|
||||
const tid = currentTenantId()
|
||||
let q = supabase.from('patient_groups').select('id,nome,descricao,cor,is_system,is_active').eq('is_active',true).order('nome',{ascending:true})
|
||||
if (tid) q = q.eq('tenant_id', tid)
|
||||
const { data, error } = await q
|
||||
if (error) throw error
|
||||
return (data||[]).map(g=>({...g,name:g.nome,color:g.cor}))
|
||||
return repoListGroups({ tenantId: currentTenantId() })
|
||||
}
|
||||
async function listTags () {
|
||||
const tid = currentTenantId()
|
||||
let q = supabase.from('patient_tags').select('id,nome,cor').order('nome',{ascending:true})
|
||||
if (tid) q = q.eq('tenant_id', tid)
|
||||
const { data, error } = await q
|
||||
if (error) throw error
|
||||
return (data||[]).map(t=>({...t,name:t.nome,color:t.cor}))
|
||||
return repoListTags({ tenantId: currentTenantId() })
|
||||
}
|
||||
async function getPatientById (id) {
|
||||
const tid = currentTenantId()
|
||||
let q = supabase.from('patients').select('*').eq('id',id)
|
||||
if (tid) q = q.eq('tenant_id', tid)
|
||||
const { data, error } = await q.maybeSingle()
|
||||
if (error) throw error; return data
|
||||
return repoGetPatientById(id, { tenantId: currentTenantId() })
|
||||
}
|
||||
async function getPatientRelations (id) {
|
||||
const { data:g, error:ge } = await supabase.from('patient_group_patient').select('patient_group_id').eq('patient_id',id); if (ge) throw ge
|
||||
const { data:t, error:te } = await supabase.from('patient_patient_tag').select('tag_id').eq('patient_id',id); if (te) throw te
|
||||
return { groupIds:(g||[]).map(x=>x.patient_group_id).filter(Boolean), tagIds:(t||[]).map(x=>x.tag_id).filter(Boolean) }
|
||||
return repoGetPatientRelations(id)
|
||||
}
|
||||
async function createPatient (payload) {
|
||||
const { data, error } = await supabase.from('patients').insert(payload).select('id').single()
|
||||
if (error) throw error; return data
|
||||
return repoCreatePatient(payload)
|
||||
}
|
||||
async function updatePatient (id, payload) {
|
||||
const tid = currentTenantId()
|
||||
let q = supabase.from('patients').update({ ...payload, updated_at:new Date().toISOString() }).eq('id',id)
|
||||
if (tid) q = q.eq('tenant_id', tid)
|
||||
const { error } = await q
|
||||
if (error) throw error
|
||||
return repoUpdatePatient(id, { ...payload, updated_at: new Date().toISOString() }, { tenantId: currentTenantId() })
|
||||
}
|
||||
|
||||
const groups = ref([])
|
||||
@@ -530,32 +494,28 @@ const tags = ref([])
|
||||
const grupoIdSelecionado = ref(null)
|
||||
const tagIdsSelecionadas = ref([])
|
||||
|
||||
// V#9 — replace de grupos/tags via repo
|
||||
async function replacePatientGroups (patient_id, groupId) {
|
||||
const { error } = await supabase.from('patient_group_patient').delete().eq('patient_id',patient_id); if (error) throw error
|
||||
if (!groupId) return
|
||||
const { tenantId } = await resolveTenantContextOrFail()
|
||||
const { error:ins } = await supabase.from('patient_group_patient').insert({ patient_id, patient_group_id:groupId, tenant_id:tenantId }); if (ins) throw ins
|
||||
return repoReplacePatientGroup(patient_id, groupId, { tenantId })
|
||||
}
|
||||
async function replacePatientTags (patient_id, tagIds) {
|
||||
const ownerId = await getOwnerId()
|
||||
const { error } = await supabase.from('patient_patient_tag').delete().eq('patient_id',patient_id).eq('owner_id',ownerId); if (error) throw error
|
||||
const clean = Array.from(new Set((tagIds||[]).filter(Boolean))); if (!clean.length) return
|
||||
const { tenantId } = await resolveTenantContextOrFail()
|
||||
const { error:ins } = await supabase.from('patient_patient_tag').insert(clean.map(tag_id=>({ owner_id:ownerId, patient_id, tag_id, tenant_id:tenantId }))); if (ins) throw ins
|
||||
return repoReplacePatientTags(patient_id, tagIds, { tenantId, ownerId })
|
||||
}
|
||||
|
||||
// V#9 — CEP via composable useCep (reutilizável)
|
||||
const _cep = useCep()
|
||||
async function onCepBlur () {
|
||||
try {
|
||||
const cep = digitsOnly(form.value.cep); if (cep.length!==8) return
|
||||
const res = await fetch(`https://viacep.com.br/ws/${cep}/json/`)
|
||||
const d = await res.json(); if (!d||d.erro) return
|
||||
form.value.cidade = d.localidade || form.value.cidade
|
||||
form.value.estado = d.uf || form.value.estado
|
||||
form.value.bairro = d.bairro || form.value.bairro
|
||||
form.value.endereco = d.logradouro || form.value.endereco
|
||||
if (!form.value.complemento) form.value.complemento = d.complemento||''
|
||||
toast.add({ severity:'success', summary:'CEP', detail:`${d.localidade} / ${d.uf}`, life:2000 })
|
||||
} catch (_) {}
|
||||
const result = await _cep.fetchCep(form.value.cep)
|
||||
if (!result) return
|
||||
form.value.cidade = result.cidade || form.value.cidade
|
||||
form.value.estado = result.uf || form.value.estado
|
||||
form.value.bairro = result.bairro || form.value.bairro
|
||||
form.value.endereco = result.endereco || form.value.endereco
|
||||
if (!form.value.complemento) form.value.complemento = result.complemento || ''
|
||||
toast.add({ severity:'success', summary:'CEP', detail:`${result.cidade} / ${result.uf}`, life:2000 })
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* useCep.spec.js — V#9 (composable extraído)
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const { useCep } = await import('../useCep.js');
|
||||
|
||||
beforeEach(() => fetchMock.mockReset());
|
||||
afterEach(() => fetchMock.mockReset());
|
||||
|
||||
describe('useCep — busca ViaCEP', () => {
|
||||
it('retorna null se CEP tem menos de 8 dígitos (no-op)', async () => {
|
||||
const { fetchCep } = useCep();
|
||||
expect(await fetchCep('123')).toBe(null);
|
||||
expect(await fetchCep('')).toBe(null);
|
||||
expect(await fetchCep(null)).toBe(null);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('aceita CEP com máscara (digitsOnly normaliza)', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: true, json: async () => ({ localidade: 'São Paulo', uf: 'SP', logradouro: 'Av Paulista', bairro: 'Bela Vista', complemento: '' }) });
|
||||
const { fetchCep } = useCep();
|
||||
const r = await fetchCep('01310-100');
|
||||
expect(r).toEqual({
|
||||
cidade: 'São Paulo',
|
||||
uf: 'SP',
|
||||
bairro: 'Bela Vista',
|
||||
endereco: 'Av Paulista',
|
||||
complemento: ''
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://viacep.com.br/ws/01310100/json/');
|
||||
});
|
||||
|
||||
it('retorna null se ViaCEP devolve {erro: true}', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: true, json: async () => ({ erro: true }) });
|
||||
const { fetchCep } = useCep();
|
||||
expect(await fetchCep('00000000')).toBe(null);
|
||||
});
|
||||
|
||||
it('retorna null se HTTP falha (sem propagar erro)', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 500 });
|
||||
const { fetchCep, error } = useCep();
|
||||
expect(await fetchCep('01310100')).toBe(null);
|
||||
expect(error.value).toContain('500');
|
||||
});
|
||||
|
||||
it('captura exception de rede como null', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('network'));
|
||||
const { fetchCep, error } = useCep();
|
||||
expect(await fetchCep('01310100')).toBe(null);
|
||||
expect(error.value).toBe('network');
|
||||
});
|
||||
|
||||
it('loading reflete o ciclo da request', async () => {
|
||||
let resolveIt;
|
||||
fetchMock.mockReturnValue(new Promise((res) => { resolveIt = res; }));
|
||||
const { fetchCep, loading } = useCep();
|
||||
const p = fetchCep('01310100');
|
||||
expect(loading.value).toBe(true);
|
||||
resolveIt({ ok: true, json: async () => ({ localidade: 'A', uf: 'B' }) });
|
||||
await p;
|
||||
expect(loading.value).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* usePatientSupportContacts.spec.js — V#9
|
||||
*
|
||||
* Cobre add/remove/reset/load/save/iniciaisFor + sanitização do save.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Builder thenable: cada chamada (.select/.eq/.order/.delete/.insert) retorna um
|
||||
// objeto thenable. Pra testes, configuramos o resultado final via setNext(value).
|
||||
let nextResult = { data: [], error: null };
|
||||
let lastInsertArg = null;
|
||||
let deleteCalled = false;
|
||||
|
||||
function makeChain() {
|
||||
const chain = {};
|
||||
const passthrough = (..._a) => chain;
|
||||
chain.select = passthrough;
|
||||
chain.eq = passthrough;
|
||||
chain.order = passthrough;
|
||||
chain.delete = (...a) => { deleteCalled = true; return chain; };
|
||||
chain.insert = (rows) => { lastInsertArg = rows; return chain; };
|
||||
chain.then = (onFulfilled, onRejected) => Promise.resolve(nextResult).then(onFulfilled, onRejected);
|
||||
return chain;
|
||||
}
|
||||
|
||||
const fromMock = vi.fn(() => makeChain());
|
||||
|
||||
vi.mock('@/lib/supabase/client', () => ({
|
||||
supabase: { from: (...a) => fromMock(...a) }
|
||||
}));
|
||||
|
||||
const { usePatientSupportContacts } = await import('../usePatientSupportContacts.js');
|
||||
|
||||
beforeEach(() => {
|
||||
fromMock.mockClear();
|
||||
nextResult = { data: [], error: null };
|
||||
lastInsertArg = null;
|
||||
deleteCalled = false;
|
||||
});
|
||||
|
||||
describe('add / remove / reset', () => {
|
||||
it('add cria contato com defaults e _k único', () => {
|
||||
const c = usePatientSupportContacts();
|
||||
expect(c.contatos.value).toEqual([]);
|
||||
c.add();
|
||||
c.add();
|
||||
expect(c.contatos.value).toHaveLength(2);
|
||||
expect(c.contatos.value[0]._k).not.toBe(c.contatos.value[1]._k);
|
||||
expect(c.contatos.value[0]).toMatchObject({ nome: '', telefone: '', is_primario: false });
|
||||
});
|
||||
|
||||
it('remove pelo índice', () => {
|
||||
const c = usePatientSupportContacts();
|
||||
c.add(); c.add(); c.add();
|
||||
const middleK = c.contatos.value[1]._k;
|
||||
c.remove(1);
|
||||
expect(c.contatos.value).toHaveLength(2);
|
||||
expect(c.contatos.value.find((x) => x._k === middleK)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reset esvazia', () => {
|
||||
const c = usePatientSupportContacts();
|
||||
c.add(); c.add();
|
||||
c.reset();
|
||||
expect(c.contatos.value).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('iniciaisFor', () => {
|
||||
it('extrai 2 primeiras iniciais maiúsculas', () => {
|
||||
const { iniciaisFor } = usePatientSupportContacts();
|
||||
expect(iniciaisFor('joão pedro silva')).toBe('JP');
|
||||
expect(iniciaisFor('Maria')).toBe('M');
|
||||
expect(iniciaisFor('')).toBe('');
|
||||
expect(iniciaisFor(null)).toBe('');
|
||||
expect(iniciaisFor('A B C D')).toBe('AB');
|
||||
});
|
||||
});
|
||||
|
||||
describe('load — popula contatos do paciente', () => {
|
||||
it('sem patientId, esvazia', async () => {
|
||||
const c = usePatientSupportContacts();
|
||||
c.add();
|
||||
await c.load(null);
|
||||
expect(c.contatos.value).toEqual([]);
|
||||
expect(fromMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('mapeia rows do banco para shape do composable (com fmtPhone)', async () => {
|
||||
nextResult = {
|
||||
data: [
|
||||
{ id: 'a', nome: 'Maria', relacao: 'mãe', tipo: 'familiar', telefone: '11987654321', email: 'maria@x.com', is_primario: true },
|
||||
{ id: 'b', nome: 'Bia', relacao: 'amiga', tipo: null, telefone: null, email: null, is_primario: false }
|
||||
],
|
||||
error: null
|
||||
};
|
||||
|
||||
const c = usePatientSupportContacts();
|
||||
await c.load('p-1');
|
||||
|
||||
expect(fromMock).toHaveBeenCalledWith('patient_support_contacts');
|
||||
expect(c.contatos.value).toHaveLength(2);
|
||||
expect(c.contatos.value[0]).toMatchObject({
|
||||
_k: 'a', nome: 'Maria', is_primario: true, telefone: '(11) 98765-4321'
|
||||
});
|
||||
expect(c.contatos.value[1]).toMatchObject({ _k: 'b', telefone: '', is_primario: false });
|
||||
});
|
||||
|
||||
it('em erro, esvazia silenciosamente', async () => {
|
||||
nextResult = { data: null, error: new Error('rls') };
|
||||
const c = usePatientSupportContacts();
|
||||
c.add();
|
||||
await c.load('p-1');
|
||||
expect(c.contatos.value).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('save — sanitização e regras', () => {
|
||||
it('exige patientId', async () => {
|
||||
const c = usePatientSupportContacts();
|
||||
await expect(c.save(null, 't', 'o')).rejects.toThrow(/patientId/);
|
||||
});
|
||||
|
||||
it('descarta contatos com nome vazio', async () => {
|
||||
nextResult = { error: null };
|
||||
|
||||
const c = usePatientSupportContacts();
|
||||
c.add(); c.add();
|
||||
c.contatos.value[0].nome = 'Maria';
|
||||
c.contatos.value[0].telefone = '(11) 98765-4321';
|
||||
// contato 2 fica com nome vazio → descartado
|
||||
|
||||
await c.save('p-1', 't-1', 'o-1');
|
||||
|
||||
expect(deleteCalled).toBe(true);
|
||||
expect(lastInsertArg).toHaveLength(1);
|
||||
expect(lastInsertArg[0]).toMatchObject({
|
||||
patient_id: 'p-1', tenant_id: 't-1', owner_id: 'o-1',
|
||||
nome: 'Maria', telefone: '11987654321'
|
||||
});
|
||||
});
|
||||
|
||||
it('se todos contatos têm nome vazio, não chama insert (só delete)', async () => {
|
||||
nextResult = { error: null };
|
||||
const c = usePatientSupportContacts();
|
||||
c.add();
|
||||
await c.save('p-1', 't-1', 'o-1');
|
||||
|
||||
expect(deleteCalled).toBe(true);
|
||||
expect(lastInsertArg).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/features/patients/composables/useCep.js
|
||||
| V#9 — composable de busca CEP via ViaCEP. Reutilizável em qualquer form
|
||||
| que precise auto-completar endereço.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
import { digitsOnly } from '@/utils/validators';
|
||||
|
||||
const VIACEP_URL = (cep) => `https://viacep.com.br/ws/${cep}/json/`;
|
||||
|
||||
export function useCep() {
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
/**
|
||||
* Consulta CEP no ViaCEP. Retorna {cidade, uf, bairro, endereco, complemento}
|
||||
* ou null se CEP inválido / não encontrado.
|
||||
*/
|
||||
async function fetchCep(cepRaw) {
|
||||
const cep = digitsOnly(cepRaw);
|
||||
if (cep.length !== 8) return null;
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const res = await fetch(VIACEP_URL(cep));
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
if (!data || data.erro) return null;
|
||||
return {
|
||||
cidade: data.localidade || '',
|
||||
uf: data.uf || '',
|
||||
bairro: data.bairro || '',
|
||||
endereco: data.logradouro || '',
|
||||
complemento: data.complemento || ''
|
||||
};
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha na consulta CEP';
|
||||
return null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { loading, error, fetchCep };
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/features/patients/composables/usePatientSupportContacts.js
|
||||
| V#9 — composable de contatos de suporte do paciente (responsável, parente,
|
||||
| amigo). Encapsula CRUD + estado reativo.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { digitsOnly, fmtPhone } from '@/utils/validators';
|
||||
|
||||
function novoContato() {
|
||||
return {
|
||||
_k: Date.now() + Math.random(),
|
||||
nome: '',
|
||||
relacao: '',
|
||||
tipo: '',
|
||||
telefone: '',
|
||||
email: '',
|
||||
is_primario: false
|
||||
};
|
||||
}
|
||||
|
||||
export function usePatientSupportContacts() {
|
||||
const contatos = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
function add() {
|
||||
contatos.value.push(novoContato());
|
||||
}
|
||||
|
||||
function remove(idx) {
|
||||
contatos.value.splice(idx, 1);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
contatos.value = [];
|
||||
}
|
||||
|
||||
function iniciaisFor(nome) {
|
||||
return (nome || '')
|
||||
.split(' ')
|
||||
.filter(Boolean)
|
||||
.map((w) => w[0].toUpperCase())
|
||||
.slice(0, 2)
|
||||
.join('');
|
||||
}
|
||||
|
||||
async function load(patientId) {
|
||||
if (!patientId) {
|
||||
contatos.value = [];
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('patient_support_contacts')
|
||||
.select('*')
|
||||
.eq('patient_id', patientId)
|
||||
.order('is_primario', { ascending: false });
|
||||
if (error) throw error;
|
||||
contatos.value = (data || []).map((c) => ({
|
||||
_k: c.id,
|
||||
nome: c.nome || '',
|
||||
relacao: c.relacao || '',
|
||||
tipo: c.tipo || '',
|
||||
telefone: fmtPhone(c.telefone || ''),
|
||||
email: c.email || '',
|
||||
is_primario: !!c.is_primario
|
||||
}));
|
||||
} catch {
|
||||
contatos.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitui contatos do paciente: deleta tudo do owner + reinserta os com nome.
|
||||
* @param {string} patientId
|
||||
* @param {string} tenantId
|
||||
* @param {string} ownerId
|
||||
*/
|
||||
async function save(patientId, tenantId, ownerId) {
|
||||
if (!patientId) throw new Error('patientId obrigatório');
|
||||
|
||||
const { error: del } = await supabase
|
||||
.from('patient_support_contacts')
|
||||
.delete()
|
||||
.eq('patient_id', patientId)
|
||||
.eq('owner_id', ownerId);
|
||||
if (del) throw del;
|
||||
|
||||
const rows = contatos.value
|
||||
.filter((c) => c.nome.trim())
|
||||
.map((c) => ({
|
||||
patient_id: patientId,
|
||||
owner_id: ownerId,
|
||||
tenant_id: tenantId,
|
||||
nome: c.nome.trim() || null,
|
||||
relacao: c.relacao || null,
|
||||
tipo: c.tipo || null,
|
||||
telefone: c.telefone ? digitsOnly(c.telefone) : null,
|
||||
email: c.email || null,
|
||||
is_primario: !!c.is_primario
|
||||
}));
|
||||
|
||||
if (!rows.length) return;
|
||||
const { error: ins } = await supabase.from('patient_support_contacts').insert(rows);
|
||||
if (ins) throw ins;
|
||||
}
|
||||
|
||||
return { contatos, loading, add, remove, reset, iniciaisFor, load, save };
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/features/patients/composables/usePatients.js
|
||||
| V#3 — composable que agrega estado reativo (rows/loading/error) e delega
|
||||
| toda I/O ao patientsRepository. Mesmo padrão de useAgendaEvents.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
listPatients,
|
||||
getPatientById,
|
||||
createPatient,
|
||||
updatePatient,
|
||||
softDeletePatient
|
||||
} from '@/features/patients/services/patientsRepository';
|
||||
|
||||
export function usePatients() {
|
||||
const rows = ref([]);
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
async function load(opts) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
rows.value = await listPatients(opts);
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao carregar pacientes';
|
||||
rows.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function getById(id, opts) {
|
||||
return getPatientById(id, opts);
|
||||
}
|
||||
|
||||
async function create(payload) {
|
||||
return createPatient(payload);
|
||||
}
|
||||
|
||||
async function update(id, patch, opts) {
|
||||
return updatePatient(id, patch, opts);
|
||||
}
|
||||
|
||||
async function remove(id, opts) {
|
||||
await softDeletePatient(id, opts);
|
||||
}
|
||||
|
||||
return { rows, loading, error, load, getById, create, update, remove };
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/features/patients/services/patientsRepository.js
|
||||
| V#3 — fundação: queries de patients centralizadas.
|
||||
|
|
||||
| Mesmo padrão de feature/agenda/services/agendaRepository.js. Pages devem
|
||||
| chamar este repo em vez de fazer supabase.from('patients') direto.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { assertTenantId, getUid } from '@/features/agenda/services/_tenantGuards';
|
||||
|
||||
const PATIENTS_SELECT_BASE = `
|
||||
id, tenant_id, owner_id, user_id, responsible_member_id, therapist_member_id,
|
||||
nome_completo, email_principal, email_alternativo, telefone, telefone_alternativo,
|
||||
cpf, rg, data_nascimento, naturalidade, nacionalidade, genero, estado_civil,
|
||||
profissao, escolaridade, status, status_pagamento,
|
||||
cep, endereco, numero, bairro, complemento, cidade, estado, pais,
|
||||
nome_responsavel, telefone_responsavel, cpf_responsavel, observacao_responsavel,
|
||||
cobranca_no_responsavel,
|
||||
onde_nos_conheceu, encaminhado_por, observacoes,
|
||||
last_attended_at, created_at, updated_at,
|
||||
risco_sinalizado_por, convenio_id, patient_scope
|
||||
`;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Patients core
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Lista pacientes do tenant ativo. Aceita filtros opcionais.
|
||||
* @param {object} opts - { tenantId, ownerId?, includeInactive?, limit? }
|
||||
*/
|
||||
export async function listPatients({ tenantId, ownerId = null, includeInactive = true, limit = null } = {}) {
|
||||
assertTenantId(tenantId);
|
||||
|
||||
let q = supabase.from('patients').select(PATIENTS_SELECT_BASE).eq('tenant_id', tenantId).is('deleted_at', null);
|
||||
if (ownerId) q = q.eq('owner_id', ownerId);
|
||||
if (!includeInactive) q = q.neq('status', 'Inativo');
|
||||
if (limit) q = q.limit(limit);
|
||||
q = q.order('created_at', { ascending: false });
|
||||
|
||||
const { data, error } = await q;
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
export async function getPatientById(id, { tenantId } = {}) {
|
||||
if (!id) throw new Error('id obrigatório');
|
||||
assertTenantId(tenantId);
|
||||
const { data, error } = await supabase
|
||||
.from('patients')
|
||||
.select(PATIENTS_SELECT_BASE)
|
||||
.eq('id', id)
|
||||
.eq('tenant_id', tenantId)
|
||||
.is('deleted_at', null)
|
||||
.maybeSingle();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createPatient(payload) {
|
||||
const tenantId = payload?.tenant_id;
|
||||
assertTenantId(tenantId);
|
||||
const ownerId = payload?.owner_id || (await getUid());
|
||||
const row = { ...payload, tenant_id: tenantId, owner_id: ownerId };
|
||||
const { data, error } = await supabase.from('patients').insert(row).select(PATIENTS_SELECT_BASE).single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updatePatient(id, patch, { tenantId } = {}) {
|
||||
if (!id) throw new Error('id obrigatório');
|
||||
assertTenantId(tenantId);
|
||||
const { data, error } = await supabase
|
||||
.from('patients')
|
||||
.update(patch)
|
||||
.eq('id', id)
|
||||
.eq('tenant_id', tenantId)
|
||||
.select(PATIENTS_SELECT_BASE)
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function softDeletePatient(id, { tenantId } = {}) {
|
||||
if (!id) throw new Error('id obrigatório');
|
||||
assertTenantId(tenantId);
|
||||
const { error } = await supabase
|
||||
.from('patients')
|
||||
.update({ deleted_at: new Date().toISOString(), status: 'Arquivado' })
|
||||
.eq('id', id)
|
||||
.eq('tenant_id', tenantId);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Groups
|
||||
// -----------------------------------------------------------------------------
|
||||
export async function listGroups({ tenantId, ownerId = null } = {}) {
|
||||
assertTenantId(tenantId);
|
||||
let q = supabase.from('patient_groups').select('id, nome, cor, is_system, owner_id, is_active').eq('tenant_id', tenantId).eq('is_active', true);
|
||||
if (ownerId) q = q.or(`is_system.eq.true,owner_id.eq.${ownerId}`);
|
||||
q = q.order('nome', { ascending: true });
|
||||
const { data, error } = await q;
|
||||
if (error) throw error;
|
||||
return (data || []).map((g) => ({ id: g.id, name: g.nome, color: g.cor, isSystem: g.is_system }));
|
||||
}
|
||||
|
||||
export async function listGroupsByPatient(patientIds, { tenantId } = {}) {
|
||||
if (!patientIds?.length) return [];
|
||||
assertTenantId(tenantId);
|
||||
const { data, error } = await supabase
|
||||
.from('patient_group_patient')
|
||||
.select('patient_id, patient_group_id')
|
||||
.eq('tenant_id', tenantId)
|
||||
.in('patient_id', patientIds);
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Tags
|
||||
// -----------------------------------------------------------------------------
|
||||
export async function listTags({ tenantId, ownerId = null } = {}) {
|
||||
assertTenantId(tenantId);
|
||||
let q = supabase.from('patient_tags').select('id, nome, cor, owner_id').eq('tenant_id', tenantId);
|
||||
if (ownerId) q = q.eq('owner_id', ownerId);
|
||||
const { data, error } = await q;
|
||||
if (error) throw error;
|
||||
return (data || []).map((t) => ({ id: t.id, name: t.nome, color: t.cor }));
|
||||
}
|
||||
|
||||
export async function listTagsByPatient(patientIds, { tenantId } = {}) {
|
||||
if (!patientIds?.length) return [];
|
||||
assertTenantId(tenantId);
|
||||
const { data, error } = await supabase
|
||||
.from('patient_patient_tag')
|
||||
.select('patient_id, tag_id')
|
||||
.eq('tenant_id', tenantId)
|
||||
.in('patient_id', patientIds);
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna {groupIds, tagIds} de um paciente.
|
||||
*/
|
||||
export async function getPatientRelations(patientId) {
|
||||
if (!patientId) return { groupIds: [], tagIds: [] };
|
||||
const [{ data: g, error: ge }, { data: t, error: te }] = await Promise.all([
|
||||
supabase.from('patient_group_patient').select('patient_group_id').eq('patient_id', patientId),
|
||||
supabase.from('patient_patient_tag').select('tag_id').eq('patient_id', patientId)
|
||||
]);
|
||||
if (ge) throw ge;
|
||||
if (te) throw te;
|
||||
return {
|
||||
groupIds: (g || []).map((x) => x.patient_group_id).filter(Boolean),
|
||||
tagIds: (t || []).map((x) => x.tag_id).filter(Boolean)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitui o grupo do paciente (1:1 — sistema atual).
|
||||
*/
|
||||
export async function replacePatientGroup(patientId, groupId, { tenantId } = {}) {
|
||||
if (!patientId) throw new Error('patientId obrigatório');
|
||||
const { error: del } = await supabase.from('patient_group_patient').delete().eq('patient_id', patientId);
|
||||
if (del) throw del;
|
||||
if (!groupId) return;
|
||||
const { error: ins } = await supabase
|
||||
.from('patient_group_patient')
|
||||
.insert({ patient_id: patientId, patient_group_id: groupId, tenant_id: tenantId });
|
||||
if (ins) throw ins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitui as tags do paciente (lista). Limpa antigas do owner + inserta as novas.
|
||||
*/
|
||||
export async function replacePatientTags(patientId, tagIds, { tenantId, ownerId } = {}) {
|
||||
if (!patientId) throw new Error('patientId obrigatório');
|
||||
if (!ownerId) throw new Error('ownerId obrigatório');
|
||||
const { error: del } = await supabase
|
||||
.from('patient_patient_tag')
|
||||
.delete()
|
||||
.eq('patient_id', patientId)
|
||||
.eq('owner_id', ownerId);
|
||||
if (del) throw del;
|
||||
|
||||
const clean = Array.from(new Set((tagIds || []).filter(Boolean)));
|
||||
if (!clean.length) return;
|
||||
const { error: ins } = await supabase
|
||||
.from('patient_patient_tag')
|
||||
.insert(clean.map((tag_id) => ({ owner_id: ownerId, patient_id: patientId, tag_id, tenant_id: tenantId })));
|
||||
if (ins) throw ins;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Sessões agregadas (V#8 — get_patient_session_counts RPC)
|
||||
// -----------------------------------------------------------------------------
|
||||
/**
|
||||
* Retorna contagem + última sessão por paciente. Usa RPC SECURITY DEFINER.
|
||||
* @param {string[]} patientIds
|
||||
* @returns {Array<{patient_id, session_count, last_session_at}>}
|
||||
*/
|
||||
export async function getSessionCounts(patientIds) {
|
||||
if (!patientIds?.length) return [];
|
||||
const { data, error } = await supabase.rpc('get_patient_session_counts', { p_patient_ids: patientIds });
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
@@ -48,6 +48,20 @@ function buildStoragePath(tenantId, patientId, fileName) {
|
||||
return `${tenantId}/${patientId}/${timestamp}-${safe}`;
|
||||
}
|
||||
|
||||
// V#51: SHA-256 hex do conteúdo. Calculado no client antes do upload.
|
||||
async function computeSha256Hex(file) {
|
||||
if (!file || !window?.crypto?.subtle) return null;
|
||||
try {
|
||||
const buf = await file.arrayBuffer();
|
||||
const hashBuf = await crypto.subtle.digest('SHA-256', buf);
|
||||
return Array.from(new Uint8Array(hashBuf))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
} catch {
|
||||
return null; // hash é best-effort; falha não bloqueia upload
|
||||
}
|
||||
}
|
||||
|
||||
// ── Upload ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -65,6 +79,9 @@ export async function uploadDocument(file, patientId, meta = {}) {
|
||||
const ownerId = await getOwnerId();
|
||||
const tenantId = await getActiveTenantId(ownerId);
|
||||
|
||||
// V#51: hash SHA-256 do conteúdo ANTES do upload (integridade)
|
||||
const contentSha256 = await computeSha256Hex(file);
|
||||
|
||||
// Upload ao Storage
|
||||
const path = buildStoragePath(tenantId, patientId, file.name);
|
||||
const { error: upErr } = await supabase.storage
|
||||
@@ -83,6 +100,7 @@ export async function uploadDocument(file, patientId, meta = {}) {
|
||||
nome_original: file.name,
|
||||
mime_type: file.type || null,
|
||||
tamanho_bytes: file.size || null,
|
||||
content_sha256: contentSha256,
|
||||
tipo_documento: meta.tipo_documento || 'outro',
|
||||
categoria: meta.categoria || null,
|
||||
descricao: meta.descricao || null,
|
||||
@@ -311,3 +329,37 @@ export async function getUsedTags() {
|
||||
}
|
||||
return [...set].sort((a, b) => a.localeCompare(b, 'pt-BR'));
|
||||
}
|
||||
|
||||
// ── V#51: verificação de integridade ──────────────────────────
|
||||
/**
|
||||
* Baixa o documento e recalcula o SHA-256, compara com o registrado.
|
||||
* @returns {{ ok: boolean, expected: string|null, actual: string|null }}
|
||||
*/
|
||||
export async function verifyDocumentIntegrity(docId) {
|
||||
const { data: doc, error } = await supabase
|
||||
.from('documents')
|
||||
.select('id, bucket_path, storage_bucket, content_sha256')
|
||||
.eq('id', docId)
|
||||
.single();
|
||||
if (error) throw error;
|
||||
if (!doc?.content_sha256) {
|
||||
return { ok: false, expected: null, actual: null, reason: 'sem_hash_registrado' };
|
||||
}
|
||||
|
||||
const { data: blob, error: dlErr } = await supabase.storage
|
||||
.from(doc.storage_bucket || BUCKET)
|
||||
.download(doc.bucket_path);
|
||||
if (dlErr) throw dlErr;
|
||||
|
||||
const buf = await blob.arrayBuffer();
|
||||
const hashBuf = await crypto.subtle.digest('SHA-256', buf);
|
||||
const actual = Array.from(new Uint8Array(hashBuf))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
|
||||
return {
|
||||
ok: actual === doc.content_sha256,
|
||||
expected: doc.content_sha256,
|
||||
actual
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user