@hed-hog/core 0.0.400 → 0.0.401

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.
Files changed (203) hide show
  1. package/dist/ai/ai.controller.d.ts +2 -0
  2. package/dist/ai/ai.controller.d.ts.map +1 -1
  3. package/dist/ai/ai.service.d.ts +86 -2
  4. package/dist/ai/ai.service.d.ts.map +1 -1
  5. package/dist/ai/ai.service.js +304 -23
  6. package/dist/ai/ai.service.js.map +1 -1
  7. package/dist/ai-instruction/ai-prompt-resolver.service.d.ts +12 -0
  8. package/dist/ai-instruction/ai-prompt-resolver.service.d.ts.map +1 -1
  9. package/dist/ai-instruction/ai-prompt-resolver.service.js +26 -0
  10. package/dist/ai-instruction/ai-prompt-resolver.service.js.map +1 -1
  11. package/dist/core.module.d.ts.map +1 -1
  12. package/dist/core.module.js +3 -0
  13. package/dist/core.module.js.map +1 -1
  14. package/dist/document-kit/document-kit.module.d.ts +3 -0
  15. package/dist/document-kit/document-kit.module.d.ts.map +1 -0
  16. package/dist/document-kit/document-kit.module.js +24 -0
  17. package/dist/document-kit/document-kit.module.js.map +1 -0
  18. package/dist/document-kit/document-kit.registry.d.ts +55 -0
  19. package/dist/document-kit/document-kit.registry.d.ts.map +1 -0
  20. package/dist/document-kit/document-kit.registry.js +91 -0
  21. package/dist/document-kit/document-kit.registry.js.map +1 -0
  22. package/dist/document-kit/document-kit.service.d.ts +49 -0
  23. package/dist/document-kit/document-kit.service.d.ts.map +1 -0
  24. package/dist/document-kit/document-kit.service.js +135 -0
  25. package/dist/document-kit/document-kit.service.js.map +1 -0
  26. package/dist/file/file.service.d.ts +15 -1
  27. package/dist/file/file.service.d.ts.map +1 -1
  28. package/dist/file/file.service.js +52 -24
  29. package/dist/file/file.service.js.map +1 -1
  30. package/dist/file/mcp-file-source.schema.d.ts.map +1 -1
  31. package/dist/file/mcp-file-source.schema.js +8 -5
  32. package/dist/file/mcp-file-source.schema.js.map +1 -1
  33. package/dist/index.d.ts +5 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +5 -0
  36. package/dist/index.js.map +1 -1
  37. package/dist/integration-profile/integration-profile.secrets.d.ts.map +1 -1
  38. package/dist/integration-profile/integration-profile.secrets.js +21 -0
  39. package/dist/integration-profile/integration-profile.secrets.js.map +1 -1
  40. package/dist/integration-profile/testers/ai.testers.d.ts.map +1 -1
  41. package/dist/integration-profile/testers/ai.testers.js +46 -8
  42. package/dist/integration-profile/testers/ai.testers.js.map +1 -1
  43. package/dist/mcp/mcp.module.d.ts.map +1 -1
  44. package/dist/mcp/mcp.module.js +2 -0
  45. package/dist/mcp/mcp.module.js.map +1 -1
  46. package/dist/mcp/tools/core-access-log.mcp-tools.d.ts +17 -0
  47. package/dist/mcp/tools/core-access-log.mcp-tools.d.ts.map +1 -0
  48. package/dist/mcp/tools/core-access-log.mcp-tools.js +74 -0
  49. package/dist/mcp/tools/core-access-log.mcp-tools.js.map +1 -0
  50. package/dist/mcp/tools/core-files.mcp-tools.js +4 -4
  51. package/dist/mcp/tools/core-files.mcp-tools.js.map +1 -1
  52. package/dist/meeting/meeting-provider.registry.d.ts +72 -0
  53. package/dist/meeting/meeting-provider.registry.d.ts.map +1 -1
  54. package/dist/meeting/meeting-provider.registry.js.map +1 -1
  55. package/dist/oauth/oauth-token-broker.service.d.ts +50 -1
  56. package/dist/oauth/oauth-token-broker.service.d.ts.map +1 -1
  57. package/dist/oauth/oauth-token-broker.service.js +105 -3
  58. package/dist/oauth/oauth-token-broker.service.js.map +1 -1
  59. package/dist/oauth/providers/microsoft-entra-id.provider.d.ts +6 -0
  60. package/dist/oauth/providers/microsoft-entra-id.provider.d.ts.map +1 -1
  61. package/dist/oauth/providers/microsoft-entra-id.provider.js +30 -4
  62. package/dist/oauth/providers/microsoft-entra-id.provider.js.map +1 -1
  63. package/dist/pdf/document/charts/chart-bar.d.ts +24 -0
  64. package/dist/pdf/document/charts/chart-bar.d.ts.map +1 -0
  65. package/dist/pdf/document/charts/chart-bar.js +117 -0
  66. package/dist/pdf/document/charts/chart-bar.js.map +1 -0
  67. package/dist/pdf/document/charts/chart-donut.d.ts +16 -0
  68. package/dist/pdf/document/charts/chart-donut.d.ts.map +1 -0
  69. package/dist/pdf/document/charts/chart-donut.js +89 -0
  70. package/dist/pdf/document/charts/chart-donut.js.map +1 -0
  71. package/dist/pdf/document/charts/chart-gauge.d.ts +15 -0
  72. package/dist/pdf/document/charts/chart-gauge.d.ts.map +1 -0
  73. package/dist/pdf/document/charts/chart-gauge.js +60 -0
  74. package/dist/pdf/document/charts/chart-gauge.js.map +1 -0
  75. package/dist/pdf/document/charts/chart-line.d.ts +29 -0
  76. package/dist/pdf/document/charts/chart-line.d.ts.map +1 -0
  77. package/dist/pdf/document/charts/chart-line.js +89 -0
  78. package/dist/pdf/document/charts/chart-line.js.map +1 -0
  79. package/dist/pdf/document/charts/chart-primitives.d.ts +64 -0
  80. package/dist/pdf/document/charts/chart-primitives.d.ts.map +1 -0
  81. package/dist/pdf/document/charts/chart-primitives.js +110 -0
  82. package/dist/pdf/document/charts/chart-primitives.js.map +1 -0
  83. package/dist/pdf/document/charts/chart-radar.d.ts +13 -0
  84. package/dist/pdf/document/charts/chart-radar.d.ts.map +1 -0
  85. package/dist/pdf/document/charts/chart-radar.js +83 -0
  86. package/dist/pdf/document/charts/chart-radar.js.map +1 -0
  87. package/dist/pdf/document/charts/chart-scale.d.ts +39 -0
  88. package/dist/pdf/document/charts/chart-scale.d.ts.map +1 -0
  89. package/dist/pdf/document/charts/chart-scale.js +118 -0
  90. package/dist/pdf/document/charts/chart-scale.js.map +1 -0
  91. package/dist/pdf/document/charts/index.d.ts +8 -0
  92. package/dist/pdf/document/charts/index.d.ts.map +1 -0
  93. package/dist/pdf/document/charts/index.js +24 -0
  94. package/dist/pdf/document/charts/index.js.map +1 -0
  95. package/dist/pdf/document/index.d.ts +8 -0
  96. package/dist/pdf/document/index.d.ts.map +1 -0
  97. package/dist/pdf/document/index.js +24 -0
  98. package/dist/pdf/document/index.js.map +1 -0
  99. package/dist/pdf/document/pdf-document.chrome.d.ts +20 -0
  100. package/dist/pdf/document/pdf-document.chrome.d.ts.map +1 -0
  101. package/dist/pdf/document/pdf-document.chrome.js +36 -0
  102. package/dist/pdf/document/pdf-document.chrome.js.map +1 -0
  103. package/dist/pdf/document/pdf-document.css.d.ts +20 -0
  104. package/dist/pdf/document/pdf-document.css.d.ts.map +1 -0
  105. package/dist/pdf/document/pdf-document.css.js +239 -0
  106. package/dist/pdf/document/pdf-document.css.js.map +1 -0
  107. package/dist/pdf/document/pdf-document.renderer.d.ts +10 -0
  108. package/dist/pdf/document/pdf-document.renderer.d.ts.map +1 -0
  109. package/dist/pdf/document/pdf-document.renderer.js +153 -0
  110. package/dist/pdf/document/pdf-document.renderer.js.map +1 -0
  111. package/dist/pdf/document/pdf-document.service.d.ts +27 -0
  112. package/dist/pdf/document/pdf-document.service.d.ts.map +1 -0
  113. package/dist/pdf/document/pdf-document.service.js +81 -0
  114. package/dist/pdf/document/pdf-document.service.js.map +1 -0
  115. package/dist/pdf/document/pdf-document.theme.d.ts +41 -0
  116. package/dist/pdf/document/pdf-document.theme.d.ts.map +1 -0
  117. package/dist/pdf/document/pdf-document.theme.js +105 -0
  118. package/dist/pdf/document/pdf-document.theme.js.map +1 -0
  119. package/dist/pdf/document/pdf-document.types.d.ts +108 -0
  120. package/dist/pdf/document/pdf-document.types.d.ts.map +1 -0
  121. package/dist/pdf/document/pdf-document.types.js +18 -0
  122. package/dist/pdf/document/pdf-document.types.js.map +1 -0
  123. package/dist/pdf/pdf.module.d.ts.map +1 -1
  124. package/dist/pdf/pdf.module.js +3 -2
  125. package/dist/pdf/pdf.module.js.map +1 -1
  126. package/dist/pdf/pdf.service.d.ts +69 -1
  127. package/dist/pdf/pdf.service.d.ts.map +1 -1
  128. package/dist/pdf/pdf.service.js +164 -63
  129. package/dist/pdf/pdf.service.js.map +1 -1
  130. package/dist/pdf/templates/commercial-proposal.template.d.ts +96 -0
  131. package/dist/pdf/templates/commercial-proposal.template.d.ts.map +1 -0
  132. package/dist/pdf/templates/commercial-proposal.template.js +276 -0
  133. package/dist/pdf/templates/commercial-proposal.template.js.map +1 -0
  134. package/dist/setting/favicon.controller.d.ts +26 -0
  135. package/dist/setting/favicon.controller.d.ts.map +1 -0
  136. package/dist/setting/favicon.controller.js +130 -0
  137. package/dist/setting/favicon.controller.js.map +1 -0
  138. package/dist/setting/setting.module.d.ts.map +1 -1
  139. package/dist/setting/setting.module.js +2 -1
  140. package/dist/setting/setting.module.js.map +1 -1
  141. package/dist/whatsapp-sender/whatsapp-sender.service.d.ts.map +1 -1
  142. package/dist/whatsapp-sender/whatsapp-sender.service.js +25 -2
  143. package/dist/whatsapp-sender/whatsapp-sender.service.js.map +1 -1
  144. package/hedhog/data/integration_type.yaml +6 -0
  145. package/hedhog/data/route.yaml +8 -0
  146. package/hedhog/data/setting_group.yaml +52 -0
  147. package/hedhog/frontend/messages/en.json +54 -1
  148. package/hedhog/frontend/messages/pt.json +54 -1
  149. package/package.json +4 -4
  150. package/src/ai/ai-attachment.fragments.spec.ts +56 -3
  151. package/src/ai/ai.service.responses.spec.ts +429 -0
  152. package/src/ai/ai.service.ts +409 -27
  153. package/src/ai-instruction/ai-prompt-resolver.service.spec.ts +44 -0
  154. package/src/ai-instruction/ai-prompt-resolver.service.ts +29 -0
  155. package/src/core.module.ts +3 -0
  156. package/src/document-kit/document-kit.module.ts +12 -0
  157. package/src/document-kit/document-kit.registry.ts +125 -0
  158. package/src/document-kit/document-kit.service.spec.ts +156 -0
  159. package/src/document-kit/document-kit.service.ts +173 -0
  160. package/src/file/file.service.spec.ts +168 -1
  161. package/src/file/file.service.ts +72 -34
  162. package/src/file/mcp-file-source.schema.ts +8 -5
  163. package/src/index.ts +5 -0
  164. package/src/integration-profile/integration-profile.secrets.ts +21 -0
  165. package/src/integration-profile/testers/ai.testers.ts +70 -5
  166. package/src/integration-profile/testers/remaining.testers.spec.ts +65 -1
  167. package/src/integration-profile/testers/tester-coverage.spec.ts +10 -0
  168. package/src/mcp/mcp.module.ts +2 -0
  169. package/src/mcp/tools/core-access-log.mcp-tools.ts +66 -0
  170. package/src/mcp/tools/core-files.mcp-tools.ts +4 -4
  171. package/src/meeting/meeting-provider.registry.ts +80 -0
  172. package/src/oauth/oauth-token-broker.service.spec.ts +108 -0
  173. package/src/oauth/oauth-token-broker.service.ts +129 -2
  174. package/src/oauth/providers/microsoft-entra-id.provider.spec.ts +133 -0
  175. package/src/oauth/providers/microsoft-entra-id.provider.ts +29 -2
  176. package/src/pdf/document/charts/chart-bar.ts +217 -0
  177. package/src/pdf/document/charts/chart-donut.ts +163 -0
  178. package/src/pdf/document/charts/chart-gauge.ts +94 -0
  179. package/src/pdf/document/charts/chart-line.ts +159 -0
  180. package/src/pdf/document/charts/chart-primitives.ts +188 -0
  181. package/src/pdf/document/charts/chart-radar.ts +136 -0
  182. package/src/pdf/document/charts/chart-scale.spec.ts +93 -0
  183. package/src/pdf/document/charts/chart-scale.ts +141 -0
  184. package/src/pdf/document/charts/charts.spec.ts +157 -0
  185. package/src/pdf/document/charts/index.ts +7 -0
  186. package/src/pdf/document/index.ts +7 -0
  187. package/src/pdf/document/pdf-document.chrome.ts +38 -0
  188. package/src/pdf/document/pdf-document.css.ts +238 -0
  189. package/src/pdf/document/pdf-document.renderer.spec.ts +129 -0
  190. package/src/pdf/document/pdf-document.renderer.ts +215 -0
  191. package/src/pdf/document/pdf-document.service.ts +77 -0
  192. package/src/pdf/document/pdf-document.theme.ts +146 -0
  193. package/src/pdf/document/pdf-document.types.ts +97 -0
  194. package/src/pdf/pdf.module.ts +3 -2
  195. package/src/pdf/pdf.service.spec.ts +131 -0
  196. package/src/pdf/pdf.service.ts +245 -82
  197. package/src/pdf/templates/commercial-proposal.template.spec.ts +177 -0
  198. package/src/pdf/templates/commercial-proposal.template.ts +400 -0
  199. package/src/setting/favicon.controller.spec.ts +92 -0
  200. package/src/setting/favicon.controller.ts +135 -0
  201. package/src/setting/setting.module.ts +2 -1
  202. package/src/whatsapp-sender/whatsapp-sender.service.spec.ts +73 -0
  203. package/src/whatsapp-sender/whatsapp-sender.service.ts +32 -2
