teableio/teable

View on GitHub
apps/nestjs-backend/src/features/view/open-api/view-open-api.service.ts

Summary

Maintainability
D
2 days
Test Coverage
import {
  BadRequestException,
  Injectable,
  Logger,
  NotFoundException,
  ForbiddenException,
} from '@nestjs/common';
import type {
  IOtOperation,
  IViewRo,
  IViewVo,
  IColumnMetaRo,
  IViewPropertyKeys,
  IViewOptions,
  IGridColumnMeta,
  IFilter,
  IFilterItem,
  ILinkFieldOptions,
  IPluginViewOptions,
} from '@teable/core';
import {
  ViewType,
  IManualSortRo,
  ViewOpBuilder,
  generateShareId,
  VIEW_JSON_KEYS,
  validateOptionsType,
  FieldType,
  IdPrefix,
  generatePluginInstallId,
} from '@teable/core';
import { PrismaService } from '@teable/db-main-prisma';
import { PluginPosition, PluginStatus } from '@teable/openapi';
import type {
  IViewPluginUpdateStorageRo,
  IGetViewFilterLinkRecordsVo,
  IUpdateOrderRo,
  IUpdateRecordOrdersRo,
  IViewInstallPluginRo,
  IViewShareMetaRo,
} from '@teable/openapi';
import { Knex } from 'knex';
import { InjectModel } from 'nest-knexjs';
import { ClsService } from 'nestjs-cls';
import { InjectDbProvider } from '../../../db-provider/db.provider';
import { IDbProvider } from '../../../db-provider/db.provider.interface';
import { EventEmitterService } from '../../../event-emitter/event-emitter.service';
import { Events } from '../../../event-emitter/events';
import type { IClsStore } from '../../../types/cls';
import { Timing } from '../../../utils/timing';
import { updateMultipleOrders, updateOrder } from '../../../utils/update-order';
import { FieldViewSyncService } from '../../field/field-calculate/field-view-sync.service';
import { FieldService } from '../../field/field.service';
import type { IFieldInstance } from '../../field/model/factory';
import { createFieldInstanceByRaw, createFieldInstanceByVo } from '../../field/model/factory';
import { RecordService } from '../../record/record.service';
import { createViewInstanceByRaw } from '../model/factory';
import { ViewService } from '../view.service';

@Injectable()
export class ViewOpenApiService {
  private logger = new Logger(ViewOpenApiService.name);

  constructor(
    private readonly prismaService: PrismaService,
    private readonly recordService: RecordService,
    private readonly viewService: ViewService,
    private readonly fieldService: FieldService,
    private readonly fieldViewSyncService: FieldViewSyncService,
    private readonly eventEmitterService: EventEmitterService,
    private readonly cls: ClsService<IClsStore>,
    @InjectDbProvider() private readonly dbProvider: IDbProvider,
    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex
  ) {}

  async createView(tableId: string, viewRo: IViewRo) {
    if (viewRo.type === ViewType.Plugin) {
      const res = await this.pluginInstall(tableId, {
        name: viewRo.name,
        pluginId: (viewRo.options as IPluginViewOptions).pluginId,
      });
      return this.viewService.getViewById(res.viewId);
    }
    return await this.prismaService.$tx(async () => {
      return this.createViewInner(tableId, viewRo);
    });
  }

  async deleteView(tableId: string, viewId: string) {
    return await this.prismaService.$tx(async () => {
      await this.fieldViewSyncService.deleteLinkOptionsDependenciesByViewId(tableId, viewId);
      return await this.deleteViewInner(tableId, viewId);
    });
  }

  private async createViewInner(tableId: string, viewRo: IViewRo): Promise<IViewVo> {
    return await this.viewService.createView(tableId, viewRo);
  }

  private async deleteViewInner(tableId: string, viewId: string) {
    return await this.viewService.deleteView(tableId, viewId);
  }

