mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
A managed profile could be created and deleted and never changed. A name typed as a placeholder was permanent, a household that moved kept the timezone the record was created in, and there was no way to correct a date of birth somebody finally had (#939). PATCH /api/managed-profiles/{id} takes the fields creation takes, every one optional and at least one required — an empty body is a refusal rather than a save that audits a change nobody made. It carries the gate the rest of the family carries: a cookie session with a fresh second factor, an active Guardian grant on the record, and the same byte-identical 404 for "no such record" and "not yours". A GET beside it answers the record as it stands, because the account payload names a managed record and nothing else, and a form filled from that would have had blank fields to send. The new field is gender. Leaving it out of v1.37.0 meant every managed record started with the column NULL, which is what the cycle module derives its default from: a guardian creating a record for a child could neither record the answer nor turn the module off from there. It reuses the column and the three values every other surface writes, so there is one word for one column rather than two. Refs #939
96 lines
2.7 KiB
TypeScript
96 lines
2.7 KiB
TypeScript
import { randomBytes } from "node:crypto";
|
|
|
|
import { Prisma } from "@/generated/prisma/client";
|
|
import type { AccountGrant, User } from "@/generated/prisma/client";
|
|
import { prisma } from "@/lib/db";
|
|
|
|
const USERNAME_ATTEMPTS = 4;
|
|
|
|
export interface CreateManagedProfileInput {
|
|
creatorId: string;
|
|
displayName: string;
|
|
dateOfBirth: Date | null;
|
|
locale: string;
|
|
timezone: string;
|
|
/**
|
|
* `User.gender` — "MALE" | "FEMALE" | "OTHER". Omitted or null both mean not
|
|
* recorded, and the column keeps the NULL an account that never answered
|
|
* carries; nothing is inferred from the name or from the date of birth.
|
|
*/
|
|
gender?: string | null;
|
|
}
|
|
|
|
export interface CreatedManagedProfile {
|
|
profile: User;
|
|
creatorGrant: AccountGrant;
|
|
}
|
|
|
|
export class ManagedProfileCreationError extends Error {
|
|
constructor() {
|
|
super("Unable to allocate a managed profile identity");
|
|
this.name = "ManagedProfileCreationError";
|
|
}
|
|
}
|
|
|
|
function managedUsername(): string {
|
|
return `managed-${randomBytes(18).toString("hex")}`;
|
|
}
|
|
|
|
function isUniqueViolation(error: unknown): boolean {
|
|
return (
|
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
|
error.code === "P2002"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Create the credential-less record and its first Guardian grant together.
|
|
*
|
|
* A profile is never useful before the creator can manage it, and a creator
|
|
* grant without its record is invalid by definition. Keeping both writes in
|
|
* one transaction makes a failed grant insert leave no login-less user behind.
|
|
*/
|
|
export async function createManagedProfile(
|
|
input: CreateManagedProfileInput,
|
|
): Promise<CreatedManagedProfile> {
|
|
for (let attempt = 0; attempt < USERNAME_ATTEMPTS; attempt += 1) {
|
|
try {
|
|
return await prisma.$transaction(async (tx) => {
|
|
const now = new Date();
|
|
const profile = await tx.user.create({
|
|
data: {
|
|
username: managedUsername(),
|
|
email: null,
|
|
passwordHash: null,
|
|
displayName: input.displayName,
|
|
dateOfBirth: input.dateOfBirth,
|
|
gender: input.gender ?? null,
|
|
locale: input.locale,
|
|
timezone: input.timezone,
|
|
managedProfileAt: now,
|
|
},
|
|
});
|
|
const creatorGrant = await tx.accountGrant.create({
|
|
data: {
|
|
grantorId: profile.id,
|
|
granteeId: input.creatorId,
|
|
access: "MANAGE",
|
|
scopeJson: Prisma.DbNull,
|
|
invitedAt: now,
|
|
acceptedAt: now,
|
|
expiresAt: null,
|
|
},
|
|
});
|
|
return { profile, creatorGrant };
|
|
});
|
|
} catch (error) {
|
|
if (isUniqueViolation(error) && attempt + 1 < USERNAME_ATTEMPTS) {
|
|
continue;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
throw new ManagedProfileCreationError();
|
|
}
|