@@ -0,0 +1,400 @@
1
+ import { PdfThemeConfig } from '../interfaces/pdf-theme-config.interface';
2
+ import type { PdfGenerateOptions } from '../pdf.service';
3
+ import { buildFontImport, HCODE_NEUTRALS } from '../themes/hcode.theme';
4
+
5
+ // ─────────────────────────────────────────────────────────────────────────────
6
+ // Data contract
7
+ // ─────────────────────────────────────────────────────────────────────────────
8
+
9
+ /**
10
+ * Branding for commercial documents: the area theme plus the lines that used to
11
+ * be hardcoded per company (legal footer, partner line) and the font stacks.
12
+ * Everything comes from the `pdf` setting group, so another installation
13
+ * changes the look without touching code.
14
+ */
15
+ export interface CommercialDocumentTheme extends PdfThemeConfig {
16
+ /** Legal line repeated in the footer of every page (company, tax id, address). */
17
+ legalFooter: string;
18
+ /** Small line under the cover metadata (partners, certifications). */
19
+ partnerLine: string;
20
+ fontFamily: string;
21
+ fontMono: string;
22
+ }
23
+
24
+ /**
25
+ * Blocks a section is made of. Strings are escaped by the template; only
26
+ * `html` goes through as is, so the caller must hand it already sanitized.
27
+ */
28
+ export type CommercialProposalBlock =
29
+ | { type: 'html'; html: string }
30
+ | { type: 'paragraphs'; text: string }
31
+ | { type: 'list'; items: string[] }
32
+ | { type: 'callout'; title?: string; text: string; tone?: 'accent' | 'neutral' }
33
+ | {
34
+ type: 'table';
35
+ headers: string[];
36
+ rows: string[][];
37
+ /** Per column; defaults to left. */
38
+ align?: Array<'left' | 'right'>;
39
+ }
40
+ | {
41
+ type: 'totals';
42
+ rows: Array<{ label: string; value: string; emphasis?: boolean }>;
43
+ };
44
+
45
+ export interface CommercialProposalSection {
46
+ /** Short uppercase label next to the number: `03 · INVESTIMENTO`. */
47
+ eyebrow: string;
48
+ /** Written as a short sentence, as the proposal standard asks. */
49
+ title: string;
50
+ blocks: CommercialProposalBlock[];
51
+ }
52
+
53
+ export interface CommercialProposalSignatory {
54
+ party: string;
55
+ name?: string;
56
+ role?: string;
57
+ }
58
+
59
+ export interface CommercialProposalData {
60
+ locale?: string;
61
+ /** Mono line above the title: `PROPOSTA COMERCIAL · BRA20260865`. */
62
+ eyebrow: string;
63
+ title: string;
64
+ /** Second title line, printed in the accent color. */
65
+ titleAccent?: string;
66
+ subtitle?: string;
67
+ meta: Array<{ label: string; value: string }>;
68
+ sections: CommercialProposalSection[];
69
+ signatureTitle?: string;
70
+ signatories?: CommercialProposalSignatory[];
71
+ /** Left side of the footer when no legal footer is configured. */
72
+ documentLabel?: string;
73
+ /** Word used between the page number and the total (`3 / 9`). */
74
+ pageSeparator?: string;
75
+ }
76
+
77
+ export interface CommercialProposalRender {
78
+ html: string;
79
+ pdfOptions: PdfGenerateOptions;
80
+ }
81
+
82
+ // ─────────────────────────────────────────────────────────────────────────────
83
+ // Helpers
84
+ // ─────────────────────────────────────────────────────────────────────────────
85
+
86
+ export function escapePdfHtml(value: unknown): string {
87
+ return String(value ?? '')
88
+ .replace(/&/g, '&amp;')
89
+ .replace(/</g, '&lt;')
90
+ .replace(/>/g, '&gt;')
91
+ .replace(/"/g, '&quot;')
92
+ .replace(/'/g, '&#39;');
93
+ }
94
+
95
+ /** Only a plain hex color reaches the CSS; anything else falls back. */
96
+ function safeColor(value: string, fallback: string): string {
97
+ return /^#[0-9a-f]{3,8}$/i.test(String(value ?? '').trim()) ? value.trim() : fallback;
98
+ }
99
+
100
+ /** Font stacks end up inside a `<style>`: drop what could close it. */
101
+ function safeFontStack(value: string, fallback: string): string {
102
+ const cleaned = String(value ?? '').replace(/[<>{};]/g, '').trim();
103
+ return cleaned || fallback;
104
+ }
105
+
106
+ function sectionNumber(index: number): string {
107
+ return String(index + 1).padStart(2, '0');
108
+ }
109
+
110
+ function renderParagraphs(text: string): string {
111
+ return String(text ?? '')
112
+ .split(/\n{2,}/)
113
+ .map((paragraph) => paragraph.trim())
114
+ .filter(Boolean)
115
+ .map((paragraph) => `<p>${escapePdfHtml(paragraph).replace(/\n/g, '<br>')}</p>`)
116
+ .join('');
117
+ }
118
+
119
+ function renderBlock(block: CommercialProposalBlock): string {
120
+ switch (block.type) {
121
+ case 'html':
122
+ return `<div class="rich">${block.html}</div>`;
123
+ case 'paragraphs':
124
+ return `<div class="rich">${renderParagraphs(block.text)}</div>`;
125
+ case 'list':
126
+ return `<ul class="dash-list">${block.items
127
+ .map((item) => `<li>${escapePdfHtml(item)}</li>`)
128
+ .join('')}</ul>`;
129
+ case 'callout':
130
+ return `<div class="callout ${block.tone === 'neutral' ? 'callout-neutral' : 'callout-accent'} no-break">
131
+ ${block.title ? `<div class="label">${escapePdfHtml(block.title)}</div>` : ''}
132
+ ${renderParagraphs(block.text)}
133
+ </div>`;
134
+ case 'table': {
135
+ const align = (index: number) => (block.align?.[index] === 'right' ? ' class="num"' : '');
136
+ return `<table class="grid-table">
137
+ <thead><tr>${block.headers
138
+ .map((header, index) => `<th${align(index)}>${escapePdfHtml(header)}</th>`)
139
+ .join('')}</tr></thead>
140
+ <tbody>${block.rows
141
+ .map(
142
+ (row) =>
143
+ `<tr>${row.map((cell, index) => `<td${align(index)}>${escapePdfHtml(cell)}</td>`).join('')}</tr>`,
144
+ )
145
+ .join('')}</tbody>
146
+ </table>`;
147
+ }
148
+ case 'totals':
149
+ return `<div class="totals no-break">${block.rows
150
+ .map(
151
+ (row) =>
152
+ `<div class="totals-row${row.emphasis ? ' totals-strong' : ''}"><span>${escapePdfHtml(
153
+ row.label,
154
+ )}</span><span>${escapePdfHtml(row.value)}</span></div>`,
155
+ )
156
+ .join('')}</div>`;
157
+ default:
158
+ return '';
159
+ }
160
+ }
161
+
162
+ /** Uploaded logo when there is one, otherwise the company name as a wordmark. */
163
+ function renderBrand(theme: CommercialDocumentTheme): string {
164
+ if (theme.logoOverride) {
165
+ return `<img class="brand-logo" src="${escapePdfHtml(theme.logoOverride)}" alt="${escapePdfHtml(
166
+ theme.companyName,
167
+ )}">`;
168
+ }
169
+ const name = String(theme.companyName || '').trim();
170
+ return `<div class="wordmark"><span class="accent">${escapePdfHtml(
171
+ name.slice(0, 1),
172
+ )}</span>${escapePdfHtml(name.slice(1))}<span class="wordmark-area"> | ${escapePdfHtml(
173
+ theme.area.toUpperCase(),
174
+ )}</span></div>`;
175
+ }
176
+
177
+ // ─────────────────────────────────────────────────────────────────────────────
178
+ // Template
179
+ // ─────────────────────────────────────────────────────────────────────────────
180
+
181
+ /**
182
+ * Commercial proposal — cover with the metadata table, numbered sections
183
+ * (`01 · ESCOPO`), borderless tables, rounded cards and a footer with the
184
+ * legal line and `n / N` on every page.
185
+ *
186
+ * Pure function: the caller resolves the theme (see
187
+ * `PdfService.loadCommercialDocumentTheme`) and the data, so the same input
188
+ * always renders the same document.
189
+ */
190
+ export function commercialProposalTemplate(
191
+ data: CommercialProposalData,
192
+ theme: CommercialDocumentTheme,
193
+ ): CommercialProposalRender {
194
+ const primary = safeColor(theme.primaryColor, '#FF760C');
195
+ const ink = HCODE_NEUTRALS.ink1;
196
+ const muted = HCODE_NEUTRALS.ink2;
197
+ const line = HCODE_NEUTRALS.line;
198
+ const surface = HCODE_NEUTRALS.surface2;
199
+ const fontFamily = safeFontStack(theme.fontFamily, "'Inter', sans-serif");
200
+ const fontMono = safeFontStack(theme.fontMono, "'JetBrains Mono', monospace");
201
+
202
+ const sections = data.sections
203
+ .filter((section) => section.blocks.length > 0)
204
+ .map(
205
+ (section, index) => `<section class="section">
206
+ <div class="section-eyebrow">${sectionNumber(index)} · ${escapePdfHtml(
207
+ section.eyebrow.toUpperCase(),
208
+ )}</div>
209
+ <h2>${escapePdfHtml(section.title)}</h2>
210
+ ${section.blocks.map(renderBlock).join('\n')}
211
+ </section>`,
212
+ )
213
+ .join('\n');
214
+
215
+ const signatories = data.signatories?.length
216
+ ? `<section class="section signatures no-break">
217
+ ${data.signatureTitle ? `<h2>${escapePdfHtml(data.signatureTitle)}</h2>` : ''}
218
+ <div class="signature-grid">${data.signatories
219
+ .map(
220
+ (signatory) => `<div class="signature">
221
+ <div class="signature-line"></div>
222
+ <div class="label">${escapePdfHtml(signatory.party)}</div>
223
+ ${signatory.name ? `<div class="signature-name">${escapePdfHtml(signatory.name)}</div>` : ''}
224
+ ${signatory.role ? `<div class="signature-role">${escapePdfHtml(signatory.role)}</div>` : ''}
225
+ </div>`,
226
+ )
227
+ .join('')}</div>
228
+ </section>`
229
+ : '';
230
+
231
+ const cover = `<div class="cover">
232
+ <div class="cover-brand">${renderBrand(theme)}</div>
233
+ <div class="cover-eyebrow">${escapePdfHtml(data.eyebrow.toUpperCase())}</div>
234
+ <h1 class="cover-title">${escapePdfHtml(data.title)}${
235
+ data.titleAccent ? `<br><span class="accent">${escapePdfHtml(data.titleAccent)}</span>` : ''
236
+ }</h1>
237
+ ${data.subtitle ? `<div class="cover-subtitle">${escapePdfHtml(data.subtitle)}</div>` : ''}
238
+ <table class="meta-table">
239
+ <tbody>${data.meta
240
+ .filter((item) => String(item.value ?? '').trim())
241
+ .map(
242
+ (item) =>
243
+ `<tr><th>${escapePdfHtml(item.label.toUpperCase())}</th><td>${escapePdfHtml(item.value)}</td></tr>`,
244
+ )
245
+ .join('')}</tbody>
246
+ </table>
247
+ ${theme.partnerLine ? `<div class="partner-line">${escapePdfHtml(theme.partnerLine.toUpperCase())}</div>` : ''}
248
+ </div>`;
249
+
250
+ const html = `<!DOCTYPE html>
251
+ <html lang="${escapePdfHtml(data.locale || 'pt-BR')}">
252
+ <head>
253
+ <meta charset="UTF-8">
254
+ ${buildFontImport()}
255
+ <style>
256
+ :root {
257
+ --primary: ${primary};
258
+ --primary-soft: color-mix(in srgb, ${primary} 12%, #ffffff);
259
+ --ink: ${ink};
260
+ --muted: ${muted};
261
+ --line: ${line};
262
+ --surface: ${surface};
263
+ --font: ${fontFamily};
264
+ --mono: ${fontMono};
265
+ }
266
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
267
+ html, body {
268
+ font-family: var(--font);
269
+ font-size: 10.5pt;
270
+ line-height: 1.55;
271
+ color: var(--ink);
272
+ background: #ffffff;
273
+ -webkit-print-color-adjust: exact;
274
+ print-color-adjust: exact;
275
+ }
276
+ .accent { color: var(--primary); }
277
+ .no-break { break-inside: avoid; page-break-inside: avoid; }
278
+ .label {
279
+ font-family: var(--mono);
280
+ font-size: 7.5pt;
281
+ letter-spacing: 0.14em;
282
+ text-transform: uppercase;
283
+ color: var(--muted);
284
+ margin-bottom: 6px;
285
+ }
286
+
287
+ /* Cover */
288
+ .cover { min-height: 245mm; display: flex; flex-direction: column; break-after: page; }
289
+ .cover-brand { margin-bottom: 48mm; }
290
+ .brand-logo { max-height: 40px; max-width: 180px; object-fit: contain; }
291
+ .wordmark { font-size: 26pt; font-weight: 300; letter-spacing: -0.01em; line-height: 1; }
292
+ .wordmark-area { font-family: var(--mono); font-size: 9pt; letter-spacing: 0.2em; color: #949494; }
293
+ .cover-eyebrow {
294
+ font-family: var(--mono);
295
+ font-size: 8pt;
296
+ letter-spacing: 0.16em;
297
+ color: var(--muted);
298
+ margin-bottom: 14px;
299
+ }
300
+ .cover-title { font-size: 30pt; font-weight: 700; line-height: 1.12; letter-spacing: -0.02em; margin-bottom: 14px; }
301
+ .cover-subtitle { font-size: 11.5pt; color: var(--muted); margin-bottom: 28px; }
302
+ .meta-table { width: 100%; border-collapse: collapse; margin-top: auto; }
303
+ .meta-table th, .meta-table td { padding: 7px 0; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
304
+ .meta-table th {
305
+ width: 34%;
306
+ font-family: var(--mono);
307
+ font-size: 7.5pt;
308
+ font-weight: 400;
309
+ letter-spacing: 0.14em;
310
+ color: var(--muted);
311
+ }
312
+ .meta-table td { font-size: 10pt; }
313
+ .partner-line { margin-top: 18px; font-family: var(--mono); font-size: 7.5pt; letter-spacing: 0.16em; color: #949494; }
314
+
315
+ /* Sections */
316
+ .section { margin-bottom: 26px; }
317
+ .section-eyebrow { font-family: var(--mono); font-size: 8pt; letter-spacing: 0.16em; color: var(--primary); margin-bottom: 6px; }
318
+ h2 { font-size: 16pt; font-weight: 700; line-height: 1.25; letter-spacing: -0.01em; margin-bottom: 12px; break-after: avoid; }
319
+ .rich p { margin-bottom: 8px; }
320
+ .rich ul, .rich ol { margin: 0 0 8px 18px; }
321
+ .rich h3 { font-size: 11.5pt; margin: 12px 0 6px; }
322
+ .dash-list, .rich ul { list-style: none; margin-left: 0; }
323
+ .dash-list li, .rich ul li { position: relative; padding-left: 18px; margin-bottom: 5px; }
324
+ .dash-list li::before, .rich ul li::before {
325
+ content: '';
326
+ position: absolute;
327
+ left: 0;
328
+ top: 0.75em;
329
+ width: 9px;
330
+ height: 2px;
331
+ background: var(--primary);
332
+ }
333
+
334
+ /* Cards */
335
+ .callout { border-radius: 10px; padding: 14px 16px; margin: 10px 0; }
336
+ .callout p { margin-bottom: 6px; }
337
+ .callout p:last-child { margin-bottom: 0; }
338
+ .callout-accent { background: var(--primary-soft); }
339
+ .callout-accent .label { color: var(--primary); }
340
+ .callout-neutral { background: var(--surface); }
341
+
342
+ /* Tables: horizontal rules only, no grid */
343
+ .grid-table { width: 100%; border-collapse: collapse; margin: 6px 0 10px; font-size: 9.5pt; }
344
+ .grid-table th {
345
+ font-family: var(--mono);
346
+ font-size: 7.5pt;
347
+ font-weight: 400;
348
+ letter-spacing: 0.12em;
349
+ text-transform: uppercase;
350
+ color: var(--muted);
351
+ text-align: left;
352
+ padding: 0 7px 7px;
353
+ border-bottom: 1px solid var(--ink);
354
+ }
355
+ .grid-table td { padding: 8px 7px; border-bottom: 1px solid var(--line); vertical-align: top; }
356
+ .grid-table th:first-child, .grid-table td:first-child { padding-left: 0; }
357
+ .grid-table th:last-child, .grid-table td:last-child { padding-right: 0; }
358
+ .grid-table tr { break-inside: avoid; }
359
+ .grid-table .num { text-align: right; white-space: nowrap; }
360
+
361
+ .totals { margin: 6px 0 10px auto; width: 60%; }
362
+ .totals-row { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid var(--line); font-size: 10pt; }
363
+ .totals-strong { border-bottom: none; border-top: 2px solid var(--ink); font-weight: 700; font-size: 12pt; color: var(--primary); }
364
+
365
+ /* Signatures */
366
+ .signature-grid { display: flex; gap: 32px; margin-top: 36px; }
367
+ .signature { flex: 1; }
368
+ .signature-line { border-top: 1px solid var(--ink); margin-bottom: 8px; }
369
+ .signature-name { font-weight: 600; }
370
+ .signature-role { color: var(--muted); font-size: 9.5pt; }
371
+ </style>
372
+ </head>
373
+ <body>
374
+ ${cover}
375
+ ${sections}
376
+ ${signatories}
377
+ </body>
378
+ </html>`;
379
+
380
+ const footerLeft = theme.legalFooter || data.documentLabel || theme.companyName;
381
+ const separator = data.pageSeparator ?? '/';
382
+
383
+ const pdfOptions: PdfGenerateOptions = {
384
+ format: 'A4',
385
+ printBackground: true,
386
+ displayHeaderFooter: true,
387
+ headerTemplate: `<div style="position:absolute;top:0;left:0;right:0;height:6px;background:${primary};-webkit-print-color-adjust:exact;print-color-adjust:exact;"></div>`,
388
+ footerTemplate: `<div style="width:100%;margin:0 25mm;display:flex;justify-content:space-between;gap:16px;font-family:${escapePdfHtml(
389
+ fontMono,
390
+ )};font-size:6.5pt;letter-spacing:0.12em;color:#949494;-webkit-print-color-adjust:exact;"><span>${escapePdfHtml(
391
+ footerLeft.toUpperCase(),
392
+ )}</span><span style="white-space:nowrap;"><span class="pageNumber"></span> ${escapePdfHtml(
393
+ separator,
394
+ )} <span class="totalPages"></span></span></div>`,
395
+ margin: { top: '22mm', right: '25mm', bottom: '22mm', left: '25mm' },
396
+ label: 'commercial-proposal',
397
+ };
398
+
399
+ return { html, pdfOptions };
400
+ }
@@ -0,0 +1,92 @@
1
+ import { NotFoundException } from '@nestjs/common';
2
+ import { FaviconController } from './favicon.controller';
3
+
4
+ const makeRes = () => {
5
+ const res: any = { headers: {} as Record<string, string> };
6
+ res.setHeader = jest.fn((k: string, v: string) => (res.headers[k] = v));
7
+ res.send = jest.fn((body: Buffer) => (res.body = body));
8
+ return res;
9
+ };
10
+
11
+ const makeController = (settings: Record<string, string>) => {
12
+ const setting = { getSettingValues: jest.fn().mockResolvedValue(settings) };
13
+ const brandAsset = {
14
+ resolveAssetUrl: jest.fn(async (value: string) =>
15
+ value.startsWith('/') ? `https://app.example.com${value}` : value,
16
+ ),
17
+ };
18
+ return new FaviconController(setting as any, brandAsset as any);
19
+ };
20
+
21
+ const svg = '<svg xmlns="http://www.w3.org/2000/svg"/>';
22
+
23
+ describe('FaviconController', () => {
24
+ const originalFetch = globalThis.fetch;
25
+ let fetchMock: jest.Mock;
26
+
27
+ beforeEach(() => {
28
+ fetchMock = jest.fn().mockResolvedValue(
29
+ new Response(svg, { headers: { 'content-type': 'image/svg+xml' } }),
30
+ );
31
+ globalThis.fetch = fetchMock as any;
32
+ });
33
+
34
+ afterEach(() => {
35
+ globalThis.fetch = originalFetch;
36
+ });
37
+
38
+ it('serves the Branding icon with the origin content type', async () => {
39
+ const controller = makeController({ 'icon-url': '/logo.svg' });
40
+ const res = makeRes();
41
+
42
+ await controller.favicon(res);
43
+
44
+ expect(fetchMock.mock.calls[0][0]).toBe('https://app.example.com/logo.svg');
45
+ expect(res.headers['Content-Type']).toBe('image/svg+xml');
46
+ expect(res.body.toString()).toBe(svg);
47
+ });
48
+
49
+ it('falls back to image-url and caches the download', async () => {
50
+ const controller = makeController({ 'icon-url': '', 'image-url': '/brand.svg' });
51
+
52
+ await controller.favicon(makeRes());
53
+ await controller.favicon(makeRes());
54
+
55
+ expect(fetchMock).toHaveBeenCalledTimes(1);
56
+ expect(fetchMock.mock.calls[0][0]).toBe('https://app.example.com/brand.svg');
57
+ });
58
+
59
+ it('decodes a data URI without fetching', async () => {
60
+ const png = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
61
+ const controller = makeController({
62
+ 'icon-url': `data:image/png;base64,${png.toString('base64')}`,
63
+ });
64
+ const res = makeRes();
65
+
66
+ await controller.favicon(res);
67
+
68
+ expect(fetchMock).not.toHaveBeenCalled();
69
+ expect(res.headers['Content-Type']).toBe('image/png');
70
+ expect(Buffer.compare(res.body, png)).toBe(0);
71
+ });
72
+
73
+ it('answers 404 when the source is not an image', async () => {
74
+ fetchMock.mockResolvedValue(
75
+ new Response('<html/>', { headers: { 'content-type': 'text/html' } }),
76
+ );
77
+ const controller = makeController({ 'icon-url': '/logo.svg' });
78
+
79
+ await expect(controller.favicon(makeRes())).rejects.toBeInstanceOf(
80
+ NotFoundException,
81
+ );
82
+ });
83
+
84
+ it('answers 404 when nothing is configured', async () => {
85
+ const controller = makeController({});
86
+
87
+ await expect(controller.favicon(makeRes())).rejects.toBeInstanceOf(
88
+ NotFoundException,
89
+ );
90
+ expect(fetchMock).not.toHaveBeenCalled();
91
+ });
92
+ });
@@ -0,0 +1,135 @@
1
+ import { Public } from '@hed-hog/api';
2
+ import { Controller, Get, Logger, NotFoundException, Res } from '@nestjs/common';
3
+ import { Response } from 'express';
4
+ import { BrandAssetService } from './brand-asset.service';
5
+ import { SettingService } from './setting.service';
6
+
7
+ /** Timeout do fetch da imagem configurada. */
8
+ const FETCH_TIMEOUT_MS = 5_000;
9
+
10
+ /** Quanto tempo o binario baixado fica em memoria antes de buscar de novo. */
11
+ const CACHE_TTL_MS = 10 * 60_000;
12
+
13
+ type CachedIcon = {
14
+ url: string;
15
+ body: Buffer;
16
+ contentType: string;
17
+ expiresAt: number;
18
+ };
19
+
20
+ /**
21
+ * `GET /favicon.ico` no host da API, com o icone do Branding (`icon-url`).
22
+ *
23
+ * Quem procura o favicon no dominio da API sao os clientes que so conhecem esse
24
+ * endereco - o conector MCP no claude.ai, por exemplo, mostra o icone do dominio
25
+ * do servidor ao lado do nome. Sem esta rota a resposta era o 404 JSON do Nest e
26
+ * o conector caia na letra inicial.
27
+ *
28
+ * O arquivo e servido daqui (e nao por redirect) porque nem todo coletor de
29
+ * favicon segue redirecionamento. O Content-Type e o da origem, entao um SVG
30
+ * configurado no Branding chega como SVG.
31
+ */
32
+ @Controller()
33
+ export class FaviconController {
34
+ private readonly logger = new Logger(FaviconController.name);
35
+ private cache: CachedIcon | null = null;
36
+
37
+ constructor(
38
+ private readonly setting: SettingService,
39
+ private readonly brandAsset: BrandAssetService,
40
+ ) {}
41
+
42
+ @Public()
43
+ @Get('favicon.ico')
44
+ async favicon(@Res() res: Response) {
45
+ const icon = await this.loadIcon();
46
+
47
+ if (!icon) {
48
+ throw new NotFoundException();
49
+ }
50
+
51
+ res.setHeader('Content-Type', icon.contentType);
52
+ res.setHeader('Cache-Control', 'public, max-age=3600');
53
+ res.send(icon.body);
54
+ }
55
+
56
+ private async loadIcon(): Promise<CachedIcon | null> {
57
+ const settings = await this.setting.getSettingValues([
58
+ 'icon-url',
59
+ 'image-url',
60
+ ]);
61
+ const value =
62
+ String(settings['icon-url'] ?? '').trim() ||
63
+ String(settings['image-url'] ?? '').trim();
64
+ const url = await this.brandAsset.resolveAssetUrl(value);
65
+
66
+ if (!url) {
67
+ return null;
68
+ }
69
+
70
+ if (
71
+ this.cache &&
72
+ this.cache.url === url &&
73
+ this.cache.expiresAt > Date.now()
74
+ ) {
75
+ return this.cache;
76
+ }
77
+
78
+ const icon = await this.download(url);
79
+
80
+ if (icon) {
81
+ this.cache = icon;
82
+ return icon;
83
+ }
84
+
85
+ // Origem fora do ar: melhor o icone anterior do que nenhum.
86
+ return this.cache?.url === url ? this.cache : null;
87
+ }
88
+
89
+ private async download(url: string): Promise<CachedIcon | null> {
90
+ const dataUri = /^data:([^;,]+)(;base64)?,([\s\S]*)$/i.exec(url);
91
+
92
+ if (dataUri) {
93
+ const [, contentType, base64, payload] = dataUri;
94
+ return {
95
+ url,
96
+ contentType,
97
+ body: base64
98
+ ? Buffer.from(payload, 'base64')
99
+ : Buffer.from(decodeURIComponent(payload)),
100
+ expiresAt: Date.now() + CACHE_TTL_MS,
101
+ };
102
+ }
103
+
104
+ try {
105
+ const controller = new AbortController();
106
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
107
+ const response = await globalThis
108
+ .fetch(url, { signal: controller.signal })
109
+ .finally(() => clearTimeout(timeout));
110
+
111
+ const contentType = response.headers.get('content-type') ?? '';
112
+
113
+ if (!response.ok || !contentType.startsWith('image/')) {
114
+ this.logger.warn(
115
+ `Favicon source ${url} answered ${response.status} ${contentType || '(no content-type)'}`,
116
+ );
117
+ return null;
118
+ }
119
+
120
+ return {
121
+ url,
122
+ contentType,
123
+ body: Buffer.from(await response.arrayBuffer()),
124
+ expiresAt: Date.now() + CACHE_TTL_MS,
125
+ };
126
+ } catch (error) {
127
+ this.logger.warn(
128
+ `Could not fetch favicon source ${url}: ${
129
+ error instanceof Error ? error.message : String(error)
130
+ }`,
131
+ );
132
+ return null;
133
+ }
134
+ }
135
+ }
@@ -5,6 +5,7 @@ import { Global, Module, forwardRef } from '@nestjs/common';
5
5
  import { AuthModule } from '../auth/auth.module';
6
6
  import { MailModule } from '../mail/mail.module';
7
7
  import { BrandAssetService } from './brand-asset.service';
8
+ import { FaviconController } from './favicon.controller';
8
9
  import { SettingsController } from './setting.controller';
9
10
  import { SettingService } from './setting.service';
10
11
 
@@ -17,7 +18,7 @@ import { SettingService } from './setting.service';
17
18
  forwardRef(() => LocaleModule),
18
19
  forwardRef(() => MailModule),
19
20
  ],
20
- controllers: [SettingsController],
21
+ controllers: [SettingsController, FaviconController],
21
22
  providers: [SettingService, BrandAssetService],
22
23
  exports: [SettingService, BrandAssetService],
23
24
  })