Smart-Lock-Gastcodes zu Smart-Lock/Alarmanlagen-Code umgebaut

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).
This commit is contained in:
Giuseppe Lombardo 2026-08-13 12:55:29 +00:00
parent f576cddf6a
commit fdf98a70e5
5 changed files with 189 additions and 151 deletions

View File

@ -10,7 +10,7 @@ import { cleaningTasksRouter } from './routes/cleaningTasks';
import { expensesRouter } from './routes/expenses';
import { inventoryRouter } from './routes/inventory';
import { handoverProtocolsRouter } from './routes/handoverProtocols';
import { guestCodesRouter } from './routes/guestCodes';
import { accessCodesRouter } from './routes/accessCodes';
import { storageRouter } from './routes/storage';
import { documentsRouter } from './routes/documents';
import { contractsRouter } from './routes/contracts';
@ -49,7 +49,7 @@ export function createApp() {
app.use('/v1', expensesRouter);
app.use('/v1', inventoryRouter);
app.use('/v1', handoverProtocolsRouter);
app.use('/v1', guestCodesRouter);
app.use('/v1', accessCodesRouter);
app.use('/v1', storageRouter);
app.use('/v1', documentsRouter);
app.use('/v1', contractsRouter);

View File

@ -0,0 +1,105 @@
import { Router, Response } from 'express';
import { PrismaClient, AccessCodeStatus } from '@prisma/client';
import { AuthedRequest, requireAuth, requireRole } from '../middleware/auth';
const prisma = new PrismaClient();
/**
* Router für den Smart-Lock/Alarmanlagen-Code je Mieter.
*
* Zugriffsregel: nur Vermieter/Admin vergeben oder ändern einen Code für
* einen bestimmten Mieter. Der Mieter selbst kann seinen eigenen Code nur
* einsehen (lesend), nicht anlegen, ändern oder widerrufen.
*/
export const accessCodesRouter = Router();
const DEFAULT_LOCK_DEVICE_ID = 'front-door-01';
const ACCESS_CODE_SELECT = {
id: true,
code: true,
lockDeviceId: true,
status: true,
createdAt: true,
updatedAt: true,
user: { select: { id: true, fullName: true } },
assignedBy: { select: { id: true, fullName: true } },
};
// POST /access-codes { userId, code, lockDeviceId? } (nur Vermieter/Admin)
// Vergibt/ersetzt den Code für den angegebenen Mieter. Ein vorhandener
// aktiver Code desselben Mieters wird dabei automatisch widerrufen.
accessCodesRouter.post(
'/access-codes',
requireAuth,
requireRole('LANDLORD', 'ADMIN'),
async (req: AuthedRequest, res: Response) => {
const { userId, code, lockDeviceId } = req.body || {};
if (!userId || typeof userId !== 'string') {
return res.status(400).json({ error: 'userId (Mieter) ist erforderlich' });
}
if (!code || !String(code).trim()) {
return res.status(400).json({ error: 'Code ist erforderlich' });
}
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) {
return res.status(400).json({ error: 'Mieter nicht gefunden' });
}
await prisma.accessCode.updateMany({
where: { userId, status: AccessCodeStatus.ACTIVE },
data: { status: AccessCodeStatus.REVOKED },
});
const accessCode = await prisma.accessCode.create({
data: {
userId,
assignedById: req.user!.id,
code: String(code).trim(),
lockDeviceId: typeof lockDeviceId === 'string' && lockDeviceId.trim() ? lockDeviceId.trim() : DEFAULT_LOCK_DEVICE_ID,
status: AccessCodeStatus.ACTIVE,
},
select: ACCESS_CODE_SELECT,
});
res.status(201).json({ accessCode });
},
);
// GET /access-codes — Vermieter/Admin sehen alle Codes, ein Mieter sieht nur
// seinen eigenen (aktiven) Code.
accessCodesRouter.get('/access-codes', requireAuth, async (req: AuthedRequest, res: Response) => {
const isLandlord = req.user!.role === 'LANDLORD' || req.user!.role === 'ADMIN';
const where: Record<string, unknown> = {};
if (!isLandlord) {
where.userId = req.user!.id;
where.status = AccessCodeStatus.ACTIVE;
}
const accessCodes = await prisma.accessCode.findMany({
where,
orderBy: { createdAt: 'desc' },
select: ACCESS_CODE_SELECT,
});
res.status(200).json({ accessCodes });
});
// DELETE /access-codes/:id — nur Vermieter/Admin widerrufen einen Code.
accessCodesRouter.delete(
'/access-codes/:id',
requireAuth,
requireRole('LANDLORD', 'ADMIN'),
async (req: AuthedRequest, res: Response) => {
const accessCode = await prisma.accessCode.findUnique({ where: { id: req.params.id } });
if (!accessCode) return res.status(404).json({ error: 'Code nicht gefunden' });
await prisma.accessCode.update({
where: { id: req.params.id },
data: { status: AccessCodeStatus.REVOKED },
});
res.status(204).send();
},
);

