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 } });
|
user = await db.user.update({ where: { id: byMail.id }, data: { oidcSubject: verified.subject } });
|
||||||
} else {
|
} else {
|
||||||
const invite = await db.invite.findUnique({ where: { email: verified.email } });
|
const invite = await db.invite.findUnique({ where: { email: verified.email } });
|
||||||
if (!invite || invite.acceptedAt !== null || invite.expiresAt.getTime() <= Date.now()) {
|
const invited = invite && invite.acceptedAt === null && invite.expiresAt.getTime() > Date.now() ? invite : null;
|
||||||
return deny("keine-einladung");
|
|
||||||
}
|
|
||||||
user = await db.user.create({
|
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 });
|
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 { NextRequest, NextResponse } from "next/server";
|
||||||
import { getDb } from "@/lib/db";
|
import { getDb } from "@/lib/db";
|
||||||
import { validateContact } from "@/lib/contacts";
|
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 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);
|
return NextResponse.json(all);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
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 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 = {
|
const input = {
|
||||||
name: String(body.name ?? ""),
|
name: String(body.name ?? ""),
|
||||||
email: body.email ? String(body.email) : null,
|
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 });
|
if (errs.length) return NextResponse.json({ errors: errs }, { status: 400 });
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const created = await db.contact.create({
|
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 });
|
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 { NextRequest, NextResponse } from "next/server";
|
||||||
import { getDb } from "@/lib/db";
|
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. */
|
/** Nutzer zuordnen/entfernen (nur Server-Admin). */
|
||||||
export async function DELETE(req: NextRequest) {
|
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const admin = await requireAdmin();
|
const viewer = await getViewer();
|
||||||
if (!admin) return NextResponse.json({ error: "forbidden" }, { status: 403 });
|
if (!viewer || viewer.role !== "admin") return NextResponse.json({ error: "forbidden" }, { status: 403 });
|
||||||
const id = Number(req.nextUrl.searchParams.get("id"));
|
const { id } = await params;
|
||||||
if (!Number.isInteger(id)) return NextResponse.json({ error: "not found" }, { status: 404 });
|
const numId = Number(id);
|
||||||
if (id === admin.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 });
|
return NextResponse.json({ errors: ["id"], message: "Das eigene Konto kann nicht gelöscht werden." }, { status: 400 });
|
||||||
}
|
}
|
||||||
const db = getDb();
|
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) return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||||
if (target.role === "admin") {
|
if (target.role === "admin") {
|
||||||
const admins = await db.user.count({ where: { 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 });
|
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 });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
@@ -36,6 +36,9 @@ export async function POST(req: NextRequest) {
|
|||||||
const created = await db.user.create({
|
const created = await db.user.create({
|
||||||
data: { name, email: invite.email, passwordHash, role: invite.role },
|
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` } });
|
await db.invite.update({ where: { id: invite.id }, data: { acceptedAt: new Date(), tokenHash: `${hashInviteToken(token)}:verbraucht` } });
|
||||||
return NextResponse.json({ ok: true, id: created.id });
|
return NextResponse.json({ ok: true, id: created.id });
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getDb } from "@/lib/db";
|
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 { createInviteToken, hashInviteToken, normalizeEmail, INVITE_VALID_DAYS } from "@/lib/invites";
|
||||||
import { sendMail } from "@/lib/mail";
|
import { sendMail } from "@/lib/mail";
|
||||||
|
|
||||||
@@ -9,21 +9,29 @@ function inviteUrl(req: NextRequest, rawToken: string): string {
|
|||||||
return `${appUrl}/accept-invite?token=${rawToken}`;
|
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() {
|
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 db = getDb();
|
||||||
const invites = await db.invite.findMany({
|
if (viewer.role !== "admin" && !(viewer.householdId !== null && viewer.householdRole === "admin")) {
|
||||||
where: { acceptedAt: null },
|
return NextResponse.json({ error: "forbidden" }, { status: 403 });
|
||||||
orderBy: { createdAt: "desc" },
|
}
|
||||||
select: { id: true, email: true, role: true, expiresAt: true, createdAt: true },
|
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);
|
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) {
|
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 body = await req.json().catch(() => ({}));
|
||||||
const email = normalizeEmail(typeof body.email === "string" ? body.email : "");
|
const email = normalizeEmail(typeof body.email === "string" ? body.email : "");
|
||||||
if (!email) return NextResponse.json({ errors: ["email"] }, { status: 400 });
|
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 } })) {
|
if (await db.user.findUnique({ where: { email } })) {
|
||||||
return NextResponse.json({ errors: ["email"], message: "Für diese E-Mail existiert bereits ein Konto." }, { status: 409 });
|
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 rawToken = createInviteToken();
|
||||||
const expiresAt = new Date(Date.now() + INVITE_VALID_DAYS * 24 * 3600 * 1000);
|
const expiresAt = new Date(Date.now() + INVITE_VALID_DAYS * 24 * 3600 * 1000);
|
||||||
await db.invite.upsert({
|
await db.invite.upsert({
|
||||||
where: { email },
|
where: { email },
|
||||||
create: { email, tokenHash: hashInviteToken(rawToken), role: "member", expiresAt },
|
create: { email, tokenHash: hashInviteToken(rawToken), role: "member", householdId, expiresAt },
|
||||||
update: { tokenHash: hashInviteToken(rawToken), expiresAt, acceptedAt: null },
|
update: { tokenHash: hashInviteToken(rawToken), expiresAt, acceptedAt: null, householdId },
|
||||||
});
|
});
|
||||||
const url = inviteUrl(req, rawToken);
|
const url = inviteUrl(req, rawToken);
|
||||||
let mailError: string | null = null;
|
let mailError: string | null = null;
|
||||||
@@ -48,14 +68,19 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ ok: true, url, mailError }, { status: 201 });
|
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) {
|
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"));
|
const id = Number(req.nextUrl.searchParams.get("id"));
|
||||||
if (!Number.isInteger(id)) return NextResponse.json({ error: "not found" }, { status: 404 });
|
if (!Number.isInteger(id)) return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const existing = await db.invite.findUnique({ where: { id } });
|
const existing = await db.invite.findUnique({ where: { id } });
|
||||||
if (!existing) return NextResponse.json({ error: "not found" }, { status: 404 });
|
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 } });
|
await db.invite.delete({ where: { id } });
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
@@ -1,20 +1,36 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getDb } from "@/lib/db";
|
import { getDb } from "@/lib/db";
|
||||||
import { getSessionUser } from "@/lib/authz";
|
import { getViewer } from "@/lib/viewer";
|
||||||
import { normalizeEmail } from "@/lib/invites";
|
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). */
|
/** Eigene E-Mail-Adresse setzen/ändern (für OIDC-Verknüpfung nötig). */
|
||||||
export async function PUT(req: NextRequest) {
|
export async function PUT(req: NextRequest) {
|
||||||
const me = await getSessionUser();
|
const viewer = await getViewer();
|
||||||
if (!me) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
if (!viewer) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||||
const body = await req.json().catch(() => ({}));
|
const body = await req.json().catch(() => ({}));
|
||||||
const email = normalizeEmail(typeof body.email === "string" ? body.email : "");
|
const email = normalizeEmail(typeof body.email === "string" ? body.email : "");
|
||||||
if (!email) return NextResponse.json({ errors: ["email"] }, { status: 400 });
|
if (!email) return NextResponse.json({ errors: ["email"] }, { status: 400 });
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const taken = await db.user.findUnique({ where: { email } });
|
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 });
|
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 });
|
return NextResponse.json({ ok: true, email: updated.email });
|
||||||
}
|
}
|
||||||
+45
-7
@@ -1,17 +1,55 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getDb } from "@/lib/db";
|
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() {
|
export async function GET() {
|
||||||
const admin = await requireAdmin();
|
const viewer = await getViewer();
|
||||||
if (!admin) return NextResponse.json({ error: "forbidden" }, { status: 403 });
|
if (!viewer) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||||
const db = getDb();
|
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" },
|
orderBy: { createdAt: "asc" },
|
||||||
select: { id: true, name: true, email: true, role: true, oidcSubject: true, createdAt: true },
|
|
||||||
});
|
});
|
||||||
return NextResponse.json(
|
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 { getDb } from "@/lib/db";
|
||||||
import { withLogoUrl } from "@/lib/logo";
|
import { withLogoUrl } from "@/lib/logo";
|
||||||
import { validateVoucher, parseShopUrl, parseVoucherFields } from "@/lib/vouchers";
|
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 }> }) {
|
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const numId = Number(id);
|
const numId = Number(id);
|
||||||
if (!Number.isInteger(numId)) return NextResponse.json({ error: "not found" }, { status: 404 });
|
if (!Number.isInteger(numId)) return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||||
const db = getDb();
|
const found = await loadVoucher(numId);
|
||||||
const v = await db.voucher.findUnique({ where: { id: numId }, include: { contacts: { include: { contact: true } }, rules: true } });
|
if (found.status !== 200) return NextResponse.json({ error: found.status === 401 ? "unauthorized" : "not found" }, { status: found.status });
|
||||||
if (!v) return NextResponse.json({ error: "not found" }, { status: 404 });
|
return NextResponse.json(await withLogoUrl(found.v));
|
||||||
return NextResponse.json(await withLogoUrl(v));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const numId = Number(id);
|
const numId = Number(id);
|
||||||
if (!Number.isInteger(numId)) return NextResponse.json({ error: "not found" }, { status: 404 });
|
if (!Number.isInteger(numId)) return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||||
const db = getDb();
|
const found = await loadVoucher(numId);
|
||||||
const v = await db.voucher.findUnique({ where: { id: numId } });
|
if (found.status !== 200) return NextResponse.json({ error: found.status === 401 ? "unauthorized" : "not found" }, { status: found.status });
|
||||||
if (!v) return NextResponse.json({ error: "not found" }, { status: 404 });
|
|
||||||
const body = await req.json().catch(() => ({}));
|
const body = await req.json().catch(() => ({}));
|
||||||
if (!("shopUrl" in body)) return NextResponse.json({ errors: ["shopUrl"] }, { status: 400 });
|
if (!("shopUrl" in body)) return NextResponse.json({ errors: ["shopUrl"] }, { status: 400 });
|
||||||
const shop = parseShopUrl(body.shopUrl);
|
const shop = parseShopUrl(body.shopUrl);
|
||||||
if (shop.error) return NextResponse.json({ errors: ["shopUrl"] }, { status: 400 });
|
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 } });
|
const updated = await db.voucher.update({ where: { id: numId }, data: { shopUrl: shop.url } });
|
||||||
return NextResponse.json(await withLogoUrl(updated));
|
return NextResponse.json(await withLogoUrl(updated));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
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 { id } = await params;
|
||||||
const numId = Number(id);
|
const numId = Number(id);
|
||||||
if (!Number.isInteger(numId)) return NextResponse.json({ error: "not found" }, { status: 404 });
|
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(() => ({}));
|
const body = await req.json().catch(() => ({}));
|
||||||
if (body.action !== "redeem") return NextResponse.json({ error: "unknown action" }, { status: 400 });
|
if (body.action !== "redeem") return NextResponse.json({ error: "unknown action" }, { status: 400 });
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const v = await db.voucher.findUnique({ where: { id: numId } });
|
const updated = await db.voucher.update({ where: { id: numId }, data: { status: "eingeloest", restCents: 0 } });
|
||||||
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 } });
|
|
||||||
return NextResponse.json(updated);
|
return NextResponse.json(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
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 { id } = await params;
|
||||||
const numId = Number(id);
|
const numId = Number(id);
|
||||||
if (!Number.isInteger(numId)) return NextResponse.json({ error: "not found" }, { status: 404 });
|
if (!Number.isInteger(numId)) return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||||
const db = getDb();
|
const found = await loadVoucher(numId);
|
||||||
const existing = await db.voucher.findUnique({ where: { id: numId } });
|
if (found.status !== 200) return NextResponse.json({ error: found.status === 401 ? "unauthorized" : "not found" }, { status: found.status });
|
||||||
if (!existing) return NextResponse.json({ error: "not found" }, { status: 404 });
|
const { viewer } = found;
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { fields, errors: parseErrors } = parseVoucherFields(body);
|
const { fields, errors: parseErrors } = parseVoucherFields(body);
|
||||||
const errs = [...parseErrors, ...validateVoucher(fields)];
|
const errs = [...parseErrors, ...validateVoucher(fields)];
|
||||||
if (errs.length) return NextResponse.json({ errors: errs }, { status: 400 });
|
if (errs.length) return NextResponse.json({ errors: errs }, { status: 400 });
|
||||||
|
const db = getDb();
|
||||||
if (fields.contactIds.length) {
|
if (fields.contactIds.length) {
|
||||||
const found = await db.contact.findMany({ where: { id: { in: fields.contactIds } }, select: { id: true } });
|
const contacts = await db.contact.findMany({ where: { id: { in: fields.contactIds } } });
|
||||||
if (found.length !== new Set(fields.contactIds).size) {
|
if (contacts.length !== new Set(fields.contactIds).size) {
|
||||||
return NextResponse.json({ errors: ["contactIds"] }, { status: 400 });
|
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([
|
await db.$transaction([
|
||||||
db.voucherContact.deleteMany({ where: { voucherId: numId } }),
|
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 }> }) {
|
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 { id } = await params;
|
||||||
const numId = Number(id);
|
const numId = Number(id);
|
||||||
if (!Number.isInteger(numId)) return NextResponse.json({ error: "not found" }, { status: 404 });
|
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 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 } });
|
await db.voucher.delete({ where: { id: numId } });
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
@@ -3,12 +3,21 @@ import { getDb } from "@/lib/db";
|
|||||||
import { getVoucherStatus } from "@/lib/voucher-status";
|
import { getVoucherStatus } from "@/lib/voucher-status";
|
||||||
import { validateVoucher, isExpiringSoon, parseVoucherFields } from "@/lib/vouchers";
|
import { validateVoucher, isExpiringSoon, parseVoucherFields } from "@/lib/vouchers";
|
||||||
import { withLogoUrl } from "@/lib/logo";
|
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) {
|
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 filter = req.nextUrl.searchParams.get("filter");
|
||||||
|
const scopeParam = req.nextUrl.searchParams.get("bestand") ?? (viewer.householdId !== null ? "household" : "private");
|
||||||
const db = getDb();
|
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 filtered = all.filter((v) => {
|
||||||
const s = getVoucherStatus(v);
|
const s = getVoucherStatus(v);
|
||||||
if (filter === "eingeloest") return s === "eingeloest";
|
if (filter === "eingeloest") return s === "eingeloest";
|
||||||
@@ -21,20 +30,23 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const me = await requireUser();
|
const viewer = await getViewer();
|
||||||
if (!me) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
if (!viewer) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||||
const body = await req.json();
|
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 { fields, errors: parseErrors } = parseVoucherFields(body);
|
||||||
const errs = [...parseErrors, ...validateVoucher(fields)];
|
const errs = [...parseErrors, ...validateVoucher(fields)];
|
||||||
if (errs.length) return NextResponse.json({ errors: errs }, { status: 400 });
|
if (errs.length) return NextResponse.json({ errors: errs }, { status: 400 });
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
if (fields.contactIds.length) {
|
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) {
|
if (found.length !== new Set(fields.contactIds).size) {
|
||||||
return NextResponse.json({ errors: ["contactIds"] }, { status: 400 });
|
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({
|
const created = await db.voucher.create({
|
||||||
data: {
|
data: {
|
||||||
name: fields.name.trim(),
|
name: fields.name.trim(),
|
||||||
@@ -49,8 +61,10 @@ export async function POST(req: NextRequest) {
|
|||||||
kategorie: fields.kategorie,
|
kategorie: fields.kategorie,
|
||||||
notiz: fields.notiz,
|
notiz: fields.notiz,
|
||||||
providerDomain: fields.providerDomain,
|
providerDomain: fields.providerDomain,
|
||||||
createdBy,
|
createdBy: viewer.name,
|
||||||
shopUrl: fields.shopUrl,
|
shopUrl: fields.shopUrl,
|
||||||
|
ownerId: viewer.id,
|
||||||
|
householdId: target.householdId,
|
||||||
contacts: fields.contactIds.length ? { create: fields.contactIds.map((contactId) => ({ contactId })) } : undefined,
|
contacts: fields.contactIds.length ? { create: fields.contactIds.map((contactId) => ({ contactId })) } : undefined,
|
||||||
rules: {
|
rules: {
|
||||||
create: fields.reminderDays.map((tageVorher) => ({ tageVorher })),
|
create: fields.reminderDays.map((tageVorher) => ({ tageVorher })),
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export type OverviewParams = {
|
|||||||
sort: string;
|
sort: string;
|
||||||
ansicht: string;
|
ansicht: string;
|
||||||
gruppe: string;
|
gruppe: string;
|
||||||
|
bestand: string;
|
||||||
|
householdName: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const FILTERS = [
|
const FILTERS = [
|
||||||
@@ -30,6 +32,7 @@ function toUrl(p: OverviewParams) {
|
|||||||
if (p.sort !== "neu") q.set("sort", p.sort);
|
if (p.sort !== "neu") q.set("sort", p.sort);
|
||||||
if (p.ansicht !== "liste") q.set("ansicht", p.ansicht);
|
if (p.ansicht !== "liste") q.set("ansicht", p.ansicht);
|
||||||
if (p.gruppe !== "keine") q.set("gruppe", p.gruppe);
|
if (p.gruppe !== "keine") q.set("gruppe", p.gruppe);
|
||||||
|
if (p.bestand && p.bestand !== "household") q.set("bestand", p.bestand);
|
||||||
const s = q.toString();
|
const s = q.toString();
|
||||||
return s ? `/?${s}` : "/";
|
return s ? `/?${s}` : "/";
|
||||||
}
|
}
|
||||||
@@ -62,16 +65,42 @@ export default function OverviewToolbar({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [q]);
|
}, [q]);
|
||||||
|
|
||||||
|
const bestandDefault = current.householdName !== null ? "household" : "private";
|
||||||
const hasActive =
|
const hasActive =
|
||||||
current.filter !== "" ||
|
current.filter !== "" ||
|
||||||
current.q !== "" ||
|
current.q !== "" ||
|
||||||
current.kat !== "" ||
|
current.kat !== "" ||
|
||||||
current.typ !== "" ||
|
current.typ !== "" ||
|
||||||
current.sort !== "neu" ||
|
current.sort !== "neu" ||
|
||||||
current.gruppe !== "keine";
|
current.gruppe !== "keine" ||
|
||||||
|
(current.bestand !== "" && current.bestand !== bestandDefault);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-4 grid gap-4 rounded-card border border-line bg-white p-4">
|
<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>
|
<div>
|
||||||
<label htmlFor="q" className="sr-only">
|
<label htmlFor="q" className="sr-only">
|
||||||
Suchen
|
Suchen
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export type VoucherFormInitial = {
|
|||||||
shopUrl: string;
|
shopUrl: string;
|
||||||
providerDomain: string | null;
|
providerDomain: string | null;
|
||||||
providerName: string;
|
providerName: string;
|
||||||
|
scope: "" | "private" | "household";
|
||||||
contactIds: number[];
|
contactIds: number[];
|
||||||
reminderDays: number[];
|
reminderDays: number[];
|
||||||
};
|
};
|
||||||
@@ -53,6 +54,7 @@ export type VoucherFormPayload = {
|
|||||||
notiz: string | null;
|
notiz: string | null;
|
||||||
shopUrl: string | null;
|
shopUrl: string | null;
|
||||||
providerDomain: string | null;
|
providerDomain: string | null;
|
||||||
|
scope: "private" | "household" | "";
|
||||||
contactIds: number[];
|
contactIds: number[];
|
||||||
reminderDays: number[];
|
reminderDays: number[];
|
||||||
};
|
};
|
||||||
@@ -74,11 +76,13 @@ export const EMPTY_FORM: VoucherFormInitial = {
|
|||||||
providerName: "",
|
providerName: "",
|
||||||
contactIds: [],
|
contactIds: [],
|
||||||
reminderDays: [...DEFAULT_REMINDER_DAYS],
|
reminderDays: [...DEFAULT_REMINDER_DAYS],
|
||||||
|
scope: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function VoucherForm({
|
export default function VoucherForm({
|
||||||
initial,
|
initial,
|
||||||
showShopLink = true,
|
showShopLink = true,
|
||||||
|
householdName,
|
||||||
creatorName,
|
creatorName,
|
||||||
submitLabel,
|
submitLabel,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
@@ -86,6 +90,7 @@ export default function VoucherForm({
|
|||||||
}: {
|
}: {
|
||||||
initial: VoucherFormInitial;
|
initial: VoucherFormInitial;
|
||||||
showShopLink?: boolean;
|
showShopLink?: boolean;
|
||||||
|
householdName: string | null;
|
||||||
creatorName?: string | null;
|
creatorName?: string | null;
|
||||||
submitLabel: string;
|
submitLabel: string;
|
||||||
onSubmit: (payload: VoucherFormPayload) => Promise<{ ok: true; id: number } | { ok: false; errors: string[]; serverError: 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 [errors, setErrors] = useState<string[]>([]);
|
||||||
const [serverError, setServerError] = useState("");
|
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 [contactIds, setContactIds] = useState<number[]>(() => [...initial.contactIds]);
|
||||||
|
const [target, setTarget] = useState<"" | "private" | "household">(initial.scope);
|
||||||
const [wertTyp, setWertTyp] = useState<"euro" | "prozent">(initial.wertTyp);
|
const [wertTyp, setWertTyp] = useState<"euro" | "prozent">(initial.wertTyp);
|
||||||
const [reminderDays, setReminderDays] = useState<number[]>(() => [...initial.reminderDays]);
|
const [reminderDays, setReminderDays] = useState<number[]>(() => [...initial.reminderDays]);
|
||||||
const [candidates, setCandidates] = useState<LogoCandidate[]>([]);
|
const [candidates, setCandidates] = useState<LogoCandidate[]>([]);
|
||||||
@@ -165,6 +171,8 @@ export default function VoucherForm({
|
|||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
const fieldErrors: string[] = [];
|
||||||
|
if (!target) fieldErrors.push("scope");
|
||||||
const wertCents = euroToCents(values.wertEuro);
|
const wertCents = euroToCents(values.wertEuro);
|
||||||
const restParsed = euroToCents(values.restEuro);
|
const restParsed = euroToCents(values.restEuro);
|
||||||
const mindestParsed = euroToCents(values.mindestEuro);
|
const mindestParsed = euroToCents(values.mindestEuro);
|
||||||
@@ -182,10 +190,11 @@ export default function VoucherForm({
|
|||||||
kategorie: values.kategorie || null,
|
kategorie: values.kategorie || null,
|
||||||
notiz: values.notiz || null,
|
notiz: values.notiz || null,
|
||||||
providerDomain: selectedLogo?.domain ?? null,
|
providerDomain: selectedLogo?.domain ?? null,
|
||||||
|
scope: target,
|
||||||
contactIds,
|
contactIds,
|
||||||
reminderDays,
|
reminderDays,
|
||||||
};
|
};
|
||||||
const errs = validateVoucher(payload);
|
const errs = [...fieldErrors, ...validateVoucher(payload)];
|
||||||
setErrors(errs);
|
setErrors(errs);
|
||||||
if (errs.length) return;
|
if (errs.length) return;
|
||||||
const result = await onSubmit(payload);
|
const result = await onSubmit(payload);
|
||||||
@@ -197,6 +206,12 @@ export default function VoucherForm({
|
|||||||
onSuccess(result.id);
|
onSuccess(result.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const visibleContacts = contacts.filter((c) => {
|
||||||
|
if (!target) return true;
|
||||||
|
if (target === "household") return c.householdId !== null;
|
||||||
|
return c.householdId === null;
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="mt-5 rounded-card border border-line bg-white p-5 sm:p-6" noValidate>
|
<form onSubmit={handleSubmit} className="mt-5 rounded-card border border-line bg-white p-5 sm:p-6" noValidate>
|
||||||
{creatorName !== undefined && (
|
{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).
|
Angelegt von {creatorName ? <span className="font-semibold text-ink">{creatorName}</span> : "nicht erfasst"} (bleibt unverändert).
|
||||||
</p>
|
</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">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
{FIELDS.filter((f) => showShopLink || f.name !== "shopUrl").map((f) => (
|
{FIELDS.filter((f) => showShopLink || f.name !== "shopUrl").map((f) => (
|
||||||
<div key={f.name} className={f.name === "notiz" ? "sm:col-span-2" : undefined}>
|
<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">
|
<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>
|
<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>
|
<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">
|
<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>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="mt-2 grid gap-1.5">
|
<ul className="mt-2 grid gap-1.5">
|
||||||
{contacts.map((c) => (
|
{visibleContacts.map((c) => (
|
||||||
<li key={c.id}>
|
<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">
|
<label className="flex cursor-pointer items-center gap-2.5 rounded-lg px-1 py-1 text-sm hover:bg-white">
|
||||||
<input
|
<input
|
||||||
|
|||||||
+49
-7
@@ -4,7 +4,7 @@ import Link from "next/link";
|
|||||||
import Shell from "../components/shell";
|
import Shell from "../components/shell";
|
||||||
import { validateContact } from "@/lib/contacts";
|
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 =
|
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";
|
"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 [email, setEmail] = useState("");
|
||||||
const [telegramChatId, setTelegramChatId] = useState("");
|
const [telegramChatId, setTelegramChatId] = useState("");
|
||||||
const [errors, setErrors] = useState<string[]>([]);
|
const [errors, setErrors] = useState<string[]>([]);
|
||||||
|
const [scope, setScope] = useState<"private" | "household">("private");
|
||||||
|
const [householdName, setHouseholdName] = useState<string | null>(null);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const res = await fetch("/api/contacts");
|
const res = await fetch("/api/contacts");
|
||||||
if (res.ok) setContacts(await res.json());
|
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) {
|
async function onSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -31,7 +39,7 @@ export default function ContactsPage() {
|
|||||||
const res = await fetch("/api/contacts", {
|
const res = await fetch("/api/contacts", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify({ ...input, scope }),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}));
|
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>
|
<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>
|
<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>
|
<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="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<label htmlFor="name" className="block text-[13px] font-semibold">Name</label>
|
<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">
|
<ul className="divide-y divide-line">
|
||||||
{contacts.map((c) => (
|
{contacts.map((c) => (
|
||||||
<li key={c.id} className="py-3 text-sm">
|
<li key={c.id} className="flex items-center justify-between gap-3 py-3 text-sm">
|
||||||
<span className="font-semibold">{c.name}</span>
|
<div>
|
||||||
{c.email && <span className="text-muted"> · {c.email}</span>}
|
<span className="font-semibold">{c.name}</span>
|
||||||
{c.telegramChatId && <span className="text-muted"> · Telegram {c.telegramChatId}</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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
+24
-7
@@ -6,6 +6,8 @@ import OverviewToolbar from "./components/overview-toolbar";
|
|||||||
import { withLogoUrl } from "@/lib/logo";
|
import { withLogoUrl } from "@/lib/logo";
|
||||||
import type { Voucher as DbVoucher } from "@prisma/client";
|
import type { Voucher as DbVoucher } from "@prisma/client";
|
||||||
import { getDb } from "@/lib/db";
|
import { getDb } from "@/lib/db";
|
||||||
|
import { getViewer } from "@/lib/viewer";
|
||||||
|
import { scopeWhere } from "@/lib/access";
|
||||||
import { getVoucherStatus, type VoucherStatus } from "@/lib/voucher-status";
|
import { getVoucherStatus, type VoucherStatus } from "@/lib/voucher-status";
|
||||||
import { formatEuro, formatMindestbestellwert, formatWert } from "@/lib/vouchers";
|
import { formatEuro, formatMindestbestellwert, formatWert } from "@/lib/vouchers";
|
||||||
import { filterVouchers, sortVouchers, groupVouchers, listCategories } from "@/lib/voucher-view";
|
import { filterVouchers, sortVouchers, groupVouchers, listCategories } from "@/lib/voucher-view";
|
||||||
@@ -101,13 +103,19 @@ function Skeleton() {
|
|||||||
async function VoucherOverview({
|
async function VoucherOverview({
|
||||||
params,
|
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 db = getDb();
|
||||||
const all = await db.voucher.findMany({ orderBy: { createdAt: "desc" } });
|
const scoped = await db.voucher.findMany({ where: scopeWhere(viewer), orderBy: { createdAt: "desc" } });
|
||||||
const kategorien = listCategories(all);
|
const inBestand = scoped.filter((v) =>
|
||||||
|
effectiveBestand === "household" ? v.householdId === viewer.householdId : v.householdId === null
|
||||||
|
);
|
||||||
|
const kategorien = listCategories(inBestand);
|
||||||
const filtered = sortVouchers(
|
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
|
params.sort
|
||||||
);
|
);
|
||||||
const withLogos = await Promise.all(filtered.map(withLogoUrl));
|
const withLogos = await Promise.all(filtered.map(withLogoUrl));
|
||||||
@@ -153,9 +161,12 @@ async function VoucherOverview({
|
|||||||
export default async function Home({
|
export default async function Home({
|
||||||
searchParams,
|
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 sp = await searchParams;
|
||||||
|
const bestand = sp.bestand === "private" || viewer.householdId === null ? "private" : "household";
|
||||||
const current = {
|
const current = {
|
||||||
filter: sp.filter ?? "",
|
filter: sp.filter ?? "",
|
||||||
q: sp.q ?? "",
|
q: sp.q ?? "",
|
||||||
@@ -164,14 +175,20 @@ export default async function Home({
|
|||||||
sort: sp.sort ?? "neu",
|
sort: sp.sort ?? "neu",
|
||||||
ansicht: sp.ansicht ?? "liste",
|
ansicht: sp.ansicht ?? "liste",
|
||||||
gruppe: sp.gruppe ?? "keine",
|
gruppe: sp.gruppe ?? "keine",
|
||||||
|
bestand,
|
||||||
|
householdName: viewer.householdName,
|
||||||
};
|
};
|
||||||
const db = getDb();
|
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 (
|
return (
|
||||||
<Shell>
|
<Shell>
|
||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Gutscheine</h1>
|
<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>
|
</div>
|
||||||
<OverviewToolbar current={current} kategorien={kategorien} />
|
<OverviewToolbar current={current} kategorien={kategorien} />
|
||||||
<div className="mb-5">
|
<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 Link from "next/link";
|
||||||
import Shell from "../components/shell";
|
import Shell from "../components/shell";
|
||||||
import { getDb } from "@/lib/db";
|
import { getDb } from "@/lib/db";
|
||||||
import { getSessionUser } from "@/lib/authz";
|
import { getViewer } from "@/lib/viewer";
|
||||||
import { DEFAULT_TAGE_VORHER } from "@/lib/settings";
|
import { DEFAULT_TAGE_VORHER } from "@/lib/settings";
|
||||||
import SettingsTabs from "./tabs";
|
import SettingsTabs from "./tabs";
|
||||||
|
|
||||||
@@ -9,8 +9,10 @@ export const dynamic = "force-dynamic";
|
|||||||
|
|
||||||
export default async function SettingsPage() {
|
export default async function SettingsPage() {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const me = await getSessionUser();
|
const me = await getViewer();
|
||||||
const isAdmin = me?.role === "admin";
|
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" } });
|
const last = await db.notificationLog.findFirst({ orderBy: { sentAt: "desc" } });
|
||||||
return (
|
return (
|
||||||
<Shell>
|
<Shell>
|
||||||
@@ -20,9 +22,12 @@ export default async function SettingsPage() {
|
|||||||
<h1 className="mt-2 text-2xl font-semibold tracking-tight">Einstellungen</h1>
|
<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>
|
<p className="mt-1 text-sm text-muted">Erinnerungen, Benachrichtigungen, Dienste und Daten.</p>
|
||||||
<SettingsTabs
|
<SettingsTabs
|
||||||
isAdmin={!!isAdmin}
|
isAdmin={isAdmin}
|
||||||
ownId={me?.id ?? null}
|
isHouseholdAdmin={isHouseholdAdmin}
|
||||||
ownEmail={me?.email ?? null}
|
ownId={me.id}
|
||||||
|
ownEmail={me.email}
|
||||||
|
householdId={me.householdId}
|
||||||
|
householdName={me.householdName}
|
||||||
defaultReminderText={`Neue Gutscheine erhalten diese Erinnerungsregeln: ${DEFAULT_TAGE_VORHER.join(", ")} Tage vorher.`}
|
defaultReminderText={`Neue Gutscheine erhalten diese Erinnerungsregeln: ${DEFAULT_TAGE_VORHER.join(", ")} Tage vorher.`}
|
||||||
lastCronText={
|
lastCronText={
|
||||||
last
|
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 { Suspense } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import UsersSection from "./users";
|
import UsersSection from "./users";
|
||||||
|
import { HouseholdPanel } from "./household-panel";
|
||||||
|
import ServerHouseholds from "./server-households";
|
||||||
import { TestButtons } from "./test-buttons";
|
import { TestButtons } from "./test-buttons";
|
||||||
import { SmtpSection, TelegramSection, LogoSection, OidcSection, useIntegrationStatus } from "./integrations-form";
|
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({
|
export default function SettingsTabs({
|
||||||
isAdmin,
|
isAdmin,
|
||||||
|
isHouseholdAdmin,
|
||||||
ownId,
|
ownId,
|
||||||
ownEmail,
|
ownEmail,
|
||||||
|
householdId,
|
||||||
|
householdName,
|
||||||
defaultReminderText,
|
defaultReminderText,
|
||||||
lastCronText,
|
lastCronText,
|
||||||
}: {
|
}: {
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
isHouseholdAdmin: boolean;
|
||||||
ownId: number | null;
|
ownId: number | null;
|
||||||
ownEmail: string | null;
|
ownEmail: string | null;
|
||||||
|
householdId: number | null;
|
||||||
|
householdName: string | null;
|
||||||
defaultReminderText: string;
|
defaultReminderText: string;
|
||||||
lastCronText: string | null;
|
lastCronText: string | null;
|
||||||
}) {
|
}) {
|
||||||
@@ -33,8 +41,11 @@ export default function SettingsTabs({
|
|||||||
<Suspense>
|
<Suspense>
|
||||||
<TabsInner
|
<TabsInner
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
|
isHouseholdAdmin={isHouseholdAdmin}
|
||||||
ownId={ownId}
|
ownId={ownId}
|
||||||
ownEmail={ownEmail}
|
ownEmail={ownEmail}
|
||||||
|
householdId={householdId}
|
||||||
|
householdName={householdName}
|
||||||
defaultReminderText={defaultReminderText}
|
defaultReminderText={defaultReminderText}
|
||||||
lastCronText={lastCronText}
|
lastCronText={lastCronText}
|
||||||
/>
|
/>
|
||||||
@@ -44,14 +55,20 @@ export default function SettingsTabs({
|
|||||||
|
|
||||||
function TabsInner({
|
function TabsInner({
|
||||||
isAdmin,
|
isAdmin,
|
||||||
|
isHouseholdAdmin,
|
||||||
ownId,
|
ownId,
|
||||||
ownEmail,
|
ownEmail,
|
||||||
|
householdId,
|
||||||
|
householdName,
|
||||||
defaultReminderText,
|
defaultReminderText,
|
||||||
lastCronText,
|
lastCronText,
|
||||||
}: {
|
}: {
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
isHouseholdAdmin: boolean;
|
||||||
ownId: number | null;
|
ownId: number | null;
|
||||||
ownEmail: string | null;
|
ownEmail: string | null;
|
||||||
|
householdId: number | null;
|
||||||
|
householdName: string | null;
|
||||||
defaultReminderText: string;
|
defaultReminderText: string;
|
||||||
lastCronText: string | null;
|
lastCronText: string | null;
|
||||||
}) {
|
}) {
|
||||||
@@ -108,7 +125,19 @@ function TabsInner({
|
|||||||
Kontakte verwalten
|
Kontakte verwalten
|
||||||
</Link>
|
</Link>
|
||||||
</section>
|
</section>
|
||||||
|
{isAdmin && <ServerHouseholds />}
|
||||||
{isAdmin && ownId !== null && <UsersSection ownId={ownId} ownEmail={ownEmail} />}
|
{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;
|
role: string;
|
||||||
oidcVerknuepft: boolean;
|
oidcVerknuepft: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
householdId: number | null;
|
||||||
|
householdName: string | null;
|
||||||
|
householdRole: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type InviteRow = { id: number; email: string; role: string; expiresAt: string; createdAt: string };
|
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 }) {
|
export default function UsersSection({ ownId, ownEmail }: { ownId: number; ownEmail: string | null }) {
|
||||||
const [users, setUsers] = useState<UserRow[]>([]);
|
const [users, setUsers] = useState<UserRow[]>([]);
|
||||||
const [invites, setInvites] = useState<InviteRow[]>([]);
|
const [invites, setInvites] = useState<InviteRow[]>([]);
|
||||||
|
const [households, setHouseholds] = useState<{ id: number; name: string }[]>([]);
|
||||||
const [inviteMail, setInviteMail] = useState("");
|
const [inviteMail, setInviteMail] = useState("");
|
||||||
|
const [inviteHousehold, setInviteHousehold] = useState("");
|
||||||
const [inviteLink, setInviteLink] = useState("");
|
const [inviteLink, setInviteLink] = useState("");
|
||||||
const [notice, setNotice] = useState("");
|
const [notice, setNotice] = useState("");
|
||||||
const [myEmail, setMyEmail] = useState(ownEmail ?? "");
|
const [myEmail, setMyEmail] = useState(ownEmail ?? "");
|
||||||
|
|
||||||
async function load() {
|
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 : []);
|
setUsers(Array.isArray(u) ? u : []);
|
||||||
setInvites(Array.isArray(inv) ? inv : []);
|
setInvites(Array.isArray(inv) ? inv : []);
|
||||||
|
setHouseholds(Array.isArray(hh) ? hh.map((h: { id: number; name: string }) => ({ id: h.id, name: h.name })) : []);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -40,7 +50,7 @@ export default function UsersSection({ ownId, ownEmail }: { ownId: number; ownEm
|
|||||||
const res = await fetch("/api/users/invites", {
|
const res = await fetch("/api/users/invites", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
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(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -48,6 +58,7 @@ export default function UsersSection({ ownId, ownEmail }: { ownId: number; ownEm
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setInviteMail("");
|
setInviteMail("");
|
||||||
|
setInviteHousehold("");
|
||||||
setInviteLink(data.url ?? "");
|
setInviteLink(data.url ?? "");
|
||||||
if (data.mailError) setNotice(`Einladung angelegt, aber E-Mail-Versand schlug fehl: ${data.mailError}`);
|
if (data.mailError) setNotice(`Einladung angelegt, aber E-Mail-Versand schlug fehl: ${data.mailError}`);
|
||||||
else setNotice("Einladung erstellt (7 Tage gültig).");
|
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>}
|
{u.id === ownId && <span className="ml-2 font-normal text-muted">(du)</span>}
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate text-[13px] text-muted">
|
<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" : ""}
|
{u.oidcVerknuepft ? " · SSO verknüpft" : ""}
|
||||||
</p>
|
</p>
|
||||||
|
<p className="truncate text-[13px] text-muted">
|
||||||
|
{u.householdName ? `Haushalt: ${u.householdName}${u.householdRole === "admin" ? " (Admin)" : ""}` : "Kein Haushalt"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{u.id !== ownId && (
|
{u.id !== ownId && (
|
||||||
<button
|
<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">
|
<section className="rounded-card border border-line bg-white p-5">
|
||||||
<h2 className="text-[15px] font-semibold tracking-tight">Per E-Mail einladen</h2>
|
<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>
|
<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 flex gap-2">
|
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||||
<input
|
<div>
|
||||||
value={inviteMail}
|
<label htmlFor="invite-mail" className="block text-[13px] font-semibold">E-Mail-Adresse</label>
|
||||||
onChange={(e) => setInviteMail(e.target.value)}
|
<input
|
||||||
type="email"
|
id="invite-mail"
|
||||||
autoComplete="off"
|
value={inviteMail}
|
||||||
placeholder="name@beispiel.de"
|
onChange={(e) => setInviteMail(e.target.value)}
|
||||||
aria-label="E-Mail-Adresse für Einladung"
|
type="email"
|
||||||
className="h-11 min-w-0 flex-1 rounded-field border border-line bg-white px-3.5 text-sm text-ink"
|
autoComplete="off"
|
||||||
/>
|
placeholder="name@beispiel.de"
|
||||||
<button
|
className="mt-1.5 h-11 w-full rounded-field border border-line bg-white px-3.5 text-sm text-ink"
|
||||||
type="button"
|
/>
|
||||||
onClick={invite}
|
</div>
|
||||||
className="h-11 shrink-0 rounded-field bg-brand-600 px-5 text-sm font-semibold text-white transition-colors hover:bg-brand-700"
|
<div>
|
||||||
>
|
<label htmlFor="invite-household" className="block text-[13px] font-semibold">Haushalt (optional)</label>
|
||||||
Einladen
|
<select
|
||||||
</button>
|
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>
|
</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 && (
|
{inviteLink && (
|
||||||
<p className="mt-2 break-all text-[13px]">
|
<p className="mt-2 break-all text-[13px]">
|
||||||
Einladungslink:{" "}
|
Einladungslink:{" "}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export default function EditVoucherPage({ params }: { params: Promise<{ id: stri
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [initial, setInitial] = useState<VoucherFormInitial | null>(null);
|
const [initial, setInitial] = useState<VoucherFormInitial | null>(null);
|
||||||
const [creatorName, setCreatorName] = useState<string | null>(null);
|
const [creatorName, setCreatorName] = useState<string | null>(null);
|
||||||
|
const [householdName, setHouseholdName] = useState<string | null>(null);
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [deleteError, setDeleteError] = useState("");
|
const [deleteError, setDeleteError] = useState("");
|
||||||
@@ -37,6 +38,10 @@ export default function EditVoucherPage({ params }: { params: Promise<{ id: stri
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
fetch("/api/users/me")
|
||||||
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
|
.then((me) => setHouseholdName(me?.householdName ?? null))
|
||||||
|
.catch(() => {});
|
||||||
fetch(`/api/vouchers/${id}`)
|
fetch(`/api/vouchers/${id}`)
|
||||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error("not found"))))
|
.then((r) => (r.ok ? r.json() : Promise.reject(new Error("not found"))))
|
||||||
.then((v) => {
|
.then((v) => {
|
||||||
@@ -59,6 +64,7 @@ export default function EditVoucherPage({ params }: { params: Promise<{ id: stri
|
|||||||
providerName: v.anbieter ?? "",
|
providerName: v.anbieter ?? "",
|
||||||
contactIds: (v.contacts ?? []).map((c: { contactId: number }) => c.contactId),
|
contactIds: (v.contacts ?? []).map((c: { contactId: number }) => c.contactId),
|
||||||
reminderDays: rules.map((r) => r.tageVorher),
|
reminderDays: rules.map((r) => r.tageVorher),
|
||||||
|
scope: v.householdId != null ? "household" : "private",
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(() => setError("Gutschein nicht gefunden"))
|
.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>
|
<p className="mt-1 text-sm text-muted">Alle Angaben lassen sich ändern. Der Ersteller bleibt erhalten.</p>
|
||||||
<VoucherForm
|
<VoucherForm
|
||||||
initial={initial}
|
initial={initial}
|
||||||
|
householdName={householdName}
|
||||||
creatorName={creatorName}
|
creatorName={creatorName}
|
||||||
submitLabel="Änderungen speichern"
|
submitLabel="Änderungen speichern"
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type Voucher = {
|
|||||||
createdBy?: string | null;
|
createdBy?: string | null;
|
||||||
providerDomain?: string | null;
|
providerDomain?: string | null;
|
||||||
shopUrl?: string | null;
|
shopUrl?: string | null;
|
||||||
|
household?: { name: string } | null;
|
||||||
logoUrl?: string | null;
|
logoUrl?: string | null;
|
||||||
contacts?: { contact: { id: number; name: string; email: string | null; telegramChatId: 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="Mindestbestellwert">{formatEuro(v.mindestbestellwertCents)}</Row>
|
||||||
)}
|
)}
|
||||||
<Row label="Status">{status === "aktiv" ? "Aktiv" : status === "abgelaufen" ? "Abgelaufen" : "Eingelöst"}</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>
|
<Row label="Angelegt von">{v.createdBy ? v.createdBy : "Nicht erfasst"}</Row>
|
||||||
{v.ablaufdatum && (
|
{v.ablaufdatum && (
|
||||||
<Row label="Ablaufdatum">{new Date(v.ablaufdatum).toLocaleDateString("de-DE")}</Row>
|
<Row label="Ablaufdatum">{new Date(v.ablaufdatum).toLocaleDateString("de-DE")}</Row>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Shell from "../../components/shell";
|
import Shell from "../../components/shell";
|
||||||
@@ -7,11 +8,19 @@ import VoucherForm, { EMPTY_FORM, type VoucherFormPayload } from "../../componen
|
|||||||
|
|
||||||
export default function NewVoucherPage() {
|
export default function NewVoucherPage() {
|
||||||
const router = useRouter();
|
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) {
|
async function onSubmit(payload: VoucherFormPayload) {
|
||||||
const res = await fetch("/api/vouchers", {
|
const res = await fetch("/api/vouchers", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -32,6 +41,7 @@ export default function NewVoucherPage() {
|
|||||||
<VoucherForm
|
<VoucherForm
|
||||||
initial={EMPTY_FORM}
|
initial={EMPTY_FORM}
|
||||||
showShopLink={false}
|
showShopLink={false}
|
||||||
|
householdName={householdName}
|
||||||
submitLabel="Speichern"
|
submitLabel="Speichern"
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
onSuccess={(id) => router.push(`/vouchers/${id}`)}
|
onSuccess={(id) => router.push(`/vouchers/${id}`)}
|
||||||
|
|||||||
+2
-1
@@ -1,9 +1,10 @@
|
|||||||
export async function register() {
|
export async function register() {
|
||||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||||
const { ensureSchema } = await import("./lib/db-migrate");
|
const { ensureSchema, ensureMultitenancy } = await import("./lib/db-migrate");
|
||||||
await ensureSchema();
|
await ensureSchema();
|
||||||
const { ensureFirstAdmin } = await import("./lib/ensure-admin");
|
const { ensureFirstAdmin } = await import("./lib/ensure-admin");
|
||||||
await ensureFirstAdmin();
|
await ensureFirstAdmin();
|
||||||
|
await ensureMultitenancy();
|
||||||
const { ensureSchedulerStarted } = await import("./lib/scheduler");
|
const { ensureSchedulerStarted } = await import("./lib/scheduler");
|
||||||
ensureSchedulerStarted();
|
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")) {
|
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)`;
|
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")) {
|
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 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_name_key" ON "HouseholdMember"("name")`;
|
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")) {
|
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)`;
|
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")`;
|
await db.$executeRaw`CREATE UNIQUE INDEX "User_oidcSubject_key" ON "User"("oidcSubject")`;
|
||||||
}
|
}
|
||||||
if (!tables.includes("Invite")) {
|
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_email_key" ON "Invite"("email")`;
|
||||||
await db.$executeRaw`CREATE UNIQUE INDEX "Invite_tokenHash_key" ON "Invite"("tokenHash")`;
|
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
|
(c) => c.name
|
||||||
);
|
);
|
||||||
if (!cols.includes("providerDomain")) {
|
if (!vcols.includes("providerDomain")) {
|
||||||
await db.$executeRaw`ALTER TABLE "Voucher" ADD COLUMN "providerDomain" TEXT`;
|
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`;
|
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'`;
|
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`;
|
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`;
|
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`;
|
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) {
|
} catch (err) {
|
||||||
console.error("ensureSchema fehlgeschlagen (Start läuft weiter):", 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")
|
wertTyp String @default("euro")
|
||||||
prozentWert Int?
|
prozentWert Int?
|
||||||
mindestbestellwertCents 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")
|
status String @default("aktiv")
|
||||||
contacts VoucherContact[]
|
contacts VoucherContact[]
|
||||||
rules ReminderRule[]
|
rules ReminderRule[]
|
||||||
@@ -32,6 +36,10 @@ model Contact {
|
|||||||
name String
|
name String
|
||||||
email String?
|
email String?
|
||||||
telegramChatId 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[]
|
vouchers VoucherContact[]
|
||||||
}
|
}
|
||||||
model VoucherContact {
|
model VoucherContact {
|
||||||
@@ -68,10 +76,25 @@ model AppSetting {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
model HouseholdMember {
|
model Household {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String @unique
|
name String @unique
|
||||||
createdAt DateTime @default(now())
|
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 {
|
model User {
|
||||||
@@ -84,6 +107,9 @@ model User {
|
|||||||
tokenVersion Int @default(0)
|
tokenVersion Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
membership HouseholdMember?
|
||||||
|
ownedVouchers Voucher[] @relation("VoucherOwner")
|
||||||
|
ownedContacts Contact[] @relation("ContactOwner")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Invite {
|
model Invite {
|
||||||
@@ -91,6 +117,8 @@ model Invite {
|
|||||||
email String @unique
|
email String @unique
|
||||||
tokenHash String @unique
|
tokenHash String @unique
|
||||||
role String @default("member")
|
role String @default("member")
|
||||||
|
householdId Int?
|
||||||
|
household Household? @relation(fields: [householdId], references: [id], onDelete: Cascade)
|
||||||
expiresAt DateTime
|
expiresAt DateTime
|
||||||
acceptedAt DateTime?
|
acceptedAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
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