Statt zeitlich begrenzter Gästecodes, die jeder selbst ausstellen konnte,
gibt es jetzt einen dauerhaften Code je Mieter, den ausschließlich der
Vermieter vergibt/widerruft. Der Mieter sieht nur seinen eigenen Code
(read-only).
Chrome blocks top-level navigation to data: URLs (anti-phishing), so
the earlier target="_blank" preview links silently opened a blank tab
for every uploaded document — real bug, caught by live testing.
Replaces that with a same-tab modal: clicking a document name opens
it in an overlay (<iframe> for PDFs, <img> for images — data: URLs
work fine for embedding, just not top-level navigation), with its own
download button and a close control. The small ⬇ icon next to each
chip still downloads directly, unaffected since the `download`
attribute uses a different code path than navigation.
Every uploaded-document link (contract PDFs, ID front/back, Schufa
report — active and archived) previously forced a download on click
via the `download` attribute, with no way to just look at the file
first. New shared buildDocumentChip() renders the filename as a
target="_blank" link (browsers preview PDFs/images natively) plus a
small ⬇ icon next to it for an explicit download, reused across all
three places these chips are rendered.
Accepting an invitation with a roomId previously only set User.roomId
— no Contract row was created. Since every tenant-facing feature
(Miet-Ampel, Vertragsdokumente, Kündigungsfristen, ...) is driven by
Contract, not User.roomId, the tenant would silently show up nowhere
despite "having" a room. Now the accept transaction also creates an
active Contract (rent/deposit defaulted from the room's baseRent/
utilityPauschal, same defaults already used by the "Wieder aufnehmen"
flow) and marks the room OCCUPIED, unless the room already has an
active contract (guards against a stale/duplicate invite race).
Server-side testing showed the 30mb express.json limit itself was
fine for payloads up to 30MB, but real phone photos (15-25MB each,
×3 files) can still exceed that. Adds readFileAsCompressedDataUrl:
downscales image uploads to max 1800px on the long edge at 0.82 JPEG
quality via canvas before converting to a data URL — typically
shrinks a multi-MB phone photo to a few hundred KB, which is more than
sufficient resolution for a legible ID/Schufa scan. PDFs pass through
uncompressed. Also raises the raw pre-upload size check to 25MB/file
and the server body limit to 50mb as a safety margin for PDF-heavy
cases the client-side compression doesn't touch.
Accepting an invitation as TENANT now sends up to three files (ID
front, ID back, optional Schufa) as base64 data URLs in one JSON body.
Raises express.json's limit from 10mb to 30mb so three real phone
photos fit. Also adds a client-side 8MB-per-file check with a clear
error message instead of letting an oversized file hit the server
limit and surface a bare "Status 413".
The "Mieter einladen" form previously listed every room regardless of
occupancy, letting a landlord accidentally assign a new invite to an
already-occupied room. Filters lastRooms to status === 'VACANT' for
that select only — ticket/inventory room pickers still show all rooms
since those aren't about assigning a tenancy.
Accepting an invitation as a TENANT now mandates phone number, current
first-residence address (Erstwohnsitz), and both sides of an ID
document — the accept endpoint rejects the request with a clear error
if any are missing. A Schufa credit report upload stays optional, but
if provided its issue date must be within the last 3 months or the
request is rejected.
New User fields: phoneNumber was already there; added
firstResidenceAddress, idDocumentFrontUrl, idDocumentBackUrl,
schufaDocumentUrl, schufaDocumentDate. Kept nullable at the DB level
(existing accounts have none of this and shouldn't be broken) —
enforcement lives in the accept-invitation route, not a DB constraint.
Landlords now see a completeness badge ("Profil vollständig" /
"Unvollständig: X fehlt") plus a Schufa freshness badge on each
contract card in Verträge & Abrechnung, with direct download links for
the uploaded ID/Schufa files.
Adds "Wieder aufnehmen" to each archived contract card: landlord picks
a vacant room, move-in date, rent and deposit, then POST
/contracts/reactivate creates a fresh Contract row for that user (new
id, new terms, no carried-over documents/signature) and re-assigns the
room. The old contract is never touched — it stays exactly as-is in
the archive as a historical record, per the requirement that the
original tenancy documentation must be preserved even after rejoining.
New GET /contracts/vacant-rooms lists rooms with no active contract,
used to populate the room picker and prefill sensible rent/deposit
defaults from the room's base rent.
Landlords can now remove a tenant from their room via a "Mieter
ausziehen lassen" button on the contract card. This never deletes
anything: POST /contracts/:id/move-out sets Contract.isActive=false
(with an endDate), frees the room (User.roomId=null,
Room.status=VACANT) so it can be re-assigned via the existing invite
flow, and leaves the contract row — along with every linked payment,
ticket, rating, handover protocol, and contract document — untouched
and permanently queryable.
GET /contracts/documents now only lists active contracts; the new
GET /contracts/archive (landlord/admin only) lists deactivated ones,
rendered in a new "Archiv — ausgezogene Mieter" section on the
Verträge-page with their historical documents still downloadable.
Writing the footer near the bottom margin (page.height - 40, past the
56pt bottom margin) made pdfkit's automatic overflow check think the
text didn't fit and insert a new page per footer write, doubling the
page count with blank pages. Fix: zero out page.margins.bottom while
writing the footer, restore it after.
Rewrites contractDocumentGenerator.ts from a short summary into a
proper Wohnraummietvertrag layout: numbered §§ for Vertragsparteien,
Mieträume, Mietzeit, Miete/Nebenkosten, Kaution, Instandhaltung/
Schönheitsreparaturen, Hausordnung, Tierhaltung/Untervermietung,
Mietanpassung, sonstige Vereinbarungen, salvatorische Klausel, plus
signature lines for both landlord and tenant (with the drawn signature
embedded once signed) and a page-footer disclaimer instead of a
top-of-document warning box.
Adds Contract.landlordName/landlordAddress/propertyAddress so the
document can show a real letterhead — addresses default to a
"please fill in" placeholder rather than a fabricated address, since
this is a real WG's data. Landlord fills them once via new fields in
the contract generator form; persisted per contract like the other
template options.
Landlords configure a contract via checkboxes/selects (WLAN included,
furnished, utility billing model, notice period, deposit, rent
adjustment clause) and generate a PDF (pdfkit) that's automatically
attached to the contract's document list. Tenants sign their own
contract in-app by drawing a signature on a canvas; signing embeds the
signature image into a freshly generated final PDF and marks the
contract as signed with a timestamp.
New: Contract.wifiIncluded/furnished/utilityBillingModel (+ new
UtilityBillingModel enum), Contract.tenantSignatureUrl/signedAt.
New endpoints: POST /contracts/:id/generate-document (landlord/admin),
POST /contracts/:id/sign (tenant on own contract, or landlord/admin).
Explicitly a documentation template generated from app data, not a
legally reviewed contract — noted in the PDF itself.
Tenants could previously see every other tenant's private room
inventory including purchase prices. Now matches the app's standard
visibility rule: own room + roomId=NULL (shared) items only; landlord/
admin unchanged (see everything).
Replaces the unused, never-wired-up single contractPdfUrl field with
contractDocumentUrls (String[]) so landlords can attach several files
per contract (signed lease, addenda, ...) instead of just one. New
endpoints: GET /contracts/documents (tenant sees own contract only,
landlord sees all — same visibility rule as the rest of the app),
POST/DELETE /contracts/:id/documents (landlord/admin only). Frontend
reuses the existing FileReader-to-data-URL upload pattern already used
for inventory photos, so files are stored inline like everywhere else
in this app rather than introducing a new storage mechanism.
Rebuilds the "Müll-Kalender" as a proper color-coded FullCalendar (v6,
MIT-licensed, CDN, no build step) month grid instead of a bare list of
type+date rows. Adds a manual "Kalender aktualisieren" action for
landlords (POST /v1/trash-schedule/sync) that re-runs the same KAW
sync job as the daily cron, instead of letting them delete individual
calendar entries by clicking — accidental single-click deletion of
official data was too easy and had no real justification, since a
missing date just gets re-added by the next sync anyway.
Adds trashCalendarSyncJob.ts, which pulls Restmüll/Biomüll/Gelbe Tonne/
Papiertonne dates for Nackenheim (CityId 35, Verbandsgemeinde Bodenheim)
from the public, keyless API behind lk.kaw-mainz-bingen.de's official
waste calendar, and upserts them into trash_schedule. Adds a unique
(type, date) constraint to prevent duplicate entries on reruns. Run via
`npm run trash-sync:run`, same standalone-script pattern as the other
reminder jobs.
20 flat scrolling sections replaced with a 10-item sidebar nav (Übersicht,
Mieter, Tickets & Handwerker, Putzplan, WG-Kasse, Inventar & Übergabe,
Zugang, Küche & Müll, Dokumente, Verträge & Abrechnung). One page visible
at a time, collapsible off-canvas menu on mobile. No backend/API changes.
The lock in this WG is a Homematic IP door lock (keypad + alarm), not
Nuki/Tuya as originally specced — confirmed with the user. Homematic IP
has no official self-service partner API for Access-Point-only setups
(no local CCU), so this uses the actively-maintained open-source
`homematicip` Python library (implements the same reverse-engineered
cloud protocol used by the official app) via two scripts rather than
reimplementing the HTTP/HMAC handshake from scratch — lower risk of
subtly wrong protocol details.
- scripts/hmip_register.py: non-interactive pairing (connection
request -> wait for the physical blue-button press on the Access
Point -> auth token). Writes progress to a status file so the API
can poll it instead of blocking a request for up to ~6 minutes.
- scripts/hmip_control.py: list-devices / lock / unlock via
HMIP_ACCESS_POINT + HMIP_AUTH_TOKEN env vars (not CLI args, so they
don't leak into process listings).
- routes/smartLock.ts: POST /smartlock/pairing/start + GET .../status
(LANDLORD/ADMIN, one-time setup), GET /smartlock/devices
(LANDLORD/ADMIN), POST /smartlock/devices/:id/:lock|unlock (any
authenticated user — this is the tenant-facing "keyless door" use
case from the spec). All lock/unlock routes 409 until pairing has
produced HMIP_ACCESS_POINT/HMIP_AUTH_TOKEN.
Does NOT cover temporary guest PIN codes on the keypad itself — that's
handled through Homematic IP's own "eSchlüssel" app feature, which
isn't exposed by this API; guest codes remain DB-only as before.
"Automatische Warnungen bei Auslauf/Kündigungsfristen" was only a
passive dashboard view (GET /contracts/notice-deadlines, isUrgent
flag) — nobody gets notified unless the landlord happens to open the
cockpit. Adds a daily job (same pattern as dailyDunningJob.ts) that
pushes landlords/admins when a contract's notice deadline falls
within 30 days. Dedup is via the Notification history (payload.
contractId within a 14-day cooldown) rather than a new Contract
column, consistent with contracts.ts's existing "no extra model
needed" approach.
sendPushNotification() (services/notificationService.ts) already called
the real OneSignal API correctly, but two things made it unreachable:
no endpoint ever set User.pushToken, and no job existed for the two
push-based requirements beyond rent dunning — cleaning check-in
reminders and the trash-calendar evening push. Both CleaningTask and
TrashSchedule already had an unused `reminderSentAt` column, so the
schema anticipated this and was just never wired up.
- PUT /v1/auth/push-token: registers the current device's OneSignal
Player ID.
- jobs/cleaningReminderJob.ts, jobs/trashReminderJob.ts: same
find-due/send-once/mark-reminderSentAt pattern as the existing
dailyDunningJob.ts. Safe to run without OneSignal credentials
configured — sendPushNotification degrades to an in-app Notification
log when ONESIGNAL_APP_ID/API_KEY or a user's pushToken are absent.
Not done here (needs real third-party setup from the user, same as the
Cloudflare tunnel token pattern): ONESIGNAL_APP_ID/API_KEY in .env, and
a frontend OneSignal Web SDK integration (service worker, VAPID keys)
to actually populate pushToken from a real browser.
Prisma 5.22's generator wants the explicit output to point at the
internal .prisma/client directory (which @prisma/client forwards to),
not the @prisma/client package directory itself — pointing it there
directly made `prisma generate` fail outright ("replace ... with
../backend/node_modules/.prisma/client").
Implements the previously-deferred "Bewertungssystem für Zwischenmieter"
requirement, which existed only as a vague idea with no model, route, or
UI. Design (confirmed with user): any authenticated user can rate any
tenant (1-5 + optional comment), freestanding (not tied to a contract,
creatable any time), visible only to LANDLORD/ADMIN — tenants can submit
ratings but not view them, to keep WG-internal friction out of the open.
- prisma: TenantRating model + User relations. Also pins the Prisma
Client `output` path explicitly: since schema.prisma lives at the repo
root (no package.json there) while node_modules only exists under
backend/, `prisma generate`'s root-inference walked up past the repo
and wrote into an unrelated ancestor directory when invoked from a
fresh checkout. The explicit relative output keeps repo-root schema +
backend-only deps working the same locally and in Docker.
- backend: GET/POST /v1/ratings (role-gated read), GET /v1/ratings/tenants
(name+room only, any authenticated user, for the picker).
- dashboard: rating form for everyone, landlord-only ratings/summary view.
Implements the "1-Klick-PDF-Export für Handwerker" requirement from
the ticketing module spec, which existed only as a schema stub
(HandoverProtocol.pdfUrl) with no actual generator anywhere in the
codebase. Adds a server-side PDF endpoint (pdfkit, no headless
browser needed) summarizing a ticket's category, priority, status,
room, reporter and description, plus a "PDF" button in the landlord
cockpit that downloads it via an authenticated blob fetch (the API
uses a Bearer token, not cookies, so a plain <a href> wouldn't carry
auth).
The seed's deleteMany() chain didn't cover Ticket (or other tables
that now reference User), so it started failing with a foreign key
violation once real ticket data existed. Rather than cascading the
delete (which would wipe real data on every container restart), seed
now only runs against an empty database and is a no-op otherwise.
ts-node resolved node_modules relative to the script path, so running
from prisma/seed.ts (outside backend/) never found bcryptjs or
@prisma/client in backend/node_modules — the seed silently failed on
every container start. Moving the script under backend/prisma/ fixes
resolution; also adds a missing .gitignore for node_modules.