Track HiveGrid Cloud planning reference
This commit is contained in:
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);
|
||||
}
|
||||
Reference in new issue
Block a user