Rechtstexte: Generator-Satz funktionen6 im neuen Stil
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
90b7f10f1a
commit
33b7a4ce0f
2 files changed
+174
-13
No files matched your search
+123
-13
@@ -18,18 +18,25 @@
|
||||
// dasselbe für die Widerrufsbelehrung der Projektaufträge (Wortlaut aus server/services/auftraege/texte.js)
|
||||
// und die AGB-Kopie (Quelle docs/rechtliches/agb.md)
|
||||
//
|
||||
// node scripts/build-legal-html.mjs funktionen6 [--check] [--nur=<doc,doc>]
|
||||
// die zehn Funktionsunterlagen der Fassung 6 (Leistungsbeschreibungen und AVV, Quellen docs/rechtliches/<slug>-v6.md)
|
||||
// im neuen Stil (meta.stil 'neu') unter public/neu/rechtliches/<slug>-<Fassung>.html; --nur= begrenzt auf die
|
||||
// genannten Dokumente (news-lb, termine-lb, termine-avv, shop-lb, shop-avv, unterkunft-lb, unterkunft-avv, zeiten-lb,
|
||||
// analyse-lb, analyse-avv; auch der Dateiname ohne Fassung, z. B. avv-webshop). Trägt die Fassungszeile der Quelle die Entwurfsmarke, bekommt die Seite meta robots noindex.
|
||||
//
|
||||
// Fassung = config.termine.avvVersion (Satz termine), config.shop.fassung (Satz shop) bzw. config.unterkunft.fassung (Satz unterkunft) bzw. TEXTE_FASSUNG und AGB_FASSUNG (Satz auftraege). Die Ausgabe enthält keine Datumsangabe außer der Fassung und hängt nur vom Markdown und
|
||||
// von der Fassung ab (zweimal erzeugen ergibt dieselben Bytes). Unterstützte Teilmenge von Markdown: Überschriften # bis ###
|
||||
// (# nur als erste Zeile), Absätze, Listen mit "- ", **fett**, `code` und Tabellen (Kopfzeile, Trennzeile |---|---|, Zeilen).
|
||||
// Jede andere Syntax (Codeblöcke, Zitate, Bilder, Links, rohes HTML, nummerierte Listen, Einrückungen, Trennlinien) bricht laut ab,
|
||||
// damit nie eine Seite mit falsch dargestelltem Rechtstext entsteht.
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { basename, join } from 'node:path'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { config } from '../server/config.js'
|
||||
import { legalPaths } from '../server/services/legalDocs.js'
|
||||
import { TEXTE_FASSUNG, WIDERRUFSBELEHRUNG_PROJEKT, WIDERRUFSFORMULAR } from '../server/services/auftraege/texte.js'
|
||||
import { AGB_FASSUNG } from '../server/services/auftraege/konstanten.js'
|
||||
import { FUNKTIONEN_UNTERLAGEN_V6 } from '../server/services/products.js'
|
||||
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url))
|
||||
const FASSUNG_RE = /^\d{4}-\d{2}-\d{2}$/
|
||||
@@ -190,6 +197,44 @@ const STYLE = (seitenkopf) => ` :root { color-scheme: light; }
|
||||
}
|
||||
`
|
||||
|
||||
/**
|
||||
* Neue Seitenhülle (Fassung 6, Spec 5.1): weiße Fläche, Text #1a1f36, Zweittext #5e6678, Linien #ebeef1/#d8dde3, Akzent #111, Systemschrift,
|
||||
* keine Karten, kein Lila, kein Gewicht 800, kein Dunkelmodus. Dieselben Tokens wie die Abo-Bedingungen unter public/neu/rechtliches/.
|
||||
*/
|
||||
const STYLE_NEU = (seitenkopf) => ` :root { color-scheme: light; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: #fff; color: #1a1f36; font: 16px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-variant-numeric: tabular-nums; }
|
||||
main { max-width: 780px; margin: 0 auto; padding: 44px 20px 80px; }
|
||||
.brand { font-weight: 600; letter-spacing: 0.12em; font-size: 13px; color: #111; }
|
||||
h1 { font-size: 30px; line-height: 1.2; margin: 6px 0 6px; font-weight: 600; }
|
||||
h2 { font-size: 20px; margin: 36px 0 10px; padding-top: 14px; border-top: 1px solid #ebeef1; font-weight: 600; }
|
||||
h3 { font-size: 16.5px; margin: 22px 0 6px; font-weight: 600; }
|
||||
p, li { margin: 6px 0; }
|
||||
ul { padding-left: 24px; }
|
||||
.meta { color: #5e6678; font-size: 14px; }
|
||||
.box { border-top: 1px solid #d8dde3; border-bottom: 1px solid #d8dde3; padding: 14px 0; margin: 18px 0; }
|
||||
.box.widerspruch { border-top: 2px solid #111; border-bottom: 2px solid #111; }
|
||||
table { border-collapse: collapse; width: 100%; font-size: 15px; }
|
||||
td, th { padding: 6px 10px 6px 0; border-bottom: 1px solid #ebeef1; vertical-align: top; text-align: left; }
|
||||
th { font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; color: #5e6678; background: #f8f9fb; padding-left: 8px; }
|
||||
a { color: #111; text-decoration: underline; text-decoration-color: #d8dde3; text-underline-offset: 2px; }
|
||||
a:hover { background: #f6f8fa; }
|
||||
nav.top { font-size: 14px; margin-bottom: 22px; }
|
||||
.offen { display: inline-block; background: #fcedb9; color: #a82c00; padding: 1px 10px; border-radius: 6px; font-weight: 600; }
|
||||
@media print {
|
||||
@page { size: A4; margin: 18mm 17mm 18mm;
|
||||
@top-left { content: "WEBklar · ${cssText(seitenkopf)}"; font: 8pt sans-serif; color: #8a93a3; }
|
||||
@bottom-right { content: "Seite " counter(page) " von " counter(pages); font: 8pt sans-serif; color: #8a93a3; } }
|
||||
body { background: #fff; font-size: 10pt; line-height: 1.45; }
|
||||
main { max-width: none; padding: 0; }
|
||||
nav.top { display: none; }
|
||||
h1 { font-size: 20pt; }
|
||||
h2 { font-size: 13pt; margin-top: 16pt; break-after: avoid; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
.box, tr, ol.abs > li { break-inside: avoid; }
|
||||
}
|
||||
`
|
||||
|
||||
/** Adresse eines Links der Navigation: relativ zur Wurzel ("/…", nicht "//") oder https; kein Rückwärtsschrägstrich (Browser lesen ihn wie "/"). */
|
||||
function pruefeHref(href) {
|
||||
if (typeof href !== 'string' || !(/^\/(?!\/)/.test(href) || /^https:\/\/[^\s"'<>]+$/.test(href)) || /[\s"'<>\\]/.test(href)) {
|
||||
@@ -231,8 +276,10 @@ function bloeckeHtml(bloecke) {
|
||||
/**
|
||||
* Rendert ein Dokument in die Seitenhülle der Rechtstexte.
|
||||
* @param {string} md Markdown (Teilmenge, siehe Kopf); erste Zeile "# Titel", danach der Absatz "**Fassung JJJJ-MM-TT**[ · Zusatz]"
|
||||
* @param {{ fassung: string, titel: string, seitenkopf: string, nav: Array<{ href: string, text: string }> }} meta
|
||||
* titel = Seitentitel (vor " – WEBklar"), seitenkopf = Kopfzeile jeder gedruckten Seite hinter "WEBklar · "
|
||||
* @param {{ fassung: string, titel: string, seitenkopf: string, nav: Array<{ href: string, text: string }>, stil?: 'neu', noindex?: boolean }} meta
|
||||
* titel = Seitentitel (vor " – WEBklar"), seitenkopf = Kopfzeile jeder gedruckten Seite hinter "WEBklar · ";
|
||||
* stil = 'neu' wählt die weiße Hülle der Fassung 6 (ohne stil bleibt die Ausgabe bytegleich zur bisherigen Hülle);
|
||||
* noindex = true ergänzt meta robots noindex (Entwurfsseiten)
|
||||
* @returns {string} HTML mit abschließendem Zeilenumbruch
|
||||
*/
|
||||
export function render(md, meta) {
|
||||
@@ -241,6 +288,7 @@ export function render(md, meta) {
|
||||
if (typeof meta[feld] !== 'string' || meta[feld].trim() === '') throw new Error(`render: meta.${feld} fehlt`)
|
||||
}
|
||||
if (!Array.isArray(meta.nav) || meta.nav.length === 0) throw new Error('render: meta.nav fehlt')
|
||||
if (meta.stil !== undefined && meta.stil !== 'neu') throw new Error(`render: meta.stil ist unbekannt (erlaubt: 'neu' oder weglassen): ${String(meta.stil).slice(0, 30)}`)
|
||||
const bloecke = parse(md)
|
||||
if (bloecke[0]?.art !== 'h' || bloecke[0].ebene !== 1) throw new MarkdownFehler(1, 'die erste Zeile muss "# Titel" sein')
|
||||
if (bloecke.filter((b) => b.art === 'h' && b.ebene === 1).length !== 1) throw new MarkdownFehler(1, 'genau eine Überschrift #')
|
||||
@@ -254,9 +302,9 @@ export function render(md, meta) {
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="webklar-fassung" content="${esc(meta.fassung)}">
|
||||
<title>${esc(meta.titel)} – WEBklar</title>
|
||||
${meta.noindex === true ? ' <meta name="robots" content="noindex">\n' : ''} <title>${esc(meta.titel)} – WEBklar</title>
|
||||
<style>
|
||||
${STYLE(meta.seitenkopf)} </style>
|
||||
${(meta.stil === 'neu' ? STYLE_NEU : STYLE)(meta.seitenkopf)} </style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
@@ -338,7 +386,46 @@ export function unterkunftDokumente(fassung = config.unterkunft.fassung) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Sätze der Kommandozeile: Name => Dokumente. */
|
||||
/** Namen der Funktionen in Titel und Kopfzeile der Fassung 6 (Schlüssel wie in FUNKTIONEN_UNTERLAGEN_V6). */
|
||||
const FUNKTIONEN6_NAMEN = {
|
||||
news: 'News-Pinnwand',
|
||||
termine: 'Terminplaner',
|
||||
shop: 'Webshop',
|
||||
unterkunft: 'Buchungskalender',
|
||||
zeiten: 'Öffnungszeiten live',
|
||||
analyse: 'Website-Analyse',
|
||||
}
|
||||
|
||||
/** Entwurfsmarke in der Fassungszeile der Quellen: solange sie dort steht, bekommt die Seite meta robots noindex (Nachtrag N45). */
|
||||
export const ENTWURFSMARKE = 'Entwurf – Prüfung durch Kanzlei offen'
|
||||
|
||||
/**
|
||||
* Die zehn Funktionsunterlagen der Fassung 6 (Leistungsbeschreibungen und AVV, Reihenfolge wie FUNKTIONEN_UNTERLAGEN_V6) im neuen Stil.
|
||||
* doc = '<key>-lb' | '<key>-avv'; Quelle docs/rechtliches/<slug>-v6.md, Ziel public/neu/rechtliches/<slug>-<Fassung>.html (Adressen aus products.js).
|
||||
* Die Navigation verweist nur auf Seiten im Vorschaupfad, nie auf die eingefrorenen Seiten unter /rechtliches/.
|
||||
*/
|
||||
export function funktionen6Dokumente() {
|
||||
return Object.entries(FUNKTIONEN_UNTERLAGEN_V6).flatMap(([key, unterlagen]) => {
|
||||
const name = FUNKTIONEN6_NAMEN[key]
|
||||
const dokumente = [
|
||||
{ art: 'lb', d: unterlagen.lb, titel: `Leistungsbeschreibung ${name}`, seitenkopf: `Leistungsbeschreibung ${name}` },
|
||||
...(unterlagen.avv ? [{ art: 'avv', d: unterlagen.avv, titel: `Auftragsverarbeitungsvertrag ${name}`, seitenkopf: `AVV ${name}` }] : []),
|
||||
]
|
||||
const nav = [
|
||||
{ href: '/dashboard.html', text: '← Kundenportal' },
|
||||
{ href: '/neu/rechtliches/abo-bedingungen.html', text: 'Abo-Bedingungen' },
|
||||
...dokumente.map((x) => ({ href: x.d.url, text: x.seitenkopf })),
|
||||
{ href: '/datenschutz.html', text: 'Datenschutzerklärung' },
|
||||
{ href: 'https://webklar.com/impressum', text: 'Impressum' },
|
||||
]
|
||||
return dokumente.map((x) => ({
|
||||
doc: `${key}-${x.art}`,
|
||||
md: `docs/rechtliches/${basename(x.d.url, `-${x.d.version}.html`)}-v6.md`,
|
||||
html: `public${x.d.url}`,
|
||||
meta: { fassung: x.d.version, titel: x.titel, seitenkopf: x.seitenkopf, nav, stil: 'neu' },
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
/** Markdown eines Dokuments; trägt die Quelle eine andere Fassung (d.stempel), wird die Fassungszeile auf die des Dokuments gesetzt. */
|
||||
export function quelleVon(d, root = ROOT) {
|
||||
@@ -407,30 +494,53 @@ export function auftraegeDokumente(texteFassung = TEXTE_FASSUNG, agbFassung = AG
|
||||
}
|
||||
|
||||
/** Sätze der Kommandozeile: Name => Dokumente. */
|
||||
const SAETZE = { termine: termineDokumente, shop: shopDokumente, unterkunft: unterkunftDokumente, auftraege: auftraegeDokumente }
|
||||
const SAETZE = { termine: termineDokumente, shop: shopDokumente, unterkunft: unterkunftDokumente, auftraege: auftraegeDokumente, funktionen6: funktionen6Dokumente }
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------- Aufruf
|
||||
|
||||
/**
|
||||
* Kommandozeile: `termine|shop|unterkunft|auftraege [--check]`. root = Wurzel des Projekts (Tests übergeben ein Verzeichnis mit Kopien).
|
||||
* Kommandozeile: `termine|shop|unterkunft|auftraege [--check]` und `funktionen6 [--check] [--nur=<doc,doc>]`. root = Wurzel des Projekts
|
||||
* (Tests übergeben ein Verzeichnis mit Kopien).
|
||||
* => Exit-Code: 0 ok, 1 mindestens eine Seite weicht ab (nur --check), 2 falscher Aufruf
|
||||
*/
|
||||
export function lauf(argv, { root = ROOT, log = console.log, fehler = console.error } = {}) {
|
||||
const [satz, ...optionen] = argv
|
||||
const pruefen = optionen.includes('--check')
|
||||
if (!Object.hasOwn(SAETZE, satz ?? '') || optionen.some((o) => o !== '--check')) {
|
||||
fehler('Aufruf: node scripts/build-legal-html.mjs termine|shop|unterkunft [--check]\n node scripts/build-legal-html.mjs auftraege [--check]')
|
||||
const nurOptionen = optionen.filter((o) => o.startsWith('--nur='))
|
||||
const usage = () => fehler(
|
||||
'Aufruf: node scripts/build-legal-html.mjs termine|shop|unterkunft [--check]\n'
|
||||
+ ' node scripts/build-legal-html.mjs auftraege [--check]\n'
|
||||
+ ' node scripts/build-legal-html.mjs funktionen6 [--check] [--nur=<doc,doc>]',
|
||||
)
|
||||
if (!Object.hasOwn(SAETZE, satz ?? '') || optionen.some((o) => o !== '--check' && !o.startsWith('--nur=')) || nurOptionen.length > 1 || (nurOptionen.length === 1 && satz !== 'funktionen6')) {
|
||||
usage()
|
||||
return 2
|
||||
}
|
||||
let dokumente = SAETZE[satz]()
|
||||
if (nurOptionen.length === 1) {
|
||||
// erlaubt sind der Name des Dokuments (doc, z. B. shop-avv) und der Name seiner Datei ohne Fassung und Endung (z. B. avv-webshop)
|
||||
const namen = (d) => [d.doc, basename(d.html).replace(/-\d{4}-\d{2}-\d{2}\.html$/, '')]
|
||||
const gewuenscht = nurOptionen[0].slice('--nur='.length).split(',').map((x) => x.trim())
|
||||
if (gewuenscht.some((x) => !dokumente.some((d) => namen(d).includes(x)))) {
|
||||
fehler(`Unbekanntes Dokument in --nur=. Erlaubt: ${dokumente.map((d) => d.doc).join(', ')} (oder der Dateiname ohne Fassung)`)
|
||||
usage()
|
||||
return 2
|
||||
}
|
||||
dokumente = dokumente.filter((d) => namen(d).some((n) => gewuenscht.includes(n)))
|
||||
}
|
||||
let abweichungen = 0
|
||||
for (const d of SAETZE[satz]()) {
|
||||
const html = render(d.markdown ? d.markdown() : quelleVon(d, root), d.meta)
|
||||
for (const d of dokumente) {
|
||||
const md = d.markdown ? d.markdown() : quelleVon(d, root)
|
||||
// Entwurfsseiten (Marke in der Fassungszeile der Quelle) sind nicht indexierbar; die Marke zu entfernen gibt die Seite frei
|
||||
const meta = d.meta.stil === 'neu' && md.includes(ENTWURFSMARKE) ? { ...d.meta, noindex: true } : d.meta
|
||||
const html = render(md, meta)
|
||||
const ziel = join(root, d.html)
|
||||
if (pruefen) {
|
||||
const gleich = existsSync(ziel) && readFileSync(ziel, 'utf8') === html
|
||||
log(`${gleich ? 'ok ' : 'ABWEICHT'} ${d.html}`)
|
||||
if (!gleich) abweichungen += 1
|
||||
} else {
|
||||
mkdirSync(dirname(ziel), { recursive: true })
|
||||
writeFileSync(ziel, html)
|
||||
log(`-> ${d.html}`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
|
||||
// Generator der Rechtstexte: neuer Stil und Satz funktionen6 (Fassung 6, Entwurf). Ergänzt test/buildLegalHtml.test.js.
|
||||
|
||||
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1' })
|
||||
const { render, lauf, funktionen6Dokumente } = await import('../scripts/build-legal-html.mjs')
|
||||
|
||||
const F = '2026-10-16'
|
||||
const SLUGS = ['leistungsbeschreibung-news', 'leistungsbeschreibung-terminplaner', 'avv-terminplaner', 'leistungsbeschreibung-webshop', 'avv-webshop', 'leistungsbeschreibung-unterkunft', 'avv-unterkunft', 'leistungsbeschreibung-zeiten', 'leistungsbeschreibung-website-analyse', 'avv-website-analyse']
|
||||
test('funktionen6Dokumente: zehn Seiten unter public/neu/rechtliches, Quellen *-v6.md, Navigation im Vorschaupfad', () => {
|
||||
const d = funktionen6Dokumente()
|
||||
assert.deepEqual(d.map((x) => x.doc), ['news-lb', 'termine-lb', 'termine-avv', 'shop-lb', 'shop-avv', 'unterkunft-lb', 'unterkunft-avv', 'zeiten-lb', 'analyse-lb', 'analyse-avv'])
|
||||
assert.deepEqual(d.map((x) => [x.md, x.html]), SLUGS.map((s) => [`docs/rechtliches/${s}-v6.md`, `public/neu/rechtliches/${s}-${F}.html`]))
|
||||
for (const x of d) {
|
||||
assert.deepEqual([x.meta.stil, x.meta.fassung], ['neu', F])
|
||||
const hrefs = x.meta.nav.map((l) => l.href)
|
||||
assert.ok(hrefs.includes('/neu/rechtliches/abo-bedingungen.html') && hrefs.includes(`/${x.html.slice('public/'.length)}`), x.doc)
|
||||
assert.ok(hrefs.every((h) => !h.startsWith('/rechtliches/')), 'keine Verweise auf eingefrorene Seiten')
|
||||
}
|
||||
})
|
||||
test('render mit stil neu: weiß und schwarz ohne Lila und Verlauf; ohne stil bleibt die alte Hülle', () => {
|
||||
const meta = { fassung: F, titel: 'Prüfdokument', seitenkopf: 'Prüfdokument', nav: [{ href: '/dashboard.html', text: 'Portal' }] }
|
||||
const md = `# Titel\n\n**Fassung ${F}** · Entwurf – Prüfung durch Kanzlei offen\n\n## 1. Geltung\n\n(1) Text.\n`
|
||||
const neu = render(md, { ...meta, stil: 'neu' }), alt = render(md, meta)
|
||||
for (const t of ['#1a1f36', '#ebeef1', '#f8f9fb', '#111', '-apple-system']) assert.ok(neu.includes(t), t)
|
||||
assert.doesNotMatch(neu, /#6d1fd1|gradient|#f7f6f3/)
|
||||
assert.ok(alt.includes('#6d1fd1') && alt.includes('#f7f6f3'))
|
||||
assert.equal(neu.replace(/<style>[\s\S]*?<\/style>/, ''), alt.replace(/<style>[\s\S]*?<\/style>/, ''), 'nur die Hülle unterscheidet sich')
|
||||
assert.throws(() => render(md, { ...meta, stil: 'dunkel' }), /stil/)
|
||||
})
|
||||
test('lauf funktionen6: schreibt in neuen Ordner, --check, --nur, falscher Aufruf', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'legalv6-'))
|
||||
try {
|
||||
mkdirSync(join(dir, 'docs/rechtliches'), { recursive: true })
|
||||
for (const s of SLUGS) writeFileSync(join(dir, `docs/rechtliches/${s}-v6.md`), `# T\n\n**Fassung ${F}** · Entwurf\n\nText.\n`)
|
||||
const z = []; const ruf = (...a) => lauf(a, { root: dir, log: (x) => z.push(x), fehler: (x) => z.push(x) })
|
||||
assert.equal(ruf('funktionen6', '--check'), 1)
|
||||
assert.equal(ruf('funktionen6', '--nur=avv-webshop,news-lb'), 0)
|
||||
assert.deepEqual(z.filter((x) => x.startsWith('->')), [`-> public/neu/rechtliches/leistungsbeschreibung-news-${F}.html`, `-> public/neu/rechtliches/avv-webshop-${F}.html`])
|
||||
assert.equal(ruf('funktionen6'), 0); assert.equal(ruf('funktionen6', '--check'), 0)
|
||||
writeFileSync(join(dir, 'docs/rechtliches/avv-webshop-v6.md'), `# T\n\n**Fassung ${F}** · Entwurf\n\nGeändert.\n`)
|
||||
z.length = 0; assert.equal(ruf('funktionen6', '--check'), 1)
|
||||
assert.deepEqual(z.filter((x) => x.startsWith('ABWEICHT')), [`ABWEICHT public/neu/rechtliches/avv-webshop-${F}.html`])
|
||||
assert.equal(ruf('funktionen6', '--schreiben'), 2); assert.equal(ruf('shop', '--nur=x'), 2); assert.equal(ruf('funktionen6', '--nur=gibt-es-nicht'), 2)
|
||||
} finally { rmSync(dir, { recursive: true, force: true }) }
|
||||
})
|
||||
Reference in new issue
Block a user