@hed-hog/core 0.0.299 → 0.0.301

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 (133) hide show
  1. package/dist/ai/ai.service.d.ts +13 -2
  2. package/dist/ai/ai.service.d.ts.map +1 -1
  3. package/dist/ai/ai.service.js +104 -2
  4. package/dist/ai/ai.service.js.map +1 -1
  5. package/dist/dashboard/dashboard/dashboard.controller.d.ts +6 -0
  6. package/dist/dashboard/dashboard/dashboard.controller.d.ts.map +1 -1
  7. package/dist/dashboard/dashboard/dashboard.service.d.ts +6 -0
  8. package/dist/dashboard/dashboard/dashboard.service.d.ts.map +1 -1
  9. package/dist/dashboard/dashboard-component/dashboard-component.controller.d.ts +2 -1
  10. package/dist/dashboard/dashboard-component/dashboard-component.controller.d.ts.map +1 -1
  11. package/dist/dashboard/dashboard-component/dashboard-component.controller.js +6 -3
  12. package/dist/dashboard/dashboard-component/dashboard-component.controller.js.map +1 -1
  13. package/dist/dashboard/dashboard-component/dashboard-component.service.d.ts +7 -1
  14. package/dist/dashboard/dashboard-component/dashboard-component.service.d.ts.map +1 -1
  15. package/dist/dashboard/dashboard-component/dashboard-component.service.js +76 -33
  16. package/dist/dashboard/dashboard-component/dashboard-component.service.js.map +1 -1
  17. package/dist/dashboard/dashboard-core/dashboard-core.controller.d.ts +82 -0
  18. package/dist/dashboard/dashboard-core/dashboard-core.controller.d.ts.map +1 -1
  19. package/dist/dashboard/dashboard-core/dashboard-core.controller.js +117 -0
  20. package/dist/dashboard/dashboard-core/dashboard-core.controller.js.map +1 -1
  21. package/dist/dashboard/dashboard-core/dashboard-core.service.d.ts +93 -0
  22. package/dist/dashboard/dashboard-core/dashboard-core.service.d.ts.map +1 -1
  23. package/dist/dashboard/dashboard-core/dashboard-core.service.js +654 -20
  24. package/dist/dashboard/dashboard-core/dashboard-core.service.js.map +1 -1
  25. package/dist/dashboard/dashboard-item/dashboard-item.controller.d.ts +2 -0
  26. package/dist/dashboard/dashboard-item/dashboard-item.controller.d.ts.map +1 -1
  27. package/dist/dashboard/dashboard-item/dashboard-item.service.d.ts +2 -0
  28. package/dist/dashboard/dashboard-item/dashboard-item.service.d.ts.map +1 -1
  29. package/dist/dashboard/dashboard-role/dashboard-role.controller.d.ts +2 -0
  30. package/dist/dashboard/dashboard-role/dashboard-role.controller.d.ts.map +1 -1
  31. package/dist/dashboard/dashboard-role/dashboard-role.service.d.ts +2 -0
  32. package/dist/dashboard/dashboard-role/dashboard-role.service.d.ts.map +1 -1
  33. package/dist/index.d.ts +1 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +1 -0
  36. package/dist/index.js.map +1 -1
  37. package/dist/mail/mail.service.d.ts +9 -2
  38. package/dist/mail/mail.service.d.ts.map +1 -1
  39. package/dist/mail/mail.service.js +56 -4
  40. package/dist/mail/mail.service.js.map +1 -1
  41. package/dist/setting/setting.service.d.ts +6 -1
  42. package/dist/setting/setting.service.d.ts.map +1 -1
  43. package/dist/setting/setting.service.js +188 -15
  44. package/dist/setting/setting.service.js.map +1 -1
  45. package/hedhog/data/dashboard.yaml +12 -6
  46. package/hedhog/data/dashboard_component_role.yaml +66 -0
  47. package/hedhog/data/dashboard_role.yaml +2 -8
  48. package/hedhog/data/route.yaml +72 -0
  49. package/hedhog/data/setting_group.yaml +28 -0
  50. package/hedhog/frontend/app/dashboard/[slug]/dashboard-content.tsx.ejs +333 -128
  51. package/hedhog/frontend/app/dashboard/[slug]/widget-renderer.tsx.ejs +277 -53
  52. package/hedhog/frontend/app/dashboard/components/add-widget-selector-dialog.tsx.ejs +179 -231
  53. package/hedhog/frontend/app/dashboard/components/draggable-grid.tsx.ejs +64 -18
  54. package/hedhog/frontend/app/dashboard/dashboard-home-tabs.tsx.ejs +1619 -0
  55. package/hedhog/frontend/app/dashboard/dashboard.css.ejs +37 -0
  56. package/hedhog/frontend/app/dashboard/management/page.tsx.ejs +1 -1
  57. package/hedhog/frontend/app/dashboard/management/tabs/components-tab.tsx.ejs +6 -6
  58. package/hedhog/frontend/app/dashboard/management/tabs/dashboards-tab.tsx.ejs +8 -8
  59. package/hedhog/frontend/app/dashboard/management/tabs/items-tab.tsx.ejs +3 -3
  60. package/hedhog/frontend/app/dashboard/page.tsx.ejs +3 -25
  61. package/hedhog/frontend/messages/en.json +124 -2
  62. package/hedhog/frontend/messages/pt.json +123 -1
  63. package/hedhog/frontend/widgets/account-security.tsx.ejs +1 -1
  64. package/hedhog/frontend/widgets/active-users-card.tsx.ejs +2 -2
  65. package/hedhog/frontend/widgets/activity-timeline.tsx.ejs +1 -1
  66. package/hedhog/frontend/widgets/email-notifications.tsx.ejs +1 -1
  67. package/hedhog/frontend/widgets/locale-config.tsx.ejs +1 -1
  68. package/hedhog/frontend/widgets/login-history-chart.tsx.ejs +1 -1
  69. package/hedhog/frontend/widgets/mail-config.tsx.ejs +1 -1
  70. package/hedhog/frontend/widgets/mail-sent-card.tsx.ejs +2 -2
  71. package/hedhog/frontend/widgets/mail-sent-chart.tsx.ejs +1 -1
  72. package/hedhog/frontend/widgets/menus-card.tsx.ejs +2 -2
  73. package/hedhog/frontend/widgets/oauth-config.tsx.ejs +1 -1
  74. package/hedhog/frontend/widgets/permissions-card.tsx.ejs +2 -2
  75. package/hedhog/frontend/widgets/permissions-chart.tsx.ejs +1 -1
  76. package/hedhog/frontend/widgets/profile-card.tsx.ejs +1 -1
  77. package/hedhog/frontend/widgets/routes-card.tsx.ejs +2 -2
  78. package/hedhog/frontend/widgets/session-activity-chart.tsx.ejs +1 -1
  79. package/hedhog/frontend/widgets/sessions-today-card.tsx.ejs +2 -2
  80. package/hedhog/frontend/widgets/stat-access-level.tsx.ejs +1 -1
  81. package/hedhog/frontend/widgets/stat-actions-today.tsx.ejs +1 -1
  82. package/hedhog/frontend/widgets/stat-consecutive-days.tsx.ejs +1 -1
  83. package/hedhog/frontend/widgets/stat-online-time.tsx.ejs +1 -1
  84. package/hedhog/frontend/widgets/storage-config.tsx.ejs +1 -1
  85. package/hedhog/frontend/widgets/theme-config.tsx.ejs +1 -1
  86. package/hedhog/frontend/widgets/user-growth-chart.tsx.ejs +1 -1
  87. package/hedhog/frontend/widgets/user-roles.tsx.ejs +1 -1
  88. package/hedhog/frontend/widgets/user-sessions.tsx.ejs +1 -1
  89. package/hedhog/table/dashboard.yaml +6 -0
  90. package/package.json +3 -3
  91. package/src/ai/ai.service.ts +129 -1
  92. package/src/dashboard/dashboard-component/dashboard-component.controller.ts +15 -2
  93. package/src/dashboard/dashboard-component/dashboard-component.service.ts +107 -43
  94. package/src/dashboard/dashboard-core/dashboard-core.controller.ts +119 -1
  95. package/src/dashboard/dashboard-core/dashboard-core.service.ts +876 -20
  96. package/src/index.ts +7 -6
  97. package/src/mail/mail.service.ts +67 -3
  98. package/src/setting/setting.service.ts +222 -15
  99. package/hedhog/frontend/app/dashboard/components/widgets/core..gitkeep.ejs +0 -11
  100. package/hedhog/frontend/app/dashboard/components/widgets/core.account-security.tsx.ejs +0 -192
  101. package/hedhog/frontend/app/dashboard/components/widgets/core.active-users-card.tsx.ejs +0 -58
  102. package/hedhog/frontend/app/dashboard/components/widgets/core.activity-timeline.tsx.ejs +0 -223
  103. package/hedhog/frontend/app/dashboard/components/widgets/core.email-notifications.tsx.ejs +0 -226
  104. package/hedhog/frontend/app/dashboard/components/widgets/core.locale-config.tsx.ejs +0 -168
  105. package/hedhog/frontend/app/dashboard/components/widgets/core.login-history-chart.tsx.ejs +0 -115
  106. package/hedhog/frontend/app/dashboard/components/widgets/core.mail-config.tsx.ejs +0 -199
  107. package/hedhog/frontend/app/dashboard/components/widgets/core.mail-sent-card.tsx.ejs +0 -58
  108. package/hedhog/frontend/app/dashboard/components/widgets/core.mail-sent-chart.tsx.ejs +0 -149
  109. package/hedhog/frontend/app/dashboard/components/widgets/core.menus-card.tsx.ejs +0 -58
  110. package/hedhog/frontend/app/dashboard/components/widgets/core.oauth-config.tsx.ejs +0 -175
  111. package/hedhog/frontend/app/dashboard/components/widgets/core.permissions-card.tsx.ejs +0 -61
  112. package/hedhog/frontend/app/dashboard/components/widgets/core.permissions-chart.tsx.ejs +0 -156
  113. package/hedhog/frontend/app/dashboard/components/widgets/core.profile-card.tsx.ejs +0 -186
  114. package/hedhog/frontend/app/dashboard/components/widgets/core.routes-card.tsx.ejs +0 -58
  115. package/hedhog/frontend/app/dashboard/components/widgets/core.session-activity-chart.tsx.ejs +0 -183
  116. package/hedhog/frontend/app/dashboard/components/widgets/core.sessions-today-card.tsx.ejs +0 -62
  117. package/hedhog/frontend/app/dashboard/components/widgets/core.stat-access-level.tsx.ejs +0 -57
  118. package/hedhog/frontend/app/dashboard/components/widgets/core.stat-actions-today.tsx.ejs +0 -57
  119. package/hedhog/frontend/app/dashboard/components/widgets/core.stat-consecutive-days.tsx.ejs +0 -57
  120. package/hedhog/frontend/app/dashboard/components/widgets/core.stat-online-time.tsx.ejs +0 -57
  121. package/hedhog/frontend/app/dashboard/components/widgets/core.storage-config.tsx.ejs +0 -196
  122. package/hedhog/frontend/app/dashboard/components/widgets/core.theme-config.tsx.ejs +0 -213
  123. package/hedhog/frontend/app/dashboard/components/widgets/core.user-growth-chart.tsx.ejs +0 -210
  124. package/hedhog/frontend/app/dashboard/components/widgets/core.user-roles.tsx.ejs +0 -132
  125. package/hedhog/frontend/app/dashboard/components/widgets/core.user-sessions.tsx.ejs +0 -236
  126. package/hedhog/frontend/app/dashboard/components/widgets/finance.alerts.tsx.ejs +0 -108
  127. package/hedhog/frontend/app/dashboard/components/widgets/finance.cash-balance-kpi.tsx.ejs +0 -66
  128. package/hedhog/frontend/app/dashboard/components/widgets/finance.cash-flow-chart.tsx.ejs +0 -122
  129. package/hedhog/frontend/app/dashboard/components/widgets/finance.default-kpi.tsx.ejs +0 -63
  130. package/hedhog/frontend/app/dashboard/components/widgets/finance.payable-30d-kpi.tsx.ejs +0 -73
  131. package/hedhog/frontend/app/dashboard/components/widgets/finance.receivable-30d-kpi.tsx.ejs +0 -73
  132. package/hedhog/frontend/app/dashboard/components/widgets/finance.upcoming-payable.tsx.ejs +0 -123
  133. package/hedhog/frontend/app/dashboard/components/widgets/finance.upcoming-receivable.tsx.ejs +0 -118
