@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.
- package/dist/file/file.service.d.ts +1 -0
- package/dist/file/file.service.d.ts.map +1 -1
- package/dist/file/file.service.js +1 -1
- package/dist/file/file.service.js.map +1 -1
- package/dist/notification/events/notification-stale-progress-check.event.d.ts +6 -0
- package/dist/notification/events/notification-stale-progress-check.event.d.ts.map +1 -0
- package/dist/notification/events/notification-stale-progress-check.event.js +11 -0
- package/dist/notification/events/notification-stale-progress-check.event.js.map +1 -0
- package/dist/notification/notification.service.d.ts +4 -1
- package/dist/notification/notification.service.d.ts.map +1 -1
- package/dist/notification/notification.service.js +9 -26
- package/dist/notification/notification.service.js.map +1 -1
- package/dist/task/task.service.d.ts +19 -0
- package/dist/task/task.service.d.ts.map +1 -1
- package/dist/task/task.service.js +46 -0
- package/dist/task/task.service.js.map +1 -1
- package/dist/user/user.controller.d.ts +23 -5
- package/dist/user/user.controller.d.ts.map +1 -1
- package/dist/user/user.controller.js +32 -15
- package/dist/user/user.controller.js.map +1 -1
- package/dist/user/user.service.d.ts +28 -8
- package/dist/user/user.service.d.ts.map +1 -1
- package/dist/user/user.service.js +120 -8
- package/dist/user/user.service.js.map +1 -1
- package/hedhog/data/route.yaml +9 -0
- package/hedhog/data/setting_group.yaml +14 -0
- package/hedhog/frontend/app/roles/page.tsx.ejs +4 -1
- package/hedhog/frontend/app/users/user-logs.tsx.ejs +190 -0
- package/hedhog/frontend/app/users/user-sheet.tsx.ejs +15 -1
- package/hedhog/frontend/messages/en.json +13 -0
- package/hedhog/frontend/messages/pt.json +13 -0
- package/hedhog/table/user_change_log.yaml +40 -0
- package/package.json +5 -5
- package/src/file/file.service.ts +2 -1
- package/src/notification/events/notification-stale-progress-check.event.ts +5 -0
- package/src/notification/notification.service.ts +12 -25
- package/src/task/task.service.ts +52 -0
- package/src/user/user.controller.ts +23 -9
- 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={() =>
|
|
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.
|
|
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-
|
|
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-
|
|
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
|
".": {
|
package/src/file/file.service.ts
CHANGED
|
@@ -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
|
|
|
@@ -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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
package/src/task/task.service.ts
CHANGED
|
@@ -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
|
}
|