1. Summary of changes
The installer-created account is no longer special. "Owner" is now literally renamed Administrator and behaves exactly like the existing role/permission system already allowed — the codebase turned out to already define "administrator" structurally (a StaffProfile whose role holds MANAGE_STAFF), not as a name or a business.ownerId-style field, and last-administrator protection already existed. The real work was: closing the gap between that and a full admin-management UI, fixing two OTP-migration bugs the new spec's requirements exposed, and making the existing protection transactional.
Businesses can now have any number of Administrators, added via Invite Administrator (name + email → OTP-verified before access), promoted/demoted via the existing staff role dropdown (now intercepted with a confirmation dialog when it crosses the Administrator boundary), or removed via the existing deactivate flow.
Two real bugs found and fixed, both required for the spec's "unverified email must not get access":
Invitation acceptance (/invite/accept) never actually verified an OTP — it silently relied on a sign-in path that would have failed for every real invitee. It now sends and verifies a real code, reusing the shared OtpCodeStep UI.
A newly-invited (not-yet-accepted) account could sign in via plain email OTP before ever touching the invite link, because sign-in only checked "does a User row exist," not whether it was verified. Fixed in the shared OTP service, benefiting every invitation, not just administrators'.
Last-administrator protection (already existed) is now transactional — a SELECT ... FOR UPDATE lock on the business row closes the race where two concurrent removals of two different administrators could both succeed.
Administrators (any staff member, in fact) can now change their own email — new address must be OTP-verified before it replaces the login email.
No hidden installer privilege: confirmed via code audit and a live install → the account is a plain Administrator-role staff member, provable identically via the Team UI as any later-added one.
NextChair Manager and platform Super Administrator are untouched.
2. Prisma/database changes
No schema changes. One data-only migration: prisma/migrations/20260905214944_administrator_role_rename — UPDATE Role SET name = 'Administrator' WHERE name = 'Owner' AND isSystem = true. Runs through the normal migration-deploy process; not something to run by hand.
3. How Administrator roles are represented
A per-business Role row named "Administrator" (was "Owner"), isSystem: true, holding every Permission — created identically at install and in dev seed data. This is what "Invite"/"Make Administrator" assign.
Whether a StaffProfile currently counts as an Administrator is permission-based (holds MANAGE_STAFF), not a name check — the same definition the codebase already used for last-owner protection (isStaffAdministrator in staff.service.ts). This is deliberately reused rather than introduced, and stays correct if a business ever gets a custom role that also grants MANAGE_STAFF.
No Business.adminId-style field anywhere — confirmed none existed before this work either.
4. How administrator invitations work
Reuses the existing StaffInvitation model and createStaffProfile/createInvitation machinery verbatim — a new inviteAdministratorAction just pre-selects the Administrator role and uses a minimal Name+Email form (invite-administrator-form.tsx). Acceptance (accept-invitation.ts) now requires a real OTP code sent to the invited email before the account activates — expiry (7 days), resend, and revoke are all the pre-existing invitation mechanics (revoke = the existing deactivate action, relabeled "Revoke Invitation" while pending).
5. How last-administrator protection is enforced
Server-side only, inside staff.service.ts's updateStaffAsOwner (role change) and deactivateStaff (remove), each wrapped in prisma.$transaction that first locks the business row (SELECT ... FOR UPDATE), then counts other active Administrators before allowing the change. Verified with a genuine concurrency test (two simultaneous removals against a 2-admin business) and manually in-browser (see below).
6. Installation flow changes
Only the role name and one line of wizard copy changed ("This becomes the first Administrator..."). No structural UX change — still collects name/email, verifies via OTP, creates/activates the account, assigns the role, exactly as before.
7. Files changed
New: the migration; email-change.service.ts + change-email.ts action + change-email-section.tsx; invite-administrator-form.tsx + add-administrator/page.tsx; 3 new integration test files.
Modified — core: staff.service.ts (transactional protection, isStaffAdministrator), staff-invitation.service.ts, otp.service.ts (onlyIfUserExists fix, email-change purpose), default-permissions.ts, permissions.ts, auth.ts (JWT email-refresh, simplified finalize path).
Modified — actions/UI: owner/actions/staff.ts, staff-detail-panel.tsx, team/page.tsx, add-staff-form.tsx, accept-invitation.ts/form/page, otp-code-step.tsx, profile-form.tsx, installer files, seed.ts, plus a terminology sweep ("owner" → "administrator" copy in breaks.ts/time-off.ts/staff-emails.ts/profile page).
8. Manual testing steps
Sign in as the seeded owner → /owner/team shows an Administrator badge and an "Invite Administrator" icon next to "+". Invite someone, accept via /invite/accept?token=... printed to the terminal (code also prints there) — they land signed in with full Manage access. Promote/demote any staff member via the role dropdown on their detail page — crossing the Administrator boundary shows a confirmation dialog. With only one Administrator left, attempting to demote or deactivate them shows "You can't remove the only administrator. Add another administrator first." Change your own email from Profile settings — the new address must be OTP-verified before it replaces the old one.
9. Deployment steps on the VPS
None beyond the normal deploy process — the migration is a standard Prisma migration that prisma migrate deploy (whatever already runs it there) will pick up automatically. No new environment variables. No changes to NextChair Manager or its authentication.
Verification performed: tsc/lint/unit — all clean; full test:integration — 219 passed (up from 207, all new); full test:e2e — same 36/37 as before this work (the 1 failure is a pre-existing, auth-unrelated flake unrelated to any of this); a full live manual pass covering every scenario above, including the last-administrator block and the email-change JWT-refresh behavior.
This commit is contained in:
33 files changed
+1348
-265
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
import { Weekday } from "@/generated/prisma/enums";
|
||||
import {
|
||||
calendarDateOf,
|
||||
@@ -34,7 +34,7 @@ export type StaffServiceErrorCode =
|
||||
| "PERMISSION_DENIED"
|
||||
| "EMAIL_ALREADY_IN_USE"
|
||||
| "ROLE_NOT_FOUND"
|
||||
| "LAST_OWNER";
|
||||
| "LAST_ADMINISTRATOR";
|
||||
|
||||
export class StaffPermissionDeniedError extends Error {
|
||||
constructor(message = "You don't have permission to manage this staff member's schedule.") {
|
||||
@@ -43,6 +43,19 @@ export class StaffPermissionDeniedError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** A Prisma client or an in-flight transaction client — same convention as src/lib/auth/otp.service.ts's `Db` alias, so the last-administrator count/update below can run atomically inside one `$transaction`. */
|
||||
type Db = PrismaClient | Prisma.TransactionClient;
|
||||
|
||||
/** Thrown inside a `$transaction` callback below to carry a specific StaffServiceErrorCode back out through the surrounding try/catch — mirrors installer.service.ts's own InstallStepError pattern. */
|
||||
class StaffOperationError extends Error {
|
||||
constructor(
|
||||
readonly code: StaffServiceErrorCode,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Every mutation here takes the acting staff member's own id and asserts the resource belongs to them — a staff member manages only their own schedule this phase (see docs/phase-5-summary.md, "Permission model"). */
|
||||
export function assertOwnsResource(resourceStaffId: string, actingStaffId: string): void {
|
||||
if (resourceStaffId !== actingStaffId) {
|
||||
@@ -696,12 +709,12 @@ export async function listRolesForBusiness(prisma: PrismaClient, businessId: str
|
||||
});
|
||||
}
|
||||
|
||||
/** A role holding MANAGE_STAFF is this app's definition of "an owner" — see docs/phase-6-summary.md, "Roles and permissions". */
|
||||
async function countOtherActiveManagers(
|
||||
prisma: PrismaClient,
|
||||
/** A role holding MANAGE_STAFF is this app's definition of "an Administrator" — see docs/phase-6-summary.md, "Roles and permissions". Deliberately permission-based, not a check against the role's name — stays correct even if a business ever gets a custom role that also grants MANAGE_STAFF. */
|
||||
async function countOtherActiveAdministrators(
|
||||
db: Db,
|
||||
params: { businessId: string; excludeStaffId: string },
|
||||
) {
|
||||
return prisma.staffProfile.count({
|
||||
return db.staffProfile.count({
|
||||
where: {
|
||||
businessId: params.businessId,
|
||||
status: "ACTIVE",
|
||||
@@ -711,8 +724,9 @@ async function countOtherActiveManagers(
|
||||
});
|
||||
}
|
||||
|
||||
async function staffCurrentlyManagesStaff(prisma: PrismaClient, staffId: string): Promise<boolean> {
|
||||
const staff = await prisma.staffProfile.findUnique({
|
||||
/** Exported so the owner-actions layer can decide when a role change / deactivation crosses the Administrator boundary, for audit-log naming (ADMINISTRATOR_PROMOTED/DEMOTED/REMOVED vs. the generic STAFF_* actions). */
|
||||
export async function isStaffAdministrator(db: Db, staffId: string): Promise<boolean> {
|
||||
const staff = await db.staffProfile.findUnique({
|
||||
where: { id: staffId },
|
||||
select: { status: true, role: { select: { permissions: { select: { permission: true } } } } },
|
||||
});
|
||||
@@ -796,63 +810,87 @@ export interface UpdateStaffAsOwnerInput {
|
||||
roleId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A role change is the only path here that can ever reduce the business's
|
||||
* administrator count, so the last-administrator check and the actual
|
||||
* write must happen atomically — otherwise two concurrent requests each
|
||||
* demoting a *different* administrator could both read "someone else is
|
||||
* still an administrator" before either commits, and both succeed,
|
||||
* leaving zero. When a role change is requested, this acquires a
|
||||
* `SELECT ... FOR UPDATE` lock on the business's own row first (same
|
||||
* `$queryRaw` tagged-template pattern already used in
|
||||
* src/app/api/health/route.ts) so a second concurrent call for the same
|
||||
* business blocks until the first transaction commits — at which point its
|
||||
* own count reflects the first call's already-applied change. Calls that
|
||||
* don't touch `roleId` skip the lock entirely so ordinary concurrent
|
||||
* profile edits for different staff members are never needlessly
|
||||
* serialized against each other.
|
||||
*/
|
||||
export async function updateStaffAsOwner(
|
||||
prisma: PrismaClient,
|
||||
params: { staffId: string; businessId: string; input: UpdateStaffAsOwnerInput },
|
||||
) {
|
||||
const existing = await prisma.staffProfile.findFirst({
|
||||
where: { id: params.staffId, businessId: params.businessId },
|
||||
});
|
||||
if (!existing) {
|
||||
return { success: false as const, validation: fail({ code: "STAFF_NOT_FOUND", message: "Staff member not found." }) };
|
||||
}
|
||||
|
||||
if (params.input.roleId && params.input.roleId !== existing.roleId) {
|
||||
const role = await prisma.role.findFirst({ where: { id: params.input.roleId, businessId: params.businessId } });
|
||||
if (!role) {
|
||||
return { success: false as const, validation: fail({ code: "ROLE_NOT_FOUND", message: "Role not found." }) };
|
||||
}
|
||||
const wasManager = await staffCurrentlyManagesStaff(prisma, params.staffId);
|
||||
const stillManagesAfter = !!(await prisma.rolePermission.findFirst({
|
||||
where: { roleId: role.id, permission: "MANAGE_STAFF" },
|
||||
}));
|
||||
if (wasManager && !stillManagesAfter) {
|
||||
const otherManagers = await countOtherActiveManagers(prisma, {
|
||||
businessId: params.businessId,
|
||||
excludeStaffId: params.staffId,
|
||||
});
|
||||
if (otherManagers === 0) {
|
||||
return {
|
||||
success: false as const,
|
||||
validation: fail({
|
||||
code: "LAST_OWNER",
|
||||
message: "This is the only active owner — assign another owner first.",
|
||||
}),
|
||||
};
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
if (params.input.roleId !== undefined) {
|
||||
await tx.$queryRaw`SELECT id FROM Business WHERE id = ${params.businessId} FOR UPDATE`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.user.update({
|
||||
where: { id: existing.userId },
|
||||
data: {
|
||||
...(params.input.name !== undefined ? { name: params.input.name } : {}),
|
||||
...(params.input.phone !== undefined ? { phone: params.input.phone || null } : {}),
|
||||
...(params.input.image !== undefined ? { image: params.input.image || null } : {}),
|
||||
},
|
||||
}),
|
||||
prisma.staffProfile.update({
|
||||
where: { id: params.staffId },
|
||||
data: {
|
||||
...(params.input.bio !== undefined ? { bio: params.input.bio || null } : {}),
|
||||
...(params.input.isBookableOnline !== undefined
|
||||
? { isBookableOnline: params.input.isBookableOnline }
|
||||
: {}),
|
||||
...(params.input.roleId !== undefined ? { roleId: params.input.roleId } : {}),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const existing = await tx.staffProfile.findFirst({
|
||||
where: { id: params.staffId, businessId: params.businessId },
|
||||
});
|
||||
if (!existing) {
|
||||
throw new StaffOperationError("STAFF_NOT_FOUND", "Staff member not found.");
|
||||
}
|
||||
|
||||
if (params.input.roleId && params.input.roleId !== existing.roleId) {
|
||||
const role = await tx.role.findFirst({ where: { id: params.input.roleId, businessId: params.businessId } });
|
||||
if (!role) {
|
||||
throw new StaffOperationError("ROLE_NOT_FOUND", "Role not found.");
|
||||
}
|
||||
const wasAdministrator = await isStaffAdministrator(tx, params.staffId);
|
||||
const stillAdministratorAfter = !!(await tx.rolePermission.findFirst({
|
||||
where: { roleId: role.id, permission: "MANAGE_STAFF" },
|
||||
}));
|
||||
if (wasAdministrator && !stillAdministratorAfter) {
|
||||
const otherAdministrators = await countOtherActiveAdministrators(tx, {
|
||||
businessId: params.businessId,
|
||||
excludeStaffId: params.staffId,
|
||||
});
|
||||
if (otherAdministrators === 0) {
|
||||
throw new StaffOperationError(
|
||||
"LAST_ADMINISTRATOR",
|
||||
"You can't remove the only administrator. Add another administrator first.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await tx.user.update({
|
||||
where: { id: existing.userId },
|
||||
data: {
|
||||
...(params.input.name !== undefined ? { name: params.input.name } : {}),
|
||||
...(params.input.phone !== undefined ? { phone: params.input.phone || null } : {}),
|
||||
...(params.input.image !== undefined ? { image: params.input.image || null } : {}),
|
||||
},
|
||||
});
|
||||
await tx.staffProfile.update({
|
||||
where: { id: params.staffId },
|
||||
data: {
|
||||
...(params.input.bio !== undefined ? { bio: params.input.bio || null } : {}),
|
||||
...(params.input.isBookableOnline !== undefined
|
||||
? { isBookableOnline: params.input.isBookableOnline }
|
||||
: {}),
|
||||
...(params.input.roleId !== undefined ? { roleId: params.input.roleId } : {}),
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof StaffOperationError) {
|
||||
return { success: false as const, validation: fail({ code: error.code, message: error.message }) };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { success: true as const, validation: ok() };
|
||||
}
|
||||
@@ -888,24 +926,6 @@ export async function deactivateStaff(
|
||||
};
|
||||
}
|
||||
|
||||
const wasManager = await staffCurrentlyManagesStaff(prisma, params.staffId);
|
||||
if (wasManager) {
|
||||
const otherManagers = await countOtherActiveManagers(prisma, {
|
||||
businessId: params.businessId,
|
||||
excludeStaffId: params.staffId,
|
||||
});
|
||||
if (otherManagers === 0) {
|
||||
return {
|
||||
success: false as const,
|
||||
validation: fail({
|
||||
code: "LAST_OWNER",
|
||||
message: "This is the only active owner — assign another owner first.",
|
||||
}),
|
||||
conflicts: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!params.acknowledgeConflicts) {
|
||||
const conflicts = await findStaffDeactivationConflicts(prisma, { staffId: params.staffId });
|
||||
if (conflicts.length > 0) {
|
||||
@@ -920,7 +940,37 @@ export async function deactivateStaff(
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.staffProfile.update({ where: { id: params.staffId }, data: { status: "INACTIVE" } });
|
||||
// Last-administrator protection is checked fresh, inside the same
|
||||
// transaction as the actual write, under a lock on the business row —
|
||||
// see updateStaffAsOwner's own comment for why this needs to be atomic
|
||||
// rather than the read-then-write this used to be.
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.$queryRaw`SELECT id FROM Business WHERE id = ${params.businessId} FOR UPDATE`;
|
||||
|
||||
const wasAdministrator = await isStaffAdministrator(tx, params.staffId);
|
||||
if (wasAdministrator) {
|
||||
const otherAdministrators = await countOtherActiveAdministrators(tx, {
|
||||
businessId: params.businessId,
|
||||
excludeStaffId: params.staffId,
|
||||
});
|
||||
if (otherAdministrators === 0) {
|
||||
throw new StaffOperationError(
|
||||
"LAST_ADMINISTRATOR",
|
||||
"You can't remove the only administrator. Add another administrator first.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await tx.staffProfile.update({ where: { id: params.staffId }, data: { status: "INACTIVE" } });
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof StaffOperationError) {
|
||||
return { success: false as const, validation: fail({ code: error.code, message: error.message }), conflicts: [] };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { success: true as const, conflicts: [], validation: ok() };
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user