View File

@ -1,96 +0,0 @@
import { Router, Response } from 'express';
import { PrismaClient, GuestCodeStatus } from '@prisma/client';
import { AuthedRequest, requireAuth } from '../middleware/auth';
const prisma = new PrismaClient();
/**
* Router für Smart-Lock-Gastcodes (Nuki/Tuya). Jeder eingeloggte Nutzer
* (Mieter oder Vermieter) kann einen zeitlich begrenzten Gäste-Code
* generieren. Ein Mieter sieht/verwaltet nur seine EIGENEN ausgestellten
* Codes (wer wann Besuch bekommt, ist privat) Vermieter/Admin sehen alle.
*/
export const guestCodesRouter = Router();
const DEFAULT_LOCK_DEVICE_ID = 'front-door-01';
function generateCode(): string {
return String(Math.floor(100000 + Math.random() * 900000)); // 6-stelliger PIN
}
const GUEST_CODE_SELECT = {
id: true,
code: true,
lockDeviceId: true,
validFrom: true,
validUntil: true,
status: true,
createdAt: true,
issuedBy: { select: { id: true, fullName: true } },
};
guestCodesRouter.post('/guest-codes', requireAuth, async (req: AuthedRequest, res: Response) => {
const { validFrom, validUntil, lockDeviceId } = req.body || {};
if (!validFrom || Number.isNaN(Date.parse(validFrom))) {
return res.status(400).json({ error: 'validFrom (Datum/Zeit) ist erforderlich' });
}
if (!validUntil || Number.isNaN(Date.parse(validUntil))) {
return res.status(400).json({ error: 'validUntil (Datum/Zeit) ist erforderlich' });
}
if (new Date(validUntil) <= new Date(validFrom)) {
return res.status(400).json({ error: 'validUntil muss nach validFrom liegen' });
}
let code = generateCode();
for (let attempt = 0; attempt < 5; attempt += 1) {
const existing = await prisma.guestCode.findUnique({ where: { code } });
if (!existing) break;
code = generateCode();
}
const guestCode = await prisma.guestCode.create({
data: {
issuedById: req.user!.id,
code,
lockDeviceId: typeof lockDeviceId === 'string' && lockDeviceId.trim() ? lockDeviceId.trim() : DEFAULT_LOCK_DEVICE_ID,
validFrom: new Date(validFrom),
validUntil: new Date(validUntil),
status: GuestCodeStatus.ACTIVE,
},
select: GUEST_CODE_SELECT,
});
res.status(201).json({ guestCode });
});
guestCodesRouter.get('/guest-codes', requireAuth, async (req: AuthedRequest, res: Response) => {
const isLandlord = req.user!.role === 'LANDLORD' || req.user!.role === 'ADMIN';
const where: Record<string, unknown> = {};
if (!isLandlord) where.issuedById = req.user!.id;
const guestCodes = await prisma.guestCode.findMany({
where,
orderBy: { validFrom: 'desc' },
select: GUEST_CODE_SELECT,
});
res.status(200).json({ guestCodes });
});
// Widerrufen — eigener Code oder Vermieter/Admin.
guestCodesRouter.delete('/guest-codes/:id', requireAuth, async (req: AuthedRequest, res: Response) => {
const guestCode = await prisma.guestCode.findUnique({ where: { id: req.params.id } });
if (!guestCode) return res.status(404).json({ error: 'Gästecode nicht gefunden' });
const isLandlord = req.user!.role === 'LANDLORD' || req.user!.role === 'ADMIN';
if (!isLandlord && guestCode.issuedById !== req.user!.id) {
return res.status(403).json({ error: 'Du kannst nur deine eigenen Gästecodes widerrufen' });
}
await prisma.guestCode.update({
where: { id: req.params.id },
data: { status: GuestCodeStatus.REVOKED },
});
res.status(204).send();
});

