News: Aufbewahrung, Datenschutz-Abschnitt und Leistungsbeschreibung (Entwurf)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0e0776b406
commit
0dc57fe5e8
6 files changed
+220
-4
No files matched your search
@@ -0,0 +1,22 @@
|
|||||||
|
# Mustertext: Datenschutzerklärung der Kundenwebsite (News-Pinnwand)
|
||||||
|
|
||||||
|
Entwurf, Freigabe Nutzer ausstehend. Der Absatz gehört in die Datenschutzerklärung jeder Kundenwebsite, auf der die News-Pinnwand läuft. Bau die Pinnwand erst ein, wenn er dort steht.
|
||||||
|
|
||||||
|
Hinweise für das Team:
|
||||||
|
|
||||||
|
- Variante A, wenn WEBklar die Website hostet (Abo). Variante B, wenn die Website woanders liegt und die Pinnwand von `project.webklar.com` lädt.
|
||||||
|
- Im sichtbaren Text der Kundenwebsite keine Gedankenstriche (Wunsch von Emma's Schatzkiste). Den Text unverändert übernehmen oder ohne Gedankenstriche anpassen.
|
||||||
|
- Anrede und Ton an die übrige Erklärung der Website anpassen (meist Sie). `[Überschrift]`-Nummerierung der Website beachten.
|
||||||
|
- Beide Varianten setzen voraus, dass das Skript keine Cookies setzt und nichts im Browser speichert. Das gilt für `news.js` (nur lesende Abrufe).
|
||||||
|
|
||||||
|
## Variante A: Website wird von WEBklar gehostet
|
||||||
|
|
||||||
|
### Neuigkeiten auf dieser Website
|
||||||
|
|
||||||
|
Auf dieser Website zeigen wir unter „Neuigkeiten“ kurze Beiträge mit Text und Fotos. Beim Aufruf der Seite lädt Ihr Browser diese Beiträge und Bilder von dem Server, den die WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, in unserem Auftrag betreibt und der bei der Hetzner Online GmbH in Falkenstein (Deutschland) steht. Dabei werden Ihre IP-Adresse, Datum und Uhrzeit des Aufrufs sowie Angaben zu Ihrem Browser und Betriebssystem übertragen und kurzzeitig in Server-Protokollen gespeichert. Die Pinnwand setzt keine Cookies und speichert nichts in Ihrem Browser. Wir brauchen diese Daten, um die Beiträge auszuliefern und den Betrieb sicher zu halten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse liegt in der Darstellung aktueller Informationen und im sicheren, funktionsfähigen Betrieb der Website. Die Protokolle werden nach 14 Tagen gelöscht.
|
||||||
|
|
||||||
|
## Variante B: Website liegt bei einem anderen Anbieter
|
||||||
|
|
||||||
|
### Neuigkeiten auf dieser Website
|
||||||
|
|
||||||
|
Auf dieser Website zeigen wir unter „Neuigkeiten“ kurze Beiträge mit Text und Fotos. Diese Beiträge und Bilder lädt Ihr Browser beim Aufruf der Seite von project.webklar.com, einem Server der WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, der bei der Hetzner Online GmbH in Falkenstein (Deutschland) steht. Dabei werden Ihre IP-Adresse, Datum und Uhrzeit des Aufrufs sowie Angaben zu Ihrem Browser und Betriebssystem an project.webklar.com übertragen und kurzzeitig in Server-Protokollen gespeichert. Die Pinnwand setzt keine Cookies und speichert nichts in Ihrem Browser. Wir nutzen den Dienst, damit wir Ihnen aktuelle Informationen zeigen können, ohne die Website jedes Mal neu zu veröffentlichen. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse liegt in der einfachen und aktuellen Darstellung unserer Neuigkeiten. Die Protokolle werden nach 14 Tagen gelöscht. WEBklar verarbeitet die Daten in unserem Auftrag.
|
||||||
@@ -83,6 +83,13 @@
|
|||||||
<p id="chat-anhaenge">Im Chat können Sie uns auch Bilder, Fotos, Dateien und Sprachnachrichten schicken. Auf Kamera und Mikrofon greift das Portal nur zu, während Sie selbst ein Foto aufnehmen oder eine Sprachnachricht aufzeichnen, und nur, wenn Sie das in Ihrem Browser erlauben. Anhänge werden schon beim Hinzufügen auf unseren Server übertragen. Nicht gesendete Anhänge, auch solche, die Sie vor dem Senden wieder entfernen, löschen wir spätestens nach 24 Stunden. Gesendete Anhänge speichern wir wie Ihre Chat-Nachrichten. Die E-Mail-Benachrichtigung an unsere Mitarbeiter nennt nur Art und Namen der Anhänge, nicht die Dateien selbst.</p>
|
<p id="chat-anhaenge">Im Chat können Sie uns auch Bilder, Fotos, Dateien und Sprachnachrichten schicken. Auf Kamera und Mikrofon greift das Portal nur zu, während Sie selbst ein Foto aufnehmen oder eine Sprachnachricht aufzeichnen, und nur, wenn Sie das in Ihrem Browser erlauben. Anhänge werden schon beim Hinzufügen auf unseren Server übertragen. Nicht gesendete Anhänge, auch solche, die Sie vor dem Senden wieder entfernen, löschen wir spätestens nach 24 Stunden. Gesendete Anhänge speichern wir wie Ihre Chat-Nachrichten. Die E-Mail-Benachrichtigung an unsere Mitarbeiter nennt nur Art und Namen der Anhänge, nicht die Dateien selbst.</p>
|
||||||
<p>Alle Portal-Daten liegen in unseren selbst betriebenen Systemen auf unserem Server bei Hetzner. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO. Wir speichern die Daten für die Dauer der Geschäftsbeziehung und danach bis zum Ablauf der Verjährungsfrist (in der Regel drei Jahre). Für Ihre Abos und Bestellungen gelten die Fristen in Abschnitt 7. Rechnungen bewahren wir wie in Abschnitt 9 beschrieben auf.</p>
|
<p>Alle Portal-Daten liegen in unseren selbst betriebenen Systemen auf unserem Server bei Hetzner. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO. Wir speichern die Daten für die Dauer der Geschäftsbeziehung und danach bis zum Ablauf der Verjährungsfrist (in der Regel drei Jahre). Für Ihre Abos und Bestellungen gelten die Fristen in Abschnitt 7. Rechnungen bewahren wir wie in Abschnitt 9 beschrieben auf.</p>
|
||||||
|
|
||||||
|
<!-- ENTWURF, Freigabe Nutzer ausstehend -->
|
||||||
|
<h2 id="news">6a. News-Beiträge und Fotos</h2>
|
||||||
|
<p>Mit der Funktion „News-Pinnwand“ können Sie im Kundenportal kurze Beiträge mit Titel, Text und Foto schreiben, die auf Ihrer eigenen Website erscheinen. Dafür verarbeiten wir den Inhalt Ihrer Beiträge, hochgeladene Fotos, Zettelfarbe und Anzeigedauer sowie die Zeitpunkte von Anlage, Änderung und Abnahme. Beiträge und Fotos speichern wir auf unserem Server bei Hetzner im Rechenzentrum Falkenstein (Deutschland), wie die übrigen Portal-Daten (Abschnitt 3).</p>
|
||||||
|
<p>Jedes Foto wird beim Hochladen neu kodiert und verkleinert. Dabei entfernen wir alle Metadaten, auch Angaben zum Aufnahmeort (GPS) und zur Kamera. Nicht verwendete Uploads löschen wir spätestens nach 24 Stunden.</p>
|
||||||
|
<p>Veröffentlichte Beiträge sind auf Ihrer Website öffentlich sichtbar, bis die von Ihnen gewählte Anzeigedauer endet oder Sie den Beitrag abnehmen. Die Besucher Ihrer Website sehen dabei nur den Inhalt der Beiträge, nicht Ihre Kundendaten. Sie können Beiträge und Fotos jederzeit im Kundenportal löschen. Was beim Aufruf der Pinnwand durch einen Besucher übertragen wird, steht in der Datenschutzerklärung Ihrer Website.</p>
|
||||||
|
<p>Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Vertrag). Nach dem Ende der Geschäftsbeziehung löschen wir Ihre Beiträge und Fotos zusammen mit den übrigen Portal-Daten nach den Fristen in Abschnitt 6 und 7 und schalten die Funktion ab.</p>
|
||||||
|
|
||||||
<h2>7. Abos und Zahlung über Stripe</h2>
|
<h2>7. Abos und Zahlung über Stripe</h2>
|
||||||
<p>Schließen Sie eines unserer Abos ab, verarbeiten wir Name, E-Mail-Adresse, Rechnungsanschrift, das gewählte Abo und den gebuchten Zusatzspeicher, Abo- und Zahlungsstatus sowie Ihre Rechnungen. Zahlungsdaten wie Kartennummer oder IBAN geben Sie direkt bei unserem Zahlungsdienstleister ein. Wir erhalten davon nur eingeschränkte Angaben, etwa die Art des Zahlungsmittels und die letzten vier Ziffern.</p>
|
<p>Schließen Sie eines unserer Abos ab, verarbeiten wir Name, E-Mail-Adresse, Rechnungsanschrift, das gewählte Abo und den gebuchten Zusatzspeicher, Abo- und Zahlungsstatus sowie Ihre Rechnungen. Zahlungsdaten wie Kartennummer oder IBAN geben Sie direkt bei unserem Zahlungsdienstleister ein. Wir erhalten davon nur eingeschränkte Angaben, etwa die Art des Zahlungsmittels und die letzten vier Ziffern.</p>
|
||||||
<p>Um Ihre Bestellungen nachweisen zu können, speichern wir bei jeder Bestellung Zeitpunkt, Version der Vertragsbedingungen, gegebenenfalls Ihre Angaben zum Leistungsbeginn sowie IP-Adresse und Browserkennung, im Kundenportal und als Angaben zum Abo bei Stripe. Das gilt für den Abschluss eines Abos, für den Wechsel vom Hosting-Abo ins Wartungs-Abo und für jede Bestellung von E-Mail-Zusatzspeicher. Außerdem halten wir Ihre Bestellungen und Änderungen als Vertragsereignisse mit Zeitpunkt und Status fest: Abschluss, Wechsel, Zusatzspeicher einschließlich Verringerungen, Kündigung und Widerruf. Bei Verringerungen, Kündigung und Widerruf speichern wir als Vertragsereignis nur Zeitpunkt und Status, keine IP-Adresse und Browserkennung. Den Stand Ihrer Bestellungen, etwa laufende Widerrufsfristen und die Einrichtung von Zusatzspeicher, sehen Sie im Kundenportal unter „Verträge“. Für uns sind die Vertragsereignisse zugleich die Aufgabenliste, um Änderungen am Zusatzspeicher umzusetzen. Rechtsgrundlagen sind Art. 6 Abs. 1 lit. b, c und f DSGVO. Unser berechtigtes Interesse ist der Nachweis des Vertragsschlusses und Ihrer Bestellungen. Diese Angaben löschen wir drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet.</p>
|
<p>Um Ihre Bestellungen nachweisen zu können, speichern wir bei jeder Bestellung Zeitpunkt, Version der Vertragsbedingungen, gegebenenfalls Ihre Angaben zum Leistungsbeginn sowie IP-Adresse und Browserkennung, im Kundenportal und als Angaben zum Abo bei Stripe. Das gilt für den Abschluss eines Abos, für den Wechsel vom Hosting-Abo ins Wartungs-Abo und für jede Bestellung von E-Mail-Zusatzspeicher. Außerdem halten wir Ihre Bestellungen und Änderungen als Vertragsereignisse mit Zeitpunkt und Status fest: Abschluss, Wechsel, Zusatzspeicher einschließlich Verringerungen, Kündigung und Widerruf. Bei Verringerungen, Kündigung und Widerruf speichern wir als Vertragsereignis nur Zeitpunkt und Status, keine IP-Adresse und Browserkennung. Den Stand Ihrer Bestellungen, etwa laufende Widerrufsfristen und die Einrichtung von Zusatzspeicher, sehen Sie im Kundenportal unter „Verträge“. Für uns sind die Vertragsereignisse zugleich die Aufgabenliste, um Änderungen am Zusatzspeicher umzusetzen. Rechtsgrundlagen sind Art. 6 Abs. 1 lit. b, c und f DSGVO. Unser berechtigtes Interesse ist der Nachweis des Vertragsschlusses und Ihrer Bestellungen. Diese Angaben löschen wir drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet.</p>
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<!-- ENTWURF, Freigabe Nutzer ausstehend -->
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Leistungsbeschreibung Funktionen – News-Pinnwand – WEBklar</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; background: #f7f6f3; color: #1d1d1f; font: 16px/1.6 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; }
|
||||||
|
main { max-width: 780px; margin: 0 auto; padding: 44px 20px 80px; }
|
||||||
|
.brand { font-weight: 800; letter-spacing: 0.12em; font-size: 13px; color: #6d1fd1; }
|
||||||
|
h1 { font-size: 30px; line-height: 1.2; margin: 6px 0 6px; }
|
||||||
|
h2 { font-size: 20px; margin: 36px 0 10px; padding-top: 14px; border-top: 1px solid #e3e0da; }
|
||||||
|
p, li { margin: 6px 0; }
|
||||||
|
ul { padding-left: 24px; }
|
||||||
|
.meta { color: #6b6b70; font-size: 14px; }
|
||||||
|
.box { background: #fff; border: 1px solid #e3e0da; border-radius: 14px; padding: 16px 20px; margin: 18px 0; }
|
||||||
|
table { border-collapse: collapse; width: 100%; font-size: 15px; }
|
||||||
|
td { padding: 6px 10px 6px 0; border-bottom: 1px solid #eee; vertical-align: top; text-align: left; }
|
||||||
|
td:first-child { font-weight: 600; width: 30%; }
|
||||||
|
a { color: #6d1fd1; }
|
||||||
|
nav.top { font-size: 14px; margin-bottom: 22px; }
|
||||||
|
@media print {
|
||||||
|
@page { size: A4; margin: 18mm 17mm 18mm; }
|
||||||
|
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 { break-inside: avoid; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<nav class="top"><a href="/dashboard.html">← Zurück zum Kundenportal</a> · <a href="/abo-bedingungen.html">Abo-Bedingungen</a> · <a href="/datenschutz.html#news">Datenschutz</a></nav>
|
||||||
|
|
||||||
|
<div class="brand">WEBKLAR</div>
|
||||||
|
<h1>Leistungsbeschreibung: Funktionen – News-Pinnwand</h1>
|
||||||
|
<p class="meta">Stand 30.09.2026</p>
|
||||||
|
|
||||||
|
<div class="box">
|
||||||
|
<strong>Das Wichtigste auf einen Blick</strong>
|
||||||
|
<table>
|
||||||
|
<tr><td>Leistung</td><td>Einbau der News-Pinnwand in Ihre Website durch WEBklar; Pflege der Beiträge durch Sie im Kundenportal</td></tr>
|
||||||
|
<tr><td>Preis</td><td>30,00 € einmalig, inklusive Umsatzsteuer, keine laufenden Kosten</td></tr>
|
||||||
|
<tr><td>Dauer des Einbaus</td><td>meist innerhalb von 1–2 Wochen (keine verbindliche Zusage)</td></tr>
|
||||||
|
<tr><td>Widerruf</td><td>Verbraucher können innerhalb von 14 Tagen widerrufen; das Recht kann nach § 356 Abs. 4 BGB erlöschen (Abschnitt 6)</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 id="leistung">1. Leistung</h2>
|
||||||
|
<p>Wir bauen die News-Pinnwand in die Website ein, die Sie für das gewählte Projekt bei uns im Kundenportal führen. Die Pinnwand zeigt Ihre Beiträge als Zettel mit Titel, Text und Foto; pro Seite erscheinen sechs Beiträge, die neuesten zuerst. Die Beiträge schreiben, ändern und löschen Sie selbst im Kundenportal im Bereich „Funktionen“. Sie wählen je Beitrag Zettelfarbe und Anzeigedauer; danach verschwindet der Beitrag von Ihrer Website. Hochgeladene Fotos werden verkleinert, alle Metadaten einschließlich Aufnahmeort (GPS) werden entfernt.</p>
|
||||||
|
<p>Änderungen an der Website, die über den Einbau der Pinnwand hinausgehen, sind nicht Teil dieser Leistung.</p>
|
||||||
|
|
||||||
|
<h2 id="preis">2. Preis</h2>
|
||||||
|
<p>Der Einbau kostet 30,00 € einmalig, inklusive Umsatzsteuer. Die Nutzung der Pinnwand danach kostet nichts; es entstehen keine laufenden Kosten und kein Abo. Sie bezahlen bei der Bestellung im Kundenportal über Stripe und erhalten eine Rechnung.</p>
|
||||||
|
|
||||||
|
<h2 id="einbau">3. Einbau und Freischaltung</h2>
|
||||||
|
<p>Mit Ihrer Bestellung legen wir einen Auftrag für den Einbau an. Der Einbau dauert erfahrungsgemäß meist innerhalb von 1–2 Wochen. Das ist eine Einschätzung und keine verbindliche Zusage eines Termins. Nach Abschluss des Einbaus schalten wir die Funktion für Sie frei; ab dann erscheinen Ihre Beiträge auf der Website.</p>
|
||||||
|
|
||||||
|
<h2 id="ende">4. Ende der Zusammenarbeit</h2>
|
||||||
|
<p>Endet die Zusammenarbeit mit uns, schalten wir die Pinnwand ab. Ihre Beiträge und Fotos löschen wir nach den Fristen der Datenschutzerklärung (<a href="/datenschutz.html#news">Abschnitt 6a</a>).</p>
|
||||||
|
|
||||||
|
<h2 id="inhalte">5. Ihre Inhalte</h2>
|
||||||
|
<p>Sie sind für die Inhalte Ihrer Beiträge und Fotos verantwortlich. Sie versichern, dass Sie die nötigen Rechte an Texten und Fotos haben und dass die Inhalte keine Rechte Dritter verletzen, etwa Urheberrechte, Persönlichkeitsrechte oder das Recht am eigenen Bild abgebildeter Personen.</p>
|
||||||
|
|
||||||
|
<h2 id="widerruf">6. Widerrufsrecht</h2>
|
||||||
|
<p>Sind Sie Verbraucher, können Sie diesen Vertrag innerhalb von 14 Tagen ohne Angabe von Gründen widerrufen. Die Frist beginnt mit dem Vertragsschluss. Zum Widerruf genügt eine eindeutige Erklärung an uns, zum Beispiel über <a href="/widerrufen.html">project.webklar.com/widerrufen.html</a>.</p>
|
||||||
|
<p>Verlangen Sie ausdrücklich, dass wir vor Ablauf der Widerrufsfrist mit dem Einbau beginnen, müssen Sie uns bei einem Widerruf einen angemessenen Betrag für die bis dahin erbrachte Leistung zahlen. Das Widerrufsrecht erlischt nach § 356 Abs. 4 BGB, wenn wir die Leistung vollständig erbracht haben und Sie vorher ausdrücklich zugestimmt haben, dass wir vor Ablauf der Widerrufsfrist beginnen, und Ihre Kenntnis bestätigt haben, dass Sie Ihr Widerrufsrecht bei vollständiger Vertragserfüllung verlieren.</p>
|
||||||
|
|
||||||
|
<p class="meta">Anbieter: WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler. Es gelten ergänzend die <a href="/abo-bedingungen.html">Vertragsbedingungen</a> und die <a href="/datenschutz.html">Datenschutzerklärung</a>.</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -2,6 +2,8 @@ import { rm } from 'node:fs/promises'
|
|||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { config } from '../config.js'
|
import { config } from '../config.js'
|
||||||
import { deleteDocument, listDocuments, Query } from './appwriteAdmin.js'
|
import { deleteDocument, listDocuments, Query } from './appwriteAdmin.js'
|
||||||
|
import { deleteNewsImage } from './newsImages.js'
|
||||||
|
import { disable } from './siteFeatures.js'
|
||||||
import { getStripe } from './stripeBilling.js'
|
import { getStripe } from './stripeBilling.js'
|
||||||
import { filesOf, STATUS } from './takeoverCredits.js'
|
import { filesOf, STATUS } from './takeoverCredits.js'
|
||||||
|
|
||||||
@@ -16,6 +18,9 @@ import { filesOf, STATUS } from './takeoverCredits.js'
|
|||||||
* (purgeExpiredUploads, sechs Monate nach der Entscheidung); hier werden sie nur gemeldet, falls die
|
* (purgeExpiredUploads, sechs Monate nach der Entscheidung); hier werden sie nur gemeldet, falls die
|
||||||
* Bereinigung sie verpasst hat. Genehmigte Antraege sind Belege und bleiben acht Jahre ab Ende des
|
* Bereinigung sie verpasst hat. Genehmigte Antraege sind Belege und bleiben acht Jahre ab Ende des
|
||||||
* Entscheidungsjahres (Abschnitt 9, § 147 AO); erst danach werden sie samt Nachweisen geloescht.
|
* Entscheidungsjahres (Abschnitt 9, § 147 AO); erst danach werden sie samt Nachweisen geloescht.
|
||||||
|
* - News-Pinnwand: Wird ein Kunde geloescht (Frist wie oben), loeschen wir auch seine News-Beitraege samt Fotos
|
||||||
|
* (newsPosts, Bilddateien) und setzen seine Funktionen (portalFeatures) auf disabled. Der Plan fuehrt sie in
|
||||||
|
* plan.news (nur Kunden mit Beitraegen oder noch nicht abgeschalteten Funktionen), damit plan.customers unveraendert bleibt.
|
||||||
* Zeitrechnung in deutscher Zeit: Silvester 23:30 in Berlin ist noch das alte Jahr.
|
* Zeitrechnung in deutscher Zeit: Silvester 23:30 in Berlin ist noch das alte Jahr.
|
||||||
* Nicht abgedeckt: Vertragsereignisse ohne customerId bzw. ohne zugehoeriges Abo-Dokument (Waisen) werden
|
* Nicht abgedeckt: Vertragsereignisse ohne customerId bzw. ohne zugehoeriges Abo-Dokument (Waisen) werden
|
||||||
* weder gefunden noch geloescht; sie muessen von Hand geprueft werden. Listen werden nicht paginiert: wird eine
|
* weder gefunden noch geloescht; sie muessen von Hand geprueft werden. Listen werden nicht paginiert: wird eine
|
||||||
@@ -25,6 +30,8 @@ import { filesOf, STATUS } from './takeoverCredits.js'
|
|||||||
const SUBSCRIPTIONS = () => config.collections.subscriptions
|
const SUBSCRIPTIONS = () => config.collections.subscriptions
|
||||||
const EVENTS = () => config.collections.contractEvents
|
const EVENTS = () => config.collections.contractEvents
|
||||||
const TAKEOVER = () => config.collections.takeoverCredits
|
const TAKEOVER = () => config.collections.takeoverCredits
|
||||||
|
const NEWS_POSTS = () => config.collections.newsPosts
|
||||||
|
const FEATURES = () => config.collections.portalFeatures
|
||||||
|
|
||||||
export const RETENTION_YEARS = 3
|
export const RETENTION_YEARS = 3
|
||||||
export const RECEIPT_YEARS = 8
|
export const RECEIPT_YEARS = 8
|
||||||
@@ -101,6 +108,18 @@ export async function findDueDeletions(now = new Date(), { limit = LIST_LIMIT }
|
|||||||
customers.push({ customerId, lastEndAt, subscriptionIds: docs.map((d) => d.$id), eventIds: events.map((e) => e.$id) })
|
customers.push({ customerId, lastEndAt, subscriptionIds: docs.map((d) => d.$id), eventIds: events.map((e) => e.$id) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const news = []
|
||||||
|
for (const { customerId } of customers) {
|
||||||
|
const posts = await list(NEWS_POSTS(), [Query.equal('customerId', customerId)])
|
||||||
|
const features = (await list(FEATURES(), [Query.equal('customerId', customerId)])).filter((f) => f.state !== 'disabled')
|
||||||
|
if (!posts.length && !features.length) continue
|
||||||
|
news.push({
|
||||||
|
customerId,
|
||||||
|
posts: posts.map((p) => ({ id: p.$id, projectId: p.projectId, imageId: p.imageId || null })),
|
||||||
|
featureIds: features.map((f) => f.$id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const takeover = []
|
const takeover = []
|
||||||
const rejectedCutoff = now.getTime() - config.takeover.rejectedRetentionDays * 86400000
|
const rejectedCutoff = now.getTime() - config.takeover.rejectedRetentionDays * 86400000
|
||||||
const rejected = await list(TAKEOVER(), [Query.equal('status', STATUS.rejected)])
|
const rejected = await list(TAKEOVER(), [Query.equal('status', STATUS.rejected)])
|
||||||
@@ -126,7 +145,7 @@ export async function findDueDeletions(now = new Date(), { limit = LIST_LIMIT }
|
|||||||
reason: `genehmigt ${decided.slice(0, 10)}, Beleg-Aufbewahrung von acht Jahren ab Jahresende abgelaufen`,
|
reason: `genehmigt ${decided.slice(0, 10)}, Beleg-Aufbewahrung von acht Jahren ab Jahresende abgelaufen`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return { customers, takeover, pending, skipped }
|
return { customers, news, takeover, pending, skipped }
|
||||||
}
|
}
|
||||||
|
|
||||||
const isGone = (err) => err?.status === 404
|
const isGone = (err) => err?.status === 404
|
||||||
@@ -145,12 +164,13 @@ async function deleteQuietly(collection, id) {
|
|||||||
* Faellige Daten loeschen. Reihenfolge je Kunde: erst Stripe-Metadaten (Abo-ID steht noch im Plan), dann die
|
* Faellige Daten loeschen. Reihenfolge je Kunde: erst Stripe-Metadaten (Abo-ID steht noch im Plan), dann die
|
||||||
* Appwrite-Dokumente. Lehnt Stripe ab (beendete Abos lassen sich oft nicht mehr aendern), steht das Abo in manual;
|
* Appwrite-Dokumente. Lehnt Stripe ab (beendete Abos lassen sich oft nicht mehr aendern), steht das Abo in manual;
|
||||||
* die Appwrite-Dokumente werden trotzdem geloescht, denn die Frist ist abgelaufen.
|
* die Appwrite-Dokumente werden trotzdem geloescht, denn die Frist ist abgelaufen.
|
||||||
* @returns {Promise<{ deleted: { subscriptions: number, contractEvents: number, takeover: number, stripeCleared: number },
|
* @returns {Promise<{ deleted: { subscriptions: number, contractEvents: number, takeover: number, stripeCleared: number,
|
||||||
|
* newsPosts: number, newsImages: number, features: number },
|
||||||
* manual: { customerId: string, subscriptionId: string, reason: string }[],
|
* manual: { customerId: string, subscriptionId: string, reason: string }[],
|
||||||
* failed: { what: string, id: string, reason: string }[] }>}
|
* failed: { what: string, id: string, reason: string }[] }>}
|
||||||
*/
|
*/
|
||||||
export async function applyDeletions(plan) {
|
export async function applyDeletions(plan) {
|
||||||
const deleted = { subscriptions: 0, contractEvents: 0, takeover: 0, stripeCleared: 0 }
|
const deleted = { subscriptions: 0, contractEvents: 0, takeover: 0, stripeCleared: 0, newsPosts: 0, newsImages: 0, features: 0 }
|
||||||
const manual = []
|
const manual = []
|
||||||
const failed = []
|
const failed = []
|
||||||
const clear = Object.fromEntries(CONSENT_METADATA_KEYS.map((key) => [key, '']))
|
const clear = Object.fromEntries(CONSENT_METADATA_KEYS.map((key) => [key, '']))
|
||||||
@@ -184,6 +204,28 @@ export async function applyDeletions(plan) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const entry of plan.news || []) {
|
||||||
|
for (const post of entry.posts) {
|
||||||
|
// erst das Bild (Dokument bleibt bei Fehler und wird beim naechsten Lauf erneut gefunden), dann der Beitrag
|
||||||
|
if (post.imageId) {
|
||||||
|
const removed = await attempt('newsImages', post.imageId, async () => {
|
||||||
|
await deleteNewsImage(post.projectId, post.imageId)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if (!removed) continue
|
||||||
|
deleted.newsImages += 1
|
||||||
|
}
|
||||||
|
if (await attempt('newsPosts', post.id, () => deleteQuietly(NEWS_POSTS(), post.id))) deleted.newsPosts += 1
|
||||||
|
}
|
||||||
|
for (const id of entry.featureIds) {
|
||||||
|
const off = await attempt('portalFeatures', id, async () => {
|
||||||
|
await disable(id, { adminNote: 'Abgeschaltet: Aufbewahrungsfrist abgelaufen' })
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if (off) deleted.features += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const item of (plan.takeover || []).filter((t) => t.action === 'loeschen')) {
|
for (const item of (plan.takeover || []).filter((t) => t.action === 'loeschen')) {
|
||||||
const removed = await attempt('takeoverCredits', item.id, async () => {
|
const removed = await attempt('takeoverCredits', item.id, async () => {
|
||||||
await rm(path.join(config.takeover.uploadDir, item.id), { recursive: true, force: true })
|
await rm(path.join(config.takeover.uploadDir, item.id), { recursive: true, force: true })
|
||||||
@@ -206,6 +248,10 @@ export function formatReport(plan, result = null) {
|
|||||||
`Kunde ${c.customerId}: letztes Abo endete am ${fmtDate(c.lastEndAt)}; ${c.subscriptionIds.length} Abo(s) (${c.subscriptionIds.join(', ')}), ${c.eventIds.length} Vertragsereignis(se)`
|
`Kunde ${c.customerId}: letztes Abo endete am ${fmtDate(c.lastEndAt)}; ${c.subscriptionIds.length} Abo(s) (${c.subscriptionIds.join(', ')}), ${c.eventIds.length} Vertragsereignis(se)`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
for (const n of plan.news || []) {
|
||||||
|
const images = n.posts.filter((p) => p.imageId).length
|
||||||
|
lines.push(`Kunde ${n.customerId}: ${n.posts.length} News-Beitrag/-Beiträge mit ${images} Foto(s) zu löschen, ${n.featureIds.length} Funktion(en) abzuschalten`)
|
||||||
|
}
|
||||||
const del = plan.takeover.filter((t) => t.action === 'loeschen')
|
const del = plan.takeover.filter((t) => t.action === 'loeschen')
|
||||||
const report = plan.takeover.filter((t) => t.action === 'melden')
|
const report = plan.takeover.filter((t) => t.action === 'melden')
|
||||||
if (del.length) lines.push('', 'Übernahme-Anträge zu löschen:', ...del.map((t) => ` ${t.id}: ${t.reason}`))
|
if (del.length) lines.push('', 'Übernahme-Anträge zu löschen:', ...del.map((t) => ` ${t.id}: ${t.reason}`))
|
||||||
@@ -220,7 +266,8 @@ export function formatReport(plan, result = null) {
|
|||||||
const d = result.deleted
|
const d = result.deleted
|
||||||
lines.push(
|
lines.push(
|
||||||
'',
|
'',
|
||||||
`Gelöscht: ${d.subscriptions} Abo(s), ${d.contractEvents} Vertragsereignis(se), ${d.takeover} Übernahme-Antrag/-Anträge; Stripe-Metadaten geleert bei ${d.stripeCleared} Abo(s).`
|
`Gelöscht: ${d.subscriptions} Abo(s), ${d.contractEvents} Vertragsereignis(se), ${d.takeover} Übernahme-Antrag/-Anträge; Stripe-Metadaten geleert bei ${d.stripeCleared} Abo(s).`,
|
||||||
|
`News: ${d.newsPosts} Beitrag/Beiträge und ${d.newsImages} Foto(s) gelöscht, ${d.features} Funktion(en) abgeschaltet.`
|
||||||
)
|
)
|
||||||
if (result.failed?.length) {
|
if (result.failed?.length) {
|
||||||
lines.push('', 'Fehlgeschlagen (Lauf erneut starten):', ...result.failed.map((f) => ` ${f.what} ${f.id}: ${f.reason}`))
|
lines.push('', 'Fehlgeschlagen (Lauf erneut starten):', ...result.failed.map((f) => ` ${f.what} ${f.id}: ${f.reason}`))
|
||||||
|
|||||||
@@ -462,3 +462,23 @@ test('Fassung 5: Widerrufsbelehrung und Formular wortgleich mit Fassung 4', () =
|
|||||||
const muster = (belehrung) => between(belehrung, 'Widerrufsrecht Sie haben das Recht', 'Ende der Widerrufsbelehrung')
|
const muster = (belehrung) => between(belehrung, 'Widerrufsrecht Sie haben das Recht', 'Ende der Widerrufsbelehrung')
|
||||||
assert.equal(muster(section(cur, 'widerruf', 'formular')), muster(section(v4, 'widerruf', 'formular')))
|
assert.equal(muster(section(cur, 'widerruf', 'formular')), muster(section(v4, 'widerruf', 'formular')))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('legal_datenschutz_news: Abschnitt 6a und Leistungsbeschreibung (Entwurf)', () => {
|
||||||
|
const html = read('public/datenschutz.html')
|
||||||
|
const a = html.indexOf('id="news"')
|
||||||
|
assert.ok(a > html.indexOf('6. Kundenportal'), 'Abschnitt 6a steht nach Abschnitt 6')
|
||||||
|
const b = html.indexOf('<h2>7. ', a)
|
||||||
|
assert.ok(b > a)
|
||||||
|
const news = strip(html.slice(a, b))
|
||||||
|
for (const s of ['News-Beiträge und Fotos', 'Metadaten', 'GPS', 'Falkenstein', 'Art. 6 Abs. 1 lit. b DSGVO']) assert.ok(news.includes(s), s)
|
||||||
|
assert.ok(html.slice(Math.max(0, a - 120), a).includes('ENTWURF, Freigabe Nutzer ausstehend'))
|
||||||
|
assert.ok(html.includes('<h2>7. Abos und Zahlung über Stripe</h2>'), 'keine Umnummerierung')
|
||||||
|
|
||||||
|
const lb = read('public/vertragsunterlagen/funktionen-leistungsbeschreibung.html')
|
||||||
|
assert.ok(lb.includes('ENTWURF, Freigabe Nutzer ausstehend'))
|
||||||
|
const text = strip(lb)
|
||||||
|
for (const s of ['30,00 €', '§ 356 Abs. 4 BGB', '1–2 Wochen', 'keine laufenden Kosten']) assert.ok(text.includes(s), s)
|
||||||
|
|
||||||
|
const muster = read('docs/funktionen/website-datenschutz-absatz.md')
|
||||||
|
assert.ok(!/[–—]/.test(muster.slice(muster.indexOf('## Variante A'))), 'Kundentext ohne Gedankenstriche')
|
||||||
|
})
|
||||||
@@ -15,11 +15,14 @@ const aw = await startAppwriteMock({
|
|||||||
subscriptions: schemaKeys('subscriptions'),
|
subscriptions: schemaKeys('subscriptions'),
|
||||||
contractEvents: schemaKeys('contractEvents'),
|
contractEvents: schemaKeys('contractEvents'),
|
||||||
takeoverCredits: schemaKeys('takeoverCredits'),
|
takeoverCredits: schemaKeys('takeoverCredits'),
|
||||||
|
newsPosts: schemaKeys('newsPosts'),
|
||||||
|
portalFeatures: schemaKeys('portalFeatures'),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const st = await startFakeStripe()
|
const st = await startFakeStripe()
|
||||||
const uploadDir = mkdtempSync(path.join(os.tmpdir(), 'loeschfristen-'))
|
const uploadDir = mkdtempSync(path.join(os.tmpdir(), 'loeschfristen-'))
|
||||||
process.env.TAKEOVER_UPLOAD_DIR = uploadDir
|
process.env.TAKEOVER_UPLOAD_DIR = uploadDir
|
||||||
|
process.env.NEWS_UPLOAD_DIR = path.join(uploadDir, 'news')
|
||||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||||
const { findDueDeletions, applyDeletions, formatReport, CONSENT_METADATA_KEYS } = await import('../server/services/retention.js')
|
const { findDueDeletions, applyDeletions, formatReport, CONSENT_METADATA_KEYS } = await import('../server/services/retention.js')
|
||||||
const { run } = await import('../scripts/loeschfristen.mjs')
|
const { run } = await import('../scripts/loeschfristen.mjs')
|
||||||
@@ -264,3 +267,42 @@ test('genehmigter Antrag ohne decidedAt: übersprungen und gemeldet, kein Rückg
|
|||||||
assert.ok(plan.skipped.some((t) => t.id === 'nd_tk'))
|
assert.ok(plan.skipped.some((t) => t.id === 'nd_tk'))
|
||||||
assert.match(formatReport(plan), /Übersprungen[\s\S]*nd_tk/)
|
assert.match(formatReport(plan), /Übersprungen[\s\S]*nd_tk/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('retention_loescht_news: Beiträge, Fotos und Funktionen fälliger Kunden', async () => {
|
||||||
|
const { storeNewsImage, newsImagePath } = await import('../server/services/newsImages.js')
|
||||||
|
const sharp = (await import('sharp')).default
|
||||||
|
const png = () => sharp({ create: { width: 40, height: 30, channels: 3, background: '#c33' } }).png().toBuffer()
|
||||||
|
sub('rn_sub', 'rn', { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' })
|
||||||
|
sub('rn2_sub', 'rn2', { status: 'active' }) // laufendes Abo: nichts davon wird angefasst
|
||||||
|
stripeSub('rn_sub', 'rn')
|
||||||
|
const { imageId } = await storeNewsImage({ customerId: 'rn', projectId: 'rnp', buffer: await png() })
|
||||||
|
const other = await storeNewsImage({ customerId: 'rn2', projectId: 'rntwop', buffer: await png() })
|
||||||
|
aw.seed('newsPosts', { $id: 'rn_post1', customerId: 'rn', projectId: 'rnp', type: 'photo', imageId, title: '', text: '' })
|
||||||
|
aw.seed('newsPosts', { $id: 'rn_post2', customerId: 'rn', projectId: 'rnp', type: 'note', imageId: null, title: 'Hi', text: 'Hallo' })
|
||||||
|
aw.seed('newsPosts', { $id: 'rn2_post', customerId: 'rn2', projectId: 'rntwop', type: 'photo', imageId: other.imageId, title: '', text: '' })
|
||||||
|
aw.seed('portalFeatures', { $id: 'rn_feat', customerId: 'rn', projectId: 'rnp', featureKey: 'news', state: 'active', enabled: true })
|
||||||
|
aw.seed('portalFeatures', { $id: 'rn2_feat', customerId: 'rn2', projectId: 'rntwop', featureKey: 'news', state: 'active', enabled: true })
|
||||||
|
|
||||||
|
const plan = await findDueDeletions(JAN_2026)
|
||||||
|
const entry = plan.news.find((n) => n.customerId === 'rn')
|
||||||
|
assert.deepEqual(entry.posts.map((p) => p.id).sort(), ['rn_post1', 'rn_post2'])
|
||||||
|
assert.deepEqual(entry.posts.find((p) => p.id === 'rn_post1'), { id: 'rn_post1', projectId: 'rnp', imageId })
|
||||||
|
assert.deepEqual(entry.featureIds, ['rn_feat'])
|
||||||
|
assert.ok(!plan.news.some((n) => n.customerId === 'rn2'))
|
||||||
|
assert.match(formatReport(plan), /Kunde rn: 2 News-Beitrag\/-Beiträge mit 1 Foto\(s\) zu löschen, 1 Funktion\(en\) abzuschalten/)
|
||||||
|
|
||||||
|
const result = await applyDeletions(plan)
|
||||||
|
assert.deepEqual(result.failed, [])
|
||||||
|
assert.equal(result.deleted.newsPosts, 2)
|
||||||
|
assert.equal(result.deleted.newsImages, 1)
|
||||||
|
assert.equal(result.deleted.features, 1)
|
||||||
|
assert.match(formatReport(plan, result), /News: 2 Beitrag\/Beiträge und 1 Foto\(s\) gelöscht, 1 Funktion\(en\) abgeschaltet/)
|
||||||
|
assert.equal([...aw.docs('newsPosts').values()].filter((d) => d.customerId === 'rn').length, 0)
|
||||||
|
assert.equal([...aw.docs('newsPosts').values()].filter((d) => d.customerId === 'rn2').length, 1)
|
||||||
|
assert.equal(existsSync(newsImagePath('rnp', imageId, 'l')), false)
|
||||||
|
assert.equal(existsSync(newsImagePath('rntwop', other.imageId, 'l')), true)
|
||||||
|
const feat = [...aw.docs('portalFeatures').values()].find((d) => d.$id === 'rn_feat')
|
||||||
|
assert.equal(feat.state, 'disabled')
|
||||||
|
assert.equal(feat.enabled, false)
|
||||||
|
assert.equal([...aw.docs('portalFeatures').values()].find((d) => d.$id === 'rn2_feat').state, 'active')
|
||||||
|
})
|
||||||
Reference in new issue
Block a user