  private updateRecordOrderSql(orderRawSql: string, dbTableName: string, indexField: string) {
    return this.knex
      .raw(
        `
        UPDATE :dbTableName:
        SET :indexField: = temp_order.new_order
        FROM (
          SELECT __id, ROW_NUMBER() OVER (ORDER BY ${orderRawSql}) AS new_order FROM :dbTableName:
        ) AS temp_order
        WHERE :dbTableName:.__id = temp_order.__id AND :dbTableName:.:indexField: != temp_order.new_order;
      `,
        {
          dbTableName,
          indexField,
        }
      )
      .toQuery();
  }

  @Timing()
  async manualSort(tableId: string, viewId: string, viewOrderRo: IManualSortRo) {
    const { sortObjs } = viewOrderRo;
    const dbTableName = await this.recordService.getDbTableName(tableId);
    const fields = await this.fieldService.getFieldsByQuery(tableId, { viewId });
    const indexField = await this.viewService.getOrCreateViewIndexField(dbTableName, viewId);

    const queryBuilder = this.knex(dbTableName);

    const fieldInsMap = fields.reduce(
      (map, field) => {
        map[field.id] = createFieldInstanceByVo(field);
        return map;
      },
      {} as Record<string, IFieldInstance>
    );

    const orderRawSql = this.dbProvider
      .sortQuery(queryBuilder, fieldInsMap, sortObjs)
      .getRawSortSQLText();

    // build ops
    const newSort = {
      sortObjs: sortObjs,
      manualSort: true,
    };

    await this.prismaService.$tx(async (prisma) => {
      await prisma.$executeRawUnsafe(
        this.updateRecordOrderSql(orderRawSql, dbTableName, indexField)
      );
      await this.viewService.updateViewSort(tableId, viewId, newSort);
    });
  }

  async updateViewColumnMeta(
    tableId: string,
    viewId: string,
    columnMetaRo: IColumnMetaRo,
    windowId?: string
  ) {
    const view = await this.prismaService.view
      .findFirstOrThrow({
        where: { tableId, id: viewId },
        select: {
          columnMeta: true,
          version: true,
          id: true,
          type: true,
        },
      })
      .catch(() => {
        throw new BadRequestException('view found column meta error');
      });

    // validate field legal
    const fields = await this.prismaService.field.findMany({
      where: { tableId, deletedTime: null },
      select: {
        id: true,
        isPrimary: true,
      },
    });
    const primaryFields = fields.filter((field) => field.isPrimary).map((field) => field.id);

    const isHiddenPrimaryField = columnMetaRo.some(
      (f) => primaryFields.includes(f.fieldId) && (f.columnMeta as IGridColumnMeta).hidden
    );
    const fieldIds = columnMetaRo.map(({ fieldId }) => fieldId);

    if (!fieldIds.every((id) => fields.map(({ id }) => id).includes(id))) {
      throw new BadRequestException('field is not found in table');
    }

    const allowHiddenPrimaryType = [ViewType.Calendar, ViewType.Form];
    /**
     * validate whether hidden primary field
     * only form view or list view(todo) can hidden primary field
     */
    if (isHiddenPrimaryField && !allowHiddenPrimaryType.includes(view.type as ViewType)) {
      throw new ForbiddenException('primary field can not be hidden');
    }

    const curColumnMeta = JSON.parse(view.columnMeta);
    const ops: IOtOperation[] = [];

    columnMetaRo.forEach(({ fieldId, columnMeta }) => {
      const obj = {
        fieldId,
        newColumnMeta: { ...curColumnMeta[fieldId], ...columnMeta },
        oldColumnMeta: curColumnMeta[fieldId] ? curColumnMeta[fieldId] : undefined,
      };
      ops.push(ViewOpBuilder.editor.updateViewColumnMeta.build(obj));
    });

    await this.updateViewByOps(tableId, viewId, ops);

    if (windowId) {
      this.eventEmitterService.emitAsync(Events.OPERATION_VIEW_UPDATE, {
        tableId,
        windowId,
        viewId,
        userId: this.cls.get('user.id'),
        byOps: ops,
      });
    }
  }

