@hed-hog/core 0.0.364 → 0.0.366

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 (189) hide show
  1. package/dist/ai/ai.controller.d.ts +4 -4
  2. package/dist/ai/ai.module.d.ts.map +1 -1
  3. package/dist/ai/ai.module.js +2 -0
  4. package/dist/ai/ai.module.js.map +1 -1
  5. package/dist/ai/ai.service.d.ts +7 -5
  6. package/dist/ai/ai.service.d.ts.map +1 -1
  7. package/dist/ai/ai.service.js +63 -31
  8. package/dist/ai/ai.service.js.map +1 -1
  9. package/dist/ai-instruction/ai-execution.service.d.ts +45 -0
  10. package/dist/ai-instruction/ai-execution.service.d.ts.map +1 -0
  11. package/dist/ai-instruction/ai-execution.service.js +203 -0
  12. package/dist/ai-instruction/ai-execution.service.js.map +1 -0
  13. package/dist/ai-instruction/ai-instruction.controller.d.ts +25 -0
  14. package/dist/ai-instruction/ai-instruction.controller.d.ts.map +1 -0
  15. package/dist/ai-instruction/ai-instruction.controller.js +117 -0
  16. package/dist/ai-instruction/ai-instruction.controller.js.map +1 -0
  17. package/dist/ai-instruction/ai-instruction.module.d.ts +3 -0
  18. package/dist/ai-instruction/ai-instruction.module.d.ts.map +1 -0
  19. package/dist/ai-instruction/ai-instruction.module.js +27 -0
  20. package/dist/ai-instruction/ai-instruction.module.js.map +1 -0
  21. package/dist/ai-instruction/ai-instruction.service.d.ts +38 -0
  22. package/dist/ai-instruction/ai-instruction.service.d.ts.map +1 -0
  23. package/dist/ai-instruction/ai-instruction.service.js +289 -0
  24. package/dist/ai-instruction/ai-instruction.service.js.map +1 -0
  25. package/dist/ai-instruction/ai-prompt-builder.service.d.ts +53 -0
  26. package/dist/ai-instruction/ai-prompt-builder.service.d.ts.map +1 -0
  27. package/dist/ai-instruction/ai-prompt-builder.service.js +132 -0
  28. package/dist/ai-instruction/ai-prompt-builder.service.js.map +1 -0
  29. package/dist/ai-instruction/dto/create-ai-instruction.dto.d.ts +12 -0
  30. package/dist/ai-instruction/dto/create-ai-instruction.dto.d.ts.map +1 -0
  31. package/dist/ai-instruction/dto/create-ai-instruction.dto.js +57 -0
  32. package/dist/ai-instruction/dto/create-ai-instruction.dto.js.map +1 -0
  33. package/dist/ai-instruction/dto/update-ai-instruction.dto.d.ts +10 -0
  34. package/dist/ai-instruction/dto/update-ai-instruction.dto.d.ts.map +1 -0
  35. package/dist/ai-instruction/dto/update-ai-instruction.dto.js +56 -0
  36. package/dist/ai-instruction/dto/update-ai-instruction.dto.js.map +1 -0
  37. package/dist/ai-instruction/types/ai-instruction.types.d.ts +78 -0
  38. package/dist/ai-instruction/types/ai-instruction.types.d.ts.map +1 -0
  39. package/dist/ai-instruction/types/ai-instruction.types.js +3 -0
  40. package/dist/ai-instruction/types/ai-instruction.types.js.map +1 -0
  41. package/dist/auth/auth.controller.d.ts +13 -5
  42. package/dist/auth/auth.controller.d.ts.map +1 -1
  43. package/dist/auth/auth.controller.js +39 -20
  44. package/dist/auth/auth.controller.js.map +1 -1
  45. package/dist/auth/auth.service.d.ts +18 -7
  46. package/dist/auth/auth.service.d.ts.map +1 -1
  47. package/dist/auth/auth.service.js +20 -16
  48. package/dist/auth/auth.service.js.map +1 -1
  49. package/dist/core.module.d.ts.map +1 -1
  50. package/dist/core.module.js +3 -0
  51. package/dist/core.module.js.map +1 -1
  52. package/dist/file/file.service.d.ts +37 -0
  53. package/dist/file/file.service.d.ts.map +1 -1
  54. package/dist/file/file.service.js +94 -4
  55. package/dist/file/file.service.js.map +1 -1
  56. package/dist/file/provider/abstract.provider.d.ts +6 -2
  57. package/dist/file/provider/abstract.provider.d.ts.map +1 -1
  58. package/dist/file/provider/abstract.provider.js.map +1 -1
  59. package/dist/file/provider/azure.provider.d.ts +4 -1
  60. package/dist/file/provider/azure.provider.d.ts.map +1 -1
  61. package/dist/file/provider/azure.provider.js +7 -2
  62. package/dist/file/provider/azure.provider.js.map +1 -1
  63. package/dist/file/provider/gcs.provider.d.ts +4 -1
  64. package/dist/file/provider/gcs.provider.d.ts.map +1 -1
  65. package/dist/file/provider/gcs.provider.js +7 -2
  66. package/dist/file/provider/gcs.provider.js.map +1 -1
  67. package/dist/file/provider/local.provider.d.ts +2 -2
  68. package/dist/file/provider/local.provider.d.ts.map +1 -1
  69. package/dist/file/provider/local.provider.js +7 -2
  70. package/dist/file/provider/local.provider.js.map +1 -1
  71. package/dist/file/provider/s3.provider.d.ts +4 -1
  72. package/dist/file/provider/s3.provider.d.ts.map +1 -1
  73. package/dist/file/provider/s3.provider.js +9 -6
  74. package/dist/file/provider/s3.provider.js.map +1 -1
  75. package/dist/file/provider/spaces.provider.d.ts +4 -1
  76. package/dist/file/provider/spaces.provider.d.ts.map +1 -1
  77. package/dist/file/provider/spaces.provider.js +9 -5
  78. package/dist/file/provider/spaces.provider.js.map +1 -1
  79. package/dist/integration-profile/integration-profile.controller.d.ts +3 -3
  80. package/dist/integration-profile/integration-profile.service.d.ts +3 -3
  81. package/dist/integration-profile/integration-profile.service.d.ts.map +1 -1
  82. package/dist/integration-profile/integration-profile.service.js +1 -10
  83. package/dist/integration-profile/integration-profile.service.js.map +1 -1
  84. package/dist/mcp-chat/mcp-chat.controller.d.ts +2 -2
  85. package/dist/mcp-chat/mcp-chat.module.d.ts.map +1 -1
  86. package/dist/mcp-chat/mcp-chat.module.js +2 -0
  87. package/dist/mcp-chat/mcp-chat.module.js.map +1 -1
  88. package/dist/mcp-chat/mcp-chat.service.d.ts +8 -3
  89. package/dist/mcp-chat/mcp-chat.service.d.ts.map +1 -1
  90. package/dist/mcp-chat/mcp-chat.service.js +100 -20
  91. package/dist/mcp-chat/mcp-chat.service.js.map +1 -1
  92. package/dist/notification/notification.service.d.ts +1 -0
  93. package/dist/notification/notification.service.d.ts.map +1 -1
  94. package/dist/notification/notification.service.js +29 -1
  95. package/dist/notification/notification.service.js.map +1 -1
  96. package/dist/oauth/oauth.service.d.ts +1 -0
  97. package/dist/oauth/oauth.service.d.ts.map +1 -1
  98. package/dist/oauth/oauth.service.js +55 -1
  99. package/dist/oauth/oauth.service.js.map +1 -1
  100. package/dist/profile/events/profile-avatar-updated.event.d.ts +7 -0
  101. package/dist/profile/events/profile-avatar-updated.event.d.ts.map +1 -0
  102. package/dist/profile/events/profile-avatar-updated.event.js +12 -0
  103. package/dist/profile/events/profile-avatar-updated.event.js.map +1 -0
  104. package/dist/profile/events/profile-email-changed.event.d.ts +7 -0
  105. package/dist/profile/events/profile-email-changed.event.d.ts.map +1 -0
  106. package/dist/profile/events/profile-email-changed.event.js +12 -0
  107. package/dist/profile/events/profile-email-changed.event.js.map +1 -0
  108. package/dist/profile/events/profile-updated.event.d.ts +7 -0
  109. package/dist/profile/events/profile-updated.event.d.ts.map +1 -0
  110. package/dist/profile/events/profile-updated.event.js +12 -0
  111. package/dist/profile/events/profile-updated.event.js.map +1 -0
  112. package/dist/profile/profile.service.d.ts +3 -1
  113. package/dist/profile/profile.service.d.ts.map +1 -1
  114. package/dist/profile/profile.service.js +28 -17
  115. package/dist/profile/profile.service.js.map +1 -1
  116. package/dist/session/session.controller.d.ts +1 -0
  117. package/dist/session/session.controller.d.ts.map +1 -1
  118. package/dist/session/session.service.d.ts +9 -4
  119. package/dist/session/session.service.d.ts.map +1 -1
  120. package/dist/session/session.service.js +61 -51
  121. package/dist/session/session.service.js.map +1 -1
  122. package/dist/setting/setting.controller.d.ts +5 -5
  123. package/dist/setting/setting.service.d.ts +5 -5
  124. package/dist/setting/setting.service.d.ts.map +1 -1
  125. package/dist/setting/setting.service.js +2 -0
  126. package/dist/setting/setting.service.js.map +1 -1
  127. package/dist/task/task.service.d.ts +8 -11
  128. package/dist/task/task.service.d.ts.map +1 -1
  129. package/dist/task/task.service.js +10 -16
  130. package/dist/task/task.service.js.map +1 -1
  131. package/dist/user/user.controller.d.ts +1 -0
  132. package/dist/user/user.controller.d.ts.map +1 -1
  133. package/dist/user/user.service.d.ts +3 -0
  134. package/dist/user/user.service.d.ts.map +1 -1
  135. package/dist/webhook-integration/webhook-command.registry.d.ts +9 -2
  136. package/dist/webhook-integration/webhook-command.registry.d.ts.map +1 -1
  137. package/dist/webhook-integration/webhook-command.registry.js.map +1 -1
  138. package/dist/webhook-integration/webhook-integration.service.d.ts.map +1 -1
  139. package/dist/webhook-integration/webhook-integration.service.js +15 -7
  140. package/dist/webhook-integration/webhook-integration.service.js.map +1 -1
  141. package/hedhog/data/integration_provider.yaml +17 -0
  142. package/hedhog/data/integration_type.yaml +6 -0
  143. package/hedhog/data/route.yaml +86 -2
  144. package/hedhog/frontend/app/configurations/[slug]/components/setting-field.tsx.ejs +109 -0
  145. package/hedhog/frontend/app/configurations/layout.tsx.ejs +6 -6
  146. package/hedhog/frontend/app/integration/profiles/page.tsx.ejs +169 -14
  147. package/hedhog/frontend/messages/en.json +11 -2
  148. package/hedhog/frontend/messages/pt.json +11 -2
  149. package/hedhog/table/ai_execution.yaml +79 -0
  150. package/hedhog/table/ai_execution_step.yaml +65 -0
  151. package/hedhog/table/ai_instruction.yaml +43 -0
  152. package/hedhog/table/ai_instruction_version.yaml +30 -0
  153. package/hedhog/table/ai_model_pricing.yaml +35 -0
  154. package/hedhog/table/user_session.yaml +2 -0
  155. package/package.json +3 -2
  156. package/src/ai/ai.module.ts +2 -0
  157. package/src/ai/ai.service.ts +64 -31
  158. package/src/ai-instruction/ai-execution.service.ts +244 -0
  159. package/src/ai-instruction/ai-instruction.controller.ts +73 -0
  160. package/src/ai-instruction/ai-instruction.module.ts +14 -0
  161. package/src/ai-instruction/ai-instruction.service.ts +343 -0
  162. package/src/ai-instruction/ai-prompt-builder.service.ts +142 -0
  163. package/src/ai-instruction/dto/create-ai-instruction.dto.ts +36 -0
  164. package/src/ai-instruction/dto/update-ai-instruction.dto.ts +35 -0
  165. package/src/ai-instruction/types/ai-instruction.types.ts +90 -0
  166. package/src/auth/auth.controller.ts +23 -5
  167. package/src/auth/auth.service.ts +26 -15
  168. package/src/core.module.ts +3 -0
  169. package/src/file/file.service.ts +136 -4
  170. package/src/file/provider/abstract.provider.ts +4 -2
  171. package/src/file/provider/azure.provider.ts +6 -3
  172. package/src/file/provider/gcs.provider.ts +5 -3
  173. package/src/file/provider/local.provider.ts +7 -3
  174. package/src/file/provider/s3.provider.ts +8 -6
  175. package/src/file/provider/spaces.provider.ts +8 -7
  176. package/src/integration-profile/integration-profile.service.ts +1 -10
  177. package/src/mcp-chat/mcp-chat.module.ts +19 -17
  178. package/src/mcp-chat/mcp-chat.service.ts +95 -9
  179. package/src/notification/notification.service.ts +32 -1
  180. package/src/oauth/oauth.service.ts +74 -1
  181. package/src/profile/events/profile-avatar-updated.event.ts +8 -0
  182. package/src/profile/events/profile-email-changed.event.ts +8 -0
  183. package/src/profile/events/profile-updated.event.ts +8 -0
  184. package/src/profile/profile.service.ts +25 -16
  185. package/src/session/session.service.ts +70 -52
  186. package/src/setting/setting.service.ts +10 -8
  187. package/src/task/task.service.ts +10 -16
  188. package/src/webhook-integration/webhook-command.registry.ts +10 -2
  189. package/src/webhook-integration/webhook-integration.service.ts +33 -6
