feat: filter digest tiles by user permissions and add import progress status
Build and Push Multi-Platform Images / build-and-push (push) Successful in 42s
Build and Push Multi-Platform Images / build-and-push (push) Successful in 42s
- Store UserGroups from OIDC in UserSettings entity, sync on each request - Filter daily digest tiles based on user's permission groups - Add in-memory job status tracking to EmailImportService - Poll import job status in MailImportWizard and show progress in Spin tip Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,12 +8,12 @@ export class DailyDigestController {
|
||||
@Post('send-now')
|
||||
@HttpCode(200)
|
||||
async sendNow(@Request() req: any) {
|
||||
const { userId, email, preferredUsername } = req.user;
|
||||
const { userId, email, preferredUsername, groups } = req.user;
|
||||
if (!email) {
|
||||
return { ok: false, error: 'Keine E-Mail-Adresse im Benutzerprofil gefunden.' };
|
||||
}
|
||||
try {
|
||||
await this.dailyDigestService.sendDigestForUser(userId, email, preferredUsername);
|
||||
await this.dailyDigestService.sendDigestForUser(userId, email, preferredUsername, groups);
|
||||
return { ok: true };
|
||||
} catch (err: any) {
|
||||
return { ok: false, error: err.message };
|
||||
|
||||
@@ -4,6 +4,30 @@ import { Cron } from '@nestjs/schedule';
|
||||
import { StatsService, DashboardCounts } from '../stats/stats.service';
|
||||
import { UserSettingsService } from '../user-settings/user-settings.service';
|
||||
import { MailService } from '../postprocessing/mail.service';
|
||||
import { mapGroupsToPermissions, Permission } from '../auth/permissions.enum';
|
||||
|
||||
interface DigestTile {
|
||||
key: keyof DashboardCounts;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
accent: string;
|
||||
accentSoft: string;
|
||||
permission: Permission;
|
||||
}
|
||||
|
||||
const DIGEST_TILES: DigestTile[] = [
|
||||
{ key: 'inbox', title: 'Eingangsbox', description: 'Neu eingegangene Scans, Uploads und E-Mail-Anhänge prüfen.', icon: '📥', accent: '#1677ff', accentSoft: '#e6f0ff', permission: Permission.VIEW_SCANNER },
|
||||
{ key: 'posteingang', title: 'Posteingang', description: 'Verarbeitete Dokumente sichten und an Paperless übergeben.', icon: '📄', accent: '#13c2c2', accentSoft: '#e6fffb', permission: Permission.VIEW_INBOX },
|
||||
{ key: 'manuell', title: 'Manuell bearbeiten', description: 'Dokumente mit fehlender Erkennung manuell ergänzen.', icon: '✏️', accent: '#fa8c16', accentSoft: '#fff7e6', permission: Permission.PROCESS_MANUALLY },
|
||||
{ key: 'mailpostfach', title: 'Mailpostfach', description: 'Eingehende E-Mails mit Anhängen durchsuchen und zuordnen.', icon: '📬', accent: '#722ed1', accentSoft: '#f9f0ff', permission: Permission.VIEW_MAIL },
|
||||
{ key: 'agrarmonitor', title: 'In Agrarmonitor', description: 'Dokumente im Agrarmonitor-Eingang anzeigen und verwalten.', icon: '🌱', accent: '#52c41a', accentSoft: '#f6ffed', permission: Permission.PROCESS_MANUALLY },
|
||||
];
|
||||
|
||||
function getVisibleTiles(groups: string[] | null | undefined): DigestTile[] {
|
||||
const permissions = mapGroupsToPermissions(groups ?? []);
|
||||
return DIGEST_TILES.filter(t => permissions.includes(t.permission));
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DailyDigestService {
|
||||
@@ -21,11 +45,16 @@ export class DailyDigestService {
|
||||
this.agrarmonitorBaseUrl = this.configService.get<string>('AGRARMONITOR_BASE_URL', '').replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
async sendDigestForUser(userId: string, email: string, preferredUsername?: string) {
|
||||
async sendDigestForUser(userId: string, email: string, preferredUsername?: string, groups?: string[]) {
|
||||
const visibleTiles = getVisibleTiles(groups);
|
||||
if (visibleTiles.length === 0) {
|
||||
this.logger.warn(`Kein Digest für ${email}: keine sichtbaren Kacheln (Gruppen: ${JSON.stringify(groups)})`);
|
||||
return;
|
||||
}
|
||||
const today = new Date().toLocaleDateString('de-DE', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
||||
const counts = await this.statsService.getDashboardCounts(preferredUsername);
|
||||
const html = buildDigestHtml(counts, today, this.appUrl, this.agrarmonitorBaseUrl);
|
||||
const plainText = buildDigestPlainText(counts, today);
|
||||
const html = buildDigestHtml(counts, today, this.appUrl, this.agrarmonitorBaseUrl, visibleTiles);
|
||||
const plainText = buildDigestPlainText(counts, today, visibleTiles);
|
||||
await this.mailService.sendMail({
|
||||
to: email,
|
||||
subject: `Paperless Manager – Tagesübersicht ${today}`,
|
||||
@@ -49,9 +78,14 @@ export class DailyDigestService {
|
||||
|
||||
for (const sub of subscribers) {
|
||||
try {
|
||||
const visibleTiles = getVisibleTiles(sub.UserGroups);
|
||||
if (visibleTiles.length === 0) {
|
||||
this.logger.warn(`Überspringe Digest für ${sub.UserEmail}: keine sichtbaren Kacheln`);
|
||||
continue;
|
||||
}
|
||||
const counts = await this.statsService.getDashboardCounts(sub.UserPreferredUsername ?? undefined);
|
||||
const html = buildDigestHtml(counts, today, this.appUrl, this.agrarmonitorBaseUrl);
|
||||
const plainText = buildDigestPlainText(counts, today);
|
||||
const html = buildDigestHtml(counts, today, this.appUrl, this.agrarmonitorBaseUrl, visibleTiles);
|
||||
const plainText = buildDigestPlainText(counts, today, visibleTiles);
|
||||
await this.mailService.sendMail({
|
||||
to: sub.UserEmail!,
|
||||
subject: `Paperless Manager – Tagesübersicht ${today}`,
|
||||
@@ -66,59 +100,17 @@ export class DailyDigestService {
|
||||
}
|
||||
}
|
||||
|
||||
function buildDigestHtml(counts: DashboardCounts, today: string, appUrl: string, agrarmonitorBaseUrl: string): string {
|
||||
const tiles = [
|
||||
{
|
||||
key: 'inbox' as const,
|
||||
title: 'Eingangsbox',
|
||||
description: 'Neu eingegangene Scans, Uploads und E-Mail-Anhänge prüfen.',
|
||||
icon: '📥',
|
||||
accent: '#1677ff',
|
||||
accentSoft: '#e6f0ff',
|
||||
url: appUrl ? `${appUrl}/inbox` : '',
|
||||
count: counts.inbox,
|
||||
},
|
||||
{
|
||||
key: 'posteingang' as const,
|
||||
title: 'Posteingang',
|
||||
description: 'Verarbeitete Dokumente sichten und an Paperless übergeben.',
|
||||
icon: '📄',
|
||||
accent: '#13c2c2',
|
||||
accentSoft: '#e6fffb',
|
||||
url: appUrl ? `${appUrl}/posteingang` : '',
|
||||
count: counts.posteingang,
|
||||
},
|
||||
{
|
||||
key: 'manuell' as const,
|
||||
title: 'Manuell bearbeiten',
|
||||
description: 'Dokumente mit fehlender Erkennung manuell ergänzen.',
|
||||
icon: '✏️',
|
||||
accent: '#fa8c16',
|
||||
accentSoft: '#fff7e6',
|
||||
url: appUrl ? `${appUrl}/manuell` : '',
|
||||
count: counts.manuell,
|
||||
},
|
||||
{
|
||||
key: 'mailpostfach' as const,
|
||||
title: 'Mailpostfach',
|
||||
description: 'Eingehende E-Mails mit Anhängen durchsuchen und zuordnen.',
|
||||
icon: '📬',
|
||||
accent: '#722ed1',
|
||||
accentSoft: '#f9f0ff',
|
||||
url: appUrl ? `${appUrl}/mailpostfach` : '',
|
||||
count: counts.mailpostfach,
|
||||
},
|
||||
{
|
||||
key: 'agrarmonitor' as const,
|
||||
title: 'In Agrarmonitor',
|
||||
description: 'Dokumente im Agrarmonitor-Eingang anzeigen und verwalten.',
|
||||
icon: '🌱',
|
||||
accent: '#52c41a',
|
||||
accentSoft: '#f6ffed',
|
||||
url: agrarmonitorBaseUrl ? `${agrarmonitorBaseUrl}/dateien/eingang#dateien` : '',
|
||||
count: counts.agrarmonitor,
|
||||
},
|
||||
];
|
||||
function tileUrl(tile: DigestTile, appUrl: string, agrarmonitorBaseUrl: string): string {
|
||||
if (tile.key === 'agrarmonitor') return agrarmonitorBaseUrl ? `${agrarmonitorBaseUrl}/dateien/eingang#dateien` : '';
|
||||
return appUrl ? `${appUrl}/${tile.key === 'inbox' ? 'inbox' : tile.key}` : '';
|
||||
}
|
||||
|
||||
function buildDigestHtml(counts: DashboardCounts, today: string, appUrl: string, agrarmonitorBaseUrl: string, visibleTiles: DigestTile[]): string {
|
||||
const tiles = visibleTiles.map(t => ({
|
||||
...t,
|
||||
url: tileUrl(t, appUrl, agrarmonitorBaseUrl),
|
||||
count: counts[t.key],
|
||||
}));
|
||||
|
||||
const totalOpen = tiles.reduce((sum, t) => sum + t.count, 0);
|
||||
const summaryText = totalOpen > 0
|
||||
@@ -205,15 +197,12 @@ function buildDigestHtml(counts: DashboardCounts, today: string, appUrl: string,
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function buildDigestPlainText(counts: DashboardCounts, today: string): string {
|
||||
function buildDigestPlainText(counts: DashboardCounts, today: string, visibleTiles: DigestTile[]): string {
|
||||
const lines = visibleTiles.map(t => ` ${t.title.padEnd(22)} ${counts[t.key]}`).join('\n');
|
||||
return `Paperless Manager – Tagesübersicht ${today}
|
||||
|
||||
Offene Vorgänge:
|
||||
Eingangsbox (Scanner): ${counts.inbox}
|
||||
Posteingang: ${counts.posteingang}
|
||||
Manuell bearbeiten: ${counts.manuell}
|
||||
Mailpostfach: ${counts.mailpostfach}
|
||||
In Agrarmonitor: ${counts.agrarmonitor}
|
||||
${lines}
|
||||
|
||||
Diese E-Mail wird täglich automatisch von Paperless Manager versendet.
|
||||
`;
|
||||
|
||||
@@ -38,6 +38,9 @@ export class UserSettings {
|
||||
@Column({ type: 'boolean', default: false })
|
||||
DailyDigestEnabled!: boolean;
|
||||
|
||||
@Column({ type: 'json', nullable: true })
|
||||
UserGroups!: string[] | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
UserEmail!: string | null;
|
||||
|
||||
|
||||
@@ -130,6 +130,13 @@ export class EmailImportController {
|
||||
res.sendFile(previewPath);
|
||||
}
|
||||
|
||||
// --- Import Job Status ---
|
||||
@Get('jobs/:jobId/status')
|
||||
@RequirePermissions(Permission.VIEW_MAIL)
|
||||
getJobStatus(@Param('jobId') jobId: string) {
|
||||
return this.importService.getJobStatus(jobId) ?? { message: '', done: false };
|
||||
}
|
||||
|
||||
// --- Final Import ---
|
||||
@Post('execute')
|
||||
@RequirePermissions(Permission.VIEW_MAIL)
|
||||
|
||||
@@ -21,6 +21,16 @@ import * as crypto from 'crypto';
|
||||
@Injectable()
|
||||
export class EmailImportService {
|
||||
private readonly logger = new Logger(EmailImportService.name);
|
||||
private readonly importJobs = new Map<string, { message: string; done: boolean }>();
|
||||
|
||||
private setJobStatus(jobId: string | undefined, message: string, done = false): void {
|
||||
if (!jobId) return;
|
||||
this.importJobs.set(jobId, { message, done });
|
||||
}
|
||||
|
||||
getJobStatus(jobId: string): { message: string; done: boolean } | null {
|
||||
return this.importJobs.get(jobId) ?? null;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
@@ -328,12 +338,13 @@ export class EmailImportService {
|
||||
|
||||
// --- Import Logic ---
|
||||
async executeImport(data: {
|
||||
jobId?: string;
|
||||
attachments: {
|
||||
attachmentId: number;
|
||||
type: 'MAIN' | 'ATTACHMENT' | 'IGNORE';
|
||||
paperlessCorrespondentId?: number | null;
|
||||
parentDocumentId?: number | null; // Used if type is ATTACHMENT (should map to a Custom Field theoretically, or just tags. For now, CF if configured, but we pass it)
|
||||
splitRanges?: { start: number; end: number }[]; // 1-based pages, e.g. [{start: 1, end: 3}, {start: 4, end: 5}]
|
||||
parentDocumentId?: number | null;
|
||||
splitRanges?: { start: number; end: number }[];
|
||||
barcode?: { x: number; y: number; nummer: string; datum: string; jahr: string };
|
||||
belegnummer?: string;
|
||||
}[];
|
||||
@@ -341,6 +352,7 @@ export class EmailImportService {
|
||||
}): Promise<{ success: boolean; results: any[] }> {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'paperless-mail-import-'));
|
||||
const results = [];
|
||||
this.setJobStatus(data.jobId, 'Dokumente werden vorbereitet...');
|
||||
|
||||
try {
|
||||
for (const att of data.attachments) {
|
||||
@@ -417,6 +429,7 @@ export class EmailImportService {
|
||||
};
|
||||
if (att.paperlessCorrespondentId) options.correspondent = att.paperlessCorrespondentId;
|
||||
|
||||
this.setJobStatus(data.jobId, `Lade ${uploadItem.filename} hoch...`);
|
||||
const paperlessTaskId = await this.paperlessService.uploadDocument(uploadItem.path, options);
|
||||
|
||||
// Create background task for enrichment (same logic as Inbox)
|
||||
@@ -437,6 +450,7 @@ export class EmailImportService {
|
||||
// Still poll for Doc ID so we can return it to the frontend for immediate preview
|
||||
let docId = null;
|
||||
for (let i = 0; i < 30; i++) {
|
||||
this.setJobStatus(data.jobId, `Warte auf Paperless-Verarbeitung... (${i + 1}/30)`);
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
try {
|
||||
const taskStatus = await this.paperlessService.getTask(paperlessTaskId);
|
||||
@@ -481,10 +495,12 @@ export class EmailImportService {
|
||||
}
|
||||
}
|
||||
|
||||
this.setJobStatus(data.jobId, 'Import abgeschlossen', true);
|
||||
return { success: true, results };
|
||||
} finally {
|
||||
// Clean up temp dir
|
||||
// Clean up temp dir and job status
|
||||
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||
if (data.jobId) setTimeout(() => this.importJobs.delete(data.jobId!), 5000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,12 @@ export class UserSettingsController {
|
||||
|
||||
@Get()
|
||||
async getSettings(@Request() req: any) {
|
||||
return this.userSettingsService.getSettings(req.user.userId, req.user.email, req.user.preferredUsername);
|
||||
return this.userSettingsService.getSettings(req.user.userId, req.user.email, req.user.preferredUsername, req.user.groups);
|
||||
}
|
||||
|
||||
@Put()
|
||||
async updateSettings(@Request() req: any, @Body() body: any) {
|
||||
return this.userSettingsService.updateSettings(req.user.userId, body, req.user.email, req.user.preferredUsername);
|
||||
return this.userSettingsService.updateSettings(req.user.userId, body, req.user.email, req.user.preferredUsername, req.user.groups);
|
||||
}
|
||||
|
||||
@Get('senders')
|
||||
|
||||
@@ -65,14 +65,15 @@ export class UserSettingsService {
|
||||
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
|
||||
}
|
||||
|
||||
async getSettings(userId: string, email?: string, preferredUsername?: string): Promise<UserSettingsDto> {
|
||||
async getSettings(userId: string, email?: string, preferredUsername?: string, groups?: string[]): Promise<UserSettingsDto> {
|
||||
let entity = await this.repo.findOne({ where: { UserId: userId } });
|
||||
if (email || preferredUsername) {
|
||||
if (email || preferredUsername || groups) {
|
||||
if (!entity) {
|
||||
entity = this.repo.create({ UserId: userId });
|
||||
}
|
||||
if (email) entity.UserEmail = email;
|
||||
if (preferredUsername) entity.UserPreferredUsername = preferredUsername;
|
||||
if (groups) entity.UserGroups = groups;
|
||||
await this.repo.save(entity);
|
||||
}
|
||||
return this.toDto(entity);
|
||||
@@ -95,6 +96,7 @@ export class UserSettingsService {
|
||||
},
|
||||
email?: string,
|
||||
preferredUsername?: string,
|
||||
groups?: string[],
|
||||
): Promise<UserSettingsDto> {
|
||||
let entity = await this.repo.findOne({ where: { UserId: userId } });
|
||||
if (!entity) {
|
||||
@@ -116,6 +118,7 @@ export class UserSettingsService {
|
||||
if (data.dailyDigestEnabled !== undefined) entity.DailyDigestEnabled = data.dailyDigestEnabled;
|
||||
if (email) entity.UserEmail = email;
|
||||
if (preferredUsername) entity.UserPreferredUsername = preferredUsername;
|
||||
if (groups) entity.UserGroups = groups;
|
||||
|
||||
await this.repo.save(entity);
|
||||
return this.toDto(entity);
|
||||
|
||||
@@ -62,11 +62,20 @@ export const emailImportApi = {
|
||||
return res.data.isDuplicate;
|
||||
},
|
||||
|
||||
executeImport: async (emailDate: string, attachments: AttachmentImportData[]): Promise<{ success: boolean; results: any[] }> => {
|
||||
const res = await api.post('/api/email-import/execute', { emailDate, attachments });
|
||||
executeImport: async (emailDate: string, attachments: AttachmentImportData[], jobId?: string): Promise<{ success: boolean; results: any[] }> => {
|
||||
const res = await api.post('/api/email-import/execute', { emailDate, attachments, jobId }, { timeout: 300_000 });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
getJobStatus: async (jobId: string): Promise<{ message: string; done: boolean } | null> => {
|
||||
try {
|
||||
const res = await api.get(`/api/email-import/jobs/${jobId}/status`);
|
||||
return res.data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
ensurePreviews: async (emailId: number): Promise<void> => {
|
||||
await api.post(`/api/email-import/emails/${emailId}/ensure-previews`);
|
||||
},
|
||||
|
||||
@@ -44,6 +44,7 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
|
||||
|
||||
// Step 3 specific state
|
||||
const [importSuccess, setImportSuccess] = useState(false);
|
||||
const [importStatus, setImportStatus] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && attachments.length > 0) {
|
||||
@@ -315,6 +316,14 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
|
||||
|
||||
const executeImport = async () => {
|
||||
setLoading(true);
|
||||
setImportStatus('Import wird gestartet...');
|
||||
const jobId = crypto.randomUUID ? crypto.randomUUID() : `job-${Date.now()}`;
|
||||
const statusPoll = setInterval(async () => {
|
||||
try {
|
||||
const status = await emailImportApi.getJobStatus(jobId);
|
||||
if (status?.message) setImportStatus(status.message);
|
||||
} catch {}
|
||||
}, 1500);
|
||||
try {
|
||||
const finalData = [];
|
||||
|
||||
@@ -358,12 +367,14 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
|
||||
});
|
||||
}
|
||||
|
||||
await emailImportApi.executeImport(email.Date, finalData);
|
||||
await emailImportApi.executeImport(email.Date, finalData, jobId);
|
||||
setImportSuccess(true);
|
||||
setCurrentStep(2);
|
||||
} catch (e: any) {
|
||||
message.error(`Fehler beim Import: ${e.message}`);
|
||||
} finally {
|
||||
clearInterval(statusPoll);
|
||||
setImportStatus('');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -747,7 +758,7 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
|
||||
style={{ marginBottom: 24 }}
|
||||
/>
|
||||
|
||||
<Spin spinning={loading}>
|
||||
<Spin spinning={loading} tip={importStatus || undefined}>
|
||||
<div style={{ minHeight: 300 }}>
|
||||
{currentStep === 0 && renderStep1()}
|
||||
{currentStep === 1 && renderStep2()}
|
||||
|
||||
Reference in New Issue
Block a user