@hed-hog/core 0.0.400 → 0.0.402
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/dist/access-log/access-log.interceptor.d.ts +10 -0
- package/dist/access-log/access-log.interceptor.d.ts.map +1 -1
- package/dist/access-log/access-log.interceptor.js +15 -1
- package/dist/access-log/access-log.interceptor.js.map +1 -1
- package/dist/ai/ai.controller.d.ts +2 -0
- package/dist/ai/ai.controller.d.ts.map +1 -1
- package/dist/ai/ai.service.d.ts +86 -2
- package/dist/ai/ai.service.d.ts.map +1 -1
- package/dist/ai/ai.service.js +304 -23
- package/dist/ai/ai.service.js.map +1 -1
- package/dist/ai-instruction/ai-prompt-resolver.service.d.ts +12 -0
- package/dist/ai-instruction/ai-prompt-resolver.service.d.ts.map +1 -1
- package/dist/ai-instruction/ai-prompt-resolver.service.js +26 -0
- package/dist/ai-instruction/ai-prompt-resolver.service.js.map +1 -1
- package/dist/core.module.d.ts.map +1 -1
- package/dist/core.module.js +3 -0
- package/dist/core.module.js.map +1 -1
- package/dist/document-kit/document-kit.module.d.ts +3 -0
- package/dist/document-kit/document-kit.module.d.ts.map +1 -0
- package/dist/document-kit/document-kit.module.js +24 -0
- package/dist/document-kit/document-kit.module.js.map +1 -0
- package/dist/document-kit/document-kit.registry.d.ts +55 -0
- package/dist/document-kit/document-kit.registry.d.ts.map +1 -0
- package/dist/document-kit/document-kit.registry.js +91 -0
- package/dist/document-kit/document-kit.registry.js.map +1 -0
- package/dist/document-kit/document-kit.service.d.ts +49 -0
- package/dist/document-kit/document-kit.service.d.ts.map +1 -0
- package/dist/document-kit/document-kit.service.js +135 -0
- package/dist/document-kit/document-kit.service.js.map +1 -0
- package/dist/file/file-upload-ticket.service.d.ts +1 -1
- package/dist/file/file.controller.d.ts +3 -3
- package/dist/file/file.service.d.ts +136 -13
- package/dist/file/file.service.d.ts.map +1 -1
- package/dist/file/file.service.js +297 -27
- package/dist/file/file.service.js.map +1 -1
- package/dist/file/mcp-file-source.schema.d.ts.map +1 -1
- package/dist/file/mcp-file-source.schema.js +8 -5
- package/dist/file/mcp-file-source.schema.js.map +1 -1
- package/dist/file/provider/abstract.provider.d.ts +33 -0
- package/dist/file/provider/abstract.provider.d.ts.map +1 -1
- package/dist/file/provider/abstract.provider.js +9 -0
- package/dist/file/provider/abstract.provider.js.map +1 -1
- package/dist/file/provider/s3-multipart.util.d.ts +17 -0
- package/dist/file/provider/s3-multipart.util.d.ts.map +1 -0
- package/dist/file/provider/s3-multipart.util.js +88 -0
- package/dist/file/provider/s3-multipart.util.js.map +1 -0
- package/dist/file/provider/s3.provider.d.ts +9 -1
- package/dist/file/provider/s3.provider.d.ts.map +1 -1
- package/dist/file/provider/s3.provider.js +16 -0
- package/dist/file/provider/s3.provider.js.map +1 -1
- package/dist/file/provider/spaces.provider.d.ts +10 -1
- package/dist/file/provider/spaces.provider.d.ts.map +1 -1
- package/dist/file/provider/spaces.provider.js +17 -0
- package/dist/file/provider/spaces.provider.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/integration-profile/integration-profile.controller.d.ts +1 -0
- package/dist/integration-profile/integration-profile.controller.d.ts.map +1 -1
- package/dist/integration-profile/integration-profile.secrets.d.ts.map +1 -1
- package/dist/integration-profile/integration-profile.secrets.js +26 -0
- package/dist/integration-profile/integration-profile.secrets.js.map +1 -1
- package/dist/integration-profile/integration-profile.service.d.ts +1 -0
- package/dist/integration-profile/integration-profile.service.d.ts.map +1 -1
- package/dist/integration-profile/integration-profile.service.js +3 -3
- package/dist/integration-profile/integration-profile.service.js.map +1 -1
- package/dist/integration-profile/testers/ai.testers.d.ts.map +1 -1
- package/dist/integration-profile/testers/ai.testers.js +46 -8
- package/dist/integration-profile/testers/ai.testers.js.map +1 -1
- package/dist/integration-profile/testers/http-probe.d.ts.map +1 -1
- package/dist/integration-profile/testers/http-probe.js +5 -3
- package/dist/integration-profile/testers/http-probe.js.map +1 -1
- package/dist/integration-profile/testers/integration-tester.types.d.ts +5 -0
- package/dist/integration-profile/testers/integration-tester.types.d.ts.map +1 -1
- package/dist/mail/mail.service.d.ts.map +1 -1
- package/dist/mail/mail.service.js +16 -1
- package/dist/mail/mail.service.js.map +1 -1
- package/dist/mcp/mcp-server.service.d.ts.map +1 -1
- package/dist/mcp/mcp-server.service.js +10 -1
- package/dist/mcp/mcp-server.service.js.map +1 -1
- package/dist/mcp/mcp.module.d.ts.map +1 -1
- package/dist/mcp/mcp.module.js +2 -0
- package/dist/mcp/mcp.module.js.map +1 -1
- package/dist/mcp/tools/core-access-log.mcp-tools.d.ts +17 -0
- package/dist/mcp/tools/core-access-log.mcp-tools.d.ts.map +1 -0
- package/dist/mcp/tools/core-access-log.mcp-tools.js +74 -0
- package/dist/mcp/tools/core-access-log.mcp-tools.js.map +1 -0
- package/dist/mcp/tools/core-files.mcp-tools.js +4 -4
- package/dist/mcp/tools/core-files.mcp-tools.js.map +1 -1
- package/dist/meeting/meeting-provider.registry.d.ts +72 -0
- package/dist/meeting/meeting-provider.registry.d.ts.map +1 -1
- package/dist/meeting/meeting-provider.registry.js.map +1 -1
- package/dist/oauth/oauth-token-broker.service.d.ts +50 -1
- package/dist/oauth/oauth-token-broker.service.d.ts.map +1 -1
- package/dist/oauth/oauth-token-broker.service.js +105 -3
- package/dist/oauth/oauth-token-broker.service.js.map +1 -1
- package/dist/oauth/providers/microsoft-entra-id.provider.d.ts +6 -0
- package/dist/oauth/providers/microsoft-entra-id.provider.d.ts.map +1 -1
- package/dist/oauth/providers/microsoft-entra-id.provider.js +30 -4
- package/dist/oauth/providers/microsoft-entra-id.provider.js.map +1 -1
- package/dist/pdf/document/charts/chart-bar.d.ts +24 -0
- package/dist/pdf/document/charts/chart-bar.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-bar.js +117 -0
- package/dist/pdf/document/charts/chart-bar.js.map +1 -0
- package/dist/pdf/document/charts/chart-donut.d.ts +16 -0
- package/dist/pdf/document/charts/chart-donut.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-donut.js +89 -0
- package/dist/pdf/document/charts/chart-donut.js.map +1 -0
- package/dist/pdf/document/charts/chart-gauge.d.ts +15 -0
- package/dist/pdf/document/charts/chart-gauge.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-gauge.js +60 -0
- package/dist/pdf/document/charts/chart-gauge.js.map +1 -0
- package/dist/pdf/document/charts/chart-line.d.ts +29 -0
- package/dist/pdf/document/charts/chart-line.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-line.js +89 -0
- package/dist/pdf/document/charts/chart-line.js.map +1 -0
- package/dist/pdf/document/charts/chart-primitives.d.ts +64 -0
- package/dist/pdf/document/charts/chart-primitives.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-primitives.js +110 -0
- package/dist/pdf/document/charts/chart-primitives.js.map +1 -0
- package/dist/pdf/document/charts/chart-radar.d.ts +13 -0
- package/dist/pdf/document/charts/chart-radar.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-radar.js +83 -0
- package/dist/pdf/document/charts/chart-radar.js.map +1 -0
- package/dist/pdf/document/charts/chart-scale.d.ts +39 -0
- package/dist/pdf/document/charts/chart-scale.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-scale.js +118 -0
- package/dist/pdf/document/charts/chart-scale.js.map +1 -0
- package/dist/pdf/document/charts/index.d.ts +8 -0
- package/dist/pdf/document/charts/index.d.ts.map +1 -0
- package/dist/pdf/document/charts/index.js +24 -0
- package/dist/pdf/document/charts/index.js.map +1 -0
- package/dist/pdf/document/index.d.ts +8 -0
- package/dist/pdf/document/index.d.ts.map +1 -0
- package/dist/pdf/document/index.js +24 -0
- package/dist/pdf/document/index.js.map +1 -0
- package/dist/pdf/document/pdf-document.chrome.d.ts +20 -0
- package/dist/pdf/document/pdf-document.chrome.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.chrome.js +36 -0
- package/dist/pdf/document/pdf-document.chrome.js.map +1 -0
- package/dist/pdf/document/pdf-document.css.d.ts +20 -0
- package/dist/pdf/document/pdf-document.css.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.css.js +327 -0
- package/dist/pdf/document/pdf-document.css.js.map +1 -0
- package/dist/pdf/document/pdf-document.renderer.d.ts +10 -0
- package/dist/pdf/document/pdf-document.renderer.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.renderer.js +226 -0
- package/dist/pdf/document/pdf-document.renderer.js.map +1 -0
- package/dist/pdf/document/pdf-document.service.d.ts +27 -0
- package/dist/pdf/document/pdf-document.service.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.service.js +81 -0
- package/dist/pdf/document/pdf-document.service.js.map +1 -0
- package/dist/pdf/document/pdf-document.theme.d.ts +41 -0
- package/dist/pdf/document/pdf-document.theme.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.theme.js +105 -0
- package/dist/pdf/document/pdf-document.theme.js.map +1 -0
- package/dist/pdf/document/pdf-document.types.d.ts +164 -0
- package/dist/pdf/document/pdf-document.types.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.types.js +18 -0
- package/dist/pdf/document/pdf-document.types.js.map +1 -0
- package/dist/pdf/pdf.module.d.ts.map +1 -1
- package/dist/pdf/pdf.module.js +3 -2
- package/dist/pdf/pdf.module.js.map +1 -1
- package/dist/pdf/pdf.service.d.ts +75 -1
- package/dist/pdf/pdf.service.d.ts.map +1 -1
- package/dist/pdf/pdf.service.js +167 -63
- package/dist/pdf/pdf.service.js.map +1 -1
- package/dist/pdf/templates/commercial-proposal.template.d.ts +96 -0
- package/dist/pdf/templates/commercial-proposal.template.d.ts.map +1 -0
- package/dist/pdf/templates/commercial-proposal.template.js +276 -0
- package/dist/pdf/templates/commercial-proposal.template.js.map +1 -0
- package/dist/setting/favicon.controller.d.ts +26 -0
- package/dist/setting/favicon.controller.d.ts.map +1 -0
- package/dist/setting/favicon.controller.js +130 -0
- package/dist/setting/favicon.controller.js.map +1 -0
- package/dist/setting/setting.module.d.ts.map +1 -1
- package/dist/setting/setting.module.js +2 -1
- package/dist/setting/setting.module.js.map +1 -1
- package/dist/setting/setting.service.d.ts.map +1 -1
- package/dist/setting/setting.service.js +3 -0
- package/dist/setting/setting.service.js.map +1 -1
- package/dist/task/task.service.d.ts +6 -0
- package/dist/task/task.service.d.ts.map +1 -1
- package/dist/task/task.service.js +44 -13
- package/dist/task/task.service.js.map +1 -1
- package/dist/whatsapp-sender/whatsapp-sender.service.d.ts +35 -2
- package/dist/whatsapp-sender/whatsapp-sender.service.d.ts.map +1 -1
- package/dist/whatsapp-sender/whatsapp-sender.service.js +107 -22
- package/dist/whatsapp-sender/whatsapp-sender.service.js.map +1 -1
- package/hedhog/data/integration_type.yaml +6 -0
- package/hedhog/data/route.yaml +8 -0
- package/hedhog/data/setting_group.yaml +52 -0
- package/hedhog/frontend/app/configurations/[slug]/components/setting-field.test.tsx.ejs +85 -2
- package/hedhog/frontend/app/configurations/[slug]/components/setting-field.tsx.ejs +576 -491
- package/hedhog/frontend/app/users/simulate-access-dialog.test.tsx.ejs +29 -50
- package/hedhog/frontend/app/users/simulate-access-dialog.tsx.ejs +146 -50
- package/hedhog/frontend/messages/en.json +81 -2
- package/hedhog/frontend/messages/pt.json +81 -2
- package/hedhog/table/file.yaml +3 -1
- package/package.json +5 -5
- package/src/access-log/access-log.interceptor.spec.ts +50 -0
- package/src/access-log/access-log.interceptor.ts +18 -0
- package/src/ai/ai-attachment.fragments.spec.ts +56 -3
- package/src/ai/ai.service.responses.spec.ts +429 -0
- package/src/ai/ai.service.ts +409 -27
- package/src/ai-instruction/ai-prompt-resolver.service.spec.ts +44 -0
- package/src/ai-instruction/ai-prompt-resolver.service.ts +29 -0
- package/src/core.module.ts +3 -0
- package/src/document-kit/document-kit.module.ts +12 -0
- package/src/document-kit/document-kit.registry.ts +125 -0
- package/src/document-kit/document-kit.service.spec.ts +156 -0
- package/src/document-kit/document-kit.service.ts +173 -0
- package/src/file/file-direct-upload.spec.ts +344 -0
- package/src/file/file.service.spec.ts +168 -1
- package/src/file/file.service.ts +435 -36
- package/src/file/mcp-file-source.schema.ts +8 -5
- package/src/file/provider/abstract.provider.ts +57 -0
- package/src/file/provider/s3-multipart.spec.ts +123 -0
- package/src/file/provider/s3-multipart.util.ts +126 -0
- package/src/file/provider/s3.provider.ts +65 -1
- package/src/file/provider/spaces.provider.ts +67 -1
- package/src/index.ts +5 -0
- package/src/integration-profile/integration-profile.secrets.ts +26 -0
- package/src/integration-profile/integration-profile.service.ts +1 -0
- package/src/integration-profile/testers/ai.testers.ts +70 -5
- package/src/integration-profile/testers/http-probe.ts +5 -3
- package/src/integration-profile/testers/integration-tester.types.ts +5 -0
- package/src/integration-profile/testers/oauth.testers.spec.ts +13 -0
- package/src/integration-profile/testers/remaining.testers.spec.ts +65 -1
- package/src/integration-profile/testers/tester-coverage.spec.ts +12 -0
- package/src/mail/mail.service.spec.ts +25 -0
- package/src/mail/mail.service.ts +18 -1
- package/src/mcp/mcp-server.service.spec.ts +16 -0
- package/src/mcp/mcp-server.service.ts +10 -1
- package/src/mcp/mcp.module.ts +2 -0
- package/src/mcp/tools/core-access-log.mcp-tools.ts +66 -0
- package/src/mcp/tools/core-files.mcp-tools.ts +4 -4
- package/src/meeting/meeting-provider.registry.ts +80 -0
- package/src/oauth/oauth-token-broker.service.spec.ts +108 -0
- package/src/oauth/oauth-token-broker.service.ts +129 -2
- package/src/oauth/providers/microsoft-entra-id.provider.spec.ts +133 -0
- package/src/oauth/providers/microsoft-entra-id.provider.ts +29 -2
- package/src/pdf/document/charts/chart-bar.ts +217 -0
- package/src/pdf/document/charts/chart-donut.ts +163 -0
- package/src/pdf/document/charts/chart-gauge.ts +94 -0
- package/src/pdf/document/charts/chart-line.ts +159 -0
- package/src/pdf/document/charts/chart-primitives.ts +188 -0
- package/src/pdf/document/charts/chart-radar.ts +136 -0
- package/src/pdf/document/charts/chart-scale.spec.ts +93 -0
- package/src/pdf/document/charts/chart-scale.ts +141 -0
- package/src/pdf/document/charts/charts.spec.ts +157 -0
- package/src/pdf/document/charts/index.ts +7 -0
- package/src/pdf/document/index.ts +7 -0
- package/src/pdf/document/pdf-document.chrome.ts +38 -0
- package/src/pdf/document/pdf-document.css.ts +326 -0
- package/src/pdf/document/pdf-document.renderer.spec.ts +172 -0
- package/src/pdf/document/pdf-document.renderer.ts +317 -0
- package/src/pdf/document/pdf-document.service.ts +77 -0
- package/src/pdf/document/pdf-document.theme.ts +146 -0
- package/src/pdf/document/pdf-document.types.ts +149 -0
- package/src/pdf/pdf.module.ts +3 -2
- package/src/pdf/pdf.service.spec.ts +131 -0
- package/src/pdf/pdf.service.ts +256 -82
- package/src/pdf/templates/commercial-proposal.template.spec.ts +177 -0
- package/src/pdf/templates/commercial-proposal.template.ts +400 -0
- package/src/setting/favicon.controller.spec.ts +92 -0
- package/src/setting/favicon.controller.ts +135 -0
- package/src/setting/setting.module.ts +2 -1
- package/src/setting/setting.service.ts +3 -0
- package/src/task/task.service.spec.ts +77 -1
- package/src/task/task.service.ts +58 -14
- package/src/whatsapp-sender/whatsapp-sender.service.spec.ts +263 -0
- package/src/whatsapp-sender/whatsapp-sender.service.ts +154 -16
|
@@ -78,6 +78,16 @@ export class OAuthTokenBrokerService {
|
|
|
78
78
|
/** Refresh a bit early so an in-flight request never rides an about-to-expire token. */
|
|
79
79
|
private static readonly EXPIRY_SKEW_MS = 60 * 1000;
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Quantas vezes seguir um refresh token que outro processo rotacionou.
|
|
83
|
+
*
|
|
84
|
+
* Duas bastam na prática: a corrida acontece quando várias réplicas disparam
|
|
85
|
+
* o mesmo tick de cron, e a segunda tentativa já parte do token que a
|
|
86
|
+
* vencedora gravou. O teto existe para o caso patológico de rotações
|
|
87
|
+
* encadeadas não virar laço.
|
|
88
|
+
*/
|
|
89
|
+
private static readonly MAX_ROTATION_RETRIES = 2;
|
|
90
|
+
|
|
81
91
|
constructor(
|
|
82
92
|
private readonly http: HttpService,
|
|
83
93
|
@Inject(forwardRef(() => SettingService))
|
|
@@ -169,6 +179,70 @@ export class OAuthTokenBrokerService {
|
|
|
169
179
|
return { tokenUrl, clientId, clientSecret };
|
|
170
180
|
}
|
|
171
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Lê uma coluna `Bytes` que guarda texto cifrado.
|
|
184
|
+
*
|
|
185
|
+
* O Prisma 6 passou a devolver `Bytes` como `Uint8Array`, e não mais como
|
|
186
|
+
* `Buffer`. Os dois têm `.toString()`, mas com significados opostos: o do
|
|
187
|
+
* Buffer decodifica os bytes como utf8 ("salt:iv:tag:cifra"), o do
|
|
188
|
+
* Uint8Array imprime os bytes em decimal separados por vírgula
|
|
189
|
+
* ("115,97,108,116,..."). O `SecurityService.decrypt` então não achava as 4
|
|
190
|
+
* partes separadas por ":" e lançava — e como toda conta caía nesse mesmo
|
|
191
|
+
* erro, a mensagem final era "reconecte a conta", que nenhuma reconexão
|
|
192
|
+
* resolvia. `Buffer.from` normaliza os dois casos.
|
|
193
|
+
*/
|
|
194
|
+
private readEncryptedColumn(value: Uint8Array | Buffer): string {
|
|
195
|
+
return Buffer.from(value).toString();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Resumo do erro do provedor para o log. Usa só `error` e `error_description`
|
|
200
|
+
* — campos que a Microsoft e o Google preenchem com código e explicação
|
|
201
|
+
* (AADSTS…), nunca com credencial. O corpo inteiro não vai para o log: a
|
|
202
|
+
* requisição que falhou carrega o client_secret e o refresh token.
|
|
203
|
+
*/
|
|
204
|
+
private describeGrantError(error: any): string {
|
|
205
|
+
const data = error?.response?.data;
|
|
206
|
+
const code = data?.error ?? 'unknown';
|
|
207
|
+
const description = String(data?.error_description ?? '')
|
|
208
|
+
.replace(/\s+/g, ' ')
|
|
209
|
+
.trim();
|
|
210
|
+
return description ? `${code}: ${description.slice(0, 300)}` : code;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Relê o refresh token do banco para ver se outro processo já o rotacionou.
|
|
215
|
+
*
|
|
216
|
+
* Devolve `true` só quando o valor guardado é DIFERENTE do que acabou de
|
|
217
|
+
* falhar — é isso que distingue uma corrida entre réplicas (vale tentar de
|
|
218
|
+
* novo, agora com o token novo) de uma revogação de verdade (não adianta
|
|
219
|
+
* insistir). Comparar os valores importa: sem essa checagem, uma conta
|
|
220
|
+
* realmente desconectada entraria em laço de tentativas.
|
|
221
|
+
*
|
|
222
|
+
* Nunca lança: qualquer problema aqui vira "não rotacionou", e quem chamou
|
|
223
|
+
* segue para o erro original, que é a informação correta a dar ao usuário.
|
|
224
|
+
*/
|
|
225
|
+
private async reloadRotatedRefreshToken(
|
|
226
|
+
userId: number,
|
|
227
|
+
provider: string,
|
|
228
|
+
usedRefreshToken: string,
|
|
229
|
+
): Promise<boolean> {
|
|
230
|
+
try {
|
|
231
|
+
const account = await this.prisma.user_account.findFirst({
|
|
232
|
+
where: { user_id: userId, provider: this.toAccountProvider(provider) },
|
|
233
|
+
select: { refresh_token: true },
|
|
234
|
+
});
|
|
235
|
+
if (!account?.refresh_token) return false;
|
|
236
|
+
|
|
237
|
+
const stored = this.security.decrypt(
|
|
238
|
+
this.readEncryptedColumn(account.refresh_token),
|
|
239
|
+
);
|
|
240
|
+
return Boolean(stored) && stored !== usedRefreshToken;
|
|
241
|
+
} catch {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
172
246
|
private async loadUserAccount(userId: number, provider: string) {
|
|
173
247
|
const account = await this.prisma.user_account.findFirst({
|
|
174
248
|
where: {
|
|
@@ -232,6 +306,13 @@ export class OAuthTokenBrokerService {
|
|
|
232
306
|
async getAccessToken(
|
|
233
307
|
userId: number,
|
|
234
308
|
provider: string,
|
|
309
|
+
/**
|
|
310
|
+
* Profundidade da retentativa após uma rotação concorrente. Existe para a
|
|
311
|
+
* recursão ter fim: numa corrida entre muitas réplicas, cada uma pode
|
|
312
|
+
* rotacionar de novo entre a nossa falha e a releitura, e sem teto duas
|
|
313
|
+
* réplicas azaradas ficariam se revezando indefinidamente.
|
|
314
|
+
*/
|
|
315
|
+
attempt = 0,
|
|
235
316
|
): Promise<BrokeredAccessToken> {
|
|
236
317
|
const key = this.cacheKey(userId, provider);
|
|
237
318
|
const cached = this.cache.get(key);
|
|
@@ -249,8 +330,23 @@ export class OAuthTokenBrokerService {
|
|
|
249
330
|
|
|
250
331
|
let decryptedRefreshToken: string;
|
|
251
332
|
try {
|
|
252
|
-
decryptedRefreshToken = this.security.decrypt(
|
|
333
|
+
decryptedRefreshToken = this.security.decrypt(
|
|
334
|
+
this.readEncryptedColumn(account.refresh_token),
|
|
335
|
+
);
|
|
253
336
|
} catch (error) {
|
|
337
|
+
// Este caminho e o `invalid_grant` lá embaixo chegam ao usuário com a
|
|
338
|
+
// MESMA frase ("reconecte a conta"), e as causas não têm nada a ver uma
|
|
339
|
+
// com a outra: aqui o valor guardado não abre, lá é a Microsoft recusando
|
|
340
|
+
// o grant. Sem distinguir no log, não há como saber qual dos dois ocorreu.
|
|
341
|
+
// A mensagem do decrypt separa os dois motivos possíveis daqui — formato
|
|
342
|
+
// inválido (o valor lido não é o cifrado) e falha de autenticação (chave
|
|
343
|
+
// diferente da que cifrou) —, e é truncada porque ela ecoa o começo do
|
|
344
|
+
// valor que tentou decifrar.
|
|
345
|
+
this.logger.warn(
|
|
346
|
+
`Stored refresh token could not be decrypted: provider=${provider} userId=${userId} detail=${
|
|
347
|
+
error instanceof Error ? error.message.split('Value:')[0].trim() : 'unknown'
|
|
348
|
+
}`,
|
|
349
|
+
);
|
|
254
350
|
throw new OAuthTokenBrokerError(
|
|
255
351
|
'refresh_revoked',
|
|
256
352
|
`Failed to decrypt stored refresh token for "${provider}".`,
|
|
@@ -290,8 +386,39 @@ export class OAuthTokenBrokerService {
|
|
|
290
386
|
} catch (error: any) {
|
|
291
387
|
const providerError = error?.response?.data?.error;
|
|
292
388
|
if (providerError === 'invalid_grant') {
|
|
293
|
-
// Refresh token revoked/expired — the user must reconnect.
|
|
294
389
|
this.cache.delete(key);
|
|
390
|
+
|
|
391
|
+
// Antes de declarar a conta desconectada, checa se outro processo
|
|
392
|
+
// rotacionou o token enquanto este fazia a troca.
|
|
393
|
+
//
|
|
394
|
+
// A Microsoft emite um refresh token novo a cada troca e invalida o
|
|
395
|
+
// anterior, então basta duas réplicas usarem o mesmo token ao mesmo
|
|
396
|
+
// tempo para a segunda levar `invalid_grant` — e este serviço roda em
|
|
397
|
+
// seis. Sem esta releitura, uma corrida entre réplicas era reportada ao
|
|
398
|
+
// usuário como "reconecte a conta", e reconectar não resolvia nada,
|
|
399
|
+
// porque a corrida se repetia no ciclo seguinte.
|
|
400
|
+
const rotated =
|
|
401
|
+
attempt < OAuthTokenBrokerService.MAX_ROTATION_RETRIES &&
|
|
402
|
+
(await this.reloadRotatedRefreshToken(
|
|
403
|
+
userId,
|
|
404
|
+
provider,
|
|
405
|
+
decryptedRefreshToken,
|
|
406
|
+
));
|
|
407
|
+
if (rotated) {
|
|
408
|
+
this.logger.log(
|
|
409
|
+
`Refresh token was rotated by another process; retrying: provider=${provider} userId=${userId} attempt=${attempt + 1}`,
|
|
410
|
+
);
|
|
411
|
+
return this.getAccessToken(userId, provider, attempt + 1);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Nada mudou no banco: o grant foi mesmo recusado. O `error_description`
|
|
415
|
+
// do provedor é a única coisa que diz POR QUÊ (na Microsoft, o código
|
|
416
|
+
// AADSTS: token expirado, consentimento faltando, acesso condicional).
|
|
417
|
+
// Sem ele no log, "reconecte a conta" é tudo que sobra para diagnosticar
|
|
418
|
+
// — e reconectar não resolve a maioria desses casos.
|
|
419
|
+
this.logger.warn(
|
|
420
|
+
`Refresh grant rejected: provider=${provider} userId=${userId} scopesSent="${scopeParam.join(' ')}" detail=${this.describeGrantError(error)}`,
|
|
421
|
+
);
|
|
295
422
|
throw new OAuthTokenBrokerError(
|
|
296
423
|
'refresh_revoked',
|
|
297
424
|
`Refresh token for "${provider}" is no longer valid. Reconnect the account.`,
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { describe, expect, it, jest } from '@jest/globals';
|
|
2
|
+
import { HttpService } from '@nestjs/axios';
|
|
3
|
+
import { of } from 'rxjs';
|
|
4
|
+
import { MicrosoftEntraIdProvider } from './microsoft-entra-id.provider';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* O que estes testes protegem: `user_account.scopes` tem que registrar o que a
|
|
8
|
+
* Microsoft CONCEDEU, não o que a setting pede.
|
|
9
|
+
*
|
|
10
|
+
* Gravar a setting quebrava toda a integração delegada de um jeito que não dava
|
|
11
|
+
* pista nenhuma. O `OAuthTokenBrokerService` monta o `scope` do refresh a partir
|
|
12
|
+
* dessa coluna; se ela promete um escopo que o consentimento não cobre, a
|
|
13
|
+
* Microsoft recusa o grant com `invalid_grant`, o broker traduz para "reconecte
|
|
14
|
+
* a conta" — e reconectar pelo login regrava a mesma setting, então o usuário
|
|
15
|
+
* reconecta, falha, reconecta, falha.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const SCOPES_CONFIGURADOS = [
|
|
19
|
+
'openid',
|
|
20
|
+
'profile',
|
|
21
|
+
'email',
|
|
22
|
+
'User.Read',
|
|
23
|
+
'offline_access',
|
|
24
|
+
'Chat.Read',
|
|
25
|
+
'Files.Read.All',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const makeProvider = (tokenResponse: Record<string, unknown>) => {
|
|
29
|
+
const http = {
|
|
30
|
+
post: jest.fn(() => of({ data: tokenResponse })),
|
|
31
|
+
get: jest.fn(() => of({ data: { id: 'graph-id', mail: 'alguem@exemplo.test', displayName: 'Alguém' } })),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const setting = {
|
|
35
|
+
getSettingValues: jest.fn(async (slugs: string[]) => {
|
|
36
|
+
const values: Record<string, unknown> = {
|
|
37
|
+
'oauth-microsoft-entra-id-profile-id': 'perfil-entra',
|
|
38
|
+
'oauth-microsoft-entra-id-scopes': SCOPES_CONFIGURADOS,
|
|
39
|
+
url: 'https://app.exemplo.test',
|
|
40
|
+
'api-url': 'https://api.exemplo.test',
|
|
41
|
+
};
|
|
42
|
+
return Object.fromEntries(slugs.map((slug) => [slug, values[slug]]));
|
|
43
|
+
}),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const prisma = {
|
|
47
|
+
integration_profile: {
|
|
48
|
+
findUnique: jest.fn(async () => ({
|
|
49
|
+
slug: 'perfil-entra',
|
|
50
|
+
config: { client_id: 'CID', client_secret: 'SECRET', tenant_id: 'TENANT' },
|
|
51
|
+
integration_provider: { slug: 'microsoft-entra-id-oauth' },
|
|
52
|
+
})),
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return new MicrosoftEntraIdProvider(
|
|
57
|
+
http as unknown as HttpService,
|
|
58
|
+
setting as any,
|
|
59
|
+
prisma as any,
|
|
60
|
+
);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const parse = (profile: any) => String(profile.oauth_scopes).split(',');
|
|
64
|
+
|
|
65
|
+
describe('MicrosoftEntraIdProvider.getProfile — escopos', () => {
|
|
66
|
+
it('grava o que a Microsoft concedeu, não o que a setting pede', async () => {
|
|
67
|
+
// Consentimento antigo: nem Chat.Read nem Files.Read.All foram aprovados.
|
|
68
|
+
const provider = makeProvider({
|
|
69
|
+
access_token: 'ACCESS',
|
|
70
|
+
refresh_token: 'REFRESH',
|
|
71
|
+
expires_in: 3600,
|
|
72
|
+
scope: 'openid profile email User.Read',
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const scopes = parse(await provider.getProfile('CODE', 'login'));
|
|
76
|
+
|
|
77
|
+
expect(scopes).not.toContain('Chat.Read');
|
|
78
|
+
expect(scopes).not.toContain('Files.Read.All');
|
|
79
|
+
expect(scopes).toContain('User.Read');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('mantém offline_access quando veio refresh token, mesmo fora do scope', async () => {
|
|
83
|
+
// A Microsoft não repete `offline_access` em `token.scope`. Perdê-lo faria
|
|
84
|
+
// o broker tratar a conta como sem acesso offline.
|
|
85
|
+
const provider = makeProvider({
|
|
86
|
+
access_token: 'ACCESS',
|
|
87
|
+
refresh_token: 'REFRESH',
|
|
88
|
+
expires_in: 3600,
|
|
89
|
+
scope: 'openid profile email User.Read',
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(parse(await provider.getProfile('CODE', 'login'))).toContain(
|
|
93
|
+
'offline_access',
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('não inventa offline_access quando não houve refresh token', async () => {
|
|
98
|
+
const provider = makeProvider({
|
|
99
|
+
access_token: 'ACCESS',
|
|
100
|
+
expires_in: 3600,
|
|
101
|
+
scope: 'openid profile email',
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(parse(await provider.getProfile('CODE', 'login'))).not.toContain(
|
|
105
|
+
'offline_access',
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('cai para os escopos pedidos só quando a Microsoft não informa nenhum', async () => {
|
|
110
|
+
// Ausência de `scope` é omissão do provedor; lista vazia seria resposta.
|
|
111
|
+
const provider = makeProvider({
|
|
112
|
+
access_token: 'ACCESS',
|
|
113
|
+
refresh_token: 'REFRESH',
|
|
114
|
+
expires_in: 3600,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const scopes = parse(await provider.getProfile('CODE', 'login'));
|
|
118
|
+
|
|
119
|
+
expect(scopes).toEqual(expect.arrayContaining(SCOPES_CONFIGURADOS));
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('registra escopo concedido vazio como vazio, sem assumir o pedido', async () => {
|
|
123
|
+
const provider = makeProvider({
|
|
124
|
+
access_token: 'ACCESS',
|
|
125
|
+
expires_in: 3600,
|
|
126
|
+
scope: ' ',
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
expect(parse(await provider.getProfile('CODE', 'login'))).not.toContain(
|
|
130
|
+
'Files.Read.All',
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -77,6 +77,19 @@ export class MicrosoftEntraIdProvider extends BaseOAuthProvider {
|
|
|
77
77
|
};
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Escopos que a Microsoft devolveu no token, ou `null` quando ela não
|
|
82
|
+
* informou — só nesse caso vale cair de volta para os escopos pedidos.
|
|
83
|
+
* Uma lista vazia é resposta legítima e não pode virar "assume o pedido".
|
|
84
|
+
*/
|
|
85
|
+
private parseGrantedScopes(scope: unknown): string[] | null {
|
|
86
|
+
if (typeof scope !== 'string') return null;
|
|
87
|
+
return scope
|
|
88
|
+
.split(/[\s,]+/)
|
|
89
|
+
.map((item) => item.trim())
|
|
90
|
+
.filter(Boolean);
|
|
91
|
+
}
|
|
92
|
+
|
|
80
93
|
/** Absolute redirect URI the web flow registers/exchanges against (the API). */
|
|
81
94
|
private buildApiRedirectUri(apiUrl: string) {
|
|
82
95
|
if (!String(apiUrl).trim()) {
|
|
@@ -155,8 +168,22 @@ export class MicrosoftEntraIdProvider extends BaseOAuthProvider {
|
|
|
155
168
|
'https://graph.microsoft.com/v1.0/me',
|
|
156
169
|
);
|
|
157
170
|
|
|
171
|
+
// `token.scope` é o que a Microsoft de fato consentiu, que pode ser MENOS
|
|
172
|
+
// do que a setting pede — consentimento antigo do usuário, ou escopo novo
|
|
173
|
+
// ainda sem aprovação do admin. Gravar a setting como se fosse o concedido
|
|
174
|
+
// faz o refresh seguinte pedir escopo que o grant não cobre, e aí a
|
|
175
|
+
// resposta é `invalid_grant`: a UI diz "reconecte a conta", e reconectar
|
|
176
|
+
// pelo login regrava a mesma setting, então nunca sai do lugar.
|
|
177
|
+
const grantedScopes = this.parseGrantedScopes(token.scope) ?? scopes;
|
|
178
|
+
// A Microsoft não repete `offline_access` em `token.scope`, mas ter
|
|
179
|
+
// devolvido um refresh token é prova de que foi concedido.
|
|
180
|
+
if (token.refresh_token && !grantedScopes.includes('offline_access')) {
|
|
181
|
+
grantedScopes.push('offline_access');
|
|
182
|
+
}
|
|
183
|
+
const missing = scopes.filter((scope) => !grantedScopes.includes(scope));
|
|
184
|
+
|
|
158
185
|
this.logger.log(
|
|
159
|
-
`OAuth profile fetched: provider=MICROSOFT-ENTRA-ID type=${type} profileIdPresent=${profile?.id ? 'yes' : 'no'} emailPresent=${profile?.mail || profile?.userPrincipalName ? 'yes' : 'no'}`,
|
|
186
|
+
`OAuth profile fetched: provider=MICROSOFT-ENTRA-ID type=${type} profileIdPresent=${profile?.id ? 'yes' : 'no'} emailPresent=${profile?.mail || profile?.userPrincipalName ? 'yes' : 'no'} scopesGranted=${grantedScopes.length}/${scopes.length}${missing.length ? ` missing=${missing.join(' ')}` : ''}`,
|
|
160
187
|
);
|
|
161
188
|
|
|
162
189
|
const pictureUrl = 'https://graph.microsoft.com/v1.0/me/photo/$value';
|
|
@@ -165,7 +192,7 @@ export class MicrosoftEntraIdProvider extends BaseOAuthProvider {
|
|
|
165
192
|
email: profile.mail || profile.userPrincipalName,
|
|
166
193
|
name: profile.displayName,
|
|
167
194
|
picture: pictureUrl,
|
|
168
|
-
oauth_scopes:
|
|
195
|
+
oauth_scopes: grantedScopes.join(','),
|
|
169
196
|
oauth_tokens: {
|
|
170
197
|
access_token: token.access_token,
|
|
171
198
|
refresh_token: token.refresh_token,
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { SafeSvg } from '../pdf-document.types';
|
|
2
|
+
import {
|
|
3
|
+
CHART_DEFAULT_HEIGHT,
|
|
4
|
+
CHART_FONT,
|
|
5
|
+
CHART_FULL_WIDTH,
|
|
6
|
+
ChartBaseOptions,
|
|
7
|
+
ChartSeries,
|
|
8
|
+
chartColor,
|
|
9
|
+
emptyChart,
|
|
10
|
+
legendRows,
|
|
11
|
+
line,
|
|
12
|
+
svgRoot,
|
|
13
|
+
text,
|
|
14
|
+
xAxisLabels,
|
|
15
|
+
yAxis,
|
|
16
|
+
} from './chart-primitives';
|
|
17
|
+
import {
|
|
18
|
+
axisLabelPlan,
|
|
19
|
+
bandScale,
|
|
20
|
+
estimateTextWidth,
|
|
21
|
+
formatNumber,
|
|
22
|
+
linearScale,
|
|
23
|
+
niceScale,
|
|
24
|
+
truncateToWidth,
|
|
25
|
+
} from './chart-scale';
|
|
26
|
+
|
|
27
|
+
const hasData = (series: ChartSeries[]): boolean =>
|
|
28
|
+
series.some((s) => s.values.some((v) => typeof v === 'number' && Number.isFinite(v)));
|
|
29
|
+
|
|
30
|
+
const maxOf = (series: ChartSeries[], stacked: boolean): number => {
|
|
31
|
+
if (!stacked) {
|
|
32
|
+
return series.reduce(
|
|
33
|
+
(max, s) => Math.max(max, ...s.values.map((v) => (typeof v === 'number' ? v : 0))),
|
|
34
|
+
0,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const length = series.reduce((max, s) => Math.max(max, s.values.length), 0);
|
|
39
|
+
let max = 0;
|
|
40
|
+
for (let i = 0; i < length; i += 1) {
|
|
41
|
+
max = Math.max(
|
|
42
|
+
max,
|
|
43
|
+
series.reduce((sum, s) => sum + (typeof s.values[i] === 'number' ? (s.values[i] as number) : 0), 0),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
return max;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Barras verticais, uma faixa por categoria. */
|
|
50
|
+
export function barChartSvg(
|
|
51
|
+
options: ChartBaseOptions & {
|
|
52
|
+
categories: string[];
|
|
53
|
+
series: ChartSeries[];
|
|
54
|
+
stacked?: boolean;
|
|
55
|
+
yMax?: number;
|
|
56
|
+
yTicks?: number;
|
|
57
|
+
showValues?: boolean;
|
|
58
|
+
legend?: boolean;
|
|
59
|
+
},
|
|
60
|
+
): SafeSvg {
|
|
61
|
+
const {
|
|
62
|
+
theme,
|
|
63
|
+
categories,
|
|
64
|
+
series,
|
|
65
|
+
stacked = false,
|
|
66
|
+
width = CHART_FULL_WIDTH,
|
|
67
|
+
height = CHART_DEFAULT_HEIGHT,
|
|
68
|
+
valueFormat = (value: number) => formatNumber(value),
|
|
69
|
+
} = options;
|
|
70
|
+
|
|
71
|
+
if (categories.length === 0 || !hasData(series)) return emptyChart(options);
|
|
72
|
+
|
|
73
|
+
const showLegend = options.legend ?? series.length > 1;
|
|
74
|
+
const legendHeight = showLegend ? 16 : 0;
|
|
75
|
+
const plot = { left: 42, right: width - 8, top: 10, bottom: height - 26 - legendHeight };
|
|
76
|
+
|
|
77
|
+
const scale = niceScale(0, options.yMax ?? maxOf(series, stacked), options.yTicks ?? 4);
|
|
78
|
+
const y = linearScale([scale.min, scale.max], [plot.bottom, plot.top]);
|
|
79
|
+
const band = bandScale(categories.length, [plot.left, plot.right]);
|
|
80
|
+
|
|
81
|
+
const parts: string[] = [
|
|
82
|
+
yAxis(scale.ticks, y, plot, theme, valueFormat),
|
|
83
|
+
line(plot.left, plot.bottom, plot.right, plot.bottom, theme.mutedFg),
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
const groupWidth = stacked ? band.bandwidth : band.bandwidth / series.length;
|
|
87
|
+
|
|
88
|
+
series.forEach((serie, serieIndex) => {
|
|
89
|
+
const color = chartColor(theme, serieIndex, serie.color);
|
|
90
|
+
const stackTops = new Array(categories.length).fill(plot.bottom);
|
|
91
|
+
|
|
92
|
+
categories.forEach((_, index) => {
|
|
93
|
+
const raw = serie.values[index];
|
|
94
|
+
if (typeof raw !== 'number' || !Number.isFinite(raw)) return;
|
|
95
|
+
|
|
96
|
+
const barX = stacked ? band.band(index) : band.band(index) + groupWidth * serieIndex;
|
|
97
|
+
const top = stacked ? stackTops[index] - (plot.bottom - y(raw)) : y(raw);
|
|
98
|
+
const barHeight = Math.max(plot.bottom - y(raw), raw > 0 ? 1 : 0);
|
|
99
|
+
|
|
100
|
+
if (stacked) stackTops[index] = top;
|
|
101
|
+
|
|
102
|
+
parts.push(
|
|
103
|
+
`<rect x="${barX.toFixed(1)}" y="${top.toFixed(1)}" width="${groupWidth.toFixed(1)}" ` +
|
|
104
|
+
`height="${barHeight.toFixed(1)}" fill="${color}" rx="1.5" />`,
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// O valor só entra quando cabe dentro da largura da barra; senão vira
|
|
108
|
+
// sopa de números sobrepostos.
|
|
109
|
+
if (
|
|
110
|
+
options.showValues &&
|
|
111
|
+
!stacked &&
|
|
112
|
+
estimateTextWidth(valueFormat(raw), CHART_FONT.value) < groupWidth
|
|
113
|
+
) {
|
|
114
|
+
parts.push(
|
|
115
|
+
text(valueFormat(raw), barX + groupWidth / 2, top - 3, {
|
|
116
|
+
size: CHART_FONT.value,
|
|
117
|
+
fill: theme.ink,
|
|
118
|
+
anchor: 'middle',
|
|
119
|
+
weight: 600,
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const plan = axisLabelPlan(categories, band.bandwidth, CHART_FONT.axis);
|
|
127
|
+
parts.push(xAxisLabels(categories, band.center, plot.bottom + 12, plan, band.bandwidth, theme));
|
|
128
|
+
|
|
129
|
+
if (showLegend) {
|
|
130
|
+
const legend = legendRows(
|
|
131
|
+
series.map((serie, index) => ({
|
|
132
|
+
label: serie.name,
|
|
133
|
+
color: chartColor(theme, index, serie.color),
|
|
134
|
+
})),
|
|
135
|
+
width,
|
|
136
|
+
height - 4,
|
|
137
|
+
theme,
|
|
138
|
+
);
|
|
139
|
+
parts.push(legend.markup);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return svgRoot(width, height, parts.join(''), options.ariaLabel);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Barras horizontais — a forma certa quando o rótulo é um nome (curso, aluno),
|
|
147
|
+
* que deitado vira texto girado e ilegível.
|
|
148
|
+
*/
|
|
149
|
+
export function horizontalBarChartSvg(
|
|
150
|
+
options: ChartBaseOptions & {
|
|
151
|
+
categories: string[];
|
|
152
|
+
series: ChartSeries[];
|
|
153
|
+
xMax?: number;
|
|
154
|
+
labelWidth?: number;
|
|
155
|
+
showValues?: boolean;
|
|
156
|
+
},
|
|
157
|
+
): SafeSvg {
|
|
158
|
+
const {
|
|
159
|
+
theme,
|
|
160
|
+
categories,
|
|
161
|
+
series,
|
|
162
|
+
width = CHART_FULL_WIDTH,
|
|
163
|
+
valueFormat = (value: number) => formatNumber(value),
|
|
164
|
+
showValues = true,
|
|
165
|
+
} = options;
|
|
166
|
+
|
|
167
|
+
if (categories.length === 0 || !hasData(series)) return emptyChart(options);
|
|
168
|
+
|
|
169
|
+
const rowHeight = 22;
|
|
170
|
+
const height = options.height ?? Math.max(categories.length * rowHeight + 24, 90);
|
|
171
|
+
const labelWidth = options.labelWidth ?? Math.min(Math.round(width * 0.34), 180);
|
|
172
|
+
const plot = { left: labelWidth + 8, right: width - 38, top: 8, bottom: height - 16 };
|
|
173
|
+
|
|
174
|
+
const scale = niceScale(0, options.xMax ?? maxOf(series, false), 4);
|
|
175
|
+
const x = linearScale([scale.min, scale.max], [plot.left, plot.right]);
|
|
176
|
+
const band = bandScale(categories.length, [plot.top, plot.bottom], 0.25);
|
|
177
|
+
|
|
178
|
+
const parts: string[] = scale.ticks.map((tick) =>
|
|
179
|
+
line(x(tick), plot.top, x(tick), plot.bottom, theme.line),
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
categories.forEach((category, index) => {
|
|
183
|
+
parts.push(
|
|
184
|
+
text(truncateToWidth(category, CHART_FONT.axis + 1, labelWidth), labelWidth, band.center(index) + 3, {
|
|
185
|
+
size: CHART_FONT.axis + 1,
|
|
186
|
+
fill: theme.ink,
|
|
187
|
+
anchor: 'end',
|
|
188
|
+
}),
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const barHeight = band.bandwidth / series.length;
|
|
192
|
+
|
|
193
|
+
series.forEach((serie, serieIndex) => {
|
|
194
|
+
const raw = serie.values[index];
|
|
195
|
+
if (typeof raw !== 'number' || !Number.isFinite(raw)) return;
|
|
196
|
+
|
|
197
|
+
const barY = band.band(index) + barHeight * serieIndex;
|
|
198
|
+
const barWidth = Math.max(x(raw) - plot.left, raw > 0 ? 1 : 0);
|
|
199
|
+
|
|
200
|
+
parts.push(
|
|
201
|
+
`<rect x="${plot.left}" y="${barY.toFixed(1)}" width="${barWidth.toFixed(1)}" ` +
|
|
202
|
+
`height="${barHeight.toFixed(1)}" fill="${chartColor(theme, serieIndex, serie.color)}" rx="1.5" />`,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
if (showValues && series.length === 1) {
|
|
206
|
+
parts.push(
|
|
207
|
+
text(valueFormat(raw), plot.left + barWidth + 4, barY + barHeight / 2 + 3, {
|
|
208
|
+
size: CHART_FONT.value,
|
|
209
|
+
fill: theme.mutedFg,
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
return svgRoot(width, height, parts.join(''), options.ariaLabel);
|
|
217
|
+
}
|