Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions src/Odca.Api/Controllers/ContractStudioCatalogController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
using System.Security.Claims;
using System.Text.Json;
using System.Text.Json.Serialization;
using Dapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Npgsql;
using Odca.Application.Contracts;
using Odca.Contracts.Studio;

namespace Odca.Api.Controllers;

[ApiController]
[Authorize(Policy = "PasswordChanged")]
[Route("api/v1/organizations/{tenantId:guid}/studio")]
public sealed class ContractStudioCatalogController(NpgsqlDataSource dataSource) : ControllerBase
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter() }
};

[HttpGet("templates/{templateId:guid}")]
public async Task<IActionResult> Template(Guid tenantId, Guid templateId, CancellationToken ct)
{
var actor = Actor(); if (actor is null) return Unauthorized();
await using var connection = await dataSource.OpenConnectionAsync(ct);
if (!await Allowed(connection, actor.Value, tenantId, "tenant.templates.read", ct)) return Forbid();
var row = await connection.QuerySingleOrDefaultAsync<TemplatePreviewRow>(new CommandDefinition("""
SELECT t.id AS Id, t.name AS Name, t.description AS Description, t.contract_type AS ContractType,
t.scope AS Scope, t.status AS Status, t.current_version AS Version, v.fields::text AS Fields
FROM odca.contract_templates t
JOIN odca.contract_template_versions v ON v.template_id = t.id AND v.version_number = t.current_version
WHERE t.id = @templateId AND t.status = 'published'
AND (t.scope = 'global' OR t.owner_tenant_id = @tenantId OR EXISTS (
SELECT 1 FROM odca.contract_template_access a
WHERE a.template_id = t.id AND a.tenant_id = @tenantId AND a.revoked_at IS NULL))
""", new { templateId, tenantId }, cancellationToken: ct));
if (row is null) return NotFound();
var fields = JsonSerializer.Deserialize<JsonElement>(row.Fields);
var labels = JsonSerializer.Deserialize<ContractFieldDefinition[]>(row.Fields, JsonOptions)?
.Select(item => item.Label).ToArray() ?? [];
return Ok(new TemplatePreview(row.Id, row.Name, row.Description, row.ContractType, row.Scope, row.Status, row.Version, fields, labels));
}

[HttpPost("templates/official")]
public async Task<IActionResult> InstallOfficial(Guid tenantId, CancellationToken ct)
{
var actor = Actor(); if (actor is null) return Unauthorized();
OfficialContractTemplates.EnsureValid();
await using var connection = await dataSource.OpenConnectionAsync(ct);
if (!await Allowed(connection, actor.Value, tenantId, "tenant.templates.manage", ct)) return Forbid();
await using var tx = await connection.BeginTransactionAsync(ct);
await connection.ExecuteAsync(new CommandDefinition(
"SELECT set_config('odca.tenant_id',@value,true),set_config('odca.actor_id',@actor,true)",
new { value = tenantId.ToString(), actor = actor.Value.ToString() }, tx, cancellationToken: ct));
var installed = 0;
var present = 0;
var names = new List<string>();
foreach (var template in OfficialContractTemplates.All)
{
var exists = await connection.ExecuteScalarAsync<bool>(new CommandDefinition("""
SELECT EXISTS(
SELECT 1 FROM odca.contract_templates
WHERE name = @name AND status <> 'archived'
AND (scope = 'global' OR owner_tenant_id = @tenantId))
""", new { template.Name, tenantId }, tx, cancellationToken: ct));
if (exists) { present++; continue; }
var id = Guid.NewGuid();
var versionId = Guid.NewGuid();
var fields = OfficialContractTemplates.SerializeFields(template.Fields);
await connection.ExecuteAsync(new CommandDefinition("""
INSERT INTO odca.contract_templates(id,owner_tenant_id,name,description,contract_type,scope,status,author_id,published_at)
VALUES(@id,@tenantId,@name,@description,@contractType,'private','published',@actor,now());
INSERT INTO odca.contract_template_versions(id,template_id,version_number,content,fields,created_by,published_at)
VALUES(@versionId,@id,1,@content::jsonb,@fields::jsonb,@actor,now());
INSERT INTO odca.audit_events(scope_type,tenant_id,actor_user_id,action,entity_type,entity_id,result,metadata)
VALUES('tenant',@tenantId,@actor,'template.official_installed','contract_template',@id,'success',jsonb_build_object('key',@key));
""", new { id, tenantId, name = template.Name, template.Description, template.ContractType, actor = actor.Value, versionId, content = template.Content, fields, key = template.Key }, tx, cancellationToken: ct));
installed++;
names.Add(template.Name);
}

await tx.CommitAsync(ct);
return Ok(new OfficialTemplateInstallResponse(installed, present, names));
}