@@ -11,6 +11,8 @@ import {
11
11
  NotFoundException,
12
12
  } from '@nestjs/common';
13
13
  import axios from 'axios';
14
+ import { AiExecutionService } from '../ai-instruction/ai-execution.service';
15
+ import { AiPromptBuilderService } from '../ai-instruction/ai-prompt-builder.service';
14
16
  import { buildAiConfigFromIntegration } from '../integration-profile/integration-profile.utils';
15
17
  import { McpRegistryService, McpToolEntry } from '../mcp/mcp-registry.service';
16
18
  import { McpContext } from '../mcp/types/mcp-context.type';
@@ -81,13 +83,18 @@ type ConversationHistoryItem = {
81
83
  const MAX_OPENAI_TOOLS = 128;
82
84
  const TARGET_SELECTED_TOOLS = 112;
83
85
  const MAX_ITERATIONS = 20;
86
+ // Maximum number of history messages to send per turn.
87
+ // Older messages beyond this window are dropped to limit prompt tokens.
88
+ const HISTORY_MESSAGE_LIMIT = 40;
84
89
  const FORCE_FINISH_THRESHOLD = 2;
85
90
  const MIN_DOMAIN_CONFIDENCE = 2;
86
91
  const AMBIGUOUS_DOMAIN_DELTA = 1;
87
92
 
88
- const SYSTEM_PROMPT =
93
+ // SYSTEM_PROMPT is now managed via AiInstructionService (slug: system.global).
94
+ // This fallback is used only if the DB is unreachable during startup.
95
+ const SYSTEM_PROMPT_FALLBACK =
89
96
  'You are a helpful assistant with access to tools from the HedHog system. ' +
90
- 'Use the available tools to answer the user\'s questions accurately. ' +
97
+ "Use the available tools to answer the user's questions accurately. " +
91
98
  'Always respond in the same language the user writes in.';
92
99
 
93
100
  const sanitize = (n: string) => n.replace(/\./g, '__');
@@ -242,6 +249,10 @@ export class McpChatService {
242
249
  private readonly setting: SettingService,
243
250
  @Inject(forwardRef(() => McpRegistryService))
244
251
  private readonly registry: McpRegistryService,
252
+ @Inject(forwardRef(() => AiPromptBuilderService))
253
+ private readonly promptBuilder: AiPromptBuilderService,
254
+ @Inject(forwardRef(() => AiExecutionService))
255
+ private readonly aiExecution: AiExecutionService,
245
256
  ) {}
246
257
 
247
258
  async listConversations(userId: number) {
@@ -311,10 +322,33 @@ export class McpChatService {
311
322
  });
312
323
  }