@@ -642,6 +642,241 @@ export class DashboardCoreService {
642
642
  return value.map((provider) => String(provider).toLowerCase());
643
643
  }
644
644
 
645
+ private slugifyDashboardName(value: string): string {
646
+ const normalized = value
647
+ .normalize('NFD')
648
+ .replace(/[\u0300-\u036f]/g, '')
649
+ .toLowerCase()
650
+ .replace(/[^a-z0-9]+/g, '-')
651
+ .replace(/^-+|-+$/g, '');
652
+
653
+ return normalized || `dashboard-${Date.now()}`;
654
+ }
655
+
656
+ private getDashboardDisplayName(
657
+ dashboard: {
658
+ slug: string;
659
+ dashboard_locale?: Array<{ name?: string | null }>;
660
+ },
661
+ ): string {
662
+ return dashboard.dashboard_locale?.[0]?.name || dashboard.slug;
663
+ }
664
+
665
+ private async buildUniqueDashboardSlug(baseSlug: string): Promise<string> {
666
+ let slug = baseSlug;
667
+ let suffix = 2;
668
+
669
+ while (
670
+ await this.prismaService.dashboard.findFirst({
671
+ where: { slug },
672
+ select: { id: true },
673
+ })
674
+ ) {
675
+ slug = `${baseSlug}-${suffix}`;
676
+ suffix += 1;
677
+ }
678
+
679
+ return slug;
680
+ }
681
+
682
+ private async getDashboardUserOrThrow(
683
+ userId: number,
684
+ slug: string,
685
+ locale: string,
686
+ ) {
687
+ const dashboardUser = await this.prismaService.dashboard_user.findFirst({
688
+ where: {
689
+ user_id: userId,
690
+ dashboard: { slug },
691
+ },
692
+ include: {
693
+ dashboard: {
694
+ include: {
695
+ dashboard_locale: {
696
+ where: {
697
+ locale: {
698
+ code: locale,
699
+ },
700
+ },
701
+ },
702
+ },
703
+ },
704
+ },
705
+ });
706
+
707
+ if (!dashboardUser) {
708
+ throw new ForbiddenException(
709
+ getLocaleText('dashboardNotFound', locale, 'Dashboard not found.'),
710
+ );
711
+ }
712
+
713
+ return dashboardUser;
714
+ }
715
+
716
+ private async getUserRoleIds(userId: number) {
717
+ const roleUsers = await this.prismaService.role_user.findMany({
718
+ where: { user_id: userId },
719
+ select: { role_id: true },
720
+ });
721
+
722
+ return roleUsers.map((roleUser) => roleUser.role_id);
723
+ }
724
+
725
+ private async getDashboardComponentRoleRequirements(dashboardId: number) {
726
+ const dashboardItems = await this.prismaService.dashboard_item.findMany({
727
+ where: {
728
+ dashboard_id: dashboardId,
729
+ },
730
+ select: {
731
+ component_id: true,
732
+ dashboard_component: {
733
+ select: {
734
+ dashboard_component_role: {
735
+ select: {
736
+ role_id: true,
737
+ },
738
+ },
739
+ },
740
+ },
741
+ },
742
+ });
743
+
744
+ const uniqueByComponentId = new Map<number, number[]>();
745
+
746
+ for (const item of dashboardItems) {
747
+ if (uniqueByComponentId.has(item.component_id)) {
748
+ continue;
749
+ }
750
+
751
+ uniqueByComponentId.set(
752
+ item.component_id,
753
+ item.dashboard_component.dashboard_component_role.map(
754
+ (relation) => relation.role_id,
755
+ ),
756
+ );
757
+ }
758
+
759
+ return Array.from(uniqueByComponentId.values());
760
+ }
761
+
762
+ private userHasRequiredRolesForDashboard(
763
+ componentRoleRequirements: number[][],
764
+ userRoleIds: number[],
765
+ ) {
766
+ if (componentRoleRequirements.length === 0) {
767
+ return true;
768
+ }
769
+
770
+ if (userRoleIds.length === 0) {
771
+ return componentRoleRequirements.every(
772
+ (requiredRoles) => requiredRoles.length === 0,
773
+ );
774
+ }
775
+
776
+ const userRoleIdSet = new Set(userRoleIds);
777
+
778
+ return componentRoleRequirements.every(
779
+ (requiredRoles) =>
780
+ requiredRoles.length === 0 ||
781
+ requiredRoles.some((roleId) => userRoleIdSet.has(roleId)),
782
+ );
783
+ }
784
+
785
+ private async getDashboardRoleAccessState(dashboardId: number, userId: number) {
786
+ const [componentRoleRequirements, userRoleIds] = await Promise.all([
787
+ this.getDashboardComponentRoleRequirements(dashboardId),
788
+ this.getUserRoleIds(userId),
789
+ ]);
790
+
791
+ const hasRequiredRoles = this.userHasRequiredRolesForDashboard(
792
+ componentRoleRequirements,
793
+ userRoleIds,
794
+ );
795
+
796
+ return {
797
+ hasRequiredRoles,
798
+ accessStatus: hasRequiredRoles ? 'allowed' : 'missing-roles',
799
+ };
800
+ }
801
+
802
+ private async assertDashboardRoleAccess(dashboardId: number, userId: number) {
803
+ const { hasRequiredRoles } = await this.getDashboardRoleAccessState(
804
+ dashboardId,
805
+ userId,
806
+ );
807
+
808
+ if (!hasRequiredRoles) {
809
+ throw new ForbiddenException('Access denied to this dashboard');
810
+ }
811
+ }
812
+
813
+ private async getAccessibleTemplateOrThrow(
814
+ userId: number,
815
+ templateSlug: string,
816
+ locale: string,
817
+ ) {
818
+ const userRoleIds = await this.getUserRoleIds(userId);
819
+ const templateAccessFilter =
820
+ userRoleIds.length > 0
821
+ ? {
822
+ OR: [
823
+ {
824
+ dashboard_role: {
825
+ some: {
826
+ role_id: {
827
+ in: userRoleIds,
828
+ },
829
+ },
830
+ },
831
+ },
832
+ {
833
+ dashboard_role: {
834
+ none: {},
835
+ },
836
+ },
837
+ ],
838
+ }
839
+ : {
840
+ dashboard_role: {
841
+ none: {},
842
+ },
843
+ };
844
+
845
+ const template = await this.prismaService.dashboard.findFirst({
846
+ where: {
847
+ slug: templateSlug,
848
+ is_template: true,
849
+ ...templateAccessFilter,
850
+ },
851
+ include: {
852
+ dashboard_locale: {
853
+ where: {
854
+ locale: {
855
+ code: locale,
856
+ },
857
+ },
858
+ },
859
+ dashboard_item: {
860
+ select: {
861
+ component_id: true,
862
+ width: true,
863
+ height: true,
864
+ x_axis: true,
865
+ y_axis: true,
866
+ },
867
+ },
868
+ },
869
+ });
870
+
871
+ if (!template) {
872
+ throw new ForbiddenException(
873
+ getLocaleText('dashboardNotFound', locale, 'Dashboard template not found.'),
874
+ );
875
+ }
876
+
877
+ return template;
878
+ }
879
+
645
880
  async getHome(userId: number, locale: string) {
646
881
  const user = await this.prismaService.user.findUnique({
647
882
  where: { id: userId },
@@ -986,6 +1221,8 @@ export class DashboardCoreService {
986
1221
  return [];
987
1222
  }
988
1223
 
1224
+ await this.assertDashboardRoleAccess(dashboard.id, userId);
1225
+
989
1226
  const dashboardItems = await this.prismaService.dashboard_item.findMany({
990
1227
  where: {
991
1228
  dashboard_id: dashboard.id,
@@ -1061,28 +1298,36 @@ export class DashboardCoreService {
1061
1298
  throw new ForbiddenException('Access denied to this dashboard');
1062
1299
  }
1063
1300
 
1064
- for (const item of layout) {
1065
- const itemId = parseInt(item.i.replace('widget-', ''));
1066
- const dashboardItem = await this.prismaService.dashboard_item.findFirst({
1067
- where: {
1068
- id: itemId,
1069
- dashboard_id: dashboard.id,
1070
- },
1071
- });
1301
+ await this.assertDashboardRoleAccess(dashboard.id, userId);
1072
1302
 
1073
- if (!dashboardItem) {
1074
- continue;
1303
+ const layoutUpdates = layout.flatMap((item) => {
1304
+ const itemId = Number.parseInt(item.i.replace('widget-', ''), 10);
1305
+
1306
+ if (Number.isNaN(itemId)) {
1307
+ this.logger.warn(
1308
+ `Skipping dashboard layout item with invalid id: ${item.i}`,
1309
+ );
1310
+ return [];
1075
1311
  }
1076
1312
 
1077
- await this.prismaService.dashboard_item.update({
1078
- where: { id: dashboardItem.id },
1079
- data: {
1080
- x_axis: item.x,
1081
- y_axis: item.y,
1082
- width: item.w,
1083
- height: item.h,
1084
- },
1085
- });
1313
+ return [
1314
+ this.prismaService.dashboard_item.updateMany({
1315
+ where: {
1316
+ id: itemId,
1317
+ dashboard_id: dashboard.id,
1318
+ },
1319
+ data: {
1320
+ x_axis: item.x,
1321
+ y_axis: item.y,
1322
+ width: item.w,
1323
+ height: item.h,
1324
+ },
1325
+ }),
1326
+ ];
1327
+ });
1328
+
1329
+ if (layoutUpdates.length > 0) {
1330
+ await this.prismaService.$transaction(layoutUpdates);
1086
1331
  }
1087
1332
 
1088
1333
  return { success: true };
@@ -1114,6 +1359,8 @@ export class DashboardCoreService {
1114
1359
  throw new ForbiddenException('Access denied to this dashboard');
1115
1360
  }
1116
1361
 
1362
+ await this.assertDashboardRoleAccess(dashboard.id, userId);
1363
+
1117
1364
  const userRoles = await this.prismaService.role_user.findMany({
1118
1365
  where: { user_id: userId },
1119
1366
  select: { role_id: true },
@@ -1254,6 +1501,8 @@ export class DashboardCoreService {
1254
1501
  throw new ForbiddenException('Access denied to this dashboard');
1255
1502
  }
1256
1503
 
1504
+ await this.assertDashboardRoleAccess(dashboard.id, userId);
1505
+
1257
1506
  const parsedWidgetId = Number(widgetId.replace(/^widget-/, ''));
1258
1507
 
1259
1508
  if (!Number.isInteger(parsedWidgetId) || parsedWidgetId <= 0) {
@@ -1311,9 +1560,20 @@ export class DashboardCoreService {
1311
1560
  },
1312
1561
  });
1313
1562
 
1314
- const hasAccess = Boolean(dashboardUser);
1563
+ if (!dashboardUser) {
1564
+ return {
1565
+ hasAccess: false,
1566
+ accessStatus: 'not-shared',
1567
+ dashboard: null,
1568
+ };
1569
+ }
1570
+
1571
+ const roleAccess = await this.getDashboardRoleAccessState(dashboard.id, userId);
1572
+ const hasAccess = roleAccess.hasRequiredRoles;
1573
+
1315
1574
  return {
1316
1575
  hasAccess,
1576
+ accessStatus: roleAccess.accessStatus,
1317
1577
  dashboard: hasAccess
1318
1578
  ? {
1319
1579
  id: dashboard.id,
@@ -1325,6 +1585,8 @@ export class DashboardCoreService {
1325
1585
  }
1326
1586
 
1327
1587
  async getUserDashboards(userId: number, locale: string) {
1588
+ await this.getHome(userId, locale);
1589
+
1328
1590
  const dashboardUsers = await this.prismaService.dashboard_user.findMany({
1329
1591
  where: { user_id: userId },
1330
1592
  include: {
@@ -1340,6 +1602,7 @@ export class DashboardCoreService {
1340
1602
  },
1341
1603
  },
1342
1604
  },
1605
+ orderBy: [{ is_home: 'desc' }, { id: 'asc' }],
1343
1606
  });
1344
1607
 
1345
1608
  const uniqueByDashboardId = new Map<number, (typeof dashboardUsers)[number]>();
@@ -1352,10 +1615,603 @@ export class DashboardCoreService {
1352
1615
  return Array.from(uniqueByDashboardId.values()).map((dashboardUser) => ({
1353
1616
  id: dashboardUser.dashboard.id,
1354
1617
  slug: dashboardUser.dashboard.slug,
1618
+ name: this.getDashboardDisplayName(dashboardUser.dashboard),
1619
+ icon: dashboardUser.dashboard.icon,
1620
+ is_home: dashboardUser.is_home,
1355
1621
  dashboard_locale: dashboardUser.dashboard.dashboard_locale,
1356
1622
  }));
1357
1623
  }
1358
1624
 
1625
+ async getAvailableTemplates(userId: number, locale: string) {
1626
+ const userRoleIds = await this.getUserRoleIds(userId);
1627
+ const templateAccessFilter =
1628
+ userRoleIds.length > 0
1629
+ ? {
1630
+ OR: [
1631
+ {
1632
+ dashboard_role: {
1633
+ some: {
1634
+ role_id: {
1635
+ in: userRoleIds,
1636
+ },
1637
+ },
1638
+ },
1639
+ },
1640
+ {
1641
+ dashboard_role: {
1642
+ none: {},
1643
+ },
1644
+ },
1645
+ ],
1646
+ }
1647
+ : {
1648
+ dashboard_role: {
1649
+ none: {},
1650
+ },
1651
+ };
1652
+
1653
+ const templates = await this.prismaService.dashboard.findMany({
1654
+ where: {
1655
+ is_template: true,
1656
+ ...templateAccessFilter,
1657
+ },
1658
+ include: {
1659
+ dashboard_locale: {
1660
+ where: {
1661
+ locale: {
1662
+ code: locale,
1663
+ },
1664
+ },
1665
+ },
1666
+ dashboard_item: {
1667
+ select: { id: true },
1668
+ },
1669
+ },
1670
+ orderBy: [{ id: 'asc' }],
1671
+ });
1672
+
1673
+ return templates.map((template) => ({
1674
+ id: template.id,
1675
+ slug: template.slug,
1676
+ name: this.getDashboardDisplayName(template),
1677
+ icon: template.icon,
1678
+ itemCount: template.dashboard_item.length,
1679
+ }));
1680
+ }
1681
+
1682
+ async createUserDashboard(
1683
+ userId: number,
1684
+ data: { name?: string; slug?: string; icon?: string | null; templateSlug?: string },
1685
+ locale: string,
1686
+ ) {
1687
+ const templateSlug = this.toNullableString(data?.templateSlug);
1688
+ const template = templateSlug
1689
+ ? await this.getAccessibleTemplateOrThrow(userId, templateSlug, locale)
1690
+ : null;
1691
+
1692
+ const templateName = template ? this.getDashboardDisplayName(template) : null;
1693
+ const name = this.toNullableString(data?.name) || templateName;
1694
+
1695
+ if (!name) {
1696
+ throw new BadRequestException(
1697
+ getLocaleText('validation.fieldRequired', locale, 'Dashboard name is required.'),
1698
+ );
1699
+ }
1700
+
1701
+ const requestedSlug = this.toNullableString(data?.slug);
1702
+ const baseSlug = this.slugifyDashboardName(requestedSlug || name);
1703
+ const slug = await this.buildUniqueDashboardSlug(baseSlug);
1704
+ const icon =
1705
+ data?.icon === undefined
1706
+ ? template?.icon ?? null
1707
+ : this.toNullableString(data?.icon) ?? template?.icon ?? null;
1708
+
1709
+ const [localeRecord, existingCount] = await Promise.all([
1710
+ this.prismaService.locale.findFirst({
1711
+ where: { code: locale },
1712
+ select: { id: true },
1713
+ }),
1714
+ this.prismaService.dashboard_user.count({
1715
+ where: { user_id: userId },
1716
+ }),
1717
+ ]);
1718
+
1719
+ const dashboard = await this.prismaService.dashboard.create({
1720
+ data: {
1721
+ slug,
1722
+ icon,
1723
+ },
1724
+ });
1725
+
1726
+ if (localeRecord) {
1727
+ await this.prismaService.dashboard_locale.create({
1728
+ data: {
1729
+ dashboard_id: dashboard.id,
1730
+ locale_id: localeRecord.id,
1731
+ name,
1732
+ },
1733
+ });
1734
+ }
1735
+
1736
+ await this.prismaService.dashboard_user.create({
1737
+ data: {
1738
+ dashboard_id: dashboard.id,
1739
+ user_id: userId,
1740
+ is_home: existingCount === 0,
1741
+ },
1742
+ });
1743
+
1744
+ if (template?.dashboard_item?.length) {
1745
+ await this.prismaService.dashboard_item.createMany({
1746
+ data: template.dashboard_item.map((item) => ({
1747
+ dashboard_id: dashboard.id,
1748
+ component_id: item.component_id,
1749
+ width: item.width,
1750
+ height: item.height,
1751
+ x_axis: item.x_axis,
1752
+ y_axis: item.y_axis,
1753
+ })),
1754
+ });
1755
+ }
1756
+
1757
+ return {
1758
+ id: dashboard.id,
1759
+ slug,
1760
+ name,
1761
+ icon,
1762
+ is_home: existingCount === 0,
1763
+ };
1764
+ }
1765
+
1766
+ async renameUserDashboard(
1767
+ userId: number,
1768
+ slug: string,
1769
+ data: { name?: string; icon?: string | null },
1770
+ locale: string,
1771
+ ) {
1772
+ const dashboardUser = await this.getDashboardUserOrThrow(
1773
+ userId,
1774
+ slug,
1775
+ locale,
1776
+ );
1777
+ const name = this.toNullableString(data?.name);
1778
+ const normalizedIcon =
1779
+ data?.icon === undefined ? undefined : this.toNullableString(data?.icon);
1780
+
1781
+ if (!name && data?.icon === undefined) {
1782
+ throw new BadRequestException(
1783
+ getLocaleText(
1784
+ 'validation.fieldRequired',
1785
+ locale,
1786
+ 'Dashboard name or icon is required.',
1787
+ ),
1788
+ );
1789
+ }
1790
+
1791
+ if (data?.icon !== undefined) {
1792
+ await this.prismaService.dashboard.update({
1793
+ where: { id: dashboardUser.dashboard_id },
1794
+ data: {
1795
+ icon: normalizedIcon ?? null,
1796
+ },
1797
+ });
1798
+ }
1799
+
1800
+ if (name) {
1801
+ const localeRecord = await this.prismaService.locale.findFirst({
1802
+ where: { code: locale },
1803
+ select: { id: true },
1804
+ });
1805
+
1806
+ if (!localeRecord) {
1807
+ throw new BadRequestException(
1808
+ getLocaleText('localeNotFound', locale, 'Locale not found.'),
1809
+ );
1810
+ }
1811
+
1812
+ const existingLocale = await this.prismaService.dashboard_locale.findFirst({
1813
+ where: {
1814
+ dashboard_id: dashboardUser.dashboard_id,
1815
+ locale_id: localeRecord.id,
1816
+ },
1817
+ select: { id: true },
1818
+ });
1819
+
1820
+ if (existingLocale) {
1821
+ await this.prismaService.dashboard_locale.update({
1822
+ where: { id: existingLocale.id },
1823
+ data: { name },
1824
+ });
1825
+ } else {
1826
+ await this.prismaService.dashboard_locale.create({
1827
+ data: {
1828
+ dashboard_id: dashboardUser.dashboard_id,
1829
+ locale_id: localeRecord.id,
1830
+ name,
1831
+ },
1832
+ });
1833
+ }
1834
+ }
1835
+
1836
+ const updatedDashboard = await this.prismaService.dashboard.findUnique({
1837
+ where: { id: dashboardUser.dashboard_id },
1838
+ include: {
1839
+ dashboard_locale: {
1840
+ where: {
1841
+ locale: {
1842
+ code: locale,
1843
+ },
1844
+ },
1845
+ },
1846
+ },
1847
+ });
1848
+
1849
+ return {
1850
+ success: true,
1851
+ slug,
1852
+ name:
1853
+ name ||
1854
+ (updatedDashboard
1855
+ ? this.getDashboardDisplayName(updatedDashboard)
1856
+ : slug),
1857
+ icon: updatedDashboard?.icon ?? normalizedIcon ?? null,
1858
+ };
1859
+ }
1860
+
1861
+ async setHomeDashboard(userId: number, slug: string, locale: string) {
1862
+ const dashboardUser = await this.getDashboardUserOrThrow(userId, slug, locale);
1863
+
1864
+ await this.prismaService.dashboard_user.updateMany({
1865
+ where: { user_id: userId },
1866
+ data: { is_home: false },
1867
+ });
1868
+
1869
+ await this.prismaService.dashboard_user.update({
1870
+ where: { id: dashboardUser.id },
1871
+ data: { is_home: true },
1872
+ });
1873
+
1874
+ return {
1875
+ success: true,
1876
+ slug,
1877
+ };
1878
+ }
1879
+
1880
+ async getDashboardShares(userId: number, slug: string, locale: string) {
1881
+ const dashboardUser = await this.getDashboardUserOrThrow(userId, slug, locale);
1882
+ const dashboardRoleRequirements = await this.getDashboardComponentRoleRequirements(
1883
+ dashboardUser.dashboard_id,
1884
+ );
1885
+
1886
+ const sharedUsers = await this.prismaService.dashboard_user.findMany({
1887
+ where: {
1888
+ dashboard_id: dashboardUser.dashboard_id,
1889
+ },
1890
+ include: {
1891
+ user: {
1892
+ select: {
1893
+ id: true,
1894
+ name: true,
1895
+ role_user: {
1896
+ select: {
1897
+ role_id: true,
1898
+ },
1899
+ },
1900
+ user_identifier: {
1901
+ where: {
1902
+ type: 'email',
1903
+ },
1904
+ select: {
1905
+ value: true,
1906
+ },
1907
+ take: 1,
1908
+ },
1909
+ },
1910
+ },
1911
+ },
1912
+ orderBy: {
1913
+ id: 'asc',
1914
+ },
1915
+ });
1916
+
1917
+ return sharedUsers.map((sharedDashboardUser) => {
1918
+ const userRoleIds = sharedDashboardUser.user.role_user.map(
1919
+ (roleUser) => roleUser.role_id,
1920
+ );
1921
+ const hasRequiredRoles = this.userHasRequiredRolesForDashboard(
1922
+ dashboardRoleRequirements,
1923
+ userRoleIds,
1924
+ );
1925
+
1926
+ return {
1927
+ id: sharedDashboardUser.user.id,
1928
+ name: sharedDashboardUser.user.name,
1929
+ email: sharedDashboardUser.user.user_identifier[0]?.value ?? null,
1930
+ isCurrentUser: sharedDashboardUser.user.id === userId,
1931
+ isHome: sharedDashboardUser.is_home,
1932
+ hasRequiredRoles,
1933
+ accessStatus: hasRequiredRoles ? 'allowed' : 'missing-roles',
1934
+ };
1935
+ });
1936
+ }
1937
+
1938
+ async getShareableUsers(
1939
+ userId: number,
1940
+ slug: string,
1941
+ options:
1942
+ | {
1943
+ search?: string;
1944
+ page?: string | number;
1945
+ pageSize?: string | number;
1946
+ }
1947
+ | undefined,
1948
+ locale: string,
1949
+ ) {
1950
+ const dashboardUser = await this.getDashboardUserOrThrow(userId, slug, locale);
1951
+ const normalizedSearch = this.toNullableString(options?.search);
1952
+ const requestedPage = Number.parseInt(String(options?.page ?? '1'), 10);
1953
+ const requestedPageSize = Number.parseInt(String(options?.pageSize ?? '10'), 10);
1954
+ const page = Number.isFinite(requestedPage) && requestedPage > 0 ? requestedPage : 1;
1955
+ const pageSize = Number.isFinite(requestedPageSize) && requestedPageSize > 0
1956
+ ? Math.min(requestedPageSize, 50)
1957
+ : 10;
1958
+
1959
+ const [existingUsers, dashboardRoleRequirements] = await Promise.all([
1960
+ this.prismaService.dashboard_user.findMany({
1961
+ where: {
1962
+ dashboard_id: dashboardUser.dashboard_id,
1963
+ },
1964
+ select: {
1965
+ user_id: true,
1966
+ },
1967
+ }),
1968
+ this.getDashboardComponentRoleRequirements(dashboardUser.dashboard_id),
1969
+ ]);
1970
+
1971
+ const where: any = {
1972
+ id: {
1973
+ notIn: existingUsers.map((item) => item.user_id),
1974
+ },
1975
+ ...(normalizedSearch
1976
+ ? {
1977
+ OR: [
1978
+ {
1979
+ name: {
1980
+ contains: normalizedSearch,
1981
+ mode: 'insensitive' as const,
1982
+ },
1983
+ },
1984
+ {
1985
+ user_identifier: {
1986
+ some: {
1987
+ type: 'email',
1988
+ value: {
1989
+ contains: normalizedSearch,
1990
+ mode: 'insensitive' as const,
1991
+ },
1992
+ },
1993
+ },
1994
+ },
1995
+ ],
1996
+ }
1997
+ : {}),
1998
+ };
1999
+
2000
+ const total = await this.prismaService.user.count({ where });
2001
+ const lastPage = total > 0 ? Math.ceil(total / pageSize) : 1;
2002
+ const safePage = Math.min(page, lastPage);
2003
+
2004
+ const users = await this.prismaService.user.findMany({
2005
+ where,
2006
+ select: {
2007
+ id: true,
2008
+ name: true,
2009
+ role_user: {
2010
+ select: {
2011
+ role_id: true,
2012
+ },
2013
+ },
2014
+ user_identifier: {
2015
+ where: {
2016
+ type: 'email',
2017
+ },
2018
+ select: {
2019
+ value: true,
2020
+ },
2021
+ take: 1,
2022
+ },
2023
+ },
2024
+ skip: (safePage - 1) * pageSize,
2025
+ take: pageSize,
2026
+ orderBy: [{ name: 'asc' }, { id: 'asc' }],
2027
+ });
2028
+
2029
+ return {
2030
+ data: users.map((candidateUser) => {
2031
+ const userRoleIds = candidateUser.role_user.map(
2032
+ (roleUser) => roleUser.role_id,
2033
+ );
2034
+ const hasRequiredRoles = this.userHasRequiredRolesForDashboard(
2035
+ dashboardRoleRequirements,
2036
+ userRoleIds,
2037
+ );
2038
+
2039
+ return {
2040
+ id: candidateUser.id,
2041
+ name: candidateUser.name,
2042
+ email: candidateUser.user_identifier[0]?.value ?? null,
2043
+ hasRequiredRoles,
2044
+ accessStatus: hasRequiredRoles ? 'allowed' : 'missing-roles',
2045
+ };
2046
+ }),
2047
+ total,
2048
+ page: safePage,
2049
+ pageSize,
2050
+ lastPage,
2051
+ prev: safePage > 1 ? safePage - 1 : null,
2052
+ next: safePage < lastPage ? safePage + 1 : null,
2053
+ };
2054
+ }
2055
+
2056
+ async shareDashboard(
2057
+ userId: number,
2058
+ slug: string,
2059
+ sharedUserIds: number[] | undefined,
2060
+ sharedUserId: number | undefined,
2061
+ locale: string,
2062
+ ) {
2063
+ const requestedIds = Array.from(
2064
+ new Set(
2065
+ [
2066
+ ...(Array.isArray(sharedUserIds) ? sharedUserIds : []),
2067
+ ...(sharedUserId !== undefined ? [sharedUserId] : []),
2068
+ ]
2069
+ .map((value) => Number(value))
2070
+ .filter((value) => Number.isInteger(value) && value > 0),
2071
+ ),
2072
+ );
2073
+
2074
+ if (requestedIds.length === 0) {
2075
+ throw new BadRequestException(
2076
+ getLocaleText('validation.fieldRequired', locale, 'User is required.'),
2077
+ );
2078
+ }
2079
+
2080
+ const dashboardUser = await this.getDashboardUserOrThrow(userId, slug, locale);
2081
+ const sanitizedUserIds = requestedIds.filter((candidateUserId) => candidateUserId !== userId);
2082
+
2083
+ if (sanitizedUserIds.length === 0) {
2084
+ throw new BadRequestException(
2085
+ getLocaleText(
2086
+ 'validation.invalidValue',
2087
+ locale,
2088
+ 'You already have access to this dashboard.',
2089
+ ),
2090
+ );
2091
+ }
2092
+
2093
+ const targetUsers = await this.prismaService.user.findMany({
2094
+ where: {
2095
+ id: {
2096
+ in: sanitizedUserIds,
2097
+ },
2098
+ },
2099
+ select: {
2100
+ id: true,
2101
+ },
2102
+ });
2103
+
2104
+ if (targetUsers.length !== sanitizedUserIds.length) {
2105
+ throw new BadRequestException(
2106
+ getLocaleText('userNotFound', locale, 'User not found.'),
2107
+ );
2108
+ }
2109
+
2110
+ const existingShares = await this.prismaService.dashboard_user.findMany({
2111
+ where: {
2112
+ dashboard_id: dashboardUser.dashboard_id,
2113
+ user_id: {
2114
+ in: sanitizedUserIds,
2115
+ },
2116
+ },
2117
+ select: {
2118
+ user_id: true,
2119
+ },
2120
+ });
2121
+
2122
+ const alreadySharedUserIds = existingShares.map((item) => item.user_id);
2123
+ const alreadySharedSet = new Set(alreadySharedUserIds);
2124
+ const newUserIds = sanitizedUserIds.filter(
2125
+ (candidateUserId) => !alreadySharedSet.has(candidateUserId),
2126
+ );
2127
+
2128
+ if (newUserIds.length > 0) {
2129
+ await this.prismaService.dashboard_user.createMany({
2130
+ data: newUserIds.map((candidateUserId) => ({
2131
+ dashboard_id: dashboardUser.dashboard_id,
2132
+ user_id: candidateUserId,
2133
+ is_home: false,
2134
+ })),
2135
+ });
2136
+ }
2137
+
2138
+ return {
2139
+ success: true,
2140
+ sharedCount: newUserIds.length,
2141
+ sharedUserIds: newUserIds,
2142
+ alreadySharedUserIds,
2143
+ };
2144
+ }
2145
+
2146
+ async revokeDashboardShare(
2147
+ userId: number,
2148
+ slug: string,
2149
+ sharedUserId: number,
2150
+ locale: string,
2151
+ ) {
2152
+ const dashboardUser = await this.getDashboardUserOrThrow(userId, slug, locale);
2153
+
2154
+ if (sharedUserId === userId) {
2155
+ throw new BadRequestException(
2156
+ getLocaleText(
2157
+ 'validation.invalidValue',
2158
+ locale,
2159
+ 'Use the remove dashboard action to leave this tab.',
2160
+ ),
2161
+ );
2162
+ }
2163
+
2164
+ await this.prismaService.dashboard_user.deleteMany({
2165
+ where: {
2166
+ dashboard_id: dashboardUser.dashboard_id,
2167
+ user_id: sharedUserId,
2168
+ },
2169
+ });
2170
+
2171
+ return {
2172
+ success: true,
2173
+ };
2174
+ }
2175
+
2176
+ async removeUserDashboard(userId: number, slug: string, locale: string) {
2177
+ const dashboardUser = await this.getDashboardUserOrThrow(userId, slug, locale);
2178
+
2179
+ await this.prismaService.dashboard_user.delete({
2180
+ where: { id: dashboardUser.id },
2181
+ });
2182
+
2183
+ if (dashboardUser.is_home) {
2184
+ const nextDashboard = await this.prismaService.dashboard_user.findFirst({
2185
+ where: { user_id: userId },
2186
+ orderBy: { id: 'asc' },
2187
+ });
2188
+
2189
+ if (nextDashboard) {
2190
+ await this.prismaService.dashboard_user.update({
2191
+ where: { id: nextDashboard.id },
2192
+ data: { is_home: true },
2193
+ });
2194
+ }
2195
+ }
2196
+
2197
+ const remainingShares = await this.prismaService.dashboard_user.count({
2198
+ where: {
2199
+ dashboard_id: dashboardUser.dashboard_id,
2200
+ },
2201
+ });
2202
+
2203
+ if (remainingShares === 0) {
2204
+ await this.prismaService.dashboard.delete({
2205
+ where: { id: dashboardUser.dashboard_id },
2206
+ });
2207
+ }
2208
+
2209
+ return {
2210
+ success: true,
2211
+ removedSlug: slug,
2212
+ };
2213
+ }
2214
+
1359
2215
  async getAccountSecurity(userId: number) {
1360
2216
  const now = new Date();
1361
2217
  const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);