@hed-hog/core 0.0.367 → 0.0.368

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 (39) hide show
  1. package/dist/file/file.service.d.ts +1 -0
  2. package/dist/file/file.service.d.ts.map +1 -1
  3. package/dist/file/file.service.js +1 -1
  4. package/dist/file/file.service.js.map +1 -1
  5. package/dist/notification/events/notification-stale-progress-check.event.d.ts +6 -0
  6. package/dist/notification/events/notification-stale-progress-check.event.d.ts.map +1 -0
  7. package/dist/notification/events/notification-stale-progress-check.event.js +11 -0
  8. package/dist/notification/events/notification-stale-progress-check.event.js.map +1 -0
  9. package/dist/notification/notification.service.d.ts +4 -1
  10. package/dist/notification/notification.service.d.ts.map +1 -1
  11. package/dist/notification/notification.service.js +9 -26
  12. package/dist/notification/notification.service.js.map +1 -1
  13. package/dist/task/task.service.d.ts +19 -0
  14. package/dist/task/task.service.d.ts.map +1 -1
  15. package/dist/task/task.service.js +46 -0
  16. package/dist/task/task.service.js.map +1 -1
  17. package/dist/user/user.controller.d.ts +23 -5
  18. package/dist/user/user.controller.d.ts.map +1 -1
  19. package/dist/user/user.controller.js +32 -15
  20. package/dist/user/user.controller.js.map +1 -1
  21. package/dist/user/user.service.d.ts +28 -8
  22. package/dist/user/user.service.d.ts.map +1 -1
  23. package/dist/user/user.service.js +120 -8
  24. package/dist/user/user.service.js.map +1 -1
  25. package/hedhog/data/route.yaml +9 -0
  26. package/hedhog/data/setting_group.yaml +14 -0
  27. package/hedhog/frontend/app/roles/page.tsx.ejs +4 -1
  28. package/hedhog/frontend/app/users/user-logs.tsx.ejs +190 -0
  29. package/hedhog/frontend/app/users/user-sheet.tsx.ejs +15 -1
  30. package/hedhog/frontend/messages/en.json +13 -0
  31. package/hedhog/frontend/messages/pt.json +13 -0
  32. package/hedhog/table/user_change_log.yaml +40 -0
  33. package/package.json +5 -5
  34. package/src/file/file.service.ts +2 -1
  35. package/src/notification/events/notification-stale-progress-check.event.ts +5 -0
  36. package/src/notification/notification.service.ts +12 -25
  37. package/src/task/task.service.ts +52 -0
  38. package/src/user/user.controller.ts +23 -9
  39. package/src/user/user.service.ts +183 -8