private Guid? Actor() => Guid.TryParse(User.FindFirstValue("sub"), out var id) ? id : null;

private static Task<bool> Allowed(NpgsqlConnection connection, Guid actor, Guid tenant, string permission, CancellationToken ct) =>
connection.ExecuteScalarAsync<bool>(new CommandDefinition(
"SELECT odca.tenant_actor_has_permission(@actor,@tenant,@permission)",
new { actor, tenant, permission }, cancellationToken: ct));

private sealed record TemplatePreviewRow(Guid Id, string Name, string? Description, string ContractType, string Scope, string Status, int Version, string Fields);
}
228 changes: 228 additions & 0 deletions src/Odca.Application/Contracts/OfficialContractTemplates.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
using System.Text.Json;

namespace Odca.Application.Contracts;

/// <summary>
/// Published operational starters for the studio catalog. These are not legal advice
/// and must still be reviewed before a generated version is submitted.
/// </summary>
public static class OfficialContractTemplates
{
public static readonly IReadOnlySet<string> ContractTypes = new HashSet<string>(StringComparer.Ordinal)
{
"nda", "services", "amendment", "supply", "lease"
};

public static readonly IReadOnlySet<string> Scopes = new HashSet<string>(StringComparer.Ordinal)
{
"private", "consultancy", "global"
};

public static IReadOnlyList<OfficialContractTemplate> All { get; } =
[
NdaUnilateral(),
NdaMutual(),
ServicesAgreement(),
ContractAmendment()
];

public static string? NormalizeType(string? value) =>
string.IsNullOrWhiteSpace(value) || !ContractTypes.Contains(value.Trim()) ? null : value.Trim();

public static string? NormalizeScope(string? value) =>
string.IsNullOrWhiteSpace(value) || !Scopes.Contains(value.Trim()) ? null : value.Trim();

public static void EnsureValid()
{
foreach (var template in All)
{
StructuredContractDocument.Parse(template.Content, template.Fields);
}
}

public static string SerializeFields(IReadOnlyList<ContractFieldDefinition> fields) =>
JsonSerializer.Serialize(fields);

private static OfficialContractTemplate NdaUnilateral() => new(
"nda-unilateral",
"Termo de Confidencialidade (unilateral)",
"Modelo operacional para divulgação de informações a um destinatário. Revise partes, prazo e foro antes de gerar versão.",
"nda",
Document(
Heading("Termo de Confidencialidade"),
Paragraph(
Text("Pelo presente instrumento, "),
Field("discloser_name"),
Text(", inscrito(a) sob o documento "),
Field("discloser_document"),
Text(" (\"Divulgador\"), disponibiliza informações ao destinatário "),
Field("recipient_name"),
Text(", documento "),
Field("recipient_document"),
Text(" (\"Destinatário\").")),
Paragraph(
Text("Finalidade da divulgação: "),
Field("purpose"),
Text(". Vigência a partir de "),
Field("effective_date"),
Text(", pelo prazo de "),
Field("term_months"),
Text(" meses.")),
Paragraph(Text("O Destinatário obriga-se a usar as informações apenas para a finalidade declarada, a restringir o acesso a pessoas com necessidade de conhecê-las e a devolver ou eliminar cópias ao término, salvo retenção legal.")),
Paragraph(
Text("Foro e legislação de referência: "),
Field("jurisdiction"),
Text(". Este modelo é um ponto de partida operacional e não substitui revisão jurídica."))),
[
new("discloser_name", "Nome do divulgador", ContractFieldType.ShortText, true),
new("discloser_document", "Documento do divulgador", ContractFieldType.BrazilianDocument, true),
new("recipient_name", "Nome do destinatário", ContractFieldType.ShortText, true),
new("recipient_document", "Documento do destinatário", ContractFieldType.BrazilianDocument, true),
new("purpose", "Finalidade", ContractFieldType.LongText, true),
new("effective_date", "Início da vigência", ContractFieldType.Date, true),
new("term_months", "Prazo em meses", ContractFieldType.Number, true),
new("jurisdiction", "Foro", ContractFieldType.Choice, true, ["São Paulo/SP", "Rio de Janeiro/RJ", "Brasília/DF", "Belo Horizonte/MG"])
]);

private static OfficialContractTemplate NdaMutual() => new(
"nda-mutual",
"Acordo de Confidencialidade Recíproca",
"Modelo operacional para troca bilateral de informações. Confirme partes, objeto e prazo no painel do estúdio.",
"nda",
Document(
Heading("Acordo de Confidencialidade Recíproca"),
Paragraph(
Text("As partes "),
Field("party_a_name"),
Text(" ("),
Field("party_a_document"),
Text(") e "),
Field("party_b_name"),
Text(" ("),
Field("party_b_document"),
Text(") acordam proteger informações trocadas para "),
Field("purpose"),
Text(".")),
Paragraph(
Text("Vigência a partir de "),
Field("effective_date"),
Text(" até "),
Field("end_date"),
Text(". Cada parte permanece titular de suas informações e concede apenas o uso necessário à finalidade.")),
Paragraph(Text("A obrigação de confidencialidade sobrevive ao encerramento pelo período residual indicado na revisão jurídica da organização. Foro: "), Field("jurisdiction"), Text("."))),
[
new("party_a_name", "Parte A", ContractFieldType.ShortText, true),
new("party_a_document", "Documento da Parte A", ContractFieldType.BrazilianDocument, true),
new("party_b_name", "Parte B", ContractFieldType.ShortText, true),
new("party_b_document", "Documento da Parte B", ContractFieldType.BrazilianDocument, true),
new("purpose", "Objeto da troca", ContractFieldType.LongText, true),
new("effective_date", "Início", ContractFieldType.Date, true),
new("end_date", "Término", ContractFieldType.Date, true),
new("jurisdiction", "Foro", ContractFieldType.Choice, true, ["São Paulo/SP", "Rio de Janeiro/RJ", "Brasília/DF"])
]);

private static OfficialContractTemplate ServicesAgreement() => new(
"services-agreement",
"Contrato de Prestação de Serviços",
"Minuta operacional de serviços com objeto, valor e vigência estruturados. Confirme campos obrigatórios antes de gerar versão.",
"services",
Document(
Heading("Contrato de Prestação de Serviços"),
Paragraph(
Text("Contratante: "),
Field("client_name"),
Text(", documento "),
Field("client_document"),
Text(". Contratado: "),
Field("provider_name"),
Text(", documento "),
Field("provider_document"),
Text(".")),
Paragraph(Text("Objeto: "), Field("object")),
Paragraph(
Text("Valor: "),
Field("amount"),
Text(" com pagamento "),
Field("payment_terms"),
Text(". Vigência de "),
Field("start_date"),
Text(" a "),
Field("end_date"),
Text(".")),
Paragraph(
Text("Foro: "),
Field("jurisdiction"),
Text(". O contratado executa com diligência e o contratante disponibiliza as informações necessárias. Este texto não é parecer jurídico."))),
[
new("client_name", "Contratante", ContractFieldType.ShortText, true),
new("client_document", "Documento do contratante", ContractFieldType.BrazilianDocument, true),
new("provider_name", "Contratado", ContractFieldType.ShortText, true),
new("provider_document", "Documento do contratado", ContractFieldType.BrazilianDocument, true),
new("object", "Objeto dos serviços", ContractFieldType.LongText, true),
new("amount", "Valor", ContractFieldType.Currency, true),
new("payment_terms", "Condições de pagamento", ContractFieldType.Choice, true, ["mensal", "à vista", "por marco", "em 30 dias"]),
new("start_date", "Início", ContractFieldType.Date, true),
new("end_date", "Término", ContractFieldType.Date, true),
new("jurisdiction", "Foro", ContractFieldType.Choice, true, ["São Paulo/SP", "Rio de Janeiro/RJ", "Brasília/DF"])
]);

private static OfficialContractTemplate ContractAmendment() => new(
"contract-amendment",
"Termo de Aditivo Contratual",
"Aditivo operacional preso ao contrato base. Use para registrar alteração de prazo, valor ou objeto após revisão.",
"amendment",
Document(
Heading("Termo de Aditivo"),
Paragraph(
Text("Aditivo "),
Field("amendment_number"),
Text(" ao contrato "),
Field("base_title"),
Text(", referência "),
Field("base_reference"),
Text(", celebrado entre "),
Field("party_a_name"),
Text(" e "),
Field("party_b_name"),
Text(".")),
Paragraph(
Text("Vigência deste aditivo a partir de "),
Field("effective_date"),
Text(". Alteração: "),
Field("change_summary"),
Text(".")),
Paragraph(Text("As cláusulas não modificadas permanecem em vigor. Foro: "), Field("jurisdiction"), Text("."))),
[
new("amendment_number", "Número do aditivo", ContractFieldType.ShortText, true),
new("base_title", "Título do contrato base", ContractFieldType.ShortText, true),
new("base_reference", "Referência do contrato base", ContractFieldType.ShortText, true),
new("party_a_name", "Parte A", ContractFieldType.ShortText, true),
new("party_b_name", "Parte B", ContractFieldType.ShortText, true),
new("effective_date", "Início do aditivo", ContractFieldType.Date, true),
new("change_summary", "Resumo da alteração", ContractFieldType.LongText, true),
new("jurisdiction", "Foro", ContractFieldType.Choice, true, ["São Paulo/SP", "Rio de Janeiro/RJ", "Brasília/DF"])
]);

private static string Document(params string[] blocks) =>
"{\"type\":\"document\",\"content\":[" + string.Join(',', blocks) + "]}";

private static string Heading(string text) =>
"{\"type\":\"heading\",\"level\":1,\"content\":[{\"type\":\"text\",\"text\":" + JsonSerializer.Serialize(text) + "}]}";

private static string Paragraph(params string[] nodes) =>
"{\"type\":\"paragraph\",\"alignment\":\"justify\",\"content\":[" + string.Join(',', nodes) + "]}";

private static string Text(string value) =>
"{\"type\":\"text\",\"text\":" + JsonSerializer.Serialize(value) + "}";

private static string Field(string id) =>
"{\"type\":\"field\",\"fieldId\":" + JsonSerializer.Serialize(id) + "}";
}