  async updateShareMeta(tableId: string, viewId: string, viewShareMetaRo: IViewShareMetaRo) {
    return this.setViewProperty(tableId, viewId, 'shareMeta', viewShareMetaRo);
  }

  async setViewProperty(
    tableId: string,
    viewId: string,
    key: IViewPropertyKeys,
    newValue: unknown,
    windowId?: string
  ) {
    const curView = await this.prismaService.view
      .findFirstOrThrow({
        select: { [key]: true },
        where: { tableId, id: viewId, deletedTime: null },
      })
      .catch(() => {
        throw new BadRequestException('View not found');
      });
    const oldValue =
      curView[key] != null && VIEW_JSON_KEYS.includes(key)
        ? JSON.parse(curView[key])
        : curView[key];
    const ops = ViewOpBuilder.editor.setViewProperty.build({
      key,
      newValue,
      oldValue,
    });

    await this.updateViewByOps(tableId, viewId, [ops]);

    if (windowId) {
      this.eventEmitterService.emitAsync(Events.OPERATION_VIEW_UPDATE, {
        tableId,
        windowId,
        viewId,
        userId: this.cls.get('user.id'),
        byKey: {
          key,
          newValue,
          oldValue,
        },
      });
    }
  }

  async updateViewByOps(tableId: string, viewId: string, ops: IOtOperation[]) {
    return await this.prismaService.$tx(async () => {
      return await this.viewService.updateViewByOps(tableId, viewId, ops);
    });
  }

  async patchViewOptions(
    tableId: string,
    viewId: string,
    viewOptions: IViewOptions,
    windowId?: string
  ) {
    const curView = await this.prismaService.view
      .findFirstOrThrow({
        select: { options: true, type: true },
        where: { tableId, id: viewId, deletedTime: null },
      })
      .catch(() => {
        throw new BadRequestException('View option not found');
      });
    const { options, type: viewType } = curView;

    // validate option type
    try {
      validateOptionsType(viewType as ViewType, viewOptions);
    } catch (err) {
      throw new BadRequestException(err);
    }

    const oldOptions = options ? JSON.parse(options) : options;
    const op = ViewOpBuilder.editor.setViewProperty.build({
      key: 'options',
      newValue: {
        ...oldOptions,
        ...viewOptions,
      },
      oldValue: oldOptions,
    });
    await this.updateViewByOps(tableId, viewId, [op]);

    if (windowId) {
      this.eventEmitterService.emitAsync(Events.OPERATION_VIEW_UPDATE, {
        tableId,
        windowId,
        viewId,
        userId: this.cls.get('user.id'),
        byOps: [op],
      });
    }
  }

  /**
   * shuffle view order
   */
  async shuffle(tableId: string) {
    const views = await this.prismaService.view.findMany({
      where: { tableId, deletedTime: null },
      select: { id: true, order: true },
      orderBy: { order: 'asc' },
    });

    this.logger.log(`lucky view shuffle! ${tableId}`, 'shuffle');

    await this.prismaService.$tx(async () => {
      for (let i = 0; i < views.length; i++) {
        const view = views[i];
        await this.viewService.updateViewByOps(tableId, view.id, [
          ViewOpBuilder.editor.setViewProperty.build({
            key: 'order',
            newValue: i,
            oldValue: view.order,
          }),
        ]);
      }
    });
  }

