Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/utils/api/.server/RowsApi.ts
T

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));
}
}