Initial commit with Email Import Wizard and Task Processor updates
This commit is contained in:
commit
effdc5d59f
170 files changed
+67739
No files matched your search
@@ -0,0 +1,488 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as crypto from 'crypto';
|
||||
import {
|
||||
InboxPostprocessingAction,
|
||||
} from '../database/entities/inbox-postprocessing-action.entity';
|
||||
import { InboxDocument } from '../database/entities/inbox-document.entity';
|
||||
import { BarcodeTemplate } from '../database/entities/barcode-template.entity';
|
||||
import { Task } from '../database/entities/task.entity';
|
||||
import { PageCacheService } from '../barcode/page-cache.service';
|
||||
import { PaperlessService } from '../paperless/paperless.service';
|
||||
import { MailService } from '../postprocessing/mail.service';
|
||||
import { ExportService } from '../postprocessing/export.service';
|
||||
import { applyEditsToTemp, cleanupTemp, extractSectionToTemp } from './edit-applier';
|
||||
import { applyTemplate, buildVariables } from './variable-resolver';
|
||||
|
||||
function parseFlexDate(s: string): Date | null {
|
||||
if (!s) return null;
|
||||
// ISO: YYYY-MM-DD
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) {
|
||||
const d = new Date(s);
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
// German: DD.MM.YYYY
|
||||
const m = s.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
|
||||
if (m) {
|
||||
const d = new Date(`${m[3]}-${m[2].padStart(2, '0')}-${m[1].padStart(2, '0')}`);
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface ActionResult {
|
||||
sectionIndex: number;
|
||||
actionId: number;
|
||||
actionType: string;
|
||||
ok: boolean;
|
||||
skipped?: boolean;
|
||||
message?: string;
|
||||
duplicateOfDocumentId?: number;
|
||||
}
|
||||
|
||||
export interface RunForDocumentResult {
|
||||
results: ActionResult[];
|
||||
totalSections: number;
|
||||
}
|
||||
|
||||
interface PaperlessRunResult {
|
||||
skipped?: boolean;
|
||||
message?: string;
|
||||
duplicateOfDocumentId?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class InboxPostprocessorService {
|
||||
private readonly logger = new Logger(InboxPostprocessorService.name);
|
||||
|
||||
constructor(
|
||||
private readonly pageCache: PageCacheService,
|
||||
private readonly paperlessService: PaperlessService,
|
||||
private readonly mailService: MailService,
|
||||
private readonly exportService: ExportService,
|
||||
@InjectRepository(InboxPostprocessingAction)
|
||||
private readonly actionRepo: Repository<InboxPostprocessingAction>,
|
||||
@InjectRepository(InboxDocument)
|
||||
private readonly docRepo: Repository<InboxDocument>,
|
||||
@InjectRepository(BarcodeTemplate)
|
||||
private readonly templateRepo: Repository<BarcodeTemplate>,
|
||||
@InjectRepository(Task)
|
||||
private readonly taskRepo: Repository<Task>,
|
||||
) {}
|
||||
|
||||
async runForDocument(
|
||||
documentId: string,
|
||||
preferredUsername: string | null,
|
||||
sectionOffset: number = 0,
|
||||
processOnlyOne: boolean = false,
|
||||
replaceDuplicate: boolean = false,
|
||||
): Promise<RunForDocumentResult> {
|
||||
const doc = await this.docRepo.findOne({ where: { Id: documentId } });
|
||||
if (!doc) throw new NotFoundException('Dokument nicht gefunden');
|
||||
if (doc.Source === 'user' && doc.OwnerUsername !== preferredUsername) {
|
||||
throw new NotFoundException('Dokument nicht gefunden');
|
||||
}
|
||||
|
||||
const sourcePdf = this.pageCache.documentPdfPath(doc.Id);
|
||||
try {
|
||||
await fs.access(sourcePdf);
|
||||
} catch {
|
||||
throw new NotFoundException('Dokument-PDF fehlt');
|
||||
}
|
||||
|
||||
const templates = await this.templateRepo.find({ order: { Id: 'ASC' } });
|
||||
const matchedTemplateIds = [
|
||||
...new Set(
|
||||
doc.QrCodes
|
||||
.map((qr) => {
|
||||
const tpl = templates.find((t) => {
|
||||
try { return new RegExp(t.Regex).test(qr.value); }
|
||||
catch { return false; }
|
||||
});
|
||||
return tpl?.Id ?? null;
|
||||
})
|
||||
.filter((id): id is number => id !== null),
|
||||
),
|
||||
];
|
||||
if (matchedTemplateIds.length === 0) return { results: [], totalSections: 0 };
|
||||
|
||||
const actions = await this.actionRepo.find({
|
||||
where: matchedTemplateIds.map((tid) => ({ BarcodeTemplateId: tid, IsActive: true })),
|
||||
order: { BarcodeTemplateId: 'ASC', Order: 'ASC', Id: 'ASC' },
|
||||
});
|
||||
|
||||
if (actions.length === 0) {
|
||||
return { results: [], totalSections: 0 };
|
||||
}
|
||||
|
||||
// Aktionen nach Template gruppieren, damit jede Gruppe ihre eigenen Barcode-Variablen bekommt
|
||||
const actionsByTemplate = new Map<number, InboxPostprocessingAction[]>();
|
||||
for (const action of actions) {
|
||||
const tid = action.BarcodeTemplateId!;
|
||||
if (!actionsByTemplate.has(tid)) actionsByTemplate.set(tid, []);
|
||||
actionsByTemplate.get(tid)!.push(action);
|
||||
}
|
||||
|
||||
let processedPdfPath: string | null = null;
|
||||
let abortProcessing = false;
|
||||
const results: ActionResult[] = [];
|
||||
try {
|
||||
processedPdfPath = await applyEditsToTemp(doc, sourcePdf);
|
||||
|
||||
// Überlebende Seiten berechnen (1-basiert auf Original)
|
||||
const deletedSet = new Set(doc.DeletedPages ?? []);
|
||||
const survivingOriginalPages: number[] = [];
|
||||
for (let p = 1; p <= doc.PageCount; p++) {
|
||||
if (!deletedSet.has(p)) survivingOriginalPages.push(p);
|
||||
}
|
||||
// Mapping: Original-Seite → 0-basierter Index in processedPdf
|
||||
const processedPageIndex = new Map<number, number>();
|
||||
survivingOriginalPages.forEach((origPage, idx) => processedPageIndex.set(origPage, idx));
|
||||
|
||||
// QR-Codes mit ihrem Index in der verarbeiteten PDF (gelöschte QR-Seiten herausfiltern)
|
||||
const qrsWithIdx = doc.QrCodes
|
||||
.filter(qr => processedPageIndex.has(qr.page))
|
||||
.map(qr => ({ page: qr.page, value: qr.value, processedIdx: processedPageIndex.get(qr.page)! }))
|
||||
.sort((a, b) => a.processedIdx - b.processedIdx);
|
||||
|
||||
// Split-Punkte: 0-basierte Indizes in der verarbeiteten PDF wo SplitBefore-Templates beginnen
|
||||
const splitPoints: number[] = [];
|
||||
for (const qr of qrsWithIdx) {
|
||||
const tplMatch = templates.find(t => { try { return new RegExp(t.Regex).test(qr.value); } catch { return false; } });
|
||||
if (tplMatch?.SplitBefore) splitPoints.push(qr.processedIdx);
|
||||
}
|
||||
const processedPageCount = survivingOriginalPages.length;
|
||||
|
||||
// Gesamtzahl aktiver Abschnitte vorab berechnen (für Stepper-UI im Frontend)
|
||||
let totalSections = 0;
|
||||
for (let i = 0; i < qrsWithIdx.length; i++) {
|
||||
const qr = qrsWithIdx[i];
|
||||
const tpl = templates.find(t => { try { return new RegExp(t.Regex).test(qr.value); } catch { return false; } });
|
||||
if (!tpl) continue;
|
||||
if (i > 0 && !tpl.SplitBefore) continue;
|
||||
totalSections++;
|
||||
}
|
||||
|
||||
// Ersten Barcode immer verarbeiten (SplitBefore egal).
|
||||
// Weitere Barcodes nur wenn SplitBefore=true → neues Dokument.
|
||||
// sectionOffset: erste N aktive Abschnitte überspringen (für "Nächstes Dokument").
|
||||
// processOnlyOne: nach dem ersten verarbeiteten Abschnitt abbrechen (für Wizard).
|
||||
let activeSectionCount = 0;
|
||||
let processedSection = false;
|
||||
for (let i = 0; i < qrsWithIdx.length; i++) {
|
||||
if (abortProcessing) break;
|
||||
if (processedSection && processOnlyOne) break;
|
||||
const qr = qrsWithIdx[i];
|
||||
|
||||
const tpl = templates.find(t => { try { return new RegExp(t.Regex).test(qr.value); } catch { return false; } });
|
||||
if (!tpl) continue;
|
||||
if (i > 0 && !tpl.SplitBefore) continue;
|
||||
|
||||
if (activeSectionCount < sectionOffset) {
|
||||
activeSectionCount++;
|
||||
continue;
|
||||
}
|
||||
const currentSectionIndex = activeSectionCount;
|
||||
activeSectionCount++;
|
||||
|
||||
const tplActions = actionsByTemplate.get(tpl.Id);
|
||||
if (!tplActions || tplActions.length === 0) continue;
|
||||
|
||||
const variables = buildVariables({ doc, template: tpl, matchingQrValue: qr.value });
|
||||
|
||||
// Abschnitt aus der verarbeiteten PDF extrahieren
|
||||
const startIdx = qr.processedIdx;
|
||||
const nextSplitIdx = splitPoints.find(sp => sp > startIdx);
|
||||
const endIdx = nextSplitIdx !== undefined ? nextSplitIdx - 1 : processedPageCount - 1;
|
||||
const pageIndices = Array.from({ length: endIdx - startIdx + 1 }, (_, i) => startIdx + i);
|
||||
const sectionPdfPath = await extractSectionToTemp(processedPdfPath, pageIndices);
|
||||
|
||||
const defaultFilenameBase = tpl.DateinameTemplate
|
||||
? applyTemplate(tpl.DateinameTemplate, variables)
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
for (const action of tplActions) {
|
||||
if (abortProcessing) break;
|
||||
if (action.ActionType === 'PAPERLESS') {
|
||||
try {
|
||||
const res = await this.runPaperless(action.Content ?? {}, sectionPdfPath, variables, defaultFilenameBase, replaceDuplicate);
|
||||
results.push({
|
||||
sectionIndex: currentSectionIndex,
|
||||
actionId: action.Id,
|
||||
actionType: action.ActionType,
|
||||
ok: true,
|
||||
skipped: res.skipped,
|
||||
message: res.message,
|
||||
duplicateOfDocumentId: res.duplicateOfDocumentId,
|
||||
});
|
||||
if (res.skipped) {
|
||||
abortProcessing = true;
|
||||
break;
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.logger.error(
|
||||
`Aktion PAPERLESS#${action.Id} für Dokument ${doc.Id} fehlgeschlagen: ${err.message}`,
|
||||
);
|
||||
results.push({ sectionIndex: currentSectionIndex, actionId: action.Id, actionType: action.ActionType, ok: false, message: err.message });
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await this.runAction(action, sectionPdfPath, doc, variables, defaultFilenameBase);
|
||||
results.push({ sectionIndex: currentSectionIndex, actionId: action.Id, actionType: action.ActionType, ok: true });
|
||||
} catch (err: any) {
|
||||
this.logger.error(
|
||||
`Aktion ${action.ActionType}#${action.Id} für Dokument ${doc.Id} fehlgeschlagen: ${err.message}`,
|
||||
);
|
||||
results.push({ sectionIndex: currentSectionIndex, actionId: action.Id, actionType: action.ActionType, ok: false, message: err.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await cleanupTemp(sectionPdfPath);
|
||||
}
|
||||
|
||||
processedSection = true;
|
||||
}
|
||||
|
||||
return { results, totalSections };
|
||||
} finally {
|
||||
await cleanupTemp(processedPdfPath);
|
||||
}
|
||||
}
|
||||
|
||||
private async runAction(
|
||||
action: InboxPostprocessingAction,
|
||||
pdfPath: string,
|
||||
doc: InboxDocument,
|
||||
variables: Record<string, string>,
|
||||
defaultFilenameBase?: string,
|
||||
): Promise<void> {
|
||||
const content = action.Content ?? {};
|
||||
|
||||
switch (action.ActionType) {
|
||||
case 'MAIL':
|
||||
return this.runMail(content, pdfPath, doc, variables, defaultFilenameBase);
|
||||
case 'EXPORT':
|
||||
return this.runExport(content, pdfPath, variables, defaultFilenameBase);
|
||||
default:
|
||||
throw new Error(`Unbekannter ActionType: ${action.ActionType}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async runMail(
|
||||
content: Record<string, any>,
|
||||
pdfPath: string,
|
||||
doc: InboxDocument,
|
||||
variables: Record<string, string>,
|
||||
defaultFilenameBase?: string,
|
||||
): Promise<void> {
|
||||
const to = applyTemplate(String(content.to ?? ''), variables).trim();
|
||||
if (!to) throw new Error('Empfänger fehlt');
|
||||
const subject = applyTemplate(String(content.subject ?? ''), variables);
|
||||
const body = applyTemplate(String(content.body ?? ''), variables);
|
||||
const filenameTpl = String(content.filenameTemplate ?? '').trim();
|
||||
const filename = filenameTpl
|
||||
? `${applyTemplate(filenameTpl, variables)}.pdf`
|
||||
: defaultFilenameBase
|
||||
? `${defaultFilenameBase}.pdf`
|
||||
: doc.OriginalName;
|
||||
const buffer = await fs.readFile(pdfPath);
|
||||
await this.mailService.sendMail({
|
||||
to,
|
||||
subject,
|
||||
body,
|
||||
attachments: [{ filename, content: buffer }],
|
||||
});
|
||||
}
|
||||
|
||||
private async runExport(
|
||||
content: Record<string, any>,
|
||||
pdfPath: string,
|
||||
variables: Record<string, string>,
|
||||
defaultFilenameBase?: string,
|
||||
): Promise<void> {
|
||||
const targetId = Number(content.exportTargetId);
|
||||
if (!targetId) throw new Error('Export-Ziel fehlt');
|
||||
const filenameTpl = String(content.filenameTemplate ?? '').trim();
|
||||
const filename = filenameTpl
|
||||
? `${applyTemplate(filenameTpl, variables)}.pdf`
|
||||
: defaultFilenameBase
|
||||
? `${defaultFilenameBase}.pdf`
|
||||
: `${path.basename(pdfPath, '.pdf')}.pdf`;
|
||||
const buffer = await fs.readFile(pdfPath);
|
||||
await this.exportService.exportFile(targetId, filename, buffer);
|
||||
}
|
||||
|
||||
private async runPaperless(
|
||||
content: Record<string, any>,
|
||||
pdfPath: string,
|
||||
variables: Record<string, string>,
|
||||
defaultFilenameBase?: string,
|
||||
replaceDuplicate: boolean = false,
|
||||
): Promise<PaperlessRunResult> {
|
||||
// 1. Interne Belegnummer auflösen (Pflicht)
|
||||
const intNrTpl = String(content.interneBelegnummer ?? '').trim();
|
||||
if (!intNrTpl) throw new Error('Interne Belegnummer ist in der Aktion nicht konfiguriert');
|
||||
const interneBelegnummer = applyTemplate(intNrTpl, variables).trim();
|
||||
if (!interneBelegnummer) throw new Error('Interne Belegnummer konnte nicht aufgelöst werden');
|
||||
|
||||
// 2. ASN bestimmen (vor Duplikat-Checks, damit Paperless danach gefragt werden kann)
|
||||
const asnTpl = String(content.asn ?? '').trim();
|
||||
const asn = asnTpl ? applyTemplate(asnTpl, variables).trim() : null;
|
||||
let archiveSerialNumber: number | undefined;
|
||||
if (asn) {
|
||||
const n = parseInt(asn.replace(/[^0-9]/g, ''), 10);
|
||||
if (!Number.isNaN(n)) archiveSerialNumber = n;
|
||||
}
|
||||
if (archiveSerialNumber === undefined) {
|
||||
const n = parseInt(interneBelegnummer.replace(/[^0-9]/g, ''), 10);
|
||||
if (!Number.isNaN(n)) archiveSerialNumber = n;
|
||||
}
|
||||
|
||||
if (replaceDuplicate) {
|
||||
this.logger.log(`Duplikat-Check übersprungen (replaceDuplicate=true) für ${interneBelegnummer}`);
|
||||
} else {
|
||||
// 3. Duplikat-Check lokal (tasks-Tabelle)
|
||||
const existingTask = await this.taskRepo.findOneBy({ InterneBelegnummer: interneBelegnummer });
|
||||
if (existingTask) {
|
||||
this.logger.warn(`Duplikat (lokal): Belegnummer ${interneBelegnummer} bereits in tasks-Tabelle`);
|
||||
return {
|
||||
skipped: true,
|
||||
message: `Duplikat – Belegnummer ${interneBelegnummer} bereits vorhanden`,
|
||||
duplicateOfDocumentId: existingTask.PaperlessDocumentID ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// 4. Duplikat-Check Paperless API (Custom Field 7 = InterneBelegnummer)
|
||||
const cf7DocId = await this.paperlessService.findDocumentIdByCustomField(7, interneBelegnummer);
|
||||
if (cf7DocId !== null) {
|
||||
this.logger.warn(`Duplikat (Paperless CF7): Belegnummer ${interneBelegnummer} bereits in Paperless (Doc ${cf7DocId})`);
|
||||
return {
|
||||
skipped: true,
|
||||
message: `Duplikat – Belegnummer ${interneBelegnummer} bereits in Paperless`,
|
||||
duplicateOfDocumentId: cf7DocId,
|
||||
};
|
||||
}
|
||||
|
||||
// 5. Duplikat-Check Paperless API (archive_serial_number)
|
||||
if (archiveSerialNumber !== undefined) {
|
||||
const asnDocId = await this.paperlessService.findDocumentIdByAsn(archiveSerialNumber);
|
||||
if (asnDocId !== null) {
|
||||
this.logger.warn(`Duplikat (Paperless ASN): ${archiveSerialNumber} bereits in Paperless (Doc ${asnDocId})`);
|
||||
return {
|
||||
skipped: true,
|
||||
message: `Duplikat – ASN ${archiveSerialNumber} bereits in Paperless`,
|
||||
duplicateOfDocumentId: asnDocId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Checksum berechnen und prüfen
|
||||
const buffer = await fs.readFile(pdfPath);
|
||||
const checksum = crypto.createHash('md5').update(buffer).digest('hex');
|
||||
const checksumExists = await this.paperlessService.checksumExists(checksum);
|
||||
if (checksumExists) {
|
||||
this.logger.warn(`Duplikat (Checksum): ${checksum}`);
|
||||
return { skipped: true, message: 'Duplikat (Checksum-Übereinstimmung)' };
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Restliche Metadaten auflösen
|
||||
|
||||
const titleTpl = String(content.title ?? '').trim();
|
||||
const title = titleTpl
|
||||
? applyTemplate(titleTpl, variables)
|
||||
: defaultFilenameBase || undefined;
|
||||
|
||||
const tags = Array.isArray(content.tags)
|
||||
? content.tags.map((t: any) => Number(t)).filter((n: number) => Number.isFinite(n))
|
||||
: undefined;
|
||||
|
||||
const documentType = content.documentType ? Number(content.documentType) : undefined;
|
||||
const correspondent = content.correspondent ? Number(content.correspondent) : undefined;
|
||||
const owner = content.owner !== undefined && content.owner !== null && content.owner !== ''
|
||||
? Number(content.owner)
|
||||
: undefined;
|
||||
|
||||
const rawCustomFields: Record<string, string> | null =
|
||||
content.customFields && typeof content.customFields === 'object'
|
||||
? Object.fromEntries(
|
||||
Object.entries(content.customFields as Record<string, any>).map(([k, v]) => [
|
||||
k,
|
||||
applyTemplate(String(v ?? ''), variables),
|
||||
]),
|
||||
)
|
||||
: null;
|
||||
|
||||
// Eingangsdatum: Priorität 1 = dediziertes Feld, Priorität 2 = Custom Field 9, Fallback = heute
|
||||
const eingangsdatumTpl = String(content.eingangsdatum ?? '').trim();
|
||||
let eingangsdatum: Date;
|
||||
if (eingangsdatumTpl) {
|
||||
eingangsdatum = parseFlexDate(applyTemplate(eingangsdatumTpl, variables).trim()) ?? new Date();
|
||||
} else if (rawCustomFields?.['9']) {
|
||||
const parsed = parseFlexDate(rawCustomFields['9']);
|
||||
if (parsed) {
|
||||
eingangsdatum = parsed;
|
||||
delete rawCustomFields['9']; // Task-Processor schreibt Field 9 in korrektem ISO-Format
|
||||
} else {
|
||||
eingangsdatum = new Date();
|
||||
}
|
||||
} else {
|
||||
eingangsdatum = new Date();
|
||||
}
|
||||
|
||||
// Custom Fields für den Upload zusammenstellen:
|
||||
// - CF7 = InterneBelegnummer (für Duplikat-Check via Paperless API)
|
||||
// - CF9 = Eingangsdatum in ISO-Format
|
||||
// - User-konfigurierte Felder aus rawCustomFields
|
||||
const uploadCustomFields: Record<string, string> = {};
|
||||
if (rawCustomFields) {
|
||||
for (const [k, v] of Object.entries(rawCustomFields)) uploadCustomFields[k] = v;
|
||||
}
|
||||
uploadCustomFields['7'] = interneBelegnummer;
|
||||
uploadCustomFields['9'] = `${eingangsdatum.getFullYear()}-${String(eingangsdatum.getMonth() + 1).padStart(2, '0')}-${String(eingangsdatum.getDate()).padStart(2, '0')}`;
|
||||
|
||||
// 6. Upload
|
||||
const taskIdRaw = await this.paperlessService.uploadDocument(pdfPath, {
|
||||
title,
|
||||
filename: defaultFilenameBase || undefined,
|
||||
documentType,
|
||||
correspondent,
|
||||
owner,
|
||||
tags,
|
||||
archiveSerialNumber,
|
||||
customFields: uploadCustomFields,
|
||||
});
|
||||
const taskId = String(taskIdRaw).replace(/"/g, '');
|
||||
|
||||
// 7. Task anlegen
|
||||
const task = this.taskRepo.create({
|
||||
TaskId: taskId,
|
||||
InterneBelegnummer: interneBelegnummer,
|
||||
DocumentType: documentType ?? null,
|
||||
Eingangsdatum: eingangsdatum,
|
||||
Fertig: 0,
|
||||
Tags: tags && tags.length > 0 ? tags.join(',') : null,
|
||||
BetriebID: owner ?? null,
|
||||
externeBelegnummer: null,
|
||||
CustomFieldsJson: rawCustomFields && Object.keys(rawCustomFields).length > 0
|
||||
? JSON.stringify(rawCustomFields)
|
||||
: null,
|
||||
Asn: asn || null,
|
||||
Lieferant: null,
|
||||
EinkaufID: null,
|
||||
Belegdatum: null,
|
||||
TaskReferenceID: null,
|
||||
BarcodeJson: null,
|
||||
DuplikatZU: null,
|
||||
});
|
||||
await this.taskRepo.save(task);
|
||||
|
||||
this.logger.log(`Dokument hochgeladen und Task angelegt: ${interneBelegnummer} → ${taskId}`);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user