1148 lines
39 KiB
TypeScript
1148 lines
39 KiB
TypeScript
import {
|
|
buildLegacyRowPropertyFiltersWhere,
|
|
buildLegacyRowQueryWhere,
|
|
changeLegacyRowOrder,
|
|
countLegacyRows,
|
|
createRowWithValues,
|
|
hardDeleteRowById,
|
|
getLegacyRow,
|
|
getLegacyRowById,
|
|
LegacyRowWithDetails,
|
|
listLegacyRows,
|
|
updateRowWithValuesInTransaction,
|
|
} from "@toolctrl/db/rows.server";
|
|
import { EntityTag, Prisma, Row, RowPermission } from "@toolctrl/db/prisma-browser";
|
|
import { EntityWithDetails, getEntityByIdOrName } from "~/utils/db/entities/entities.db.server";
|
|
import { EntityViewWithDetails, getEntityViews } from "~/utils/db/entities/entityViews.db.server";
|
|
import { FilterablePropertyDto } from "../../../application/dtos/data/FilterablePropertyDto";
|
|
import { PaginationDto } from "../../../application/dtos/data/PaginationDto";
|
|
import { createEntityTag, getEntityTag, getEntityTags } from "../../db/entities/entityTags.db.server";
|
|
import { getEntityPermission } from "../../helpers/PermissionsHelper";
|
|
import { getRowPermissionsCondition, getUserPermission, getUserRowPermission } from "../../helpers/.server/PermissionsService";
|
|
import { getPaginationFromCurrentUrl, getEntityFiltersFromCurrentUrl } from "../../helpers/RowPaginationHelper";
|
|
import RowFiltersHelper from "~/utils/helpers/RowFiltersHelper";
|
|
import Constants from "~/application/Constants";
|
|
import { createManualRowLog, getRowLogs, LogWithDetails } from "../../db/logs.db.server";
|
|
import { DefaultLogActions } from "~/application/dtos/shared/DefaultLogActions";
|
|
import { RowValueDto } from "~/application/dtos/entities/RowValueDto";
|
|
import { getRowComments, RowCommentWithDetails } from "../../db/entities/rowComments.db.server";
|
|
import { getAllRowTasks, getRowTasks, RowTaskWithDetails } from "../../db/entities/rowTasks.db.server";
|
|
import { createRowTag, deleteRowTags, getRowTagByIds, getRowTags, RowTagWithDetails } from "../../db/entities/rowTags.db.server";
|
|
import { RowPermissionsDto } from "~/application/dtos/entities/RowPermissionsDto";
|
|
import { getRowPermissions } from "../../db/permissions/rowPermissions.db.server";
|
|
import { EntityRelationshipWithDetails } from "../../db/entities/entityRelationships.db.server";
|
|
import { Colors } from "~/application/enums/shared/Colors";
|
|
import { getPlanFeatureUsage, reportUsage } from "../../services/.server/subscriptionService";
|
|
import { TFunction } from "i18next";
|
|
import RowHelper from "../../helpers/RowHelper";
|
|
import { promiseHash } from "../../promises/promiseHash";
|
|
import { TimeFunction, timeFake } from "~/modules/metrics/services/.server/MetricTracker";
|
|
import RelationshipHelper from "../../helpers/RelationshipHelper";
|
|
import { DefaultVisibility } from "~/application/dtos/shared/DefaultVisibility";
|
|
import { storeRowMediaInStorageProvider } from "~/utils/helpers/.server/MediaService";
|
|
import { RowPermissionsApi } from "./RowPermissionsApi";
|
|
import { getRowUserOrApiKey } from "../../helpers/RowEventsHelper";
|
|
import EventsService from "~/modules/events/services/.server/EventsService";
|
|
import { RowDto } from "~/modules/rows/repositories/RowDto";
|
|
import { RowCreatedDto } from "~/modules/events/dtos/RowCreatedDto";
|
|
import { RowDeletedDto } from "~/modules/events/dtos/RowDeletedDto";
|
|
import { RowUpdatedDto } from "~/modules/events/dtos/RowUpdatedDto";
|
|
import ApiHelper from "../../helpers/ApiHelper";
|
|
import { EntityViewsApi } from "./EntityViewsApi";
|
|
import { DefaultAppRoles } from "~/application/dtos/shared/DefaultAppRoles";
|
|
import { getUserRoleInTenant } from "~/utils/db/permissions/userRoles.db.server";
|
|
import { EntitiesApi } from "./EntitiesApi";
|
|
import { emitPluginHookFireAndForget } from "~/modules/plugins/services/.server/pluginHookPublisher";
|
|
import { PLUGIN_HOOK_ENTITY_CREATED, PLUGIN_HOOK_ENTITY_DELETED, PLUGIN_HOOK_ENTITY_UPDATED } from "@toolctrl/plugin-runtime";
|
|
import { emitRowSearchDelete, emitRowSearchUpsert } from "~/modules/rows/services/rowSearchOutbox.server";
|
|
import { searchRows } from "~/modules/rows/services/rowSearch.server";
|
|
import { getDbClient } from "@toolctrl/db/client.server";
|
|
import { searchCrmContacts } from "~/modules/crm/services/crmContactSearch.server";
|
|
|
|
const db = getDbClient();
|
|
|
|
type RowWithDetails = LegacyRowWithDetails;
|
|
type RowMutationValue = Parameters<typeof createRowWithValues>[0]["data"]["dynamicProperties"];
|
|
type RowCreateClient = NonNullable<Parameters<typeof createRowWithValues>[1]> & NonNullable<Parameters<typeof getLegacyRowById>[1]>;
|
|
type RowUpdateClient = Parameters<typeof updateRowWithValuesInTransaction>[2];
|
|
type RowDeleteClient = NonNullable<Parameters<typeof hardDeleteRowById>[1]>;
|
|
type RowEventContext = {
|
|
source: "request" | "workflow";
|
|
request?: Request;
|
|
};
|
|
type RecordedRowEvent<TEvent extends "row.created" | "row.updated" | "row.deleted", TData> = {
|
|
event: TEvent;
|
|
tenantId: string | null;
|
|
userId: string | null;
|
|
data: TData;
|
|
request?: Request;
|
|
} & Awaited<ReturnType<typeof EventsService.createInTransaction>>;
|
|
|
|
export type RowApiErrorCode =
|
|
| "entity-not-found"
|
|
| "row-not-found"
|
|
| "row-access-denied"
|
|
| "row-update-denied"
|
|
| "row-delete-denied"
|
|
| "permission-unknown"
|
|
| "permission-missing"
|
|
| "usage-limit-disabled";
|
|
|
|
export class RowApiError extends Error {
|
|
readonly code: RowApiErrorCode;
|
|
readonly status: number;
|
|
readonly details?: Record<string, unknown>;
|
|
|
|
constructor(code: RowApiErrorCode, status = 400, details?: Record<string, unknown>) {
|
|
super(`RowApiError:${code}`);
|
|
this.name = "RowApiError";
|
|
this.code = code;
|
|
this.status = status;
|
|
this.details = details;
|
|
}
|
|
}
|
|
|
|
function rowApiError(code: RowApiErrorCode, status = 400, details?: Record<string, unknown>): RowApiError {
|
|
return new RowApiError(code, status, details);
|
|
}
|
|
|
|
function assertRowEntity(entity: EntityWithDetails | undefined): asserts entity is EntityWithDetails {
|
|
if (!entity) {
|
|
throw rowApiError("entity-not-found", 404);
|
|
}
|
|
}
|
|
|
|
function shouldCreateRowEvent(options: { createEvent?: boolean } | undefined, request: Request | undefined, eventContext: RowEventContext | undefined): boolean {
|
|
return options?.createEvent !== false && Boolean(request || eventContext?.source === "workflow");
|
|
}
|
|
|
|
function getRowEventRequest(request: Request | undefined, eventContext: RowEventContext | undefined): Request | undefined {
|
|
return request ?? eventContext?.request;
|
|
}
|
|
|
|
async function buildReadableRowsWhere({
|
|
tenantId,
|
|
userId,
|
|
includePublic,
|
|
}: {
|
|
tenantId?: string | null;
|
|
userId?: string;
|
|
includePublic?: boolean;
|
|
}): Promise<Prisma.RowWhereInput> {
|
|
const conditions: Prisma.RowWhereInput[] = [];
|
|
if (userId) {
|
|
const userPermissions = (await getRowPermissionsCondition({ tenantId, userId })) as Prisma.RowWhereInput;
|
|
if (Array.isArray(userPermissions.OR)) {
|
|
conditions.push(...userPermissions.OR);
|
|
} else if (Object.keys(userPermissions).length > 0) {
|
|
conditions.push(userPermissions);
|
|
}
|
|
}
|
|
if (includePublic) {
|
|
conditions.push({ permissions: { some: { public: true } } });
|
|
}
|
|
return conditions.length > 0 ? { OR: conditions } : {};
|
|
}
|
|
|
|
export namespace RowsApi {
|
|
export function isRowApiError(error: unknown): error is RowApiError {
|
|
return error instanceof RowApiError;
|
|
}
|
|
|
|
export type GetRowsData = {
|
|
results: number;
|
|
entity: EntityWithDetails;
|
|
items: RowWithDetails[];
|
|
tags: EntityTag[];
|
|
pagination: PaginationDto;
|
|
views: EntityViewWithDetails[];
|
|
currentView: EntityViewWithDetails | null;
|
|
filterableProperties?: FilterablePropertyDto[];
|
|
counts: { [key: string]: number };
|
|
};
|
|
export async function getAll({
|
|
entity,
|
|
tenantId,
|
|
userId,
|
|
urlSearchParams,
|
|
pageSize,
|
|
includePublic,
|
|
entityView,
|
|
rowWhere,
|
|
time,
|
|
}: {
|
|
entity: { id?: string; name?: string };
|
|
tenantId?: string | null;
|
|
userId?: string;
|
|
urlSearchParams?: URLSearchParams;
|
|
pageSize?: number;
|
|
includePublic?: boolean;
|
|
entityView?: EntityViewWithDetails;
|
|
rowWhere?: Prisma.RowWhereInput;
|
|
time?: TimeFunction;
|
|
}): Promise<GetRowsData> {
|
|
if (!time) {
|
|
time = timeFake;
|
|
}
|
|
if (!urlSearchParams) {
|
|
urlSearchParams = new URLSearchParams();
|
|
}
|
|
const rowEntity: EntityWithDetails | undefined = await time(getEntityByIdOrName({ tenantId, ...entity }), "RowsApi.getAll.1.getEntityByIdOrName");
|
|
assertRowEntity(rowEntity);
|
|
await time(validateEntityPermission({ userId, entity: rowEntity, tenantId, name: "view" }), "RowsApi.getAll.2.validateEntityPermission");
|
|
const views = await time(
|
|
getEntityViews(rowEntity.id, {
|
|
tenantId,
|
|
userId,
|
|
}),
|
|
"RowsApi.getAll.3.getEntityViews"
|
|
);
|
|
let currentView =
|
|
entityView ??
|
|
(await time(EntityViewsApi.getCurrentEntityView(rowEntity.id, urlSearchParams, { tenantId, userId }), "RowsApi.getAll.4.getCurrentEntityView"));
|
|
|
|
const currentPagination = getPaginationFromCurrentUrl(urlSearchParams);
|
|
let filters = getEntityFiltersFromCurrentUrl(true, rowEntity, urlSearchParams, currentView);
|
|
if (!pageSize) {
|
|
pageSize = Constants.DEFAULT_PAGE_SIZE;
|
|
const urlPageSize = Number(urlSearchParams.get("pageSize"));
|
|
if (currentView && currentView?.pageSize > 0) {
|
|
pageSize = currentView.pageSize;
|
|
}
|
|
if (urlPageSize !== 0) {
|
|
pageSize = urlPageSize;
|
|
}
|
|
}
|
|
const orderBy: Prisma.RowOrderByWithRelationInput[] = [{ order: "desc" }, { createdAt: "desc" }];
|
|
const skip = pageSize === -1 ? undefined : pageSize * (currentPagination.page - 1);
|
|
const take = pageSize === -1 ? undefined : pageSize;
|
|
const readableRowsWhere = await time(buildReadableRowsWhere({ tenantId, userId, includePublic }), "RowsApi.getAll.readableRowsWhere");
|
|
// Phase 04 #48 — Meilisearch hot-path. When a freetext query is
|
|
// present and we have a tenant scope, ask Meilisearch (with SQL
|
|
// fallback inside `searchCrmContacts`/`searchRows`) for the matching
|
|
// row IDs and swap the legacy OR-on-properties LIKE chain with an
|
|
// `id IN (...)`.
|
|
// The fallback path inside the helper reproduces the legacy SQL
|
|
// semantics 1:1 when MEILI_URL is unset or the service is down.
|
|
let rowIdFilter: { id: { in: string[] } } | undefined;
|
|
if (filters.query && tenantId) {
|
|
const ids =
|
|
rowEntity.name === "contact"
|
|
? (
|
|
await time(
|
|
searchCrmContacts({ tenantId, query: filters.query, limit: 1000 }),
|
|
"RowsApi.getAll.searchCrmContacts"
|
|
)
|
|
).hits.map((hit) => hit.id)
|
|
: (
|
|
await time(
|
|
searchRows({ tenantId, entityId: rowEntity.id, query: filters.query, limit: 1000 }),
|
|
"RowsApi.getAll.searchRows"
|
|
)
|
|
).hits.map((hit) => hit.id);
|
|
rowIdFilter = { id: { in: ids } };
|
|
// Suppress the legacy OR-on-properties chain so it doesn't double-up.
|
|
filters = { ...filters, query: "" };
|
|
}
|
|
const queryWhere = buildLegacyRowQueryWhere({
|
|
entityId: entity.id,
|
|
entityName: entity.name,
|
|
tenantId,
|
|
rowWhere: rowIdFilter ? { ...(rowWhere ?? {}), ...rowIdFilter } : rowWhere,
|
|
filtersWhere: {
|
|
AND: [RowFiltersHelper.getRowFiltersCondition(filters), readableRowsWhere],
|
|
},
|
|
includePublic,
|
|
});
|
|
const { items, totalItems } = await time(
|
|
promiseHash({
|
|
items: listLegacyRows({ where: queryWhere, take, skip, orderBy, tenantId }),
|
|
totalItems: countLegacyRows({ where: queryWhere }),
|
|
}),
|
|
"RowsApi.getAll.rows"
|
|
);
|
|
const pagination: PaginationDto = {
|
|
totalItems,
|
|
totalPages: pageSize === -1 ? 1 : Math.ceil(totalItems / pageSize),
|
|
page: currentPagination.page,
|
|
pageSize,
|
|
sortedBy: currentPagination.sortedBy,
|
|
query: undefined,
|
|
};
|
|
|
|
const { tags } = await time(
|
|
promiseHash({
|
|
tags: getEntityTags(rowEntity.id),
|
|
}),
|
|
"RowsApi.getAll.5.details"
|
|
);
|
|
let counts: { [key: string]: number } = {};
|
|
items.forEach((item) => cleanDuplicatedNestedRows(item));
|
|
const data: GetRowsData = {
|
|
results: items.length,
|
|
entity: rowEntity,
|
|
pagination,
|
|
items,
|
|
tags,
|
|
views,
|
|
currentView,
|
|
// filterableProperties,
|
|
counts,
|
|
};
|
|
return data;
|
|
}
|
|
|
|
export type GetRowData = {
|
|
entity: EntityWithDetails;
|
|
item: RowWithDetails;
|
|
logs: LogWithDetails[];
|
|
comments: RowCommentWithDetails[];
|
|
tasks: RowTaskWithDetails[];
|
|
tags: RowTagWithDetails[];
|
|
permissions: RowPermission[];
|
|
rowPermissions: RowPermissionsDto;
|
|
};
|
|
export async function get(
|
|
id: string,
|
|
{
|
|
entity,
|
|
tenantId,
|
|
userId,
|
|
includePublic,
|
|
time,
|
|
}: {
|
|
entity: { id?: string; name?: string } | EntityWithDetails;
|
|
tenantId?: string | null;
|
|
userId?: string;
|
|
includePublic?: boolean;
|
|
time?: TimeFunction;
|
|
}
|
|
): Promise<GetRowData> {
|
|
if (!time) {
|
|
time = timeFake;
|
|
}
|
|
let rowEntity: EntityWithDetails | undefined;
|
|
// if entity is not type of EntityWithDetails, then get it from id or name
|
|
if (!entity.id || !entity.name) {
|
|
rowEntity = await time(getEntityByIdOrName({ tenantId, ...entity }), "RowsApi.get.getEntityByIdOrName");
|
|
} else {
|
|
rowEntity = entity as EntityWithDetails;
|
|
}
|
|
if (!rowEntity) {
|
|
throw rowApiError("entity-not-found", 404);
|
|
}
|
|
await time(validateEntityPermission({ userId, entity: rowEntity, tenantId, name: "read" }), "RowsApi.get.validateEntityPermission");
|
|
const item = await time(getLegacyRow({ entityId: rowEntity.id, id, where: buildLegacyRowQueryWhere({ tenantId }), tenantId }), "RowsApi.get.getRow");
|
|
if (!item) {
|
|
const existing = await time(getLegacyRowById(id), "RowsApi.get.getRowById");
|
|
if (!existing || existing?.entityId !== entity.id || (tenantId !== undefined && existing?.tenantId !== tenantId)) {
|
|
throw rowApiError("row-not-found", 404);
|
|
}
|
|
throw rowApiError("row-not-found", 404);
|
|
}
|
|
const publicPermissions = includePublic ? await time(getRowPermissions(item.id), "RowsApi.get.getPublicRowPermissions") : undefined;
|
|
const isPublicRow = publicPermissions?.some((permission) => permission.public) ?? false;
|
|
const rowPermissions = await time(getUserRowPermission(item, tenantId, userId), "RowsApi.get.getUserRowPermission");
|
|
if (!rowPermissions.canRead && !isPublicRow) {
|
|
throw rowApiError("row-access-denied", 403);
|
|
}
|
|
const { logs, tags, comments, tasks, permissions } = await time(
|
|
promiseHash({
|
|
logs: getRowLogs(item.tenantId, item.id),
|
|
tags: getRowTags(item.id),
|
|
comments: getRowComments(item.id),
|
|
tasks: getRowTasks(item.id),
|
|
permissions: publicPermissions ? Promise.resolve(publicPermissions) : getRowPermissions(item.id),
|
|
}),
|
|
"RowsApi.get.details"
|
|
);
|
|
cleanDuplicatedNestedRows(item);
|
|
|
|
await EntitiesApi.fillSystemProperties({ entity: rowEntity, tenantId, userId });
|
|
const data: GetRowData = {
|
|
item,
|
|
entity: rowEntity,
|
|
logs,
|
|
tags,
|
|
comments,
|
|
tasks,
|
|
permissions,
|
|
rowPermissions,
|
|
};
|
|
return data;
|
|
}
|
|
|
|
export type GetRelationshipRowsData = {
|
|
relationship: EntityRelationshipWithDetails;
|
|
rows: RowWithDetails[];
|
|
}[];
|
|
export async function getRelationshipRows({
|
|
entity,
|
|
tenantId,
|
|
userId,
|
|
time,
|
|
}: {
|
|
entity: EntityWithDetails;
|
|
tenantId: string | null;
|
|
userId?: string;
|
|
time?: TimeFunction;
|
|
}) {
|
|
if (!time) {
|
|
time = timeFake;
|
|
}
|
|
const relationshipRows: GetRelationshipRowsData = [];
|
|
await time(
|
|
Promise.all(
|
|
entity.parentEntities
|
|
.filter((relationship) => RelationshipHelper.getInputType({ fromEntityId: entity.id, relationship }) === "single-select")
|
|
.map((parent) =>
|
|
RowsApi.getAll({ entity: { id: parent.parentId }, tenantId, userId, urlSearchParams: new URLSearchParams(), pageSize: 100 }).then((rowsData) => {
|
|
relationshipRows.push({ relationship: parent, rows: rowsData.items });
|
|
})
|
|
)
|
|
),
|
|
"RowsApi.getRelationshipRows.getParentEntities"
|
|
);
|
|
|
|
await time(
|
|
Promise.all(
|
|
entity.childEntities
|
|
.filter((relationship) => RelationshipHelper.getInputType({ fromEntityId: entity.id, relationship }) === "single-select")
|
|
.map(async (child) => {
|
|
const rowsData = await RowsApi.getAll({
|
|
entity: { id: child.childId },
|
|
tenantId,
|
|
userId,
|
|
urlSearchParams: new URLSearchParams(),
|
|
pageSize: 100,
|
|
});
|
|
relationshipRows.push({ relationship: child, rows: rowsData.items });
|
|
})
|
|
),
|
|
"RowsApi.getRelationshipRows.getChildEntities"
|
|
);
|
|
|
|
return relationshipRows;
|
|
}
|
|
|
|
export type CreateRowActionData = RowWithDetails;
|
|
type CreateRowInput = {
|
|
entity: { id?: string; name?: string } | EntityWithDetails;
|
|
tenantId: string | null;
|
|
userId?: string;
|
|
rowValues?: {
|
|
dynamicProperties: RowValueDto[];
|
|
properties?: any;
|
|
parentRows?: { relationshipId: string; parentId: string }[];
|
|
childRows?: { relationshipId: string; childId: string }[];
|
|
};
|
|
rowCreateInput?: Partial<Prisma.RowCreateInput>;
|
|
nextFolio?: number;
|
|
nextOrder?: number;
|
|
options?: {
|
|
checkUsage: boolean;
|
|
createLog: boolean;
|
|
createEvent: boolean;
|
|
storeMedia: boolean;
|
|
reportUsage: boolean;
|
|
};
|
|
eventContext?: RowEventContext;
|
|
};
|
|
export async function create({
|
|
entity,
|
|
tenantId,
|
|
userId,
|
|
rowValues,
|
|
rowCreateInput,
|
|
nextOrder,
|
|
nextFolio,
|
|
time,
|
|
options,
|
|
eventContext,
|
|
request,
|
|
}: CreateRowInput & {
|
|
time?: TimeFunction;
|
|
request?: Request;
|
|
}): Promise<CreateRowActionData> {
|
|
if (!time) {
|
|
time = timeFake;
|
|
}
|
|
let rowEntity: EntityWithDetails | undefined;
|
|
if (!entity.id || !entity.name) {
|
|
rowEntity = await time(getEntityByIdOrName({ tenantId, ...entity }), "RowsApi.get.getEntityByIdOrName");
|
|
} else {
|
|
rowEntity = entity as EntityWithDetails;
|
|
}
|
|
if (options?.checkUsage === undefined || options?.checkUsage) {
|
|
assertRowEntity(rowEntity);
|
|
if (tenantId) {
|
|
const usage = await time(getPlanFeatureUsage(tenantId, rowEntity.name), "RowsApi.create.getPlanFeatureUsage");
|
|
if (usage && !usage.enabled) {
|
|
throw rowApiError("usage-limit-disabled", 403);
|
|
}
|
|
}
|
|
}
|
|
assertRowEntity(rowEntity);
|
|
await time(validateEntityPermission({ userId, entity: rowEntity, tenantId, name: "create" }), "RowsApi.create.validateEntityPermission");
|
|
let rowCreatedEvent: RecordedRowEvent<"row.created", RowCreatedDto> | undefined;
|
|
const createdRow = await time(
|
|
db.$transaction(async (tx) => {
|
|
const rowClient = tx as unknown as RowCreateClient;
|
|
const item = await createRowWithValues(
|
|
{
|
|
entityId: rowEntity.id,
|
|
data: {
|
|
tenantId,
|
|
createdByUserId: userId,
|
|
dynamicProperties: rowValues?.dynamicProperties as RowMutationValue,
|
|
properties: rowValues?.properties,
|
|
parentRows: rowValues?.parentRows,
|
|
childRows: rowValues?.childRows,
|
|
rowCreateInput,
|
|
},
|
|
nextFolio,
|
|
nextOrder,
|
|
},
|
|
rowClient
|
|
);
|
|
|
|
const createdRow = await getLegacyRowById(item.id, rowClient, tenantId);
|
|
if (!createdRow) {
|
|
throw rowApiError("row-not-found", 404);
|
|
}
|
|
|
|
if (shouldCreateRowEvent(options, request, eventContext)) {
|
|
const eventRequest = getRowEventRequest(request, eventContext);
|
|
const data = {
|
|
id: createdRow.id,
|
|
title: RowHelper.getTextDescription({ entity: rowEntity, item: createdRow }),
|
|
row: ApiHelper.getApiFormat(rowEntity, createdRow),
|
|
entity: { id: rowEntity.id, name: rowEntity.name, slug: rowEntity.slug, title: rowEntity.title },
|
|
visibility: "",
|
|
...(await getRowUserOrApiKey({ userId: createdRow.createdByUserId, apiKeyId: createdRow.createdByApiKeyId })),
|
|
// permissions: getRowPermissionsObjects(permissions),
|
|
} satisfies RowCreatedDto;
|
|
rowCreatedEvent = {
|
|
request: eventRequest,
|
|
event: "row.created",
|
|
tenantId,
|
|
userId: userId ?? null,
|
|
data,
|
|
...(await EventsService.createInTransaction(tx as Parameters<typeof EventsService.createInTransaction>[0], {
|
|
request: eventRequest,
|
|
event: "row.created",
|
|
tenantId,
|
|
userId: userId ?? null,
|
|
data,
|
|
})),
|
|
};
|
|
}
|
|
|
|
return createdRow;
|
|
}),
|
|
"RowsApi.create.createRow"
|
|
);
|
|
|
|
if (rowCreatedEvent) {
|
|
await EventsService.dispatchPostCommitSideEffects(rowCreatedEvent);
|
|
}
|
|
|
|
if (entity && createdRow) {
|
|
if (options?.storeMedia === undefined || options?.storeMedia === true) {
|
|
await storeRowMediaInStorageProvider(rowEntity, createdRow);
|
|
}
|
|
if (options?.reportUsage === undefined || options?.reportUsage === true) {
|
|
if (tenantId) {
|
|
await reportUsage(tenantId, rowEntity.name);
|
|
}
|
|
}
|
|
await RowPermissionsApi.shareWithDefault(createdRow, rowEntity.defaultVisibility as DefaultVisibility);
|
|
}
|
|
|
|
if (options?.createLog === undefined || options?.createLog) {
|
|
await time(
|
|
createManualRowLog({ tenantId, createdByUserId: userId, action: DefaultLogActions.Created, entity: rowEntity, item: createdRow }),
|
|
"RowsApi.create.createManualRowLog"
|
|
);
|
|
}
|
|
// Phase 06 #22 — emit `entity.created` plugin hook. Fire-and-forget.
|
|
await emitPluginHookFireAndForget(
|
|
PLUGIN_HOOK_ENTITY_CREATED,
|
|
{
|
|
entityType: rowEntity.name,
|
|
entityId: createdRow.id,
|
|
tenantId: tenantId ?? null,
|
|
userId: userId ?? null,
|
|
occurredAt: new Date().toISOString(),
|
|
},
|
|
{ tenantId: tenantId ?? null, dispatchKey: `entity:${rowEntity.name}:${createdRow.id}:created` }
|
|
);
|
|
// Phase 04 #48 — emit search-index outbox event. Fire-and-forget;
|
|
// see modules/rows/services/rowSearchOutbox.server.ts.
|
|
await emitRowSearchUpsert({ tenantId: tenantId ?? null, entity: rowEntity, row: createdRow });
|
|
return createdRow;
|
|
}
|
|
|
|
export async function createSimple({
|
|
entity,
|
|
tenantId,
|
|
userId,
|
|
rowValues,
|
|
rowCreateInput,
|
|
nextOrder,
|
|
nextFolio,
|
|
time,
|
|
}: CreateRowInput & {
|
|
time?: TimeFunction;
|
|
}) {
|
|
if (!time) {
|
|
time = timeFake;
|
|
}
|
|
let rowEntity: EntityWithDetails | undefined;
|
|
if (!entity.id || !entity.name) {
|
|
rowEntity = await time(getEntityByIdOrName({ tenantId, ...entity }), "RowsApi.get.getEntityByIdOrName");
|
|
} else {
|
|
rowEntity = entity as EntityWithDetails;
|
|
}
|
|
assertRowEntity(rowEntity);
|
|
const item = await time(
|
|
createRowWithValues({
|
|
entityId: rowEntity.id,
|
|
data: {
|
|
tenantId,
|
|
createdByUserId: userId,
|
|
dynamicProperties: rowValues?.dynamicProperties as RowMutationValue,
|
|
properties: rowValues?.properties,
|
|
parentRows: rowValues?.parentRows,
|
|
childRows: rowValues?.childRows,
|
|
rowCreateInput,
|
|
},
|
|
nextFolio,
|
|
nextOrder,
|
|
}),
|
|
"RowsApi.create.createRow"
|
|
);
|
|
const createdRow = await getLegacyRowById(item.id, undefined, tenantId);
|
|
if (!createdRow) {
|
|
throw rowApiError("row-not-found", 404);
|
|
}
|
|
|
|
await RowPermissionsApi.shareWithDefault(createdRow, rowEntity.defaultVisibility as DefaultVisibility);
|
|
|
|
// Phase 04 #48 — emit search-index outbox event. Fire-and-forget.
|
|
await emitRowSearchUpsert({ tenantId: tenantId ?? null, entity: rowEntity, row: createdRow });
|
|
return createdRow;
|
|
}
|
|
|
|
export type EditRowData = RowWithDetails;
|
|
export async function update(
|
|
id: string,
|
|
{
|
|
entity,
|
|
request,
|
|
tenantId,
|
|
userId,
|
|
rowValues,
|
|
rowUpdateInput,
|
|
checkPermissions = true,
|
|
time,
|
|
options,
|
|
eventContext,
|
|
}: {
|
|
entity: { id?: string; name?: string } | EntityWithDetails;
|
|
request?: Request;
|
|
tenantId: string | null;
|
|
userId?: string;
|
|
rowValues: {
|
|
dynamicProperties: RowValueDto[];
|
|
properties?: any;
|
|
parentRows?: { relationshipId: string; parentId: string }[];
|
|
childRows?: { relationshipId: string; childId: string }[];
|
|
};
|
|
rowUpdateInput?: Prisma.RowUpdateInput;
|
|
checkPermissions?: boolean;
|
|
time?: TimeFunction;
|
|
options?: {
|
|
checkUsage?: boolean;
|
|
createEvent?: boolean;
|
|
createLog?: boolean;
|
|
};
|
|
eventContext?: RowEventContext;
|
|
}
|
|
): Promise<CreateRowActionData> {
|
|
if (!time) {
|
|
time = timeFake;
|
|
}
|
|
const rowEntity = await time(getEntityByIdOrName({ tenantId, ...entity }), "RowsApi.update.getEntityByIdOrName");
|
|
assertRowEntity(rowEntity);
|
|
const row = await time(getLegacyRowById(id, undefined, tenantId), "RowsApi.update.getRowById");
|
|
if (!row || row.entityId !== rowEntity.id || row.tenantId !== tenantId) {
|
|
throw rowApiError("row-not-found", 404);
|
|
}
|
|
if (checkPermissions) {
|
|
const { permissions } = await time(
|
|
promiseHash({
|
|
permissions: getUserRowPermission(row!, tenantId, userId),
|
|
entityPermission: validateEntityPermission({ userId, entity: rowEntity, tenantId, name: "update" }),
|
|
}),
|
|
"RowsApi.update.permissions"
|
|
);
|
|
|
|
if (userId !== undefined && !permissions.canUpdate) {
|
|
throw rowApiError("row-update-denied", 403);
|
|
}
|
|
}
|
|
let rowUpdatedEvent: RecordedRowEvent<"row.updated", RowUpdatedDto> | undefined;
|
|
const item = await time(
|
|
db.$transaction(async (tx) => {
|
|
const updated = await updateRowWithValuesInTransaction(
|
|
{ id, entityId: rowEntity.id, tenantId },
|
|
{
|
|
dynamicProperties: rowValues.dynamicProperties as RowMutationValue,
|
|
properties: rowValues.properties,
|
|
parentRows: rowValues.parentRows,
|
|
childRows: rowValues.childRows,
|
|
rowUpdateInput,
|
|
},
|
|
tx as unknown as RowUpdateClient
|
|
);
|
|
|
|
if (shouldCreateRowEvent(options, request, eventContext)) {
|
|
const eventRequest = getRowEventRequest(request, eventContext);
|
|
const data = {
|
|
id: updated.id,
|
|
title: RowHelper.getTextDescription({ entity: rowEntity, item: updated }),
|
|
entity: { id: rowEntity.id, name: rowEntity.name, slug: rowEntity.slug, title: rowEntity.title },
|
|
row: RowHelper.getDiff({ entity: rowEntity, before: row, after: updated }),
|
|
...(await getRowUserOrApiKey({ userId })),
|
|
} satisfies RowUpdatedDto;
|
|
rowUpdatedEvent = {
|
|
request: eventRequest,
|
|
event: "row.updated",
|
|
tenantId,
|
|
userId: userId ?? null,
|
|
data,
|
|
...(await EventsService.createInTransaction(tx as Parameters<typeof EventsService.createInTransaction>[0], {
|
|
request: eventRequest,
|
|
event: "row.updated",
|
|
tenantId,
|
|
userId: userId ?? null,
|
|
data,
|
|
})),
|
|
};
|
|
}
|
|
|
|
return updated;
|
|
}),
|
|
"RowsApi.update.updateRowWithValues"
|
|
);
|
|
if (rowUpdatedEvent) {
|
|
await EventsService.dispatchPostCommitSideEffects(rowUpdatedEvent);
|
|
}
|
|
// await FormulaService.trigger({ trigger: "ON_UPDATE", rows: item ? [item] : [], entity: rowEntity, session: { tenantId, userId } });
|
|
if (options?.createLog === undefined || options?.createLog) {
|
|
await time(
|
|
createManualRowLog({ tenantId, createdByUserId: userId, action: DefaultLogActions.Updated, entity: rowEntity, item }),
|
|
"RowsApi.update.createManualRowLog"
|
|
);
|
|
}
|
|
// Phase 06 #22 — emit `entity.updated` plugin hook. Fire-and-forget.
|
|
await emitPluginHookFireAndForget(
|
|
PLUGIN_HOOK_ENTITY_UPDATED,
|
|
{
|
|
entityType: rowEntity.name,
|
|
entityId: id,
|
|
tenantId: tenantId ?? null,
|
|
userId: userId ?? null,
|
|
changedFields: rowValues.dynamicProperties?.map((p) => p.property?.name).filter((s): s is string => typeof s === "string") ?? [],
|
|
occurredAt: new Date().toISOString(),
|
|
},
|
|
{ tenantId: tenantId ?? null }
|
|
);
|
|
// Phase 04 #48 — emit search-index outbox event. Fire-and-forget.
|
|
await emitRowSearchUpsert({ tenantId: tenantId ?? null, entity: rowEntity, row: item });
|
|
return item;
|
|
}
|
|
|
|
export async function del(
|
|
id: string,
|
|
{
|
|
entity,
|
|
request,
|
|
tenantId,
|
|
userId,
|
|
checkPermissions = true,
|
|
time,
|
|
options,
|
|
eventContext,
|
|
}: {
|
|
entity: { id?: string; name?: string };
|
|
request?: Request;
|
|
tenantId?: string | null;
|
|
userId?: string;
|
|
checkPermissions?: boolean;
|
|
time?: TimeFunction;
|
|
options?: {
|
|
createEvent?: boolean;
|
|
};
|
|
eventContext?: RowEventContext;
|
|
}
|
|
) {
|
|
if (!time) {
|
|
time = timeFake;
|
|
}
|
|
const rowEntity = await time(getEntityByIdOrName({ tenantId, ...entity }), "RowsApi.del.getEntityByIdOrName");
|
|
assertRowEntity(rowEntity);
|
|
const row = await time(getLegacyRowById(id, undefined, tenantId), "RowsApi.del.getRowById");
|
|
if (!row || row.entityId !== rowEntity.id || row.tenantId !== tenantId) {
|
|
throw rowApiError("row-not-found", 404);
|
|
}
|
|
if (checkPermissions) {
|
|
const { permissions } = await time(
|
|
promiseHash({
|
|
permissions: getUserRowPermission(row!, tenantId, userId),
|
|
entityPermission: validateEntityPermission({ userId, entity: rowEntity, tenantId, name: "delete" }),
|
|
}),
|
|
"RowsApi.del.permissions"
|
|
);
|
|
if (!permissions.canDelete) {
|
|
if (!tenantId || !userId) {
|
|
throw rowApiError("row-delete-denied", 403);
|
|
} else {
|
|
const isSuperUser = await getUserRoleInTenant(userId, tenantId, DefaultAppRoles.SuperUser);
|
|
if (!isSuperUser) {
|
|
throw rowApiError("row-delete-denied", 403);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (row) {
|
|
for (const child of row.childRows) {
|
|
const relationship = rowEntity.childEntities.find((f) => f.id === child.relationshipId);
|
|
if (relationship?.cascade) {
|
|
await time(
|
|
del(child.childId, { entity: { id: relationship.childId }, tenantId, userId, checkPermissions, options: { createEvent: false } }),
|
|
"RowsApi.del.childRows"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
await time(
|
|
createManualRowLog({ tenantId: row?.tenantId ?? null, createdByUserId: userId, action: DefaultLogActions.Deleted, entity: rowEntity, item: row }),
|
|
"RowsApi.del.createManualRowLog"
|
|
);
|
|
let rowDeletedEvent: RecordedRowEvent<"row.deleted", RowDeletedDto> | undefined;
|
|
const deleted = await time(
|
|
db.$transaction(async (tx) => {
|
|
const deleted = await hardDeleteRowById({ id, entityId: rowEntity.id, tenantId: row.tenantId ?? null }, tx as unknown as RowDeleteClient);
|
|
|
|
if (shouldCreateRowEvent(options, request, eventContext)) {
|
|
const eventRequest = getRowEventRequest(request, eventContext);
|
|
const data = {
|
|
id: row.id,
|
|
title: RowHelper.getTextDescription({ entity: rowEntity, item: row }),
|
|
row: ApiHelper.getApiFormat(rowEntity, row),
|
|
entity: { id: rowEntity.id, name: rowEntity.name, slug: rowEntity.slug, title: rowEntity.title },
|
|
...(await getRowUserOrApiKey({ userId })),
|
|
} satisfies RowDeletedDto;
|
|
rowDeletedEvent = {
|
|
request: eventRequest,
|
|
event: "row.deleted",
|
|
tenantId,
|
|
userId: userId ?? null,
|
|
data,
|
|
...(await EventsService.createInTransaction(tx as Parameters<typeof EventsService.createInTransaction>[0], {
|
|
request: eventRequest,
|
|
event: "row.deleted",
|
|
tenantId,
|
|
userId: userId ?? null,
|
|
data,
|
|
})),
|
|
};
|
|
}
|
|
|
|
return deleted;
|
|
}),
|
|
"RowsApi.del.hardDeleteRowById"
|
|
);
|
|
if (rowDeletedEvent) {
|
|
await EventsService.dispatchPostCommitSideEffects(rowDeletedEvent);
|
|
}
|
|
// Phase 06 #22 — emit `entity.deleted` plugin hook. Fire-and-forget.
|
|
await emitPluginHookFireAndForget(
|
|
PLUGIN_HOOK_ENTITY_DELETED,
|
|
{
|
|
entityType: rowEntity.name,
|
|
entityId: id,
|
|
tenantId: row.tenantId ?? null,
|
|
userId: userId ?? null,
|
|
occurredAt: new Date().toISOString(),
|
|
},
|
|
{ tenantId: row.tenantId ?? null, dispatchKey: `entity:${rowEntity.name}:${id}:deleted` }
|
|
);
|
|
// Phase 04 #48 — emit search-index delete outbox event. Fire-and-forget.
|
|
await emitRowSearchDelete({ tenantId: row.tenantId ?? null, entity: { name: rowEntity.name }, rowId: id });
|
|
return deleted;
|
|
}
|
|
|
|
export async function count({
|
|
entity,
|
|
tenantId,
|
|
rowWhere,
|
|
includePublic,
|
|
time,
|
|
}: {
|
|
entity: { id?: string; name?: string };
|
|
tenantId: string | null | undefined;
|
|
userId?: string;
|
|
rowWhere?: Prisma.RowWhereInput;
|
|
includePublic?: boolean;
|
|
time?: TimeFunction;
|
|
}): Promise<number> {
|
|
if (!time) {
|
|
time = timeFake;
|
|
}
|
|
const rowEntity = await time(getEntityByIdOrName({ tenantId, ...entity }), "RowsApi.count.getEntityByIdOrName");
|
|
assertRowEntity(rowEntity);
|
|
return await time(
|
|
countLegacyRows({ where: buildLegacyRowQueryWhere({ entityId: rowEntity.id, tenantId, rowWhere, includePublic }) }),
|
|
"RowsApi.count.countRows"
|
|
);
|
|
}
|
|
|
|
export async function getTasks({
|
|
completed,
|
|
assignedOrCreatedUserId,
|
|
tenantId,
|
|
time,
|
|
}: {
|
|
completed?: boolean;
|
|
assignedOrCreatedUserId?: string;
|
|
tenantId?: string | null;
|
|
time?: TimeFunction;
|
|
}) {
|
|
if (!time) {
|
|
time = timeFake;
|
|
}
|
|
return await time(getAllRowTasks({ completed, assignedOrCreatedUserId, tenantId }), "RowsApi.getTasks.getAllRowTasks");
|
|
}
|
|
|
|
export async function find({
|
|
entity,
|
|
tenantId,
|
|
properties,
|
|
time,
|
|
}: {
|
|
entity: { id?: string; name?: string };
|
|
tenantId?: string | null;
|
|
properties: { name: string; value: string | null }[];
|
|
time?: TimeFunction;
|
|
}) {
|
|
if (!time) {
|
|
time = timeFake;
|
|
}
|
|
const rowEntity = await time(getEntityByIdOrName({ tenantId, ...entity }), "RowsApi.find.getEntityByIdOrName");
|
|
assertRowEntity(rowEntity);
|
|
const rows = await time(
|
|
listLegacyRows({
|
|
where: buildLegacyRowQueryWhere({
|
|
entityId: rowEntity.id,
|
|
tenantId,
|
|
filtersWhere: buildLegacyRowPropertyFiltersWhere(
|
|
properties.map((f) => ({
|
|
property: rowEntity.properties.find((p) => p.name === f.name),
|
|
value: f.value,
|
|
condition: "equals",
|
|
}))
|
|
),
|
|
}),
|
|
}),
|
|
"RowsApi.find.listLegacyRows"
|
|
);
|
|
|
|
if (rows.length === 0) {
|
|
return null;
|
|
}
|
|
return rows[0];
|
|
}
|
|
|
|
export async function findMany({
|
|
entity,
|
|
tenantId,
|
|
properties,
|
|
time,
|
|
}: {
|
|
entity: { id?: string; name?: string };
|
|
tenantId?: string | null;
|
|
properties: { name: string; value: string | null }[];
|
|
time?: TimeFunction;
|
|
}) {
|
|
if (!time) {
|
|
time = timeFake;
|
|
}
|
|
const rowEntity = await time(getEntityByIdOrName({ tenantId, ...entity }), "RowsApi.findMany.getEntityByIdOrName");
|
|
assertRowEntity(rowEntity);
|
|
const rows = await time(
|
|
listLegacyRows({
|
|
where: buildLegacyRowQueryWhere({
|
|
entityId: rowEntity.id,
|
|
tenantId,
|
|
filtersWhere: buildLegacyRowPropertyFiltersWhere(
|
|
properties.map((f) => ({
|
|
property: rowEntity.properties.find((p) => p.name === f.name),
|
|
value: f.value,
|
|
condition: "equals",
|
|
match: "or",
|
|
}))
|
|
),
|
|
}),
|
|
}),
|
|
"RowsApi.findMany.listLegacyRows"
|
|
);
|
|
|
|
return rows;
|
|
}
|
|
|
|
export async function addTag({ row, tag, time }: { row: Row; tag: { value: string; color?: Colors }; time?: TimeFunction }) {
|
|
if (!time) {
|
|
time = timeFake;
|
|
}
|
|
let existingTag = await time(getEntityTag(row.entityId, tag.value), "RowsApi.addTag.getEntityTag");
|
|
if (!existingTag) {
|
|
existingTag = await time(
|
|
createEntityTag({
|
|
entityId: row.entityId,
|
|
value: tag.value,
|
|
color: tag.color ?? Colors.GRAY,
|
|
}),
|
|
"RowsApi.addTag.createEntityTag"
|
|
);
|
|
}
|
|
return await time(
|
|
createRowTag({
|
|
rowId: row.id,
|
|
tagId: existingTag.id,
|
|
}),
|
|
"RowsApi.addTag.createRowTag"
|
|
);
|
|
}
|
|
|
|
export async function removeTag({ row, tag, time }: { row: Row; tag: string; time?: TimeFunction }) {
|
|
if (!time) {
|
|
time = timeFake;
|
|
}
|
|
const entityTag = await time(getEntityTag(row.entityId, tag), "RowsApi.removeTag.getEntityTag");
|
|
if (entityTag) {
|
|
const rowTag = await time(getRowTagByIds(row.id, entityTag.id), "RowsApi.removeTag.getRowTagByIds");
|
|
if (rowTag) {
|
|
return await time(deleteRowTags(row.id, entityTag.id), "RowsApi.removeTag.deleteRowTags");
|
|
}
|
|
}
|
|
}
|
|
|
|
async function validateEntityPermission({
|
|
userId,
|
|
entity,
|
|
tenantId,
|
|
name,
|
|
}: {
|
|
entity: { name: string };
|
|
name: "view" | "read" | "create" | "update" | "delete";
|
|
userId?: string;
|
|
tenantId?: string | null;
|
|
}) {
|
|
if (userId && tenantId) {
|
|
const permissionName = getEntityPermission(entity, name);
|
|
const { permission, userPermission } = await getUserPermission({ userId, permissionName, tenantId });
|
|
if (!permission) {
|
|
throw rowApiError("permission-unknown", 403, { permissionName });
|
|
}
|
|
if (!userPermission) {
|
|
throw rowApiError("permission-missing", 403, { permissionName });
|
|
}
|
|
}
|
|
}
|
|
export async function createCustom({
|
|
request,
|
|
entity,
|
|
t,
|
|
form,
|
|
row,
|
|
tenantId,
|
|
userId,
|
|
rowCreateInput,
|
|
time,
|
|
}: {
|
|
entity: EntityWithDetails;
|
|
tenantId: string | null;
|
|
userId?: string;
|
|
t: TFunction;
|
|
form: FormData;
|
|
row: RowWithDetails | undefined;
|
|
rowCreateInput?: Partial<Prisma.RowCreateInput> | undefined;
|
|
time?: TimeFunction;
|
|
request: Request;
|
|
}) {
|
|
if (!time) {
|
|
time = timeFake;
|
|
}
|
|
const rowValues = RowHelper.getRowPropertiesFromForm({ t, entity, form, existing: row });
|
|
if (!row) {
|
|
await time(
|
|
RowsApi.create({
|
|
entity,
|
|
tenantId,
|
|
userId,
|
|
rowValues,
|
|
rowCreateInput,
|
|
request,
|
|
}),
|
|
"RowsApi.createCustom.create"
|
|
);
|
|
} else {
|
|
await time(
|
|
RowsApi.update(row.id, {
|
|
entity,
|
|
tenantId,
|
|
userId,
|
|
rowValues,
|
|
}),
|
|
"RowsApi.createCustom.update"
|
|
);
|
|
}
|
|
}
|
|
export async function changeOrder(
|
|
{ id, entityId, tenantId }: { id: string; entityId: string; tenantId: string | null },
|
|
{
|
|
target,
|
|
time,
|
|
}: {
|
|
target: "up" | "down";
|
|
time?: TimeFunction;
|
|
}
|
|
) {
|
|
if (!time) {
|
|
time = timeFake;
|
|
}
|
|
await time(changeLegacyRowOrder({ id, entityId, tenantId }, target), "RowsApi.changeOrder.changeLegacyRowOrder");
|
|
}
|
|
function cleanDuplicatedNestedRows(originalRow: RowWithDetails) {
|
|
const originalId = originalRow.id;
|
|
function cleanRows(items: RowDto[]) {
|
|
items.forEach((item) => {
|
|
if (item.id === originalId) {
|
|
item.values = [];
|
|
}
|
|
if ("childRows" in item) {
|
|
cleanRows(item.childRows?.map((f) => f.child) ?? []);
|
|
}
|
|
if ("parentRows" in item) {
|
|
cleanRows(item.parentRows?.map((f) => f.parent) ?? []);
|
|
}
|
|
});
|
|
}
|
|
cleanRows(originalRow.childRows.map((f) => f.child));
|
|
cleanRows(originalRow.parentRows.map((f) => f.parent));
|
|
}
|
|
}
|