Refactor admin procedures into separate files
Extract admin procedures from router.ts into dedicated files under procedures/admin/ with consolidated exports via _routes.ts. Adds shared helper functions for response transformation and includes race condition fixes via transaction-scoped existence checks. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
43
apps/api-server/src/procedures/admin/_routes.ts
Normal file
43
apps/api-server/src/procedures/admin/_routes.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Admin routes - consolidated exports for os.router()
|
||||
*/
|
||||
|
||||
import { adminAuthCompleteLogin } from "./auth/complete-login.js";
|
||||
import { adminOrgsCreate } from "./orgs/create.js";
|
||||
import { adminOrgsDelete } from "./orgs/delete.js";
|
||||
import { adminOrgsGet } from "./orgs/get.js";
|
||||
import { adminOrgsList } from "./orgs/list.js";
|
||||
import {
|
||||
adminOrgsAddSite,
|
||||
adminOrgsListSites,
|
||||
adminOrgsRemoveSite,
|
||||
} from "./orgs/sites.js";
|
||||
import { adminOrgsUpdate } from "./orgs/update.js";
|
||||
import { adminUsersConfirmEmail } from "./users/confirm-email.js";
|
||||
import { adminUsersCreate } from "./users/create.js";
|
||||
import { adminUsersGet } from "./users/get.js";
|
||||
import { adminUsersList } from "./users/list.js";
|
||||
import { adminUsersUpdate } from "./users/update.js";
|
||||
|
||||
export const adminRoutes = {
|
||||
orgs: {
|
||||
list: adminOrgsList,
|
||||
get: adminOrgsGet,
|
||||
create: adminOrgsCreate,
|
||||
update: adminOrgsUpdate,
|
||||
delete: adminOrgsDelete,
|
||||
listSites: adminOrgsListSites,
|
||||
addSite: adminOrgsAddSite,
|
||||
removeSite: adminOrgsRemoveSite,
|
||||
},
|
||||
users: {
|
||||
list: adminUsersList,
|
||||
get: adminUsersGet,
|
||||
create: adminUsersCreate,
|
||||
update: adminUsersUpdate,
|
||||
confirmEmail: adminUsersConfirmEmail,
|
||||
},
|
||||
auth: {
|
||||
completeLogin: adminAuthCompleteLogin,
|
||||
},
|
||||
};
|
||||
32
apps/api-server/src/procedures/admin/auth/complete-login.ts
Normal file
32
apps/api-server/src/procedures/admin/auth/complete-login.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* admin.auth.completeLogin - Complete pending login request (dev helper)
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
|
||||
export const adminAuthCompleteLogin = os.admin.auth.completeLogin
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
const loginRequest = await context.db
|
||||
.selectFrom("login_requests")
|
||||
.where("email", "=", input.email.toLowerCase())
|
||||
.where("completed_at", "is", null)
|
||||
.where("expires_at", ">", new Date())
|
||||
.orderBy("created_at", "desc")
|
||||
.select(["id"])
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!loginRequest) {
|
||||
throw new ORPCError("NOT_FOUND", {
|
||||
message: "No pending login request found",
|
||||
});
|
||||
}
|
||||
|
||||
await context.db
|
||||
.updateTable("login_requests")
|
||||
.set({ completed_at: new Date() })
|
||||
.where("id", "=", loginRequest.id)
|
||||
.execute();
|
||||
});
|
||||
35
apps/api-server/src/procedures/admin/helpers.ts
Normal file
35
apps/api-server/src/procedures/admin/helpers.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Admin procedure helpers - shared transformation functions
|
||||
*/
|
||||
|
||||
import type { Selectable } from "kysely";
|
||||
import type { Orgs, OrgSites, Users } from "@reviq/db-schema";
|
||||
|
||||
/** Transform org record to API response format */
|
||||
export const toOrgResponse = (org: Selectable<Orgs>) => ({
|
||||
id: org.id,
|
||||
slug: org.slug,
|
||||
displayName: org.display_name,
|
||||
logoUrl: org.logo_url,
|
||||
createdAt: org.created_at,
|
||||
});
|
||||
|
||||
/** Transform user record to API response format */
|
||||
export const toUserResponse = (user: Selectable<Users>) => ({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.display_name,
|
||||
fullName: user.full_name,
|
||||
phoneNumber: user.phone_number,
|
||||
avatarUrl: user.avatar_url,
|
||||
emailVerified: user.email_verified_at !== null,
|
||||
needsSetup: user.display_name === null,
|
||||
isSuperuser: user.is_superuser,
|
||||
});
|
||||
|
||||
/** Transform site record to API response format */
|
||||
export const toSiteResponse = (site: Selectable<OrgSites>) => ({
|
||||
id: site.id,
|
||||
domain: site.domain,
|
||||
createdAt: site.created_at,
|
||||
});
|
||||
58
apps/api-server/src/procedures/admin/orgs/create.ts
Normal file
58
apps/api-server/src/procedures/admin/orgs/create.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* admin.orgs.create - Create organization with owner
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
|
||||
export const adminOrgsCreate = os.admin.orgs.create
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
const { slug, displayName, ownerEmail } = input;
|
||||
|
||||
// Find owner user by email (outside transaction - read-only)
|
||||
const owner = await context.db
|
||||
.selectFrom("users")
|
||||
.where("email", "=", ownerEmail.toLowerCase())
|
||||
.select(["id"])
|
||||
.executeTakeFirst();
|
||||
if (!owner) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "User not found" });
|
||||
}
|
||||
|
||||
// Create org and owner membership in transaction (with race condition protection)
|
||||
await context.db.transaction().execute(async (trx) => {
|
||||
// Check for existing org inside transaction to prevent race condition
|
||||
const existingOrg = await trx
|
||||
.selectFrom("orgs")
|
||||
.where("slug", "=", slug)
|
||||
.select(["id"])
|
||||
.executeTakeFirst();
|
||||
if (existingOrg) {
|
||||
throw new ORPCError("CONFLICT", {
|
||||
message: "Organization with this slug already exists",
|
||||
});
|
||||
}
|
||||
|
||||
const newOrg = await trx
|
||||
.insertInto("orgs")
|
||||
.values({
|
||||
slug,
|
||||
display_name: displayName,
|
||||
})
|
||||
.returning(["id"])
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
await trx
|
||||
.insertInto("org_members")
|
||||
.values({
|
||||
org_id: newOrg.id,
|
||||
user_id: owner.id,
|
||||
role: "owner",
|
||||
})
|
||||
.execute();
|
||||
});
|
||||
|
||||
return { slug };
|
||||
});
|
||||
36
apps/api-server/src/procedures/admin/orgs/delete.ts
Normal file
36
apps/api-server/src/procedures/admin/orgs/delete.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* admin.orgs.delete - Delete organization and all related records
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
|
||||
export const adminOrgsDelete = os.admin.orgs.delete
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
const { slug } = input;
|
||||
|
||||
// Delete org and related records in transaction
|
||||
await context.db.transaction().execute(async (trx) => {
|
||||
const org = await trx
|
||||
.selectFrom("orgs")
|
||||
.where("slug", "=", slug)
|
||||
.select(["id"])
|
||||
.executeTakeFirst();
|
||||
if (!org) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
||||
}
|
||||
|
||||
await trx
|
||||
.deleteFrom("org_invites")
|
||||
.where("org_id", "=", org.id)
|
||||
.execute();
|
||||
await trx.deleteFrom("org_sites").where("org_id", "=", org.id).execute();
|
||||
await trx
|
||||
.deleteFrom("org_members")
|
||||
.where("org_id", "=", org.id)
|
||||
.execute();
|
||||
await trx.deleteFrom("orgs").where("id", "=", org.id).execute();
|
||||
});
|
||||
});
|
||||
22
apps/api-server/src/procedures/admin/orgs/get.ts
Normal file
22
apps/api-server/src/procedures/admin/orgs/get.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* admin.orgs.get - Get organization by slug
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { toOrgResponse } from "../helpers.js";
|
||||
|
||||
export const adminOrgsGet = os.admin.orgs.get
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
const org = await context.db
|
||||
.selectFrom("orgs")
|
||||
.where("slug", "=", input.slug)
|
||||
.selectAll()
|
||||
.executeTakeFirst();
|
||||
if (!org) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
||||
}
|
||||
return toOrgResponse(org);
|
||||
});
|
||||
14
apps/api-server/src/procedures/admin/orgs/list.ts
Normal file
14
apps/api-server/src/procedures/admin/orgs/list.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* admin.orgs.list - List all organizations
|
||||
*/
|
||||
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { toOrgResponse } from "../helpers.js";
|
||||
|
||||
export const adminOrgsList = os.admin.orgs.list
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
const orgs = await context.db.selectFrom("orgs").selectAll().execute();
|
||||
return orgs.map(toOrgResponse);
|
||||
});
|
||||
97
apps/api-server/src/procedures/admin/orgs/sites.ts
Normal file
97
apps/api-server/src/procedures/admin/orgs/sites.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* admin.orgs.listSites, admin.orgs.addSite, admin.orgs.removeSite
|
||||
* Site management for organizations
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { toSiteResponse } from "../helpers.js";
|
||||
|
||||
export const adminOrgsListSites = os.admin.orgs.listSites
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
const { slug } = input;
|
||||
|
||||
const org = await context.db
|
||||
.selectFrom("orgs")
|
||||
.where("slug", "=", slug)
|
||||
.select(["id"])
|
||||
.executeTakeFirst();
|
||||
if (!org) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
||||
}
|
||||
|
||||
const sites = await context.db
|
||||
.selectFrom("org_sites")
|
||||
.where("org_id", "=", org.id)
|
||||
.selectAll()
|
||||
.execute();
|
||||
|
||||
return sites.map(toSiteResponse);
|
||||
});
|
||||
|
||||
export const adminOrgsAddSite = os.admin.orgs.addSite
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
const { slug, domain } = input;
|
||||
|
||||
// Use transaction to prevent race condition on site creation
|
||||
await context.db.transaction().execute(async (trx) => {
|
||||
const org = await trx
|
||||
.selectFrom("orgs")
|
||||
.where("slug", "=", slug)
|
||||
.select(["id"])
|
||||
.executeTakeFirst();
|
||||
if (!org) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
||||
}
|
||||
|
||||
// Check if site already exists (inside transaction)
|
||||
const existingSite = await trx
|
||||
.selectFrom("org_sites")
|
||||
.where("domain", "=", domain)
|
||||
.select(["id"])
|
||||
.executeTakeFirst();
|
||||
if (existingSite) {
|
||||
throw new ORPCError("CONFLICT", {
|
||||
message: "Site with this domain already exists",
|
||||
});
|
||||
}
|
||||
|
||||
await trx
|
||||
.insertInto("org_sites")
|
||||
.values({
|
||||
org_id: org.id,
|
||||
domain,
|
||||
})
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
export const adminOrgsRemoveSite = os.admin.orgs.removeSite
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
const { slug, domain } = input;
|
||||
|
||||
const org = await context.db
|
||||
.selectFrom("orgs")
|
||||
.where("slug", "=", slug)
|
||||
.select(["id"])
|
||||
.executeTakeFirst();
|
||||
if (!org) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
||||
}
|
||||
|
||||
const result = await context.db
|
||||
.deleteFrom("org_sites")
|
||||
.where("org_id", "=", org.id)
|
||||
.where("domain", "=", domain)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!result.numDeletedRows || result.numDeletedRows === 0n) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "Site not found" });
|
||||
}
|
||||
});
|
||||
50
apps/api-server/src/procedures/admin/orgs/update.ts
Normal file
50
apps/api-server/src/procedures/admin/orgs/update.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* admin.orgs.update - Update organization
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
|
||||
export const adminOrgsUpdate = os.admin.orgs.update
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
const { slug, displayName, logoUrl } = input;
|
||||
|
||||
// Check if there are actual updates to make
|
||||
if (displayName === undefined && logoUrl === undefined) {
|
||||
// Verify org exists even for no-op
|
||||
const org = await context.db
|
||||
.selectFrom("orgs")
|
||||
.where("slug", "=", slug)
|
||||
.select(["id"])
|
||||
.executeTakeFirst();
|
||||
if (!org) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const updates: Partial<{
|
||||
display_name: string;
|
||||
logo_url: string | null;
|
||||
updated_at: Date;
|
||||
}> = { updated_at: new Date() };
|
||||
|
||||
if (displayName !== undefined) {
|
||||
updates.display_name = displayName;
|
||||
}
|
||||
if (logoUrl !== undefined) {
|
||||
updates.logo_url = logoUrl || null;
|
||||
}
|
||||
|
||||
const result = await context.db
|
||||
.updateTable("orgs")
|
||||
.set(updates)
|
||||
.where("slug", "=", slug)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!result.numUpdatedRows || result.numUpdatedRows === 0n) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
||||
}
|
||||
});
|
||||
24
apps/api-server/src/procedures/admin/users/confirm-email.ts
Normal file
24
apps/api-server/src/procedures/admin/users/confirm-email.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* admin.users.confirmEmail - Confirm a user's email (used by CLI)
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
|
||||
export const adminUsersConfirmEmail = os.admin.users.confirmEmail
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
const result = await context.db
|
||||
.updateTable("users")
|
||||
.set({
|
||||
email_verified_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
})
|
||||
.where("email", "=", input.email.toLowerCase())
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!result.numUpdatedRows || result.numUpdatedRows === 0n) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "User not found" });
|
||||
}
|
||||
});
|
||||
63
apps/api-server/src/procedures/admin/users/create.ts
Normal file
63
apps/api-server/src/procedures/admin/users/create.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* admin.users.create - Create passwordless user, optionally add to org
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
|
||||
export const adminUsersCreate = os.admin.users.create
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
const { email, name, orgSlug, orgRole } = input;
|
||||
const normalizedEmail = email.toLowerCase();
|
||||
|
||||
// If orgSlug provided, verify org exists (outside transaction - read-only)
|
||||
let orgId: number | undefined;
|
||||
if (orgSlug) {
|
||||
const org = await context.db
|
||||
.selectFrom("orgs")
|
||||
.where("slug", "=", orgSlug)
|
||||
.select(["id"])
|
||||
.executeTakeFirst();
|
||||
if (!org) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
||||
}
|
||||
orgId = org.id;
|
||||
}
|
||||
|
||||
// Create user in transaction (with race condition protection)
|
||||
await context.db.transaction().execute(async (trx) => {
|
||||
// Check for existing user inside transaction to prevent race condition
|
||||
const existingUser = await trx
|
||||
.selectFrom("users")
|
||||
.where("email", "=", normalizedEmail)
|
||||
.select(["id"])
|
||||
.executeTakeFirst();
|
||||
if (existingUser) {
|
||||
throw new ORPCError("CONFLICT", {
|
||||
message: "User with this email already exists",
|
||||
});
|
||||
}
|
||||
|
||||
const newUser = await trx
|
||||
.insertInto("users")
|
||||
.values({
|
||||
email: normalizedEmail,
|
||||
display_name: name || null,
|
||||
})
|
||||
.returning(["id"])
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
if (orgId !== undefined) {
|
||||
await trx
|
||||
.insertInto("org_members")
|
||||
.values({
|
||||
org_id: orgId,
|
||||
user_id: newUser.id,
|
||||
role: orgRole || "member",
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
});
|
||||
});
|
||||
22
apps/api-server/src/procedures/admin/users/get.ts
Normal file
22
apps/api-server/src/procedures/admin/users/get.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* admin.users.get - Get user by email
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { toUserResponse } from "../helpers.js";
|
||||
|
||||
export const adminUsersGet = os.admin.users.get
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
const user = await context.db
|
||||
.selectFrom("users")
|
||||
.where("email", "=", input.email.toLowerCase())
|
||||
.selectAll()
|
||||
.executeTakeFirst();
|
||||
if (!user) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "User not found" });
|
||||
}
|
||||
return toUserResponse(user);
|
||||
});
|
||||
14
apps/api-server/src/procedures/admin/users/list.ts
Normal file
14
apps/api-server/src/procedures/admin/users/list.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* admin.users.list - List all users
|
||||
*/
|
||||
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { toUserResponse } from "../helpers.js";
|
||||
|
||||
export const adminUsersList = os.admin.users.list
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
const users = await context.db.selectFrom("users").selectAll().execute();
|
||||
return users.map(toUserResponse);
|
||||
});
|
||||
51
apps/api-server/src/procedures/admin/users/update.ts
Normal file
51
apps/api-server/src/procedures/admin/users/update.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* admin.users.update - Update user properties (e.g., isSuperuser)
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
|
||||
export const adminUsersUpdate = os.admin.users.update
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
const { email, isSuperuser } = input;
|
||||
const normalizedEmail = email.toLowerCase();
|
||||
|
||||
// Check if there are actual updates to make
|
||||
if (isSuperuser === undefined) {
|
||||
// Verify user exists even for no-op
|
||||
const user = await context.db
|
||||
.selectFrom("users")
|
||||
.where("email", "=", normalizedEmail)
|
||||
.select(["id"])
|
||||
.executeTakeFirst();
|
||||
if (!user) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "User not found" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent superuser from demoting themselves
|
||||
if (
|
||||
isSuperuser === false &&
|
||||
normalizedEmail === context.user.email.toLowerCase()
|
||||
) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Cannot remove your own superuser status",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await context.db
|
||||
.updateTable("users")
|
||||
.set({
|
||||
is_superuser: isSuperuser,
|
||||
updated_at: new Date(),
|
||||
})
|
||||
.where("email", "=", normalizedEmail)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!result.numUpdatedRows || result.numUpdatedRows === 0n) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "User not found" });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user