Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

@@ -0,0 +1,27 @@
// @vitest-environment node
import { afterEach, describe, expect, test } from "vitest";
import CryptoApi from "./CryptoApi";
const previousCryptoSecret = process.env.CRYPTO_SECRET;
afterEach(() => {
process.env.CRYPTO_SECRET = previousCryptoSecret;
});
describe("CryptoApi", () => {
test("encrypts and decrypts values with authenticated node crypto", () => {
process.env.CRYPTO_SECRET = "test-crypto-secret";
const encrypted = CryptoApi.encrypt("secret value");
expect(encrypted).toMatch(/^v1:[A-Za-z0-9_-]+:[A-Za-z0-9_-]+:[A-Za-z0-9_-]+$/);
expect(encrypted).not.toContain("secret value");
expect(CryptoApi.decrypt(encrypted)).toBe("secret value");
});
test("rejects malformed encrypted payloads", () => {
process.env.CRYPTO_SECRET = "test-crypto-secret";
expect(() => CryptoApi.decrypt("legacy-or-corrupt-value")).toThrow("Invalid encrypted payload");
});
});
@@ -0,0 +1,35 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
const algorithm = "aes-256-gcm";
const version = "v1";
function getSecretKey() {
const secret = process.env.CRYPTO_SECRET?.toString();
if (!secret) {
throw Error("CRYPTO_SECRET env variable not set");
}
return createHash("sha256").update(secret).digest();
}
function encrypt(text: string) {
const iv = randomBytes(12);
const cipher = createCipheriv(algorithm, getSecretKey(), iv);
const ciphertext = Buffer.concat([cipher.update(text, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return [version, iv.toString("base64url"), tag.toString("base64url"), ciphertext.toString("base64url")].join(":");
}
function decrypt(encryptedText: string) {
const [payloadVersion, iv, tag, ciphertext] = encryptedText.split(":");
if (payloadVersion !== version || !iv || !tag || !ciphertext) {
throw new Error("Invalid encrypted payload");
}
const decipher = createDecipheriv(algorithm, getSecretKey(), Buffer.from(iv, "base64url"));
decipher.setAuthTag(Buffer.from(tag, "base64url"));
return Buffer.concat([decipher.update(Buffer.from(ciphertext, "base64url")), decipher.final()]).toString("utf8");
}
export default {
encrypt,
decrypt,
};
@@ -0,0 +1,88 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { EntitiesApi } from "./EntitiesApi";
import { findEntityByName, findEntityBySlug, getEntityByPrefix } from "../../db/entities/entities.db.server";
vi.mock("../../db/entities/entities.db.server", () => ({
findEntityByName: vi.fn(),
findEntityBySlug: vi.fn(),
getEntityByIdOrName: vi.fn(),
getEntityByPrefix: vi.fn(),
}));
vi.mock("../../services/.server/subscriptionService", () => ({
getPlanFeatureUsage: vi.fn(),
}));
describe("EntitiesApi.validateEntity", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(findEntityByName).mockResolvedValue(null);
vi.mocked(findEntityBySlug).mockResolvedValue(null);
vi.mocked(getEntityByPrefix).mockResolvedValue(null);
});
it("rejects entity slugs that would collide with canonical tenant child routes", async () => {
await expect(
EntitiesApi.validateEntity({
tenantId: null,
name: "settings",
slug: " Settings ",
order: null,
prefix: "set",
})
).resolves.toContain("Reserved entity slug ' Settings ' conflicts with a workspace route");
});
});
describe("EntitiesApi.getNoCodeRoutes", () => {
it("does not classify tenant slugs that only start with admin as admin routes", () => {
const routes = EntitiesApi.getNoCodeRoutes({
request: new Request("http://app.localhost:3000/adminish/g/sales/contact"),
params: { tenant: "adminish", group: "sales" },
});
expect(routes.list).toBe("/adminish/g/sales/:entity");
expect(routes.overview).toBe("/adminish/g/sales/:entity/:id");
});
it("uses existing no-code entity routes for admin grouped entity contexts", () => {
const routes = EntitiesApi.getNoCodeRoutes({
request: new Request("http://admin.localhost:3000/g/sales/contact"),
params: { group: "sales" },
});
expect(routes.list).toBe("/entities/:entity/no-code/:entity");
expect(routes.new).toBe("/entities/:entity/no-code/:entity/new");
expect(routes.overview).toBe("/entities/:entity/no-code/:entity/:id");
expect(routes.edit).toBe("/entities/:entity/no-code/:entity/:id/edit");
expect(routes.import).toBe("/entities/:entity/no-code/:entity/import");
expect(routes.export).toBe("/entities/:entity/no-code/:entity/export");
expect(routes.group).toBe("/entities/groups");
});
it("uses host context instead of removed admin path prefixes", () => {
const request = new Request("https://unknown.example.test/admin/entities/company/no-code/company");
const routes = EntitiesApi.getNoCodeRoutes({ request, params: { tenant: "acme" } });
expect(routes.list).toBe("/acme/:entity");
expect(routes.list).not.toBe("/entities/:entity/no-code/:entity");
});
it("does not treat removed tenant-prefixed runtime paths as app context for grouped routes", () => {
const request = new Request("https://unknown.example.test/app/acme/g/sales/company");
const routes = EntitiesApi.getNoCodeRoutes({ request, params: { tenant: "acme", group: "sales" } });
expect(routes.group).toBeUndefined();
expect(routes.list).toBe("/acme/:entity");
});
it("matches tenant CRM routes by exact path segment", () => {
const crmRequest = new Request("https://app.example.test/acme/crm/company");
const crmishRequest = new Request("https://app.example.test/acme/crmish/company");
expect(EntitiesApi.getNoCodeRoutes({ request: crmRequest, params: { tenant: "acme" } }).list).toBe("/acme/crm/:entity");
expect(EntitiesApi.getNoCodeRoutes({ request: crmishRequest, params: { tenant: "acme" } }).list).toBe("/acme/:entity");
});
});
@@ -0,0 +1,190 @@
import { PlanFeatureUsageDto } from "~/application/dtos/subscriptions/PlanFeatureUsageDto";
import { EntityWithDetails, findEntityByName, findEntityBySlug, getEntityByIdOrName, getEntityByPrefix } from "../../db/entities/entities.db.server";
import { getPlanFeatureUsage } from "../../services/.server/subscriptionService";
import { Entity } from "@toolctrl/db/prisma-browser";
import { Params } from "react-router";
import { getAbsoluteUrl } from "../../url.server";
import { adminPath, appPath } from "~/utils/app/CanonicalUrlUtils";
import { getHostRoutingContextFromRequest } from "~/utils/app/HostRouting.server";
import { isReservedTenantChildRouteSlug } from "~/utils/services/tenantService";
export namespace EntitiesApi {
export type GetEntityData = {
entity: EntityWithDetails;
featureUsageEntity?: PlanFeatureUsageDto | undefined;
};
export type Routes = {
list?: string;
new?: string;
overview?: string;
edit?: string;
publicUrl?: string;
import?: string;
export?: string;
group?: string;
};
export async function get({ entity, tenantId, userId }: { entity: { id?: string; name?: string }; tenantId?: string | null; userId?: string }) {
const item = await getEntityByIdOrName({ tenantId, ...entity });
const featureUsageEntity = tenantId ? await getPlanFeatureUsage(tenantId, item.name) : undefined;
await fillSystemProperties({ entity: item, tenantId, userId });
const data: GetEntityData = {
entity: item,
featureUsageEntity,
};
return data;
}
export async function validateEntity({
tenantId,
name,
slug,
order,
prefix,
entity,
}: {
tenantId: string | null;
name: string;
slug: string;
order: number | null;
prefix: string;
entity?: Entity;
}) {
const errors: string[] = [];
if (isReservedTenantChildRouteSlug(slug)) {
errors.push(`Reserved entity slug '${slug}' conflicts with a workspace route`);
}
if (!entity || entity?.name !== name) {
const existingName = await findEntityByName({ tenantId, name });
if (existingName) {
errors.push(`Existing entity with name '${name}': ${existingName.slug}`);
}
}
if (!entity || entity?.slug !== slug) {
const existingSlug = await findEntityBySlug(slug);
if (existingSlug) {
errors.push(`Existing entity with slug '${slug}': ${existingSlug.slug}`);
}
}
// if (order) {
// if (!entity || entity?.order !== order) {
// const existingOrder = await getEntityByOrder(order);
// if (existingOrder) {
// errors.push(`Existing entity with order '${order}': ${existingOrder.slug}`);
// }
// }
// }
if (!entity || entity?.prefix !== prefix) {
const existingPrefix = await getEntityByPrefix(prefix);
if (existingPrefix) {
errors.push(`Existing entity with prefix '${prefix}': ${existingPrefix.slug}`);
}
}
return errors;
}
export function getNoCodeRoutes({ request, params }: { request: Request; params: Params }): EntitiesApi.Routes {
const url = new URL(request.url);
const hostContext = getHostRoutingContextFromRequest(request);
const isAdminRequest = hostContext === "admin";
const isAppRequest = hostContext === "app";
const adminRoute = (path: string) => adminPath(path, { mode: "host-aware" });
const tenantRoute = (path: string) => appPath({ tenantSlug: params?.tenant ?? "", path, mode: "host-aware" });
if (params.group) {
if (isAdminRequest) {
const routes: EntitiesApi.Routes = {
list: adminRoute("entities/:entity/no-code/:entity"),
new: adminRoute("entities/:entity/no-code/:entity/new"),
overview: adminRoute("entities/:entity/no-code/:entity/:id"),
edit: adminRoute("entities/:entity/no-code/:entity/:id/edit"),
import: adminRoute("entities/:entity/no-code/:entity/import"),
export: adminRoute("entities/:entity/no-code/:entity/export"),
publicUrl: getAbsoluteUrl(request, `/public/:entity/:id`),
group: adminRoute("entities/groups"),
};
return routes;
} else if (isAppRequest) {
const routes: EntitiesApi.Routes = {
list: tenantRoute(`g/${params.group}/:entity`),
new: tenantRoute(`g/${params.group}/:entity/new`),
overview: tenantRoute(`g/${params.group}/:entity/:id`),
edit: tenantRoute(`g/${params.group}/:entity/:id/edit`),
import: tenantRoute(`g/${params.group}/:entity/import`),
export: tenantRoute(`g/${params.group}/:entity/export`),
publicUrl: getAbsoluteUrl(request, `/public/:entity/:id`),
group: tenantRoute(`g/${params.group}`),
};
return routes;
}
} else if (isAdminRequest && isPathOrChild(url.pathname, "/crm")) {
const routes: EntitiesApi.Routes = {
list: adminRoute("crm/:entity"),
new: adminRoute("crm/:entity/new"),
overview: adminRoute("crm/:entity/:id"),
edit: adminRoute("crm/:entity/:id/edit"),
import: adminRoute("crm/:entity/import"),
export: adminRoute("crm/:entity/export"),
publicUrl: getAbsoluteUrl(request, `/public/:entity/:id`),
};
return routes;
} else if (isAdminRequest) {
const routes: EntitiesApi.Routes = {
list: adminRoute("entities/:entity/no-code/:entity"),
new: adminRoute("entities/:entity/no-code/:entity/new"),
overview: adminRoute("entities/:entity/no-code/:entity/:id"),
edit: adminRoute("entities/:entity/no-code/:entity/:id/edit"),
import: adminRoute("entities/:entity/no-code/:entity/import"),
export: adminRoute("entities/:entity/no-code/:entity/export"),
publicUrl: getAbsoluteUrl(request, `/public/:entity/:id`),
};
return routes;
} else if (params?.tenant && isPathOrChild(url.pathname, `/${params.tenant}/crm`)) {
const routes: EntitiesApi.Routes = {
list: tenantRoute("crm/:entity"),
new: tenantRoute("crm/:entity/new"),
overview: tenantRoute("crm/:entity/:id"),
edit: tenantRoute("crm/:entity/:id/edit"),
import: tenantRoute("crm/:entity/import"),
export: tenantRoute("crm/:entity/export"),
publicUrl: getAbsoluteUrl(request, `/public/:entity/:id`),
};
return routes;
}
const routes: EntitiesApi.Routes = {
list: tenantRoute(":entity"),
new: tenantRoute(":entity/new"),
overview: tenantRoute(":entity/:id"),
edit: tenantRoute(":entity/:id/edit"),
import: tenantRoute(":entity/import"),
export: tenantRoute(":entity/export"),
publicUrl: getAbsoluteUrl(request, `/public/:entity/:id`),
};
return routes;
}
export async function fillSystemProperties({
entity,
tenantId,
userId,
}: {
entity: EntityWithDetails;
tenantId: string | null | undefined;
userId: string | undefined;
}) {
// const userProp = entity.properties.find((f) => f.name === "user");
// if (userProp) {
// const users = await adminGetAllUsersNames();
// userProp.options = users.map((f) => ({
// value: f.email,
// name: f.email,
// })) as PropertyOption[];
// }
}
}
function isPathOrChild(pathname: string, prefix: string) {
return pathname === prefix || pathname.startsWith(`${prefix}/`);
}
@@ -0,0 +1,19 @@
// @vitest-environment node
import { describe, expect, test } from "vitest";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
const source = readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "EntityViewsApi.ts"), "utf8");
describe("EntityViewsApi tenant scope", () => {
test("validates entity views against the current tenant/user scope", () => {
expect(source).toContain("validateEntityView({ entityId: entity.id, isDefault, name, title, order: null, tenantId, userId })");
expect(source).toContain("tenantId: item.tenantId, userId: item.userId");
expect(source).toContain("const views = await getEntityViews(entityId, { tenantId, userId })");
});
test("loads entity views by id through the current entity and tenant scope", () => {
expect(source).toContain("getEntityView(id, { entityId: entity.id, tenantId })");
});
});
@@ -0,0 +1,358 @@
import { EntityView } from "@toolctrl/db/prisma-browser";
import { EntityWithDetails, getEntityByName } from "../../db/entities/entities.db.server";
import {
createEntityView,
EntityViewWithDetails,
getEntityView,
getEntityViewByName,
getEntityViewDefault,
getEntityViews,
updateEntityViewFilters,
updateEntityViewProperties,
updateEntityViewSort,
updateScopedEntityView,
updateScopedEntityViewFilters,
updateScopedEntityViewProperties,
updateScopedEntityViewSort,
} from "../../db/entities/entityViews.db.server";
import { RowsApi } from "./RowsApi";
import { getUser } from "../../db/users.db.server";
import { getTenant } from "../../db/tenants.db.server";
import { PropertyType } from "~/application/enums/entities/PropertyType";
export namespace EntityViewsApi {
export type GetEntityViewsWithRows = {
view: EntityViewWithDetails | undefined;
rowsData: RowsApi.GetRowsData;
rowsCount: number;
};
export async function getAll({
entityName,
tenantId,
withDefault = true,
withRows = true,
}: {
entityName: string;
tenantId: string | null;
withDefault: boolean;
withRows: boolean;
}): Promise<GetEntityViewsWithRows[]> {
const entity = await getEntityByName({ tenantId, name: entityName });
const views = await getEntityViews(entity.id, { tenantId });
let allViews: (EntityViewWithDetails | undefined)[] = views;
if (withDefault) {
allViews = [undefined, ...views];
}
return await Promise.all(
allViews.map(async (entityView) => {
let rowsData = await RowsApi.getAll({
entity,
tenantId,
entityView,
urlSearchParams: new URLSearchParams(),
pageSize: 10000,
});
const rowsCount = rowsData.items.length;
if (!withRows) {
rowsData = { ...rowsData, items: [] };
}
const data: GetEntityViewsWithRows = {
view: entityView,
rowsData,
rowsCount,
};
return data;
})
);
}
export async function get(
id: string | undefined,
{ entityName, tenantId, pageSize }: { entityName: string; tenantId: string | null; pageSize?: number }
): Promise<GetEntityViewsWithRows | null> {
const entity = await getEntityByName({ tenantId, name: entityName });
if (!entity) {
return null;
}
let view: EntityViewWithDetails | null = null;
if (id) {
view = await getEntityView(id, { entityId: entity.id, tenantId });
if (!view) {
return null;
}
}
const rowsData = await RowsApi.getAll({
entity,
tenantId,
entityView: view ?? undefined,
urlSearchParams: new URLSearchParams(),
pageSize,
});
const data: GetEntityViewsWithRows = {
view: view ?? undefined,
rowsData,
rowsCount: rowsData.items.length,
};
return data;
}
export async function createFromForm({ entity, form, createdByUserId }: { form: FormData; entity: EntityWithDetails; createdByUserId: string }) {
const layout = form.get("layout")?.toString() ?? "";
const name = form.get("name")?.toString().toLowerCase() ?? "";
const title = form.get("title")?.toString() ?? "";
const pageSize = Number(form.get("pageSize"));
const isDefault = Boolean(form.get("isDefault"));
// Board
const groupBy = form.get("groupBy")?.toString() ?? undefined;
const groupByPropertyId = form.get("groupByPropertyId")?.toString() ?? undefined;
// Grid
const gridColumns = Number(form.get("gridColumns") ?? 0);
const gridColumnsSm = Number(form.get("gridColumnsSm") ?? 0);
const gridColumnsMd = Number(form.get("gridColumnsMd") ?? 0);
const gridColumnsLg = Number(form.get("gridColumnsLg") ?? 0);
const gridColumnsXl = Number(form.get("gridColumnsXl") ?? 0);
const gridColumns2xl = Number(form.get("gridColumns2xl") ?? 0);
const gridGap = form.get("gridGap")?.toString() ?? "sm";
const isSystem = form.get("isSystem") === "true";
let tenantId: string | null = form.get("tenantId")?.toString() ?? null;
let userId: string | null = form.get("userId")?.toString() ?? null;
if (!tenantId?.toString().trim()) {
tenantId = null;
}
if (!userId?.toString().trim()) {
userId = null;
}
const errors = [
...(await validateEntityView({ entityId: entity.id, isDefault, name, title, order: null, tenantId, userId })),
...(await validateGroupBy(entity, layout, groupBy, groupByPropertyId)),
];
if (errors.length > 0) {
throw Error(errors.join(", "));
}
const properties: { propertyId: string; name: string; order: number }[] = form.getAll("properties[]").map((f: FormDataEntryValue) => {
return JSON.parse(f.toString());
});
if (properties.length === 0) {
throw Error("Add at least one property to display");
}
const filters: { name: string; condition: string; value: string; match: string }[] = form.getAll("filters[]").map((f: FormDataEntryValue) => {
return JSON.parse(f.toString());
});
const sort: { name: string; asc: boolean; order: number }[] = form.getAll("sort[]").map((f: FormDataEntryValue) => {
return JSON.parse(f.toString());
});
if (tenantId) {
const tenant = await getTenant(tenantId);
if (!tenant) {
// eslint-disable-next-line no-console
console.log("Invalid account", { tenantId });
throw Error("Invalid account");
}
}
if (userId) {
const user = await getUser(userId);
if (!user) {
// eslint-disable-next-line no-console
console.log("Invalid user", { userId });
throw Error("Invalid user");
}
}
try {
const entityView = await createEntityView({
entityId: entity.id,
tenantId: tenantId,
userId: userId,
createdByUserId,
layout,
name,
title,
isDefault,
pageSize,
groupByPropertyId: groupBy === "byProperty" ? groupByPropertyId ?? null : null,
gridColumns,
gridColumnsSm,
gridColumnsMd,
gridColumnsLg,
gridColumnsXl,
gridColumns2xl,
gridGap,
isSystem,
});
await updateEntityViewProperties(entityView.id, properties);
await updateEntityViewFilters(entityView.id, filters);
await updateEntityViewSort(entityView.id, sort);
return entityView;
} catch (e: any) {
throw Error(e.message);
}
}
export async function updateFromForm({ item, entity, form }: { item: EntityView; form: FormData; entity: EntityWithDetails }) {
const layout = form.get("layout")?.toString() ?? "";
const name = form.get("name")?.toString() ?? "";
const title = form.get("title")?.toString() ?? "";
const pageSize = Number(form.get("pageSize"));
const order = Number(form.get("order"));
const isDefault = Boolean(form.get("isDefault"));
// Board
const groupBy = form.get("groupBy")?.toString() ?? undefined;
const groupByPropertyId = form.get("groupByPropertyId")?.toString() ?? undefined;
// Grid
const gridColumns = Number(form.get("gridColumns") ?? 0);
const gridColumnsSm = Number(form.get("gridColumnsSm") ?? 0);
const gridColumnsMd = Number(form.get("gridColumnsMd") ?? 0);
const gridColumnsLg = Number(form.get("gridColumnsLg") ?? 0);
const gridColumnsXl = Number(form.get("gridColumnsXl") ?? 0);
const gridColumns2xl = Number(form.get("gridColumns2xl") ?? 0);
const gridGap = form.get("gridGap")?.toString() ?? "sm";
const errors = [
...(await validateEntityView({ entityId: entity.id, isDefault, name, title, order, entityView: item, tenantId: item.tenantId, userId: item.userId })),
...(await validateGroupBy(entity, layout, groupBy, groupByPropertyId)),
];
if (errors.length > 0) {
throw Error(errors.join(", "));
}
const properties: { propertyId: string; name: string; order: number }[] = form.getAll("properties[]").map((f: FormDataEntryValue) => {
return JSON.parse(f.toString());
});
if (properties.length === 0) {
throw Error("Add at least one property to display");
}
const filters: { name: string; condition: string; value: string; match: string }[] = form.getAll("filters[]").map((f: FormDataEntryValue) => {
return JSON.parse(f.toString());
});
const sort: { name: string; asc: boolean; order: number }[] = form.getAll("sort[]").map((f: FormDataEntryValue) => {
return JSON.parse(f.toString());
});
try {
const viewScope = {
id: item.id,
entityId: entity.id,
tenantId: item.tenantId,
userId: item.userId,
};
const view = await updateScopedEntityView(viewScope, {
order,
layout,
name,
title,
isDefault,
pageSize,
groupByPropertyId: groupBy === "byProperty" ? groupByPropertyId ?? null : null,
gridColumns,
gridColumnsSm,
gridColumnsMd,
gridColumnsLg,
gridColumnsXl,
gridColumns2xl,
gridGap,
});
await updateScopedEntityViewProperties(viewScope, properties);
await updateScopedEntityViewFilters(viewScope, filters);
await updateScopedEntityViewSort(viewScope, sort);
return view;
} catch (e: any) {
throw Error(e.message);
}
}
export const getCurrentEntityView = async (
entityId: string,
urlParams: URLSearchParams,
{ tenantId, userId }: { tenantId?: string | null; userId?: string | null } = {}
) => {
const name = urlParams.get("v");
if (name) {
return await getEntityViewByName({ entityId, name, isSystem: false, tenantId, userId });
} else {
return await getEntityViewDefault({ entityId, isSystem: false, tenantId, userId });
}
};
const validateEntityView = async ({
entityId,
isDefault,
name,
title,
order,
entityView,
tenantId,
userId,
}: {
entityId: string;
isDefault: boolean;
name: string;
title: string;
order: number | null;
entityView?: EntityView;
tenantId: string | null;
userId: string | null;
}) => {
const errors: string[] = [];
const views = await getEntityViews(entityId, { tenantId, userId });
if (isDefault && userId) {
const defaultView = views.find((f) => f.id !== entityView?.id && f.isDefault && f.userId === userId);
if (defaultView) {
errors.push(`Existing default entity view with title: ${defaultView.title}`);
}
}
if (!entityView || entityView?.name !== name) {
const existingName = views.find((f) => f.id !== entityView?.id && f.name.toLowerCase() === name.toLowerCase());
if (existingName) {
errors.push(`Existing entity view with name: ${existingName.name}`);
}
}
if (!entityView || entityView?.title !== title) {
const existingTitle = views.find((f) => f.id !== entityView?.id && f.title.toLowerCase() === title.toLowerCase());
if (existingTitle) {
errors.push(`Existing entity view with title: ${existingTitle.title}`);
}
}
// if (order) {
// if (!entityView || entityView?.order !== order) {
// const existingOrder = await getEntityViewByOrder(entityId, order);
// if (existingOrder) {
// errors.push(`Existing entity view with order '${order}': ${existingOrder.title}`);
// }
// }
// }
return errors;
};
const validateGroupBy = async (entity: EntityWithDetails, layout: string, groupBy: string | undefined, groupByPropertyId: string | undefined) => {
if (layout !== "board") {
return [];
}
const errors: string[] = [];
if (groupBy === "byProperty") {
const property = entity.properties.find((p) => p.id === groupByPropertyId);
if (!property || property.type !== PropertyType.SELECT) {
errors.push(`Cannot save a Board view without a SELECT property`);
}
}
return errors;
};
}
@@ -0,0 +1,128 @@
import { createProperty, updatePropertyAttributes, updatePropertyOptions } from "../../db/entities/properties.db.server";
import { PropertyType } from "~/application/enums/entities/PropertyType";
import { Colors } from "~/application/enums/shared/Colors";
import { EntityWithDetails } from "../../db/entities/entities.db.server";
export namespace PropertiesApi {
export async function create({
entityId,
name,
title,
type,
subtype,
order,
isDefault,
isRequired,
isHidden,
isDisplay,
isReadOnly,
canUpdate,
showInCreate,
formulaId,
options,
attributes,
tenantId,
}: {
entityId: string;
name: string;
title: string;
type: PropertyType;
subtype?: string | null;
order?: number;
isDefault?: boolean;
isRequired?: boolean;
isHidden?: boolean;
isDisplay?: boolean;
isReadOnly?: boolean;
canUpdate?: boolean;
showInCreate?: boolean;
formulaId?: string | null;
options?: { order: number; value: string; name?: string | null; color?: Colors }[];
attributes?: { name: string; value: string }[];
tenantId: string | null;
}) {
if (!order) {
order = 0;
}
const property = await createProperty({
entityId,
name,
title,
type,
subtype: subtype ?? null,
order,
isDefault: isDefault ?? false,
isRequired: isRequired ?? false,
isHidden: isHidden ?? false,
isDisplay: isDisplay ?? false,
isReadOnly: isReadOnly ?? false,
canUpdate: canUpdate ?? true,
showInCreate: showInCreate ?? true,
formulaId: formulaId ?? null,
tenantId,
});
if (options !== undefined) {
await updatePropertyOptions(property.id, options);
}
if (attributes !== undefined) {
await updatePropertyAttributes(property.id, attributes);
}
return property;
}
export async function duplicate({ entity, propertyId }: { entity: EntityWithDetails; propertyId: string }) {
const existingProperty = entity.properties.find((p) => p.id === propertyId);
if (!existingProperty) {
throw Error("Property not found");
}
let number = 2;
let name = "";
const findExistingProperty = (name: string) => {
return entity.properties.find((p) => p.name === name);
};
do {
name = `${existingProperty.name}${number}`;
const existingPropertyWithNewName = findExistingProperty(name);
if (!existingPropertyWithNewName) {
break;
}
if (number > 10) {
throw Error("Too many duplicates");
}
number++;
} while (true);
let maxOrder = 0;
if (entity.properties.length > 0) {
maxOrder = Math.max(...entity.properties.map((p) => p.order));
}
return await create({
entityId: entity.id,
name,
title: existingProperty.title + " " + number,
type: existingProperty.type,
subtype: existingProperty.subtype,
order: maxOrder + 1,
isDefault: existingProperty.isDefault,
isRequired: existingProperty.isRequired,
isHidden: existingProperty.isHidden,
isDisplay: existingProperty.isDisplay,
isReadOnly: existingProperty.isReadOnly,
showInCreate: existingProperty.showInCreate,
formulaId: existingProperty.formulaId,
options: existingProperty.options.map((o) => ({
order: o.order,
value: o.value,
name: o.name,
color: o.color,
})),
attributes: existingProperty.attributes.map((a) => ({
name: a.name,
value: a.value,
})),
tenantId: existingProperty.tenantId,
});
}
}
@@ -0,0 +1,40 @@
import { getEntityByIdOrName } from "~/utils/db/entities/entities.db.server";
import { getRowById } from "~/utils/db/entities/rows.db.server";
import { createManualRowLog } from "../../db/logs.db.server";
import { DefaultLogActions } from "~/application/dtos/shared/DefaultLogActions";
import { createRowComment } from "../../db/entities/rowComments.db.server";
export namespace RowCommentsApi {
export async function create(
id: string,
{
comment,
tenantId,
userId,
}: {
comment: string;
tenantId: string | null;
userId: string;
}
) {
const item = await getRowById(id, { tenantId });
const entity = await getEntityByIdOrName({ tenantId, id: item?.entityId });
if (!item || !entity) {
throw new Error("Row not found");
}
const created = await createRowComment({
createdByUserId: userId,
rowId: id,
value: comment,
});
await createManualRowLog({
tenantId: item.tenantId,
createdByUserId: userId,
action: DefaultLogActions.Commented,
entity,
item,
commentId: created.id,
});
return created;
}
}
@@ -0,0 +1,74 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { RowPermissionsApi } from "./RowPermissionsApi";
import { getGroup } from "~/utils/db/permissions/groups.db.server";
import { getRole } from "~/utils/db/permissions/roles.db.server";
import { getTenant, getTenantUserByIds } from "~/utils/db/tenants.db.server";
vi.mock("~/utils/db/permissions/rowPermissions.db.server", () => ({
createRowPermission: vi.fn(),
deleteRowPermissionByIdForRow: vi.fn(),
getRowPermissions: vi.fn(async () => []),
updateRowPermissionForRow: vi.fn(),
}));
vi.mock("~/utils/db/permissions/groups.db.server", () => ({
getGroup: vi.fn(),
}));
vi.mock("~/utils/db/permissions/roles.db.server", () => ({
getRole: vi.fn(),
}));
vi.mock("~/utils/db/tenants.db.server", () => ({
getTenant: vi.fn(),
getTenantUserByIds: vi.fn(),
}));
describe("RowPermissionsApi.validateShareTarget", () => {
beforeEach(() => {
vi.mocked(getGroup).mockReset();
vi.mocked(getRole).mockReset();
vi.mocked(getTenant).mockReset();
vi.mocked(getTenantUserByIds).mockReset();
});
test("rejects tenant share targets outside the current tenant", async () => {
await expect(
RowPermissionsApi.validateShareTarget({ type: "tenant", id: "tenant_b", access: "view" }, { currentTenantId: "tenant_a" })
).rejects.toThrow("Invalid permission target");
});
test("rejects invalid access values", async () => {
await expect(
RowPermissionsApi.validateShareTarget({ type: "public", access: "owner" as never })
).rejects.toThrow("Invalid permission access");
});
test("rejects user share targets that are not members of the current tenant", async () => {
vi.mocked(getTenantUserByIds).mockResolvedValue(null);
await expect(
RowPermissionsApi.validateShareTarget({ type: "user", id: "user_b", access: "view" }, { currentTenantId: "tenant_a" })
).rejects.toThrow("Invalid permission target");
expect(getTenantUserByIds).toHaveBeenCalledWith("tenant_a", "user_b");
});
test("rejects group share targets outside the current tenant", async () => {
vi.mocked(getGroup).mockResolvedValue(null);
await expect(
RowPermissionsApi.validateShareTarget({ type: "group", id: "group_b", access: "view" }, { currentTenantId: "tenant_a" })
).rejects.toThrow("Invalid permission target");
expect(getGroup).toHaveBeenCalledWith("group_b", "tenant_a");
});
test("rejects admin roles as tenant share targets", async () => {
vi.mocked(getRole).mockResolvedValue({ id: "role_admin", type: "admin" } as Awaited<ReturnType<typeof getRole>>);
await expect(
RowPermissionsApi.validateShareTarget({ type: "role", id: "role_admin", access: "view" }, { currentTenantId: "tenant_a" })
).rejects.toThrow("Invalid permission target");
});
});
@@ -0,0 +1,158 @@
import { RowAccess, RowAccessTypes } from "~/application/enums/entities/RowAccess";
import {
createRowPermission,
deleteRowPermissionByIdForRow,
getRowPermissions,
updateRowPermissionForRow,
} from "../../db/permissions/rowPermissions.db.server";
import { DefaultVisibility } from "~/application/dtos/shared/DefaultVisibility";
import { Row } from "@toolctrl/db/prisma-browser";
import { getGroup } from "~/utils/db/permissions/groups.db.server";
import { getRole } from "~/utils/db/permissions/roles.db.server";
import { getTenant, getTenantUserByIds } from "~/utils/db/tenants.db.server";
export namespace RowPermissionsApi {
type ShareData = { type: "tenant" | "user" | "role" | "group" | "public"; id?: string; access: RowAccess };
type ShareScope = { currentTenantId?: string | null };
export async function shareWithDefault(row: Row, defaultVisibility: DefaultVisibility) {
if (defaultVisibility === "tenant" && row.tenantId) {
await shareWithTenant(row, row.tenantId, "edit");
}
if (defaultVisibility === "public") {
return await shareWithPublic(row, "view");
}
}
export async function share(row: Row, data: ShareData, scope: ShareScope = {}) {
await validateShareTarget(data, scope);
const permission = {
tenantId: data.type === "tenant" ? data.id : null,
roleId: data.type === "role" ? data.id : null,
groupId: data.type === "group" ? data.id : null,
userId: data.type === "user" ? data.id : null,
public: data.type === "public",
access: data.access,
};
if (data.type !== "public" && !permission.tenantId && !permission.roleId && !permission.groupId && !permission.userId) {
throw Error("Invalid permission");
}
const permissions = await getRowPermissions(row.id);
const existing = permissions.find(
(f) =>
f.tenantId === permission.tenantId &&
f.roleId === permission.roleId &&
f.groupId === permission.groupId &&
f.userId === permission.userId &&
f.public === permission.public
);
if (data.type === "user" && data.id === row?.createdByUserId) {
return;
} else if (existing) {
return existing;
}
const create = await createRowPermission({
rowId: row.id,
...permission,
});
return create;
}
export async function validateShareTarget(data: ShareData, scope: ShareScope = {}) {
validateAccess(data.access);
if (data.type !== "tenant" && data.type !== "user" && data.type !== "role" && data.type !== "group" && data.type !== "public") {
throw Error("Invalid permission target");
}
if (data.type !== "public" && data.id === "public") {
throw Error("Invalid permission target");
}
if (data.type === "public" && data.id) {
throw Error("Invalid permission target");
}
if (data.type === "public") {
return;
}
if (!data.id) {
throw Error("Invalid permission target");
}
const { currentTenantId } = scope;
if (data.type === "tenant") {
if (currentTenantId && data.id !== currentTenantId) {
throw Error("Invalid permission target");
}
if (currentTenantId === null && !(await getTenant(data.id))) {
throw Error("Invalid permission target");
}
return;
}
if (data.type === "user") {
if (currentTenantId && !(await getTenantUserByIds(currentTenantId, data.id))) {
throw Error("Invalid permission target");
}
return;
}
if (data.type === "group") {
if (!(await getGroup(data.id, currentTenantId))) {
throw Error("Invalid permission target");
}
return;
}
const role = await getRole(data.id);
if (!role || (currentTenantId && role.type !== "app")) {
throw Error("Invalid permission target");
}
}
export function validateAccess(access: RowAccess) {
if (!RowAccessTypes.includes(access)) {
throw Error("Invalid permission access");
}
}
export async function shareWithTenant(row: Row, tenantId: string, access: RowAccess) {
return await share(row, {
type: "tenant",
id: tenantId,
access,
}, { currentTenantId: tenantId });
}
export async function shareWithUser(row: Row, userId: string, access: RowAccess) {
return await share(row, {
type: "user",
id: userId,
access,
});
}
export async function shareWithRole(row: Row, roleId: string, access: RowAccess) {
return await share(row, {
type: "role",
id: roleId,
access,
});
}
export async function shareWithGroup(row: Row, groupId: string, access: RowAccess) {
return await share(row, {
type: "group",
id: groupId,
access,
});
}
export async function shareWithPublic(row: Row, access: RowAccess) {
return await share(row, {
type: "public",
access,
});
}
export async function setAccessForRow(rowId: string, id: string, access: RowAccess) {
validateAccess(access);
const updated = await updateRowPermissionForRow(rowId, id, { access });
if (updated.count !== 1) {
throw Error("Permission not found");
}
return updated;
}
export async function delForRow(rowId: string, id: string) {
const deleted = await deleteRowPermissionByIdForRow(rowId, id);
if (deleted.count !== 1) {
throw Error("Permission not found");
}
return deleted;
}
}
@@ -0,0 +1,231 @@
import {
createLegacyRowRelationship,
deleteLegacyRowRelationship,
deleteLegacyRowRelationshipById,
findLegacyEntityRelationship,
findLegacyRowRelationshipById,
} from "@toolctrl/db/relationships.server";
import { TimeFunction, timeFake } from "~/modules/metrics/services/.server/MetricTracker";
import { RowsApi } from "./RowsApi";
import { EntityWithDetails, EntityWithDetailsAndRelationships, getEntityById, getEntityByName } from "../../db/entities/entities.db.server";
export namespace RowRelationshipsApi {
export async function getRelationship(
id: string,
{
tenantId,
userId,
time,
}: {
userId: string | undefined;
tenantId: string | null;
time?: TimeFunction;
}
) {
if (!time) {
time = timeFake;
}
const relationship = await time(findLegacyRowRelationshipById(id, tenantId), "RowRelationshipsApi.getRelationship");
if (!relationship) {
return null;
}
const parent = await RowsApi.get(relationship.parentId, {
entity: {
id: relationship.relationship.parentId,
},
tenantId,
userId,
});
const child = await RowsApi.get(relationship.childId, {
entity: {
id: relationship.relationship.childId,
},
tenantId,
userId,
});
return {
...relationship,
parent,
child,
};
}
export async function createRelationship({
parent,
child,
time,
allEntities,
metadata,
tenantId,
}: {
parent: { id: string; entityId: string } | { id: string; entityName: string };
child: { id: string; entityId: string } | { id: string; entityName: string };
tenantId?: string | null;
metadata?: string | null;
time?: TimeFunction;
allEntities?: EntityWithDetails[];
}) {
if (!time) {
time = timeFake;
}
const { relationship } = await getParentChildEntities({ parent, child, allEntities });
return await time(
createLegacyRowRelationship({
relationshipId: relationship.id,
parentId: parent.id,
childId: child.id,
tenantId,
metadata: metadata || null,
}),
"RowRelationshipsApi.createRelationship.createRowRelationship"
);
}
export async function createRelationshipWithIds({
parentEntityId,
childEntityId,
parentId,
childId,
metadata,
tenantId,
time,
allEntities,
}: {
parentEntityId: string;
childEntityId: string;
parentId: string;
childId: string;
tenantId?: string | null;
metadata: string | null;
time?: TimeFunction;
allEntities?: EntityWithDetails[];
}) {
if (!time) {
time = timeFake;
}
const { relationship } = await getParentChildEntities({
parent: { id: parentId, entityId: parentEntityId },
child: { id: childId, entityId: childEntityId },
allEntities,
});
return await time(
createLegacyRowRelationship({
relationshipId: relationship.id,
parentId,
childId,
tenantId,
metadata,
}),
"RowRelationshipsApi.createRelationship.createRowRelationship"
);
}
export async function deleteRelationship({
parent,
child,
time,
allEntities,
tenantId,
}: {
parent: { id: string; entityId: string } | { id: string; entityName: string };
child: { id: string; entityId: string } | { id: string; entityName: string };
tenantId?: string | null;
time?: TimeFunction;
allEntities?: EntityWithDetails[];
}) {
if (!time) {
time = timeFake;
}
const { parentEntity, childEntity } = await getParentChildEntities({ parent, child, allEntities });
const relationship = await time(
findLegacyEntityRelationship({
parentId: parentEntity.id,
childId: childEntity.id,
}),
"RowRelationshipsApi.deleteRelationship.getEntitiesRelationship"
);
if (!relationship) {
throw Error(`No relationship found between ${parentEntity.name} and ${childEntity.name}`);
}
return await time(
deleteLegacyRowRelationship({
relationshipId: relationship.id,
parentId: parent.id,
childId: child.id,
tenantId,
}),
"RowRelationshipsApi.deleteRelationship.deleteRowRelationship"
);
}
async function getParentChildEntities({
parent,
child,
allEntities,
}: {
parent: { id: string; entityId: string } | { id: string; entityName: string };
child: { id: string; entityId: string } | { id: string; entityName: string };
allEntities?: EntityWithDetails[];
}) {
let parentEntity: EntityWithDetailsAndRelationships | null = null;
let childEntity: EntityWithDetailsAndRelationships | null = null;
if ("entityName" in parent) {
parentEntity = allEntities
? allEntities.find((e) => e.name === parent.entityName) || null
: await getEntityByName({ tenantId: undefined, name: parent.entityName });
} else {
parentEntity = allEntities
? allEntities.find((e) => e.id === parent.entityId) || null
: await getEntityById({ tenantId: undefined, id: parent.entityId });
}
if ("entityName" in child) {
childEntity = allEntities
? allEntities.find((e) => e.name === child.entityName) || null
: await getEntityByName({ tenantId: undefined, name: child.entityName });
} else {
childEntity = allEntities ? allEntities.find((e) => e.id === child.entityId) || null : await getEntityById({ tenantId: undefined, id: child.entityId });
}
if (!parentEntity) {
throw Error(`Invalid parent entity ${JSON.stringify(parent)}`);
}
if (!childEntity) {
throw Error(`Invalid child entity ${JSON.stringify(child)}`);
}
const relationship = parentEntity.childEntities.find((e) => e.childId === childEntity!.id);
if (!relationship) {
throw Error(`No relationship found between ${parentEntity?.name} and ${childEntity?.name}`);
}
return {
parentEntity,
childEntity,
relationship,
};
}
export async function deleteRelationshipById(
id: string,
{
tenantId,
userId,
time,
}: {
tenantId: string | null;
userId: string | undefined;
time?: TimeFunction;
}
) {
if (!time) {
time = timeFake;
}
const relationship = await getRelationship(id, { tenantId, userId, time });
if (!relationship) {
throw Error(`No relationship found with id ${id}`);
}
return await time(
deleteLegacyRowRelationshipById({
id: relationship.id,
relationshipId: relationship.relationshipId,
parentId: relationship.parentId,
childId: relationship.childId,
tenantId,
}),
"RowRelationshipsApi.deleteRelationship.deleteRowRelationship",
);
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,380 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
const tx = {
event: {
create: vi.fn(),
},
};
return {
tx,
db: {
$transaction: vi.fn(async (runner: (tx: unknown) => Promise<unknown>) => runner(tx)),
},
rows: {
buildLegacyRowPropertyFiltersWhere: vi.fn(() => ({})),
buildLegacyRowQueryWhere: vi.fn(() => ({})),
changeLegacyRowOrder: vi.fn(),
countLegacyRows: vi.fn(),
createRowWithValues: vi.fn(),
hardDeleteRowById: vi.fn(),
getLegacyRow: vi.fn(),
getLegacyRowById: vi.fn(),
listLegacyRows: vi.fn(),
updateRowWithValuesInTransaction: vi.fn(),
},
entities: {
getEntityByIdOrName: vi.fn(),
},
entityViews: {
getEntityViews: vi.fn(),
},
entityTags: {
createEntityTag: vi.fn(),
getEntityTag: vi.fn(),
getEntityTags: vi.fn(),
},
permissions: {
getEntityPermission: vi.fn((entity: { name: string }, name: string) => `${entity.name}.${name}`),
getRowPermissionsCondition: vi.fn(() => ({})),
getUserPermission: vi.fn(async () => ({ permission: { id: "permission_1" }, userPermission: { id: "user_permission_1" } })),
getUserRowPermission: vi.fn(async () => ({ canRead: true, canUpdate: true, canDelete: true })),
getRowPermissions: vi.fn(),
getUserRoleInTenant: vi.fn(),
},
pagination: {
getPaginationFromCurrentUrl: vi.fn(() => ({ page: 1, pageSize: 25, sortedBy: [] })),
getEntityFiltersFromCurrentUrl: vi.fn(() => ({ query: "" })),
},
rowFiltersHelper: {
getRowFiltersCondition: vi.fn(() => ({})),
},
logs: {
createManualRowLog: vi.fn(),
getRowLogs: vi.fn(),
},
rowComments: {
getRowComments: vi.fn(),
},
rowTasks: {
getAllRowTasks: vi.fn(),
getRowTasks: vi.fn(),
},
rowTags: {
createRowTag: vi.fn(),
deleteRowTags: vi.fn(),
getRowTagByIds: vi.fn(),
getRowTags: vi.fn(),
},
subscription: {
getPlanFeatureUsage: vi.fn(),
reportUsage: vi.fn(),
},
rowHelper: {
getTextDescription: vi.fn(() => "Contact row"),
getDiff: vi.fn(() => ({ firstName: { before: "Ada", after: "Grace" } })),
},
relationshipHelper: {
getInputType: vi.fn(),
},
media: {
storeRowMediaInStorageProvider: vi.fn(),
},
rowPermissionsApi: {
shareWithDefault: vi.fn(),
},
rowEventsHelper: {
getRowUserOrApiKey: vi.fn(async () => ({ user: null, apiKey: null })),
},
events: {
getDescription: vi.fn((event: string) => `${event} description`),
publishOutboxEvent: vi.fn(),
},
apiHelper: {
getApiFormat: vi.fn((entity: { name: string }, row: { id: string }) => ({ id: row.id, entity: entity.name })),
},
entityViewsApi: {
getCurrentEntityView: vi.fn(),
},
entitiesApi: {
fillSystemProperties: vi.fn(),
},
pluginHooks: {
emitPluginHookFireAndForget: vi.fn(),
},
rowSearchOutbox: {
emitRowSearchDelete: vi.fn(),
emitRowSearchUpsert: vi.fn(),
},
rowSearch: {
searchRows: vi.fn(),
},
crmContactSearch: {
searchCrmContacts: vi.fn(),
},
notifications: {
sendToRoles: vi.fn(),
},
eventWebhookAttempts: {
createEventWebhookAttempt: vi.fn(),
updateEventWebhookAttempt: vi.fn(),
},
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
};
});
vi.mock("@toolctrl/db/client.server", () => ({
getDbClient: () => mocks.db,
}));
vi.mock("~/utils/db.server", () => ({
db: mocks.db,
}));
vi.mock("@toolctrl/db/rows.server", () => mocks.rows);
vi.mock("~/utils/db/entities/entities.db.server", () => mocks.entities);
vi.mock("~/utils/db/entities/entityViews.db.server", () => mocks.entityViews);
vi.mock("../../db/entities/entityTags.db.server", () => mocks.entityTags);
vi.mock("../../helpers/PermissionsHelper", () => mocks.permissions);
vi.mock("../../helpers/.server/PermissionsService", () => mocks.permissions);
vi.mock("../../helpers/RowPaginationHelper", () => mocks.pagination);
vi.mock("~/utils/helpers/RowFiltersHelper", () => ({ default: mocks.rowFiltersHelper }));
vi.mock("~/application/Constants", () => ({ default: { DEFAULT_PAGE_SIZE: 25 } }));
vi.mock("../../db/logs.db.server", () => mocks.logs);
vi.mock("../../db/entities/rowComments.db.server", () => mocks.rowComments);
vi.mock("../../db/entities/rowTasks.db.server", () => mocks.rowTasks);
vi.mock("../../db/entities/rowTags.db.server", () => mocks.rowTags);
vi.mock("../../db/permissions/rowPermissions.db.server", () => mocks.permissions);
vi.mock("../../services/.server/subscriptionService", () => mocks.subscription);
vi.mock("i18next", () => ({ t: (key: string) => key }));
vi.mock("../../helpers/RowHelper", () => ({ default: mocks.rowHelper }));
vi.mock("../../helpers/RelationshipHelper", () => ({ default: mocks.relationshipHelper }));
vi.mock("~/utils/helpers/.server/MediaService", () => mocks.media);
vi.mock("./RowPermissionsApi", () => ({ RowPermissionsApi: mocks.rowPermissionsApi }));
vi.mock("../../helpers/RowEventsHelper", () => mocks.rowEventsHelper);
vi.mock("@toolctrl/db/outbox", () => ({ publishOutboxEvent: mocks.events.publishOutboxEvent }));
vi.mock("../../utils/EventUtils", () => ({ default: mocks.events }));
vi.mock("../../db/eventWebhookAttempts.db.server", () => mocks.eventWebhookAttempts);
vi.mock("~/modules/notifications/services/.server/NotificationService", () => ({ default: mocks.notifications }));
vi.mock("~/utils/url.server", () => ({
getAbsoluteApiUrl: vi.fn(() => "https://api.example.test/v1/events/webhooks/attempts/attempt_1"),
getBaseURL: vi.fn(() => "https://app.example.test"),
}));
vi.mock("~/utils/app/CanonicalUrlUtils", () => ({
adminPath: vi.fn((path: string) => `/admin/${path}`),
appPath: vi.fn(({ path }: { path: string }) => `/app/${path}`),
}));
vi.mock("~/utils/logger.server", () => ({
createScopedLogger: vi.fn(() => mocks.logger),
}));
vi.mock("../../helpers/ApiHelper", () => ({ default: mocks.apiHelper }));
vi.mock("./EntityViewsApi", () => ({ EntityViewsApi: mocks.entityViewsApi }));
vi.mock("./EntitiesApi", () => ({ EntitiesApi: mocks.entitiesApi }));
vi.mock("~/utils/db/permissions/userRoles.db.server", () => mocks.permissions);
vi.mock("~/modules/plugins/services/.server/pluginHookPublisher", () => mocks.pluginHooks);
vi.mock("@toolctrl/plugin-runtime", () => ({
PLUGIN_HOOK_ENTITY_CREATED: "entity.created",
PLUGIN_HOOK_ENTITY_DELETED: "entity.deleted",
PLUGIN_HOOK_ENTITY_UPDATED: "entity.updated",
}));
vi.mock("~/modules/rows/services/rowSearchOutbox.server", () => mocks.rowSearchOutbox);
vi.mock("~/modules/rows/services/rowSearch.server", () => mocks.rowSearch);
vi.mock("~/modules/crm/services/crmContactSearch.server", () => mocks.crmContactSearch);
import { RowApiError, RowsApi } from "./RowsApi";
const entity = {
id: "entity_contact",
name: "contact",
slug: "contacts",
title: "Contact",
defaultVisibility: "private",
properties: [],
parentEntities: [],
childEntities: [],
};
function row(id: string, patch: Record<string, unknown> = {}) {
return {
id,
entityId: entity.id,
tenantId: "tenant_1",
createdByUserId: "user_1",
createdByApiKeyId: null,
values: [],
parentRows: [],
childRows: [],
createdAt: new Date("2026-01-01T00:00:00.000Z"),
updatedAt: new Date("2026-01-01T00:00:00.000Z"),
...patch,
};
}
describe("RowsApi workflow row events", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.db.$transaction.mockImplementation(async (runner: (tx: unknown) => Promise<unknown>) => runner(mocks.tx));
mocks.tx.event.create.mockImplementation(async ({ data }: { data: Record<string, unknown> }) => ({
id: `event_${mocks.tx.event.create.mock.calls.length}`,
...data,
}));
mocks.entities.getEntityByIdOrName.mockResolvedValue(entity);
mocks.subscription.getPlanFeatureUsage.mockResolvedValue(null);
mocks.permissions.getUserPermission.mockResolvedValue({ permission: { id: "permission_1" }, userPermission: { id: "user_permission_1" } });
mocks.permissions.getUserRowPermission.mockResolvedValue({ canRead: true, canUpdate: true, canDelete: true });
mocks.entityViews.getEntityViews.mockResolvedValue([]);
mocks.entityViewsApi.getCurrentEntityView.mockResolvedValue(null);
mocks.entityTags.getEntityTags.mockResolvedValue([]);
mocks.rows.listLegacyRows.mockResolvedValue([]);
mocks.rows.countLegacyRows.mockResolvedValue(0);
});
it("publishes tenant-scoped workflow outbox events for row create, update, and delete", async () => {
const created = row("row_created");
const beforeUpdate = row("row_updated", { values: [{ id: "value_before" }] });
const afterUpdate = row("row_updated", { values: [{ id: "value_after" }] });
const deleted = row("row_deleted");
const rowsById = new Map([
[created.id, created],
[beforeUpdate.id, beforeUpdate],
[deleted.id, deleted],
]);
const request = new Request("https://app.example.test/app/acme/contacts");
mocks.rows.createRowWithValues.mockResolvedValue({ id: created.id });
mocks.rows.getLegacyRowById.mockImplementation(async (id: string) => rowsById.get(id) ?? null);
mocks.rows.updateRowWithValuesInTransaction.mockResolvedValue(afterUpdate);
mocks.rows.hardDeleteRowById.mockResolvedValue(deleted);
await RowsApi.create({ entity, tenantId: "tenant_1", userId: "user_1", request, rowValues: { dynamicProperties: [] } });
await RowsApi.update(beforeUpdate.id, { entity, tenantId: "tenant_1", userId: "user_1", request, rowValues: { dynamicProperties: [] } });
await RowsApi.del(deleted.id, { entity, tenantId: "tenant_1", userId: "user_1", request });
expect(mocks.events.publishOutboxEvent).toHaveBeenCalledTimes(3);
expect(mocks.events.publishOutboxEvent).toHaveBeenNthCalledWith(1, mocks.tx, {
tenantId: "tenant_1",
type: "workflow.trigger.event",
payload: expect.objectContaining({ event: "row.created", userId: "user_1" }),
dispatchKey: "workflow-trigger:event_1",
});
expect(mocks.events.publishOutboxEvent).toHaveBeenNthCalledWith(2, mocks.tx, {
tenantId: "tenant_1",
type: "workflow.trigger.event",
payload: expect.objectContaining({ event: "row.updated", userId: "user_1" }),
dispatchKey: "workflow-trigger:event_2",
});
expect(mocks.events.publishOutboxEvent).toHaveBeenNthCalledWith(3, mocks.tx, {
tenantId: "tenant_1",
type: "workflow.trigger.event",
payload: expect.objectContaining({ event: "row.deleted", userId: "user_1" }),
dispatchKey: "workflow-trigger:event_3",
});
expect(mocks.tx.event.create).toHaveBeenCalledTimes(3);
expect(mocks.tx.event.create).toHaveBeenNthCalledWith(1, expect.objectContaining({ data: expect.objectContaining({ name: "row.created", tenantId: "tenant_1", userId: "user_1" }) }));
expect(mocks.tx.event.create).toHaveBeenNthCalledWith(2, expect.objectContaining({ data: expect.objectContaining({ name: "row.updated", tenantId: "tenant_1", userId: "user_1" }) }));
expect(mocks.tx.event.create).toHaveBeenNthCalledWith(3, expect.objectContaining({ data: expect.objectContaining({ name: "row.deleted", tenantId: "tenant_1", userId: "user_1" }) }));
expect(mocks.notifications.sendToRoles).toHaveBeenCalledTimes(3);
});
it("does not publish duplicate workflow outbox events when row event creation is disabled", async () => {
const deleted = row("row_parent");
mocks.rows.getLegacyRowById.mockResolvedValue(deleted);
mocks.rows.hardDeleteRowById.mockResolvedValue(deleted);
await RowsApi.del(deleted.id, {
entity,
tenantId: "tenant_1",
userId: "user_1",
request: new Request("https://app.example.test/app/acme/contacts"),
options: { createEvent: false },
});
expect(mocks.tx.event.create).not.toHaveBeenCalled();
expect(mocks.events.publishOutboxEvent).not.toHaveBeenCalled();
expect(mocks.notifications.sendToRoles).not.toHaveBeenCalled();
expect(mocks.rowSearchOutbox.emitRowSearchDelete).toHaveBeenCalledWith({ tenantId: "tenant_1", entity: { name: "contact" }, rowId: deleted.id });
});
});
describe("RowsApi row error codes", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.entities.getEntityByIdOrName.mockResolvedValue(entity);
});
it("throws a stable code for denied row reads", async () => {
mocks.rows.getLegacyRow.mockResolvedValue(row("row_private"));
mocks.permissions.getUserRowPermission.mockResolvedValue({ canRead: false, canUpdate: false, canDelete: false });
try {
await RowsApi.get("row_private", { entity, tenantId: "tenant_1", userId: "user_2" });
throw new Error("Expected RowsApi.get to reject");
} catch (error) {
expect(error).toBeInstanceOf(RowApiError);
expect(error).toMatchObject({ code: "row-access-denied", status: 403 });
}
});
});
describe("RowsApi CRM contact list search", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.entities.getEntityByIdOrName.mockResolvedValue(entity);
mocks.permissions.getUserPermission.mockResolvedValue({ permission: { id: "permission_1" }, userPermission: { id: "user_permission_1" } });
mocks.entityViews.getEntityViews.mockResolvedValue([]);
mocks.entityViewsApi.getCurrentEntityView.mockResolvedValue(null);
mocks.pagination.getPaginationFromCurrentUrl.mockReturnValue({ page: 1, pageSize: 25, sortedBy: [] });
mocks.pagination.getEntityFiltersFromCurrentUrl.mockReturnValue({ query: "ada" });
mocks.permissions.getRowPermissionsCondition.mockResolvedValue({});
mocks.rowFiltersHelper.getRowFiltersCondition.mockReturnValue({});
mocks.rows.buildLegacyRowQueryWhere.mockReturnValue({ id: { in: ["contact_row_1"] } });
mocks.rows.listLegacyRows.mockResolvedValue([]);
mocks.rows.countLegacyRows.mockResolvedValue(0);
mocks.entityTags.getEntityTags.mockResolvedValue([]);
mocks.crmContactSearch.searchCrmContacts.mockResolvedValue({
hits: [{ id: "contact_row_1", firstName: "Ada", lastName: "Lovelace", email: "ada@example.test", company: "", jobTitle: "" }],
total: 1,
fellBack: false,
});
mocks.rowSearch.searchRows.mockResolvedValue({ hits: [{ id: "row_1", folio: 1, entityName: "company" }], total: 1, fellBack: false });
});
it("routes contact-list freetext through searchCrmContacts and keeps the legacy SQL hydration path", async () => {
await RowsApi.getAll({
entity,
tenantId: "tenant_1",
userId: "user_1",
urlSearchParams: new URLSearchParams("q=ada"),
});
expect(mocks.crmContactSearch.searchCrmContacts).toHaveBeenCalledWith({ tenantId: "tenant_1", query: "ada", limit: 1000 });
expect(mocks.rowSearch.searchRows).not.toHaveBeenCalled();
expect(mocks.rows.buildLegacyRowQueryWhere).toHaveBeenCalledWith(
expect.objectContaining({
rowWhere: { id: { in: ["contact_row_1"] } },
})
);
expect(mocks.rows.listLegacyRows).toHaveBeenCalled();
expect(mocks.rows.countLegacyRows).toHaveBeenCalled();
});
it("keeps non-contact freetext on the generic rows search helper", async () => {
const companyEntity = { ...entity, id: "entity_company", name: "company", slug: "companies", title: "Company" };
mocks.entities.getEntityByIdOrName.mockResolvedValue(companyEntity);
await RowsApi.getAll({
entity: companyEntity,
tenantId: "tenant_1",
userId: "user_1",
urlSearchParams: new URLSearchParams("q=ada"),
});
expect(mocks.rowSearch.searchRows).toHaveBeenCalledWith({ tenantId: "tenant_1", entityId: "entity_company", query: "ada", limit: 1000 });
expect(mocks.crmContactSearch.searchCrmContacts).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,46 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
const rowsApiPath = path.resolve(process.cwd(), "apps/web/app/utils/api/.server/RowsApi.ts");
describe("RowsApi workflow outbox transaction boundary", () => {
it("creates rows, reloads rows, and records row.created workflow events inside the same transaction", () => {
const source = fs.readFileSync(rowsApiPath, "utf8");
const createSection = source.slice(source.indexOf("export async function create({"), source.indexOf("export async function createSimple({"));
expect(createSection).toContain("db.$transaction(async (tx) =>");
expect(createSection).toContain("const rowClient = tx as unknown as RowCreateClient");
expect(createSection).toContain("rowClient\n );");
expect(createSection).toContain("getLegacyRowById(item.id, rowClient, tenantId)");
expect(createSection).toContain("EventsService.createInTransaction(tx as Parameters<typeof EventsService.createInTransaction>[0]");
expect(createSection).toContain("EventsService.dispatchPostCommitSideEffects(rowCreatedEvent)");
expect(createSection).not.toContain('EventsService.create({\n request,\n event: "row.created"');
});
it("updates rows and records row.updated workflow events inside the same transaction", () => {
const source = fs.readFileSync(rowsApiPath, "utf8");
const updateSection = source.slice(source.indexOf("export async function update("), source.indexOf("export async function del("));
expect(updateSection).toContain("db.$transaction(async (tx) =>");
expect(updateSection).toContain("updateRowWithValuesInTransaction(");
expect(updateSection).toContain('event: "row.updated"');
expect(updateSection).toContain("EventsService.createInTransaction(tx as Parameters<typeof EventsService.createInTransaction>[0]");
expect(updateSection.indexOf("updateRowWithValuesInTransaction(")).toBeLessThan(updateSection.indexOf("EventsService.createInTransaction"));
expect(updateSection).toContain("EventsService.dispatchPostCommitSideEffects(rowUpdatedEvent)");
expect(updateSection).not.toContain('EventsService.create({\n request,\n event: "row.updated"');
});
it("deletes rows and records row.deleted workflow events inside the same transaction", () => {
const source = fs.readFileSync(rowsApiPath, "utf8");
const deleteSection = source.slice(source.indexOf("export async function del("), source.indexOf("export async function count({"));
expect(deleteSection).toContain("db.$transaction(async (tx) =>");
expect(deleteSection).toContain("hardDeleteRowById({ id, entityId: rowEntity.id, tenantId: row.tenantId ?? null }, tx as unknown as RowDeleteClient)");
expect(deleteSection).toContain('event: "row.deleted"');
expect(deleteSection).toContain("EventsService.createInTransaction(tx as Parameters<typeof EventsService.createInTransaction>[0]");
expect(deleteSection.indexOf("hardDeleteRowById(")).toBeLessThan(deleteSection.indexOf("EventsService.createInTransaction"));
expect(deleteSection).toContain("EventsService.dispatchPostCommitSideEffects(rowDeletedEvent)");
expect(deleteSection).not.toContain('EventsService.create({\n request,\n event: "row.deleted"');
});
});
@@ -0,0 +1,64 @@
import { getDefaultTenantTypes } from "../../db/tenants/tenantTypes.db.server";
import { SubscriptionProductDto } from "~/application/dtos/subscriptions/SubscriptionProductDto";
import { addTenantTypeToTenant, getTenant } from "../../db/tenants.db.server";
export namespace TenantTypesApi {
export async function setTenantTypes({
tenantId,
subscriptionProduct,
types,
}: {
tenantId: string;
subscriptionProduct?: SubscriptionProductDto;
types?: string[];
}) {
const tenant = await getTenant(tenantId);
if (!tenant) {
return;
}
if (types) {
await Promise.all(
types.map(async (typeId) => {
await addTenantTypeToTenant(tenant.id, { typeId });
})
);
return;
}
const defaultTenantTypes = await getDefaultTenantTypes();
const allTenantTypes = [...defaultTenantTypes, ...(subscriptionProduct?.assignsTenantTypes ?? [])].filter(
(v, i, a) => a.findIndex((t) => t.id === v.id) === i
);
await Promise.all(
allTenantTypes.map(async (tenantType) => {
if (!tenant.types.find((f) => f.id === tenantType.id)) {
await addTenantTypeToTenant(tenant.id, {
typeId: tenantType.id,
});
}
})
);
}
// export async function filterEntities({
// tenant,
// entities,
// entityGroups,
// }: {
// tenant: TenantSimple;
// entities: EntityWithDetails[];
// entityGroups?: EntityGroupWithDetails[];
// }) {
// const tenantTypes = await getAllTenantTypes();
// if (tenantTypes.length > 0) {
// const tenantEntities = await TenantEntitiesApi.getEntities({ inTypes: tenant.types, enabledOnly: true });
// entities = tenantEntities.allEntities;
// let newGroups: EntityGroupWithDetails[] = [];
// entityGroups?.forEach((group) => {
// group.entities = group.entities.filter((f) => entities.some((e) => e.id === f.id));
// if (group.entities.length > 0) {
// newGroups.push(group);
// }
// });
// entityGroups = newGroups;
// }
// }
}
@@ -0,0 +1,75 @@
import { getTranslations } from "~/locale/i18next.server";
import { findEntityByName } from "../../db/entities/entities.db.server";
import { createTenant } from "../../db/tenants.db.server";
import { getUser, updateUserDefaultTenantId } from "../../db/users.db.server";
import { getUserInfo } from "../../session.server";
import { createStripeCustomer } from "../../stripe.server";
import { RowsApi } from "./RowsApi";
import { TenantTypesApi } from "./TenantTypesApi";
import EventsService from "../../../modules/events/services/.server/EventsService";
import { AccountCreatedDto } from "~/modules/events/dtos/AccountCreatedDto";
import { autosubscribeToTrialOrFreePlan } from "../../services/.server/pricingService";
import { appPath } from "../../app/CanonicalUrlUtils";
export namespace TenantsApi {
export async function create({ request, form, name, slug }: { request: Request; form: FormData; name: string; slug?: string }) {
const { t } = await getTranslations(request);
let userInfo = await getUserInfo(request);
const user = await getUser(userInfo.userId);
if (!user) {
throw new Error("User not found");
}
if (!name) {
throw new Error("Name is required");
}
let stripeCustomerId: string | undefined;
if (process.env.STRIPE_SK) {
const stripeCustomer = await createStripeCustomer(user?.email, name);
if (!stripeCustomer) {
throw new Error("Could not create Stripe customer");
}
stripeCustomerId = stripeCustomer.id;
}
const tenant = await createTenant({ name, subscriptionCustomerId: stripeCustomerId, slug });
await updateUserDefaultTenantId({ defaultTenantId: tenant.id }, user.id);
await autosubscribeToTrialOrFreePlan({ request, t, tenantId: tenant.id, userId: user.id });
await EventsService.create({
request,
event: "account.created",
tenantId: tenant.id,
userId: user.id,
data: {
tenant: { id: tenant.id, name: tenant.name, slug: tenant.slug },
user: { id: user.id, email: user.email },
} satisfies AccountCreatedDto,
});
const tenantSettingsEntity = await findEntityByName({ tenantId: null, name: "tenantSettings" });
if (tenantSettingsEntity) {
try {
await RowsApi.createCustom({
request,
entity: tenantSettingsEntity,
tenantId: tenant.id,
t,
form,
row: undefined,
});
} catch (e: any) {
throw e;
}
}
await TenantTypesApi.setTenantTypes({ tenantId: tenant.id });
return {
redirectTo: appPath({ tenantSlug: tenant.slug, path: "dashboard", mode: "host-aware" }),
tenant,
user,
};
}
}
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { readRepoFile } from "~/utils/testing/repoPaths";
type BoundarySurface = {
path: string;
packageImports: string[];
forbiddenImports: string[];
};
const rowApiBoundarySurfaces: BoundarySurface[] = [
{
path: "apps/web/app/utils/api/.server/RowsApi.ts",
packageImports: [
"@toolctrl/db/rows.server",
"buildLegacyRowPropertyFiltersWhere",
"buildLegacyRowQueryWhere",
"changeLegacyRowOrder",
"countLegacyRows",
"createRowWithValues",
"hardDeleteRowById",
"getLegacyRow",
"getLegacyRowById",
"listLegacyRows",
"updateRowWithValues",
],
forbiddenImports: ["~/utils/db.server", "~/utils/db/entities/rows.db.server", "../../db/entities/rows.db.server"],
},
{
path: "apps/web/app/utils/api/.server/RowRelationshipsApi.ts",
packageImports: [
"@toolctrl/db/relationships.server",
"createLegacyRowRelationship",
"deleteLegacyRowRelationship",
"deleteLegacyRowRelationshipById",
"findLegacyEntityRelationship",
"findLegacyRowRelationshipById",
],
forbiddenImports: ["~/utils/db.server", "~/utils/db/entities/rowRelationships.db.server", "../../db/entities/rowRelationships.db.server"],
},
];
function readSource(relativePath: string) {
return readRepoFile(relativePath);
}
function importFrom(importPath: string) {
return [`from \"${importPath}\"`, `from '${importPath}'`];
}
describe("row API package boundary imports", () => {
it.each(rowApiBoundarySurfaces)("imports migrated row persistence helpers from package boundaries in $path", ({ path: relativePath, packageImports }) => {
const source = readSource(relativePath);
for (const packageImport of packageImports) {
expect(source, `${relativePath} should import ${packageImport}`).toContain(packageImport);
}
});
it.each(rowApiBoundarySurfaces)("does not reintroduce app-local row persistence imports in $path", ({ path: relativePath, forbiddenImports }) => {
const source = readSource(relativePath);
for (const forbiddenImport of forbiddenImports) {
for (const importStatement of importFrom(forbiddenImport)) {
expect(source, `${relativePath} should not import ${forbiddenImport}`).not.toContain(importStatement);
}
}
});
});
@@ -0,0 +1,20 @@
import { createHash } from "node:crypto";
const SENSITIVE_API_LOG_PARAM_PATTERN = /authorization|cookie|password|secret|token|api[-_]?key|key|email|session|jwt|code/i;
const SECRET_LIKE_API_LOG_VALUE_PATTERN = /\bBearer\s+\S+|\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9_]+|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/i;
export function getApiKeyLookupCacheKey(key: string) {
const digest = createHash("sha256").update(key).digest("hex");
return `apiKeyHash:${digest}`;
}
export function serializeApiLogSearchParams(request: Request) {
const searchParams = new URL(request.url).searchParams;
searchParams.delete("entity");
searchParams.delete("id");
const entries = Array.from(searchParams.entries()).map(([key, value]) => [
key,
SENSITIVE_API_LOG_PARAM_PATTERN.test(key) || SECRET_LIKE_API_LOG_VALUE_PATTERN.test(value) ? "[REDACTED]" : value,
]);
return JSON.stringify(Object.fromEntries(entries));
}
@@ -0,0 +1,48 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import { loadWebConfig } from "@toolctrl/config";
import { NOT_FOUND_ENVELOPE } from "~/utils/errors.server";
export const internalWebApiProxyHeader = "x-toolctrl-internal-web-api-proxy";
export const internalWebApiProxySignatureHeader = "x-toolctrl-internal-web-api-proxy-signature";
export function requireInternalWebApiProxyRequest(request: Request) {
if (request.headers.get(internalWebApiProxyHeader) !== "1" || !isValidInternalWebApiProxySignature(request)) {
throw Response.json(NOT_FOUND_ENVELOPE, { status: 404 });
}
}
export function createInternalWebApiProxySignature(input: { method: string; url: string | URL; secret: string }) {
const url = new URL(input.url);
const canonicalTarget = `${input.method.toUpperCase()}\n${url.pathname}${url.search}`;
return createHmac("sha256", input.secret).update(canonicalTarget).digest("base64url");
}
function getApiAccessToken() {
return loadWebConfig().config.apiAccessToken;
}
export function createInternalWebApiProxyHeaders(request: Request, secret = getApiAccessToken()) {
if (!secret) {
throw new Error("API_ACCESS_TOKEN is required for internal web API proxy requests.");
}
return {
[internalWebApiProxyHeader]: "1",
[internalWebApiProxySignatureHeader]: createInternalWebApiProxySignature({ method: request.method, url: request.url, secret }),
};
}
function isValidInternalWebApiProxySignature(request: Request) {
const secret = getApiAccessToken();
const signature = request.headers.get(internalWebApiProxySignatureHeader);
if (!secret || !signature) return false;
const expected = createInternalWebApiProxySignature({ method: request.method, url: request.url, secret });
return timingSafeEqualString(signature, expected);
}
function timingSafeEqualString(left: string, right: string) {
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
}