Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/utils/db/tenants.db.server.ts
T

639 lines
17 KiB
TypeScript

import { Prisma, Role, Tenant, TenantInboundAddress, TenantSettingsRow, TenantType, TenantUser } from "@toolctrl/db/prisma-browser";
import { FiltersDto } from "~/application/dtos/data/FiltersDto";
import { PaginationDto } from "~/application/dtos/data/PaginationDto";
import { TenantUserJoined } from "~/application/enums/tenants/TenantUserJoined";
import { TenantUserStatus } from "~/application/enums/tenants/TenantUserStatus";
import { db, dbRead } from "~/utils/db.server";
import UserModelHelper from "~/utils/helpers/models/UserModelHelper";
import RowFiltersHelper from "../helpers/RowFiltersHelper";
import { getAvailableTenantInboundAddress } from "../services/emailService";
import { createUserRole } from "./permissions/userRoles.db.server";
import { TenantSubscriptionWithDetails, createTenantSubscription } from "./tenantSubscriptions.db.server";
import { UserSimple, UserWithRoles } from "./users.db.server";
import { RowWithDetails } from "./entities/rows.db.server";
import { getAvailableTenantSlug } from "../services/tenantService";
import RowModelHelper from "../helpers/models/RowModelHelper";
import TenantModelHelper from "../helpers/models/TenantModelHelper";
import TenantSubscriptionProductModelHelper from "../helpers/models/TenantSubscriptionProductModelHelper";
import { cachified, clearCacheKey, clearCacheKeyPrefix } from "../cache.server";
import { emitPluginHookFireAndForget } from "~/modules/plugins/services/.server/pluginHookPublisher";
import { PLUGIN_HOOK_TENANT_CREATED } from "@toolctrl/plugin-runtime";
export type TenantSimple = {
id: string;
name: string;
slug: string;
icon: string | null;
logo: string | null;
deactivatedReason: string | null;
types: TenantType[];
active: boolean;
theme: string | null;
themeConfig: string | null;
};
export type TenantWithProfile = Tenant & {
types: TenantType[];
};
export type TenantWithDetails = Tenant & {
inboundAddresses: TenantInboundAddress[];
users: (TenantUser & {
user: UserSimple;
})[];
subscription: TenantSubscriptionWithDetails | null;
tenantSettingsRow: (TenantSettingsRow & { row: RowWithDetails }) | null;
types: TenantType[];
};
export type TenantWithUsage = TenantWithDetails & {
_count: {
users?: number;
invitations?: number;
rows?: number;
logs?: number;
apiKeys?: number;
events?: number;
};
};
export type TenantUserWithUser = TenantUser & {
user: UserWithRoles;
};
export type TenantUserWithDetails = TenantUser & {
tenant: Tenant;
user: UserSimple;
};
export async function getTenantsInIds(ids: string[]): Promise<TenantWithDetails[]> {
return await db.tenant.findMany({
where: { id: { in: ids } },
include: {
...TenantModelHelper.includeTenantWithDetails,
},
});
}
export async function adminGetAllTenants(): Promise<TenantWithDetails[]> {
return await db.tenant.findMany({
include: {
...TenantModelHelper.includeTenantWithDetails,
},
orderBy: { createdAt: "desc" },
});
}
export async function adminGetAllTenantsIdsAndNames(): Promise<{ id: string; name: string; slug: string }[]> {
// Admin-only enumeration used for picklists and audit views — replica-safe.
// Called AFTER auth has already resolved the admin user via the write client,
// so the replica-lag read-after-write hazard does not apply here.
return await dbRead.tenant.findMany({
select: { id: true, name: true, slug: true },
});
}
export async function adminGetAllActiveTenantSimple(): Promise<TenantSimple[]> {
// Admin tenant-switcher listing used post-auth — replica-safe.
// The admin user identity itself was loaded on the write client in the
// session-resolution path; this enumeration is purely informational.
return await dbRead.tenant.findMany({
where: { active: true },
select: TenantModelHelper.selectSimpleTenantProperties,
orderBy: { name: "asc" },
});
}
export async function adminGetAllTenantsWithUsage(
filters?: FiltersDto,
pagination?: { page: number; pageSize: number }
): Promise<{ items: TenantWithUsage[]; pagination: PaginationDto }> {
let where: Prisma.TenantWhereInput = RowFiltersHelper.getFiltersCondition(filters);
const userId = filters?.properties.find((f) => f.name === "userId")?.value ?? filters?.query ?? "";
if (userId) {
if (where) {
where = {
OR: [where, { users: { some: { userId } } }],
};
} else {
where = { users: { some: { userId } } };
}
}
const typeId = filters?.properties.find((f) => f.name === "typeId")?.value;
if (typeId) {
if (where) {
if (typeId === "null") {
where = {
OR: [where, { types: { none: {} } }],
};
} else {
where = {
OR: [where, { types: { some: { id: typeId } } }],
};
}
} else {
if (typeId === "null") {
where = { types: { none: {} } };
} else {
where = { types: { some: { id: typeId } } };
}
}
}
const items = await db.tenant.findMany({
skip: pagination ? pagination?.pageSize * (pagination?.page - 1) : undefined,
take: pagination ? pagination?.pageSize : undefined,
where,
include: {
inboundAddresses: true,
users: {
include: {
...UserModelHelper.includeSimpleUser,
},
},
subscription: {
include: {
products: { include: { ...TenantSubscriptionProductModelHelper.includeTenantSubscriptionProductDetails } },
},
},
tenantSettingsRow: { include: { row: { include: RowModelHelper.includeRowDetails } } },
types: true,
_count: true,
},
orderBy: {
createdAt: "desc",
},
});
const totalItems = await db.tenant.count({
where,
});
return {
items,
pagination: {
page: pagination?.page ?? 1,
pageSize: pagination?.pageSize ?? 10,
totalItems,
totalPages: Math.ceil(totalItems / (pagination?.pageSize ?? 10)),
},
};
}
export async function getTenant(id: string): Promise<TenantWithDetails | null> {
return await cachified({
key: `tenant:${id}`,
ttl: 1000 * 60 * 60 * 24,
getFreshValue: () =>
db.tenant.findUnique({
where: {
id,
},
include: {
...TenantModelHelper.includeTenantWithDetails,
},
}),
});
}
export async function requireTenant(id: string): Promise<TenantWithDetails> {
const tenant = await getTenant(id);
if (!tenant) {
throw new Error("Tenant not found: " + id);
}
return tenant;
}
export async function getTenantSimple(id: string): Promise<TenantSimple | null> {
return await cachified({
key: `tenantSimple:${id}`,
ttl: 1000 * 60 * 60 * 24,
getFreshValue: () =>
db.tenant.findUnique({
where: { id },
select: TenantModelHelper.selectSimpleTenantProperties,
}),
});
}
export async function getTenantByIdOrSlug(id: string) {
return await cachified({
key: `tenantIdOrSlug:${id}`,
ttl: 1000 * 60 * 60 * 24,
getFreshValue: () =>
db.tenant.findFirst({
where: {
OR: [{ id }, { slug: id }],
},
include: {
subscription: true,
},
}),
});
}
export async function getTenantBySlug(slug: string) {
return await db.tenant.findUnique({
where: {
slug,
},
});
}
export async function getTenantWithProfile(id: string): Promise<TenantWithProfile | null> {
return await db.tenant.findUnique({
where: { id },
include: {
types: true,
},
});
}
export async function getTenantWithUsers(id?: string) {
if (!id) {
return null;
}
return await db.tenant.findUnique({
where: {
id,
},
include: {
users: true,
},
});
}
export async function getMyTenants(userId: string): Promise<TenantSimple[]> {
const tenantsAsMember = await db.tenant.findMany({
where: { active: true, users: { some: { userId, status: TenantUserStatus.ACTIVE } } },
select: TenantModelHelper.selectSimpleTenantProperties,
orderBy: { name: "asc" },
});
// await Promise.all(tenantsAsMember.map(async(tenant) => {
// const relatedTenants = await db.tenantRelationship.findFirst({
// where: { fromTenantId: tenant.id },
// include: { toTenant: true },
// });
// }));
return [...tenantsAsMember];
}
export async function getTenantUsersCount(tenantId: string) {
return await db.tenantUser.count({
where: { tenantId, status: TenantUserStatus.ACTIVE },
});
}
export async function getTenantUsers(tenantId?: string | null, options: { includeInactive?: boolean } = {}): Promise<TenantUserWithUser[]> {
if (!tenantId) {
return [];
}
return await db.tenantUser.findMany({
where: {
tenantId,
...(options.includeInactive ? {} : { status: TenantUserStatus.ACTIVE }),
},
include: {
user: {
include: {
roles: { where: { tenantId }, include: { role: true } },
},
},
},
});
}
export async function getTenantUsersInTenantIds(tenantIds: string[]): Promise<TenantUserWithDetails[]> {
return await db.tenantUser.findMany({
where: {
tenantId: { in: tenantIds },
},
include: {
tenant: true,
user: { select: UserModelHelper.selectSimpleUserProperties },
},
});
}
export async function getAllTenantUsers(): Promise<TenantUserWithUser[]> {
return await db.tenantUser.findMany({
include: {
user: {
include: {
admin: true,
roles: { include: { role: true } },
},
},
},
});
}
export async function getTenantUser(id?: string, tenantId?: string) {
if (!id) {
return null;
}
return await db.tenantUser.findFirst({
where: {
id,
...(tenantId ? { tenantId } : {}),
},
include: {
tenant: true,
user: true,
},
});
}
export async function getTenantUserByIds(tenantId: string, userId: string) {
return await db.tenantUser.findFirst({
where: {
tenantId,
userId,
},
include: {
tenant: true,
user: true,
},
});
}
export async function getTenantUserSimple({ tenantId, userId }: { tenantId: string; userId: string }) {
return await cachified({
key: `tenantUserSimple:${tenantId}:${userId}`,
ttl: 1000 * 60 * 60 * 24, // 1 day
getFreshValue: () =>
db.tenantUser
.findFirstOrThrow({
where: { tenantId, userId, status: TenantUserStatus.ACTIVE, tenant: { active: true } },
select: { id: true, type: true },
})
.catch(() => null),
});
}
export async function getTenantMember(userId?: string, tenantId?: string) {
return await db.tenantUser.findFirst({
where: {
userId,
tenantId,
status: TenantUserStatus.ACTIVE,
tenant: { active: true },
},
include: {
tenant: true,
user: true,
},
});
}
export async function getTenantUserType(userId: string, tenantId: string) {
return await db.tenantUser.findFirst({
where: {
userId,
tenantId,
status: TenantUserStatus.ACTIVE,
tenant: {
active: true,
},
},
select: { status: true, type: true },
});
}
export async function updateTenant(before: Tenant, data: { name?: string; icon?: string; slug?: string; typeIds?: string[] }) {
const update: Prisma.TenantUncheckedUpdateInput = {
name: data.name,
icon: data.icon,
slug: data.slug,
};
if (data.typeIds) {
update.types = { set: data.typeIds.map((type) => ({ id: type })) };
}
return await db.tenant
.update({
where: {
id: before.id,
},
data: update,
})
.then((item) => {
clearCacheKey(`tenant:${before.slug}`);
clearCacheKey(`tenant:${before.id}`);
clearCacheKey(`tenantIdOrSlug:${before.id}`);
clearCacheKey(`tenantIdOrSlug:${before.slug}`);
clearCacheKey(`tenantSimple:${before.id}`);
return item;
});
}
export async function updateTenantTheme(id: string, data: { theme: string | null; themeConfig: string | null }) {
return await db.tenant
.update({
where: { id },
data: {
theme: data.theme,
themeConfig: data.themeConfig,
},
})
.then((item) => {
clearCacheKey(`tenant:${item.slug}`);
clearCacheKey(`tenant:${item.id}`);
clearCacheKey(`tenantIdOrSlug:${item.id}`);
clearCacheKey(`tenantIdOrSlug:${item.slug}`);
clearCacheKey(`tenantSimple:${item.id}`);
clearCacheKeyPrefix("apiKeyHash:");
return item;
});
}
export async function addTenantTypeToTenant(id: string, data: { typeId: string }) {
return await db.tenant
.update({
where: { id },
data: { types: { connect: { id: data.typeId } } },
})
.then((item) => {
clearCacheKey(`tenant:${item.slug}`);
clearCacheKey(`tenant:${item.id}`);
clearCacheKey(`tenantSimple:${item.id}`);
return item;
});
}
export async function createTenant({
name,
icon,
subscriptionCustomerId,
active,
slug,
}: {
name: string;
icon?: string;
subscriptionCustomerId?: string | undefined;
active?: boolean;
slug?: string;
}) {
slug = await getAvailableTenantSlug({ name, slug });
const inboundAddress = await getAvailableTenantInboundAddress(name);
const tenant = await db.tenant.create({
data: {
name,
slug,
icon,
active: active ?? true,
inboundAddresses: {
create: [{ address: inboundAddress }],
},
},
});
if (subscriptionCustomerId) {
await createTenantSubscription(tenant.id, subscriptionCustomerId);
}
// Phase 06 #22 — emit plugin hook `tenant.created` so installed
// plugins subscribed to the tenant lifecycle can react. Fire-and-
// forget: hook delivery failures never propagate into tenant create.
await emitPluginHookFireAndForget(
PLUGIN_HOOK_TENANT_CREATED,
{
tenantId: tenant.id,
slug: tenant.slug,
ownerEmail: null,
plan: null,
createdAt: tenant.createdAt.toISOString(),
},
{ tenantId: tenant.id, dispatchKey: `tenant:${tenant.id}:created` }
);
return tenant;
}
export async function createTenantUser(data: { tenantId: string; userId: string; type: number }, roles: Role[]) {
const tenantUser = await db.tenantUser.create({
data: {
...data,
joined: TenantUserJoined.JOINED_BY_INVITATION,
status: TenantUserStatus.ACTIVE,
},
});
await Promise.all(
roles.map(async (role) => {
return await createUserRole(tenantUser.userId, role.id, tenantUser.tenantId);
})
);
return tenantUser;
}
export async function deleteTenantUser(id: string, tenantId?: string) {
if (tenantId) {
const tenantUser = await db.tenantUser.findFirst({
where: {
id,
tenantId,
},
select: {
tenantId: true,
userId: true,
},
});
if (!tenantUser) {
return { count: 0 };
}
const [deleted] = await db.$transaction([
db.tenantUser.deleteMany({
where: {
id,
tenantId,
},
}),
db.userRole.deleteMany({
where: {
tenantId: tenantUser.tenantId,
userId: tenantUser.userId,
},
}),
db.groupUser.deleteMany({
where: {
userId: tenantUser.userId,
group: {
tenantId: tenantUser.tenantId,
},
},
}),
]);
clearCacheKey(`userRoles:${tenantUser.userId}:${tenantUser.tenantId}`);
return deleted;
}
const deleted = await db.tenantUser.delete({
where: {
id,
},
});
if (deleted.tenantId) {
await db.$transaction([
db.userRole.deleteMany({
where: {
tenantId: deleted.tenantId,
userId: deleted.userId,
},
}),
db.groupUser.deleteMany({
where: {
userId: deleted.userId,
group: {
tenantId: deleted.tenantId,
},
},
}),
]);
clearCacheKey(`userRoles:${deleted.userId}:${deleted.tenantId}`);
}
return deleted;
}
export async function deleteTenant(id: string) {
return await db.tenant.delete({
where: {
id,
},
include: {
subscription: true,
users: true,
invitations: true,
rows: true,
logs: true,
apiKeys: true,
userRoles: true,
},
});
}
export async function updateTenantDeactivated(id: string, data: { active: boolean; deactivatedReason: string | null }) {
return await db.tenant
.update({
where: {
id,
},
data,
})
.then((item) => {
clearCacheKey(`tenant:${item.slug}`);
clearCacheKey(`tenant:${item.id}`);
clearCacheKey(`tenantIdOrSlug:${item.id}`);
clearCacheKey(`tenantIdOrSlug:${item.slug}`);
clearCacheKey(`tenantSimple:${item.id}`);
return item;
});
}
export async function getAllTenantsWithoutTypes() {
return await db.tenant.findMany({
where: { types: { none: {} } },
});
}