feat(signatures): deriva sócios/representante automaticamente na criação do envelope
signers no CreateEnvelopeDto passa a ser opcional. Quando omitido, o contratante é derivado automaticamente da origem do envelope: representante legal autorizado (pj_rep_*/rep_*) + sócios marcados will_sign (client_registration_partners/reseller_partners), deduplicado por CPF; para cliente PF, o próprio titular. signers explícito continua funcionando como override manual completo, sem mudança de comportamento. Nunca falha silenciosamente: sócio marcado pra assinar sem e-mail de assinatura, ou nada disponível pra derivar, retorna 400 com mensagem clara em vez de criar um envelope incompleto. Suíte: 113/113 (107→113). pnpm turbo run typecheck build: 28/28. Testado ao vivo contra a API real. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -43,9 +43,17 @@ export class CreateEnvelopeDto {
|
||||
@IsUUID("4", { each: true })
|
||||
documentGenerationIds!: string[];
|
||||
|
||||
/**
|
||||
* Omitted/empty → `contratante` signers are derived automatically from
|
||||
* the envelope's origin (`clientRegistrationId`/`resellerId`): the
|
||||
* legal representative (if authorized) plus every sócio marked
|
||||
* `will_sign` — see `EnvelopeService.deriveContratanteSigners`. Provide
|
||||
* this explicitly to override that derivation entirely (e.g. a signer
|
||||
* not on file, or an origin-less envelope).
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SignerInputDto)
|
||||
signers!: SignerInputDto[];
|
||||
signers?: SignerInputDto[];
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
companiesRepo,
|
||||
documentsRepo,
|
||||
signaturesRepo,
|
||||
clientRegistrationsRepo,
|
||||
clientRegistrationPartnersRepo,
|
||||
resellersRepo,
|
||||
resellerPartnersRepo,
|
||||
type ActorType,
|
||||
type EnvelopeWithDetails,
|
||||
type SignatureEnvelope,
|
||||
@@ -33,6 +37,18 @@ export interface RequestContext {
|
||||
userAgent: string | null;
|
||||
}
|
||||
|
||||
interface ContratanteSignerDraft {
|
||||
party: "contratante";
|
||||
personType: "pf" | "pj" | null;
|
||||
fullName: string;
|
||||
cpf: string | null;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
companyName: string | null;
|
||||
cnpj: string | null;
|
||||
roleTitle: string | null;
|
||||
}
|
||||
|
||||
function resolveActorType(roleKey: string): ActorType {
|
||||
if (roleKey === "super_admin") return "SUPER_ADMIN";
|
||||
if (roleKey === "admin") return "ADMIN";
|
||||
@@ -119,6 +135,7 @@ export class EnvelopeService {
|
||||
});
|
||||
}
|
||||
|
||||
const contratanteSigners = await this.deriveContratanteSigners(dto);
|
||||
const signers = [
|
||||
{
|
||||
party: "contratada" as const,
|
||||
@@ -127,17 +144,7 @@ export class EnvelopeService {
|
||||
email: company.legal_rep_email,
|
||||
roleTitle: company.legal_rep_role,
|
||||
},
|
||||
...dto.signers.map((s) => ({
|
||||
party: "contratante" as const,
|
||||
personType: s.personType ?? null,
|
||||
fullName: s.fullName,
|
||||
cpf: s.cpf ?? null,
|
||||
email: s.email,
|
||||
phone: s.phone ?? null,
|
||||
companyName: s.companyName ?? null,
|
||||
cnpj: s.cnpj ?? null,
|
||||
roleTitle: s.roleTitle ?? null,
|
||||
})),
|
||||
...contratanteSigners,
|
||||
];
|
||||
|
||||
const envelope = await signaturesRepo.createEnvelope(
|
||||
@@ -161,6 +168,157 @@ export class EnvelopeService {
|
||||
return envelope;
|
||||
}
|
||||
|
||||
/**
|
||||
* `dto.signers` explícito (não vazio) sempre vence — override manual
|
||||
* completo, mesmo comportamento de antes desta função existir. Vazio/
|
||||
* omitido: deriva automaticamente da origem do envelope (usuário pediu
|
||||
* isso depois de completar o cadastro de sócios/representante legal em
|
||||
* client_registrations e resellers) — representante legal autorizado
|
||||
* (`*_rep_authorization_ack`) + cada sócio marcado `will_sign`, dedup
|
||||
* por CPF (o representante pode também constar como sócio). PF de
|
||||
* client_registrations não tem sócios/representante — o próprio titular
|
||||
* é o único signatário possível.
|
||||
*/
|
||||
private async deriveContratanteSigners(dto: CreateEnvelopeDto): Promise<ContratanteSignerDraft[]> {
|
||||
if (dto.signers && dto.signers.length > 0) {
|
||||
return dto.signers.map((s) => ({
|
||||
party: "contratante" as const,
|
||||
personType: s.personType ?? null,
|
||||
fullName: s.fullName,
|
||||
cpf: s.cpf ?? null,
|
||||
email: s.email,
|
||||
phone: s.phone ?? null,
|
||||
companyName: s.companyName ?? null,
|
||||
cnpj: s.cnpj ?? null,
|
||||
roleTitle: s.roleTitle ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
if (dto.clientRegistrationId) {
|
||||
const registration = await clientRegistrationsRepo.getClientRegistrationById(dto.clientRegistrationId);
|
||||
if (!registration) throw new NotFoundException("Cadastro de cliente não encontrado");
|
||||
|
||||
if (registration.person_type === "pf") {
|
||||
if (!registration.pf_full_name || !registration.pf_email) {
|
||||
throw new BadRequestException(
|
||||
"O cadastro de cliente não tem nome/e-mail completos para assinar — complete o cadastro ou informe os signatários manualmente.",
|
||||
);
|
||||
}
|
||||
return [
|
||||
{
|
||||
party: "contratante" as const,
|
||||
personType: "pf" as const,
|
||||
fullName: registration.pf_full_name,
|
||||
cpf: registration.pf_cpf,
|
||||
email: registration.pf_email,
|
||||
phone: registration.pf_mobile_phone,
|
||||
companyName: null,
|
||||
cnpj: null,
|
||||
roleTitle: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const partners = await clientRegistrationPartnersRepo.listPartnersForRegistration(dto.clientRegistrationId);
|
||||
return this.deriveFromRepAndPartners({
|
||||
companyName: registration.pj_company_name,
|
||||
cnpj: registration.pj_cnpj,
|
||||
repFullName: registration.pj_rep_full_name,
|
||||
repCpf: registration.pj_rep_cpf,
|
||||
repRole: registration.pj_rep_role,
|
||||
repEmail: registration.pj_rep_email,
|
||||
repMobilePhone: registration.pj_rep_mobile_phone,
|
||||
repAuthorizationAck: registration.pj_rep_authorization_ack,
|
||||
partners,
|
||||
originLabel: "cadastro de cliente",
|
||||
});
|
||||
}
|
||||
|
||||
// `hasReseller` — a XOR de clientRegistrationId/resellerId já foi validada por quem chama.
|
||||
const reseller = await resellersRepo.getResellerById(dto.resellerId!);
|
||||
if (!reseller) throw new NotFoundException("Revenda não encontrada");
|
||||
const partners = await resellerPartnersRepo.listPartnersForReseller(dto.resellerId!);
|
||||
return this.deriveFromRepAndPartners({
|
||||
companyName: reseller.name,
|
||||
cnpj: reseller.document,
|
||||
repFullName: reseller.rep_full_name,
|
||||
repCpf: reseller.rep_cpf,
|
||||
repRole: reseller.rep_role,
|
||||
repEmail: reseller.rep_email,
|
||||
repMobilePhone: reseller.rep_mobile_phone,
|
||||
repAuthorizationAck: reseller.rep_authorization_ack,
|
||||
partners,
|
||||
originLabel: "revenda",
|
||||
});
|
||||
}
|
||||
|
||||
private deriveFromRepAndPartners(input: {
|
||||
companyName: string | null;
|
||||
cnpj: string | null;
|
||||
repFullName: string | null;
|
||||
repCpf: string | null;
|
||||
repRole: string | null;
|
||||
repEmail: string | null;
|
||||
repMobilePhone: string | null;
|
||||
repAuthorizationAck: boolean;
|
||||
partners: Array<{
|
||||
name: string;
|
||||
cpf: string | null;
|
||||
qualification: string | null;
|
||||
will_sign: boolean;
|
||||
signature_email: string | null;
|
||||
}>;
|
||||
originLabel: string;
|
||||
}): ContratanteSignerDraft[] {
|
||||
const signers: ContratanteSignerDraft[] = [];
|
||||
const seenCpfs = new Set<string>();
|
||||
|
||||
if (input.repAuthorizationAck && input.repFullName && input.repEmail) {
|
||||
signers.push({
|
||||
party: "contratante",
|
||||
personType: "pf",
|
||||
fullName: input.repFullName,
|
||||
cpf: input.repCpf,
|
||||
email: input.repEmail,
|
||||
phone: input.repMobilePhone,
|
||||
companyName: input.companyName,
|
||||
cnpj: input.cnpj,
|
||||
roleTitle: input.repRole,
|
||||
});
|
||||
if (input.repCpf) seenCpfs.add(input.repCpf);
|
||||
}
|
||||
|
||||
for (const partner of input.partners) {
|
||||
if (!partner.will_sign) continue;
|
||||
if (partner.cpf && seenCpfs.has(partner.cpf)) continue; // já coberto pelo representante
|
||||
if (!partner.signature_email) {
|
||||
throw new BadRequestException(
|
||||
`O sócio "${partner.name}" está marcado para assinar mas não tem e-mail de assinatura cadastrado — complete o cadastro (${input.originLabel}) ou informe os signatários manualmente.`,
|
||||
);
|
||||
}
|
||||
signers.push({
|
||||
party: "contratante",
|
||||
personType: "pf",
|
||||
fullName: partner.name,
|
||||
cpf: partner.cpf,
|
||||
email: partner.signature_email,
|
||||
phone: null,
|
||||
companyName: input.companyName,
|
||||
cnpj: input.cnpj,
|
||||
roleTitle: partner.qualification,
|
||||
});
|
||||
if (partner.cpf) seenCpfs.add(partner.cpf);
|
||||
}
|
||||
|
||||
if (signers.length === 0) {
|
||||
throw new BadRequestException(
|
||||
`Nenhum sócio marcado para assinar nem representante legal autorizado no cadastro (${input.originLabel}) — marque quem assina no cadastro ou informe os signatários manualmente nesta requisição.`,
|
||||
);
|
||||
}
|
||||
|
||||
return signers;
|
||||
}
|
||||
|
||||
/** eden.md §5.5: gera um token novo por signatário-alvo ainda não assinado (revoga implicitamente o anterior), 7 dias de validade, e-mail com o link. Envelope READY->SENT na primeira vez. */
|
||||
async sendInvite(id: string, dto: SendInviteDto, user: EdenAuthenticatedUser): Promise<EnvelopeWithDetails> {
|
||||
const envelope = await this.get(id, user);
|
||||
|
||||
Reference in New Issue
Block a user