@oondemand/create-central-oon 0.7.4 → 0.7.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -14
- package/bin/cli.js +12 -2
- package/docs/AGENTS.md +12 -56
- package/docs/AGENT_WORKFLOW.md +8 -15
- package/docs/BACKEND_API.md +5 -5
- package/docs/BACKEND_DOMAIN_MANIFEST.md +1 -1
- package/docs/CAPABILITIES.md +11 -3
- package/docs/CHECKLIST_IMPLEMENTACAO.md +4 -5
- package/docs/CORE_UPGRADE.md +17 -14
- package/docs/ERRORS.md +1 -1
- package/docs/EXECUTABLE_EXAMPLE.md +42 -0
- package/docs/NATIVE_RESOURCES.md +94 -0
- package/docs/OON_APP_AGENT_GUIDE.md +26 -45
- package/docs/PDF_RENDERING.md +2 -3
- package/docs/PUBLIC_CONTRACTS.md +124 -0
- package/docs/RBAC_SECURITY.md +1 -1
- package/docs/ROUTES_HOOKS_WORKERS.md +19 -8
- package/docs/SECRETS.md +54 -0
- package/docs/TASK_EXAMPLES.md +134 -0
- package/docs/TASK_INDEX.md +36 -0
- package/docs/TESTING_CONFORMANCE.md +12 -19
- package/docs/TRANSACTIONAL_EMAIL.md +1 -1
- package/examples/first-journey/FIRST_JOURNEY.md +27 -0
- package/examples/first-journey/backend/central.domain.json +141 -0
- package/examples/first-journey/backend/central.process.json +105 -0
- package/examples/first-journey/backend/emails/confirmacao.json +12 -0
- package/examples/first-journey/backend/src/models/Documento.js +9 -0
- package/examples/first-journey/backend/src/models/Pessoa.js +6 -0
- package/examples/first-journey/backend/src/routes/integracao.js +23 -0
- package/examples/first-journey/backend/src/routes/jornada.js +10 -0
- package/examples/first-journey/backend/src/routes/recursos.js +26 -0
- package/examples/first-journey/backend/src/routes/segredos.js +27 -0
- package/examples/first-journey/backend/src/services/confirmacao.js +11 -0
- package/examples/first-journey/frontend/src/pages/JourneyPage.tsx +32 -0
- package/examples/first-journey/frontend/src/pages/NativeResourcesPage.tsx +50 -0
- package/examples/first-journey/frontend/src/pages/SecretsPage.tsx +31 -0
- package/package.json +3 -2
- package/schemas/central.app.schema.json +12 -0
- package/src/doctor.js +90 -0
- package/src/example.js +39 -0
- package/src/index.js +31 -16
- package/templates/_base/README.md +17 -46
- package/templates/_base/backend/package.json +1 -1
- package/templates/_base/frontend/package.json +1 -1
- package/templates/_base/frontend/src/app/app.tsx +2 -2
- package/templates/_base/frontend/src/pages/HomePage.tsx +3 -3
- package/templates/_base/package.json +2 -2
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"name": "Primeira jornada",
|
|
4
|
+
"models": [
|
|
5
|
+
{
|
|
6
|
+
"name": "Ticket",
|
|
7
|
+
"singular": "ticket",
|
|
8
|
+
"basePath": "/tickets",
|
|
9
|
+
"crud": {
|
|
10
|
+
"enabled": true,
|
|
11
|
+
"permissions": {
|
|
12
|
+
"read": "tickets.read",
|
|
13
|
+
"write": "tickets.write"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"fields": {
|
|
17
|
+
"titulo": {
|
|
18
|
+
"kind": "string",
|
|
19
|
+
"required": true
|
|
20
|
+
},
|
|
21
|
+
"etapa": {
|
|
22
|
+
"kind": "enum",
|
|
23
|
+
"values": [
|
|
24
|
+
"Aberto",
|
|
25
|
+
"Concluido"
|
|
26
|
+
],
|
|
27
|
+
"default": "Aberto"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"name": "Contrato",
|
|
33
|
+
"basePath": "/contratos",
|
|
34
|
+
"crud": {
|
|
35
|
+
"enabled": true,
|
|
36
|
+
"permissions": {
|
|
37
|
+
"read": "contratos.read",
|
|
38
|
+
"write": "contratos.write"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"fields": {
|
|
42
|
+
"titulo": {
|
|
43
|
+
"kind": "string",
|
|
44
|
+
"required": true,
|
|
45
|
+
"maxLength": 80
|
|
46
|
+
},
|
|
47
|
+
"quantidade": {
|
|
48
|
+
"kind": "number",
|
|
49
|
+
"required": true,
|
|
50
|
+
"min": 1
|
|
51
|
+
},
|
|
52
|
+
"valorUnitario": {
|
|
53
|
+
"kind": "currency",
|
|
54
|
+
"required": true,
|
|
55
|
+
"min": 0
|
|
56
|
+
},
|
|
57
|
+
"total": {
|
|
58
|
+
"kind": "currency",
|
|
59
|
+
"computed": {
|
|
60
|
+
"precision": 2,
|
|
61
|
+
"expression": {
|
|
62
|
+
"op": "multiply",
|
|
63
|
+
"args": [
|
|
64
|
+
{
|
|
65
|
+
"field": "quantidade"
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"field": "valorUnitario"
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
"pago": {
|
|
75
|
+
"kind": "currency",
|
|
76
|
+
"readonly": true,
|
|
77
|
+
"default": 0
|
|
78
|
+
},
|
|
79
|
+
"saldo": {
|
|
80
|
+
"kind": "currency",
|
|
81
|
+
"readonly": true,
|
|
82
|
+
"default": 0
|
|
83
|
+
},
|
|
84
|
+
"etapa": {
|
|
85
|
+
"kind": "enum",
|
|
86
|
+
"values": [
|
|
87
|
+
"Aberto",
|
|
88
|
+
"Fechado"
|
|
89
|
+
],
|
|
90
|
+
"default": "Aberto"
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
"validations": [
|
|
94
|
+
{
|
|
95
|
+
"name": "limite-local",
|
|
96
|
+
"code": "TOTAL_EXCEDE_LIMITE",
|
|
97
|
+
"message": "O total da demonstração deve ser no máximo 1000.",
|
|
98
|
+
"field": "quantidade",
|
|
99
|
+
"assert": {
|
|
100
|
+
"op": "lte",
|
|
101
|
+
"args": [
|
|
102
|
+
{
|
|
103
|
+
"field": "total"
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
"value": 1000
|
|
107
|
+
}
|
|
108
|
+
]
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
]
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
"name": "Parcela",
|
|
115
|
+
"basePath": "/parcelas",
|
|
116
|
+
"crud": {
|
|
117
|
+
"enabled": true,
|
|
118
|
+
"permissions": {
|
|
119
|
+
"read": "parcelas.read",
|
|
120
|
+
"write": "parcelas.write"
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
"fields": {
|
|
124
|
+
"contratoId": {
|
|
125
|
+
"kind": "ref",
|
|
126
|
+
"ref": "Contrato",
|
|
127
|
+
"required": true
|
|
128
|
+
},
|
|
129
|
+
"valor": {
|
|
130
|
+
"kind": "currency",
|
|
131
|
+
"required": true,
|
|
132
|
+
"min": 0.01
|
|
133
|
+
},
|
|
134
|
+
"tituloContrato": {
|
|
135
|
+
"kind": "string",
|
|
136
|
+
"readonly": true
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
]
|
|
141
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"models": {
|
|
4
|
+
"Ticket": {
|
|
5
|
+
"workflow": {
|
|
6
|
+
"stageField": "etapa",
|
|
7
|
+
"initialStages": [
|
|
8
|
+
"Aberto"
|
|
9
|
+
],
|
|
10
|
+
"defaultStage": "Aberto",
|
|
11
|
+
"transitions": [
|
|
12
|
+
{
|
|
13
|
+
"from": "Aberto",
|
|
14
|
+
"to": "Concluido"
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"Contrato": {
|
|
20
|
+
"workflow": {
|
|
21
|
+
"stageField": "etapa",
|
|
22
|
+
"initialStages": [
|
|
23
|
+
"Aberto"
|
|
24
|
+
],
|
|
25
|
+
"defaultStage": "Aberto",
|
|
26
|
+
"transitions": [
|
|
27
|
+
{
|
|
28
|
+
"from": "Aberto",
|
|
29
|
+
"to": "Fechado"
|
|
30
|
+
}
|
|
31
|
+
],
|
|
32
|
+
"lockedFieldsByStage": {
|
|
33
|
+
"Fechado": [
|
|
34
|
+
"titulo",
|
|
35
|
+
"quantidade",
|
|
36
|
+
"valorUnitario"
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"bindings": [
|
|
41
|
+
{
|
|
42
|
+
"field": "pago",
|
|
43
|
+
"kind": "aggregate",
|
|
44
|
+
"sourceModel": "Parcela",
|
|
45
|
+
"foreignField": "contratoId",
|
|
46
|
+
"operator": "sum",
|
|
47
|
+
"sourceField": "valor",
|
|
48
|
+
"default": 0
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"field": "saldo",
|
|
52
|
+
"kind": "expression",
|
|
53
|
+
"precision": 2,
|
|
54
|
+
"expression": {
|
|
55
|
+
"op": "subtract",
|
|
56
|
+
"args": [
|
|
57
|
+
{
|
|
58
|
+
"field": "total"
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"field": "pago"
|
|
62
|
+
}
|
|
63
|
+
]
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
],
|
|
67
|
+
"deleteProtection": [
|
|
68
|
+
{
|
|
69
|
+
"sourceModel": "Parcela",
|
|
70
|
+
"foreignField": "contratoId",
|
|
71
|
+
"message": "Existem parcelas vinculadas."
|
|
72
|
+
}
|
|
73
|
+
]
|
|
74
|
+
},
|
|
75
|
+
"Parcela": {
|
|
76
|
+
"bindings": [
|
|
77
|
+
{
|
|
78
|
+
"field": "tituloContrato",
|
|
79
|
+
"kind": "lookup",
|
|
80
|
+
"sourceModel": "Contrato",
|
|
81
|
+
"localField": "contratoId",
|
|
82
|
+
"sourceField": "titulo",
|
|
83
|
+
"watchFields": [
|
|
84
|
+
"titulo"
|
|
85
|
+
],
|
|
86
|
+
"recalculate": "async",
|
|
87
|
+
"default": ""
|
|
88
|
+
}
|
|
89
|
+
],
|
|
90
|
+
"atomicInvariants": [
|
|
91
|
+
{
|
|
92
|
+
"name": "limite-parcelas",
|
|
93
|
+
"kind": "relatedSumLteParentField",
|
|
94
|
+
"parentModel": "Contrato",
|
|
95
|
+
"parentLocalField": "contratoId",
|
|
96
|
+
"sourceField": "valor",
|
|
97
|
+
"parentField": "total",
|
|
98
|
+
"tolerance": 0,
|
|
99
|
+
"code": "PARCELAS_ACIMA_TOTAL",
|
|
100
|
+
"message": "A soma das parcelas excede o total do contrato."
|
|
101
|
+
}
|
|
102
|
+
]
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"code": "confirmacao", "name": "Confirmação",
|
|
3
|
+
"subject": "Confirmação para {{nome}}",
|
|
4
|
+
"htmlContent": "<p>Olá, {{nome}}. Sua operação foi recebida.</p>",
|
|
5
|
+
"textContent": "Olá, {{nome}}. Sua operação foi recebida.",
|
|
6
|
+
"variablesSchema": {
|
|
7
|
+
"type": "object", "additionalProperties": false, "required": ["nome"],
|
|
8
|
+
"properties": { "nome": { "type": "string" } }
|
|
9
|
+
},
|
|
10
|
+
"locale": "pt-BR", "initialStatus": "active",
|
|
11
|
+
"attachmentsPolicy": { "allowed": false }
|
|
12
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
const { defineModel, fields } = require("@oondemand/oon-core-back");
|
|
2
|
+
defineModel({
|
|
3
|
+
name: "Documento", singular: "documento", basePath: "/documentos", scope: "tenant",
|
|
4
|
+
schema: {
|
|
5
|
+
titulo: fields.string({ required: true, label: "Título" }),
|
|
6
|
+
conteudo: fields.string({ required: true, label: "Conteúdo" }),
|
|
7
|
+
},
|
|
8
|
+
crud: { enabled: true, permissions: { read: "documentos.read", write: "documentos.write" } },
|
|
9
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
const { defineModel, fields } = require("@oondemand/oon-core-back");
|
|
2
|
+
module.exports = defineModel({
|
|
3
|
+
name: "Pessoa", singular: "pessoa", basePath: "/pessoas", scope: "tenant",
|
|
4
|
+
schema: { nome: fields.string({ required: true, label: "Nome" }) },
|
|
5
|
+
crud: { enabled: true, permissions: { read: "pessoas.read", write: "pessoas.write" } },
|
|
6
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const { defineRoutes, GenericError } = require("@oondemand/oon-core-back");
|
|
2
|
+
// The secret stays in the backend environment; no secret is accepted from the UI.
|
|
3
|
+
defineRoutes("/integracao", (router) => {
|
|
4
|
+
router.private.post("/testar", { permission: "integracao.test", sensitiveBody: true }, async (req, res) => {
|
|
5
|
+
const url = process.env.INTEGRACAO_URL;
|
|
6
|
+
const token = process.env.INTEGRACAO_TOKEN;
|
|
7
|
+
if (!url || !token) throw new GenericError("Configure a integração no backend.", { statusCode: 409, code: "INTEGRATION_NOT_CONFIGURED" });
|
|
8
|
+
let target;
|
|
9
|
+
try { target = new URL(url); } catch { /* safe error below */ }
|
|
10
|
+
if (!target || target.protocol !== "https:" || target.username || target.password) {
|
|
11
|
+
throw new GenericError("Configure um endpoint HTTPS sem credenciais na URL.", { statusCode: 422, code: "INTEGRATION_URL_INVALID" });
|
|
12
|
+
}
|
|
13
|
+
let response;
|
|
14
|
+
try {
|
|
15
|
+
response = await fetch(target, { headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(10000), redirect: "error" });
|
|
16
|
+
} catch {
|
|
17
|
+
throw new GenericError("Serviço externo indisponível.", { statusCode: 502, code: "INTEGRATION_TRANSPORT_ERROR" });
|
|
18
|
+
}
|
|
19
|
+
await response.body?.cancel();
|
|
20
|
+
if (!response.ok) throw new GenericError("Consulta recusada pelo serviço externo.", { statusCode: 502, code: "INTEGRATION_REMOTE_REJECTED", details: { remoteStatus: response.status } });
|
|
21
|
+
res.json({ message: "Consulta externa concluída." });
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const { defineRoutes, GenericError } = require("@oondemand/oon-core-back");
|
|
2
|
+
defineRoutes("/jornada", (router) => {
|
|
3
|
+
router.private.post("/saudar", { permission: "jornada.write" }, async (req, res) => {
|
|
4
|
+
const nome = req.body?.nome;
|
|
5
|
+
if (typeof nome !== "string" || !nome.trim() || nome.length > 80) {
|
|
6
|
+
throw new GenericError("Informe um nome com até 80 caracteres.", { statusCode: 422, code: "NAME_INVALID" });
|
|
7
|
+
}
|
|
8
|
+
res.json({ message: `Olá, ${nome.trim()}!` });
|
|
9
|
+
});
|
|
10
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const { defineRoutes, mergeScopedFilter, capabilities } = require("@oondemand/oon-core-back");
|
|
2
|
+
const pessoa = require("../models/Pessoa");
|
|
3
|
+
|
|
4
|
+
defineRoutes("/recursos", router => {
|
|
5
|
+
router.private.get("/pessoas/contagem", { permission: "pessoas.read" }, async (req, res) => {
|
|
6
|
+
// Não propague filtros arbitrários, tenantId ou operadores recebidos do cliente.
|
|
7
|
+
const filter = mergeScopedFilter(pessoa, {}, req.accessContext);
|
|
8
|
+
res.json({ total: await pessoa.mongooseModel.countDocuments(filter) });
|
|
9
|
+
});
|
|
10
|
+
router.private.post("/pdf", { permission: "pdf.render", sensitiveBody: true }, async (req, res) => {
|
|
11
|
+
const controller = new AbortController();
|
|
12
|
+
const abort = () => controller.abort();
|
|
13
|
+
req.once("aborted", abort);
|
|
14
|
+
res.once("close", abort);
|
|
15
|
+
try {
|
|
16
|
+
// HTML fixo: nenhum dado secreto ou conteúdo remoto nesta demonstração.
|
|
17
|
+
const pdf = await capabilities.pdf.render({ html: "<h1>Recursos nativos</h1>", format: "A4" }, {
|
|
18
|
+
...req.accessContext, correlationId: req.correlationId, signal: controller.signal,
|
|
19
|
+
});
|
|
20
|
+
res.type("application/pdf").send(pdf);
|
|
21
|
+
} finally {
|
|
22
|
+
req.off("aborted", abort);
|
|
23
|
+
res.off("close", abort);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const { defineRoutes, capabilities, GenericError } = require("@oondemand/oon-core-back");
|
|
2
|
+
const { randomUUID } = require("node:crypto");
|
|
3
|
+
function options(req) {
|
|
4
|
+
if (!["omie", "secondary"].includes(req.params.name)) throw new GenericError("Nome inválido.", { statusCode: 422 });
|
|
5
|
+
return { name: req.params.name, context: req.accessContext, correlationId: randomUUID() };
|
|
6
|
+
}
|
|
7
|
+
defineRoutes("/segredos", router => {
|
|
8
|
+
router.private.get("/:name", { permission: "segredos.manage", sensitiveBody: true }, async (req, res) => res.json(await capabilities.secrets.status(options(req))));
|
|
9
|
+
router.private.put("/:name", { permission: "segredos.manage", sensitiveBody: true }, async (req, res) => {
|
|
10
|
+
const { appKey, appSecret, replace } = req.body || {};
|
|
11
|
+
if (typeof appKey !== "string" || typeof appSecret !== "string" || !appKey || !appSecret || appKey.length > 4096 || appSecret.length > 4096) throw new GenericError("Informe as duas credenciais.", { statusCode: 422 });
|
|
12
|
+
res.json(await capabilities.secrets.put({ ...options(req), value: { appKey, appSecret }, replace: replace === true }));
|
|
13
|
+
});
|
|
14
|
+
router.private.delete("/:name", { permission: "segredos.manage", sensitiveBody: true }, async (req, res) => res.json(await capabilities.secrets.remove(options(req))));
|
|
15
|
+
router.private.post("/:name/testar", { permission: "segredos.manage", sensitiveBody: true }, async (req, res) => {
|
|
16
|
+
// Endpoint controlled by backend configuration, never by request body.
|
|
17
|
+
let target;
|
|
18
|
+
try { target = new URL(process.env.INTEGRACAO_URL); } catch { /* sanitized below */ }
|
|
19
|
+
if (!target || target.protocol !== "https:" || target.username || target.password) throw new GenericError("Configure INTEGRACAO_URL HTTPS no backend.", { statusCode: 409 });
|
|
20
|
+
await capabilities.secrets.use(options(req), async credential => {
|
|
21
|
+
const response = await fetch(target, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(credential), redirect: "error", signal: AbortSignal.timeout(10000) });
|
|
22
|
+
await response.body?.cancel();
|
|
23
|
+
if (!response.ok) throw Error("remote rejected");
|
|
24
|
+
});
|
|
25
|
+
res.json({ message: "Credencial utilizada no backend." });
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const { capabilities } = require("@oondemand/oon-core-back");
|
|
2
|
+
|
|
3
|
+
// Chame somente após autorizar a operação e resolver o destinatário no domínio.
|
|
4
|
+
// Use uma chave estável do evento de negócio; accepted não significa entregue.
|
|
5
|
+
async function enviarConfirmacao({ email, nome, eventId }, req) {
|
|
6
|
+
return capabilities.transactionalEmail.send({
|
|
7
|
+
templateCode: "confirmacao", to: email, variables: { nome },
|
|
8
|
+
idempotencyKey: eventId, correlationId: req.correlationId, maxAttempts: 5,
|
|
9
|
+
}, { ...req.accessContext });
|
|
10
|
+
}
|
|
11
|
+
module.exports = { enviarConfirmacao };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { CoreCollection, CorePipeline, useOonApi } from "@oondemand/oon-core-front";
|
|
3
|
+
|
|
4
|
+
export function JourneyPage() {
|
|
5
|
+
const { http } = useOonApi();
|
|
6
|
+
const [nome, setNome] = useState("");
|
|
7
|
+
const [message, setMessage] = useState("");
|
|
8
|
+
const [busy, setBusy] = useState(false);
|
|
9
|
+
async function send(path: string, body: object) {
|
|
10
|
+
setBusy(true);
|
|
11
|
+
setMessage("");
|
|
12
|
+
try { const response = await http.post(path, body); setMessage(response.data.message); }
|
|
13
|
+
catch (error: any) { setMessage(error?.response?.data?.message || "Não foi possível executar a ação."); }
|
|
14
|
+
finally { setBusy(false); }
|
|
15
|
+
}
|
|
16
|
+
return <main>
|
|
17
|
+
<h1>Primeira jornada</h1>
|
|
18
|
+
<p>Selecione o perfil admin no runtime local para executar. Viewer não possui as permissões destas operações.</p>
|
|
19
|
+
<section aria-label="Formulário privado">
|
|
20
|
+
<form onSubmit={(event) => { event.preventDefault(); void send("/jornada/saudar", { nome }); }}>
|
|
21
|
+
<label>Nome <input value={nome} maxLength={80} required onChange={event => setNome(event.target.value)} /></label>
|
|
22
|
+
<button disabled={busy} type="submit">Saudar</button>
|
|
23
|
+
</form>
|
|
24
|
+
<button disabled={busy} onClick={() => void send("/integracao/testar", {})}>Testar integração externa</button>
|
|
25
|
+
<p role="status">{message}</p>
|
|
26
|
+
</section>
|
|
27
|
+
<CoreCollection model="Pessoa" label="Pessoas" endpoint="/pessoas" />
|
|
28
|
+
<CorePipeline model="Ticket" label="Tickets" endpoint="/tickets" titleField="titulo" stageField="etapa"
|
|
29
|
+
create={{ enabled: true, defaultStage: "Aberto" }} defaultActions={{ workStatus: false }}
|
|
30
|
+
stages={[{ id: "Aberto", label: "Aberto" }, { id: "Concluido", label: "Concluído" }]} />
|
|
31
|
+
</main>;
|
|
32
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { CoreCollection, CoreDocument, CoreDashboard, useOonApi } from "@oondemand/oon-core-front";
|
|
3
|
+
|
|
4
|
+
export function NativeResourcesPage() {
|
|
5
|
+
const { http } = useOonApi();
|
|
6
|
+
const [tab, setTab] = useState("contratos");
|
|
7
|
+
const [message, setMessage] = useState("");
|
|
8
|
+
const [busy, setBusy] = useState(false);
|
|
9
|
+
async function download() {
|
|
10
|
+
setBusy(true);
|
|
11
|
+
setMessage("");
|
|
12
|
+
try {
|
|
13
|
+
const response = await http.post("/recursos/pdf", {}, { responseType: "blob" });
|
|
14
|
+
const url = URL.createObjectURL(response.data);
|
|
15
|
+
const link = document.createElement("a");
|
|
16
|
+
link.href = url;
|
|
17
|
+
link.download = "recursos.pdf";
|
|
18
|
+
link.click();
|
|
19
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
20
|
+
setMessage("PDF gerado.");
|
|
21
|
+
} catch (error: any) {
|
|
22
|
+
const data = error?.response?.data;
|
|
23
|
+
let detail = data;
|
|
24
|
+
if (data instanceof Blob) { try { detail = JSON.parse(await data.text()); } catch { detail = undefined; } }
|
|
25
|
+
setMessage(detail?.error?.message || detail?.message || "PDF indisponível neste ambiente.");
|
|
26
|
+
} finally { setBusy(false); }
|
|
27
|
+
}
|
|
28
|
+
return <main>
|
|
29
|
+
<h1>Recursos nativos</h1>
|
|
30
|
+
<p>Demonstração opcional em banco dedicado. Fórmulas, transições e limites são aplicados no backend.</p>
|
|
31
|
+
<label>Visualização <select value={tab} onChange={event => setTab(event.target.value)}>
|
|
32
|
+
<option value="contratos">Contratos</option><option value="parcelas">Parcelas</option>
|
|
33
|
+
<option value="documentos">Documentos</option><option value="indicadores">Indicadores</option>
|
|
34
|
+
<option value="pdf">PDF</option>
|
|
35
|
+
</select></label>
|
|
36
|
+
{tab === "contratos" && <CoreCollection model="Contrato" label="Contratos" endpoint="/contratos" />}
|
|
37
|
+
{tab === "parcelas" && <CoreCollection model="Parcela" label="Parcelas" endpoint="/parcelas" />}
|
|
38
|
+
{tab === "documentos" && <CoreDocument model="Documento" label="Documentos de texto" endpoint="/documentos" />}
|
|
39
|
+
{tab === "indicadores" && <CoreDashboard label="Contratos e parcelas" widgets={[
|
|
40
|
+
{ id: "contratos", model: "Contrato", kind: "count", label: "Contratos" },
|
|
41
|
+
{ id: "total", model: "Contrato", kind: "sum", field: "total", label: "Valor total" },
|
|
42
|
+
{ id: "etapas", model: "Contrato", kind: "groupCount", groupBy: "etapa", label: "Por etapa" },
|
|
43
|
+
]} />}
|
|
44
|
+
{tab === "pdf" && <section>
|
|
45
|
+
<p>Requer a capability de PDF resolvida. Sem ela, a operação apresenta indisponibilidade.</p>
|
|
46
|
+
<button disabled={busy} onClick={() => void download()}>Gerar PDF de demonstração</button>
|
|
47
|
+
<p role="status">{message}</p>
|
|
48
|
+
</section>}
|
|
49
|
+
</main>;
|
|
50
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useOonApi } from "@oondemand/oon-core-front";
|
|
3
|
+
export function SecretsPage() {
|
|
4
|
+
const { http } = useOonApi();
|
|
5
|
+
const [name, setName] = useState("omie");
|
|
6
|
+
const [appKey, setKey] = useState("");
|
|
7
|
+
const [appSecret, setSecret] = useState("");
|
|
8
|
+
const [replace, setReplace] = useState(false);
|
|
9
|
+
const [busy, setBusy] = useState(false);
|
|
10
|
+
const [message, setMessage] = useState("");
|
|
11
|
+
async function act(action: string) {
|
|
12
|
+
setBusy(true); setMessage("");
|
|
13
|
+
try {
|
|
14
|
+
const url = `/segredos/${name}`;
|
|
15
|
+
const result = action === "save" ? await http.put(url, { appKey, appSecret, replace }) : action === "remove" ? await http.delete(url) : action === "use" ? await http.post(`${url}/testar`, {}) : await http.get(url);
|
|
16
|
+
setMessage(result.data.message || ({ absent: "Não cadastrado", available: "Disponível", reentry_required: "Recadastro necessário; confira o provisionamento antes de substituir." } as Record<string, string>)[result.data.state]);
|
|
17
|
+
} catch { setMessage("Operação recusada. Confira acesso, configuração e estado da credencial."); }
|
|
18
|
+
finally { setKey(""); setSecret(""); setReplace(false); setBusy(false); }
|
|
19
|
+
}
|
|
20
|
+
return <main><h1>Segredos</h1><p>Valores são utilizados apenas no backend e nunca são exibidos após o cadastro.</p>
|
|
21
|
+
<fieldset disabled={busy}><legend>Credencial</legend>
|
|
22
|
+
<label>Nome <select value={name} onChange={e => { setName(e.target.value); setKey(""); setSecret(""); setMessage(""); }}><option>omie</option><option>secondary</option></select></label>
|
|
23
|
+
<form onSubmit={e => { e.preventDefault(); void act("save"); }}>
|
|
24
|
+
<label>App Key <input type="password" autoComplete="off" value={appKey} required maxLength={4096} onChange={e => setKey(e.target.value)} /></label>
|
|
25
|
+
<label>App Secret <input type="password" autoComplete="new-password" value={appSecret} required maxLength={4096} onChange={e => setSecret(e.target.value)} /></label>
|
|
26
|
+
<label><input type="checkbox" checked={replace} onChange={e => setReplace(e.target.checked)} />Substituir explicitamente valor incompatível</label>
|
|
27
|
+
<button>Salvar</button>
|
|
28
|
+
</form>
|
|
29
|
+
<button onClick={() => void act("status")}>Consultar estado</button><button onClick={() => void act("use")}>Testar chamada externa</button><button onClick={() => void act("remove")}>Remover</button>
|
|
30
|
+
</fieldset><p role="status">{message}</p></main>;
|
|
31
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oondemand/create-central-oon",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.8",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/oondemand/oon-platform.git",
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
"templates",
|
|
24
24
|
"schemas",
|
|
25
25
|
"docs",
|
|
26
|
-
"README.md"
|
|
26
|
+
"README.md",
|
|
27
|
+
"examples"
|
|
27
28
|
],
|
|
28
29
|
"scripts": {
|
|
29
30
|
"test": "node --test"
|
package/src/doctor.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const fs = require("node:fs");
|
|
3
|
+
const path = require("node:path");
|
|
4
|
+
const vm = require("node:vm");
|
|
5
|
+
const { createRequire } = require("node:module");
|
|
6
|
+
const { checkDocs, resolveCentralRoot } = require("./index");
|
|
7
|
+
const { checkConformance, listFiles } = require("./conformance");
|
|
8
|
+
|
|
9
|
+
// Read package metadata without importing/running consumer code.
|
|
10
|
+
function installedPackage(root, name) {
|
|
11
|
+
const resolver = createRequire(path.join(root, "package.json"));
|
|
12
|
+
// The frontend exports only the import condition; require.resolve(name)
|
|
13
|
+
// would reject a correctly installed ESM-only package.
|
|
14
|
+
for (const directory of resolver.resolve.paths(name) || []) {
|
|
15
|
+
const file = path.join(directory, name, "package.json");
|
|
16
|
+
if (!fs.existsSync(file)) continue;
|
|
17
|
+
const pkg = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
18
|
+
if (pkg.name === name) return pkg;
|
|
19
|
+
}
|
|
20
|
+
throw new Error(`Pacote ${name} não encontrado.`);
|
|
21
|
+
}
|
|
22
|
+
function doctor({ cwd = process.cwd() } = {}) {
|
|
23
|
+
const root = resolveCentralRoot(cwd);
|
|
24
|
+
const issues = [];
|
|
25
|
+
const add = (code, file, message) => issues.push({ code, path: file, message });
|
|
26
|
+
const versions = {};
|
|
27
|
+
let backendType;
|
|
28
|
+
let workspaceLock;
|
|
29
|
+
try {
|
|
30
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
|
|
31
|
+
if (pkg.workspaces) workspaceLock = JSON.parse(fs.readFileSync(path.join(root, "package-lock.json"), "utf8"));
|
|
32
|
+
} catch { /* diagnosed below */ }
|
|
33
|
+
for (const [directory, name] of [["", "@oondemand/create-central-oon"], ["backend", "@oondemand/oon-core-back"], ["frontend", "@oondemand/oon-core-front"]]) {
|
|
34
|
+
const file = path.join(directory, "package.json");
|
|
35
|
+
try {
|
|
36
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(root, file), "utf8"));
|
|
37
|
+
if (directory === "backend") backendType = pkg.type;
|
|
38
|
+
const spec = pkg.dependencies?.[name] || pkg.devDependencies?.[name];
|
|
39
|
+
if (!spec) add("CORE_DEPENDENCY_MISSING", file, `Declare ${name}.`);
|
|
40
|
+
try { versions[name] = installedPackage(path.join(root, directory), name).version; }
|
|
41
|
+
catch { add("CORE_NOT_INSTALLED", file, `Instale ${name} com npm ci no diretório ${directory || "."}.`); }
|
|
42
|
+
const isWorkspace = Boolean(workspaceLock && (!directory || workspaceLock.packages?.[directory]));
|
|
43
|
+
const lockPath = isWorkspace ? "package-lock.json" : path.join(directory, "package-lock.json");
|
|
44
|
+
try {
|
|
45
|
+
const lock = isWorkspace ? workspaceLock : JSON.parse(fs.readFileSync(path.join(root, lockPath), "utf8"));
|
|
46
|
+
const lockedRoot = lock.packages?.[isWorkspace ? directory : ""];
|
|
47
|
+
if ((lockedRoot?.dependencies?.[name] || lockedRoot?.devDependencies?.[name]) !== spec) {
|
|
48
|
+
add("LOCK_SPEC_MISMATCH", lockPath, `Lockfile diverge de ${file}; atualize com npm install e versione ambos.`);
|
|
49
|
+
}
|
|
50
|
+
const locked = (isWorkspace && directory && lock.packages?.[`${directory}/node_modules/${name}`]) || lock.packages?.[`node_modules/${name}`];
|
|
51
|
+
if (!locked?.version || locked.version !== versions[name]) {
|
|
52
|
+
add("LOCK_VERSION_MISMATCH", lockPath, `Versão instalada de ${name} diverge do lockfile; execute npm ci.`);
|
|
53
|
+
}
|
|
54
|
+
} catch { add("LOCKFILE_REQUIRED", lockPath, "Gere um lockfile npm v2/v3 com npm install neste diretório."); }
|
|
55
|
+
} catch (error) { add("PACKAGE_INVALID", file, error.message); }
|
|
56
|
+
}
|
|
57
|
+
if (new Set(Object.values(versions)).size > 1) add("CORE_VERSION_MISMATCH", "package.json", "Alinhe backend, frontend e gerador na mesma release; consulte CORE_UPGRADE.md.");
|
|
58
|
+
const generatorVersion = require("../package.json").version;
|
|
59
|
+
if (versions["@oondemand/create-central-oon"] && versions["@oondemand/create-central-oon"] !== generatorVersion) {
|
|
60
|
+
add("DOCTOR_VERSION_MISMATCH", "package.json", "Execute o create-central-oon instalado no App, usando npm run check.");
|
|
61
|
+
}
|
|
62
|
+
try { checkDocs({ cwd: root, quiet: true }); } catch (error) { add("DOCS_OUTDATED", ".ooncore/manifest.json", error.message); }
|
|
63
|
+
try { issues.push(...checkConformance({ cwd: root }).issues); } catch (error) { add("COMPOSITION_INVALID", ".", error.message); }
|
|
64
|
+
try {
|
|
65
|
+
const core = createRequire(path.join(root, "backend/package.json"))("@oondemand/oon-core-back");
|
|
66
|
+
for (const [file, validate] of [["central.domain.json", core.validateDomainManifest], ["central.process.json", core.validateProcessManifest]]) {
|
|
67
|
+
const full = path.join(root, "backend", file);
|
|
68
|
+
if (!fs.existsSync(full)) continue;
|
|
69
|
+
try { validate(JSON.parse(fs.readFileSync(full, "utf8"))); }
|
|
70
|
+
catch (error) { add("DOMAIN_MANIFEST_INVALID", `backend/${file}`, error.message); }
|
|
71
|
+
}
|
|
72
|
+
} catch { /* missing Core is already reported */ }
|
|
73
|
+
const files = listFiles(root);
|
|
74
|
+
for (const file of files.filter(f => /^backend\/src\/(models|validations|triggers|routes|pipelines|documents|hooks)\//.test(f))) {
|
|
75
|
+
if (backendType === "module" && /\.js$/.test(file)) add("MODULE_NOT_DISCOVERED", file, "Backend type=module exige arquivos .cjs no autoload CommonJS.");
|
|
76
|
+
if (/\.(ts|mjs)$/.test(file)) add("MODULE_NOT_DISCOVERED", file, "O autoload aceita .js/.cjs. Compile para a estrutura backend/src antes de executar/publicar.");
|
|
77
|
+
if (/\.(js|cjs)$/.test(file)) {
|
|
78
|
+
const source = fs.readFileSync(path.join(root, file), "utf8");
|
|
79
|
+
for (const match of source.matchAll(/require\(\s*["'](\.[^"']*)["']\s*\)/g)) {
|
|
80
|
+
const target = path.resolve(root, path.dirname(file), match[1]);
|
|
81
|
+
const relative = path.relative(path.join(root, "backend"), target);
|
|
82
|
+
if (relative.startsWith("../") && target !== path.join(root, "central.app.json")) add("RUNTIME_ASSET_OUTSIDE_BACKEND", file, "Import relativo depende de arquivo fora de backend/, ausente na imagem. Consulte APP_PACKAGING.md.");
|
|
83
|
+
}
|
|
84
|
+
try { new vm.Script(`(function(require,module,exports,__filename,__dirname){\n${fs.readFileSync(path.join(root, file), "utf8")}\n})`, { filename: file }); }
|
|
85
|
+
catch { add("MODULE_SYNTAX_INVALID", file, "Módulo de autoload precisa ser CommonJS válido (.js/.cjs)."); }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { ok: issues.length === 0, root, versions, issues, routes: { backendCwd: path.join(root, "backend"), publicPrefix: "/api", internalExample: "/integracao/testar", publicExample: "/api/integracao/testar", note: "O proxy existente remove /api. Não acrescente /api ao defineRoutes. Doctor é estático: prove a rota por HTTP autenticado." } };
|
|
89
|
+
}
|
|
90
|
+
module.exports = { doctor };
|