View File

@ -109,9 +109,8 @@ enum HandoverType {
MOVE_OUT
}
enum GuestCodeStatus {
enum AccessCodeStatus {
ACTIVE
EXPIRED
REVOKED
}
@ -187,7 +186,8 @@ model User {
expensesPaid Expense[]
expenseShares ExpenseShare[]
handoverProtocols HandoverProtocol[]
guestCodesIssued GuestCode[]
accessCodeAssigned AccessCode[] @relation("AccessCodeUser")
accessCodesGiven AccessCode[] @relation("AccessCodeAssignedBy")
notifications Notification[]
invitationsSent Invitation[] @relation("InvitationsSent")
storageSlots StorageSlot[]
@ -540,20 +540,26 @@ model MeterReading {
// SMART LOCK / GUEST CODES
// ----------------------------------------------------------------------------
model GuestCode {
// Persönlicher Smart-Lock/Alarmanlagen-Code je Mieter. Anders als ein
// klassischer zeitlich begrenzter Gästecode ist das ein dauerhafter Code für
// den festen Zugang/Alarm-Ein-/Ausschalten des jeweiligen Mieters — daher
// nur vom Vermieter/Admin vergebbar, der Mieter kann seinen eigenen Code nur
// einsehen (nicht selbst anlegen oder ändern).
model AccessCode {
id String @id @default(uuid())
issuedById String @map("issued_by_id")
issuedBy User @relation(fields: [issuedById], references: [id])
userId String @map("user_id")
user User @relation("AccessCodeUser", fields: [userId], references: [id])
assignedById String @map("assigned_by_id")
assignedBy User @relation("AccessCodeAssignedBy", fields: [assignedById], references: [id])
code String @unique
lockDeviceId String @map("lock_device_id") // Nuki/Tuya Device-ID
validFrom DateTime @map("valid_from")
validUntil DateTime @map("valid_until")
status GuestCodeStatus @default(ACTIVE)
code String
lockDeviceId String @map("lock_device_id") // Nuki/Tuya/HmIP Device-ID
status AccessCodeStatus @default(ACTIVE)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("guest_codes")
@@map("access_codes")
}
// ----------------------------------------------------------------------------

View File

@ -926,23 +926,26 @@
<div class="page" id="page-access">
<section id="guestCodesSection">
<h2>Smart-Lock-Gastcodes</h2>
<div class="invite-card" style="margin-bottom:16px;">
<div id="guestCodeFormError" style="display:none" class="form-error"></div>
<form id="guestCodeForm" class="invite-form-row" style="flex-wrap:wrap;">
<section id="accessCodeSection">
<h2>Smart-Lock/Alarmanlagen-Code</h2>
<div id="accessCodeAssignCard" class="invite-card" style="margin-bottom:16px; display:none">
<div id="accessCodeFormError" style="display:none" class="form-error"></div>
<form id="accessCodeForm" class="invite-form-row" style="flex-wrap:wrap;">
<div class="field" style="flex:1 1 200px;">
<label for="guestCodeFrom">Gültig ab</label>
<input type="datetime-local" id="guestCodeFrom" required />
<label for="accessCodeUser">Mieter</label>
<select id="accessCodeUser"></select>
</div>
<div class="field" style="flex:1 1 200px;">
<label for="guestCodeUntil">Gültig bis</label>
<input type="datetime-local" id="guestCodeUntil" required />
<div class="field" style="flex:1 1 160px;">
<label for="accessCodeValue">Code</label>
<input type="text" id="accessCodeValue" placeholder="z. B. 4711" maxlength="20" required />
</div>
<button type="submit" class="btn-primary" style="width:auto" id="guestCodeSubmitBtn">Code generieren</button>
<button type="submit" class="btn-primary" style="width:auto" id="accessCodeSubmitBtn">Code vergeben</button>
</form>
</div>
<div class="ticket-list" id="guestCodeList"></div>
<p id="accessCodeTenantHint" style="color:var(--text-muted); font-size:13px; display:none">
Dieser Code wird ausschließlich vom Vermieter vergeben.
</p>
<div class="ticket-list" id="accessCodeList"></div>
</section>
</div>
@ -1136,7 +1139,7 @@
const EXPENSE_CATEGORY_LABEL = { HAUSHALT: 'Haushalt', LEBENSMITTEL_GEMEINSAM: 'Lebensmittel (gemeinsam)', REPARATUR_VORAUSLAGE: 'Reparatur-Vorauslage', SONSTIGES: 'Sonstiges' };
const ITEM_CONDITION_LABEL = { NEW: 'Neu', GOOD: 'Gut', WEAR_AND_TEAR: 'Abgenutzt', DAMAGED: 'Beschädigt' };
const HANDOVER_TYPE_LABEL = { MOVE_IN: 'Einzug', MOVE_OUT: 'Auszug' };
const GUEST_CODE_STATUS_LABEL = { ACTIVE: 'Aktiv', EXPIRED: 'Abgelaufen', REVOKED: 'Widerrufen' };
const ACCESS_CODE_STATUS_LABEL = { ACTIVE: 'Aktiv', REVOKED: 'Widerrufen' };
const STORAGE_LOCATION_LABEL = { FRIDGE: 'Kühlschrank', FREEZER: 'Gefrierfach', KITCHEN_CABINET_1: 'Küchenschrank 1', KITCHEN_CABINET_2: 'Küchenschrank 2', KITCHEN_CABINET_3: 'Küchenschrank 3', PANTRY: 'Vorratsschrank' };
const TRASH_TYPE_LABEL = { RESTMUELL: 'Restmüll', BIOMUELL: 'Biomüll', GELBER_SACK: 'Gelber Sack', PAPIER: 'Papier', GLAS: 'Glas' };
const TRASH_TYPE_COLOR = { RESTMUELL: '#4A4A4A', BIOMUELL: '#8B5E34', GELBER_SACK: '#E0B400', PAPIER: '#2F80ED', GLAS: '#27AE60' };
@ -1393,7 +1396,7 @@
initExpenseForm();
if (isLandlord) initInventoryForm();
if (isLandlord) initHandoverForm();
initGuestCodeForm();
initAccessCodeForm();
if (isLandlord) initStorageForms();
if (isLandlord) initDocumentForm();
if (isLandlord) initUtilityStatementForm();
@ -1407,7 +1410,7 @@
loadExpenses();
loadInventory();
loadHandoverProtocols();
loadGuestCodes();
loadAccessCodes();
loadStorage();
loadDocuments();
loadNoticeDeadlines();
@ -1423,7 +1426,7 @@
setInterval(loadExpenses, REFRESH_INTERVAL_MS);
setInterval(loadInventory, REFRESH_INTERVAL_MS);
setInterval(loadHandoverProtocols, REFRESH_INTERVAL_MS);
setInterval(loadGuestCodes, REFRESH_INTERVAL_MS);
setInterval(loadAccessCodes, REFRESH_INTERVAL_MS);
setInterval(loadStorage, REFRESH_INTERVAL_MS);
setInterval(loadDocuments, REFRESH_INTERVAL_MS);
setInterval(loadNoticeDeadlines, REFRESH_INTERVAL_MS);
@ -2561,49 +2564,69 @@
}
// ---------------------------------------------------------------------
// SMART-LOCK-GASTCODES
// SMART-LOCK/ALARMANLAGEN-CODE
// ---------------------------------------------------------------------
function initGuestCodeForm() {
const form = document.getElementById('guestCodeForm');
const errorBox = document.getElementById('guestCodeFormError');
async function initAccessCodeForm() {
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
document.getElementById('accessCodeAssignCard').style.display = isLandlord ? 'block' : 'none';
document.getElementById('accessCodeTenantHint').style.display = isLandlord ? 'none' : 'block';
if (!isLandlord) return;
const userSelect = document.getElementById('accessCodeUser');
userSelect.innerHTML = '';
try {
const data = await apiFetch('/tenants');
data.tenants.forEach(t => {
const opt = document.createElement('option');
opt.value = t.id;
opt.textContent = t.room ? `${t.fullName} (${t.room.roomNumber})` : t.fullName;
userSelect.appendChild(opt);
});
} catch (err) {
console.error('[access-codes] Mieterliste laden fehlgeschlagen:', err.message);
}
const form = document.getElementById('accessCodeForm');
const errorBox = document.getElementById('accessCodeFormError');
form.onsubmit = async (e) => {
e.preventDefault();
errorBox.style.display = 'none';
const submitBtn = document.getElementById('guestCodeSubmitBtn');
const submitBtn = document.getElementById('accessCodeSubmitBtn');
submitBtn.disabled = true;
submitBtn.textContent = 'Generiert…';
submitBtn.textContent = 'Vergibt…';
try {
const validFrom = document.getElementById('guestCodeFrom').value;
const validUntil = document.getElementById('guestCodeUntil').value;
await apiFetch('/guest-codes', { method: 'POST', body: JSON.stringify({ validFrom, validUntil }) });
const userId = userSelect.value;
const code = document.getElementById('accessCodeValue').value;
await apiFetch('/access-codes', { method: 'POST', body: JSON.stringify({ userId, code }) });
form.reset();
loadGuestCodes();
loadAccessCodes();
} catch (err) {
errorBox.textContent = err.message;
errorBox.style.display = 'block';
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Code generieren';
submitBtn.textContent = 'Code vergeben';
}
};
}
async function loadGuestCodes() {
async function loadAccessCodes() {
if (!currentUser) return;
try {
const data = await apiFetch('/guest-codes');
renderGuestCodes(data.guestCodes);
const data = await apiFetch('/access-codes');
renderAccessCodes(data.accessCodes);
} catch (err) {
console.error('[guest-codes] Laden fehlgeschlagen:', err.message);
console.error('[access-codes] Laden fehlgeschlagen:', err.message);
}
}
function renderGuestCodes(codes) {
const list = document.getElementById('guestCodeList');
function renderAccessCodes(codes) {
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
const list = document.getElementById('accessCodeList');
list.innerHTML = '';
if (!codes.length) {
list.appendChild(el('div', 'empty-state', 'Noch keine Gästecodes ausgestellt.'));
list.appendChild(el('div', 'empty-state', isLandlord ? 'Noch keine Codes vergeben.' : 'Dir wurde noch kein Code zugewiesen.'));
return;
}
codes.forEach(g => {
@ -2611,23 +2634,23 @@
const left = el('div');
left.appendChild(el('div', 'title', `Code: ${g.code}`));
left.appendChild(el('div', 'meta-text',
`${g.issuedBy.fullName} · ${new Date(g.validFrom).toLocaleString('de-DE')} ${new Date(g.validUntil).toLocaleString('de-DE')} · ${GUEST_CODE_STATUS_LABEL[g.status] || g.status}`));
`${isLandlord && g.user ? g.user.fullName + ' · ' : ''}vergeben von ${g.assignedBy.fullName} · ${ACCESS_CODE_STATUS_LABEL[g.status] || g.status}`));
row.appendChild(left);
if (g.status === 'ACTIVE') {
if (isLandlord && g.status === 'ACTIVE') {
const btn = document.createElement('button');
btn.className = 'btn-secondary';
btn.textContent = 'Widerrufen';
btn.onclick = () => revokeGuestCode(g.id);
btn.onclick = () => revokeAccessCode(g.id);
row.appendChild(btn);
}
list.appendChild(row);
});
}
async function revokeGuestCode(id) {
async function revokeAccessCode(id) {
try {
await apiFetch(`/guest-codes/${id}`, { method: 'DELETE' });
loadGuestCodes();
await apiFetch(`/access-codes/${id}`, { method: 'DELETE' });
loadAccessCodes();
} catch (err) {
alert(err.message);
}