313
324
 
314
- if (provider === 'gemini') {
315
- return this.runGeminiLoop(conversationId, userId, locale, message, apiKey);
325
+ const executionId = await this.aiExecution.start({
326
+ userId,
327
+ conversationId,
328
+ trigger: 'manual',
329
+ contextSlug: 'mcp-chat',
330
+ });
331
+
332
+ try {
333
+ if (provider === 'gemini') {
334
+ return await this.runGeminiLoop(conversationId, userId, locale, message, apiKey, executionId);
335
+ }
336
+ return await this.runOpenAiLoop(conversationId, userId, locale, message, apiKey, executionId);
337
+ } catch (err) {
338
+ await this.aiExecution.finish(executionId, 'failed', String(err));
339
+ throw err;
340
+ }
341
+ }
342
+
343
+ private async getSystemPrompt(locale?: string): Promise<string> {
344
+ try {
345
+ const built = await this.promptBuilder.build({
346
+ locale,
347
+ });
348
+ return built.systemPrompt || SYSTEM_PROMPT_FALLBACK;
349
+ } catch {
350
+ return SYSTEM_PROMPT_FALLBACK;
316
351
  }
317
- return this.runOpenAiLoop(conversationId, userId, locale, message, apiKey);
318
352
  }
319
353
 
