- Replace Prisma client runtime with Drizzle ORM (zero Prisma engine/query engine in production) - Add Prisma-compatible facade (@/lib/prisma-facade.ts) backed by Drizzle for backwards compatibility - Runtime queries route through Drizzle ORM; @prisma/client is now devDependency (types only) - Remove @prisma/adapter-mariadb dependency; delete prisma-pool.ts and types/prisma.ts - New Drizzle schema layer: src/db/schema.ts (176 tables) and src/lib/db.ts (connection) - Update README documenting the dual-layer ORM architecture - Restore src/generated/ gitignore (build artifact for local type generation) - 0 TypeScript errors, 583 tests passing The facade intentionally uses `any` types to match the Prisma Client API surface, allowing existing code to run unmodified while routing queries through Drizzle at runtime.
272 lines
7.9 KiB
TypeScript
272 lines
7.9 KiB
TypeScript
"use server";
|
|
|
|
import { and, count, eq, inArray, sql } from "drizzle-orm";
|
|
import type { ResultSetHeader } from "mysql2";
|
|
import { revalidateTag } from "next/cache";
|
|
import { z } from "zod";
|
|
import {
|
|
AclModelPermission,
|
|
AclModelRole,
|
|
AclPermission,
|
|
AclRole,
|
|
db,
|
|
User,
|
|
} from "@/lib/db";
|
|
import { PERMS } from "@/lib/permission-slugs";
|
|
import { adminAction } from "@/lib/safe-action";
|
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
|
import {
|
|
createEmulatorRank,
|
|
deleteEmulatorRank,
|
|
updateEmulatorRank,
|
|
} from "@/lib/services/permission-ranks";
|
|
import { rcon } from "@/lib/services/rcon";
|
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
|
|
|
const createRankSchema = z.object({
|
|
rank_name: z.string().trim().min(1).max(25),
|
|
level: z.coerce.number().int().min(1),
|
|
});
|
|
|
|
export const createRank = adminAction(
|
|
{ schema: createRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
|
async (ctx) => {
|
|
const id = await createEmulatorRank(db, ctx.data);
|
|
await db
|
|
.insert(AclRole)
|
|
.values({
|
|
slug: `rank_${id}`,
|
|
title: ctx.data.rank_name,
|
|
description: "CMS role synchronized from permission_ranks",
|
|
})
|
|
.onDuplicateKeyUpdate({ set: { title: ctx.data.rank_name } });
|
|
await logStaffActivity({
|
|
staffId: ctx.session.user.id,
|
|
action: "rank_create",
|
|
description: `Created rank #${id}`,
|
|
targetType: "rank",
|
|
targetId: id,
|
|
});
|
|
await rcon.send("updatepermissions");
|
|
revalidateTag("permissions", { expire: 0 });
|
|
return actionOk({ id });
|
|
},
|
|
);
|
|
|
|
const deleteRankSchema = z.object({ id: z.coerce.number().int().positive() });
|
|
|
|
export const deleteRank = adminAction(
|
|
{ schema: deleteRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
|
async (ctx) => {
|
|
const [userCount] = await db
|
|
.select({ total: count() })
|
|
.from(User)
|
|
.where(eq(User.rank, ctx.data.id));
|
|
const users = userCount?.total ?? 0;
|
|
if (users > 0)
|
|
throw new ActionError(`Cannot delete: ${users} users have this rank`);
|
|
const [role] = await db
|
|
.select({ id: AclRole.id })
|
|
.from(AclRole)
|
|
.where(eq(AclRole.slug, `rank_${ctx.data.id}`))
|
|
.limit(1);
|
|
await deleteEmulatorRank(db, ctx.data.id);
|
|
if (role) {
|
|
await db.transaction(async (tx) => {
|
|
await tx
|
|
.delete(AclModelPermission)
|
|
.where(
|
|
and(
|
|
eq(AclModelPermission.modelId, role.id),
|
|
eq(AclModelPermission.modelType, "Role"),
|
|
),
|
|
);
|
|
await tx.delete(AclModelRole).where(eq(AclModelRole.roleId, role.id));
|
|
await tx.delete(AclRole).where(eq(AclRole.id, role.id));
|
|
});
|
|
}
|
|
await logStaffActivity({
|
|
staffId: ctx.session.user.id,
|
|
action: "rank_delete",
|
|
description: `Deleted rank #${ctx.data.id}`,
|
|
targetType: "rank",
|
|
targetId: ctx.data.id,
|
|
});
|
|
await rcon.send("updatepermissions");
|
|
revalidateTag("permissions", { expire: 0 });
|
|
return actionOk();
|
|
},
|
|
);
|
|
|
|
const saveRankSchema = z.object({
|
|
id: z.coerce.number().int().positive(),
|
|
fields: z.record(z.string(), z.union([z.string(), z.number()])),
|
|
});
|
|
|
|
export const saveRank = adminAction(
|
|
{ schema: saveRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
|
async (ctx) => {
|
|
await updateEmulatorRank(db, ctx.data.id, ctx.data.fields);
|
|
if (typeof ctx.data.fields.rank_name === "string") {
|
|
await db
|
|
.update(AclRole)
|
|
.set({ title: ctx.data.fields.rank_name })
|
|
.where(eq(AclRole.slug, `rank_${ctx.data.id}`));
|
|
}
|
|
await logStaffActivity({
|
|
staffId: ctx.session.user.id,
|
|
action: "rank_update",
|
|
description: `Updated rank #${ctx.data.id}`,
|
|
targetType: "rank",
|
|
targetId: ctx.data.id,
|
|
});
|
|
await rcon.send("updatepermissions");
|
|
revalidateTag("permissions", { expire: 0 });
|
|
return actionOk();
|
|
},
|
|
);
|
|
|
|
const setCmsPermsSchema = z.object({
|
|
roleId: z.coerce.number().int().positive(),
|
|
permissionSlugs: z.array(z.string().trim().min(1)).max(500),
|
|
});
|
|
|
|
export const setCmsPermissions = adminAction(
|
|
{ schema: setCmsPermsSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
|
async (ctx) => {
|
|
const [role] = await db
|
|
.select({ id: AclRole.id, slug: AclRole.slug })
|
|
.from(AclRole)
|
|
.where(eq(AclRole.id, ctx.data.roleId))
|
|
.limit(1);
|
|
if (!role) throw new ActionError("Role not found");
|
|
const permissions = await db
|
|
.select({ id: AclPermission.id })
|
|
.from(AclPermission)
|
|
.where(inArray(AclPermission.slug, ctx.data.permissionSlugs));
|
|
await db.transaction(async (tx) => {
|
|
await tx
|
|
.delete(AclModelPermission)
|
|
.where(
|
|
and(
|
|
eq(AclModelPermission.modelId, role.id),
|
|
eq(AclModelPermission.modelType, "Role"),
|
|
),
|
|
);
|
|
if (permissions.length) {
|
|
await tx.insert(AclModelPermission).values(
|
|
permissions.map((permission) => ({
|
|
modelId: role.id,
|
|
modelType: "Role",
|
|
permissionId: permission.id,
|
|
})),
|
|
);
|
|
}
|
|
});
|
|
await logStaffActivity({
|
|
staffId: ctx.session.user.id,
|
|
action: "acl_role_permissions_update",
|
|
description: `Updated ${permissions.length} permissions for ${role.slug}`,
|
|
targetType: "acl_role",
|
|
targetId: role.id,
|
|
});
|
|
revalidateTag("permissions", { expire: 0 });
|
|
return actionOk();
|
|
},
|
|
);
|
|
|
|
/**
|
|
* Re-apply the same grant repair as migration 0018:
|
|
* - ranks with admin.dashboard get all admin.*
|
|
* - ranks >= 6 get admin.*.view + dashboard
|
|
* - ranks >= 7 get edit/manage/execute tools used by the sidebar
|
|
*/
|
|
export const repairAdminNavAclGrants = adminAction(
|
|
{ permission: PERMS.PERMISSIONS_MANAGE },
|
|
async (ctx) => {
|
|
const [dashboardFillResult] = await db.execute(sql`
|
|
INSERT INTO \`acl_model_permissions\` (\`model_type\`, \`model_id\`, \`permission_id\`)
|
|
SELECT 'Role', ar.id, ap.id
|
|
FROM \`acl_roles\` ar
|
|
JOIN \`acl_permissions\` ap ON ap.slug LIKE 'admin.%'
|
|
WHERE EXISTS (
|
|
SELECT 1
|
|
FROM \`acl_model_permissions\` amp
|
|
JOIN \`acl_permissions\` apdash ON apdash.id = amp.permission_id
|
|
WHERE amp.model_type = 'Role'
|
|
AND amp.model_id = ar.id
|
|
AND apdash.slug = 'admin.dashboard'
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM \`acl_model_permissions\` amp2
|
|
WHERE amp2.model_type = 'Role'
|
|
AND amp2.model_id = ar.id
|
|
AND amp2.permission_id = ap.id
|
|
)
|
|
`);
|
|
|
|
const [midRankViewsResult] = await db.execute(sql`
|
|
INSERT INTO \`acl_model_permissions\` (\`model_type\`, \`model_id\`, \`permission_id\`)
|
|
SELECT 'Role', ar.id, ap.id
|
|
FROM \`permission_ranks\` pr
|
|
JOIN \`acl_roles\` ar ON ar.slug = CONCAT('rank_', pr.id)
|
|
JOIN \`acl_permissions\` ap ON (
|
|
ap.slug = 'admin.dashboard'
|
|
OR (ap.slug LIKE 'admin.%' AND ap.slug LIKE '%.view')
|
|
)
|
|
WHERE pr.id >= 6
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM \`acl_model_permissions\` amp
|
|
WHERE amp.model_type = 'Role'
|
|
AND amp.model_id = ar.id
|
|
AND amp.permission_id = ap.id
|
|
)
|
|
`);
|
|
|
|
const [highRankToolsResult] = await db.execute(sql`
|
|
INSERT INTO \`acl_model_permissions\` (\`model_type\`, \`model_id\`, \`permission_id\`)
|
|
SELECT 'Role', ar.id, ap.id
|
|
FROM \`permission_ranks\` pr
|
|
JOIN \`acl_roles\` ar ON ar.slug = CONCAT('rank_', pr.id)
|
|
JOIN \`acl_permissions\` ap ON (
|
|
(ap.slug LIKE 'admin.%' AND ap.slug LIKE '%.edit')
|
|
OR ap.slug IN (
|
|
'admin.permissions.manage',
|
|
'admin.rcon.execute',
|
|
'admin.assets.import',
|
|
'admin.export',
|
|
'admin.analytics.export',
|
|
'admin.users.ban',
|
|
'admin.users.reset_password',
|
|
'admin.room.delete'
|
|
)
|
|
)
|
|
WHERE pr.id >= 7
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM \`acl_model_permissions\` amp
|
|
WHERE amp.model_type = 'Role'
|
|
AND amp.model_id = ar.id
|
|
AND amp.permission_id = ap.id
|
|
)
|
|
`);
|
|
|
|
const inserted =
|
|
Number((dashboardFillResult as ResultSetHeader).affectedRows) +
|
|
Number((midRankViewsResult as ResultSetHeader).affectedRows) +
|
|
Number((highRankToolsResult as ResultSetHeader).affectedRows);
|
|
|
|
await logStaffActivity({
|
|
staffId: ctx.session.user.id,
|
|
action: "acl_nav_grants_repair",
|
|
description: `Repaired admin nav ACL grants (${inserted} rows inserted)`,
|
|
targetType: "acl",
|
|
targetId: 0,
|
|
});
|
|
revalidateTag("permissions", { expire: 0 });
|
|
return actionOk({ inserted });
|
|
},
|
|
);
|