public sealed record OfficialContractTemplate(
string Key,
string Name,
string Description,
string ContractType,
string Content,
IReadOnlyList<ContractFieldDefinition> Fields);
2 changes: 2 additions & 0 deletions src/Odca.Contracts/Studio/StudioContracts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,5 @@ public sealed record StudioCommentItem(Guid Id, Guid? VersionId, long DraftRevis
public sealed record CreateTemplateRequest(string Name, string? Description, string ContractType, string Scope, JsonElement Content, JsonElement Fields);
public sealed record UpdateTemplateRequest(string Name, string? Description, string ContractType, JsonElement Content, JsonElement Fields, long ExpectedVersion);
public sealed record TemplateMutationResponse(Guid Id, int Version, long RowVersion, string Status);
public sealed record TemplatePreview(Guid Id, string Name, string? Description, string ContractType, string Scope, string Status, int Version, JsonElement Fields, IReadOnlyList<string> FieldLabels);
public sealed record OfficialTemplateInstallResponse(int Installed, int AlreadyPresent, IReadOnlyList<string> Names);
2 changes: 1 addition & 1 deletion src/Odca.Infrastructure/Database/DatabaseSchema.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ namespace Odca.Infrastructure.Database;

public static class DatabaseSchema
{
public const int CurrentVersion = 18;
public const int CurrentVersion = 19;
}
Loading
Loading