320
354
  // ── OpenAI loop ──────────────────────────────────────────────────────────
@@ -325,14 +359,22 @@ export class McpChatService {
325
359
  locale: string,
326
360
  message: string,
327
361
  apiKey: string,
362
+ executionId: number,
328
363
  ) {
329
- const history = await this.prisma.mcp_conversation_message.findMany({
364
+ const systemPrompt = await this.getSystemPrompt(locale);
365
+
366
+ const allHistory = await this.prisma.mcp_conversation_message.findMany({
330
367
  where: { conversation_id: conversationId },
331
368
  orderBy: { created_at: 'asc' },
332
369
  });
370
+ // Limit to the most recent messages to avoid token explosion on long conversations.
371
+ // We take the tail rather than summarize to ensure tool_call/tool_result pairs stay intact.
372
+ const history = allHistory.length > HISTORY_MESSAGE_LIMIT
373
+ ? allHistory.slice(allHistory.length - HISTORY_MESSAGE_LIMIT)
374
+ : allHistory;
333
375
 
334
376
  const openAiMessages: OpenAiMessage[] = [
335
- { role: 'system', content: SYSTEM_PROMPT },
377
+ { role: 'system', content: systemPrompt },
336
378
  ...this.toOpenAiMessages(history),
337
379
  ];
338
380
 
@@ -351,10 +393,12 @@ export class McpChatService {
351
393
  const tools = toolSelection.tools;
352
394
 
353
395
  const callSignatures = new Set<string>();
396
+ let stepOrder = 0;
354
397
  let maxIterations = MAX_ITERATIONS;
355
398
  while (maxIterations-- > 0) {
356
399
  const forceFinish = maxIterations < FORCE_FINISH_THRESHOLD;
357
400
  let response: any;
401
+ const stepStart = Date.now();
358
402
  try {
359
403
  response = await axios.post(
360
404
  'https://api.openai.com/v1/chat/completions',
@@ -389,6 +433,20 @@ export class McpChatService {
389
433
  );
390
434
  }
391
435
 
436
+ const usage = response.data?.usage;
437
+ await this.aiExecution.recordStep({
438
+ executionId,
439
+ stepOrder: stepOrder++,
440
+ type: 'prompt',
441
+ provider: 'openai',
442
+ model: 'gpt-4o-mini',
443
+ durationMs: Date.now() - stepStart,
444
+ usage: usage
445
+ ? { input: usage.prompt_tokens ?? 0, output: usage.completion_tokens ?? 0, total: usage.total_tokens ?? 0 }
446
+ : undefined,
447
+ inputSummary: openAiMessages[openAiMessages.length - 1]?.content?.toString()?.slice(0, 200),
448
+ });
449
+
392
450
  const choice = response.data?.choices?.[0];
393
451
  const assistantMsg = choice?.message;
394
452
 
@@ -492,12 +550,15 @@ export class McpChatService {
492
550
  },
493
551
  });
494
552
 
553
+ await this.aiExecution.finish(executionId, 'completed');
554
+
495
555
  return this.prisma.mcp_conversation_message.findMany({
496
556
  where: { conversation_id: conversationId },
497
557
  orderBy: { created_at: 'asc' },
498
558
  });
499
559
  }
500
560
 
561
+ await this.aiExecution.finish(executionId, 'failed', 'Maximum tool call iterations exceeded.');
501
562
  throw new BadRequestException('Maximum tool call iterations exceeded.');
502
563
  }
503
564
 
@@ -509,11 +570,17 @@ export class McpChatService {
509
570
  locale: string,
510
571
  message: string,
511
572
  apiKey: string,
573
+ executionId: number,
512
574
  ) {
513
- const history = await this.prisma.mcp_conversation_message.findMany({
575
+ const systemPrompt = await this.getSystemPrompt(locale);
576
+
577
+ const allHistory = await this.prisma.mcp_conversation_message.findMany({
514
578
  where: { conversation_id: conversationId },
515
579
  orderBy: { created_at: 'asc' },
516
580
  });
581
+ const history = allHistory.length > HISTORY_MESSAGE_LIMIT
582
+ ? allHistory.slice(allHistory.length - HISTORY_MESSAGE_LIMIT)
583
+ : allHistory;
517
584
 
518
585
  const geminiContents: GeminiContent[] = this.toGeminiContents(history);
519
586
  const toolSelection = await this.buildGeminiTools(
@@ -523,6 +590,7 @@ export class McpChatService {
523
590
  history,
524
591
  );
525
592
  if (toolSelection.clarification) {
593
+ await this.aiExecution.finish(executionId, 'completed');
526
594
  return this.recordAssistantAndReturnHistory(
527
595
  conversationId,
528
596
  toolSelection.clarification,
@@ -530,17 +598,19 @@ export class McpChatService {
530
598
  }
531
599
  const functionDeclarations = toolSelection.tools;
532
600
 
601
+ let stepOrder = 0;
533
602
  let maxIterations = MAX_ITERATIONS;
534
603
  let callIndex = 0;
535
604
 
536
605
  while (maxIterations-- > 0) {
537
606
  const forceFinish = maxIterations < FORCE_FINISH_THRESHOLD;
538
607
  let response: any;
608
+ const stepStart = Date.now();
539
609
  try {
540
610
  response = await axios.post(
541
611
  `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`,
542
612
  {
543
- systemInstruction: { parts: [{ text: SYSTEM_PROMPT }] },
613
+ systemInstruction: { parts: [{ text: systemPrompt }] },
544
614
  contents: geminiContents,
545
615
  tools:
546
616
  !forceFinish && functionDeclarations.length > 0
@@ -572,6 +642,19 @@ export class McpChatService {
572
642
  );
573
643
  }
574
644
 
645
+ const geminiUsage = response.data?.usageMetadata;
646
+ await this.aiExecution.recordStep({
647
+ executionId,
648
+ stepOrder: stepOrder++,
649
+ type: 'prompt',
650
+ provider: 'gemini',
651
+ model: 'gemini-2.0-flash',
652
+ durationMs: Date.now() - stepStart,
653
+ usage: geminiUsage
654
+ ? { input: geminiUsage.promptTokenCount ?? 0, output: geminiUsage.candidatesTokenCount ?? 0, total: geminiUsage.totalTokenCount ?? 0 }
655
+ : undefined,
656
+ });
657
+
575
658
  const candidate = response.data?.candidates?.[0];
576
659
  const parts: GeminiPart[] = candidate?.content?.parts ?? [];
577
660
 
@@ -649,12 +732,15 @@ export class McpChatService {
649
732
  },
650
733
  });
651
734
 
735
+ await this.aiExecution.finish(executionId, 'completed');
736
+
652
737
  return this.prisma.mcp_conversation_message.findMany({
653
738
  where: { conversation_id: conversationId },
654
739
  orderBy: { created_at: 'asc' },
655
740
  });
656
741
  }
657
742
 
743
+ await this.aiExecution.finish(executionId, 'failed', 'Maximum tool call iterations exceeded.');
658
744
  throw new BadRequestException('Maximum tool call iterations exceeded.');
659
745
  }
660
746
 
@@ -52,7 +52,7 @@ export class NotificationService implements OnModuleInit, OnModuleDestroy {
52
52
  }
53
53
 
54
54
  async onModuleInit() {
55
- const databaseUrl = String(process.env.DATABASE_URL || '');
55
+ const databaseUrl = String(process.env.DIRECT_DATABASE_URL || process.env.DATABASE_URL || '');
56
56
  if (!databaseUrl.startsWith('postgresql://')) {
57
57
  this.logger.warn(
58
58
  'Cluster notification stream disabled because DATABASE_URL is not PostgreSQL',
@@ -94,6 +94,7 @@ export class NotificationService implements OnModuleInit, OnModuleDestroy {
94
94
  }
95
95
 
96
96
  async list(userId: number, paginationParams: any) {
97
+ await this.cleanupStaleProgressNotifications(userId);
97
98
  return this.pagination.paginate(
98
99
  this.prisma.notification,
99
100
  paginationParams,
@@ -104,6 +105,36 @@ export class NotificationService implements OnModuleInit, OnModuleDestroy {
104
105
  );
105
106
  }
106
107
 
108
+ private async cleanupStaleProgressNotifications(userId: number) {
109
+ const stale = await this.prisma.$queryRaw<{ id: number }[]>`
110
+ SELECT n.id
111
+ FROM notification n
112
+ WHERE n.user_id = ${userId}
113
+ AND n.progress IS NOT NULL
114
+ AND n.progress < 100
115
+ AND n.finished_at IS NULL
116
+ AND (n.action_data->>'queueJobId') IS NOT NULL
117
+ AND NOT EXISTS (
118
+ SELECT 1 FROM queue_job qj
119
+ WHERE qj.id = (n.action_data->>'queueJobId')::int
120
+ )
121
+ `;
122
+
123
+ if (!stale.length) return;
124
+
125
+ await this.prisma.notification.updateMany({
126
+ where: { id: { in: stale.map((n) => n.id) } },
127
+ data: {
128
+ progress: 100,
129
+ type: 'error' as any,
130
+ body: 'O processamento foi interrompido (job não encontrado).',
131
+ finished_at: new Date(),
132
+ },
133
+ });
134
+
135
+ await this.broadcastNotificationEvent(userId, { type: 'notification.updated' });
136
+ }
137
+
107
138
  async unreadCount(userId: number) {
108
139
  const count = await this.prisma.notification.count({
109
140
  where: { user_id: userId, status: 'unread' },
@@ -94,7 +94,8 @@ export class OAuthService {
94
94
  throw new BadRequestException('Invalid mobile redirect URI.');
95
95
  }
96
96
 
97
- const authUrl = await this.getAuthUrl(provider, sanitizedRedirectUri);
97
+ const callbackPath = `/callback/${String(provider)}/login`;
98
+ const authUrl = await this.getAuthUrl(provider, callbackPath);
98
99
  const flow = 'login';
99
100
  const { token, signature } = await this.issueMobileState(
100
101
  provider,
@@ -153,6 +154,46 @@ export class OAuthService {
153
154
  );
154
155
  }
155
156
 
157
+ // Mobile browser flow: Microsoft redirected to backend callback with hhmob state.
158
+ // Complete auth here and redirect the browser to the mobile app URI with tokens.
159
+ if (state?.startsWith('hhmob.') && !redirectUri) {
160
+ const mobileRedirectUri = await this.lookupMobileRedirectUri(state, provider);
161
+ if (!mobileRedirectUri) {
162
+ throw new BadRequestException(
163
+ getLocaleText(
164
+ 'oauth.callback.invalidState',
165
+ locale,
166
+ 'OAuth callback state is invalid or expired. Start the sign-in flow again.',
167
+ ),
168
+ );
169
+ }
170
+
171
+ const callbackKey = this.buildCallbackKey(provider, type, code);
172
+ const tokens = (await this.callbackCoordinator.execute(callbackKey, async () => {
173
+ const profile = await this.getProvider(provider).getProfile(code, type);
174
+ return this.finalizeCallback({
175
+ res,
176
+ locale,
177
+ ipAddress,
178
+ userAgent,
179
+ provider,
180
+ type,
181
+ profile,
182
+ userId,
183
+ includeRefreshTokenInBody: true,
184
+ });
185
+ })) as { accessToken: string; refreshToken?: string };
186
+
187
+ const params = new URLSearchParams({ accessToken: tokens.accessToken });
188
+ if (tokens.refreshToken) params.set('refreshToken', tokens.refreshToken);
189
+
190
+ this.logger.log(
191
+ `OAuth mobile browser callback completed: provider=${provider} type=${type}`,
192
+ );
193
+ (res as any).redirect(`${mobileRedirectUri}?${params.toString()}`);
194
+ return;
195
+ }
196
+
156
197
  const callbackKey = this.buildCallbackKey(provider, type, code);
157
198
 
158
199
  return await this.callbackCoordinator.execute(callbackKey, async () => {
@@ -321,6 +362,38 @@ export class OAuthService {
321
362
  return Number(consumedRows) > 0;
322
363
  }
323
364
 
365
+ private async lookupMobileRedirectUri(
366
+ encodedState: string,
367
+ provider: user_account_provider_52222e2ecb_enum,
368
+ ): Promise<string | null> {
369
+ if (!encodedState?.startsWith('hhmob.')) return null;
370
+
371
+ const parts = encodedState.split('.');
372
+ const stateToken = parts[1];
373
+ const signature = parts[2];
374
+
375
+ if (!stateToken || !signature) return null;
376
+
377
+ const hasValidSignature = this.security.verifyHmacSha256(
378
+ `oauth-mobile-state:${stateToken}`,
379
+ signature,
380
+ );
381
+ if (!hasValidSignature) return null;
382
+
383
+ const tokenHash = this.security.hashSha256(stateToken);
384
+ const record = await this.prisma.oauth_mobile_state_token.findFirst({
385
+ where: {
386
+ token_hash: tokenHash,
387
+ provider: String(provider).toLowerCase(),
388
+ expires_at: { gt: new Date() },
389
+ consumed_at: null,
390
+ },
391
+ select: { redirect_uri: true },
392
+ });
393
+
394
+ return record?.redirect_uri ?? null;
395
+ }
396
+
324
397
  private async finalizeCallback({
325
398
  res,
326
399
  locale,
@@ -0,0 +1,8 @@
1
+ export const PROFILE_AVATAR_UPDATED = 'profile.avatar_updated';
2
+
3
+ export class ProfileAvatarUpdatedEvent {
4
+ constructor(
5
+ public readonly userId: number,
6
+ public readonly fileId: number | null,
7
+ ) {}
8
+ }
@@ -0,0 +1,8 @@
1
+ export const PROFILE_EMAIL_CHANGED = 'profile.email_changed';
2
+
3
+ export class ProfileEmailChangedEvent {
4
+ constructor(
5
+ public readonly userId: number,
6
+ public readonly email: string,
7
+ ) {}
8
+ }
@@ -0,0 +1,8 @@
1
+ export const PROFILE_UPDATED = 'profile.updated';
2
+
3
+ export class ProfileUpdatedEvent {
4
+ constructor(
5
+ public readonly userId: number,
6
+ public readonly name: string,
7
+ ) {}
8
+ }
@@ -2,6 +2,7 @@ import { getLocaleText } from '@hed-hog/api-locale';
2
2
  import { PrismaService, user } from '@hed-hog/api-prisma';
3
3
  import { User } from '@hed-hog/api-types';
4
4
  import { BadRequestException, forwardRef, Inject, Injectable, NotFoundException } from '@nestjs/common';
5
+ import { EventEmitter2 } from '@nestjs/event-emitter';
5
6
  import { JwtService } from '@nestjs/jwt';
6
7
  import type {
7
8
  VerifiedAuthenticationResponse,
@@ -27,6 +28,9 @@ import { EmailVerificationDto } from './dto/email-verification-confirm.dto';
27
28
  import { SendEmailVerificationDto } from './dto/send-email-verification.dto';
28
29
  import { SetPreferencesDto } from './dto/set-preferences.dto';
29
30
  import { UpdateDto } from './dto/update.dto';
31
+ import { PROFILE_AVATAR_UPDATED, ProfileAvatarUpdatedEvent } from './events/profile-avatar-updated.event';
32
+ import { PROFILE_EMAIL_CHANGED, ProfileEmailChangedEvent } from './events/profile-email-changed.event';
33
+ import { PROFILE_UPDATED, ProfileUpdatedEvent } from './events/profile-updated.event';
30
34
 
31
35
  @Injectable()
32
36
  export class ProfileService {
@@ -44,6 +48,7 @@ export class ProfileService {
44
48
  private readonly token: TokenService,
45
49
  @Inject(forwardRef(() => MailService))
46
50
  private readonly mail: MailService,
51
+ private readonly eventEmitter: EventEmitter2,
47
52
  ) {}
48
53
 
49
54
  async changeEmail(locale: string, userId: number, { email, password, pin }: ChangeEmailDto) {
@@ -237,6 +242,7 @@ export class ProfileService {
237
242
  }
238
243
 
239
244
  const verifiedAt = new Date();
245
+ let userIdentifierId = challenge.user_identifier_id;
240
246
 
241
247
  await this.prisma.$transaction(async (tx) => {
242
248
 
@@ -244,8 +250,6 @@ export class ProfileService {
244
250
  where: { id: challengeId },
245
251
  })
246
252
 
247
- let userIdentifierId = challenge.user_identifier_id;
248
-
249
253
  if (!userIdentifierId) {
250
254
  const userIdentifier = await tx.user_identifier.findFirst({
251
255
  where: { user_id: userId, type: 'email', verified_at: null },
@@ -277,18 +281,19 @@ export class ProfileService {
277
281
  }
278
282
  });
279
283
 
280
- // Delete ALL other email identifiers (both verified and unverified) to maintain single email principle
281
- // This ensures user has only one verified primary email
282
- await tx.user_identifier.deleteMany({
283
- where: {
284
- user_id: userId,
285
- type: 'email',
286
- id: { not: userIdentifierId }
287
- }
288
- });
284
+ // Keep previous emails intact so the user can still authenticate with the old address.
285
+ // The verified identifier above becomes the new active email, but older verified emails remain valid aliases.
289
286
 
290
287
  });
291
288
 
289
+ const confirmedIdentifier = await this.prisma.user_identifier.findUnique({
290
+ where: { id: userIdentifierId },
291
+ select: { value: true },
292
+ });
293
+ if (confirmedIdentifier?.value) {
294
+ this.eventEmitter.emit(PROFILE_EMAIL_CHANGED, new ProfileEmailChangedEvent(userId, confirmedIdentifier.value));
295
+ }
296
+
292
297
  return { success: true };
293
298
  }
294
299
 
@@ -338,16 +343,20 @@ export class ProfileService {
338
343
  }
339
344
 
340
345
  async update(userid: number, { name }: UpdateDto) {
341
- return this.prisma.user.update({
346
+ const result = await this.prisma.user.update({
342
347
  where: { id: userid },
343
- data: {
344
- name,
345
- },
348
+ data: { name },
346
349
  });
350
+ if (name) {
351
+ this.eventEmitter.emit(PROFILE_UPDATED, new ProfileUpdatedEvent(userid, name));
352
+ }
353
+ return result;
347
354
  }
348
355
 
349
356
  async updateAvatar(locale: string, userId: number, file: MulterFile) {
350
- return this.user.changeAvatar(locale, userId, file);
357
+ const result = await this.user.changeAvatar(locale, userId, file);
358
+ this.eventEmitter.emit(PROFILE_AVATAR_UPDATED, new ProfileAvatarUpdatedEvent(userId, result?.id ?? null));
359
+ return result;
351
360
  }
352
361
 
353
362
  async changePassword(locale:string, userId: number, {currentPassword, newPassword}: ChangePasswordDto, ipAddress?: string, userAgent?: string) {