  async updateViewOrder(
    tableId: string,
    viewId: string,
    orderRo: IUpdateOrderRo,
    windowId?: string
  ) {
    const { anchorId, position } = orderRo;

    const view = await this.prismaService.view
      .findFirstOrThrow({
        select: { order: true, id: true },
        where: { tableId, id: viewId, deletedTime: null },
      })
      .catch(() => {
        throw new NotFoundException(`View ${viewId} not found in the table`);
      });

    const anchorView = await this.prismaService.view
      .findFirstOrThrow({
        select: { order: true, id: true },
        where: { tableId, id: anchorId, deletedTime: null },
      })
      .catch(() => {
        throw new NotFoundException(`Anchor ${anchorId} not found in the table`);
      });

    await updateOrder({
      query: tableId,
      position,
      item: view,
      anchorItem: anchorView,
      getNextItem: async (whereOrder, align) => {
        return this.prismaService.view.findFirst({
          select: { order: true, id: true },
          where: {
            tableId,
            deletedTime: null,
            order: whereOrder,
          },
          orderBy: { order: align },
        });
      },
      update: async (
        parentId: string,
        id: string,
        data: { newOrder: number; oldOrder: number }
      ) => {
        const op = ViewOpBuilder.editor.setViewProperty.build({
          key: 'order',
          newValue: data.newOrder,
          oldValue: data.oldOrder,
        });
        await this.updateViewByOps(parentId, id, [op]);

        if (windowId) {
          this.eventEmitterService.emitAsync(Events.OPERATION_VIEW_UPDATE, {
            tableId,
            windowId,
            viewId,
            userId: this.cls.get('user.id'),
            byOps: [op],
          });
        }
      },
      shuffle: this.shuffle.bind(this),
    });
  }

  /**
   * shuffle record order
   */
  async shuffleRecords(dbTableName: string, indexField: string) {
    const recordCount = await this.recordService.getAllRecordCount(dbTableName);
    if (recordCount > 100_000) {
      throw new BadRequestException('Not enough gap to move the row here');
    }

    const sql = this.updateRecordOrderSql(
      this.knex.raw(`?? ASC`, [indexField]).toQuery(),
      dbTableName,
      indexField
    );

    await this.prismaService.$executeRawUnsafe(sql);
  }

  async updateRecordOrdersInner(props: {
    tableId: string;
    dbTableName: string;
    itemLength: number;
    indexField: string;
    orderRo: {
      anchorId: string;
      position: 'before' | 'after';
    };
    update: (indexes: number[]) => Promise<void>;
  }) {
    const { tableId, itemLength, dbTableName, indexField, orderRo, update } = props;
    const { anchorId, position } = orderRo;

    const anchorRecordSql = this.knex(dbTableName)
      .select({
        id: '__id',
        order: indexField,
      })
      .where('__id', anchorId)
      .toQuery();

    const anchorRecord = await this.prismaService
      .txClient()
      .$queryRawUnsafe<{ id: string; order: number }[]>(anchorRecordSql)
      .then((res) => {
        return res[0];
      });

    if (!anchorRecord) {
      throw new NotFoundException(`Anchor ${anchorId} not found in the table`);
    }

    await updateMultipleOrders({
      parentId: tableId,
      position,
      itemLength,
      anchorItem: anchorRecord,
      getNextItem: async (whereOrder, align) => {
        const nextRecordSql = this.knex(dbTableName)
          .select({
            id: '__id',
            order: indexField,
          })
          .where(
            indexField,
            whereOrder.lt != null ? '<' : '>',
            (whereOrder.lt != null ? whereOrder.lt : whereOrder.gt) as number
          )
          .orderBy(indexField, align)
          .limit(1)
          .toQuery();
        return this.prismaService
          .txClient()
          .$queryRawUnsafe<{ id: string; order: number }[]>(nextRecordSql)
          .then((res) => {
            return res[0];
          });
      },
      update,
      shuffle: async () => {
        await this.shuffleRecords(dbTableName, indexField);
      },
    });
  }