@@ -0,0 +1,190 @@
1
+ 'use client';
2
+
3
+ import { EmptyState } from '@/components/entity-list';
4
+ import { formatDateTime } from '@/lib/format-date';
5
+ import { User } from '@hed-hog/api-types';
6
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
7
+ import {
8
+ Image as ImageIcon,
9
+ KeyRound,
10
+ Loader2,
11
+ Pencil,
12
+ ScrollText,
13
+ ShieldMinus,
14
+ ShieldPlus,
15
+ } from 'lucide-react';
16
+ import { useTranslations } from 'next-intl';
17
+
18
+ type UserChangeLog = {
19
+ id: string;
20
+ actorUserId: string | null;
21
+ actorName: string | null;
22
+ actorEmail: string | null;
23
+ action: string;
24
+ summary: string;
25
+ beforeData: string;
26
+ afterData: string;
27
+ ipAddress: string;
28
+ createdAt: string | null;
29
+ };
30
+
31
+ function getActionIcon(action: string) {
32
+ switch (action) {
33
+ case 'update':
34
+ return Pencil;
35
+ case 'reset_password':
36
+ return KeyRound;
37
+ case 'role_assigned':
38
+ return ShieldPlus;
39
+ case 'role_removed':
40
+ return ShieldMinus;
41
+ case 'avatar_changed':
42
+ return ImageIcon;
43
+ default:
44
+ return ScrollText;
45
+ }
46
+ }
47
+
48
+ function safeParse(raw: string): Record<string, unknown> | null {
49
+ if (!raw) return null;
50
+ try {
51
+ const parsed = JSON.parse(raw);
52
+ return parsed && typeof parsed === 'object' ? parsed : null;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ function formatValue(value: unknown) {
59
+ if (value === null || value === undefined || value === '') return '—';
60
+ return String(value);
61
+ }
62
+
63
+ function DiffList({ before, after }: { before: string; after: string }) {
64
+ const beforeObj = safeParse(before);
65
+ const afterObj = safeParse(after);
66
+
67
+ if (!beforeObj && !afterObj) return null;
68
+
69
+ const keys = Array.from(
70
+ new Set([
71
+ ...Object.keys(beforeObj ?? {}),
72
+ ...Object.keys(afterObj ?? {}),
73
+ ]),
74
+ );
75
+
76
+ if (!keys.length) return null;
77
+
78
+ return (
79
+ <div className="mt-2 space-y-1 rounded-md bg-muted/40 p-2">
80
+ {keys.map((key) => (
81
+ <div key={key} className="flex flex-wrap items-center gap-1 text-[11px]">
82
+ <span className="font-medium text-muted-foreground">{key}:</span>
83
+ {beforeObj && key in beforeObj && (
84
+ <span className="rounded bg-rose-50 px-1 py-0.5 text-rose-700 line-through">
85
+ {formatValue(beforeObj[key])}
86
+ </span>
87
+ )}
88
+ {afterObj && key in afterObj && (
89
+ <span className="rounded bg-emerald-50 px-1 py-0.5 text-emerald-700">
90
+ {formatValue(afterObj[key])}
91
+ </span>
92
+ )}
93
+ </div>
94
+ ))}
95
+ </div>
96
+ );
97
+ }
98
+
99
+ export function UserLogs({ editingUser }: { editingUser: User }) {
100
+ const { request, currentLocaleCode, getSettingValue } = useApp();
101
+ const t = useTranslations('core.UserChangeLog');
102
+ const userId = editingUser?.id;
103
+
104
+ const { data: logs = [], isLoading } = useQuery<UserChangeLog[]>({
105
+ queryKey: ['user-change-logs', userId, currentLocaleCode],
106
+ queryFn: async () => {
107
+ const response = await request<{ data: UserChangeLog[] }>({
108
+ url: `/user/${userId}/log`,
109
+ method: 'GET',
110
+ });
111
+ return response.data?.data || [];
112
+ },
113
+ enabled: !!userId,
114
+ });
115
+
116
+ const knownActions = [
117
+ 'update',
118
+ 'reset_password',
119
+ 'role_assigned',
120
+ 'role_removed',
121
+ 'avatar_changed',
122
+ ];
123
+ const getActionLabel = (action: string) =>
124
+ knownActions.includes(action) ? t(action as never) : action;
125
+
126
+ if (isLoading) {
127
+ return (
128
+ <div className="flex items-center justify-center py-8 text-muted-foreground">
129
+ <Loader2 className="h-5 w-5 animate-spin" />
130
+ </div>
131
+ );
132
+ }
133
+
134
+ return (
135
+ <div className="space-y-3">
136
+ <h4 className="text-sm font-medium">{t('title')}</h4>
137
+
138
+ <div className="space-y-2">
139
+ {logs.length ? (
140
+ logs.map((log) => {
141
+ const Icon = getActionIcon(log.action);
142
+ const actor =
143
+ log.actorName || log.actorEmail || t('systemActor');
144
+
145
+ return (
146
+ <div key={log.id} className="rounded-lg border p-3">
147
+ <div className="flex items-start gap-3">
148
+ <div className="rounded-md bg-slate-50 p-2">
149
+ <Icon className="h-5 w-5 text-muted-foreground" />
150
+ </div>
151
+ <div className="flex-1">
152
+ <div className="flex flex-wrap items-center justify-between gap-2">
153
+ <p className="text-sm font-medium">
154
+ {getActionLabel(log.action)}
155
+ </p>
156
+ {log.createdAt && (
157
+ <span className="text-[11px] text-muted-foreground">
158
+ {formatDateTime(
159
+ log.createdAt,
160
+ getSettingValue,
161
+ currentLocaleCode,
162
+ )}
163
+ </span>
164
+ )}
165
+ </div>
166
+ <p className="text-xs text-muted-foreground">
167
+ {t('byActor')} {actor}
168
+ </p>
169
+ {log.summary && (
170
+ <p className="mt-1 text-xs text-muted-foreground">
171
+ {log.summary}
172
+ </p>
173
+ )}
174
+ <DiffList before={log.beforeData} after={log.afterData} />
175
+ </div>
176
+ </div>
177
+ </div>
178
+ );
179
+ })
180
+ ) : (
181
+ <EmptyState
182
+ icon={<ScrollText className="h-6 w-6" />}
183
+ title={t('emptyTitle')}
184
+ description={t('emptyDescription')}
185
+ />
186
+ )}
187
+ </div>
188
+ </div>
189
+ );
190
+ }
@@ -66,6 +66,7 @@ import {
66
66
  LucideIcon,
67
67
  Mail,
68
68
  Monitor,
69
+ ScrollText,
69
70
  RefreshCcw,
70
71
  Save,
71
72
  Shield,
@@ -84,6 +85,7 @@ import { z } from 'zod';
84
85
  import { ActiveSessions } from './active-session';
85
86
  import { UserIdentifiersSection } from './identifiers';
86
87
  import { PermissionsSection } from './permissions';
88
+ import { UserLogs } from './user-logs';
87
89
 
88
90
  export type UserSheetProps = {
89
91
  open: boolean;
@@ -765,6 +767,10 @@ function UserEditSheet({
765
767
  <Monitor className="h-4 w-4" />
766
768
  <span>{t('tabSessions')}</span>
767
769
  </TabsTrigger>
770
+ <TabsTrigger value="logs">
771
+ <ScrollText className="h-4 w-4" />
772
+ <span>{t('tabLogs')}</span>
773
+ </TabsTrigger>
768
774
  </TabsList>
769
775
  </div>
770
776
 
@@ -1110,7 +1116,10 @@ function UserEditSheet({
1110
1116
  <FormActions
1111
1117
  statusContent={editDraftStatusContent}
1112
1118
  cancelLabel={t('cancel')}
1113
- onCancel={() => onOpenChange(false)}
1119
+ onCancel={() => {
1120
+ clearEditDraft();
1121
+ onOpenChange(false);
1122
+ }}
1114
1123
  submitLabel={t('saveChanges')}
1115
1124
  submitType="submit"
1116
1125
  className="pt-2"
@@ -1478,6 +1487,11 @@ function UserEditSheet({
1478
1487
  refetch={handleRefreshUser}
1479
1488
  />
1480
1489
  </TabsContent>
1490
+
1491
+ {/* Logs Tab */}
1492
+ <TabsContent value="logs" className="space-y-4 mt-4 p-4 pt-0">
1493
+ <UserLogs editingUser={editingUser} />
1494
+ </TabsContent>
1481
1495
  </Tabs>
1482
1496
  </ResizableSheetContent>
1483
1497
  </Sheet>
@@ -244,6 +244,7 @@
244
244
  "tabIdentifiers": "Identifiers",
245
245
  "tabMfa": "MFA",
246
246
  "tabSessions": "Sessions",
247
+ "tabLogs": "Logs",
247
248
  "cardLastLoginDescription": "Last Login",
248
249
  "cardLastLoginTitle": "Never",
249
250
  "securityTitle": "Security Overview",
@@ -944,6 +945,18 @@
944
945
  "revokeAllSuccess": "Sessions revoked successfully!",
945
946
  "revokeAllFailure": "Failed to revoke sessions"
946
947
  },
948
+ "UserChangeLog": {
949
+ "title": "Change History",
950
+ "byActor": "by",
951
+ "systemActor": "System",
952
+ "update": "Profile updated",
953
+ "reset_password": "Password reset",
954
+ "role_assigned": "Role assigned",
955
+ "role_removed": "Role removed",
956
+ "avatar_changed": "Avatar changed",
957
+ "emptyTitle": "No changes recorded",
958
+ "emptyDescription": "Administrative changes made to this user will appear here."
959
+ },
947
960
  "UserActiveSessions": {
948
961
  "title": "Active Sessions",
949
962
  "revokeAll": "Revoke All",
@@ -244,6 +244,7 @@
244
244
  "tabIdentifiers": "Identificadores",
245
245
  "tabMfa": "MFA",
246
246
  "tabSessions": "Sessões",
247
+ "tabLogs": "Logs",
247
248
  "cardLastLoginDescription": "Último login",
248
249
  "cardLastLoginTitle": "Nunca",
249
250
  "securityTitle": "Visão Geral de Segurança",
@@ -944,6 +945,18 @@
944
945
  "revokeAllSuccess": "Sessões revogadas com sucesso!",
945
946
  "revokeAllFailure": "Falha ao revogar sessões"
946
947
  },
948
+ "UserChangeLog": {
949
+ "title": "Histórico de Alterações",
950
+ "byActor": "por",
951
+ "systemActor": "Sistema",
952
+ "update": "Perfil atualizado",
953
+ "reset_password": "Senha redefinida",
954
+ "role_assigned": "Papel atribuído",
955
+ "role_removed": "Papel removido",
956
+ "avatar_changed": "Avatar alterado",
957
+ "emptyTitle": "Nenhuma alteração registrada",
958
+ "emptyDescription": "As alterações administrativas feitas neste usuário aparecerão aqui."
959
+ },
947
960
  "UserActiveSessions": {
948
961
  "title": "Sessões Ativas",
949
962
  "revokeAll": "Revogar Todas",
@@ -0,0 +1,40 @@
1
+ columns:
2
+ - type: pk
3
+ - name: target_user_id
4
+ type: fk
5
+ references:
6
+ table: user
7
+ column: id
8
+ onDelete: CASCADE
9
+ onUpdate: CASCADE
10
+ - name: actor_user_id
11
+ type: fk
12
+ isNullable: true
13
+ references:
14
+ table: user
15
+ column: id
16
+ onDelete: SET NULL
17
+ onUpdate: CASCADE
18
+ - name: action
19
+ type: varchar
20
+ length: 80
21
+ - name: summary
22
+ type: text
23
+ isNullable: true
24
+ - name: before_data
25
+ type: text
26
+ isNullable: true
27
+ - name: after_data
28
+ type: text
29
+ isNullable: true
30
+ - name: ip_address
31
+ type: varchar
32
+ length: 64
33
+ isNullable: true
34
+ - type: created_at
35
+ - type: updated_at
36
+
37
+ indices:
38
+ - columns: [target_user_id]
39
+ - columns: [actor_user_id]
40
+ - columns: [action]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hed-hog/core",
3
- "version": "0.0.367",
3
+ "version": "0.0.368",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "dependencies": {
@@ -38,12 +38,12 @@
38
38
  "speakeasy": "^2.0.0",
39
39
  "ua-parser-js": "^2.0.10",
40
40
  "uuid": "^11.1.0",
41
- "@hed-hog/api-locale": "0.0.14",
42
41
  "@hed-hog/api": "0.0.8",
43
- "@hed-hog/api-prisma": "0.0.6",
44
- "@hed-hog/api-mail": "0.0.9",
42
+ "@hed-hog/api-locale": "0.0.14",
45
43
  "@hed-hog/api-pagination": "0.0.7",
46
- "@hed-hog/api-types": "0.0.1"
44
+ "@hed-hog/api-mail": "0.0.9",
45
+ "@hed-hog/api-types": "0.0.1",
46
+ "@hed-hog/api-prisma": "0.0.6"
47
47
  },
48
48
  "exports": {
49
49
  ".": {
@@ -490,6 +490,7 @@ export class FileService implements OnModuleInit {
490
490
  mimetype?: string;
491
491
  size?: number;
492
492
  encoding?: string;
493
+ skipMimeValidation?: boolean;
493
494
  },
494
495
  ) {
495
496
  if (!filePath) {
@@ -511,7 +512,7 @@ export class FileService implements OnModuleInit {
511
512
  metadata?.originalname ?? metadata?.filename ?? basename(filePath),
512
513
  );
513
514
 
514
- if (!(await this.acceptMimetypes(mimetype))) {
515
+ if (!metadata?.skipMimeValidation && !(await this.acceptMimetypes(mimetype))) {
515
516
  throw new BadRequestException(`Invalid file type: ${mimetype}`);
516
517
  }
517
518
 
@@ -0,0 +1,5 @@
1
+ export const NOTIFICATION_STALE_PROGRESS_CHECK = 'notification.stale-progress-check';
2
+
3
+ export class NotificationStaleProgressCheckEvent {
4
+ constructor(public readonly userId: number) {}
5
+ }
@@ -11,12 +11,17 @@ import {
11
11
  OnModuleDestroy,
12
12
  OnModuleInit,
13
13
  } from '@nestjs/common';
14
+ import { EventEmitter2 } from '@nestjs/event-emitter';
14
15
  import type { Request, Response } from 'express';
15
16
  import { randomUUID } from 'node:crypto';
16
17
  import { EventEmitter } from 'node:events';
17
18
  import { Client, type Notification as PgNotification } from 'pg';
18
19
  import { CreateNotificationDto } from './dto/create-notification.dto';
19
20
  import { UpdateProgressDto } from './dto/update-progress.dto';
21
+ import {
22
+ NOTIFICATION_STALE_PROGRESS_CHECK,
23
+ NotificationStaleProgressCheckEvent,
24
+ } from './events/notification-stale-progress-check.event';
20
25
 
21
26
  type NotificationStreamEventType =
22
27
  | 'ready'
@@ -47,6 +52,7 @@ export class NotificationService implements OnModuleInit, OnModuleDestroy {
47
52
  private readonly prisma: PrismaService,
48
53
  @Inject(forwardRef(() => PaginationService))
49
54
  private readonly pagination: PaginationService,
55
+ private readonly eventEmitter: EventEmitter2,
50
56
  ) {
51
57
  this.notificationEvents.setMaxListeners(0);
52
58
  }
@@ -106,32 +112,13 @@ export class NotificationService implements OnModuleInit, OnModuleDestroy {
106
112
  }
107
113
 
108
114
  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
- });
115
+ await this.eventEmitter.emitAsync(
116
+ NOTIFICATION_STALE_PROGRESS_CHECK,
117
+ new NotificationStaleProgressCheckEvent(userId),
118
+ );
119
+ }
134
120
 
121
+ async notifyUpdated(userId: number) {
135
122
  await this.broadcastNotificationEvent(userId, { type: 'notification.updated' });
136
123
  }
137
124
 
@@ -42,6 +42,9 @@ export class TasksService {
42
42
  await this.clearOldNotifications();
43
43
  this.logger.verbose('++++++++++++++++++++++++++++++++++');
44
44
 
45
+ await this.clearStaleAsyncNotifications();
46
+ this.logger.verbose('++++++++++++++++++++++++++++++++++');
47
+
45
48
  await this.clearOldAccessLogs();
46
49
  this.logger.verbose('++++++++++++++++++++++++++++++++++');
47
50
 
@@ -322,4 +325,53 @@ export class TasksService {
322
325
  `Old notifications cleaned up in ${Date.now() - startAt}ms`,
323
326
  );
324
327
  }
328
+
329
+ /**
330
+ * Remove notificações assíncronas que ficaram "travadas" em andamento por tempo demais.
331
+ *
332
+ * Uma notificação assíncrona está em andamento quando possui acompanhamento de progresso
333
+ * (progress IS NOT NULL) que ainda não chegou ao fim (progress < 100) e que nunca foi
334
+ * finalizada (finished_at IS NULL). Se o job que a alimenta morre ou trava, a notificação
335
+ * permanece eternamente "rodando".
336
+ *
337
+ * Remove registros da tabela notification que:
338
+ * - Estão em andamento (progress IS NOT NULL AND progress < 100 AND finished_at IS NULL)
339
+ * - Começaram a rodar há mais horas do que o valor de stale-async-notification-hours,
340
+ * medido por COALESCE(started_at, created_at)
341
+ *
342
+ * Por quê: notificações assíncronas presas poluem a interface (barra de progresso eterna)
343
+ * e o banco. A remoção periódica baseada em tempo as descarta de forma previsível.
344
+ *
345
+ * Limite: cleanup-batch-limit registros por execução para evitar lock prolongado da tabela.
346
+ */
347
+ private async clearStaleAsyncNotifications(): Promise<void> {
348
+ const settings = await this.settingService.getSettingValues([
349
+ 'cleanup-batch-limit',
350
+ 'stale-async-notification-hours',
351
+ ]);
352
+ const batchLimit = Number(settings['cleanup-batch-limit']) || 20000;
353
+ const staleHours = Number(settings['stale-async-notification-hours']) || 24;
354
+ this.logger.verbose('Starting cleanup of stale async notifications');
355
+ const startAt = Date.now();
356
+
357
+ await this.prismaService.$executeRaw`
358
+ WITH del AS (
359
+ SELECT id
360
+ FROM notification
361
+ WHERE progress IS NOT NULL
362
+ AND progress < 100
363
+ AND finished_at IS NULL
364
+ AND COALESCE(started_at, created_at) < (now() - ${Prisma.sql`INTERVAL '${Prisma.raw(String(staleHours))} hours'`})
365
+ ORDER BY id
366
+ LIMIT ${batchLimit}
367
+ )
368
+ DELETE FROM notification n
369
+ USING del
370
+ WHERE n.id = del.id
371
+ `;
372
+
373
+ this.logger.verbose(
374
+ `Stale async notifications cleaned up in ${Date.now() - startAt}ms`,
375
+ );
376
+ }
325
377
  }
@@ -1,4 +1,4 @@
1
- import { Public, Role } from '@hed-hog/api';
1
+ import { Public, Role, User } from '@hed-hog/api';
2
2
  import { Locale } from '@hed-hog/api-locale';
3
3
  import { Pagination } from '@hed-hog/api-pagination';
4
4
  import {
@@ -64,9 +64,10 @@ export class UserController {
64
64
  async update(
65
65
  @Param('userId', ParseIntPipe) userId: number,
66
66
  @Body() data: UpdateDTO,
67
- @Locale() locale: string
67
+ @Locale() locale: string,
68
+ @User() actor,
68
69
  ) {
69
- return this.userService.update(locale, userId, data);
70
+ return this.userService.update(locale, userId, data, actor?.id);
70
71
  }
71
72
 
72
73
  @Patch(':userId/reset-password')
@@ -74,8 +75,9 @@ export class UserController {
74
75
  @Param('userId', ParseIntPipe) userId: number,
75
76
  @Body() data: ResetPasswordDTO,
76
77
  @Locale() locale: string,
78
+ @User() actor,
77
79
  ) {
78
- return this.userService.resetPassword(locale, userId, data);
80
+ return this.userService.resetPassword(locale, userId, data, actor?.id);
79
81
  }
80
82
 
81
83
  @UseInterceptors(
@@ -93,8 +95,9 @@ export class UserController {
93
95
  @Locale() locale: string,
94
96
  @Param('userId', ParseIntPipe) userId: number,
95
97
  @UploadedFile() avatar: MulterFile,
98
+ @User() actor,
96
99
  ) {
97
- return this.userService.changeAvatar(locale, userId, avatar)
100
+ return this.userService.changeAvatar(locale, userId, avatar, actor?.id)
98
101
  }
99
102
 
100
103
  @Post(':userId/verify-identifier/:identifierId')
@@ -132,21 +135,32 @@ export class UserController {
132
135
  return this.userService.getUserRoutes(locale, userId);
133
136
  }
134
137
 
138
+ @Get(':userId/log')
139
+ async listLogs(
140
+ @Param('userId', ParseIntPipe) userId: number,
141
+ @Pagination() paginationParams,
142
+ @Locale() locale: string,
143
+ ) {
144
+ return this.userService.listUserChangeLogs(locale, userId, paginationParams);
145
+ }
146
+
135
147
  @Post(':userId/role/:roleId')
136
148
  async assignRoleToUser(
137
149
  @Param('userId', ParseIntPipe) userId: number,
138
150
  @Param('roleId', ParseIntPipe) roleId: number,
139
- @Locale() locale: string
151
+ @Locale() locale: string,
152
+ @User() actor,
140
153
  ) {
141
- return this.userService.assignRoleToUser(locale, userId, roleId);
154
+ return this.userService.assignRoleToUser(locale, userId, roleId, actor?.id);
142
155
  }
143
156
 
144
157
  @Delete(':userId/role/:roleId')
145
158
  async removeRoleFromUser(
146
159
  @Param('userId', ParseIntPipe) userId: number,
147
160
  @Param('roleId', ParseIntPipe) roleId: number,
148
- @Locale() locale: string
161
+ @Locale() locale: string,
162
+ @User() actor,
149
163
  ) {
150
- return this.userService.removeRoleFromUser(locale, userId, roleId);
164
+ return this.userService.removeRoleFromUser(locale, userId, roleId, actor?.id);
151
165
  }
152
166
  }