feat: Mehrhaushalt-Modell mit privaten und gemeinsamen Beständen, Server-/Haushalts-Admins, OIDC-Selbstregistrierung
This commit is contained in:
1 parent
13e1facb24
commit
36c54cfa1c
28 files changed
+1354
-135
No files matched your search
@@ -60,13 +60,16 @@ export async function GET(req: NextRequest) {
|
||||
user = await db.user.update({ where: { id: byMail.id }, data: { oidcSubject: verified.subject } });
|
||||
} else {
|
||||
const invite = await db.invite.findUnique({ where: { email: verified.email } });
|
||||
if (!invite || invite.acceptedAt !== null || invite.expiresAt.getTime() <= Date.now()) {
|
||||
return deny("keine-einladung");
|
||||
}
|
||||
const invited = invite && invite.acceptedAt === null && invite.expiresAt.getTime() > Date.now() ? invite : null;
|
||||
user = await db.user.create({
|
||||
data: { name: verified.name, email: verified.email, role: invite.role, oidcSubject: verified.subject },
|
||||
data: { name: verified.name, email: verified.email, role: invited?.role ?? "member", oidcSubject: verified.subject },
|
||||
});
|
||||
await db.invite.update({ where: { id: invite.id }, data: { acceptedAt: new Date() } });
|
||||
if (invited) {
|
||||
if (invited.householdId !== null) {
|
||||
await db.householdMember.create({ data: { householdId: invited.householdId, userId: user.id, role: "member" } });
|
||||
}
|
||||
await db.invite.update({ where: { id: invited.id }, data: { acceptedAt: new Date() } });
|
||||
}
|
||||
}
|
||||
}
|
||||
const token = await createSession({ uid: user.id, name: user.name, role: user.role, tokenVersion: user.tokenVersion });
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { validateContact } from "@/lib/contacts";
|
||||
import { requireUser } from "@/lib/authz";
|
||||
import { getViewer } from "@/lib/viewer";
|
||||
import { resolveTarget, scopeWhere } from "@/lib/access";
|
||||
|
||||
export async function GET() {
|
||||
export async function GET(req: NextRequest) {
|
||||
const viewer = await getViewer();
|
||||
if (!viewer) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||
const scopeParam = req.nextUrl.searchParams.get("scope");
|
||||
const db = getDb();
|
||||
const all = await db.contact.findMany({ orderBy: { name: "asc" } });
|
||||
const all = await db.contact.findMany({ where: scopeWhere(viewer), orderBy: { name: "asc" } });
|
||||
if (!scopeParam) return NextResponse.json(all);
|
||||
if (scopeParam === "household" && viewer.householdId !== null) {
|
||||
return NextResponse.json(all.filter((c) => c.householdId === viewer.householdId));
|
||||
}
|
||||
if (scopeParam === "private") {
|
||||
return NextResponse.json(all.filter((c) => c.householdId === null));
|
||||
}
|
||||
return NextResponse.json(all);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!(await requireUser())) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||
const viewer = await getViewer();
|
||||
if (!viewer) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const target = resolveTarget(viewer, body.scope);
|
||||
if (!target.ok) return NextResponse.json({ errors: ["scope"], message: target.error }, { status: 400 });
|
||||
const input = {
|
||||
name: String(body.name ?? ""),
|
||||
email: body.email ? String(body.email) : null,
|
||||
@@ -21,7 +35,13 @@ export async function POST(req: NextRequest) {
|
||||
if (errs.length) return NextResponse.json({ errors: errs }, { status: 400 });
|
||||
const db = getDb();
|
||||
const created = await db.contact.create({
|
||||
data: { name: input.name.trim(), email: input.email?.trim() || null, telegramChatId: input.telegramChatId?.trim() || null },
|
||||
data: {
|
||||
name: input.name.trim(),
|
||||
email: input.email?.trim() || null,
|
||||
telegramChatId: input.telegramChatId?.trim() || null,
|
||||
ownerId: viewer.id,
|
||||
householdId: target.householdId,
|
||||
},
|
||||
});
|
||||
return NextResponse.json(created, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { getViewer } from "@/lib/viewer";
|
||||
|
||||
function forbidden() {
|
||||
return NextResponse.json({ error: "forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
/** Alle Haushalte mit Mitgliederzahl (nur Server-Admin). */
|
||||
export async function GET() {
|
||||
const viewer = await getViewer();
|
||||
if (!viewer || viewer.role !== "admin") return forbidden();
|
||||
const db = getDb();
|
||||
const households = await db.household.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
include: { _count: { select: { members: true, vouchers: true } }, members: { include: { user: true } } },
|
||||
});
|
||||
return NextResponse.json(
|
||||
households.map((h) => ({
|
||||
id: h.id,
|
||||
name: h.name,
|
||||
createdAt: h.createdAt,
|
||||
mitgliederAnzahl: h._count.members,
|
||||
gutscheineAnzahl: h._count.vouchers,
|
||||
mitglieder: h.members.map((m) => ({ userId: m.userId, name: m.user.name, email: m.user.email, rolle: m.role })),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
/** Haushalt anlegen, optional mit erstem Haushalts-Admin (muss unzugeordnet sein). */
|
||||
export async function POST(req: NextRequest) {
|
||||
const viewer = await getViewer();
|
||||
if (!viewer || viewer.role !== "admin") return forbidden();
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const name = typeof body.name === "string" ? body.name.trim() : "";
|
||||
if (!name || name.length > 100) return NextResponse.json({ errors: ["name"] }, { status: 400 });
|
||||
const db = getDb();
|
||||
if (await db.household.findUnique({ where: { name } })) {
|
||||
return NextResponse.json({ errors: ["name"], message: "Haushalt existiert bereits." }, { status: 409 });
|
||||
}
|
||||
const household = await db.household.create({ data: { name } });
|
||||
if (body.adminUserId !== undefined && body.adminUserId !== null) {
|
||||
const adminId = Number(body.adminUserId);
|
||||
if (!Number.isInteger(adminId)) {
|
||||
await db.household.delete({ where: { id: household.id } });
|
||||
return NextResponse.json({ errors: ["adminUserId"] }, { status: 400 });
|
||||
}
|
||||
const candidate = await db.user.findUnique({ where: { id: adminId }, include: { membership: true } });
|
||||
if (!candidate || candidate.membership) {
|
||||
await db.household.delete({ where: { id: household.id } });
|
||||
return NextResponse.json({ errors: ["adminUserId"], message: "Nutzer existiert nicht oder hat bereits einen Haushalt." }, { status: 400 });
|
||||
}
|
||||
await db.householdMember.create({ data: { householdId: household.id, userId: adminId, role: "admin" } });
|
||||
}
|
||||
return NextResponse.json({ ok: true, id: household.id }, { status: 201 });
|
||||
}
|
||||
|
||||
/** Haushalt samt Bestand auflösen (nur Server-Admin). Private Daten bleiben erhalten. */
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const viewer = await getViewer();
|
||||
if (!viewer || viewer.role !== "admin") return forbidden();
|
||||
const id = Number(req.nextUrl.searchParams.get("id"));
|
||||
if (!Number.isInteger(id)) return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||
const db = getDb();
|
||||
const household = await db.household.findUnique({ where: { id } });
|
||||
if (!household) return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||
const voucherIds = (await db.voucher.findMany({ where: { householdId: id }, select: { id: true } })).map((v) => v.id);
|
||||
const contactIds = (await db.contact.findMany({ where: { householdId: id }, select: { id: true } })).map((c) => c.id);
|
||||
await db.$transaction([
|
||||
db.notificationLog.deleteMany({ where: { rule: { voucher: { id: { in: voucherIds } } } } }),
|
||||
db.reminderRule.deleteMany({ where: { voucherId: { in: voucherIds } } }),
|
||||
db.voucherContact.deleteMany({ where: { OR: [{ voucherId: { in: voucherIds } }, { contactId: { in: contactIds } }] } }),
|
||||
db.voucher.deleteMany({ where: { id: { in: voucherIds } } }),
|
||||
db.contact.deleteMany({ where: { id: { in: contactIds } } }),
|
||||
db.invite.deleteMany({ where: { householdId: id } }),
|
||||
db.householdMember.deleteMany({ where: { householdId: id } }),
|
||||
db.household.delete({ where: { id } }),
|
||||
]);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
+55
-10
@@ -1,18 +1,51 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { requireAdmin } from "@/lib/authz";
|
||||
import { getViewer } from "@/lib/viewer";
|
||||
|
||||
/** Nutzer löschen (nicht sich selbst, nicht den letzten Admin). Nur Admin. */
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const admin = await requireAdmin();
|
||||
if (!admin) return NextResponse.json({ error: "forbidden" }, { status: 403 });
|
||||
const id = Number(req.nextUrl.searchParams.get("id"));
|
||||
if (!Number.isInteger(id)) return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||
if (id === admin.id) {
|
||||
/** Nutzer zuordnen/entfernen (nur Server-Admin). */
|
||||
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const viewer = await getViewer();
|
||||
if (!viewer || viewer.role !== "admin") return NextResponse.json({ error: "forbidden" }, { status: 403 });
|
||||
const { id } = await params;
|
||||
const numId = Number(id);
|
||||
if (!Number.isInteger(numId)) return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||
const db = getDb();
|
||||
const target = await db.user.findUnique({ where: { id: numId }, include: { membership: true } });
|
||||
if (!target) return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const hasHousehold = body.householdId !== undefined && body.householdId !== null;
|
||||
if (!hasHousehold) {
|
||||
if (target.membership) await db.householdMember.delete({ where: { userId: numId } });
|
||||
return NextResponse.json({ ok: true, householdId: null });
|
||||
}
|
||||
const hid = Number(body.householdId);
|
||||
if (!Number.isInteger(hid) || !(await db.household.findUnique({ where: { id: hid } }))) {
|
||||
return NextResponse.json({ errors: ["householdId"] }, { status: 400 });
|
||||
}
|
||||
if (target.membership && target.membership.householdId !== hid) {
|
||||
return NextResponse.json({ errors: ["householdId"], message: "Nutzer ist bereits einem anderen Haushalt zugeordnet." }, { status: 409 });
|
||||
}
|
||||
const householdRole = body.householdRole === "admin" ? "admin" : "member";
|
||||
await db.householdMember.upsert({
|
||||
where: { userId: numId },
|
||||
create: { householdId: hid, userId: numId, role: householdRole },
|
||||
update: { householdId: hid, role: householdRole },
|
||||
});
|
||||
return NextResponse.json({ ok: true, householdId: hid });
|
||||
}
|
||||
|
||||
/** Nutzer löschen (nur Server-Admin). Private Daten werden entfernt, gemeinsame Bestände bleiben erhalten. */
|
||||
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const viewer = await getViewer();
|
||||
if (!viewer || viewer.role !== "admin") return NextResponse.json({ error: "forbidden" }, { status: 403 });
|
||||
const { id } = await params;
|
||||
const numId = Number(id);
|
||||
if (!Number.isInteger(numId)) return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||
if (numId === viewer.id) {
|
||||
return NextResponse.json({ errors: ["id"], message: "Das eigene Konto kann nicht gelöscht werden." }, { status: 400 });
|
||||
}
|
||||
const db = getDb();
|
||||
const target = await db.user.findUnique({ where: { id } });
|
||||
const target = await db.user.findUnique({ where: { id: numId } });
|
||||
if (!target) return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||
if (target.role === "admin") {
|
||||
const admins = await db.user.count({ where: { role: "admin" } });
|
||||
@@ -20,6 +53,18 @@ export async function DELETE(req: NextRequest) {
|
||||
return NextResponse.json({ errors: ["id"], message: "Der letzte Admin kann nicht gelöscht werden." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
await db.user.delete({ where: { id } });
|
||||
const privateVoucherIds = (await db.voucher.findMany({ where: { ownerId: numId, householdId: null }, select: { id: true } })).map((v) => v.id);
|
||||
const privateContactIds = (await db.contact.findMany({ where: { ownerId: numId, householdId: null }, select: { id: true } })).map((c) => c.id);
|
||||
await db.$transaction([
|
||||
db.notificationLog.deleteMany({ where: { rule: { voucher: { id: { in: privateVoucherIds } } } } }),
|
||||
db.reminderRule.deleteMany({ where: { voucherId: { in: privateVoucherIds } } }),
|
||||
db.voucherContact.deleteMany({ where: { OR: [{ voucherId: { in: privateVoucherIds } }, { contactId: { in: privateContactIds } }] } }),
|
||||
db.voucher.deleteMany({ where: { id: { in: privateVoucherIds } } }),
|
||||
db.contact.deleteMany({ where: { id: { in: privateContactIds } } }),
|
||||
db.voucher.updateMany({ where: { ownerId: numId }, data: { ownerId: viewer.id } }),
|
||||
db.contact.updateMany({ where: { ownerId: numId }, data: { ownerId: viewer.id } }),
|
||||
db.householdMember.deleteMany({ where: { userId: numId } }),
|
||||
db.user.delete({ where: { id: numId } }),
|
||||
]);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -36,6 +36,9 @@ export async function POST(req: NextRequest) {
|
||||
const created = await db.user.create({
|
||||
data: { name, email: invite.email, passwordHash, role: invite.role },
|
||||
});
|
||||
if (invite.householdId !== null) {
|
||||
await db.householdMember.create({ data: { householdId: invite.householdId, userId: created.id, role: "member" } });
|
||||
}
|
||||
await db.invite.update({ where: { id: invite.id }, data: { acceptedAt: new Date(), tokenHash: `${hashInviteToken(token)}:verbraucht` } });
|
||||
return NextResponse.json({ ok: true, id: created.id });
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { requireAdmin } from "@/lib/authz";
|
||||
import { getViewer } from "@/lib/viewer";
|
||||
import { createInviteToken, hashInviteToken, normalizeEmail, INVITE_VALID_DAYS } from "@/lib/invites";
|
||||
import { sendMail } from "@/lib/mail";
|
||||
|
||||
@@ -9,21 +9,29 @@ function inviteUrl(req: NextRequest, rawToken: string): string {
|
||||
return `${appUrl}/accept-invite?token=${rawToken}`;
|
||||
}
|
||||
|
||||
/** Offene Einladungen. Nur Admin. */
|
||||
/** Offene Einladungen: Server-Admin sieht alle, Haushalts-Admin die des eigenen Haushalts. */
|
||||
export async function GET() {
|
||||
if (!(await requireAdmin())) return NextResponse.json({ error: "forbidden" }, { status: 403 });
|
||||
const viewer = await getViewer();
|
||||
if (!viewer) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||
const db = getDb();
|
||||
const invites = await db.invite.findMany({
|
||||
where: { acceptedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { id: true, email: true, role: true, expiresAt: true, createdAt: true },
|
||||
});
|
||||
return NextResponse.json(invites);
|
||||
if (viewer.role !== "admin" && !(viewer.householdId !== null && viewer.householdRole === "admin")) {
|
||||
return NextResponse.json({ error: "forbidden" }, { status: 403 });
|
||||
}
|
||||
const where = viewer.role === "admin" ? { acceptedAt: null } : { acceptedAt: null, householdId: viewer.householdId };
|
||||
const invites = await db.invite.findMany({ where, orderBy: { createdAt: "desc" } });
|
||||
return NextResponse.json(invites.map(({ id, email, role, householdId, expiresAt, createdAt }) => ({ id, email, role, householdId, expiresAt, createdAt })));
|
||||
}
|
||||
|
||||
/** Neue Einladung per E-Mail. Nur Admin. Gibt den Link einmalig zurück (zum Kopieren). */
|
||||
/**
|
||||
* Neue Einladung per E-Mail. Server-Admin wählt den Haushalt (oder lässt ihn
|
||||
* leer für unzugeordnete Nutzer); Haushalts-Admin lädt immer in den eigenen ein.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!(await requireAdmin())) return NextResponse.json({ error: "forbidden" }, { status: 403 });
|
||||
const viewer = await getViewer();
|
||||
if (!viewer) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||
const isServerAdmin = viewer.role === "admin";
|
||||
const isHouseholdAdmin = viewer.householdId !== null && viewer.householdRole === "admin";
|
||||
if (!isServerAdmin && !isHouseholdAdmin) return NextResponse.json({ error: "forbidden" }, { status: 403 });
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const email = normalizeEmail(typeof body.email === "string" ? body.email : "");
|
||||
if (!email) return NextResponse.json({ errors: ["email"] }, { status: 400 });
|
||||
@@ -31,12 +39,24 @@ export async function POST(req: NextRequest) {
|
||||
if (await db.user.findUnique({ where: { email } })) {
|
||||
return NextResponse.json({ errors: ["email"], message: "Für diese E-Mail existiert bereits ein Konto." }, { status: 409 });
|
||||
}
|
||||
let householdId: number | null = null;
|
||||
if (isServerAdmin) {
|
||||
if (body.householdId !== undefined && body.householdId !== null) {
|
||||
const hid = Number(body.householdId);
|
||||
if (!Number.isInteger(hid) || !(await db.household.findUnique({ where: { id: hid } }))) {
|
||||
return NextResponse.json({ errors: ["householdId"] }, { status: 400 });
|
||||
}
|
||||
householdId = hid;
|
||||
}
|
||||
} else {
|
||||
householdId = viewer.householdId;
|
||||
}
|
||||
const rawToken = createInviteToken();
|
||||
const expiresAt = new Date(Date.now() + INVITE_VALID_DAYS * 24 * 3600 * 1000);
|
||||
await db.invite.upsert({
|
||||
where: { email },
|
||||
create: { email, tokenHash: hashInviteToken(rawToken), role: "member", expiresAt },
|
||||
update: { tokenHash: hashInviteToken(rawToken), expiresAt, acceptedAt: null },
|
||||
create: { email, tokenHash: hashInviteToken(rawToken), role: "member", householdId, expiresAt },
|
||||
update: { tokenHash: hashInviteToken(rawToken), expiresAt, acceptedAt: null, householdId },
|
||||
});
|
||||
const url = inviteUrl(req, rawToken);
|
||||
let mailError: string | null = null;
|
||||
@@ -48,14 +68,19 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ ok: true, url, mailError }, { status: 201 });
|
||||
}
|
||||
|
||||
/** Einladung zurückziehen. Nur Admin. */
|
||||
/** Einladung zurückziehen. Server-Admin überall, Haushalts-Admin nur im eigenen Haushalt. */
|
||||
export async function DELETE(req: NextRequest) {
|
||||
if (!(await requireAdmin())) return NextResponse.json({ error: "forbidden" }, { status: 403 });
|
||||
const viewer = await getViewer();
|
||||
if (!viewer) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||
const id = Number(req.nextUrl.searchParams.get("id"));
|
||||
if (!Number.isInteger(id)) return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||
const db = getDb();
|
||||
const existing = await db.invite.findUnique({ where: { id } });
|
||||
if (!existing) return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||
const allowed =
|
||||
viewer.role === "admin" ||
|
||||
(viewer.householdId !== null && viewer.householdRole === "admin" && existing.householdId === viewer.householdId);
|
||||
if (!allowed) return NextResponse.json({ error: "forbidden" }, { status: 403 });
|
||||
await db.invite.delete({ where: { id } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -1,20 +1,36 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { getSessionUser } from "@/lib/authz";
|
||||
import { getViewer } from "@/lib/viewer";
|
||||
import { normalizeEmail } from "@/lib/invites";
|
||||
|
||||
/** Eigenes Profil inkl. Haushaltszugehörigkeit (für Formulare). */
|
||||
export async function GET() {
|
||||
const viewer = await getViewer();
|
||||
if (!viewer) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||
return NextResponse.json({
|
||||
id: viewer.id,
|
||||
name: viewer.name,
|
||||
email: viewer.email,
|
||||
role: viewer.role,
|
||||
isServerAdmin: viewer.isServerAdmin,
|
||||
householdId: viewer.householdId,
|
||||
householdName: viewer.householdName,
|
||||
householdRole: viewer.householdRole,
|
||||
});
|
||||
}
|
||||
|
||||
/** Eigene E-Mail-Adresse setzen/ändern (für OIDC-Verknüpfung nötig). */
|
||||
export async function PUT(req: NextRequest) {
|
||||
const me = await getSessionUser();
|
||||
if (!me) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||
const viewer = await getViewer();
|
||||
if (!viewer) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const email = normalizeEmail(typeof body.email === "string" ? body.email : "");
|
||||
if (!email) return NextResponse.json({ errors: ["email"] }, { status: 400 });
|
||||
const db = getDb();
|
||||
const taken = await db.user.findUnique({ where: { email } });
|
||||
if (taken && taken.id !== me.id) {
|
||||
if (taken && taken.id !== viewer.id) {
|
||||
return NextResponse.json({ errors: ["email"], message: "Diese E-Mail nutzt bereits ein anderes Konto." }, { status: 409 });
|
||||
}
|
||||
const updated = await db.user.update({ where: { id: me.id }, data: { email } });
|
||||
const updated = await db.user.update({ where: { id: viewer.id }, data: { email } });
|
||||
return NextResponse.json({ ok: true, email: updated.email });
|
||||
}
|
||||
+45
-7
@@ -1,17 +1,55 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { requireAdmin } from "@/lib/authz";
|
||||
import { getViewer } from "@/lib/viewer";
|
||||
|
||||
/** Alle Nutzer (ohne Geheimnisse). Nur Admin. */
|
||||
/**
|
||||
* Nutzerliste:
|
||||
* - Server-Admin: alle Nutzer mit Haushaltszuordnung.
|
||||
* - Haushalts-Admin: nur Mitglieder des eigenen Haushalts.
|
||||
* - Andere: keine Einsicht.
|
||||
*/
|
||||
export async function GET() {
|
||||
const admin = await requireAdmin();
|
||||
if (!admin) return NextResponse.json({ error: "forbidden" }, { status: 403 });
|
||||
const viewer = await getViewer();
|
||||
if (!viewer) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||
const db = getDb();
|
||||
const users = await db.user.findMany({
|
||||
if (viewer.role === "admin") {
|
||||
const users = await db.user.findMany({
|
||||
orderBy: { createdAt: "asc" },
|
||||
include: { membership: { include: { household: true } } },
|
||||
});
|
||||
return NextResponse.json(
|
||||
users.map((u) => ({
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
email: u.email,
|
||||
role: u.role,
|
||||
oidcVerknuepft: u.oidcSubject !== null,
|
||||
createdAt: u.createdAt,
|
||||
householdId: u.membership?.householdId ?? null,
|
||||
householdName: u.membership?.household.name ?? null,
|
||||
householdRole: u.membership?.role ?? null,
|
||||
}))
|
||||
);
|
||||
}
|
||||
if (viewer.householdId === null || viewer.householdRole !== "admin") {
|
||||
return NextResponse.json({ error: "forbidden" }, { status: 403 });
|
||||
}
|
||||
const members = await db.householdMember.findMany({
|
||||
where: { householdId: viewer.householdId },
|
||||
include: { user: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: { id: true, name: true, email: true, role: true, oidcSubject: true, createdAt: true },
|
||||
});
|
||||
return NextResponse.json(
|
||||
users.map((u) => ({ ...u, oidcVerknuepft: u.oidcSubject !== null, oidcSubject: undefined }))
|
||||
members.map((m) => ({
|
||||
id: m.user.id,
|
||||
name: m.user.name,
|
||||
email: m.user.email,
|
||||
role: m.user.role,
|
||||
oidcVerknuepft: m.user.oidcSubject !== null,
|
||||
createdAt: m.user.createdAt,
|
||||
householdId: viewer.householdId,
|
||||
householdName: viewer.householdName,
|
||||
householdRole: m.role,
|
||||
}))
|
||||
);
|
||||
}
|
||||
@@ -2,64 +2,79 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { withLogoUrl } from "@/lib/logo";
|
||||
import { validateVoucher, parseShopUrl, parseVoucherFields } from "@/lib/vouchers";
|
||||
import { requireUser } from "@/lib/authz";
|
||||
import { getViewer } from "@/lib/viewer";
|
||||
import { canAccess, contactUsableIn } from "@/lib/access";
|
||||
|
||||
async function loadVoucher(numId: number) {
|
||||
const db = getDb();
|
||||
const viewer = await getViewer();
|
||||
if (!viewer) return { status: 401 as const };
|
||||
const v = await db.voucher.findUnique({
|
||||
where: { id: numId },
|
||||
include: { contacts: { include: { contact: true } }, rules: true, household: true },
|
||||
});
|
||||
if (!v || !canAccess(viewer, v)) return { status: 404 as const };
|
||||
return { status: 200 as const, viewer, v };
|
||||
}
|
||||
|
||||
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const numId = Number(id);
|
||||
if (!Number.isInteger(numId)) return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||
const db = getDb();
|
||||
const v = await db.voucher.findUnique({ where: { id: numId }, include: { contacts: { include: { contact: true } }, rules: true } });
|
||||
if (!v) return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||
return NextResponse.json(await withLogoUrl(v));
|
||||
const found = await loadVoucher(numId);
|
||||
if (found.status !== 200) return NextResponse.json({ error: found.status === 401 ? "unauthorized" : "not found" }, { status: found.status });
|
||||
return NextResponse.json(await withLogoUrl(found.v));
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const numId = Number(id);
|
||||
if (!Number.isInteger(numId)) return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||
const db = getDb();
|
||||
const v = await db.voucher.findUnique({ where: { id: numId } });
|
||||
if (!v) return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||
const found = await loadVoucher(numId);
|
||||
if (found.status !== 200) return NextResponse.json({ error: found.status === 401 ? "unauthorized" : "not found" }, { status: found.status });
|
||||
const body = await req.json().catch(() => ({}));
|
||||
if (!("shopUrl" in body)) return NextResponse.json({ errors: ["shopUrl"] }, { status: 400 });
|
||||
const shop = parseShopUrl(body.shopUrl);
|
||||
if (shop.error) return NextResponse.json({ errors: ["shopUrl"] }, { status: 400 });
|
||||
const db = getDb();
|
||||
const updated = await db.voucher.update({ where: { id: numId }, data: { shopUrl: shop.url } });
|
||||
return NextResponse.json(await withLogoUrl(updated));
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
if (!(await requireUser())) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||
const { id } = await params;
|
||||
const numId = Number(id);
|
||||
if (!Number.isInteger(numId)) return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||
const found = await loadVoucher(numId);
|
||||
if (found.status !== 200) return NextResponse.json({ error: found.status === 401 ? "unauthorized" : "not found" }, { status: found.status });
|
||||
const body = await req.json().catch(() => ({}));
|
||||
if (body.action !== "redeem") return NextResponse.json({ error: "unknown action" }, { status: 400 });
|
||||
const db = getDb();
|
||||
const v = await db.voucher.findUnique({ where: { id: numId } });
|
||||
if (!v) return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||
const updated = await db.voucher.update({ where: { id: v.id }, data: { status: "eingeloest", restCents: 0 } });
|
||||
const updated = await db.voucher.update({ where: { id: numId }, data: { status: "eingeloest", restCents: 0 } });
|
||||
return NextResponse.json(updated);
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
if (!(await requireUser())) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||
const { id } = await params;
|
||||
const numId = Number(id);
|
||||
if (!Number.isInteger(numId)) return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||
const db = getDb();
|
||||
const existing = await db.voucher.findUnique({ where: { id: numId } });
|
||||
if (!existing) return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||
const found = await loadVoucher(numId);
|
||||
if (found.status !== 200) return NextResponse.json({ error: found.status === 401 ? "unauthorized" : "not found" }, { status: found.status });
|
||||
const { viewer } = found;
|
||||
const body = await req.json();
|
||||
const { fields, errors: parseErrors } = parseVoucherFields(body);
|
||||
const errs = [...parseErrors, ...validateVoucher(fields)];
|
||||
if (errs.length) return NextResponse.json({ errors: errs }, { status: 400 });
|
||||
const db = getDb();
|
||||
if (fields.contactIds.length) {
|
||||
const found = await db.contact.findMany({ where: { id: { in: fields.contactIds } }, select: { id: true } });
|
||||
if (found.length !== new Set(fields.contactIds).size) {
|
||||
const contacts = await db.contact.findMany({ where: { id: { in: fields.contactIds } } });
|
||||
if (contacts.length !== new Set(fields.contactIds).size) {
|
||||
return NextResponse.json({ errors: ["contactIds"] }, { status: 400 });
|
||||
}
|
||||
const householdId = found.v.householdId;
|
||||
if (contacts.some((c) => !contactUsableIn(viewer, householdId, c))) {
|
||||
return NextResponse.json({ errors: ["contactIds"] }, { status: 403 });
|
||||
}
|
||||
}
|
||||
await db.$transaction([
|
||||
db.voucherContact.deleteMany({ where: { voucherId: numId } }),
|
||||
@@ -91,13 +106,12 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id
|
||||
}
|
||||
|
||||
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
if (!(await requireUser())) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||
const { id } = await params;
|
||||
const numId = Number(id);
|
||||
if (!Number.isInteger(numId)) return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||
const found = await loadVoucher(numId);
|
||||
if (found.status !== 200) return NextResponse.json({ error: found.status === 401 ? "unauthorized" : "not found" }, { status: found.status });
|
||||
const db = getDb();
|
||||
const existing = await db.voucher.findUnique({ where: { id: numId } });
|
||||
if (!existing) return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||
await db.voucher.delete({ where: { id: numId } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -3,12 +3,21 @@ import { getDb } from "@/lib/db";
|
||||
import { getVoucherStatus } from "@/lib/voucher-status";
|
||||
import { validateVoucher, isExpiringSoon, parseVoucherFields } from "@/lib/vouchers";
|
||||
import { withLogoUrl } from "@/lib/logo";
|
||||
import { requireUser } from "@/lib/authz";
|
||||
import { getViewer } from "@/lib/viewer";
|
||||
import { canAccess, contactUsableIn, resolveTarget, scopeWhere } from "@/lib/access";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const viewer = await getViewer();
|
||||
if (!viewer) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||
const filter = req.nextUrl.searchParams.get("filter");
|
||||
const scopeParam = req.nextUrl.searchParams.get("bestand") ?? (viewer.householdId !== null ? "household" : "private");
|
||||
const db = getDb();
|
||||
const all = await db.voucher.findMany({ orderBy: { createdAt: "desc" } });
|
||||
const scoped = await db.voucher.findMany({ where: scopeWhere(viewer), orderBy: { createdAt: "desc" } });
|
||||
const all = scopeParam === "household" && viewer.householdId !== null
|
||||
? scoped.filter((v) => v.householdId === viewer.householdId)
|
||||
: scopeParam === "private"
|
||||
? scoped.filter((v) => v.householdId === null)
|
||||
: scoped;
|
||||
const filtered = all.filter((v) => {
|
||||
const s = getVoucherStatus(v);
|
||||
if (filter === "eingeloest") return s === "eingeloest";
|
||||
@@ -21,20 +30,23 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const me = await requireUser();
|
||||
if (!me) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||
const viewer = await getViewer();
|
||||
if (!viewer) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||
const body = await req.json();
|
||||
const target = resolveTarget(viewer, body.scope ?? body.ziel);
|
||||
if (!target.ok) return NextResponse.json({ errors: ["scope"], message: target.error }, { status: 400 });
|
||||
const { fields, errors: parseErrors } = parseVoucherFields(body);
|
||||
const errs = [...parseErrors, ...validateVoucher(fields)];
|
||||
if (errs.length) return NextResponse.json({ errors: errs }, { status: 400 });
|
||||
const db = getDb();
|
||||
if (fields.contactIds.length) {
|
||||
const found = await db.contact.findMany({ where: { id: { in: fields.contactIds } }, select: { id: true } });
|
||||
const found = await db.contact.findMany({ where: { id: { in: fields.contactIds } } });
|
||||
if (found.length !== new Set(fields.contactIds).size) {
|
||||
return NextResponse.json({ errors: ["contactIds"] }, { status: 400 });
|
||||
}
|
||||
const unusable = found.filter((c) => !contactUsableIn(viewer, target.householdId, c));
|
||||
if (unusable.length) return NextResponse.json({ errors: ["contactIds"] }, { status: 403 });
|
||||
}
|
||||
const createdBy: string | null = me.name;
|
||||
const created = await db.voucher.create({
|
||||
data: {
|
||||
name: fields.name.trim(),
|
||||
@@ -49,8 +61,10 @@ export async function POST(req: NextRequest) {
|
||||
kategorie: fields.kategorie,
|
||||
notiz: fields.notiz,
|
||||
providerDomain: fields.providerDomain,
|
||||
createdBy,
|
||||
createdBy: viewer.name,
|
||||
shopUrl: fields.shopUrl,
|
||||
ownerId: viewer.id,
|
||||
householdId: target.householdId,
|
||||
contacts: fields.contactIds.length ? { create: fields.contactIds.map((contactId) => ({ contactId })) } : undefined,
|
||||
rules: {
|
||||
create: fields.reminderDays.map((tageVorher) => ({ tageVorher })),
|
||||
|
||||
@@ -11,6 +11,8 @@ export type OverviewParams = {
|
||||
sort: string;
|
||||
ansicht: string;
|
||||
gruppe: string;
|
||||
bestand: string;
|
||||
householdName: string | null;
|
||||
};
|
||||
|
||||
const FILTERS = [
|
||||
@@ -30,6 +32,7 @@ function toUrl(p: OverviewParams) {
|
||||
if (p.sort !== "neu") q.set("sort", p.sort);
|
||||
if (p.ansicht !== "liste") q.set("ansicht", p.ansicht);
|
||||
if (p.gruppe !== "keine") q.set("gruppe", p.gruppe);
|
||||
if (p.bestand && p.bestand !== "household") q.set("bestand", p.bestand);
|
||||
const s = q.toString();
|
||||
return s ? `/?${s}` : "/";
|
||||
}
|
||||
@@ -62,16 +65,42 @@ export default function OverviewToolbar({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [q]);
|
||||
|
||||
const bestandDefault = current.householdName !== null ? "household" : "private";
|
||||
const hasActive =
|
||||
current.filter !== "" ||
|
||||
current.q !== "" ||
|
||||
current.kat !== "" ||
|
||||
current.typ !== "" ||
|
||||
current.sort !== "neu" ||
|
||||
current.gruppe !== "keine";
|
||||
current.gruppe !== "keine" ||
|
||||
(current.bestand !== "" && current.bestand !== bestandDefault);
|
||||
|
||||
return (
|
||||
<div className="mb-4 grid gap-4 rounded-card border border-line bg-white p-4">
|
||||
{current.householdName !== null && (
|
||||
<div className="flex flex-wrap gap-2" role="group" aria-label="Bestand">
|
||||
{(
|
||||
[
|
||||
{ key: "household", label: current.householdName },
|
||||
{ key: "private", label: "Privat" },
|
||||
] as const
|
||||
).map((b) => (
|
||||
<button
|
||||
key={b.key}
|
||||
type="button"
|
||||
onClick={() => update({ bestand: b.key })}
|
||||
aria-current={current.bestand === b.key ? "page" : undefined}
|
||||
className={`rounded-full border px-4 py-2 text-[13px] font-semibold transition-colors ${
|
||||
current.bestand === b.key
|
||||
? "border-brand-700 bg-brand-600 text-white"
|
||||
: "border-line bg-white text-ink hover:border-brand-600 hover:text-brand-700"
|
||||
}`}
|
||||
>
|
||||
{b.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label htmlFor="q" className="sr-only">
|
||||
Suchen
|
||||
|
||||
@@ -35,6 +35,7 @@ export type VoucherFormInitial = {
|
||||
shopUrl: string;
|
||||
providerDomain: string | null;
|
||||
providerName: string;
|
||||
scope: "" | "private" | "household";
|
||||
contactIds: number[];
|
||||
reminderDays: number[];
|
||||
};
|
||||
@@ -53,6 +54,7 @@ export type VoucherFormPayload = {
|
||||
notiz: string | null;
|
||||
shopUrl: string | null;
|
||||
providerDomain: string | null;
|
||||
scope: "private" | "household" | "";
|
||||
contactIds: number[];
|
||||
reminderDays: number[];
|
||||
};
|
||||
@@ -74,11 +76,13 @@ export const EMPTY_FORM: VoucherFormInitial = {
|
||||
providerName: "",
|
||||
contactIds: [],
|
||||
reminderDays: [...DEFAULT_REMINDER_DAYS],
|
||||
scope: "",
|
||||
};
|
||||
|
||||
export default function VoucherForm({
|
||||
initial,
|
||||
showShopLink = true,
|
||||
householdName,
|
||||
creatorName,
|
||||
submitLabel,
|
||||
onSubmit,
|
||||
@@ -86,6 +90,7 @@ export default function VoucherForm({
|
||||
}: {
|
||||
initial: VoucherFormInitial;
|
||||
showShopLink?: boolean;
|
||||
householdName: string | null;
|
||||
creatorName?: string | null;
|
||||
submitLabel: string;
|
||||
onSubmit: (payload: VoucherFormPayload) => Promise<{ ok: true; id: number } | { ok: false; errors: string[]; serverError: string }>;
|
||||
@@ -106,8 +111,9 @@ export default function VoucherForm({
|
||||
}));
|
||||
const [errors, setErrors] = useState<string[]>([]);
|
||||
const [serverError, setServerError] = useState("");
|
||||
const [contacts, setContacts] = useState<{ id: number; name: string }[]>([]);
|
||||
const [contacts, setContacts] = useState<{ id: number; name: string; householdId: number | null }[]>([]);
|
||||
const [contactIds, setContactIds] = useState<number[]>(() => [...initial.contactIds]);
|
||||
const [target, setTarget] = useState<"" | "private" | "household">(initial.scope);
|
||||
const [wertTyp, setWertTyp] = useState<"euro" | "prozent">(initial.wertTyp);
|
||||
const [reminderDays, setReminderDays] = useState<number[]>(() => [...initial.reminderDays]);
|
||||
const [candidates, setCandidates] = useState<LogoCandidate[]>([]);
|
||||
@@ -165,6 +171,8 @@ export default function VoucherForm({
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const fieldErrors: string[] = [];
|
||||
if (!target) fieldErrors.push("scope");
|
||||
const wertCents = euroToCents(values.wertEuro);
|
||||
const restParsed = euroToCents(values.restEuro);
|
||||
const mindestParsed = euroToCents(values.mindestEuro);
|
||||
@@ -182,10 +190,11 @@ export default function VoucherForm({
|
||||
kategorie: values.kategorie || null,
|
||||
notiz: values.notiz || null,
|
||||
providerDomain: selectedLogo?.domain ?? null,
|
||||
scope: target,
|
||||
contactIds,
|
||||
reminderDays,
|
||||
};
|
||||
const errs = validateVoucher(payload);
|
||||
const errs = [...fieldErrors, ...validateVoucher(payload)];
|
||||
setErrors(errs);
|
||||
if (errs.length) return;
|
||||
const result = await onSubmit(payload);
|
||||
@@ -197,6 +206,12 @@ export default function VoucherForm({
|
||||
onSuccess(result.id);
|
||||
}
|
||||
|
||||
const visibleContacts = contacts.filter((c) => {
|
||||
if (!target) return true;
|
||||
if (target === "household") return c.householdId !== null;
|
||||
return c.householdId === null;
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="mt-5 rounded-card border border-line bg-white p-5 sm:p-6" noValidate>
|
||||
{creatorName !== undefined && (
|
||||
@@ -204,6 +219,38 @@ export default function VoucherForm({
|
||||
Angelegt von {creatorName ? <span className="font-semibold text-ink">{creatorName}</span> : "nicht erfasst"} (bleibt unverändert).
|
||||
</p>
|
||||
)}
|
||||
<fieldset className="mb-4">
|
||||
<legend className="text-[13px] font-semibold">Wo soll der Gutschein gespeichert werden?</legend>
|
||||
<div className="mt-2 flex gap-2" role="radiogroup" aria-label="Bestand">
|
||||
{(
|
||||
[
|
||||
{ key: "private", label: "Privat" },
|
||||
...(householdName ? [{ key: "household", label: householdName } as const] : []),
|
||||
] as const
|
||||
).map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={target === t.key}
|
||||
onClick={() => {
|
||||
setTarget(t.key);
|
||||
setContactIds([]);
|
||||
}}
|
||||
className={`h-11 flex-1 rounded-field border text-sm font-semibold transition-colors sm:flex-none sm:px-8 ${
|
||||
target === t.key
|
||||
? "border-brand-700 bg-brand-600 text-white"
|
||||
: "border-line bg-white hover:border-brand-600"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{errors.includes("scope") && (
|
||||
<p className="mt-1 text-[13px] font-medium text-danger">Bitte wähle Privat oder einen Haushalt.</p>
|
||||
)}
|
||||
</fieldset>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{FIELDS.filter((f) => showShopLink || f.name !== "shopUrl").map((f) => (
|
||||
<div key={f.name} className={f.name === "notiz" ? "sm:col-span-2" : undefined}>
|
||||
@@ -404,13 +451,15 @@ export default function VoucherForm({
|
||||
<section id="kontakte" aria-label="Empfänger" className="mt-4 rounded-field bg-wash p-4">
|
||||
<h2 className="text-[13px] font-semibold">Empfänger</h2>
|
||||
<p className="mt-0.5 text-[13px] text-muted">Wer soll an diesen Gutschein erinnert werden?</p>
|
||||
{contacts.length === 0 ? (
|
||||
{!target ? (
|
||||
<p className="mt-2 text-[13px] text-muted">Wähle zuerst oben Privat oder Haushalt.</p>
|
||||
) : visibleContacts.length === 0 ? (
|
||||
<p className="mt-2 text-[13px] text-muted">
|
||||
Noch keine Kontakte. Lege sie unter <Link href="/contacts" className="font-semibold text-brand-700 underline">Kontakte</Link> an.
|
||||
Noch keine Kontakte in diesem Bestand. Lege sie unter <Link href="/contacts" className="font-semibold text-brand-700 underline">Kontakte</Link> an.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-2 grid gap-1.5">
|
||||
{contacts.map((c) => (
|
||||
{visibleContacts.map((c) => (
|
||||
<li key={c.id}>
|
||||
<label className="flex cursor-pointer items-center gap-2.5 rounded-lg px-1 py-1 text-sm hover:bg-white">
|
||||
<input
|
||||
|
||||
+49
-7
@@ -4,7 +4,7 @@ import Link from "next/link";
|
||||
import Shell from "../components/shell";
|
||||
import { validateContact } from "@/lib/contacts";
|
||||
|
||||
type Contact = { id: number; name: string; email: string | null; telegramChatId: string | null };
|
||||
type Contact = { id: number; name: string; email: string | null; telegramChatId: string | null; householdId: number | null };
|
||||
|
||||
const inputClass =
|
||||
"mt-1.5 h-11 w-full rounded-field border border-line bg-white px-3.5 text-sm text-ink placeholder:text-muted/60";
|
||||
@@ -15,12 +15,20 @@ export default function ContactsPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [telegramChatId, setTelegramChatId] = useState("");
|
||||
const [errors, setErrors] = useState<string[]>([]);
|
||||
const [scope, setScope] = useState<"private" | "household">("private");
|
||||
const [householdName, setHouseholdName] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
const res = await fetch("/api/contacts");
|
||||
if (res.ok) setContacts(await res.json());
|
||||
}
|
||||
useEffect(() => { load(); }, []);
|
||||
useEffect(() => {
|
||||
load();
|
||||
fetch("/api/users/me")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((me) => setHouseholdName(me?.householdName ?? null))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -31,7 +39,7 @@ export default function ContactsPage() {
|
||||
const res = await fetch("/api/contacts", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
body: JSON.stringify({ ...input, scope }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
@@ -50,6 +58,35 @@ export default function ContactsPage() {
|
||||
<h1 className="mt-2 text-2xl font-semibold tracking-tight">Kontakte</h1>
|
||||
<p className="mt-1 text-sm text-muted">Mindestens E-Mail oder Telegram pro Kontakt.</p>
|
||||
<form onSubmit={onSubmit} className="mt-5 rounded-card border border-line bg-white p-5 sm:p-6" noValidate>
|
||||
<fieldset className="mb-4">
|
||||
<legend className="text-[13px] font-semibold">Wo soll der Kontakt gespeichert werden?</legend>
|
||||
<div className="mt-2 flex gap-2" role="radiogroup" aria-label="Bestand">
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={scope === "private"}
|
||||
onClick={() => setScope("private")}
|
||||
className={`h-11 flex-1 rounded-field border text-sm font-semibold transition-colors sm:flex-none sm:px-8 ${
|
||||
scope === "private" ? "border-brand-700 bg-brand-600 text-white" : "border-line bg-white hover:border-brand-600"
|
||||
}`}
|
||||
>
|
||||
Privat
|
||||
</button>
|
||||
{householdName && (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={scope === "household"}
|
||||
onClick={() => setScope("household")}
|
||||
className={`h-11 flex-1 rounded-field border text-sm font-semibold transition-colors sm:flex-none sm:px-8 ${
|
||||
scope === "household" ? "border-brand-700 bg-brand-600 text-white" : "border-line bg-white hover:border-brand-600"
|
||||
}`}
|
||||
>
|
||||
{householdName}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<label htmlFor="name" className="block text-[13px] font-semibold">Name</label>
|
||||
@@ -106,10 +143,15 @@ export default function ContactsPage() {
|
||||
) : (
|
||||
<ul className="divide-y divide-line">
|
||||
{contacts.map((c) => (
|
||||
<li key={c.id} className="py-3 text-sm">
|
||||
<span className="font-semibold">{c.name}</span>
|
||||
{c.email && <span className="text-muted"> · {c.email}</span>}
|
||||
{c.telegramChatId && <span className="text-muted"> · Telegram {c.telegramChatId}</span>}
|
||||
<li key={c.id} className="flex items-center justify-between gap-3 py-3 text-sm">
|
||||
<div>
|
||||
<span className="font-semibold">{c.name}</span>
|
||||
{c.email && <span className="text-muted"> · {c.email}</span>}
|
||||
{c.telegramChatId && <span className="text-muted"> · Telegram {c.telegramChatId}</span>}
|
||||
</div>
|
||||
<span className="shrink-0 rounded-full bg-wash px-2.5 py-0.5 text-xs font-medium text-muted">
|
||||
{c.householdId !== null ? householdName ?? "Haushalt" : "Privat"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
+24
-7
@@ -6,6 +6,8 @@ import OverviewToolbar from "./components/overview-toolbar";
|
||||
import { withLogoUrl } from "@/lib/logo";
|
||||
import type { Voucher as DbVoucher } from "@prisma/client";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { getViewer } from "@/lib/viewer";
|
||||
import { scopeWhere } from "@/lib/access";
|
||||
import { getVoucherStatus, type VoucherStatus } from "@/lib/voucher-status";
|
||||
import { formatEuro, formatMindestbestellwert, formatWert } from "@/lib/vouchers";
|
||||
import { filterVouchers, sortVouchers, groupVouchers, listCategories } from "@/lib/voucher-view";
|
||||
@@ -101,13 +103,19 @@ function Skeleton() {
|
||||
async function VoucherOverview({
|
||||
params,
|
||||
}: {
|
||||
params: { filter: string; q: string; kat: string; typ: string; sort: string; ansicht: string; gruppe: string };
|
||||
params: { filter: string; q: string; kat: string; typ: string; sort: string; ansicht: string; gruppe: string; bestand: string };
|
||||
}) {
|
||||
const viewer = await getViewer();
|
||||
if (!viewer) return null;
|
||||
const effectiveBestand = params.bestand === "private" || viewer.householdId === null ? "private" : "household";
|
||||
const db = getDb();
|
||||
const all = await db.voucher.findMany({ orderBy: { createdAt: "desc" } });
|
||||
const kategorien = listCategories(all);
|
||||
const scoped = await db.voucher.findMany({ where: scopeWhere(viewer), orderBy: { createdAt: "desc" } });
|
||||
const inBestand = scoped.filter((v) =>
|
||||
effectiveBestand === "household" ? v.householdId === viewer.householdId : v.householdId === null
|
||||
);
|
||||
const kategorien = listCategories(inBestand);
|
||||
const filtered = sortVouchers(
|
||||
filterVouchers(all, { q: params.q, status: params.filter, kategorie: params.kat, typ: params.typ }),
|
||||
filterVouchers(inBestand, { q: params.q, status: params.filter, kategorie: params.kat, typ: params.typ }),
|
||||
params.sort
|
||||
);
|
||||
const withLogos = await Promise.all(filtered.map(withLogoUrl));
|
||||
@@ -153,9 +161,12 @@ async function VoucherOverview({
|
||||
export default async function Home({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ filter?: string; q?: string; kat?: string; typ?: string; sort?: string; ansicht?: string; gruppe?: string }>;
|
||||
searchParams: Promise<{ filter?: string; q?: string; kat?: string; typ?: string; sort?: string; ansicht?: string; gruppe?: string; bestand?: string }>;
|
||||
}) {
|
||||
const viewer = await getViewer();
|
||||
if (!viewer) return null;
|
||||
const sp = await searchParams;
|
||||
const bestand = sp.bestand === "private" || viewer.householdId === null ? "private" : "household";
|
||||
const current = {
|
||||
filter: sp.filter ?? "",
|
||||
q: sp.q ?? "",
|
||||
@@ -164,14 +175,20 @@ export default async function Home({
|
||||
sort: sp.sort ?? "neu",
|
||||
ansicht: sp.ansicht ?? "liste",
|
||||
gruppe: sp.gruppe ?? "keine",
|
||||
bestand,
|
||||
householdName: viewer.householdName,
|
||||
};
|
||||
const db = getDb();
|
||||
const kategorien = listCategories(await db.voucher.findMany({ select: { kategorie: true } }));
|
||||
const scoped = await db.voucher.findMany({ where: scopeWhere(viewer), select: { kategorie: true, householdId: true } });
|
||||
const inBestand = scoped.filter((v) => (bestand === "household" ? v.householdId === viewer.householdId : v.householdId === null));
|
||||
const kategorien = listCategories(inBestand);
|
||||
return (
|
||||
<Shell>
|
||||
<div className="mb-5">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Gutscheine</h1>
|
||||
<p className="mt-1 text-sm text-muted">Alle Gutscheine eures Haushalts im Blick.</p>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
{viewer.householdName ? `Gemeinsamer Bestand: ${viewer.householdName}.` : "Deine privaten Gutscheine."}
|
||||
</p>
|
||||
</div>
|
||||
<OverviewToolbar current={current} kategorien={kategorien} />
|
||||
<div className="mb-5">
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Member = { id: number; name: string; email: string | null; role: string; oidcVerknuepft: boolean };
|
||||
type InviteRow = { id: number; email: string; expiresAt: string };
|
||||
|
||||
const inputClass =
|
||||
"h-11 w-full rounded-field border border-line bg-white px-3.5 text-sm text-ink placeholder:text-muted/60";
|
||||
|
||||
/** Eigener Haushalt (für Haushalts-Admins, die kein Server-Admin sind). */
|
||||
export function HouseholdPanel({ householdId, householdName, ownId, ownEmail }: { householdId: number; householdName: string; ownId: number; ownEmail: string | null }) {
|
||||
const [members, setMembers] = useState<Member[]>([]);
|
||||
const [invites, setInvites] = useState<InviteRow[]>([]);
|
||||
const [inviteMail, setInviteMail] = useState("");
|
||||
const [inviteLink, setInviteLink] = useState("");
|
||||
const [myEmail, setMyEmail] = useState(ownEmail ?? "");
|
||||
const [notice, setNotice] = useState("");
|
||||
|
||||
async function load() {
|
||||
const [u, inv] = await Promise.all([
|
||||
fetch("/api/users").then((r) => (r.ok ? r.json() : [])),
|
||||
fetch("/api/users/invites").then((r) => (r.ok ? r.json() : [])),
|
||||
]);
|
||||
setMembers(Array.isArray(u) ? u : []);
|
||||
setInvites(Array.isArray(inv) ? inv : []);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
async function invite() {
|
||||
setNotice("");
|
||||
setInviteLink("");
|
||||
const res = await fetch("/api/users/invites", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ email: inviteMail }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setNotice(data.message ?? "Einladung fehlgeschlagen.");
|
||||
return;
|
||||
}
|
||||
setInviteMail("");
|
||||
setInviteLink(data.url ?? "");
|
||||
if (data.mailError) setNotice(`Einladung angelegt, aber E-Mail-Versand schlug fehl: ${data.mailError}`);
|
||||
else setNotice("Einladung erstellt (7 Tage gültig).");
|
||||
load();
|
||||
}
|
||||
|
||||
async function revoke(id: number) {
|
||||
await fetch(`/api/users/invites?id=${id}`, { method: "DELETE" });
|
||||
load();
|
||||
}
|
||||
|
||||
async function removeMember(id: number, name: string) {
|
||||
if (id === ownId) return;
|
||||
if (!window.confirm(`„${name}“ aus „${householdName}“ entfernen? Private Gutscheine bleiben erhalten.`)) return;
|
||||
const res = await fetch(`/api/users/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ householdId: null }),
|
||||
});
|
||||
if (!res.ok) setNotice("Entfernen fehlgeschlagen.");
|
||||
else setNotice("");
|
||||
load();
|
||||
}
|
||||
|
||||
async function saveMyEmail() {
|
||||
setNotice("");
|
||||
const res = await fetch("/api/users/me", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ email: myEmail }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) setNotice(data.message ?? "E-Mail konnte nicht gespeichert werden.");
|
||||
else setNotice("Eigene E-Mail gespeichert.");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<section className="rounded-card border border-line bg-white p-5">
|
||||
<h2 className="text-[15px] font-semibold tracking-tight">Haushalt: {householdName}</h2>
|
||||
{members.length === 0 ? (
|
||||
<p className="mt-2 text-sm text-muted">Noch keine Mitglieder.</p>
|
||||
) : (
|
||||
<ul className="mt-3 divide-y divide-line">
|
||||
{members.map((m) => (
|
||||
<li key={m.id} className="flex items-center justify-between gap-3 py-2.5 text-sm">
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold">
|
||||
{m.name}
|
||||
{m.id === ownId && <span className="ml-2 font-normal text-muted">(du)</span>}
|
||||
</p>
|
||||
<p className="truncate text-[13px] text-muted">
|
||||
{m.email ?? "keine E-Mail"} · {m.role === "admin" ? "Haushalts-Admin" : "Mitglied"}
|
||||
{m.oidcVerknuepft ? " · SSO verknüpft" : ""}
|
||||
</p>
|
||||
</div>
|
||||
{m.id !== ownId && m.role !== "admin" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeMember(m.id, m.name)}
|
||||
className="shrink-0 rounded-lg px-2 py-1 text-[13px] font-medium text-muted hover:text-danger"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-card border border-line bg-white p-5">
|
||||
<h2 className="text-[15px] font-semibold tracking-tight">Eigene E-Mail-Adresse</h2>
|
||||
<p className="mt-1 text-[13px] text-muted">Für die SSO-Verknüpfung nötig.</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<input
|
||||
value={myEmail}
|
||||
onChange={(e) => setMyEmail(e.target.value)}
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="name@beispiel.de"
|
||||
aria-label="Eigene E-Mail-Adresse"
|
||||
className="h-11 min-w-0 flex-1 rounded-field border border-line bg-white px-3.5 text-sm text-ink"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveMyEmail}
|
||||
className="h-11 shrink-0 rounded-field bg-brand-600 px-5 text-sm font-semibold text-white transition-colors hover:bg-brand-700"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-card border border-line bg-white p-5">
|
||||
<h2 className="text-[15px] font-semibold tracking-tight">Per E-Mail einladen</h2>
|
||||
<p className="mt-1 text-[13px] text-muted">Einladung ist 7 Tage gültig und fügt direkt „{householdName}“ hinzu.</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<input
|
||||
value={inviteMail}
|
||||
onChange={(e) => setInviteMail(e.target.value)}
|
||||
type="email"
|
||||
autoComplete="off"
|
||||
placeholder="name@beispiel.de"
|
||||
aria-label="E-Mail-Adresse für Einladung"
|
||||
className="h-11 min-w-0 flex-1 rounded-field border border-line bg-white px-3.5 text-sm text-ink"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={invite}
|
||||
className="h-11 shrink-0 rounded-field bg-brand-600 px-5 text-sm font-semibold text-white transition-colors hover:bg-brand-700"
|
||||
>
|
||||
Einladen
|
||||
</button>
|
||||
</div>
|
||||
{inviteLink && (
|
||||
<p className="mt-2 break-all text-[13px]">
|
||||
Einladungslink:{" "}
|
||||
<a href={inviteLink} className="font-medium text-brand-700 underline">
|
||||
{inviteLink}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
{invites.length > 0 && (
|
||||
<ul className="mt-3 divide-y divide-line">
|
||||
{invites.map((inv) => (
|
||||
<li key={inv.id} className="flex items-center justify-between gap-3 py-2 text-sm">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{inv.email}</p>
|
||||
<p className="text-[13px] text-muted">gültig bis {new Date(inv.expiresAt).toLocaleDateString("de-DE")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => revoke(inv.id)}
|
||||
className="shrink-0 rounded-lg px-2 py-1 text-[13px] font-medium text-muted hover:text-danger"
|
||||
>
|
||||
Zurückziehen
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{notice && (
|
||||
<p role="status" className="mt-3 text-sm text-muted">
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+11
-6
@@ -1,7 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import Shell from "../components/shell";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { getSessionUser } from "@/lib/authz";
|
||||
import { getViewer } from "@/lib/viewer";
|
||||
import { DEFAULT_TAGE_VORHER } from "@/lib/settings";
|
||||
import SettingsTabs from "./tabs";
|
||||
|
||||
@@ -9,8 +9,10 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const db = getDb();
|
||||
const me = await getSessionUser();
|
||||
const isAdmin = me?.role === "admin";
|
||||
const me = await getViewer();
|
||||
if (!me) return null;
|
||||
const isAdmin = me.role === "admin";
|
||||
const isHouseholdAdmin = !isAdmin && me.householdId !== null && me.householdRole === "admin";
|
||||
const last = await db.notificationLog.findFirst({ orderBy: { sentAt: "desc" } });
|
||||
return (
|
||||
<Shell>
|
||||
@@ -20,9 +22,12 @@ export default async function SettingsPage() {
|
||||
<h1 className="mt-2 text-2xl font-semibold tracking-tight">Einstellungen</h1>
|
||||
<p className="mt-1 text-sm text-muted">Erinnerungen, Benachrichtigungen, Dienste und Daten.</p>
|
||||
<SettingsTabs
|
||||
isAdmin={!!isAdmin}
|
||||
ownId={me?.id ?? null}
|
||||
ownEmail={me?.email ?? null}
|
||||
isAdmin={isAdmin}
|
||||
isHouseholdAdmin={isHouseholdAdmin}
|
||||
ownId={me.id}
|
||||
ownEmail={me.email}
|
||||
householdId={me.householdId}
|
||||
householdName={me.householdName}
|
||||
defaultReminderText={`Neue Gutscheine erhalten diese Erinnerungsregeln: ${DEFAULT_TAGE_VORHER.join(", ")} Tage vorher.`}
|
||||
lastCronText={
|
||||
last
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Household = {
|
||||
id: number;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
mitgliederAnzahl: number;
|
||||
gutscheineAnzahl: number;
|
||||
mitglieder: { userId: number; name: string; email: string | null; rolle: string }[];
|
||||
};
|
||||
|
||||
type UnassignedUser = { id: number; name: string; email: string | null };
|
||||
|
||||
/** Haushalte anlegen/auflösen und unzugeordnete Nutzer zuweisen (nur Server-Admin). */
|
||||
export default function ServerHouseholds() {
|
||||
const [households, setHouseholds] = useState<Household[]>([]);
|
||||
const [unassigned, setUnassigned] = useState<UnassignedUser[]>([]);
|
||||
const [name, setName] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
|
||||
async function load() {
|
||||
const [hh, users] = await Promise.all([
|
||||
fetch("/api/households").then((r) => (r.ok ? r.json() : [])),
|
||||
fetch("/api/users").then((r) => (r.ok ? r.json() : [])),
|
||||
]);
|
||||
setHouseholds(Array.isArray(hh) ? hh : []);
|
||||
setUnassigned(
|
||||
(Array.isArray(users) ? users : []).filter(
|
||||
(u: { householdId: number | null }) => u.householdId === null
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
async function create() {
|
||||
setNotice("");
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
setNotice("Bitte einen Haushaltsnamen eingeben.");
|
||||
return;
|
||||
}
|
||||
const res = await fetch("/api/households", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ name: trimmed }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) setNotice(data.message ?? "Haushalt konnte nicht angelegt werden.");
|
||||
else {
|
||||
setName("");
|
||||
setNotice(`Haushalt „${trimmed}“ angelegt.`);
|
||||
load();
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: number, householdName: string, gutscheineAnzahl: number, mitgliederAnzahl: number) {
|
||||
if (gutscheineAnzahl > 0 || mitgliederAnzahl > 0) {
|
||||
if (
|
||||
!window.confirm(
|
||||
`„${householdName}“ wirklich auflösen? Dabei werden ${gutscheineAnzahl} Gutschein(e) samt Erinnerungen unwiderruflich gelöscht. Mitglieder behalten nur ihre privaten Gutscheine.`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
} else if (!window.confirm(`Leeren Haushalt „${householdName}“ löschen?`)) {
|
||||
return;
|
||||
}
|
||||
const res = await fetch(`/api/households?id=${id}`, { method: "DELETE" });
|
||||
if (!res.ok) setNotice("Löschen fehlgeschlagen.");
|
||||
else {
|
||||
setNotice("");
|
||||
load();
|
||||
}
|
||||
}
|
||||
|
||||
async function assign(userId: number, householdId: number | null, asAdmin: boolean) {
|
||||
const res = await fetch(`/api/users/${userId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ householdId, householdRole: asAdmin ? "admin" : "member" }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) setNotice(data.message ?? "Zuordnen fehlgeschlagen.");
|
||||
else {
|
||||
setNotice("");
|
||||
load();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<section className="rounded-card border border-line bg-white p-5">
|
||||
<h2 className="text-[15px] font-semibold tracking-tight">Haushalt anlegen</h2>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="z. B. Familie Müller"
|
||||
aria-label="Haushaltsname"
|
||||
maxLength={100}
|
||||
className="h-11 min-w-0 flex-1 rounded-field border border-line bg-white px-3.5 text-sm text-ink"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={create}
|
||||
className="h-11 shrink-0 rounded-field bg-brand-600 px-5 text-sm font-semibold text-white transition-colors hover:bg-brand-700"
|
||||
>
|
||||
Anlegen
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{households.map((h) => (
|
||||
<HouseholdCard key={h.id} household={h} unassigned={unassigned} onAssign={assign} onDelete={remove} />
|
||||
))}
|
||||
|
||||
{unassigned.length > 0 && (
|
||||
<section className="rounded-card border border-line bg-white p-5">
|
||||
<h2 className="text-[15px] font-semibold tracking-tight">Nutzer ohne Haushalt</h2>
|
||||
<p className="mt-1 text-[13px] text-muted">Selbst registriert, bisher nur mit privatem Bestand.</p>
|
||||
<ul className="mt-3 divide-y divide-line">
|
||||
{unassigned.map((u) => (
|
||||
<AssignRow key={u.id} user={u} households={households} onAssign={assign} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{notice && (
|
||||
<p role="status" className="text-sm font-medium text-muted">
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HouseholdCard({
|
||||
household,
|
||||
unassigned,
|
||||
onAssign,
|
||||
onDelete,
|
||||
}: {
|
||||
household: Household;
|
||||
unassigned: UnassignedUser[];
|
||||
onAssign: (userId: number, householdId: number | null, asAdmin: boolean) => void;
|
||||
onDelete: (id: number, name: string, gutscheine: number, mitglieder: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-card border border-line bg-white p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-[15px] font-semibold tracking-tight">{household.name}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(household.id, household.name, household.gutscheineAnzahl, household.mitgliederAnzahl)}
|
||||
className="shrink-0 rounded-lg px-2 py-1 text-[13px] font-medium text-muted hover:text-danger"
|
||||
>
|
||||
Haushalt auflösen
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-[13px] text-muted">
|
||||
{household.mitgliederAnzahl} Mitglied(er) · {household.gutscheineAnzahl} Gutschein(e) im gemeinsamen Bestand
|
||||
</p>
|
||||
{household.mitglieder.length > 0 && (
|
||||
<ul className="mt-3 divide-y divide-line">
|
||||
{household.mitglieder.map((m) => (
|
||||
<li key={m.userId} className="flex items-center justify-between gap-3 py-2 text-sm">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{m.name}</p>
|
||||
<p className="truncate text-[13px] text-muted">
|
||||
{m.email ?? "keine E-Mail"} · {m.rolle === "admin" ? "Haushalts-Admin" : "Mitglied"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAssign(m.userId, null, false)}
|
||||
className="shrink-0 rounded-lg px-2 py-1 text-[13px] font-medium text-muted hover:text-danger"
|
||||
title="Aus dem Haushalt entfernen (bleibt als Nutzer mit privatem Bestand erhalten)"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{unassigned.length > 0 && (
|
||||
<AssignRowList householdId={household.id} unassigned={unassigned} onAssign={onAssign} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AssignRowList({
|
||||
householdId,
|
||||
unassigned,
|
||||
onAssign,
|
||||
}: {
|
||||
householdId: number;
|
||||
unassigned: UnassignedUser[];
|
||||
onAssign: (userId: number, householdId: number | null, asAdmin: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<details className="mt-3">
|
||||
<summary className="cursor-pointer text-[13px] font-semibold text-brand-700">Unzugeordneten Nutzer hinzufügen</summary>
|
||||
<ul className="mt-2 divide-y divide-line">
|
||||
{unassigned.map((u) => (
|
||||
<AssignRow key={u.id} user={u} households={[]} onAssign={onAssign} fixedHouseholdId={householdId} />
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function AssignRow({
|
||||
user,
|
||||
households,
|
||||
onAssign,
|
||||
fixedHouseholdId,
|
||||
}: {
|
||||
user: UnassignedUser;
|
||||
households: { id: number; name: string }[];
|
||||
onAssign: (userId: number, householdId: number | null, asAdmin: boolean) => void;
|
||||
fixedHouseholdId?: number;
|
||||
}) {
|
||||
return (
|
||||
<li className="flex flex-wrap items-center justify-between gap-2 py-2 text-sm">
|
||||
<span className="font-medium">
|
||||
{user.name} <span className="font-normal text-muted">{user.email ?? ""}</span>
|
||||
</span>
|
||||
<span className="flex gap-2">
|
||||
{fixedHouseholdId !== undefined ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAssign(user.id, fixedHouseholdId, false)}
|
||||
className="rounded-lg border border-line px-2 py-1 text-[13px] font-medium hover:border-brand-600 hover:text-brand-700"
|
||||
>
|
||||
Als Mitglied
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAssign(user.id, fixedHouseholdId, true)}
|
||||
className="rounded-lg border border-line px-2 py-1 text-[13px] font-medium hover:border-brand-600 hover:text-brand-700"
|
||||
>
|
||||
Als Admin
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<AssignSelect userId={user.id} households={households} onAssign={onAssign} />
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function AssignSelect({
|
||||
userId,
|
||||
households,
|
||||
onAssign,
|
||||
}: {
|
||||
userId: number;
|
||||
households: { id: number; name: string }[];
|
||||
onAssign: (userId: number, householdId: number | null, asAdmin: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<span className="flex gap-2">
|
||||
<select
|
||||
aria-label="Haushalt auswählen"
|
||||
defaultValue=""
|
||||
onChange={(e) => {
|
||||
if (e.target.value) onAssign(userId, Number(e.target.value), false);
|
||||
}}
|
||||
className="h-9 rounded-field border border-line bg-white px-2 text-[13px]"
|
||||
>
|
||||
<option value="">Haushalt …</option>
|
||||
{households.map((h) => (
|
||||
<option key={h.id} value={h.id}>
|
||||
{h.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import Link from "next/link";
|
||||
import UsersSection from "./users";
|
||||
import { HouseholdPanel } from "./household-panel";
|
||||
import ServerHouseholds from "./server-households";
|
||||
import { TestButtons } from "./test-buttons";
|
||||
import { SmtpSection, TelegramSection, LogoSection, OidcSection, useIntegrationStatus } from "./integrations-form";
|
||||
|
||||
@@ -18,14 +20,20 @@ const TABS: { key: SettingsTab; label: string; adminOnly: boolean }[] = [
|
||||
|
||||
export default function SettingsTabs({
|
||||
isAdmin,
|
||||
isHouseholdAdmin,
|
||||
ownId,
|
||||
ownEmail,
|
||||
householdId,
|
||||
householdName,
|
||||
defaultReminderText,
|
||||
lastCronText,
|
||||
}: {
|
||||
isAdmin: boolean;
|
||||
isHouseholdAdmin: boolean;
|
||||
ownId: number | null;
|
||||
ownEmail: string | null;
|
||||
householdId: number | null;
|
||||
householdName: string | null;
|
||||
defaultReminderText: string;
|
||||
lastCronText: string | null;
|
||||
}) {
|
||||
@@ -33,8 +41,11 @@ export default function SettingsTabs({
|
||||
<Suspense>
|
||||
<TabsInner
|
||||
isAdmin={isAdmin}
|
||||
isHouseholdAdmin={isHouseholdAdmin}
|
||||
ownId={ownId}
|
||||
ownEmail={ownEmail}
|
||||
householdId={householdId}
|
||||
householdName={householdName}
|
||||
defaultReminderText={defaultReminderText}
|
||||
lastCronText={lastCronText}
|
||||
/>
|
||||
@@ -44,14 +55,20 @@ export default function SettingsTabs({
|
||||
|
||||
function TabsInner({
|
||||
isAdmin,
|
||||
isHouseholdAdmin,
|
||||
ownId,
|
||||
ownEmail,
|
||||
householdId,
|
||||
householdName,
|
||||
defaultReminderText,
|
||||
lastCronText,
|
||||
}: {
|
||||
isAdmin: boolean;
|
||||
isHouseholdAdmin: boolean;
|
||||
ownId: number | null;
|
||||
ownEmail: string | null;
|
||||
householdId: number | null;
|
||||
householdName: string | null;
|
||||
defaultReminderText: string;
|
||||
lastCronText: string | null;
|
||||
}) {
|
||||
@@ -108,7 +125,19 @@ function TabsInner({
|
||||
Kontakte verwalten
|
||||
</Link>
|
||||
</section>
|
||||
{isAdmin && <ServerHouseholds />}
|
||||
{isAdmin && ownId !== null && <UsersSection ownId={ownId} ownEmail={ownEmail} />}
|
||||
{!isAdmin && isHouseholdAdmin && householdId !== null && householdName !== null && ownId !== null && (
|
||||
<HouseholdPanel householdId={householdId} householdName={householdName} ownId={ownId} ownEmail={ownEmail} />
|
||||
)}
|
||||
{!isAdmin && !isHouseholdAdmin && (
|
||||
<section className="rounded-card border border-line bg-white p-5">
|
||||
<h2 className="text-[15px] font-semibold tracking-tight">Noch kein Haushalt</h2>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
Deine Gutscheine sind privat. Bitte einen Haushalts-Admin um eine Einladung oder warte auf die Zuordnung durch den Server-Admin.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
+54
-21
@@ -9,6 +9,9 @@ type UserRow = {
|
||||
role: string;
|
||||
oidcVerknuepft: boolean;
|
||||
createdAt: string;
|
||||
householdId: number | null;
|
||||
householdName: string | null;
|
||||
householdRole: string | null;
|
||||
};
|
||||
|
||||
type InviteRow = { id: number; email: string; role: string; expiresAt: string; createdAt: string };
|
||||
@@ -19,15 +22,22 @@ const inputClass =
|
||||
export default function UsersSection({ ownId, ownEmail }: { ownId: number; ownEmail: string | null }) {
|
||||
const [users, setUsers] = useState<UserRow[]>([]);
|
||||
const [invites, setInvites] = useState<InviteRow[]>([]);
|
||||
const [households, setHouseholds] = useState<{ id: number; name: string }[]>([]);
|
||||
const [inviteMail, setInviteMail] = useState("");
|
||||
const [inviteHousehold, setInviteHousehold] = useState("");
|
||||
const [inviteLink, setInviteLink] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [myEmail, setMyEmail] = useState(ownEmail ?? "");
|
||||
|
||||
async function load() {
|
||||
const [u, inv] = await Promise.all([fetch("/api/users").then((r) => (r.ok ? r.json() : [])), fetch("/api/users/invites").then((r) => (r.ok ? r.json() : []))]);
|
||||
const [u, inv, hh] = await Promise.all([
|
||||
fetch("/api/users").then((r) => (r.ok ? r.json() : [])),
|
||||
fetch("/api/users/invites").then((r) => (r.ok ? r.json() : [])),
|
||||
fetch("/api/households").then((r) => (r.ok ? r.json() : [])),
|
||||
]);
|
||||
setUsers(Array.isArray(u) ? u : []);
|
||||
setInvites(Array.isArray(inv) ? inv : []);
|
||||
setHouseholds(Array.isArray(hh) ? hh.map((h: { id: number; name: string }) => ({ id: h.id, name: h.name })) : []);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -40,7 +50,7 @@ export default function UsersSection({ ownId, ownEmail }: { ownId: number; ownEm
|
||||
const res = await fetch("/api/users/invites", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ email: inviteMail }),
|
||||
body: JSON.stringify({ email: inviteMail, householdId: inviteHousehold === "" ? null : Number(inviteHousehold) }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
@@ -48,6 +58,7 @@ export default function UsersSection({ ownId, ownEmail }: { ownId: number; ownEm
|
||||
return;
|
||||
}
|
||||
setInviteMail("");
|
||||
setInviteHousehold("");
|
||||
setInviteLink(data.url ?? "");
|
||||
if (data.mailError) setNotice(`Einladung angelegt, aber E-Mail-Versand schlug fehl: ${data.mailError}`);
|
||||
else setNotice("Einladung erstellt (7 Tage gültig).");
|
||||
@@ -99,9 +110,12 @@ export default function UsersSection({ ownId, ownEmail }: { ownId: number; ownEm
|
||||
{u.id === ownId && <span className="ml-2 font-normal text-muted">(du)</span>}
|
||||
</p>
|
||||
<p className="truncate text-[13px] text-muted">
|
||||
{u.email ?? "keine E-Mail"} · {u.role === "admin" ? "Admin" : "Mitglied"}
|
||||
{u.email ?? "keine E-Mail"} · {u.role === "admin" ? "Server-Admin" : "Mitglied"}
|
||||
{u.oidcVerknuepft ? " · SSO verknüpft" : ""}
|
||||
</p>
|
||||
<p className="truncate text-[13px] text-muted">
|
||||
{u.householdName ? `Haushalt: ${u.householdName}${u.householdRole === "admin" ? " (Admin)" : ""}` : "Kein Haushalt"}
|
||||
</p>
|
||||
</div>
|
||||
{u.id !== ownId && (
|
||||
<button
|
||||
@@ -143,25 +157,44 @@ export default function UsersSection({ ownId, ownEmail }: { ownId: number; ownEm
|
||||
|
||||
<section className="rounded-card border border-line bg-white p-5">
|
||||
<h2 className="text-[15px] font-semibold tracking-tight">Per E-Mail einladen</h2>
|
||||
<p className="mt-1 text-[13px] text-muted">Einladung ist 7 Tage gültig und einmalig einlösbar.</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<input
|
||||
value={inviteMail}
|
||||
onChange={(e) => setInviteMail(e.target.value)}
|
||||
type="email"
|
||||
autoComplete="off"
|
||||
placeholder="name@beispiel.de"
|
||||
aria-label="E-Mail-Adresse für Einladung"
|
||||
className="h-11 min-w-0 flex-1 rounded-field border border-line bg-white px-3.5 text-sm text-ink"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={invite}
|
||||
className="h-11 shrink-0 rounded-field bg-brand-600 px-5 text-sm font-semibold text-white transition-colors hover:bg-brand-700"
|
||||
>
|
||||
Einladen
|
||||
</button>
|
||||
<p className="mt-1 text-[13px] text-muted">Einladung ist 7 Tage gültig und einmalig einlösbar. Optional direkt einem Haushalt zuordnen.</p>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="invite-mail" className="block text-[13px] font-semibold">E-Mail-Adresse</label>
|
||||
<input
|
||||
id="invite-mail"
|
||||
value={inviteMail}
|
||||
onChange={(e) => setInviteMail(e.target.value)}
|
||||
type="email"
|
||||
autoComplete="off"
|
||||
placeholder="name@beispiel.de"
|
||||
className="mt-1.5 h-11 w-full rounded-field border border-line bg-white px-3.5 text-sm text-ink"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="invite-household" className="block text-[13px] font-semibold">Haushalt (optional)</label>
|
||||
<select
|
||||
id="invite-household"
|
||||
value={inviteHousehold}
|
||||
onChange={(e) => setInviteHousehold(e.target.value)}
|
||||
className="mt-1.5 h-11 w-full rounded-field border border-line bg-white px-3.5 text-sm text-ink"
|
||||
>
|
||||
<option value="">Kein Haushalt (nur privater Bestand)</option>
|
||||
{households.map((h) => (
|
||||
<option key={h.id} value={h.id}>
|
||||
{h.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={invite}
|
||||
className="mt-3 h-11 rounded-field bg-brand-600 px-5 text-sm font-semibold text-white transition-colors hover:bg-brand-700"
|
||||
>
|
||||
Einladen
|
||||
</button>
|
||||
{inviteLink && (
|
||||
<p className="mt-2 break-all text-[13px]">
|
||||
Einladungslink:{" "}
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function EditVoucherPage({ params }: { params: Promise<{ id: stri
|
||||
const router = useRouter();
|
||||
const [initial, setInitial] = useState<VoucherFormInitial | null>(null);
|
||||
const [creatorName, setCreatorName] = useState<string | null>(null);
|
||||
const [householdName, setHouseholdName] = useState<string | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState("");
|
||||
@@ -37,6 +38,10 @@ export default function EditVoucherPage({ params }: { params: Promise<{ id: stri
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/users/me")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((me) => setHouseholdName(me?.householdName ?? null))
|
||||
.catch(() => {});
|
||||
fetch(`/api/vouchers/${id}`)
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error("not found"))))
|
||||
.then((v) => {
|
||||
@@ -59,6 +64,7 @@ export default function EditVoucherPage({ params }: { params: Promise<{ id: stri
|
||||
providerName: v.anbieter ?? "",
|
||||
contactIds: (v.contacts ?? []).map((c: { contactId: number }) => c.contactId),
|
||||
reminderDays: rules.map((r) => r.tageVorher),
|
||||
scope: v.householdId != null ? "household" : "private",
|
||||
});
|
||||
})
|
||||
.catch(() => setError("Gutschein nicht gefunden"))
|
||||
@@ -110,6 +116,7 @@ export default function EditVoucherPage({ params }: { params: Promise<{ id: stri
|
||||
<p className="mt-1 text-sm text-muted">Alle Angaben lassen sich ändern. Der Ersteller bleibt erhalten.</p>
|
||||
<VoucherForm
|
||||
initial={initial}
|
||||
householdName={householdName}
|
||||
creatorName={creatorName}
|
||||
submitLabel="Änderungen speichern"
|
||||
onSubmit={onSubmit}
|
||||
|
||||
@@ -23,6 +23,7 @@ type Voucher = {
|
||||
createdBy?: string | null;
|
||||
providerDomain?: string | null;
|
||||
shopUrl?: string | null;
|
||||
household?: { name: string } | null;
|
||||
logoUrl?: string | null;
|
||||
contacts?: { contact: { id: number; name: string; email: string | null; telegramChatId: string | null } }[];
|
||||
};
|
||||
@@ -102,6 +103,7 @@ export default function VoucherDetailPage({ params }: { params: Promise<{ id: st
|
||||
<Row label="Mindestbestellwert">{formatEuro(v.mindestbestellwertCents)}</Row>
|
||||
)}
|
||||
<Row label="Status">{status === "aktiv" ? "Aktiv" : status === "abgelaufen" ? "Abgelaufen" : "Eingelöst"}</Row>
|
||||
<Row label="Bestand">{v.household ? v.household.name : "Privat"}</Row>
|
||||
<Row label="Angelegt von">{v.createdBy ? v.createdBy : "Nicht erfasst"}</Row>
|
||||
{v.ablaufdatum && (
|
||||
<Row label="Ablaufdatum">{new Date(v.ablaufdatum).toLocaleDateString("de-DE")}</Row>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import Shell from "../../components/shell";
|
||||
@@ -7,11 +8,19 @@ import VoucherForm, { EMPTY_FORM, type VoucherFormPayload } from "../../componen
|
||||
|
||||
export default function NewVoucherPage() {
|
||||
const router = useRouter();
|
||||
const [householdName, setHouseholdName] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/users/me")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((me) => setHouseholdName(me?.householdName ?? null))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function onSubmit(payload: VoucherFormPayload) {
|
||||
const res = await fetch("/api/vouchers", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -32,6 +41,7 @@ export default function NewVoucherPage() {
|
||||
<VoucherForm
|
||||
initial={EMPTY_FORM}
|
||||
showShopLink={false}
|
||||
householdName={householdName}
|
||||
submitLabel="Speichern"
|
||||
onSubmit={onSubmit}
|
||||
onSuccess={(id) => router.push(`/vouchers/${id}`)}
|
||||
|
||||
+2
-1
@@ -1,9 +1,10 @@
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
const { ensureSchema } = await import("./lib/db-migrate");
|
||||
const { ensureSchema, ensureMultitenancy } = await import("./lib/db-migrate");
|
||||
await ensureSchema();
|
||||
const { ensureFirstAdmin } = await import("./lib/ensure-admin");
|
||||
await ensureFirstAdmin();
|
||||
await ensureMultitenancy();
|
||||
const { ensureSchedulerStarted } = await import("./lib/scheduler");
|
||||
ensureSchedulerStarted();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
export type Viewer = {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string | null;
|
||||
role: string;
|
||||
/** Server-Admin (User.role === "admin"). Allein kein Gutschein-Zugriff. */
|
||||
isServerAdmin: boolean;
|
||||
/** Genau ein Haushalt oder null (nur privater Bestand). */
|
||||
householdId: number | null;
|
||||
householdName: string | null;
|
||||
householdRole: string | null;
|
||||
};
|
||||
|
||||
export type ScopedItem = { householdId: number | null; ownerId: number };
|
||||
|
||||
/**
|
||||
* Prisma-Where für sichtbare Gutscheine/Kontakte:
|
||||
* gemeinsamer Bestand des eigenen Haushalts plus eigene private Einträge.
|
||||
* Server-Admins ohne Mitgliedschaft sehen nichts.
|
||||
*/
|
||||
export function scopeWhere(v: Viewer): { OR: Array<Record<string, unknown>> } | { householdId: null; ownerId: number } {
|
||||
if (v.householdId !== null) {
|
||||
return { OR: [{ householdId: v.householdId }, { householdId: null, ownerId: v.id }] };
|
||||
}
|
||||
return { householdId: null, ownerId: v.id };
|
||||
}
|
||||
|
||||
/** Darf dieser Betrachter den Eintrag sehen/bearbeiten? */
|
||||
export function canAccess(viewer: Viewer, item: ScopedItem): boolean {
|
||||
if (item.householdId !== null) return viewer.householdId === item.householdId;
|
||||
return item.ownerId === viewer.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Darf dieser Kontakt für einen Gutschein in der Ziel-Ablage verwendet werden?
|
||||
* Gleiche Ablage (derselbe Haushalt) oder beide privat im Besitz des Nutzers.
|
||||
*/
|
||||
export function contactUsableIn(
|
||||
viewer: Viewer,
|
||||
targetHouseholdId: number | null,
|
||||
contact: ScopedItem
|
||||
): boolean {
|
||||
if (!canAccess(viewer, contact)) return false;
|
||||
if (targetHouseholdId !== null) return contact.householdId === targetHouseholdId;
|
||||
return contact.householdId === null && contact.ownerId === viewer.id;
|
||||
}
|
||||
|
||||
/** Ziel-Ablage für neue Einträge auflösen: "private" oder "household". */
|
||||
export function resolveTarget(
|
||||
viewer: Viewer,
|
||||
scope: unknown
|
||||
): { ok: true; householdId: number | null } | { ok: false; error: string } {
|
||||
if (scope === "household") {
|
||||
if (viewer.householdId === null) return { ok: false, error: "Kein Haushalt zugeordnet" };
|
||||
return { ok: true, householdId: viewer.householdId };
|
||||
}
|
||||
if (scope === "private" || scope === undefined || scope === null || scope === "") {
|
||||
return { ok: true, householdId: null };
|
||||
}
|
||||
return { ok: false, error: "Unbekannte Ablage" };
|
||||
}
|
||||
+79
-10
@@ -15,9 +15,23 @@ export async function ensureSchema(): Promise<void> {
|
||||
if (!tables.includes("AppSetting")) {
|
||||
await db.$executeRaw`CREATE TABLE "AppSetting" ("key" TEXT NOT NULL PRIMARY KEY, "value" TEXT NOT NULL, "updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)`;
|
||||
}
|
||||
if (tables.includes("HouseholdMember")) {
|
||||
const legacyCols = (await db.$queryRaw<{ name: string }[]>`SELECT name FROM pragma_table_info('HouseholdMember')`).map(
|
||||
(c: { name: string }) => c.name
|
||||
);
|
||||
if (legacyCols.includes("name") && !legacyCols.includes("householdId")) {
|
||||
await db.$executeRaw`ALTER TABLE "HouseholdMember" RENAME TO "HouseholdMemberLegacy"`;
|
||||
tables.splice(tables.indexOf("HouseholdMember"), 1);
|
||||
}
|
||||
}
|
||||
if (!tables.includes("Household")) {
|
||||
await db.$executeRaw`CREATE TABLE "Household" ("id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "name" TEXT NOT NULL, "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)`;
|
||||
await db.$executeRaw`CREATE UNIQUE INDEX "Household_name_key" ON "Household"("name")`;
|
||||
}
|
||||
if (!tables.includes("HouseholdMember")) {
|
||||
await db.$executeRaw`CREATE TABLE "HouseholdMember" ("id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "name" TEXT NOT NULL, "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)`;
|
||||
await db.$executeRaw`CREATE UNIQUE INDEX "HouseholdMember_name_key" ON "HouseholdMember"("name")`;
|
||||
await db.$executeRaw`CREATE TABLE "HouseholdMember" ("id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "householdId" INTEGER NOT NULL, "userId" INTEGER NOT NULL, "role" TEXT NOT NULL DEFAULT 'member', "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "HouseholdMember_householdId_fkey" FOREIGN KEY ("householdId") REFERENCES "Household"("id") ON DELETE CASCADE, CONSTRAINT "HouseholdMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE)`;
|
||||
await db.$executeRaw`CREATE UNIQUE INDEX "HouseholdMember_userId_key" ON "HouseholdMember"("userId")`;
|
||||
await db.$executeRaw`CREATE UNIQUE INDEX "HouseholdMember_householdId_userId_key" ON "HouseholdMember"("householdId", "userId")`;
|
||||
}
|
||||
if (!tables.includes("User")) {
|
||||
await db.$executeRaw`CREATE TABLE "User" ("id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "name" TEXT NOT NULL, "email" TEXT, "passwordHash" TEXT, "role" TEXT NOT NULL DEFAULT 'member', "oidcSubject" TEXT, "tokenVersion" INTEGER NOT NULL DEFAULT 0, "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)`;
|
||||
@@ -25,32 +39,87 @@ export async function ensureSchema(): Promise<void> {
|
||||
await db.$executeRaw`CREATE UNIQUE INDEX "User_oidcSubject_key" ON "User"("oidcSubject")`;
|
||||
}
|
||||
if (!tables.includes("Invite")) {
|
||||
await db.$executeRaw`CREATE TABLE "Invite" ("id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "email" TEXT NOT NULL, "tokenHash" TEXT NOT NULL, "role" TEXT NOT NULL DEFAULT 'member', "expiresAt" DATETIME NOT NULL, "acceptedAt" DATETIME, "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)`;
|
||||
await db.$executeRaw`CREATE TABLE "Invite" ("id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "email" TEXT NOT NULL, "tokenHash" TEXT NOT NULL, "role" TEXT NOT NULL DEFAULT 'member', "householdId" INTEGER, "expiresAt" DATETIME NOT NULL, "acceptedAt" DATETIME, "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "Invite_householdId_fkey" FOREIGN KEY ("householdId") REFERENCES "Household"("id") ON DELETE CASCADE)`;
|
||||
await db.$executeRaw`CREATE UNIQUE INDEX "Invite_email_key" ON "Invite"("email")`;
|
||||
await db.$executeRaw`CREATE UNIQUE INDEX "Invite_tokenHash_key" ON "Invite"("tokenHash")`;
|
||||
} else {
|
||||
const cols = (await db.$queryRaw<{ name: string }[]>`SELECT name FROM pragma_table_info('Invite')`).map(
|
||||
(c) => c.name
|
||||
);
|
||||
if (!cols.includes("householdId")) {
|
||||
await db.$executeRaw`ALTER TABLE "Invite" ADD COLUMN "householdId" INTEGER REFERENCES "Household"("id") ON DELETE CASCADE`;
|
||||
}
|
||||
}
|
||||
const cols = (await db.$queryRaw<{ name: string }[]>`SELECT name FROM pragma_table_info('Voucher')`).map(
|
||||
const vcols = (await db.$queryRaw<{ name: string }[]>`SELECT name FROM pragma_table_info('Voucher')`).map(
|
||||
(c) => c.name
|
||||
);
|
||||
if (!cols.includes("providerDomain")) {
|
||||
if (!vcols.includes("providerDomain")) {
|
||||
await db.$executeRaw`ALTER TABLE "Voucher" ADD COLUMN "providerDomain" TEXT`;
|
||||
}
|
||||
if (!cols.includes("createdBy")) {
|
||||
if (!vcols.includes("createdBy")) {
|
||||
await db.$executeRaw`ALTER TABLE "Voucher" ADD COLUMN "createdBy" TEXT`;
|
||||
}
|
||||
if (!cols.includes("wertTyp")) {
|
||||
if (!vcols.includes("wertTyp")) {
|
||||
await db.$executeRaw`ALTER TABLE "Voucher" ADD COLUMN "wertTyp" TEXT NOT NULL DEFAULT 'euro'`;
|
||||
}
|
||||
if (!cols.includes("prozentWert")) {
|
||||
if (!vcols.includes("prozentWert")) {
|
||||
await db.$executeRaw`ALTER TABLE "Voucher" ADD COLUMN "prozentWert" INTEGER`;
|
||||
}
|
||||
if (!cols.includes("mindestbestellwertCents")) {
|
||||
if (!vcols.includes("mindestbestellwertCents")) {
|
||||
await db.$executeRaw`ALTER TABLE "Voucher" ADD COLUMN "mindestbestellwertCents" INTEGER`;
|
||||
}
|
||||
if (!cols.includes("shopUrl")) {
|
||||
if (!vcols.includes("shopUrl")) {
|
||||
await db.$executeRaw`ALTER TABLE "Voucher" ADD COLUMN "shopUrl" TEXT`;
|
||||
}
|
||||
if (!vcols.includes("ownerId")) {
|
||||
await db.$executeRaw`ALTER TABLE "Voucher" ADD COLUMN "ownerId" INTEGER NOT NULL DEFAULT 0`;
|
||||
}
|
||||
if (!vcols.includes("householdId")) {
|
||||
await db.$executeRaw`ALTER TABLE "Voucher" ADD COLUMN "householdId" INTEGER REFERENCES "Household"("id")`;
|
||||
}
|
||||
const ccols = (await db.$queryRaw<{ name: string }[]>`SELECT name FROM pragma_table_info('Contact')`).map(
|
||||
(c) => c.name
|
||||
);
|
||||
if (!ccols.includes("ownerId")) {
|
||||
await db.$executeRaw`ALTER TABLE "Contact" ADD COLUMN "ownerId" INTEGER NOT NULL DEFAULT 0`;
|
||||
}
|
||||
if (!ccols.includes("householdId")) {
|
||||
await db.$executeRaw`ALTER TABLE "Contact" ADD COLUMN "householdId" INTEGER REFERENCES "Household"("id")`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("ensureSchema fehlgeschlagen (Start läuft weiter):", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mehrhaushalt-Migration (einmalig, idempotent):
|
||||
* - Standard-Haushalt "Haushalt" anlegen, falls keiner existiert.
|
||||
* - Bestand ohne Zuordnung (ownerId 0) dem Standard-Haushalt und dem ältesten
|
||||
* Admin als Besitzer zuordnen.
|
||||
* - Bestehende Nutzer werden Mitglieder des Standard-Haushalts (Admins als Haushalts-Admins).
|
||||
*/
|
||||
export async function ensureMultitenancy(): Promise<void> {
|
||||
try {
|
||||
const db = getDb();
|
||||
const households = await db.household.findMany({ orderBy: { id: "asc" } });
|
||||
let standard = households[0] ?? null;
|
||||
if (!standard) {
|
||||
standard = await db.household.create({ data: { name: "Haushalt" } });
|
||||
}
|
||||
const admins = await db.user.findMany({ where: { role: "admin" }, orderBy: { id: "asc" } });
|
||||
const firstAdminId = admins[0]?.id ?? (await db.user.findFirst({ orderBy: { id: "asc" } }))?.id ?? 0;
|
||||
await db.voucher.updateMany({ where: { ownerId: 0 }, data: { ownerId: firstAdminId, householdId: standard.id } });
|
||||
await db.contact.updateMany({ where: { ownerId: 0 }, data: { ownerId: firstAdminId, householdId: standard.id } });
|
||||
const users = await db.user.findMany({ select: { id: true, role: true } });
|
||||
for (const u of users) {
|
||||
const existing = await db.householdMember.findUnique({ where: { userId: u.id } });
|
||||
if (!existing) {
|
||||
await db.householdMember.create({
|
||||
data: { householdId: standard.id, userId: u.id, role: u.role === "admin" ? "admin" : "member" },
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("ensureMultitenancy fehlgeschlagen (Start läuft weiter):", err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { getDb } from "./db";
|
||||
import { verifySession, SESSION_COOKIE, type SessionUser } from "./session";
|
||||
import { cookies } from "next/headers";
|
||||
import type { Viewer } from "./access";
|
||||
|
||||
async function sessionFromCookie(): Promise<SessionUser | null> {
|
||||
const store = await cookies();
|
||||
const token = store.get(SESSION_COOKIE)?.value;
|
||||
if (!token) return null;
|
||||
try {
|
||||
return await verifySession(token);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktueller Betrachter inkl. Haushaltsmitgliedschaft (genau ein Haushalt oder keiner).
|
||||
* Gibt null zurück, wenn kein gültiges, aktuelles Login vorliegt.
|
||||
*/
|
||||
export async function getViewer(): Promise<Viewer | null> {
|
||||
const sess = await sessionFromCookie();
|
||||
if (!sess) return null;
|
||||
const db = getDb();
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: sess.uid },
|
||||
include: { membership: { include: { household: true } } },
|
||||
});
|
||||
if (!user || user.tokenVersion !== sess.tokenVersion) return null;
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
isServerAdmin: user.role === "admin",
|
||||
householdId: user.membership?.householdId ?? null,
|
||||
householdName: user.membership?.household.name ?? null,
|
||||
householdRole: user.membership?.role ?? null,
|
||||
};
|
||||
}
|
||||
+29
-1
@@ -21,6 +21,10 @@ model Voucher {
|
||||
wertTyp String @default("euro")
|
||||
prozentWert Int?
|
||||
mindestbestellwertCents Int?
|
||||
ownerId Int @default(0)
|
||||
owner User @relation("VoucherOwner", fields: [ownerId], references: [id])
|
||||
householdId Int?
|
||||
household Household? @relation(fields: [householdId], references: [id])
|
||||
status String @default("aktiv")
|
||||
contacts VoucherContact[]
|
||||
rules ReminderRule[]
|
||||
@@ -32,6 +36,10 @@ model Contact {
|
||||
name String
|
||||
email String?
|
||||
telegramChatId String?
|
||||
ownerId Int @default(0)
|
||||
owner User @relation("ContactOwner", fields: [ownerId], references: [id])
|
||||
householdId Int?
|
||||
household Household? @relation(fields: [householdId], references: [id])
|
||||
vouchers VoucherContact[]
|
||||
}
|
||||
model VoucherContact {
|
||||
@@ -68,10 +76,25 @@ model AppSetting {
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model HouseholdMember {
|
||||
model Household {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
createdAt DateTime @default(now())
|
||||
members HouseholdMember[]
|
||||
vouchers Voucher[]
|
||||
contacts Contact[]
|
||||
invites Invite[]
|
||||
}
|
||||
|
||||
model HouseholdMember {
|
||||
id Int @id @default(autoincrement())
|
||||
householdId Int
|
||||
household Household @relation(fields: [householdId], references: [id], onDelete: Cascade)
|
||||
userId Int @unique
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
role String @default("member")
|
||||
createdAt DateTime @default(now())
|
||||
@@unique([householdId, userId])
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -84,6 +107,9 @@ model User {
|
||||
tokenVersion Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
membership HouseholdMember?
|
||||
ownedVouchers Voucher[] @relation("VoucherOwner")
|
||||
ownedContacts Contact[] @relation("ContactOwner")
|
||||
}
|
||||
|
||||
model Invite {
|
||||
@@ -91,6 +117,8 @@ model Invite {
|
||||
email String @unique
|
||||
tokenHash String @unique
|
||||
role String @default("member")
|
||||
householdId Int?
|
||||
household Household? @relation(fields: [householdId], references: [id], onDelete: Cascade)
|
||||
expiresAt DateTime
|
||||
acceptedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { canAccess, contactUsableIn, resolveTarget, scopeWhere, type Viewer } from "../lib/access";
|
||||
|
||||
const adminOhneHaushalt: Viewer = { id: 1, name: "Admin", email: null, role: "admin", isServerAdmin: true, householdId: null, householdName: null, householdRole: null };
|
||||
const julia: Viewer = { id: 2, name: "Julia", email: "j@x.de", role: "member", isServerAdmin: false, householdId: 7, householdName: "Familie", householdRole: "admin" };
|
||||
const gast: Viewer = { id: 3, name: "Gast", email: "g@x.de", role: "member", isServerAdmin: false, householdId: null, householdName: null, householdRole: null };
|
||||
|
||||
describe("scopeWhere", () => {
|
||||
it("Haushaltsmitglied sieht Haushalt plus eigene private Einträge", () => {
|
||||
expect(scopeWhere(julia)).toEqual({ OR: [{ householdId: 7 }, { householdId: null, ownerId: 2 }] });
|
||||
});
|
||||
it("Server-Admin ohne Mitgliedschaft sieht nur eigene private Einträge", () => {
|
||||
expect(scopeWhere(adminOhneHaushalt)).toEqual({ householdId: null, ownerId: 1 });
|
||||
});
|
||||
it("Unzugeordnete sehen nur eigene private Einträge", () => {
|
||||
expect(scopeWhere(gast)).toEqual({ householdId: null, ownerId: 3 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("canAccess", () => {
|
||||
it("Haushaltseinträge nur für Mitglieder desselben Haushalts", () => {
|
||||
expect(canAccess(julia, { householdId: 7, ownerId: 2 })).toBe(true);
|
||||
expect(canAccess(julia, { householdId: 8, ownerId: 2 })).toBe(false);
|
||||
expect(canAccess(adminOhneHaushalt, { householdId: 7, ownerId: 2 })).toBe(false);
|
||||
});
|
||||
it("private Einträge nur für den Besitzer", () => {
|
||||
expect(canAccess(julia, { householdId: null, ownerId: 2 })).toBe(true);
|
||||
expect(canAccess(julia, { householdId: null, ownerId: 3 })).toBe(false);
|
||||
expect(canAccess(gast, { householdId: null, ownerId: 3 })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("contactUsableIn", () => {
|
||||
it("Haushaltsgutschein braucht Kontakt desselben Haushalts", () => {
|
||||
expect(contactUsableIn(julia, 7, { householdId: 7, ownerId: 2 })).toBe(true);
|
||||
expect(contactUsableIn(julia, 7, { householdId: 8, ownerId: 2 })).toBe(false);
|
||||
expect(contactUsableIn(julia, 7, { householdId: null, ownerId: 2 })).toBe(false);
|
||||
});
|
||||
it("privater Gutschein braucht eigenen privaten Kontakt", () => {
|
||||
expect(contactUsableIn(julia, null, { householdId: null, ownerId: 2 })).toBe(true);
|
||||
expect(contactUsableIn(julia, null, { householdId: 7, ownerId: 2 })).toBe(false);
|
||||
expect(contactUsableIn(julia, null, { householdId: null, ownerId: 3 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTarget", () => {
|
||||
it("explizite Ablage, Haushalt erforderlich für gemeinsam", () => {
|
||||
expect(resolveTarget(julia, "household")).toEqual({ ok: true, householdId: 7 });
|
||||
expect(resolveTarget(gast, "household")).toEqual({ ok: false, error: expect.any(String) });
|
||||
expect(resolveTarget(julia, "private")).toEqual({ ok: true, householdId: null });
|
||||
expect(resolveTarget(julia, "falsch")).toEqual({ ok: false, error: expect.any(String) });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user