  async updateRecordIndexes(
    tableId: string,
    viewId: string,
    recordsWithOrder: {
      id: string;
      order?: Record<string, number>;
    }[]
  ) {
    // for notify view update only
    await this.prismaService.$tx(async () => {
      const ops = ViewOpBuilder.editor.setViewProperty.build({
        key: 'lastModifiedTime',
        newValue: new Date().toISOString(),
      });
      await this.viewService.updateViewByOps(tableId, viewId, [ops]);
      await this.recordService.updateRecordIndexes(tableId, recordsWithOrder);
    });
  }

  async updateRecordOrders(
    tableId: string,
    viewId: string,
    orderRo: IUpdateRecordOrdersRo,
    windowId?: string
  ) {
    const recordIds = orderRo.recordIds;
    const dbTableName = await this.recordService.getDbTableName(tableId);
    const orderIndexesBefore = windowId
      ? await this.recordService.getRecordIndexes(tableId, recordIds, viewId)
      : undefined;

    const indexField = await this.viewService.getOrCreateViewIndexField(dbTableName, viewId);

    await this.updateRecordOrdersInner({
      tableId,
      dbTableName,
      itemLength: recordIds.length,
      indexField,
      orderRo,
      update: async (indexes) => {
        // for notify view update only
        const ops = ViewOpBuilder.editor.setViewProperty.build({
          key: 'lastModifiedTime',
          newValue: new Date().toISOString(),
        });

        await this.prismaService.$tx(async (prisma) => {
          await this.viewService.updateViewByOps(tableId, viewId, [ops]);
          for (let i = 0; i < recordIds.length; i++) {
            const recordId = recordIds[i];
            const updateRecordSql = this.knex(dbTableName)
              .update({
                [indexField]: indexes[i],
              })
              .where('__id', recordId)
              .toQuery();
            await prisma.$executeRawUnsafe(updateRecordSql);
          }
        });
      },
    });

    if (windowId) {
      const orderIndexesAfter = await this.recordService.getRecordIndexes(
        tableId,
        recordIds,
        viewId
      );
      this.eventEmitterService.emitAsync(Events.OPERATION_RECORDS_ORDER_UPDATE, {
        tableId,
        windowId,
        recordIds,
        viewId,
        userId: this.cls.get('user.id'),
        orderIndexesBefore,
        orderIndexesAfter,
      });
    }
  }

  async refreshShareId(tableId: string, viewId: string) {
    const view = await this.prismaService.view.findUnique({
      where: { id: viewId, tableId, deletedTime: null },
      select: { shareId: true, enableShare: true },
    });
    if (!view) {
      throw new NotFoundException(`View ${viewId} does not exist`);
    }
    const { enableShare } = view;
    if (!enableShare) {
      throw new BadRequestException(`View ${viewId} has not been enabled share`);
    }
    const newShareId = generateShareId();
    const setShareIdOp = ViewOpBuilder.editor.setViewProperty.build({
      key: 'shareId',
      newValue: newShareId,
      oldValue: view.shareId || undefined,
    });
    await this.updateViewByOps(tableId, viewId, [setShareIdOp]);
    return { shareId: newShareId };
  }

  async enableShare(tableId: string, viewId: string) {
    const view = await this.prismaService.view.findUnique({
      where: { id: viewId, tableId, deletedTime: null },
    });
    if (!view) {
      throw new NotFoundException(`View ${viewId} does not exist`);
    }
    const { enableShare, shareId } = view;
    if (enableShare) {
      throw new BadRequestException(`View ${viewId} has already been enabled share`);
    }
    const newShareId = generateShareId();
    const enableShareOp = ViewOpBuilder.editor.setViewProperty.build({
      key: 'enableShare',
      newValue: true,
      oldValue: enableShare || undefined,
    });
    const setShareIdOp = ViewOpBuilder.editor.setViewProperty.build({
      key: 'shareId',
      newValue: newShareId,
      oldValue: shareId || undefined,
    });

    const ops = [enableShareOp, setShareIdOp];

    const viewInstance = createViewInstanceByRaw(view);
    if (!view.shareMeta && viewInstance.defaultShareMeta) {
      const initShareMetaOp = ViewOpBuilder.editor.setViewProperty.build({
        key: 'shareMeta',
        newValue: viewInstance.defaultShareMeta,
      });
      ops.push(initShareMetaOp);
    }
    await this.updateViewByOps(tableId, viewId, ops);
    return { shareId: newShareId };
  }

