mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
fix: complete managed Guardian invitation lifecycle
This commit is contained in:
1 parent
493437207e
commit
389f2a042b
4 files changed
+112
-51
No files matched your search
@@ -24,7 +24,10 @@ const INVITE_LIMIT = 10;
|
||||
const INVITE_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
const guardianInviteSchema = z
|
||||
.object({ identifier: z.string().trim().min(1).max(320) })
|
||||
.object({
|
||||
identifier: z.string().trim().min(1).max(255),
|
||||
expiresAt: z.iso.datetime({ offset: true }).nullable().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Invite another Guardian from a cookie-backed, freshly verified session. */
|
||||
@@ -63,6 +66,9 @@ export const POST = apiHandler(
|
||||
profileId: id,
|
||||
guardianId: user.id,
|
||||
granteeId: invitee.id,
|
||||
expiresAt: parsed.data.expiresAt
|
||||
? new Date(parsed.data.expiresAt)
|
||||
: null,
|
||||
});
|
||||
annotate({
|
||||
action: { name: "managed_profile.guardian.invited" },
|
||||
@@ -81,6 +87,11 @@ export const POST = apiHandler(
|
||||
});
|
||||
}
|
||||
if (error instanceof ManagedProfileLifecycleError) {
|
||||
if (error.code === "managed_grantee") {
|
||||
return apiError("A managed profile cannot be a Guardian", 422, {
|
||||
errorCode: "managed_profile.guardian.managed_invitee",
|
||||
});
|
||||
}
|
||||
return apiError("Managed profile not found", 404, {
|
||||
errorCode: "managed_profile.not_found",
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ export const activeGuardianWhere = (now: Date) => ({
|
||||
});
|
||||
|
||||
export type ManagedProfileLifecycleErrorCode =
|
||||
"not_found" | "not_managed" | "not_guardian";
|
||||
"not_found" | "not_managed" | "not_guardian" | "managed_grantee";
|
||||
|
||||
export class ManagedProfileLifecycleError extends Error {
|
||||
constructor(readonly code: ManagedProfileLifecycleErrorCode) {
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
* transition computed against the stale row.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { isP2002 } from "@/lib/prisma-errors";
|
||||
import { clearActingSessions } from "@/lib/sharing/acting-session";
|
||||
import {
|
||||
@@ -469,6 +470,7 @@ export async function inviteManagedProfileGuardian(input: {
|
||||
profileId: string;
|
||||
guardianId: string;
|
||||
granteeId: string;
|
||||
expiresAt?: Date | null;
|
||||
}): Promise<AccountGrant> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
return withManagedProfileLock(tx, input.profileId, async (profile) => {
|
||||
@@ -485,16 +487,30 @@ export async function inviteManagedProfileGuardian(input: {
|
||||
select: { id: true },
|
||||
});
|
||||
if (!guardian) throw new ManagedProfileLifecycleError("not_guardian");
|
||||
return inviteGrant(
|
||||
const grantee = await tx.user.findUnique({
|
||||
where: { id: input.granteeId },
|
||||
select: { managedProfileAt: true },
|
||||
});
|
||||
if (grantee?.managedProfileAt) {
|
||||
throw new ManagedProfileLifecycleError("managed_grantee");
|
||||
}
|
||||
const grant = await inviteGrant(
|
||||
{
|
||||
grantorId: profile.id,
|
||||
granteeId: input.granteeId,
|
||||
access: "MANAGE",
|
||||
scope: null,
|
||||
expiresAt: null,
|
||||
expiresAt: input.expiresAt ?? null,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
await auditLog("managed_profile.guardian.invited", {
|
||||
userId: profile.id,
|
||||
actorUserId: input.guardianId,
|
||||
details: { grantId: grant.id },
|
||||
client: tx,
|
||||
});
|
||||
return grant;
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -524,28 +540,49 @@ export async function revokeManagedProfileGuardian(input: {
|
||||
where: {
|
||||
id: input.grantId,
|
||||
grantorId: profile.id,
|
||||
...activeGuardianWhere(new Date()),
|
||||
access: "MANAGE",
|
||||
revokedAt: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
granteeId: true,
|
||||
acceptedAt: true,
|
||||
expiresAt: true,
|
||||
},
|
||||
select: { id: true, granteeId: true },
|
||||
});
|
||||
if (!target) throw new ManagedProfileLifecycleError("not_guardian");
|
||||
if (target.granteeId === input.guardianId) {
|
||||
throw new ManagedProfileLifecycleError("not_guardian");
|
||||
}
|
||||
|
||||
const activeGuardians = await tx.accountGrant.count({
|
||||
where: { grantorId: profile.id, ...activeGuardianWhere(new Date()) },
|
||||
});
|
||||
if (activeGuardians <= 1) throw new LastManagedGuardianError();
|
||||
const now = new Date();
|
||||
const targetIsActive =
|
||||
target.acceptedAt !== null &&
|
||||
(target.expiresAt === null || target.expiresAt > now);
|
||||
if (targetIsActive) {
|
||||
const activeGuardians = await tx.accountGrant.count({
|
||||
where: { grantorId: profile.id, ...activeGuardianWhere(now) },
|
||||
});
|
||||
if (activeGuardians <= 1) throw new LastManagedGuardianError();
|
||||
}
|
||||
|
||||
await tx.accountGrant.update({
|
||||
where: { id: target.id },
|
||||
data: { revokedAt: new Date(), revokedBy: "GRANTOR" },
|
||||
// `revokedBy` records which grant party withdrew access. The managed
|
||||
// profile is the grantor; the individual Guardian is durable audit
|
||||
// attribution below rather than an overloaded enum value.
|
||||
data: { revokedAt: now, revokedBy: "GRANTOR" },
|
||||
});
|
||||
await clearActingSessions(
|
||||
{ grantorId: profile.id, granteeId: target.granteeId },
|
||||
tx,
|
||||
);
|
||||
await auditLog("managed_profile.guardian.revoked", {
|
||||
userId: profile.id,
|
||||
actorUserId: input.guardianId,
|
||||
details: { grantId: target.id },
|
||||
client: tx,
|
||||
});
|
||||
return tx.accountGrant.findUniqueOrThrow({ where: { id: target.id } });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user