  async disableShare(tableId: string, viewId: string) {
    const view = await this.prismaService.view.findUnique({
      where: { id: viewId, tableId, deletedTime: null },
      select: { shareId: true, enableShare: true, shareMeta: true },
    });
    if (!view) {
      throw new NotFoundException(`View ${viewId} does not exist`);
    }
    const { enableShare } = view;
    if (!enableShare) {
      throw new BadRequestException(`View ${viewId} has already been disable share`);
    }
    const enableShareOp = ViewOpBuilder.editor.setViewProperty.build({
      key: 'enableShare',
      newValue: false,
      oldValue: enableShare || undefined,
    });

    await this.updateViewByOps(tableId, viewId, [enableShareOp]);
  }

  /**
   * @param linkFields {fieldId: foreignTableId}
   * @returns {foreignTableId: Set<recordId>}
   */
  private async collectFilterLinkFieldRecords(
    linkFields: Record<string, string>,
    filter?: IFilter
  ) {
    if (!filter || !filter.filterSet) {
      return undefined;
    }

    const tableRecordMap: Record<string, Set<string>> = {};

    const mergeRecordMap = (source: Record<string, Set<string>> = {}) => {
      for (const [fieldId, recordSet] of Object.entries(source)) {
        tableRecordMap[fieldId] = tableRecordMap[fieldId] || new Set();
        recordSet.forEach((item) => tableRecordMap[fieldId].add(item));
      }
    };

    for (const filterItem of filter.filterSet) {
      if ('filterSet' in filterItem) {
        const groupTableRecordMap = await this.collectFilterLinkFieldRecords(
          linkFields,
          filterItem as IFilter
        );
        if (groupTableRecordMap) {
          mergeRecordMap(groupTableRecordMap);
        }
        continue;
      }

      const { value, fieldId } = filterItem as IFilterItem;

      const foreignTableId = linkFields[fieldId];
      if (!foreignTableId) {
        continue;
      }

      if (Array.isArray(value)) {
        mergeRecordMap({ [foreignTableId]: new Set(value as string[]) });
      } else if (typeof value === 'string' && value.startsWith(IdPrefix.Record)) {
        mergeRecordMap({ [foreignTableId]: new Set([value]) });
      }
    }

    return tableRecordMap;
  }

  async getFilterLinkRecords(tableId: string, viewId: string) {
    const view = await this.viewService.getViewById(viewId);
    return this.getFilterLinkRecordsByTable(tableId, view.filter);
  }

  async getFilterLinkRecordsByTable(tableId: string, filter?: IFilter) {
    if (!filter) {
      return [];
    }
    const linkFields = await this.prismaService.field.findMany({
      where: { tableId, deletedTime: null, type: FieldType.Link },
    });
    const linkFieldTableMap = linkFields.reduce(
      (map, field) => {
        const { foreignTableId } = JSON.parse(field.options as string) as ILinkFieldOptions;
        map[field.id] = foreignTableId;
        return map;
      },
      {} as Record<string, string>
    );
    const tableRecordMap = await this.collectFilterLinkFieldRecords(linkFieldTableMap, filter);

    if (!tableRecordMap) {
      return [];
    }
    const res: IGetViewFilterLinkRecordsVo = [];
    for (const [foreignTableId, recordSet] of Object.entries(tableRecordMap)) {
      const dbTableName = await this.recordService.getDbTableName(foreignTableId);
      const primaryField = await this.prismaService.field.findFirst({
        where: { tableId: foreignTableId, isPrimary: true, deletedTime: null },
      });
      if (!primaryField) {
        continue;
      }

      const dbFieldName = primaryField.dbFieldName;

      const nativeQuery = this.knex(dbTableName)
        .select('__id as id', `${dbFieldName} as title`)
        .orderBy('__auto_number')
        .whereIn('__id', Array.from(recordSet))
        .toQuery();

      const list = await this.prismaService
        .txClient()
        .$queryRawUnsafe<{ id: string; title: string | null }[]>(nativeQuery);
      const fieldInstances = createFieldInstanceByRaw(primaryField);
      res.push({
        tableId: foreignTableId,
        records: list.map(({ id, title }) => ({
          id,
          title:
            fieldInstances.cellValue2String(fieldInstances.convertDBValue2CellValue(title)) ||
            undefined,
        })),
      });
    }
    return res;
  }

  async pluginInstall(tableId: string, ro: IViewInstallPluginRo) {
    const userId = this.cls.get('user.id');
    const { name, pluginId } = ro;
    const plugin = await this.prismaService.plugin.findUnique({
      where: { id: pluginId, status: PluginStatus.Published },
      select: { id: true, name: true, logo: true, positions: true },
    });
    if (!plugin) {
      throw new NotFoundException(`Plugin ${pluginId} not found`);
    }
    if (!plugin.positions.includes(PluginPosition.View)) {
      throw new BadRequestException(`Plugin ${pluginId} does not support install in view`);
    }
    const viewName = name || plugin.name;
    return this.prismaService.$tx(async (prisma) => {
      const pluginInstallId = generatePluginInstallId();
      const view = await this.createViewInner(tableId, {
        name: viewName,
        type: ViewType.Plugin,
        options: {
          pluginInstallId,
          pluginId,
          pluginLogo: plugin.logo,
        } as IPluginViewOptions,
      });
      const table = await prisma.tableMeta.findUniqueOrThrow({
        where: { id: tableId, deletedTime: null },
        select: { baseId: true },
      });
      const newPlugin = await prisma.pluginInstall.create({
        data: {
          id: pluginInstallId,
          baseId: table?.baseId,
          positionId: view.id,
          position: PluginPosition.View,
          name: viewName,
          pluginId: ro.pluginId,
          createdBy: userId,
        },
      });
      return {
        pluginId: newPlugin.pluginId,
        pluginInstallId: newPlugin.id,
        name: newPlugin.name,
        viewId: view.id,
      };
    });
  }

  async updatePluginStorage(viewId: string, storage: IViewPluginUpdateStorageRo['storage']) {
    const pluginInstall = await this.prismaService.pluginInstall.findFirst({
      where: { positionId: viewId, position: PluginPosition.View },
      select: { id: true },
    });
    if (!pluginInstall) {
      throw new NotFoundException(`Plugin install not found`);
    }
    return this.prismaService.pluginInstall.update({
      where: { id: pluginInstall.id },
      data: { storage: JSON.stringify(storage) },
    });
  }

  async getPluginInstall(tableId: string, viewId: string) {
    const table = await this.prismaService.tableMeta.findUniqueOrThrow({
      where: { id: tableId, deletedTime: null },
      select: { baseId: true },
    });
    const pluginInstall = await this.prismaService.pluginInstall.findFirst({
      where: { positionId: viewId, position: PluginPosition.View },
      select: {
        id: true,
        pluginId: true,
        name: true,
        storage: true,
        plugin: {
          select: { url: true },
        },
      },
    });
    if (!pluginInstall) {
      throw new NotFoundException(`Plugin install not found`);
    }
    return {
      name: pluginInstall.name,
      pluginId: pluginInstall.pluginId,
      pluginInstallId: pluginInstall.id,
      storage: pluginInstall.storage ? JSON.parse(pluginInstall.storage) : undefined,
      baseId: table.baseId,
      url: pluginInstall.plugin.url || undefined,
    };
  }
}