diff --git a/apps/nestjs-backend/src/features/notification/notification.service.ts b/apps/nestjs-backend/src/features/notification/notification.service.ts index 11f30c8314..715beec867 100644 --- a/apps/nestjs-backend/src/features/notification/notification.service.ts +++ b/apps/nestjs-backend/src/features/notification/notification.service.ts @@ -36,6 +36,11 @@ type INotifyEmailConfig = { buttonText?: string | ILocalization; }; +function toArray(value?: T | T[]): T[] { + if (!value) return []; + return Array.isArray(value) ? value : [value]; +} + const notificationListLimit = 10; const notificationListSelect = { @@ -63,6 +68,7 @@ export class NotificationService { [NotificationTypeEnum.CollaboratorMultiRowTag]: MailType.CollaboratorMultiRowTag, [NotificationTypeEnum.Comment]: MailType.Common, [NotificationTypeEnum.ExportBase]: MailType.ExportBase, + [NotificationTypeEnum.AdminNotice]: MailType.System, }; constructor( private readonly prismaService: PrismaService, @@ -304,91 +310,118 @@ export class NotificationService { async sendCommonNotify( params: { - path: string; + path?: string; fromUserId?: string; - toUserId: string; + toUserId?: string | string[]; + toEmail?: string | string[]; message: string | ILocalization; severity?: NotificationSeverityEnum; emailConfig?: INotifyEmailConfig; }, type = NotificationTypeEnum.System - ) { - const { toUserId, emailConfig, path, fromUserId = SYSTEM_USER_ID } = params; - const notifyId = generateNotificationId(); - const toUser = await this.userService.getUserById(toUserId); - if (!toUser) { - return; + ): Promise<{ + sentCount: number; + invalidUserIds?: string[]; + invalidEmails?: string[]; + }> { + const { emailConfig, path = '', fromUserId = SYSTEM_USER_ID } = params; + const ids = toArray(params.toUserId); + const emails = toArray(params.toEmail); + + const toUsers = await this.userService.getUsersByIdsOrEmails({ ids, emails }); + + const invalidUserIds = ids.length + ? ids.filter((id) => !toUsers.some((u) => u.id === id)) + : undefined; + const invalidEmails = emails.length + ? emails.filter((e) => !toUsers.some((u) => u.email.toLowerCase() === e.toLowerCase())) + : undefined; + + if (toUsers.length === 0) { + return { sentCount: 0, invalidUserIds, invalidEmails }; } const severity = params.severity ?? this.getNotificationSeverity(type); const messageI18n = this.getMessageI18n(params.message); - const data: Prisma.NotificationCreateInput = { - id: notifyId, - fromUserId: fromUserId, - toUserId, - type, - urlPath: path, - createdBy: fromUserId, - message: this.getMessage(params.message, 'en'), - messageI18n, - severity, - }; - const notifyData = await this.createNotify(data); - - const unreadCount = (await this.unreadCount(toUser.id)).unreadCount; + const messageEn = this.getMessage(params.message, 'en'); const rawUsers = await this.prismaService.user.findMany({ select: { id: true, name: true, avatar: true }, where: { id: fromUserId }, }); const fromUserSets = keyBy(rawUsers, 'id'); + const notifyIcon = this.generateNotifyIcon(type, fromUserId, fromUserSets); - const systemNotifyIcon = this.generateNotifyIcon( - notifyData.type as NotificationTypeEnum, + const createdTime = new Date(); + const notifyRecords = toUsers.map((toUser) => ({ + id: generateNotificationId(), fromUserId, - fromUserSets - ); - - const socketNotification = { - notification: { - id: notifyData.id, - message: notifyData.message, - messageI18n: notifyData.messageI18n, - notifyType: type, - url: path, - notifyIcon: systemNotifyIcon, - severity, - isRead: false, - createdTime: notifyData.createdTime.toISOString(), - }, - unreadCount: unreadCount, - }; - - this.sendNotifyBySocket(toUser.id, socketNotification); + toUserId: toUser.id, + type, + urlPath: path, + createdBy: fromUserId, + message: messageEn, + messageI18n, + severity, + createdTime, + })); - if (emailConfig && toUser.notifyMeta && toUser.notifyMeta.email) { - const lang = this.getUserLang(toUser.lang); - const emailOptions = await this.mailSenderService.commonEmailOptions({ - ...emailConfig, - title: this.getMessage(emailConfig.title, lang), - message: this.getMessage(emailConfig.message, lang), - to: toUserId, - buttonUrl: emailConfig.buttonUrl || this.mailConfig.origin + path, - buttonText: emailConfig.buttonText - ? this.getMessage(emailConfig.buttonText, lang) - : this.i18n.t('common.email.templates.notify.buttonText'), - }); - this.mailSenderService.sendMail( - { - to: toUser.email, - ...emailOptions, + const toUserIdList = toUsers.map((u) => u.id); + const unreadCounts = await this.prismaService.notification.groupBy({ + by: ['toUserId'], + where: { toUserId: { in: toUserIdList }, isRead: false }, + _count: { _all: true }, + }); + const unreadCountMap = new Map(unreadCounts.map((r) => [r.toUserId, r._count._all])); + + await this.prismaService.notification.createMany({ data: notifyRecords }); + + const notifyById = keyBy(notifyRecords, 'toUserId'); + for (const toUser of toUsers) { + const record = notifyById[toUser.id]; + const unreadCount = (unreadCountMap.get(toUser.id) ?? 0) + 1; + + this.sendNotifyBySocket(toUser.id, { + notification: { + id: record.id, + message: messageEn, + messageI18n, + notifyType: type, + url: path, + notifyIcon: notifyIcon, + severity, + isRead: false, + createdTime: createdTime.toISOString(), }, - { - type: this.mailTypeMap[type], - transporterName: MailTransporterType.Notify, - } - ); + unreadCount, + }); + + if (emailConfig && toUser.notifyMeta && toUser.notifyMeta.email) { + const lang = this.getUserLang(toUser.lang); + const emailOptions = await this.mailSenderService.commonEmailOptions({ + ...emailConfig, + title: this.getMessage(emailConfig.title, lang), + message: this.getMessage(emailConfig.message, lang), + to: toUser.id, + buttonUrl: emailConfig.buttonUrl || this.mailConfig.origin + path, + buttonText: emailConfig.buttonText + ? this.getMessage(emailConfig.buttonText, lang) + : this.i18n.t('common.email.templates.notify.buttonText'), + }); + this.mailSenderService.sendMail( + { + to: toUser.email, + ...emailOptions, + }, + { + type: this.mailTypeMap[type], + transporterName: MailTransporterType.Notify, + } + ); + } } + + return { sentCount: toUsers.length, invalidUserIds, invalidEmails }; } async sendImportResultNotify(params: { @@ -574,7 +607,7 @@ export class NotificationService { id: v.id, notifyIcon: notifyIcon, notifyType: v.type as NotificationTypeEnum, - url: this.mailConfig.origin + v.urlPath, + url: v.urlPath ? this.mailConfig.origin + v.urlPath : '', message: v.message, messageI18n: v.messageI18n, severity: this.getNotificationSeverity(v.type as NotificationTypeEnum, v.severity), @@ -594,6 +627,7 @@ export class NotificationService { switch (notifyType) { case NotificationTypeEnum.System: case NotificationTypeEnum.ExportBase: + case NotificationTypeEnum.AdminNotice: return { iconUrl: `${origin}/images/favicon/favicon.svg` }; case NotificationTypeEnum.Comment: case NotificationTypeEnum.CollaboratorCellTag: @@ -628,6 +662,7 @@ export class NotificationService { case NotificationTypeEnum.CollaboratorMultiRowTag: case NotificationTypeEnum.ExportBase: case NotificationTypeEnum.System: + case NotificationTypeEnum.AdminNotice: return NotificationSeverityEnum.Info; default: throw assertNever(notifyType); @@ -655,6 +690,8 @@ export class NotificationService { const { downloadUrl } = urlMeta || {}; return downloadUrl as string; } + case NotificationTypeEnum.AdminNotice: + return ''; default: throw assertNever(notifyType); } diff --git a/apps/nestjs-backend/src/features/setting/open-api/admin-open-api.controller.ts b/apps/nestjs-backend/src/features/setting/open-api/admin-open-api.controller.ts index 0a324a717b..9c91ecdf46 100644 --- a/apps/nestjs-backend/src/features/setting/open-api/admin-open-api.controller.ts +++ b/apps/nestjs-backend/src/features/setting/open-api/admin-open-api.controller.ts @@ -1,5 +1,11 @@ -import { Controller, Delete, Get, Param, Patch, Post, Query, Res } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Res } from '@nestjs/common'; +import { + adminSendNotificationRoSchema, + type IAdminSendNotificationRo, + type IAdminSendNotificationVo, +} from '@teable/openapi'; import { Response } from 'express'; +import { ZodValidationPipe } from '../../../zod.validation.pipe'; import { Permissions } from '../../auth/decorators/permissions.decorator'; import { AdminOpenApiService } from './admin-open-api.service'; @@ -37,4 +43,11 @@ export class AdminOpenApiController { async deletePerformanceCache(@Query('key') key?: string) { return await this.adminService.deletePerformanceCache(key); } + + @Post('notification') + async sendNotification( + @Body(new ZodValidationPipe(adminSendNotificationRoSchema)) ro: IAdminSendNotificationRo + ): Promise { + return this.adminService.sendAdminNotification(ro); + } } diff --git a/apps/nestjs-backend/src/features/setting/open-api/admin-open-api.module.ts b/apps/nestjs-backend/src/features/setting/open-api/admin-open-api.module.ts index be3569725e..0b12050c72 100644 --- a/apps/nestjs-backend/src/features/setting/open-api/admin-open-api.module.ts +++ b/apps/nestjs-backend/src/features/setting/open-api/admin-open-api.module.ts @@ -3,6 +3,7 @@ import { MulterModule } from '@nestjs/platform-express'; import multer from 'multer'; import { AttachmentsCropModule } from '../../attachments/attachments-crop.module'; import { StorageModule } from '../../attachments/plugins/storage.module'; +import { NotificationModule } from '../../notification/notification.module'; import { AdminOpenApiController } from './admin-open-api.controller'; import { AdminOpenApiService } from './admin-open-api.service'; @@ -13,6 +14,7 @@ import { AdminOpenApiService } from './admin-open-api.service'; storage: multer.diskStorage({}), }), StorageModule, + NotificationModule, ], controllers: [AdminOpenApiController], exports: [AdminOpenApiService], diff --git a/apps/nestjs-backend/src/features/setting/open-api/admin-open-api.service.ts b/apps/nestjs-backend/src/features/setting/open-api/admin-open-api.service.ts index 6c0f57752f..8d62291c4e 100644 --- a/apps/nestjs-backend/src/features/setting/open-api/admin-open-api.service.ts +++ b/apps/nestjs-backend/src/features/setting/open-api/admin-open-api.service.ts @@ -7,15 +7,20 @@ import { InternalServerErrorException, Logger, } from '@nestjs/common'; +import { NotificationSeverityEnum, NotificationTypeEnum } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; +import type { IAdminSendNotificationRo } from '@teable/openapi'; import { PluginStatus, UploadType } from '@teable/openapi'; import { Response } from 'express'; import { Knex } from 'knex'; import { InjectModel } from 'nest-knexjs'; +import { ClsService } from 'nestjs-cls'; import { PerformanceCacheService } from '../../../performance-cache'; +import type { IClsStore } from '../../../types/cls'; import { Timing } from '../../../utils/timing'; import { AttachmentsCropQueueProcessor } from '../../attachments/attachments-crop.processor'; import StorageAdapter from '../../attachments/plugins/adapter'; +import { NotificationService } from '../../notification/notification.service'; @Injectable() export class AdminOpenApiService { @@ -24,7 +29,9 @@ export class AdminOpenApiService { private readonly prismaService: PrismaService, @InjectModel('CUSTOM_KNEX') private readonly knex: Knex, private readonly attachmentsCropQueueProcessor: AttachmentsCropQueueProcessor, - private readonly performanceCacheService: PerformanceCacheService + private readonly performanceCacheService: PerformanceCacheService, + private readonly notificationService: NotificationService, + private readonly cls: ClsService ) {} async publishPlugin(pluginId: string) { @@ -178,4 +185,20 @@ export class AdminOpenApiService { // eslint-disable-next-line @typescript-eslint/no-explicit-any await this.performanceCacheService.del(key as any); } + + async sendAdminNotification(ro: IAdminSendNotificationRo) { + const fromUserId = this.cls.get('user.id'); + const { message, severity, userIds, emails } = ro; + + return this.notificationService.sendCommonNotify( + { + fromUserId, + toUserId: userIds, + toEmail: emails, + message, + severity, + }, + NotificationTypeEnum.AdminNotice + ); + } } diff --git a/apps/nestjs-backend/src/features/user/user.service.ts b/apps/nestjs-backend/src/features/user/user.service.ts index aedf63cffd..d0105cf40f 100644 --- a/apps/nestjs-backend/src/features/user/user.service.ts +++ b/apps/nestjs-backend/src/features/user/user.service.ts @@ -54,6 +54,23 @@ export class UserService { ); } + async getUsersByIdsOrEmails(params: { ids?: string[]; emails?: string[] }) { + const { ids = [], emails = [] } = params; + const conditions = []; + if (ids.length > 0) conditions.push({ id: { in: ids } }); + if (emails.length > 0) conditions.push({ email: { in: emails.map((e) => e.toLowerCase()) } }); + if (conditions.length === 0) return []; + + const users = await this.prismaService.user.findMany({ + where: { OR: conditions, deletedTime: null }, + }); + return users.map((u) => ({ + ...u, + avatar: u.avatar && getPublicFullStorageUrl(u.avatar), + notifyMeta: u.notifyMeta ? (JSON.parse(u.notifyMeta) as IUserNotifyMeta) : null, + })); + } + async getUserByEmail(email: string) { return await this.prismaService.txClient().user.findUnique({ where: { email: email.toLowerCase(), deletedTime: null }, diff --git a/apps/nestjs-backend/src/features/v2/v2-field-delete-compat.service.spec.ts b/apps/nestjs-backend/src/features/v2/v2-field-delete-compat.service.spec.ts index 50160c5f66..f311252794 100644 --- a/apps/nestjs-backend/src/features/v2/v2-field-delete-compat.service.spec.ts +++ b/apps/nestjs-backend/src/features/v2/v2-field-delete-compat.service.spec.ts @@ -1,7 +1,25 @@ import { ViewOpBuilder } from '@teable/core'; -import { v2DataDbTokens } from '@teable/v2-adapter-db-postgres-pg'; import { describe, expect, it, vi } from 'vitest'; import { V2_FIELD_DELETE_COMPAT_CONTEXT_KEY } from './v2-field-delete-compat.constants'; + +const mockV2Tokens = vi.hoisted(() => ({ + v2DataDbTokens: { + db: Symbol('v2.data.db'), + }, +})); + +vi.mock('@teable/v2-adapter-db-postgres-pg', () => ({ + v2DataDbTokens: mockV2Tokens.v2DataDbTokens, +})); + +vi.mock('./v2-container.service', () => ({ + V2ContainerService: class V2ContainerService {}, +})); + +vi.mock('./v2-view-compat.service', () => ({ + V2ViewCompatService: class V2ViewCompatService {}, +})); + import { V2FieldDeletedCompatProjection } from './v2-field-delete-compat.service'; const createInsertDb = () => { @@ -19,7 +37,7 @@ const createInsertDb = () => { const createV2ContainerService = (db: unknown) => ({ getContainer: vi.fn().mockResolvedValue({ resolve: vi.fn((token: symbol) => { - if (token !== v2DataDbTokens.db) { + if (token !== mockV2Tokens.v2DataDbTokens.db) { throw new Error(`Unexpected token ${String(token)}`); } @@ -58,15 +76,14 @@ describe('V2FieldDeletedCompatProjection', () => { }, }; - const result = await projection.handle( - { - [V2_FIELD_DELETE_COMPAT_CONTEXT_KEY]: compatContext, - } as never, - { - tableId: { toString: () => 'tblCompatTable0001' }, - fieldId: { toString: () => 'fldCompatA00000001' }, - } as never - ); + const executionContext = { + [V2_FIELD_DELETE_COMPAT_CONTEXT_KEY]: compatContext, + } as never; + + const result = await projection.handle(executionContext, { + tableId: { toString: () => 'tblCompatTable0001' }, + fieldId: { toString: () => 'fldCompatA00000001' }, + } as never); expect(result._unsafeUnwrap()).toBeUndefined(); expect(compatContext.completed).toBeUndefined(); @@ -105,21 +122,21 @@ describe('V2FieldDeletedCompatProjection', () => { }, }; - const result = await projection.handle( - { - [V2_FIELD_DELETE_COMPAT_CONTEXT_KEY]: compatContext, - } as never, - { - tableId: { toString: () => 'tblCompatTable0001' }, - fieldId: { toString: () => 'fldCompatA00000001' }, - } as never - ); + const executionContext = { + [V2_FIELD_DELETE_COMPAT_CONTEXT_KEY]: compatContext, + } as never; + + const result = await projection.handle(executionContext, { + tableId: { toString: () => 'tblCompatTable0001' }, + fieldId: { toString: () => 'fldCompatA00000001' }, + } as never); expect(result._unsafeUnwrap()).toBeUndefined(); expect(compatContext.completed).toBe(true); expect(v2ViewCompatService.batchUpdateViewByOps).toHaveBeenCalledWith( 'tblCompatTable0001', - compatContext.frozenFieldOps + compatContext.frozenFieldOps, + executionContext ); expect(db.insertInto).toHaveBeenCalledWith('table_trash'); expect(query.values).toHaveBeenCalledWith({ diff --git a/apps/nestjs-backend/src/features/v2/v2-field-delete-compat.service.ts b/apps/nestjs-backend/src/features/v2/v2-field-delete-compat.service.ts index 141dc99798..01f2da079b 100644 --- a/apps/nestjs-backend/src/features/v2/v2-field-delete-compat.service.ts +++ b/apps/nestjs-backend/src/features/v2/v2-field-delete-compat.service.ts @@ -76,7 +76,8 @@ export class V2FieldDeletedCompatProjection implements IEventHandler 0) { await this.v2ViewCompatService.batchUpdateViewByOps( compatContext.tableId, - compatContext.frozenFieldOps + compatContext.frozenFieldOps, + context ); } diff --git a/apps/nestjs-backend/src/features/v2/v2-view-compat.service.spec.ts b/apps/nestjs-backend/src/features/v2/v2-view-compat.service.spec.ts index ee59d8f1dc..c3bde87f36 100644 --- a/apps/nestjs-backend/src/features/v2/v2-view-compat.service.spec.ts +++ b/apps/nestjs-backend/src/features/v2/v2-view-compat.service.spec.ts @@ -1,20 +1,74 @@ import { IdPrefix, ViewOpBuilder } from '@teable/core'; -import { v2MetaDbTokens } from '@teable/v2-adapter-db-postgres-pg'; import { describe, expect, it, vi } from 'vitest'; + +const mockV2Tokens = vi.hoisted(() => ({ + v2MetaDbTokens: { + db: Symbol('v2.meta.db'), + }, + v2CoreTokens: { + viewOperationPluginRunner: Symbol('v2.core.viewOperationPluginRunner'), + }, +})); + +vi.mock('@teable/v2-adapter-db-postgres-pg', () => ({ + v2MetaDbTokens: mockV2Tokens.v2MetaDbTokens, +})); + +vi.mock('@teable/v2-core', () => ({ + v2CoreTokens: mockV2Tokens.v2CoreTokens, + ViewOperationKind: { + update: 'update', + }, +})); + +vi.mock('./v2-container.service', () => ({ + V2ContainerService: class V2ContainerService {}, +})); + +vi.mock('./v2-execution-context.factory', () => ({ + V2ExecutionContextFactory: class V2ExecutionContextFactory {}, +})); + import { V2ViewCompatService } from './v2-view-compat.service'; -const createV2ContainerService = (db: unknown) => ({ +const createV2ContainerService = (db: unknown, viewOperationPluginRunner: unknown) => ({ getContainer: vi.fn().mockResolvedValue({ resolve: vi.fn((token: symbol) => { - if (token !== v2MetaDbTokens.db) { - throw new Error(`Unexpected token ${String(token)}`); + if (token === mockV2Tokens.v2MetaDbTokens.db) { + return db; } - return db; + if (token === mockV2Tokens.v2CoreTokens.viewOperationPluginRunner) { + return viewOperationPluginRunner; + } + + throw new Error(`Unexpected token ${String(token)}`); }), }), }); +const okResult = (value: T) => ({ + value, + isErr: () => false, +}); + +const errResult = (error: T) => ({ + error, + isErr: () => true, +}); + +const createViewOperationPluginRunner = (guardResult = okResult(undefined)) => { + const guard = vi.fn().mockResolvedValue(guardResult); + const prepare = vi.fn().mockResolvedValue(okResult({ guard })); + return { guard, prepare }; +}; + +const createV2ContextFactory = () => ({ + createContext: vi.fn().mockResolvedValue({ + actorId: { toString: () => 'usrCompatWriter00001' }, + }), +}); + describe('V2ViewCompatService', () => { it('updates matching views through the v2 db and stores raw ops in cls state', async () => { const executeSelect = vi.fn().mockResolvedValue([{ id: 'viwCompat000000001', version: 3 }]); @@ -33,7 +87,9 @@ describe('V2ViewCompatService', () => { selectFrom: vi.fn().mockReturnValue(selectQuery), updateTable: vi.fn().mockReturnValue(updateQuery), }; - const v2ContainerService = createV2ContainerService(db); + const viewOperationPluginRunner = createViewOperationPluginRunner(); + const v2ContainerService = createV2ContainerService(db, viewOperationPluginRunner); + const v2ContextFactory = createV2ContextFactory(); const clsState = new Map(); const cls = { getId: vi.fn().mockReturnValue('cls-request-id'), @@ -48,7 +104,11 @@ describe('V2ViewCompatService', () => { clsState.set(key, value); }), }; - const service = new V2ViewCompatService(v2ContainerService as never, cls as never); + const service = new V2ViewCompatService( + v2ContainerService as never, + cls as never, + v2ContextFactory as never + ); const ops = [ ViewOpBuilder.editor.setViewProperty.build({ key: 'options', @@ -61,6 +121,19 @@ describe('V2ViewCompatService', () => { viwCompat000000001: ops, }); + expect(viewOperationPluginRunner.prepare).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'update', + payload: { + tableId: 'tblCompatTable0001', + viewId: 'viwCompat000000001', + patch: { options: { frozenFieldId: 'fldNewFrozen00001' } }, + }, + }) + ); + expect(viewOperationPluginRunner.guard).toHaveBeenCalledWith( + expect.objectContaining({ actorId: expect.anything() }) + ); expect(db.selectFrom).toHaveBeenCalledWith('view'); expect(db.updateTable).toHaveBeenCalledWith('view'); expect(updateQuery.set).toHaveBeenCalledWith({ @@ -80,4 +153,58 @@ describe('V2ViewCompatService', () => { v: 3, }); }); + + it('rejects view updates when the v2 view operation plugin reports a limit error', async () => { + const executeSelect = vi.fn().mockResolvedValue([{ id: 'viwCompat000000001', version: 3 }]); + const selectQuery = { + where: vi.fn().mockReturnThis(), + select: vi.fn().mockReturnThis(), + execute: executeSelect, + }; + const updateQuery = { + set: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + execute: vi.fn().mockResolvedValue(undefined), + }; + const db = { + selectFrom: vi.fn().mockReturnValue(selectQuery), + updateTable: vi.fn().mockReturnValue(updateQuery), + }; + const limitError = { + code: 'validation.limit.view_options_max_bytes', + message: 'Table data safety limit exceeded: validation.limit.view_options_max_bytes', + details: { attempted: 16, max: 4 }, + }; + const viewOperationPluginRunner = createViewOperationPluginRunner(errResult(limitError)); + const v2ContainerService = createV2ContainerService(db, viewOperationPluginRunner); + const cls = { + getId: vi.fn().mockReturnValue('cls-request-id'), + get: vi.fn().mockReturnValue(undefined), + set: vi.fn(), + }; + const service = new V2ViewCompatService( + v2ContainerService as never, + cls as never, + createV2ContextFactory() as never + ); + const ops = [ + ViewOpBuilder.editor.setViewProperty.build({ + key: 'options', + oldValue: {}, + newValue: { frozenFieldId: 'fldNewFrozen00001' }, + }), + ]; + + await expect( + service.batchUpdateViewByOps('tblCompatTable0001', { + viwCompat000000001: ops, + }) + ).rejects.toMatchObject({ + data: { + domainCode: 'validation.limit.view_options_max_bytes', + details: { attempted: 16, max: 4 }, + }, + }); + expect(db.updateTable).not.toHaveBeenCalled(); + }); }); diff --git a/apps/nestjs-backend/src/features/v2/v2-view-compat.service.ts b/apps/nestjs-backend/src/features/v2/v2-view-compat.service.ts index 10abcda9ec..12adcf0e1d 100644 --- a/apps/nestjs-backend/src/features/v2/v2-view-compat.service.ts +++ b/apps/nestjs-backend/src/features/v2/v2-view-compat.service.ts @@ -9,6 +9,15 @@ import { type ISetViewPropertyOpContext, } from '@teable/core'; import { v2MetaDbTokens } from '@teable/v2-adapter-db-postgres-pg'; +import { + v2CoreTokens, + ViewOperationKind, + type DomainError, + type IExecutionContext, + type ViewOperationPayloadViewConfig, + type ViewOperationPluginContext, + type ViewOperationPluginRunner, +} from '@teable/v2-core'; import type { V1TeableDatabase } from '@teable/v2-postgres-schema'; import type { Kysely } from 'kysely'; import { snakeCase } from 'lodash'; @@ -18,6 +27,7 @@ import { CustomHttpException } from '../../custom.exception'; import type { IRawOp, IRawOpMap } from '../../share-db/interface'; import type { IClsStore } from '../../types/cls'; import { V2ContainerService } from './v2-container.service'; +import { V2ExecutionContextFactory } from './v2-execution-context.factory'; /* eslint-disable @typescript-eslint/naming-convention */ type IV2ViewCompatDb = V1TeableDatabase & { @@ -43,16 +53,20 @@ type IV2ViewCompatDb = V1TeableDatabase & { export class V2ViewCompatService { constructor( private readonly v2ContainerService: V2ContainerService, - private readonly cls: ClsService + private readonly cls: ClsService, + private readonly v2ContextFactory: V2ExecutionContextFactory ) {} - private async getDb(): Promise> { - const container = await this.v2ContainerService.getContainer(); - return container.resolve>(v2MetaDbTokens.db); + private throwDomainError(error: DomainError): never { + throw new CustomHttpException(error.message, HttpErrorCode.VALIDATION_ERROR, { + domainCode: error.code, + domainTags: error.tags, + details: error.details, + }); } private mergeSetViewPropertyByOpContexts(opContexts: ISetViewPropertyOpContext[]) { - const result: Record = {}; + const result: Record = {}; for (const opContext of opContexts) { const { key, newValue } = opContext; const parseResult = viewVoSchema.partial().safeParse({ [key]: newValue }); @@ -69,12 +83,7 @@ export class V2ViewCompatService { } const parsedValue = parseResult.data[key]; - result[key] = - parsedValue == null - ? null - : typeof parsedValue === 'object' - ? JSON.stringify(parsedValue) - : parsedValue; + result[key] = parsedValue == null ? null : parsedValue; } return result; @@ -129,13 +138,38 @@ export class V2ViewCompatService { return rawOpMap; } - async batchUpdateViewByOps(tableId: string, opsMap: { [viewId: string]: IOtOperation[] }) { + private async ensureViewOperation( + runner: ViewOperationPluginRunner, + executionContext: IExecutionContext, + context: ViewOperationPluginContext + ): Promise { + const preparedResult = await runner.prepare(context); + if (preparedResult.isErr()) { + this.throwDomainError(preparedResult.error); + } + + const guardResult = await preparedResult.value.guard(executionContext); + if (guardResult.isErr()) { + this.throwDomainError(guardResult.error); + } + } + + async batchUpdateViewByOps( + tableId: string, + opsMap: { [viewId: string]: IOtOperation[] }, + context?: IExecutionContext + ) { const updatedViewIds = Object.keys(opsMap); if (!updatedViewIds.length) { return; } - const db = await this.getDb(); + const container = await this.v2ContainerService.getContainer(); + const db = container.resolve>(v2MetaDbTokens.db); + const viewOperationPluginRunner = container.resolve( + v2CoreTokens.viewOperationPluginRunner + ); + const executionContext = context ?? (await this.v2ContextFactory.createContext()); const views = await db .selectFrom('view') .where('id', 'in', updatedViewIds) @@ -153,8 +187,22 @@ export class V2ViewCompatService { continue; } + await this.ensureViewOperation(viewOperationPluginRunner, executionContext, { + kind: ViewOperationKind.update, + executionContext, + payload: { + tableId, + viewId: view.id, + patch: properties as ViewOperationPayloadViewConfig, + }, + isTransactionBound: false, + }); + const dbValues = Object.fromEntries( - Object.entries(properties).map(([key, value]) => [snakeCase(key), value]) + Object.entries(properties).map(([key, value]) => [ + snakeCase(key), + value == null ? null : typeof value === 'object' ? JSON.stringify(value) : value, + ]) ); await db diff --git a/apps/nestjs-backend/src/features/view/view-data-safety-limit.service.spec.ts b/apps/nestjs-backend/src/features/view/view-data-safety-limit.service.spec.ts new file mode 100644 index 0000000000..70d1a874ad --- /dev/null +++ b/apps/nestjs-backend/src/features/view/view-data-safety-limit.service.spec.ts @@ -0,0 +1,159 @@ +import type { ConfigService } from '@nestjs/config'; +import { HttpErrorCode, type IFilter } from '@teable/core'; +import type { PrismaService } from '@teable/db-main-prisma'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; + +vi.mock('@teable/db-main-prisma', () => ({ PrismaService: class PrismaService {} })); + +let ViewDataSafetyLimitService: typeof import('./view-data-safety-limit.service').ViewDataSafetyLimitService; + +const filterItem = { + fieldId: 'fldTest', + operator: 'is', + value: 'x', + isSymbol: false, +}; + +const createService = ( + env: Record, + options: { currentViewCount?: number } = {} +) => { + const count = vi.fn().mockResolvedValue(options.currentViewCount ?? 0); + const configService = { + get: vi.fn((key: string) => env[key]), + } as unknown as ConfigService; + const prismaService = { + txClient: () => ({ + view: { count }, + }), + } as unknown as PrismaService; + + return { + service: new ViewDataSafetyLimitService(configService, prismaService), + count, + }; +}; + +const expectLimitError = (error: unknown, domainCode: string) => { + expect(error).toMatchObject({ + code: HttpErrorCode.VALIDATION_ERROR, + data: { + domainCode, + }, + }); +}; + +describe('ViewDataSafetyLimitService', () => { + beforeAll(async () => { + ({ ViewDataSafetyLimitService } = await import('./view-data-safety-limit.service')); + }, 30_000); + + it('rejects creating a view when the table has reached the views-per-table limit', async () => { + const { service, count } = createService( + { TABLE_LIMIT_VIEWS_PER_TABLE_MAX: '2' }, + { currentViewCount: 2 } + ); + + await expect(service.ensureCanCreateView('tblTest')).rejects.toSatisfy((error: unknown) => { + expectLimitError(error, 'validation.limit.views_per_table_max'); + return true; + }); + expect(count).toHaveBeenCalledWith({ where: { tableId: 'tblTest', deletedTime: null } }); + }); + + it('allows creating a view at the views-per-table boundary', async () => { + const { service } = createService( + { TABLE_LIMIT_VIEWS_PER_TABLE_MAX: '2' }, + { currentViewCount: 1 } + ); + + await expect(service.ensureCanCreateView('tblTest')).resolves.toBeUndefined(); + }); + + it.each([ + [ + 'validation.limit.name_max_length', + { TABLE_LIMIT_NAME_MAX_LENGTH: '3' }, + () => ({ name: 'Long' }), + ], + [ + 'validation.limit.description_max_length', + { TABLE_LIMIT_DESCRIPTION_MAX_LENGTH: '3' }, + () => ({ description: 'Long' }), + ], + [ + 'validation.limit.view_filter_items_max', + { TABLE_LIMIT_VIEW_FILTER_ITEMS_MAX: '1' }, + () => ({ + filter: { + conjunction: 'and', + filterSet: [filterItem, filterItem], + } as unknown as IFilter, + }), + ], + [ + 'validation.limit.view_filter_depth_max', + { TABLE_LIMIT_VIEW_FILTER_DEPTH_MAX: '1' }, + () => ({ + filter: { + conjunction: 'and', + filterSet: [{ conjunction: 'and', filterSet: [filterItem] }], + } as unknown as IFilter, + }), + ], + [ + 'validation.limit.view_sort_items_max', + { TABLE_LIMIT_VIEW_SORT_ITEMS_MAX: '1' }, + () => ({ + sort: { + sortObjs: [ + { fieldId: 'fldA', order: 'asc' }, + { fieldId: 'fldB', order: 'desc' }, + ], + }, + }), + ], + [ + 'validation.limit.view_group_items_max', + { TABLE_LIMIT_VIEW_GROUP_ITEMS_MAX: '1' }, + () => ({ + group: [ + { fieldId: 'fldA', order: 'asc' }, + { fieldId: 'fldB', order: 'desc' }, + ], + }), + ], + [ + 'validation.limit.view_options_max_bytes', + { TABLE_LIMIT_VIEW_OPTIONS_MAX_BYTES: '4' }, + () => ({ options: { rowHeight: 1 } }), + ], + ])('rejects %s for view payloads', (expectedCode, env, payloadFactory) => { + const { service } = createService(env); + + try { + service.ensureViewPayload(payloadFactory()); + throw new Error('Expected limit error'); + } catch (error) { + expectLimitError(error, expectedCode); + } + }); + + it('validates serialized view property updates', () => { + const { service } = createService({ TABLE_LIMIT_VIEW_SORT_ITEMS_MAX: '1' }); + + try { + service.ensureSerializedProperties({ + sort: JSON.stringify({ + sortObjs: [ + { fieldId: 'fldA', order: 'asc' }, + { fieldId: 'fldB', order: 'desc' }, + ], + }), + }); + throw new Error('Expected limit error'); + } catch (error) { + expectLimitError(error, 'validation.limit.view_sort_items_max'); + } + }); +}); diff --git a/apps/nestjs-backend/src/features/view/view-data-safety-limit.service.ts b/apps/nestjs-backend/src/features/view/view-data-safety-limit.service.ts new file mode 100644 index 0000000000..6a294f2044 --- /dev/null +++ b/apps/nestjs-backend/src/features/view/view-data-safety-limit.service.ts @@ -0,0 +1,178 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + HttpErrorCode, + type IFilter, + type IGroup, + type ISort, + type IViewOptions, +} from '@teable/core'; +import { PrismaService } from '@teable/db-main-prisma'; +import { + ensureTableDataSafetyViewOperationLimits, + resolveTableDataSafetyLimits, + type ResolvedTableDataSafetyLimitConfig, + type TableDataSafetyLimitConfig, + ViewOperationKind, + type ViewOperationPayloadViewConfig, + type ViewOperationPluginContext, +} from '@teable/v2-core'; +import { CustomHttpException } from '../../custom.exception'; + +type SerializedViewProperties = { + name?: string | null; + description?: string | null; + filter?: string | null; + sort?: string | null; + group?: string | null; + options?: string | null; +}; + +type ViewPayload = ViewOperationPayloadViewConfig & { + name?: string | null; + description?: string | null; + filter?: IFilter; + sort?: ISort; + group?: IGroup; + options?: IViewOptions; +}; + +const TABLE_LIMIT_ENV_KEYS = { + tableSchema: { + maxViewsPerTable: 'TABLE_LIMIT_VIEWS_PER_TABLE_MAX', + }, + viewConfig: { + maxFilterItems: 'TABLE_LIMIT_VIEW_FILTER_ITEMS_MAX', + maxFilterDepth: 'TABLE_LIMIT_VIEW_FILTER_DEPTH_MAX', + maxSortItems: 'TABLE_LIMIT_VIEW_SORT_ITEMS_MAX', + maxGroupItems: 'TABLE_LIMIT_VIEW_GROUP_ITEMS_MAX', + maxOptionsBytes: 'TABLE_LIMIT_VIEW_OPTIONS_MAX_BYTES', + }, + displayText: { + maxNameLength: 'TABLE_LIMIT_NAME_MAX_LENGTH', + maxDescriptionLength: 'TABLE_LIMIT_DESCRIPTION_MAX_LENGTH', + }, +} as const; + +const parseJsonProperty = (value: string | null | undefined): T | undefined => { + if (value == null) return undefined; + return JSON.parse(value) as T; +}; + +@Injectable() +export class ViewDataSafetyLimitService { + constructor( + private readonly configService: ConfigService, + private readonly prismaService: PrismaService + ) {} + + private getPositiveInteger(key: string): number | undefined { + const value = this.configService.get(key); + const parsed = + typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN; + return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined; + } + + private getLimits(): ResolvedTableDataSafetyLimitConfig { + const config: TableDataSafetyLimitConfig = { + tableSchema: { + maxViewsPerTable: this.getPositiveInteger( + TABLE_LIMIT_ENV_KEYS.tableSchema.maxViewsPerTable + ), + }, + viewConfig: { + maxFilterItems: this.getPositiveInteger(TABLE_LIMIT_ENV_KEYS.viewConfig.maxFilterItems), + maxFilterDepth: this.getPositiveInteger(TABLE_LIMIT_ENV_KEYS.viewConfig.maxFilterDepth), + maxSortItems: this.getPositiveInteger(TABLE_LIMIT_ENV_KEYS.viewConfig.maxSortItems), + maxGroupItems: this.getPositiveInteger(TABLE_LIMIT_ENV_KEYS.viewConfig.maxGroupItems), + maxOptionsBytes: this.getPositiveInteger(TABLE_LIMIT_ENV_KEYS.viewConfig.maxOptionsBytes), + }, + displayText: { + maxNameLength: this.getPositiveInteger(TABLE_LIMIT_ENV_KEYS.displayText.maxNameLength), + maxDescriptionLength: this.getPositiveInteger( + TABLE_LIMIT_ENV_KEYS.displayText.maxDescriptionLength + ), + }, + }; + + return resolveTableDataSafetyLimits(config); + } + + private ensureViewOperation(context: ViewOperationPluginContext): void { + const result = ensureTableDataSafetyViewOperationLimits(context, this.getLimits()); + if (result.isOk()) return; + + const error = result.error; + throw new CustomHttpException(error.message, HttpErrorCode.VALIDATION_ERROR, { + domainCode: error.code, + domainTags: error.tags, + details: error.details, + }); + } + + async ensureCanCreateView(tableId: string): Promise { + const currentViewCount = await this.prismaService.txClient().view.count({ + where: { tableId, deletedTime: null }, + }); + + this.ensureViewOperation({ + kind: ViewOperationKind.create, + executionContext: {} as ViewOperationPluginContext['executionContext'], + payload: { + tableId, + currentViewCount, + view: {}, + }, + isTransactionBound: false, + }); + } + + ensureViewPayload(payload: ViewPayload): void { + this.ensureViewOperation({ + kind: ViewOperationKind.update, + executionContext: {} as ViewOperationPluginContext['executionContext'], + payload: { + tableId: '', + viewId: '', + patch: payload, + }, + isTransactionBound: false, + }); + } + + ensureName(name: string | null | undefined): void { + this.ensureViewPayload({ name }); + } + + ensureDescription(description: string | null | undefined): void { + this.ensureViewPayload({ description }); + } + + ensureFilter(filter: IFilter | undefined): void { + this.ensureViewPayload({ filter }); + } + + ensureSort(sort: ISort | undefined): void { + this.ensureViewPayload({ sort }); + } + + ensureGroup(group: IGroup | undefined): void { + this.ensureViewPayload({ group }); + } + + ensureOptions(options: IViewOptions | undefined): void { + this.ensureViewPayload({ options }); + } + + ensureSerializedProperties(properties: SerializedViewProperties | undefined): void { + if (!properties) return; + this.ensureViewPayload({ + name: properties.name, + description: properties.description, + filter: parseJsonProperty(properties.filter), + sort: parseJsonProperty(properties.sort), + group: parseJsonProperty(properties.group), + options: parseJsonProperty(properties.options), + }); + } +} diff --git a/apps/nestjs-backend/src/features/view/view.module.ts b/apps/nestjs-backend/src/features/view/view.module.ts index 6a678ee315..7f8db3ee08 100644 --- a/apps/nestjs-backend/src/features/view/view.module.ts +++ b/apps/nestjs-backend/src/features/view/view.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; import { DbProvider } from '../../db-provider/db.provider'; import { CalculationModule } from '../calculation/calculation.module'; +import { ViewDataSafetyLimitService } from './view-data-safety-limit.service'; import { ViewService } from './view.service'; @Module({ imports: [CalculationModule], - providers: [ViewService, DbProvider], + providers: [ViewService, ViewDataSafetyLimitService, DbProvider], exports: [ViewService], }) export class ViewModule {} diff --git a/apps/nestjs-backend/src/features/view/view.service.ts b/apps/nestjs-backend/src/features/view/view.service.ts index 116076338e..42c7612f99 100644 --- a/apps/nestjs-backend/src/features/view/view.service.ts +++ b/apps/nestjs-backend/src/features/view/view.service.ts @@ -52,6 +52,7 @@ import { BatchService } from '../calculation/batch.service'; import { ROW_ORDER_FIELD_PREFIX } from './constant'; import { createViewInstanceByRaw, createViewVoByRaw } from './model/factory'; import { adjustFrozenField } from './utils/derive-frozen-fields'; +import { ViewDataSafetyLimitService } from './view-data-safety-limit.service'; type IViewOpContext = IUpdateViewColumnMetaOpContext | ISetViewPropertyOpContext; @@ -64,7 +65,8 @@ export class ViewService implements IReadonlyAdapterService { private readonly dataPrismaService: DataPrismaService, @InjectModel(CUSTOM_KNEX) private readonly knex: Knex, @InjectModel(DATA_KNEX) private readonly dataKnex: Knex, - @InjectDbProvider() private readonly dbProvider: IDbProvider + @InjectDbProvider() private readonly dbProvider: IDbProvider, + private readonly viewDataSafetyLimitService: ViewDataSafetyLimitService ) {} getRowIndexFieldName(viewId: string) { @@ -252,6 +254,7 @@ export class ViewService implements IReadonlyAdapterService { async createDbView(tableId: string, viewRo: IViewRo) { const userId = this.cls.get('user.id'); + await this.viewDataSafetyLimitService.ensureCanCreateView(tableId); const createViewRo = await this.viewDataCompensation(tableId, viewRo); const { @@ -269,6 +272,14 @@ export class ViewService implements IReadonlyAdapterService { } = createViewRo; const { name, order } = await this.polishOrderAndName(tableId, createViewRo); + this.viewDataSafetyLimitService.ensureViewPayload({ + name, + description, + filter, + sort, + group, + options, + }); const viewId = generateViewId(); const prisma = this.prismaService.txClient(); @@ -381,6 +392,7 @@ export class ViewService implements IReadonlyAdapterService { } async updateViewSort(tableId: string, viewId: string, sort: ISort) { + this.viewDataSafetyLimitService.ensureSort(sort); const viewRaw = await this.prismaService .txClient() .view.findFirstOrThrow({ @@ -506,6 +518,9 @@ export class ViewService implements IReadonlyAdapterService { values, }; }); + for (const viewId of updatedViewIds) { + this.viewDataSafetyLimitService.ensureSerializedProperties(updateViewMap[viewId]?.property); + } if (data.length === 1) { const { id, values } = data[0]; diff --git a/apps/nestjs-backend/src/types/i18n.generated.ts b/apps/nestjs-backend/src/types/i18n.generated.ts index c7eda51e38..fabf40d872 100644 --- a/apps/nestjs-backend/src/types/i18n.generated.ts +++ b/apps/nestjs-backend/src/types/i18n.generated.ts @@ -4935,6 +4935,10 @@ export type I18nTranslations = { "contextTipNewChat": string; "contextTipMemory": string; }; + "contextCompaction": { + "auto": string; + "manual": string; + }; "taskProgress": { "title": string; }; diff --git a/apps/nextjs-app/src/features/app/components/field-setting/FieldSetting.spec.tsx b/apps/nextjs-app/src/features/app/components/field-setting/FieldSetting.spec.tsx index 5915507285..3ccedb2548 100644 --- a/apps/nextjs-app/src/features/app/components/field-setting/FieldSetting.spec.tsx +++ b/apps/nextjs-app/src/features/app/components/field-setting/FieldSetting.spec.tsx @@ -1,23 +1,101 @@ -import { CellValueType, DbFieldType, FieldType } from '@teable/core'; -import { render, screen } from '@/test-utils'; -import { FieldSettingBase } from './FieldSetting'; +import { + CellValueType, + DbFieldType, + FieldAIActionType, + FieldType, + StatisticsFunc, +} from '@teable/core'; +import type * as OpenApi from '@teable/openapi'; +import type * as SdkHooks from '@teable/sdk/hooks'; +import { act } from 'react'; +import { render, screen, TestAnchorProvider, userEvent, waitFor } from '@/test-utils'; +import { FieldSetting, FieldSettingBase } from './FieldSetting'; import { FieldOperator } from './type'; +const fieldOperationMocks = vi.hoisted(() => ({ + createField: vi.fn(), + convertField: vi.fn(), + planFieldCreate: vi.fn(), + planFieldConvert: vi.fn(), + autoFillField: vi.fn(), +})); + +const openapiMocks = vi.hoisted(() => ({ + getAggregation: vi.fn(), +})); + +vi.mock('@teable/openapi', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getAggregation: openapiMocks.getAggregation, + }; +}); + +vi.mock('@teable/sdk/hooks', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useTableId: () => 'tblTest0000000001', + useView: () => ({ id: 'viwTest0000000001' }), + useRowCount: () => 30, + useFieldOperations: () => fieldOperationMocks, + }; +}); + vi.mock('./DynamicFieldEditor', () => ({ DynamicFieldEditor: ({ field, + onChange, + onSave, }: { field: { name?: string; type?: string; isLookup?: boolean }; + onChange?: (field: unknown) => void; + onSave?: () => void | Promise; }) => (
{field.name ?? ''} {field.type ?? ''} {field.isLookup ? 'true' : 'false'} + +
), })); +const createDeferred = () => { + let resolve: (value: T) => void = () => undefined; + let reject: (reason?: unknown) => void = () => undefined; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; + describe('FieldSettingBase', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('disables save when editing target field is not available yet', () => { render( { id: 'fldLookup0000000001', name: 'Lookup Child Name', type: FieldType.SingleLineText, - description: null, options: {}, isLookup: true, - lookupOptions: { - foreignTableId: 'tblForeign000000001', - linkFieldId: 'fldLink000000000001', - lookupFieldId: 'fldTarget0000000001', - }, cellValueType: CellValueType.String, isMultipleCellValue: false, dbFieldType: DbFieldType.Text, @@ -79,4 +151,98 @@ describe('FieldSettingBase', () => { expect(screen.getByTestId('editor-field-type')).toHaveTextContent(FieldType.SingleLineText); expect(screen.getByTestId('editor-field-is-lookup')).toHaveTextContent('true'); }); + + it('keeps the AI apply dialog open until field save succeeds', async () => { + const field = { + id: 'fldTest0000000001', + name: 'Reply', + type: FieldType.SingleLineText, + options: {}, + aiConfig: { + type: FieldAIActionType.Customization, + modelKey: 'old-model', + prompt: 'Old prompt', + isAutoFill: true, + }, + cellValueType: CellValueType.String, + isMultipleCellValue: false, + dbFieldType: DbFieldType.Text, + dbFieldName: 'Reply', + } as const; + const updatedField = { + ...field, + name: 'AI Reply', + aiConfig: { + type: FieldAIActionType.Customization, + modelKey: 'gpt-5.5', + prompt: 'Write a concise customer-service reply.', + isAutoFill: true, + }, + }; + const convertDeferred = createDeferred(); + + openapiMocks.getAggregation.mockResolvedValue({ + data: { + aggregations: [ + { + fieldId: field.id, + total: { aggFunc: StatisticsFunc.Empty, value: 0 }, + }, + { + fieldId: field.id, + total: { aggFunc: StatisticsFunc.Filled, value: 30 }, + }, + ], + }, + }); + fieldOperationMocks.planFieldConvert.mockResolvedValue({ + estimateTime: 0, + linkFieldCount: 0, + }); + fieldOperationMocks.convertField.mockReturnValue(convertDeferred.promise); + fieldOperationMocks.autoFillField.mockResolvedValue(undefined); + + render( + + undefined} + onConfirm={() => undefined} + /> + + ); + + await userEvent.click(screen.getByRole('button', { name: 'Mock change AI config' })); + await userEvent.click(screen.getByRole('button', { name: 'common:actions.save' })); + + expect( + await screen.findByText('table:field.aiConfig.autoFillConfirm.title') + ).toBeInTheDocument(); + + await userEvent.click( + screen.getByRole('button', { name: 'table:field.aiConfig.autoFillConfirm.generateAll' }) + ); + + expect(fieldOperationMocks.convertField).toHaveBeenCalled(); + expect(fieldOperationMocks.autoFillField).not.toHaveBeenCalled(); + expect(screen.getByText('table:field.aiConfig.autoFillConfirm.title')).toBeInTheDocument(); + + await act(async () => { + convertDeferred.resolve(updatedField); + await convertDeferred.promise; + }); + + await waitFor(() => { + expect( + screen.queryByText('table:field.aiConfig.autoFillConfirm.title') + ).not.toBeInTheDocument(); + }); + expect(fieldOperationMocks.autoFillField).toHaveBeenCalledWith({ + tableId: 'tblTest0000000001', + fieldId: field.id, + query: { viewId: 'viwTest0000000001', mode: 'all' }, + }); + }); }); diff --git a/apps/nextjs-app/src/features/app/components/field-setting/FieldSetting.tsx b/apps/nextjs-app/src/features/app/components/field-setting/FieldSetting.tsx index 0748426f9f..3d5edca80f 100644 --- a/apps/nextjs-app/src/features/app/components/field-setting/FieldSetting.tsx +++ b/apps/nextjs-app/src/features/app/components/field-setting/FieldSetting.tsx @@ -360,21 +360,22 @@ export const FieldSetting = (props: IFieldSetting) => { }; const handleConfirmWithAutoFill = async (mode: AiAutoFillMode) => { - if (!fieldRo) return; + if (!fieldRo) return false; autoFillModeRef.current = mode; const plan = await getPlan(fieldRo); if (!plan) { - return; + return false; } setPlan(plan); const estimateTime = plan?.estimateTime || 0; const linkFieldCount = plan?.linkFieldCount || 0; if (estimateTime > 1000 || linkFieldCount > 0) { setGraphVisible(true); - return; + return true; } await performAction(fieldRo); + return true; }; return ( @@ -448,8 +449,10 @@ export const FieldSetting = (props: IFieldSetting) => { }} onClose={() => setAiConfirmVisible(false)} onConfirm={async (mode) => { - setAiConfirmVisible(false); - await handleConfirmWithAutoFill(mode); + const shouldClose = await handleConfirmWithAutoFill(mode); + if (shouldClose) { + setAiConfirmVisible(false); + } }} /> { const dynamicComponent = useCallback(() => { switch (notifyType) { case NotificationTypeEnum.ExportBase: - case NotificationTypeEnum.System: { + case NotificationTypeEnum.System: + case NotificationTypeEnum.AdminNotice: { const { iconUrl } = notifyIcon as INotificationSystemIcon; return ( diff --git a/apps/nextjs-app/src/features/app/components/notifications/NotificationItem.tsx b/apps/nextjs-app/src/features/app/components/notifications/NotificationItem.tsx index 5140289662..fba7644b90 100644 --- a/apps/nextjs-app/src/features/app/components/notifications/NotificationItem.tsx +++ b/apps/nextjs-app/src/features/app/components/notifications/NotificationItem.tsx @@ -20,9 +20,7 @@ export const NotificationItem = React.forwardRef @@ -40,7 +38,7 @@ export const NotificationItem = React.forwardRef ); - if (isExportBase) { + if (isExportBase || !url) { return (
} className={className} {...rest}> {content} diff --git a/apps/nextjs-app/src/features/app/components/notifications/NotificationsManage.tsx b/apps/nextjs-app/src/features/app/components/notifications/NotificationsManage.tsx index 3d64e8b26d..3ec2d6c143 100644 --- a/apps/nextjs-app/src/features/app/components/notifications/NotificationsManage.tsx +++ b/apps/nextjs-app/src/features/app/components/notifications/NotificationsManage.tsx @@ -20,7 +20,6 @@ import dayjs from 'dayjs'; import { useTranslation } from 'next-i18next'; import type { TFunction } from 'next-i18next'; import React, { useEffect, useState } from 'react'; -import { useLocalStorage } from 'react-use'; import { LinkNotification } from './notification-component'; import { NotificationIcon } from './NotificationIcon'; import { NotificationList } from './NotificationList'; @@ -29,7 +28,6 @@ const SHOWN_NOTIFICATIONS_LIMIT = 100; const TOAST_AUTO_CLOSE_DURATION = 1000 * 3; const TOAST_MANUAL_CLOSE_DURATION = Infinity; const shownNotificationIds = new Set(); -const NOTIFICATION_SEVERITY_FILTER_KEY = 'teable:notification:severity-filter'; const CREDIT_EXHAUSTED_NOTIFICATION_TOAST_ID = 'credit-exhausted-notification'; const CREDIT_NOTIFICATION_I18N_KEYS = new Set([ 'email.templates.notify.task.ai.cancelled.creditExhausted', @@ -41,9 +39,6 @@ const NOTIFICATION_SEVERITIES = [ NotificationSeverityEnum.Info, ] as const; -const isNotificationSeverity = (value: unknown): value is NotificationSeverityEnum => - NOTIFICATION_SEVERITIES.includes(value as NotificationSeverityEnum); - const getNotificationToastDuration = (notification: Pick) => notification.severity === NotificationSeverityEnum.Critical ? TOAST_MANUAL_CLOSE_DURATION @@ -169,17 +164,9 @@ export const NotificationsManage: React.FC = () => { const [newUnreadCount, setNewUnreadCount] = useState(undefined); const [notifyStatus, setNotifyStatus] = useState(NotificationStatesEnum.Unread); - const [storedSeverity, setStoredSeverity, removeStoredSeverity] = - useLocalStorage(NOTIFICATION_SEVERITY_FILTER_KEY, undefined, { - raw: true, - }); - const selectedSeverity = isNotificationSeverity(storedSeverity) ? storedSeverity : undefined; - - useEffect(() => { - if (storedSeverity && !isNotificationSeverity(storedSeverity)) { - removeStoredSeverity(); - } - }, [removeStoredSeverity, storedSeverity]); + const [selectedSeverity, setSelectedSeverity] = useState( + undefined + ); const { data: queryUnreadCount = 0 } = useQuery({ queryKey: ReactQueryKeys.notifyUnreadCount(), @@ -252,13 +239,8 @@ export const NotificationsManage: React.FC = () => { const getSeverityLabel = (severity: NotificationSeverityEnum) => t(`notification.severity.${severity}`); - const handleSeverityClick = (severity: NotificationSeverityEnum) => { - if (selectedSeverity === severity) { - removeStoredSeverity(); - return; - } - - setStoredSeverity(severity); + const handleSeverityClick = (severity?: NotificationSeverityEnum) => { + setSelectedSeverity(severity); }; const renderNewButton = () => { @@ -334,6 +316,28 @@ export const NotificationsManage: React.FC = () => {
+ {NOTIFICATION_SEVERITIES.map((severity) => { const isSelected = selectedSeverity === severity; @@ -344,7 +348,7 @@ export const NotificationsManage: React.FC = () => { size="xs" className={cn( 'h-7 gap-1.5 rounded px-2.5 text-xs font-medium text-muted-foreground hover:bg-muted/70 hover:text-foreground', - isSelected && 'bg-muted/80 text-foreground hover:bg-muted/80' + isSelected && 'bg-foreground/10 text-foreground hover:bg-foreground/10' )} onClick={() => handleSeverityClick(severity)} > diff --git a/apps/nextjs-app/src/features/app/components/notifications/notification-component/LinkNotification.tsx b/apps/nextjs-app/src/features/app/components/notifications/notification-component/LinkNotification.tsx index c77e0c0acd..7a1e8edec4 100644 --- a/apps/nextjs-app/src/features/app/components/notifications/notification-component/LinkNotification.tsx +++ b/apps/nextjs-app/src/features/app/components/notifications/notification-component/LinkNotification.tsx @@ -54,7 +54,7 @@ export const LinkNotification = (props: LinkNotificationProps) => { } }; - if (disableLink || notifyType === NotificationTypeEnum.ExportBase) { + if (disableLink || !url || notifyType === NotificationTypeEnum.ExportBase) { return ( <> {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} diff --git a/packages/common-i18n/src/locales/de/common.json b/packages/common-i18n/src/locales/de/common.json index 0905c18f2a..3ac0773502 100644 --- a/packages/common-i18n/src/locales/de/common.json +++ b/packages/common-i18n/src/locales/de/common.json @@ -1154,9 +1154,9 @@ } }, "changelog": { - "newUpdate": "UPDATE VOM 12. MAI", - "title": "Integrierte Anmeldung fuer App Builder", - "url": "https://help.teable.ai/en/changelog#may-12-2026", - "id": "changelog-2026-05-12-built-in-login-for-app-builder" + "newUpdate": "UPDATE VOM 14. MAI", + "title": "Jeden Knoten in AI Chat erwähnen", + "url": "https://help.teable.ai/en/changelog#may-14-2026", + "id": "changelog-2026-05-14-any-node-in-ai-chat" } } diff --git a/packages/common-i18n/src/locales/de/sdk.json b/packages/common-i18n/src/locales/de/sdk.json index 3ebf22cc02..bc7d382590 100644 --- a/packages/common-i18n/src/locales/de/sdk.json +++ b/packages/common-i18n/src/locales/de/sdk.json @@ -913,6 +913,31 @@ "automationNodeNeedTest": "Automatisierungsknoten benötigt Test", "automationNodeTestOutdated": "Automatisierungsknoten-Test veraltet", "invalidToken": "Ungültiges Token", + "limit": { + "fieldOptionsMaxBytes": "Field options are too large. Maximum is {{max}} bytes.", + "selectChoicesMax": "This select field has too many choices. Maximum is {{max}} choices.", + "selectChoiceNameMaxLength": "Select choice names are too long. Maximum is {{max}} characters.", + "selectDefaultValuesMax": "This select field has too many default values. Maximum is {{max}} values.", + "cellValueMaxBytes": "Cell value is too large. Maximum is {{max}} bytes.", + "recordFieldsMaxBytes": "Record payload is too large. Maximum is {{max}} bytes.", + "recordsPerMutationMax": "This operation changes too many records. Maximum is {{max}} records at a time.", + "computedCellValueMaxBytes": "Computed cell value is too large. Maximum is {{max}} bytes.", + "formulaMaxLength": "Formula is too long. Maximum is {{max}} characters.", + "tablesPerBaseMax": "This base has reached the table limit. Maximum is {{max}} tables.", + "fieldsPerTableMax": "This table has too many fields. Maximum is {{max}} fields.", + "rowsPerTableMax": "This table has too many records. Maximum is {{max}} records.", + "viewsPerTableMax": "This table has reached the view limit. Maximum is {{max}} views.", + "createTableFieldsMax": "The new table has too many fields. Maximum is {{max}} fields.", + "createTableViewsMax": "The new table has too many views. Maximum is {{max}} views.", + "createTableRecordsMax": "The new table has too many records. Maximum is {{max}} records.", + "viewFilterItemsMax": "This view has too many filter conditions. Maximum is {{max}} conditions.", + "viewFilterDepthMax": "This view filter is too deeply nested. Maximum depth is {{max}}.", + "viewSortItemsMax": "This view has too many sort rules. Maximum is {{max}} rules.", + "viewGroupItemsMax": "This view has too many group rules. Maximum is {{max}} rules.", + "viewOptionsMaxBytes": "View options are too large. Maximum is {{max}} bytes.", + "nameMaxLength": "Name is too long. Maximum is {{max}} characters.", + "descriptionMaxLength": "Description is too long. Maximum is {{max}} characters." + }, "custom": { "fieldValueNotNull": "\"{{tableName}}\" Feld \"{{fieldName}}\" darf keine leeren Werte enthalten, bitte vollständig ausfüllen bevor Sie absenden.", "fieldValueDuplicate": "\"{{tableName}}\" Feld \"{{fieldName}}\" darf keine doppelten Werte enthalten, bitte einen eindeutigen Wert vor dem Absenden ausfüllen.", diff --git a/packages/common-i18n/src/locales/en/common.json b/packages/common-i18n/src/locales/en/common.json index 257ba5619f..d9f38c058e 100644 --- a/packages/common-i18n/src/locales/en/common.json +++ b/packages/common-i18n/src/locales/en/common.json @@ -1502,9 +1502,9 @@ } }, "changelog": { - "newUpdate": "MAY 12 UPDATE", - "title": "Built-in Login for App Builder", - "url": "https://help.teable.ai/en/changelog#may-12-2026", - "id": "changelog-2026-05-12-built-in-login-for-app-builder" + "newUpdate": "MAY 14 UPDATE", + "title": "@ Any Node in AI Chat", + "url": "https://help.teable.ai/en/changelog#may-14-2026", + "id": "changelog-2026-05-14-any-node-in-ai-chat" } } diff --git a/packages/common-i18n/src/locales/en/sdk.json b/packages/common-i18n/src/locales/en/sdk.json index 4990585728..c503e32678 100644 --- a/packages/common-i18n/src/locales/en/sdk.json +++ b/packages/common-i18n/src/locales/en/sdk.json @@ -938,6 +938,31 @@ "automationNodeNeedTest": "Automation node need test", "automationNodeTestOutdated": "Automation node test outdated", "invalidToken": "Invalid token", + "limit": { + "fieldOptionsMaxBytes": "Field options are too large. Maximum is {{max}} bytes.", + "selectChoicesMax": "This select field has too many choices. Maximum is {{max}} choices.", + "selectChoiceNameMaxLength": "Select choice names are too long. Maximum is {{max}} characters.", + "selectDefaultValuesMax": "This select field has too many default values. Maximum is {{max}} values.", + "cellValueMaxBytes": "Cell value is too large. Maximum is {{max}} bytes.", + "recordFieldsMaxBytes": "Record payload is too large. Maximum is {{max}} bytes.", + "recordsPerMutationMax": "This operation changes too many records. Maximum is {{max}} records at a time.", + "computedCellValueMaxBytes": "Computed cell value is too large. Maximum is {{max}} bytes.", + "formulaMaxLength": "Formula is too long. Maximum is {{max}} characters.", + "tablesPerBaseMax": "This base has reached the table limit. Maximum is {{max}} tables.", + "fieldsPerTableMax": "This table has too many fields. Maximum is {{max}} fields.", + "rowsPerTableMax": "This table has too many records. Maximum is {{max}} records.", + "viewsPerTableMax": "This table has reached the view limit. Maximum is {{max}} views.", + "createTableFieldsMax": "The new table has too many fields. Maximum is {{max}} fields.", + "createTableViewsMax": "The new table has too many views. Maximum is {{max}} views.", + "createTableRecordsMax": "The new table has too many records. Maximum is {{max}} records.", + "viewFilterItemsMax": "This view has too many filter conditions. Maximum is {{max}} conditions.", + "viewFilterDepthMax": "This view filter is too deeply nested. Maximum depth is {{max}}.", + "viewSortItemsMax": "This view has too many sort rules. Maximum is {{max}} rules.", + "viewGroupItemsMax": "This view has too many group rules. Maximum is {{max}} rules.", + "viewOptionsMaxBytes": "View options are too large. Maximum is {{max}} bytes.", + "nameMaxLength": "Name is too long. Maximum is {{max}} characters.", + "descriptionMaxLength": "Description is too long. Maximum is {{max}} characters." + }, "custom": { "fieldValueNotNull": "\"{{tableName}}\" field \"{{fieldName}}\" does not allow empty values, please fill in completely before submitting.", "fieldValueDuplicate": "\"{{tableName}}\" field \"{{fieldName}}\" does not allow duplicate values, please fill in a unique value before submitting.", diff --git a/packages/common-i18n/src/locales/en/table.json b/packages/common-i18n/src/locales/en/table.json index 1b48325f3b..f569ee5c62 100644 --- a/packages/common-i18n/src/locales/en/table.json +++ b/packages/common-i18n/src/locales/en/table.json @@ -1241,6 +1241,10 @@ "contextTipNewChat": "Start a new chat for different topics to significantly reduce Credit usage.", "contextTipMemory": "Say \"remember something\" to save to long-term memory, accessible across chats." }, + "contextCompaction": { + "auto": "Context was automatically compacted", + "manual": "Context was compacted" + }, "taskProgress": { "title": "Task Progress" }, diff --git a/packages/common-i18n/src/locales/es/common.json b/packages/common-i18n/src/locales/es/common.json index eadda73182..6a5a1bfa7e 100644 --- a/packages/common-i18n/src/locales/es/common.json +++ b/packages/common-i18n/src/locales/es/common.json @@ -1157,9 +1157,9 @@ } }, "changelog": { - "newUpdate": "ACTUALIZACION DEL 12 DE MAYO", - "title": "Inicio de sesion integrado para App Builder", - "url": "https://help.teable.ai/en/changelog#may-12-2026", - "id": "changelog-2026-05-12-built-in-login-for-app-builder" + "newUpdate": "ACTUALIZACION DEL 14 DE MAYO", + "title": "Menciona cualquier nodo en AI Chat", + "url": "https://help.teable.ai/en/changelog#may-14-2026", + "id": "changelog-2026-05-14-any-node-in-ai-chat" } } diff --git a/packages/common-i18n/src/locales/es/sdk.json b/packages/common-i18n/src/locales/es/sdk.json index c3802f9078..ab78165786 100644 --- a/packages/common-i18n/src/locales/es/sdk.json +++ b/packages/common-i18n/src/locales/es/sdk.json @@ -913,6 +913,31 @@ "automationNodeNeedTest": "El nodo de automatización necesita prueba", "automationNodeTestOutdated": "Prueba del nodo de automatización desactualizada", "invalidToken": "Token no válido", + "limit": { + "fieldOptionsMaxBytes": "Field options are too large. Maximum is {{max}} bytes.", + "selectChoicesMax": "This select field has too many choices. Maximum is {{max}} choices.", + "selectChoiceNameMaxLength": "Select choice names are too long. Maximum is {{max}} characters.", + "selectDefaultValuesMax": "This select field has too many default values. Maximum is {{max}} values.", + "cellValueMaxBytes": "Cell value is too large. Maximum is {{max}} bytes.", + "recordFieldsMaxBytes": "Record payload is too large. Maximum is {{max}} bytes.", + "recordsPerMutationMax": "This operation changes too many records. Maximum is {{max}} records at a time.", + "computedCellValueMaxBytes": "Computed cell value is too large. Maximum is {{max}} bytes.", + "formulaMaxLength": "Formula is too long. Maximum is {{max}} characters.", + "tablesPerBaseMax": "This base has reached the table limit. Maximum is {{max}} tables.", + "fieldsPerTableMax": "This table has too many fields. Maximum is {{max}} fields.", + "rowsPerTableMax": "This table has too many records. Maximum is {{max}} records.", + "viewsPerTableMax": "This table has reached the view limit. Maximum is {{max}} views.", + "createTableFieldsMax": "The new table has too many fields. Maximum is {{max}} fields.", + "createTableViewsMax": "The new table has too many views. Maximum is {{max}} views.", + "createTableRecordsMax": "The new table has too many records. Maximum is {{max}} records.", + "viewFilterItemsMax": "This view has too many filter conditions. Maximum is {{max}} conditions.", + "viewFilterDepthMax": "This view filter is too deeply nested. Maximum depth is {{max}}.", + "viewSortItemsMax": "This view has too many sort rules. Maximum is {{max}} rules.", + "viewGroupItemsMax": "This view has too many group rules. Maximum is {{max}} rules.", + "viewOptionsMaxBytes": "View options are too large. Maximum is {{max}} bytes.", + "nameMaxLength": "Name is too long. Maximum is {{max}} characters.", + "descriptionMaxLength": "Description is too long. Maximum is {{max}} characters." + }, "custom": { "fieldValueNotNull": "\"{{tableName}}\" campo \"{{fieldName}}\" no permite valores vacíos, por favor complete antes de enviar.", "fieldValueDuplicate": "\"{{tableName}}\" campo \"{{fieldName}}\" no permite valores duplicados, por favor complete un valor único antes de enviar.", diff --git a/packages/common-i18n/src/locales/fr/common.json b/packages/common-i18n/src/locales/fr/common.json index 5025d378e7..3c00914a2a 100644 --- a/packages/common-i18n/src/locales/fr/common.json +++ b/packages/common-i18n/src/locales/fr/common.json @@ -1159,9 +1159,9 @@ } }, "changelog": { - "newUpdate": "MISE A JOUR DU 12 MAI", - "title": "Connexion integree pour App Builder", - "url": "https://help.teable.ai/en/changelog#may-12-2026", - "id": "changelog-2026-05-12-built-in-login-for-app-builder" + "newUpdate": "MISE A JOUR DU 14 MAI", + "title": "Mentionnez n’importe quel noeud dans AI Chat", + "url": "https://help.teable.ai/en/changelog#may-14-2026", + "id": "changelog-2026-05-14-any-node-in-ai-chat" } } diff --git a/packages/common-i18n/src/locales/fr/sdk.json b/packages/common-i18n/src/locales/fr/sdk.json index 15f699e065..2ff3f958b4 100644 --- a/packages/common-i18n/src/locales/fr/sdk.json +++ b/packages/common-i18n/src/locales/fr/sdk.json @@ -913,6 +913,31 @@ "automationNodeNeedTest": "Le nœud d'automatisation nécessite un test", "automationNodeTestOutdated": "Test du nœud d'automatisation obsolète", "invalidToken": "Jeton non valide", + "limit": { + "fieldOptionsMaxBytes": "Field options are too large. Maximum is {{max}} bytes.", + "selectChoicesMax": "This select field has too many choices. Maximum is {{max}} choices.", + "selectChoiceNameMaxLength": "Select choice names are too long. Maximum is {{max}} characters.", + "selectDefaultValuesMax": "This select field has too many default values. Maximum is {{max}} values.", + "cellValueMaxBytes": "Cell value is too large. Maximum is {{max}} bytes.", + "recordFieldsMaxBytes": "Record payload is too large. Maximum is {{max}} bytes.", + "recordsPerMutationMax": "This operation changes too many records. Maximum is {{max}} records at a time.", + "computedCellValueMaxBytes": "Computed cell value is too large. Maximum is {{max}} bytes.", + "formulaMaxLength": "Formula is too long. Maximum is {{max}} characters.", + "tablesPerBaseMax": "This base has reached the table limit. Maximum is {{max}} tables.", + "fieldsPerTableMax": "This table has too many fields. Maximum is {{max}} fields.", + "rowsPerTableMax": "This table has too many records. Maximum is {{max}} records.", + "viewsPerTableMax": "This table has reached the view limit. Maximum is {{max}} views.", + "createTableFieldsMax": "The new table has too many fields. Maximum is {{max}} fields.", + "createTableViewsMax": "The new table has too many views. Maximum is {{max}} views.", + "createTableRecordsMax": "The new table has too many records. Maximum is {{max}} records.", + "viewFilterItemsMax": "This view has too many filter conditions. Maximum is {{max}} conditions.", + "viewFilterDepthMax": "This view filter is too deeply nested. Maximum depth is {{max}}.", + "viewSortItemsMax": "This view has too many sort rules. Maximum is {{max}} rules.", + "viewGroupItemsMax": "This view has too many group rules. Maximum is {{max}} rules.", + "viewOptionsMaxBytes": "View options are too large. Maximum is {{max}} bytes.", + "nameMaxLength": "Name is too long. Maximum is {{max}} characters.", + "descriptionMaxLength": "Description is too long. Maximum is {{max}} characters." + }, "custom": { "fieldValueNotNull": "\"{{tableName}}\" champ \"{{fieldName}}\" ne permet pas les valeurs vides, veuillez les remplir complètement avant de soumettre.", "fieldValueDuplicate": "\"{{tableName}}\" champ \"{{fieldName}}\" ne permet pas les valeurs dupliquées, veuillez remplir une valeur unique avant de soumettre.", diff --git a/packages/common-i18n/src/locales/it/common.json b/packages/common-i18n/src/locales/it/common.json index 6919e92fca..8056a7c7e9 100644 --- a/packages/common-i18n/src/locales/it/common.json +++ b/packages/common-i18n/src/locales/it/common.json @@ -1159,9 +1159,9 @@ } }, "changelog": { - "newUpdate": "AGGIORNAMENTO DEL 12 MAGGIO", - "title": "Accesso integrato per App Builder", - "url": "https://help.teable.ai/en/changelog#may-12-2026", - "id": "changelog-2026-05-12-built-in-login-for-app-builder" + "newUpdate": "AGGIORNAMENTO DEL 14 MAGGIO", + "title": "Menziona qualsiasi nodo in AI Chat", + "url": "https://help.teable.ai/en/changelog#may-14-2026", + "id": "changelog-2026-05-14-any-node-in-ai-chat" } } diff --git a/packages/common-i18n/src/locales/it/sdk.json b/packages/common-i18n/src/locales/it/sdk.json index 9ae7b9a883..28513b2b46 100644 --- a/packages/common-i18n/src/locales/it/sdk.json +++ b/packages/common-i18n/src/locales/it/sdk.json @@ -913,6 +913,31 @@ "automationNodeNeedTest": "Il nodo di automazione ha bisogno di test", "automationNodeTestOutdated": "Test del nodo di automazione obsoleto", "invalidToken": "Token non valido", + "limit": { + "fieldOptionsMaxBytes": "Field options are too large. Maximum is {{max}} bytes.", + "selectChoicesMax": "This select field has too many choices. Maximum is {{max}} choices.", + "selectChoiceNameMaxLength": "Select choice names are too long. Maximum is {{max}} characters.", + "selectDefaultValuesMax": "This select field has too many default values. Maximum is {{max}} values.", + "cellValueMaxBytes": "Cell value is too large. Maximum is {{max}} bytes.", + "recordFieldsMaxBytes": "Record payload is too large. Maximum is {{max}} bytes.", + "recordsPerMutationMax": "This operation changes too many records. Maximum is {{max}} records at a time.", + "computedCellValueMaxBytes": "Computed cell value is too large. Maximum is {{max}} bytes.", + "formulaMaxLength": "Formula is too long. Maximum is {{max}} characters.", + "tablesPerBaseMax": "This base has reached the table limit. Maximum is {{max}} tables.", + "fieldsPerTableMax": "This table has too many fields. Maximum is {{max}} fields.", + "rowsPerTableMax": "This table has too many records. Maximum is {{max}} records.", + "viewsPerTableMax": "This table has reached the view limit. Maximum is {{max}} views.", + "createTableFieldsMax": "The new table has too many fields. Maximum is {{max}} fields.", + "createTableViewsMax": "The new table has too many views. Maximum is {{max}} views.", + "createTableRecordsMax": "The new table has too many records. Maximum is {{max}} records.", + "viewFilterItemsMax": "This view has too many filter conditions. Maximum is {{max}} conditions.", + "viewFilterDepthMax": "This view filter is too deeply nested. Maximum depth is {{max}}.", + "viewSortItemsMax": "This view has too many sort rules. Maximum is {{max}} rules.", + "viewGroupItemsMax": "This view has too many group rules. Maximum is {{max}} rules.", + "viewOptionsMaxBytes": "View options are too large. Maximum is {{max}} bytes.", + "nameMaxLength": "Name is too long. Maximum is {{max}} characters.", + "descriptionMaxLength": "Description is too long. Maximum is {{max}} characters." + }, "custom": { "fieldValueNotNull": "\"{{tableName}}\" campo \"{{fieldName}}\" non consente valori vuoti, per favore riempilo completamente prima di inviare.", "fieldValueDuplicate": "\"{{tableName}}\" campo \"{{fieldName}}\" non consente valori duplicati, per favore riempilo con un valore unico prima di inviare.", diff --git a/packages/common-i18n/src/locales/ja/common.json b/packages/common-i18n/src/locales/ja/common.json index 9b39808b9f..0f8e31c7e7 100644 --- a/packages/common-i18n/src/locales/ja/common.json +++ b/packages/common-i18n/src/locales/ja/common.json @@ -1161,9 +1161,9 @@ } }, "changelog": { - "newUpdate": "5月12日アップデート", - "title": "App Builder の組み込みログイン", - "url": "https://help.teable.ai/en/changelog#may-12-2026", - "id": "changelog-2026-05-12-built-in-login-for-app-builder" + "newUpdate": "5月14日アップデート", + "title": "AI Chat で任意のノードを @ メンション", + "url": "https://help.teable.ai/en/changelog#may-14-2026", + "id": "changelog-2026-05-14-any-node-in-ai-chat" } } diff --git a/packages/common-i18n/src/locales/ja/sdk.json b/packages/common-i18n/src/locales/ja/sdk.json index 3d5990e9d2..e60a336754 100644 --- a/packages/common-i18n/src/locales/ja/sdk.json +++ b/packages/common-i18n/src/locales/ja/sdk.json @@ -913,6 +913,31 @@ "automationNodeNeedTest": "自動化ノードはテストが必要です", "automationNodeTestOutdated": "自動化ノードのテストが古くなっています", "invalidToken": "無効なトークン", + "limit": { + "fieldOptionsMaxBytes": "Field options are too large. Maximum is {{max}} bytes.", + "selectChoicesMax": "This select field has too many choices. Maximum is {{max}} choices.", + "selectChoiceNameMaxLength": "Select choice names are too long. Maximum is {{max}} characters.", + "selectDefaultValuesMax": "This select field has too many default values. Maximum is {{max}} values.", + "cellValueMaxBytes": "Cell value is too large. Maximum is {{max}} bytes.", + "recordFieldsMaxBytes": "Record payload is too large. Maximum is {{max}} bytes.", + "recordsPerMutationMax": "This operation changes too many records. Maximum is {{max}} records at a time.", + "computedCellValueMaxBytes": "Computed cell value is too large. Maximum is {{max}} bytes.", + "formulaMaxLength": "Formula is too long. Maximum is {{max}} characters.", + "tablesPerBaseMax": "This base has reached the table limit. Maximum is {{max}} tables.", + "fieldsPerTableMax": "This table has too many fields. Maximum is {{max}} fields.", + "rowsPerTableMax": "This table has too many records. Maximum is {{max}} records.", + "viewsPerTableMax": "This table has reached the view limit. Maximum is {{max}} views.", + "createTableFieldsMax": "The new table has too many fields. Maximum is {{max}} fields.", + "createTableViewsMax": "The new table has too many views. Maximum is {{max}} views.", + "createTableRecordsMax": "The new table has too many records. Maximum is {{max}} records.", + "viewFilterItemsMax": "This view has too many filter conditions. Maximum is {{max}} conditions.", + "viewFilterDepthMax": "This view filter is too deeply nested. Maximum depth is {{max}}.", + "viewSortItemsMax": "This view has too many sort rules. Maximum is {{max}} rules.", + "viewGroupItemsMax": "This view has too many group rules. Maximum is {{max}} rules.", + "viewOptionsMaxBytes": "View options are too large. Maximum is {{max}} bytes.", + "nameMaxLength": "Name is too long. Maximum is {{max}} characters.", + "descriptionMaxLength": "Description is too long. Maximum is {{max}} characters." + }, "custom": { "fieldValueNotNull": "\"{{tableName}}\" フィールド \"{{fieldName}}\" は空の値を許可しません。送信する前に完全に入力してください。", "fieldValueDuplicate": "\"{{tableName}}\" フィールド \"{{fieldName}}\" は重複する値を許可しません。送信する前に一意の値を入力してください。", diff --git a/packages/common-i18n/src/locales/ru/common.json b/packages/common-i18n/src/locales/ru/common.json index 6b462fab41..86d24e75d8 100644 --- a/packages/common-i18n/src/locales/ru/common.json +++ b/packages/common-i18n/src/locales/ru/common.json @@ -1117,9 +1117,9 @@ } }, "changelog": { - "newUpdate": "ОБНОВЛЕНИЕ ОТ 12 МАЯ", - "title": "Встроенный вход для App Builder", - "url": "https://help.teable.ai/en/changelog#may-12-2026", - "id": "changelog-2026-05-12-built-in-login-for-app-builder" + "newUpdate": "ОБНОВЛЕНИЕ ОТ 14 МАЯ", + "title": "Упоминайте любой узел в AI Chat", + "url": "https://help.teable.ai/en/changelog#may-14-2026", + "id": "changelog-2026-05-14-any-node-in-ai-chat" } } diff --git a/packages/common-i18n/src/locales/ru/sdk.json b/packages/common-i18n/src/locales/ru/sdk.json index e6f0fa136c..cd671d509b 100644 --- a/packages/common-i18n/src/locales/ru/sdk.json +++ b/packages/common-i18n/src/locales/ru/sdk.json @@ -913,6 +913,31 @@ "automationNodeNeedTest": "Узел автоматизации требует тестирования", "automationNodeTestOutdated": "Тест узла автоматизации устарел", "invalidToken": "Недействительный токен", + "limit": { + "fieldOptionsMaxBytes": "Field options are too large. Maximum is {{max}} bytes.", + "selectChoicesMax": "This select field has too many choices. Maximum is {{max}} choices.", + "selectChoiceNameMaxLength": "Select choice names are too long. Maximum is {{max}} characters.", + "selectDefaultValuesMax": "This select field has too many default values. Maximum is {{max}} values.", + "cellValueMaxBytes": "Cell value is too large. Maximum is {{max}} bytes.", + "recordFieldsMaxBytes": "Record payload is too large. Maximum is {{max}} bytes.", + "recordsPerMutationMax": "This operation changes too many records. Maximum is {{max}} records at a time.", + "computedCellValueMaxBytes": "Computed cell value is too large. Maximum is {{max}} bytes.", + "formulaMaxLength": "Formula is too long. Maximum is {{max}} characters.", + "tablesPerBaseMax": "This base has reached the table limit. Maximum is {{max}} tables.", + "fieldsPerTableMax": "This table has too many fields. Maximum is {{max}} fields.", + "rowsPerTableMax": "This table has too many records. Maximum is {{max}} records.", + "viewsPerTableMax": "This table has reached the view limit. Maximum is {{max}} views.", + "createTableFieldsMax": "The new table has too many fields. Maximum is {{max}} fields.", + "createTableViewsMax": "The new table has too many views. Maximum is {{max}} views.", + "createTableRecordsMax": "The new table has too many records. Maximum is {{max}} records.", + "viewFilterItemsMax": "This view has too many filter conditions. Maximum is {{max}} conditions.", + "viewFilterDepthMax": "This view filter is too deeply nested. Maximum depth is {{max}}.", + "viewSortItemsMax": "This view has too many sort rules. Maximum is {{max}} rules.", + "viewGroupItemsMax": "This view has too many group rules. Maximum is {{max}} rules.", + "viewOptionsMaxBytes": "View options are too large. Maximum is {{max}} bytes.", + "nameMaxLength": "Name is too long. Maximum is {{max}} characters.", + "descriptionMaxLength": "Description is too long. Maximum is {{max}} characters." + }, "custom": { "fieldValueNotNull": "\"{{tableName}}\" поле \"{{fieldName}}\" не допускает пустые значения, пожалуйста, заполните его полностью перед отправкой.", "fieldValueDuplicate": "\"{{tableName}}\" поле \"{{fieldName}}\" не допускает дубликаты значений, пожалуйста, заполните уникальное значение перед отправкой.", diff --git a/packages/common-i18n/src/locales/tr/common.json b/packages/common-i18n/src/locales/tr/common.json index 70c4f0a19f..8d3c8b78b7 100644 --- a/packages/common-i18n/src/locales/tr/common.json +++ b/packages/common-i18n/src/locales/tr/common.json @@ -1149,9 +1149,9 @@ } }, "changelog": { - "newUpdate": "12 MAYIS GUNCELLEMESI", - "title": "App Builder icin yerlesik giris", - "url": "https://help.teable.ai/en/changelog#may-12-2026", - "id": "changelog-2026-05-12-built-in-login-for-app-builder" + "newUpdate": "14 MAYIS GUNCELLEMESI", + "title": "AI Chat icinde herhangi bir node etiketleyin", + "url": "https://help.teable.ai/en/changelog#may-14-2026", + "id": "changelog-2026-05-14-any-node-in-ai-chat" } } diff --git a/packages/common-i18n/src/locales/tr/sdk.json b/packages/common-i18n/src/locales/tr/sdk.json index c1a934a84f..2b926d47cc 100644 --- a/packages/common-i18n/src/locales/tr/sdk.json +++ b/packages/common-i18n/src/locales/tr/sdk.json @@ -913,6 +913,31 @@ "automationNodeNeedTest": "Otomasyon Düğümü Test Gereksinimi", "automationNodeTestOutdated": "Otomasyon Düğümü Testi Güncel Değil", "invalidToken": "Geçersiz token", + "limit": { + "fieldOptionsMaxBytes": "Field options are too large. Maximum is {{max}} bytes.", + "selectChoicesMax": "This select field has too many choices. Maximum is {{max}} choices.", + "selectChoiceNameMaxLength": "Select choice names are too long. Maximum is {{max}} characters.", + "selectDefaultValuesMax": "This select field has too many default values. Maximum is {{max}} values.", + "cellValueMaxBytes": "Cell value is too large. Maximum is {{max}} bytes.", + "recordFieldsMaxBytes": "Record payload is too large. Maximum is {{max}} bytes.", + "recordsPerMutationMax": "This operation changes too many records. Maximum is {{max}} records at a time.", + "computedCellValueMaxBytes": "Computed cell value is too large. Maximum is {{max}} bytes.", + "formulaMaxLength": "Formula is too long. Maximum is {{max}} characters.", + "tablesPerBaseMax": "This base has reached the table limit. Maximum is {{max}} tables.", + "fieldsPerTableMax": "This table has too many fields. Maximum is {{max}} fields.", + "rowsPerTableMax": "This table has too many records. Maximum is {{max}} records.", + "viewsPerTableMax": "This table has reached the view limit. Maximum is {{max}} views.", + "createTableFieldsMax": "The new table has too many fields. Maximum is {{max}} fields.", + "createTableViewsMax": "The new table has too many views. Maximum is {{max}} views.", + "createTableRecordsMax": "The new table has too many records. Maximum is {{max}} records.", + "viewFilterItemsMax": "This view has too many filter conditions. Maximum is {{max}} conditions.", + "viewFilterDepthMax": "This view filter is too deeply nested. Maximum depth is {{max}}.", + "viewSortItemsMax": "This view has too many sort rules. Maximum is {{max}} rules.", + "viewGroupItemsMax": "This view has too many group rules. Maximum is {{max}} rules.", + "viewOptionsMaxBytes": "View options are too large. Maximum is {{max}} bytes.", + "nameMaxLength": "Name is too long. Maximum is {{max}} characters.", + "descriptionMaxLength": "Description is too long. Maximum is {{max}} characters." + }, "custom": { "fieldValueNotNull": "\"{{tableName}}\" alanı \"{{fieldName}}\" boş değerlere izin vermiyor, lütfen tamamlayınız ve gönderimden önce doldurun.", "fieldValueDuplicate": "\"{{tableName}}\" alanı \"{{fieldName}}\" tekrarlayan değerlere izin vermiyor, lütfen benzersiz bir değer doldurun ve gönderimden önce doldurun.", diff --git a/packages/common-i18n/src/locales/uk/common.json b/packages/common-i18n/src/locales/uk/common.json index e3b443703a..8c9778707c 100644 --- a/packages/common-i18n/src/locales/uk/common.json +++ b/packages/common-i18n/src/locales/uk/common.json @@ -1138,9 +1138,9 @@ } }, "changelog": { - "newUpdate": "ОНОВЛЕННЯ ВІД 12 ТРАВНЯ", - "title": "Вбудований вхід для App Builder", - "url": "https://help.teable.ai/en/changelog#may-12-2026", - "id": "changelog-2026-05-12-built-in-login-for-app-builder" + "newUpdate": "ОНОВЛЕННЯ ВІД 14 ТРАВНЯ", + "title": "Згадуйте будь-який вузол в AI Chat", + "url": "https://help.teable.ai/en/changelog#may-14-2026", + "id": "changelog-2026-05-14-any-node-in-ai-chat" } } diff --git a/packages/common-i18n/src/locales/uk/sdk.json b/packages/common-i18n/src/locales/uk/sdk.json index 71f77662fd..0a37ca510e 100644 --- a/packages/common-i18n/src/locales/uk/sdk.json +++ b/packages/common-i18n/src/locales/uk/sdk.json @@ -913,6 +913,31 @@ "automationNodeNeedTest": "Вузол автоматизації потребує тестування", "automationNodeTestOutdated": "Тест вузла автоматизації застарів", "invalidToken": "Недійсний токен", + "limit": { + "fieldOptionsMaxBytes": "Field options are too large. Maximum is {{max}} bytes.", + "selectChoicesMax": "This select field has too many choices. Maximum is {{max}} choices.", + "selectChoiceNameMaxLength": "Select choice names are too long. Maximum is {{max}} characters.", + "selectDefaultValuesMax": "This select field has too many default values. Maximum is {{max}} values.", + "cellValueMaxBytes": "Cell value is too large. Maximum is {{max}} bytes.", + "recordFieldsMaxBytes": "Record payload is too large. Maximum is {{max}} bytes.", + "recordsPerMutationMax": "This operation changes too many records. Maximum is {{max}} records at a time.", + "computedCellValueMaxBytes": "Computed cell value is too large. Maximum is {{max}} bytes.", + "formulaMaxLength": "Formula is too long. Maximum is {{max}} characters.", + "tablesPerBaseMax": "This base has reached the table limit. Maximum is {{max}} tables.", + "fieldsPerTableMax": "This table has too many fields. Maximum is {{max}} fields.", + "rowsPerTableMax": "This table has too many records. Maximum is {{max}} records.", + "viewsPerTableMax": "This table has reached the view limit. Maximum is {{max}} views.", + "createTableFieldsMax": "The new table has too many fields. Maximum is {{max}} fields.", + "createTableViewsMax": "The new table has too many views. Maximum is {{max}} views.", + "createTableRecordsMax": "The new table has too many records. Maximum is {{max}} records.", + "viewFilterItemsMax": "This view has too many filter conditions. Maximum is {{max}} conditions.", + "viewFilterDepthMax": "This view filter is too deeply nested. Maximum depth is {{max}}.", + "viewSortItemsMax": "This view has too many sort rules. Maximum is {{max}} rules.", + "viewGroupItemsMax": "This view has too many group rules. Maximum is {{max}} rules.", + "viewOptionsMaxBytes": "View options are too large. Maximum is {{max}} bytes.", + "nameMaxLength": "Name is too long. Maximum is {{max}} characters.", + "descriptionMaxLength": "Description is too long. Maximum is {{max}} characters." + }, "custom": { "fieldValueNotNull": "\"{{tableName}}\" поле \"{{fieldName}}\" не допускає пусті значення, будь ласка, заповніть його повністю перед відправкою.", "fieldValueDuplicate": "\"{{tableName}}\" поле \"{{fieldName}}\" не допускає дублікатів значень, будь ласка, заповніть унікальне значення перед відправкою.", diff --git a/packages/common-i18n/src/locales/zh/common.json b/packages/common-i18n/src/locales/zh/common.json index 9d37d357dd..7587e6c0be 100644 --- a/packages/common-i18n/src/locales/zh/common.json +++ b/packages/common-i18n/src/locales/zh/common.json @@ -1499,9 +1499,9 @@ } }, "changelog": { - "newUpdate": "5 月 12 日更新", - "title": "App Builder 支持内置登录", - "url": "https://help.teable.ai/en/changelog#may-12-2026", - "id": "changelog-2026-05-12-built-in-login-for-app-builder" + "newUpdate": "5 月 14 日更新", + "title": "在 AI Chat 中 @ 任意节点", + "url": "https://help.teable.ai/en/changelog#may-14-2026", + "id": "changelog-2026-05-14-any-node-in-ai-chat" } } diff --git a/packages/common-i18n/src/locales/zh/sdk.json b/packages/common-i18n/src/locales/zh/sdk.json index b796d1a100..53f7ee1c6d 100644 --- a/packages/common-i18n/src/locales/zh/sdk.json +++ b/packages/common-i18n/src/locales/zh/sdk.json @@ -930,6 +930,31 @@ "automationNodeNeedTest": "自动化节点需要测试", "automationNodeTestOutdated": "自动化节点测试已过期", "invalidToken": "无效的令牌", + "limit": { + "fieldOptionsMaxBytes": "字段选项过大,最大允许 {{max}} 字节。", + "selectChoicesMax": "该选择字段的选项过多,最多允许 {{max}} 个选项。", + "selectChoiceNameMaxLength": "选择项名称过长,最多允许 {{max}} 个字符。", + "selectDefaultValuesMax": "该选择字段的默认值过多,最多允许 {{max}} 个默认值。", + "cellValueMaxBytes": "单元格值过大,最大允许 {{max}} 字节。", + "recordFieldsMaxBytes": "记录内容过大,最大允许 {{max}} 字节。", + "recordsPerMutationMax": "本次操作的记录数过多,最多一次处理 {{max}} 条记录。", + "computedCellValueMaxBytes": "计算单元格值过大,最大允许 {{max}} 字节。", + "formulaMaxLength": "公式过长,最多允许 {{max}} 个字符。", + "tablesPerBaseMax": "该 base 已达到表数量限制,最多允许 {{max}} 张表。", + "fieldsPerTableMax": "该表字段过多,最多允许 {{max}} 个字段。", + "rowsPerTableMax": "该表记录过多,最多允许 {{max}} 条记录。", + "viewsPerTableMax": "该表已达到视图数量限制,最多允许 {{max}} 个视图。", + "createTableFieldsMax": "新建表字段过多,最多允许 {{max}} 个字段。", + "createTableViewsMax": "新建表视图过多,最多允许 {{max}} 个视图。", + "createTableRecordsMax": "新建表记录过多,最多允许 {{max}} 条记录。", + "viewFilterItemsMax": "该视图的筛选条件过多,最多允许 {{max}} 个条件。", + "viewFilterDepthMax": "该视图的筛选嵌套过深,最多允许 {{max}} 层。", + "viewSortItemsMax": "该视图的排序规则过多,最多允许 {{max}} 条规则。", + "viewGroupItemsMax": "该视图的分组规则过多,最多允许 {{max}} 条规则。", + "viewOptionsMaxBytes": "视图配置过大,最大允许 {{max}} 字节。", + "nameMaxLength": "名称过长,最多允许 {{max}} 个字符。", + "descriptionMaxLength": "描述过长,最多允许 {{max}} 个字符。" + }, "custom": { "fieldValueNotNull": "\"{{tableName}}\" 中的 \"{{fieldName}}\" 字段不允许空值,请填写完整再提交", "fieldValueDuplicate": "\"{{tableName}}\" 中的 \"{{fieldName}}\" 字段不允许重复值,请填写唯一值再提交", diff --git a/packages/common-i18n/src/locales/zh/table.json b/packages/common-i18n/src/locales/zh/table.json index 54241df99a..e5f0362da2 100644 --- a/packages/common-i18n/src/locales/zh/table.json +++ b/packages/common-i18n/src/locales/zh/table.json @@ -1243,6 +1243,10 @@ "contextTipNewChat": "讨论新话题建议开启新对话,可显著减少 Credit 消耗。", "contextTipMemory": "对 AI 说「记住 XXX」可存入长期记忆,跨对话保留。" }, + "contextCompaction": { + "auto": "上下文已自动压缩", + "manual": "上下文已压缩" + }, "taskProgress": { "title": "任务进度" }, diff --git a/packages/core/src/models/notification/notification.enum.ts b/packages/core/src/models/notification/notification.enum.ts index d9c4dfc4aa..d7603b17f3 100644 --- a/packages/core/src/models/notification/notification.enum.ts +++ b/packages/core/src/models/notification/notification.enum.ts @@ -4,6 +4,7 @@ export enum NotificationTypeEnum { CollaboratorMultiRowTag = 'collaboratorMultiRowTag', Comment = 'comment', ExportBase = 'exportBase', + AdminNotice = 'adminNotice', } export enum NotificationStatesEnum { diff --git a/packages/openapi/src/admin/setting/get.ts b/packages/openapi/src/admin/setting/get.ts index 56c2c96b79..f107fee141 100644 --- a/packages/openapi/src/admin/setting/get.ts +++ b/packages/openapi/src/admin/setting/get.ts @@ -3,13 +3,7 @@ import { z } from 'zod'; import { axios } from '../../axios'; import { mailTransportConfigSchema } from '../../mail'; import { registerRoute } from '../../utils'; -import { - aiConfigVoSchema, - appConfigSchema, - canaryConfigSchema, - imConfigSchema, - sandboxAgentConfigSchema, -} from './update'; +import { aiConfigVoSchema, appConfigSchema, canaryConfigSchema, imConfigSchema } from './update'; export const settingVoSchema = z.object({ instanceId: z.string(), @@ -27,7 +21,6 @@ export const settingVoSchema = z.object({ automationMailTransportConfig: mailTransportConfigSchema.nullable().optional(), appConfig: appConfigSchema.nullable().optional(), canaryConfig: canaryConfigSchema.nullable().optional(), - sandboxAgentConfig: sandboxAgentConfigSchema.nullable().optional(), trashCleanupEnabledAt: z.string().nullable().optional(), imConfig: imConfigSchema.nullable().optional(), createdTime: z.string().optional(), diff --git a/packages/openapi/src/admin/setting/key.enum.ts b/packages/openapi/src/admin/setting/key.enum.ts index c9f0623a8e..4780306e2f 100644 --- a/packages/openapi/src/admin/setting/key.enum.ts +++ b/packages/openapi/src/admin/setting/key.enum.ts @@ -15,6 +15,5 @@ export enum SettingKey { ENABLE_CREDIT_REWARD = 'enableCreditReward', CANARY_CONFIG = 'canaryConfig', TRASH_CLEANUP_ENABLED_AT = 'trashCleanupEnabledAt', - SANDBOX_AGENT_CONFIG = 'sandboxAgentConfig', IM_CONFIG = 'imConfig', } diff --git a/packages/openapi/src/admin/setting/update.ts b/packages/openapi/src/admin/setting/update.ts index ec82027d8a..edc15b0ff5 100644 --- a/packages/openapi/src/admin/setting/update.ts +++ b/packages/openapi/src/admin/setting/update.ts @@ -1,6 +1,5 @@ import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; import { z } from 'zod'; -import { DEFAULT_EFFORT_LEVEL, effortLevelSchema } from '../../ai/effort'; import { DEFAULT_REALTIME_TRANSCRIPTION_MODEL, realtimeTranscriptionModelSchema, @@ -309,30 +308,6 @@ export type ICanaryConfig = z.infer; // Header name for canary release override export const X_CANARY_HEADER = 'x-teable-canary'; -export const sandboxAgentModelSchema = z.object({ - id: z.string(), - name: z.string(), -}); - -export type ISandboxAgentModel = z.infer; - -export const sandboxAgentConfigSchema = z.object({ - spaceIds: z.array(z.string()).default([]), - forceAll: z.boolean().optional(), - defaultAgent: z.enum(['claude']).default('claude').optional(), - models: z.record(z.string(), z.array(sandboxAgentModelSchema)).optional().default({}), - maxDuration: z.number().min(1).max(1440).default(300).optional(), - maxIdleTime: z.number().min(60).max(7200).default(1800).optional(), - maxConcurrentChats: z.number().min(1).max(20).default(3).optional(), - activeSnapshotId: z.string().optional(), - activeAppBuilderSnapshotId: z.string().optional(), - defaultEffort: effortLevelSchema.default(DEFAULT_EFFORT_LEVEL).optional(), -}); - -export type ISandboxAgentConfig = z.infer; - -export const X_SANDBOX_AGENT_HEADER = 'x-teable-sandbox-agent'; - export const imTelegramConfigSchema = z.object({ botToken: z.string(), botUsername: z.string(), @@ -362,7 +337,6 @@ export const updateSettingRoSchema = z.object({ appConfig: appConfigSchema.optional(), brandName: z.string().optional(), canaryConfig: canaryConfigSchema.optional(), - sandboxAgentConfig: sandboxAgentConfigSchema.optional(), notifyMailTransportConfig: mailTransportConfigSchema.nullable().optional(), automationMailTransportConfig: mailTransportConfigSchema.nullable().optional(), imConfig: imConfigSchema.nullable().optional(), diff --git a/packages/openapi/src/notification/index.ts b/packages/openapi/src/notification/index.ts index c5a81184a9..355f8d55b4 100644 --- a/packages/openapi/src/notification/index.ts +++ b/packages/openapi/src/notification/index.ts @@ -2,3 +2,4 @@ export * from './get-list'; export * from './update-status'; export * from './read-all'; export * from './unread-count'; +export * from './send-admin-notification'; diff --git a/packages/openapi/src/notification/send-admin-notification.ts b/packages/openapi/src/notification/send-admin-notification.ts new file mode 100644 index 0000000000..dd6ea1f222 --- /dev/null +++ b/packages/openapi/src/notification/send-admin-notification.ts @@ -0,0 +1,26 @@ +import { NotificationSeverityEnum } from '@teable/core'; +import { axios } from '../axios'; +import { z } from '../zod'; + +export const ADMIN_SEND_NOTIFICATION = '/admin/notification'; + +export const adminSendNotificationRoSchema = z.object({ + message: z.string().min(1).max(5000), + severity: z.enum(NotificationSeverityEnum).optional().default(NotificationSeverityEnum.Info), + userIds: z.array(z.string()).max(500).optional(), + emails: z.array(z.string().email()).max(500).optional(), +}); + +export type IAdminSendNotificationRo = z.infer; + +export const adminSendNotificationVoSchema = z.object({ + sentCount: z.number(), + invalidEmails: z.array(z.string()).optional(), + invalidUserIds: z.array(z.string()).optional(), +}); + +export type IAdminSendNotificationVo = z.infer; + +export const sendAdminNotification = async (ro: IAdminSendNotificationRo) => { + return axios.post(ADMIN_SEND_NOTIFICATION, ro); +}; diff --git a/packages/sdk/src/components/expand-record/Modal.spec.tsx b/packages/sdk/src/components/expand-record/Modal.spec.tsx new file mode 100644 index 0000000000..fe4607311a --- /dev/null +++ b/packages/sdk/src/components/expand-record/Modal.spec.tsx @@ -0,0 +1,33 @@ +import { fireEvent, render } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { Modal } from './Modal'; + +describe('Modal (ExpandRecord wrapper)', () => { + it('calls onClose when the overlay is clicked (T956)', () => { + const onClose = vi.fn(); + render( + +
inner
+
+ ); + + const overlay = document.querySelector('[data-state="open"].fixed.inset-0'); + expect(overlay).not.toBeNull(); + fireEvent.click(overlay!); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('does not call onClose when clicking inside the dialog content', () => { + const onClose = vi.fn(); + const { getByTestId } = render( + +
inner
+
+ ); + + fireEvent.click(getByTestId('content')); + + expect(onClose).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/sdk/src/components/expand-record/Modal.tsx b/packages/sdk/src/components/expand-record/Modal.tsx index 1ed971fe81..e21a0a9fc5 100644 --- a/packages/sdk/src/components/expand-record/Modal.tsx +++ b/packages/sdk/src/components/expand-record/Modal.tsx @@ -1,4 +1,4 @@ -import { Dialog, DialogContent, cn } from '@teable/ui-lib'; +import { Dialog, DialogContent, DialogOverlay, cn } from '@teable/ui-lib'; import { type FC, type PropsWithChildren } from 'react'; import { useRef } from 'react'; import { ModalContext } from './ModalContext'; @@ -22,6 +22,7 @@ export const Modal: FC< container={container} className={cn('h-full block p-0 max-w-4xl', className)} style={{ width: 'calc(100% - 40px)', height: 'calc(100% - 100px)' }} + overlay={ onClose?.()} />} onKeyDown={(e) => { if (e.key === 'Escape') { onClose?.(); diff --git a/packages/sdk/src/context/app/queryClient.spec.ts b/packages/sdk/src/context/app/queryClient.spec.ts index d5c8ed293b..e3a20c3c64 100644 --- a/packages/sdk/src/context/app/queryClient.spec.ts +++ b/packages/sdk/src/context/app/queryClient.spec.ts @@ -1,15 +1,27 @@ +import deSdk from '@teable/common-i18n/src/locales/de/sdk.json'; import deTable from '@teable/common-i18n/src/locales/de/table.json'; +import enSdk from '@teable/common-i18n/src/locales/en/sdk.json'; import enTable from '@teable/common-i18n/src/locales/en/table.json'; +import esSdk from '@teable/common-i18n/src/locales/es/sdk.json'; import esTable from '@teable/common-i18n/src/locales/es/table.json'; +import frSdk from '@teable/common-i18n/src/locales/fr/sdk.json'; import frTable from '@teable/common-i18n/src/locales/fr/table.json'; +import itSdk from '@teable/common-i18n/src/locales/it/sdk.json'; import itTable from '@teable/common-i18n/src/locales/it/table.json'; +import jaSdk from '@teable/common-i18n/src/locales/ja/sdk.json'; import jaTable from '@teable/common-i18n/src/locales/ja/table.json'; +import ruSdk from '@teable/common-i18n/src/locales/ru/sdk.json'; import ruTable from '@teable/common-i18n/src/locales/ru/table.json'; +import trSdk from '@teable/common-i18n/src/locales/tr/sdk.json'; import trTable from '@teable/common-i18n/src/locales/tr/table.json'; +import ukSdk from '@teable/common-i18n/src/locales/uk/sdk.json'; import ukTable from '@teable/common-i18n/src/locales/uk/table.json'; +import zhSdk from '@teable/common-i18n/src/locales/zh/sdk.json'; import zhTable from '@teable/common-i18n/src/locales/zh/table.json'; import { describe, expect, it } from 'vitest'; import { tableI18nKeys } from '../../../../i18n-keys/src'; +import type { ILocaleFunction } from './i18n'; +import { getHttpErrorMessage } from './queryClient'; const collectLeafKeys = (value: unknown, prefix = ''): string[] => { if (!value || typeof value !== 'object' || Array.isArray(value)) { @@ -58,3 +70,67 @@ describe('table locale coverage', () => { expect(expectedKeys.filter((key) => !localeKeys.has(key))).toEqual([]); }); }); + +describe('sdk table data safety limit locale coverage', () => { + const expectedKeys = Object.keys(enSdk.httpErrors.limit).sort(); + const locales = { + de: deSdk, + en: enSdk, + es: esSdk, + fr: frSdk, + it: itSdk, + ja: jaSdk, + ru: ruSdk, + tr: trSdk, + uk: ukSdk, + zh: zhSdk, + }; + + it.each(Object.entries(locales))( + 'covers all table data safety limit messages in %s', + (_locale, sdk) => { + expect(Object.keys(sdk.httpErrors.limit).sort()).toEqual(expectedKeys); + } + ); +}); + +const t: ILocaleFunction = ((key: string, options?: Record) => { + if (key === 'sdk:httpErrors.limit.nameMaxLength') { + return `${key}:${options?.max}`; + } + return key; +}) as ILocaleFunction; + +describe('getHttpErrorMessage', () => { + it('localizes v2 table data safety validation limit errors by domain code', () => { + const message = getHttpErrorMessage( + { + message: 'Table data safety limit exceeded: validation.limit.name_max_length', + data: { + domainCode: 'validation.limit.name_max_length', + details: { max: 100 }, + }, + }, + t, + 'sdk' + ); + + expect(message).toBe('sdk:httpErrors.limit.nameMaxLength:100'); + }); + + it('falls back to the server message for unknown validation limit keys', () => { + const message = getHttpErrorMessage( + { + message: 'fallback', + data: { + domainCode: 'validation.limit.unknown_limit', + details: { max: 1 }, + }, + }, + t, + 'sdk' + ); + + expect(message).toBe('fallback'); + }); +}); diff --git a/packages/sdk/src/context/app/queryClient.tsx b/packages/sdk/src/context/app/queryClient.tsx index 8fe5d8657f..7a3018911f 100644 --- a/packages/sdk/src/context/app/queryClient.tsx +++ b/packages/sdk/src/context/app/queryClient.tsx @@ -26,6 +26,29 @@ export function toCamelCaseErrorCode(errorCode: string): string { .join(''); } +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +const getValidationLimitMessage = ( + data: ICustomHttpExceptionData | undefined, + t: ILocaleFunction, + prefix?: string +) => { + const domainCode = data?.domainCode; + if (typeof domainCode !== 'string' || !domainCode.startsWith('validation.limit.')) { + return; + } + + const limitKey = toCamelCaseErrorCode(domainCode.slice('validation.limit.'.length)); + const key = `httpErrors.limit.${limitKey}`; + const prefixedKey = prefix ? `${prefix}:${key}` : key; + const details = isRecord(data?.details) ? data.details : {}; + const message = t(prefixedKey as TKey, details); + return typeof message === 'string' && message !== prefixedKey && message !== key + ? message + : undefined; +}; + export const getLocalizationMessage = ( localization: ILocalization, t: ILocaleFunction, @@ -38,7 +61,11 @@ export const getLocalizationMessage = ( export const getHttpErrorMessage = (error: unknown, t: ILocaleFunction, prefix?: string) => { const { message, data } = error as IHttpError; - const { localization } = (data as ICustomHttpExceptionData) || {}; + const customData = (data as ICustomHttpExceptionData) || {}; + const limitMessage = getValidationLimitMessage(customData, t, prefix); + if (limitMessage) return limitMessage; + + const { localization } = customData; return localization ? getLocalizationMessage(localization, t, prefix) : message; }; diff --git a/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRowLimitPlugin.ts b/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRowLimitPlugin.ts index 274101e73f..c80cc458fa 100644 --- a/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRowLimitPlugin.ts +++ b/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRowLimitPlugin.ts @@ -137,9 +137,10 @@ export class PostgresTableRowLimitPlugin if (rowCount + recordCount > preparedState.maxRowCount) { return err( core.domainError.validation({ - code: 'validation.max_row_limit', + code: 'validation.limit.rows_per_table_max', message: `Exceed max row limit: ${preparedState.maxRowCount}, please contact us to increase the limit`, details: { + max: preparedState.maxRowCount, maxRowCount: preparedState.maxRowCount, rowCount, recordCount, diff --git a/packages/v2/adapter-table-repository-postgres/src/integration/commands/CreateRecordHandler.db.spec.ts b/packages/v2/adapter-table-repository-postgres/src/integration/commands/CreateRecordHandler.db.spec.ts index ee239ec733..92fbcf6968 100644 --- a/packages/v2/adapter-table-repository-postgres/src/integration/commands/CreateRecordHandler.db.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/integration/commands/CreateRecordHandler.db.spec.ts @@ -3,8 +3,10 @@ import { v2PostgresDbTokens } from '@teable/v2-adapter-db-postgres-pg'; import { createV2NodeTestContainer } from '@teable/v2-container-node-test'; import { ActorId, + CreateFieldCommand, CreateRecordCommand, CreateTableCommand, + type CreateFieldResult, type CreateRecordResult, type CreateTableResult, type ICommandBus, @@ -150,6 +152,112 @@ describe('CreateRecordHandler (db)', () => { expect(rows[0]['__id']).toBe(record.id().toString()); }); + it('inserts a record when a number formula resolves to an empty string', async () => { + const { container, baseId, processOutbox } = getV2NodeTestContainer(); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const db = container.resolve>(v2PostgresDbTokens.db); + + const deviceFieldId = `fld${'d'.repeat(16)}`; + const startTimeFieldId = `fld${'u'.repeat(16)}`; + const endTimeFieldId = `fld${'t'.repeat(16)}`; + const statusFieldId = `fld${'s'.repeat(16)}`; + const textFormulaFieldId = `fld${'g'.repeat(16)}`; + const numberFormulaFieldId = `fld${'f'.repeat(16)}`; + const createTableCommand = CreateTableCommand.create({ + baseId: baseId.toString(), + name: 'Number Formula Blank Insert', + fields: [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { type: 'singleLineText', id: deviceFieldId, name: 'Device' }, + { type: 'singleLineText', id: startTimeFieldId, name: 'Start Time' }, + { type: 'singleLineText', id: endTimeFieldId, name: 'End Time' }, + { + type: 'singleSelect', + id: statusFieldId, + name: 'Status', + options: { + choices: [{ id: `cho${'w'.repeat(10)}`, name: '工作日', color: 'greenBright' }], + }, + }, + ], + views: [{ type: 'grid' }], + })._unsafeUnwrap(); + + const tableResult = await commandBus.execute( + createContext(), + createTableCommand + ); + const { table } = tableResult._unsafeUnwrap(); + const tableId = table.id().toString(); + + const createTextFormulaCommand = CreateFieldCommand.create({ + baseId: baseId.toString(), + tableId, + field: { + id: textFormulaFieldId, + type: 'formula', + name: 'Workday Minutes Text', + options: { + expression: `IF(AND({${startTimeFieldId}} = "", {${endTimeFieldId}} = ""), "", IF({${statusFieldId}} = "工作日", IF({${deviceFieldId}} = "行政考勤", {${endTimeFieldId}} % 100, {${endTimeFieldId}} % 100 - 35), ""))`, + }, + }, + })._unsafeUnwrap(); + const textFormulaResult = await commandBus.execute( + createContext(), + createTextFormulaCommand + ); + expect(textFormulaResult.isOk()).toBe(true); + + const createNumberFormulaCommand = CreateFieldCommand.create({ + baseId: baseId.toString(), + tableId, + field: { + id: numberFormulaFieldId, + type: 'formula', + name: 'Blank Number Formula', + options: { + expression: `VALUE(IF({${statusFieldId}} = "工作日", IF({${deviceFieldId}} = "行政考勤", IF(OR({${endTimeFieldId}} = "", {${endTimeFieldId}} < "17:00"), "", IF({${endTimeFieldId}} < "17:00", "", {${textFormulaFieldId}})), ""), ""))`, + }, + }, + })._unsafeUnwrap(); + const formulaResult = await commandBus.execute( + createContext(), + createNumberFormulaCommand + ); + expect(formulaResult.isOk(), formulaResult.isErr() ? formulaResult.error.message : '').toBe( + true + ); + const formulaTable = formulaResult._unsafeUnwrap().table; + const formulaField = formulaTable + .getFields() + .find((field) => field.id().toString() === numberFormulaFieldId); + expect(formulaField).toBeDefined(); + if (!formulaField) return; + + const createRecordCommand = CreateRecordCommand.create({ + tableId, + fields: {}, + })._unsafeUnwrap(); + const result = await commandBus.execute( + createContext(), + createRecordCommand + ); + + expect(result.isOk()).toBe(true); + const { record } = result._unsafeUnwrap(); + await processOutbox(); + + const dbTableName = formulaTable.dbTableName()._unsafeUnwrap().value()._unsafeUnwrap(); + const formulaDbField = formulaField.dbFieldName()._unsafeUnwrap().value()._unsafeUnwrap(); + const rows = await (db as unknown as Kysely>>) + .selectFrom(dbTableName) + .select([formulaDbField]) + .where('__id', '=', record.id().toString()) + .execute(); + + expect(rows).toEqual([{ [formulaDbField]: null }]); + }); + it('inserts multiple records with unique IDs', async () => { const { container, baseId } = getV2NodeTestContainer(); const commandBus = container.resolve(v2CoreTokens.commandBus); diff --git a/packages/v2/adapter-table-repository-postgres/src/record/computed/__tests__/UpdateFromSelectBuilder.spec.ts b/packages/v2/adapter-table-repository-postgres/src/record/computed/__tests__/UpdateFromSelectBuilder.spec.ts index 8ff8f6b46d..e7aa77c372 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/computed/__tests__/UpdateFromSelectBuilder.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/computed/__tests__/UpdateFromSelectBuilder.spec.ts @@ -153,7 +153,7 @@ describe('UpdateFromSelectBuilder', () => { WHEN BTRIM(("c_src"."col_score")::text) ~ '^[+-]?([0-9]+([.][0-9]+)?|[.][0-9]+)([eE][+-]?[0-9]+)?$' THEN BTRIM(("c_src"."col_score")::text)::double precision ELSE NULL - END as "__set_col_score" from (select "t"."__id" as "__id", "t"."__version" as "__version", 1 as "col_score" from "bseaaaaaaaaaaaaaaaa"."tblbbbbbbbbbbbbbbbb" as "t" where "t"."__id" in (select "d"."record_id" from "tmp_computed_dirty" as "d" where "d"."table_id" = $1)) as "c_src") as "c" where "u"."__id" = "c"."__id" and ("u"."col_score" IS DISTINCT FROM "c"."__set_col_score")" + END as "__set_col_score" from (select "t"."__id" as "__id", "t"."__version" as "__version", NULLIF(BTRIM((1)::text), '')::double precision as "col_score" from "bseaaaaaaaaaaaaaaaa"."tblbbbbbbbbbbbbbbbb" as "t" where "t"."__id" in (select "d"."record_id" from "tmp_computed_dirty" as "d" where "d"."table_id" = $1)) as "c_src") as "c" where "u"."__id" = "c"."__id" and ("u"."col_score" IS DISTINCT FROM "c"."__set_col_score")" ` ); }); @@ -232,7 +232,7 @@ describe('UpdateFromSelectBuilder', () => { WHEN BTRIM(("c_src"."col_score")::text) ~ '^[+-]?([0-9]+([.][0-9]+)?|[.][0-9]+)([eE][+-]?[0-9]+)?$' THEN BTRIM(("c_src"."col_score")::text)::double precision ELSE NULL - END as "__set_col_score" from (select "t"."__id" as "__id", "t"."__version" as "__version", 1 as "col_score" from "bseaaaaaaaaaaaaaaaa"."tblbbbbbbbbbbbbbbbb" as "t" inner join "tmp_computed_dirty" as "__dirty" on "t"."__id" = "__dirty"."record_id" and "__dirty"."table_id" = $1) as "c_src") as "c" where "u"."__id" = "c"."__id" and ("u"."col_score" IS DISTINCT FROM "c"."__set_col_score")" + END as "__set_col_score" from (select "t"."__id" as "__id", "t"."__version" as "__version", NULLIF(BTRIM((1)::text), '')::double precision as "col_score" from "bseaaaaaaaaaaaaaaaa"."tblbbbbbbbbbbbbbbbb" as "t" inner join "tmp_computed_dirty" as "__dirty" on "t"."__id" = "__dirty"."record_id" and "__dirty"."table_id" = $1) as "c_src") as "c" where "u"."__id" = "c"."__id" and ("u"."col_score" IS DISTINCT FROM "c"."__set_col_score")" ` ); }); diff --git a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/ComputedFieldSelectExpressionVisitor.ts b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/ComputedFieldSelectExpressionVisitor.ts index e27bd13dba..5a4cda9aaa 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/ComputedFieldSelectExpressionVisitor.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/ComputedFieldSelectExpressionVisitor.ts @@ -1,5 +1,7 @@ import { + CellValueType, FieldType, + FieldValueTypeVisitor, type AttachmentField, type AutoNumberField, type ButtonField, @@ -462,6 +464,15 @@ export class ComputedFieldSelectExpressionVisitor finalValueSql = expr.valueSql; } + const fieldValueTypeResult = field.accept(new FieldValueTypeVisitor()); + if ( + fieldValueTypeResult.isOk() && + !formulaIsMultiple && + fieldValueTypeResult.value.cellValueType.equals(CellValueType.number()) + ) { + finalValueSql = `NULLIF(BTRIM((${finalValueSql})::text), '')::double precision`; + } + const typedSql = guardValueSql(finalValueSql, expr.errorConditionSql); return ok(sql.raw(typedSql).as(colAlias)); }); diff --git a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/SameTableBatchQueryBuilder.ts b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/SameTableBatchQueryBuilder.ts index a73ef64a19..2d68023b71 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/SameTableBatchQueryBuilder.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/SameTableBatchQueryBuilder.ts @@ -1,4 +1,4 @@ -import { domainError, FieldType, FieldValueTypeVisitor } from '@teable/v2-core'; +import { CellValueType, domainError, FieldType, FieldValueTypeVisitor } from '@teable/v2-core'; import type { ConditionalLookupField, DomainError, @@ -256,27 +256,38 @@ export class SameTableBatchQueryBuilder { } private normalizeFormulaValueSql(formulaField: FormulaField, expr: SqlExpr): string { - if (expr.storageKind !== 'json' || !this.shouldExtractJsonDisplay(expr)) { - return expr.valueSql; - } + let valueSql = expr.valueSql; const formulaIsMultiple = formulaField .isMultipleCellValue() .map((multiplicity) => multiplicity.isMultiple()) .unwrapOr(false); - if (formulaIsMultiple || expr.isArray) { - const normalized = normalizeToJsonArrayWithStrategy( - expr.valueSql, - this.typeValidationStrategy - ); - return `( + if (expr.storageKind === 'json' && this.shouldExtractJsonDisplay(expr)) { + if (formulaIsMultiple || expr.isArray) { + const normalized = normalizeToJsonArrayWithStrategy( + expr.valueSql, + this.typeValidationStrategy + ); + valueSql = `( SELECT jsonb_agg(to_jsonb(${extractJsonScalarText('elem')}) ORDER BY ord) FROM jsonb_array_elements(${normalized}) WITH ORDINALITY AS _jae(elem, ord) )`; + } else { + valueSql = extractJsonScalarText(`(${expr.valueSql})::jsonb`); + } + } + + const fieldValueTypeResult = formulaField.accept(new FieldValueTypeVisitor()); + if ( + fieldValueTypeResult.isOk() && + !formulaIsMultiple && + fieldValueTypeResult.value.cellValueType.equals(CellValueType.number()) + ) { + return `NULLIF(BTRIM((${valueSql})::text), '')::double precision`; } - return extractJsonScalarText(`(${expr.valueSql})::jsonb`); + return valueSql; } private shouldExtractJsonDisplay(expr: SqlExpr): boolean { diff --git a/packages/v2/benchmark-node/src/benchmarkTableDataSafetyLimits.ts b/packages/v2/benchmark-node/src/benchmarkTableDataSafetyLimits.ts new file mode 100644 index 0000000000..81cf6fb4c1 --- /dev/null +++ b/packages/v2/benchmark-node/src/benchmarkTableDataSafetyLimits.ts @@ -0,0 +1,8 @@ +import type { TableDataSafetyLimitConfig } from '@teable/v2-core'; + +export const benchmarkTableDataSafetyLimits = { + tableSchema: { + maxCreateTableFields: 1_000, + maxFieldsPerTable: 1_000, + }, +} satisfies TableDataSafetyLimitConfig; diff --git a/packages/v2/benchmark-node/src/create-table.bench.ts b/packages/v2/benchmark-node/src/create-table.bench.ts index e5cadf6133..bcef7aca9c 100644 --- a/packages/v2/benchmark-node/src/create-table.bench.ts +++ b/packages/v2/benchmark-node/src/create-table.bench.ts @@ -17,6 +17,7 @@ import { import express from 'express'; import fastify from 'fastify'; import { afterAll, beforeAll, bench, describe } from 'vitest'; +import { benchmarkTableDataSafetyLimits } from './benchmarkTableDataSafetyLimits'; const benchOptions = { iterations: 0, @@ -109,7 +110,9 @@ const setupHono = async (container: DependencyContainer): Promise }; const setup = async () => { - const testContainer = await createV2NodeTestContainer(); + const testContainer = await createV2NodeTestContainer({ + tableDataSafetyLimits: benchmarkTableDataSafetyLimits, + }); testContainer.container.registerInstance(v2CoreTokens.logger, new NoopLogger()); dispose = testContainer.dispose; baseId = testContainer.baseId.toString(); diff --git a/packages/v2/benchmark-node/src/get-table-by-id.bench.ts b/packages/v2/benchmark-node/src/get-table-by-id.bench.ts index c932dd517d..298b1b6cf2 100644 --- a/packages/v2/benchmark-node/src/get-table-by-id.bench.ts +++ b/packages/v2/benchmark-node/src/get-table-by-id.bench.ts @@ -17,6 +17,7 @@ import { import express from 'express'; import fastify from 'fastify'; import { afterAll, beforeAll, bench, describe } from 'vitest'; +import { benchmarkTableDataSafetyLimits } from './benchmarkTableDataSafetyLimits'; const benchOptions = { iterations: 0, @@ -131,7 +132,9 @@ const createTable = async ( }; const setup = async () => { - const testContainer = await createV2NodeTestContainer(); + const testContainer = await createV2NodeTestContainer({ + tableDataSafetyLimits: benchmarkTableDataSafetyLimits, + }); testContainer.container.registerInstance(v2CoreTokens.logger, new NoopLogger()); dispose = testContainer.dispose; baseId = testContainer.baseId.toString(); diff --git a/packages/v2/core/src/application/services/TableDataSafetyLimitFieldOperationPlugin.ts b/packages/v2/core/src/application/services/TableDataSafetyLimitFieldOperationPlugin.ts index 474a082d63..b40c8c585e 100644 --- a/packages/v2/core/src/application/services/TableDataSafetyLimitFieldOperationPlugin.ts +++ b/packages/v2/core/src/application/services/TableDataSafetyLimitFieldOperationPlugin.ts @@ -202,7 +202,7 @@ const ensureFormulaLength = ( ); }; -const ensureFieldLimits = ( +export const ensureTableDataSafetyFieldLimits = ( field: Field, domainContext: IDomainContext | undefined, limits: ResolvedTableDataSafetyLimitConfig @@ -332,13 +332,21 @@ export class TableDataSafetyLimitFieldOperationPlugin ): Result { const limits = preparedState?.limits ?? resolveTableDataSafetyLimits(); if (context.kind === FieldOperationKind.create && context.result?.createdField) { - return ensureFieldLimits(context.result.createdField, preparedState?.domainContext, limits); + return ensureTableDataSafetyFieldLimits( + context.result.createdField, + preparedState?.domainContext, + limits + ); } if (context.kind === FieldOperationKind.update && context.result?.updatedField) { - return ensureFieldLimits(context.result.updatedField, preparedState?.domainContext, limits); + return ensureTableDataSafetyFieldLimits( + context.result.updatedField, + preparedState?.domainContext, + limits + ); } if (context.kind === FieldOperationKind.duplicate && context.result?.duplicatedField) { - return ensureFieldLimits( + return ensureTableDataSafetyFieldLimits( context.result.duplicatedField, preparedState?.domainContext, limits diff --git a/packages/v2/core/src/application/services/TableDataSafetyLimitTableOperationPlugin.spec.ts b/packages/v2/core/src/application/services/TableDataSafetyLimitTableOperationPlugin.spec.ts index e79da7f4fc..af6027faa2 100644 --- a/packages/v2/core/src/application/services/TableDataSafetyLimitTableOperationPlugin.spec.ts +++ b/packages/v2/core/src/application/services/TableDataSafetyLimitTableOperationPlugin.spec.ts @@ -28,14 +28,19 @@ import { TableDataSafetyLimitTableOperationPlugin } from './TableDataSafetyLimit const actorId = ActorId.create('system')._unsafeUnwrap(); const baseId = BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap(); -const createTable = (idSeed: string, name: string, tableBaseId = baseId): Table => +const createTable = ( + idSeed: string, + name: string, + tableBaseId = baseId, + fieldName = 'Title' +): Table => Table.builder() .withId(TableId.create(`tbl${idSeed.repeat(16)}`)._unsafeUnwrap()) .withBaseId(tableBaseId) .withName(TableName.create(name)._unsafeUnwrap()) .field() .singleLineText() - .withName(FieldName.create('Title')._unsafeUnwrap()) + .withName(FieldName.create(fieldName)._unsafeUnwrap()) .primary() .done() .view() @@ -81,6 +86,10 @@ class FakeTableRepository implements ITableRepository { return ok(undefined); } + async restore(): Promise> { + return ok(undefined); + } + async delete(): Promise> { return ok(undefined); } @@ -122,6 +131,24 @@ const createViewsPerTableOnlyPlugin = (repository: ITableRepository) => ]) ); +const createFieldsPerTableOnlyPlugin = (repository: ITableRepository) => + new TableDataSafetyLimitTableOperationPlugin( + repository, + new TableDataSafetyLimitComposer([ + new StaticTableDataSafetyLimitPlugin({ + displayText: { maxNameLength: 20 }, + tableSchema: { + maxTablesPerBase: 3, + maxFieldsPerTable: 2, + maxCreateTableFields: 5, + maxCreateTableViews: 2, + maxCreateTableRecords: 2, + maxViewsPerTable: 2, + }, + }), + ]) + ); + const runPlugin = async ( plugin: TableDataSafetyLimitTableOperationPlugin, context: TableOperationPluginContext @@ -359,6 +386,44 @@ describe('TableDataSafetyLimitTableOperationPlugin', () => { expect(result._unsafeUnwrapErr().code).toBe('validation.limit.views_per_table_max'); }); + it('rejects create when the field count exceeds the configured fields-per-table limit', async () => { + const repository = new FakeTableRepository(); + const result = await runPlugin( + createFieldsPerTableOnlyPlugin(repository), + createContext(TableOperationKind.create, { + baseId, + tableName: TableName.create('Create')._unsafeUnwrap(), + fieldCount: 3, + viewCount: 1, + recordCount: 0, + viewNames: ['View A'], + }) + ); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr().code).toBe('validation.limit.fields_per_table_max'); + }); + + it('rejects create when a built field exceeds display text limits', async () => { + const repository = new FakeTableRepository(); + const result = await runPlugin( + createPlugin(repository), + createContext(TableOperationKind.create, { + baseId, + tableName: TableName.create('Create')._unsafeUnwrap(), + table: createTable('e', 'Create', baseId, 'Too Long Field'), + fieldCount: 1, + viewCount: 1, + recordCount: 0, + viewNames: ['View A'], + }) + ); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr().code).toBe('validation.limit.name_max_length'); + expect(result._unsafeUnwrapErr().details?.target).toBe('field.name'); + }); + it('composes multiple table limit plugins with the strictest numeric limit', async () => { const repository = new FakeTableRepository(); const plugin = new TableDataSafetyLimitTableOperationPlugin( diff --git a/packages/v2/core/src/application/services/TableDataSafetyLimitTableOperationPlugin.ts b/packages/v2/core/src/application/services/TableDataSafetyLimitTableOperationPlugin.ts index ab9952d5bc..8340d8135f 100644 --- a/packages/v2/core/src/application/services/TableDataSafetyLimitTableOperationPlugin.ts +++ b/packages/v2/core/src/application/services/TableDataSafetyLimitTableOperationPlugin.ts @@ -2,6 +2,7 @@ import { err, ok } from 'neverthrow'; import type { Result } from 'neverthrow'; import type { BaseId } from '../../domain/base/BaseId'; +import type { IDomainContext } from '../../domain/shared/DomainContext'; import type { DomainError } from '../../domain/shared/DomainError'; import { ensureWithinTableDataSafetyLimit, @@ -9,16 +10,19 @@ import { type ResolvedTableDataSafetyLimitConfig, } from '../../domain/shared/TableDataSafetyLimits'; import { Table } from '../../domain/table/Table'; -import type { IExecutionContext } from '../../ports/ExecutionContext'; +import { getDomainContext, type IExecutionContext } from '../../ports/ExecutionContext'; import type { ITableOperationPlugin, TableOperationPluginContext, } from '../../ports/TableOperationPlugin'; import { TableOperationKind } from '../../ports/TableOperationPlugin'; import type { ITableRepository } from '../../ports/TableRepository'; +import { ensureTableDataSafetyFieldLimits } from './TableDataSafetyLimitFieldOperationPlugin'; +import { ensureTableDataSafetyViewConfigLimits } from './TableDataSafetyLimitViewOperationPlugin'; import { TableDataSafetyLimitComposer } from './TableDataSafetyLimitComposer'; type PreparedTableDataSafetyOperationLimitState = { + readonly domainContext: IDomainContext | undefined; readonly limits: ResolvedTableDataSafetyLimitConfig; }; @@ -48,7 +52,7 @@ export class TableDataSafetyLimitTableOperationPlugin private readonly limitComposer: TableDataSafetyLimitComposer ) {} - supports(): boolean { + supports(_operation: TableOperationKind): boolean { return true; } @@ -57,7 +61,10 @@ export class TableDataSafetyLimitTableOperationPlugin ): Promise> { const configResult = await this.limitComposer.compose(context.executionContext); if (configResult.isErr()) return err(configResult.error); - return ok({ limits: resolveTableDataSafetyLimits(configResult.value) }); + return ok({ + domainContext: getDomainContext(context.executionContext), + limits: resolveTableDataSafetyLimits(configResult.value), + }); } async guard( @@ -83,7 +90,11 @@ export class TableDataSafetyLimitTableOperationPlugin limits ); if (tableCountResult.isErr()) return tableCountResult; - return this.ensureCreatePayloadLimits(context.payload, limits); + return this.ensureCreatePayloadLimits( + context.payload, + limits, + preparedState?.domainContext + ); } case TableOperationKind.createMany: { const tableCountResult = await this.ensureTablesPerBaseLimit( @@ -95,18 +106,30 @@ export class TableDataSafetyLimitTableOperationPlugin if (tableCountResult.isErr()) return tableCountResult; for (const table of context.payload.tables) { - const result = this.ensureCreatePayloadLimits(table, limits); + const result = this.ensureCreatePayloadLimits( + table, + limits, + preparedState?.domainContext + ); if (result.isErr()) return result; } return ok(undefined); } - case TableOperationKind.duplicate: - return this.ensureTablesPerBaseLimit( + case TableOperationKind.duplicate: { + const tableCountResult = await this.ensureTablesPerBaseLimit( context.executionContext, context.payload.baseId, 1, limits ); + if (tableCountResult.isErr()) return tableCountResult; + if (!context.payload.table) return ok(undefined); + return this.ensureTableStructureLimits( + context.payload.table, + limits, + preparedState?.domainContext + ); + } case TableOperationKind.importCsv: { const tableCountResult = await this.ensureTablesPerBaseLimit( context.executionContext, @@ -115,7 +138,11 @@ export class TableDataSafetyLimitTableOperationPlugin limits ); if (tableCountResult.isErr()) return tableCountResult; - return this.ensureImportCsvPayloadLimits(context.payload, limits); + return this.ensureImportCsvPayloadLimits( + context.payload, + limits, + preparedState?.domainContext + ); } case TableOperationKind.rename: return ok(undefined); @@ -128,8 +155,10 @@ export class TableDataSafetyLimitTableOperationPlugin readonly viewCount: number; readonly recordCount: number; readonly viewNames: ReadonlyArray; + readonly table?: Table; }, - limits: ResolvedTableDataSafetyLimitConfig + limits: ResolvedTableDataSafetyLimitConfig, + domainContext: IDomainContext | undefined ): Result { const fieldsResult = ensureWithinTableDataSafetyLimit( 'validation.limit.create_table_fields_max', @@ -139,6 +168,9 @@ export class TableDataSafetyLimitTableOperationPlugin ); if (fieldsResult.isErr()) return fieldsResult; + const fieldsPerTableResult = this.ensureFieldsPerTableLimit(payload.fieldCount, limits); + if (fieldsPerTableResult.isErr()) return fieldsPerTableResult; + const viewsResult = ensureWithinTableDataSafetyLimit( 'validation.limit.create_table_views_max', payload.viewCount, @@ -176,16 +208,74 @@ export class TableDataSafetyLimitTableOperationPlugin if (viewNameResult.isErr()) return viewNameResult; } + if (payload.table) { + return this.ensureTableStructureLimits(payload.table, limits, domainContext); + } + return ok(undefined); } + private ensureTableStructureLimits( + table: Table, + limits: ResolvedTableDataSafetyLimitConfig, + domainContext: IDomainContext | undefined + ): Result { + const fieldsPerTableResult = this.ensureFieldsPerTableLimit(table.getFields().length, limits); + if (fieldsPerTableResult.isErr()) return fieldsPerTableResult; + + const viewsPerTableResult = ensureWithinTableDataSafetyLimit( + 'validation.limit.views_per_table_max', + table.views().length > 0 ? table.views().length : 1, + limits.tableSchema.maxViewsPerTable, + { target: 'table.views' } + ); + if (viewsPerTableResult.isErr()) return viewsPerTableResult; + + for (const field of table.getFields()) { + const fieldResult = ensureTableDataSafetyFieldLimits(field, domainContext, limits); + if (fieldResult.isErr()) return fieldResult; + } + + for (const view of table.views()) { + const queryDefaultsResult = view.queryDefaults(); + const queryDefaults = queryDefaultsResult.isOk() ? queryDefaultsResult.value.toDto() : {}; + const viewResult = ensureTableDataSafetyViewConfigLimits( + { + name: view.name().toString(), + filter: queryDefaults.filter, + sort: queryDefaults.sort, + group: queryDefaults.group, + options: view.options(), + }, + limits + ); + if (viewResult.isErr()) return viewResult; + } + + return ok(undefined); + } + + private ensureFieldsPerTableLimit( + fieldCount: number, + limits: ResolvedTableDataSafetyLimitConfig + ): Result { + return ensureWithinTableDataSafetyLimit( + 'validation.limit.fields_per_table_max', + fieldCount, + limits.tableSchema.maxFieldsPerTable, + { target: 'table.fields' } + ); + } + private ensureImportCsvPayloadLimits( payload: { readonly fieldCount: number; readonly viewCount: number; readonly recordCount: number; + readonly table?: Table; }, - limits: ResolvedTableDataSafetyLimitConfig + limits: ResolvedTableDataSafetyLimitConfig, + domainContext: IDomainContext | undefined ): Result { return this.ensureCreatePayloadLimits( { @@ -193,8 +283,10 @@ export class TableDataSafetyLimitTableOperationPlugin viewCount: payload.viewCount, recordCount: payload.recordCount, viewNames: [], + table: payload.table, }, - limits + limits, + domainContext ); } diff --git a/packages/v2/core/src/application/services/TableDataSafetyLimitViewOperationPlugin.spec.ts b/packages/v2/core/src/application/services/TableDataSafetyLimitViewOperationPlugin.spec.ts new file mode 100644 index 0000000000..2f2380c58b --- /dev/null +++ b/packages/v2/core/src/application/services/TableDataSafetyLimitViewOperationPlugin.spec.ts @@ -0,0 +1,218 @@ +import { describe, expect, it } from 'vitest'; + +import { ActorId } from '../../domain/shared/ActorId'; +import type { TableDataSafetyLimitConfig } from '../../domain/shared/TableDataSafetyLimits'; +import type { IExecutionContext } from '../../ports/ExecutionContext'; +import { + ViewOperationKind, + type ViewOperationPluginContext, +} from '../../ports/ViewOperationPlugin'; +import { + StaticTableDataSafetyLimitPlugin, + TableDataSafetyLimitComposer, +} from './TableDataSafetyLimitComposer'; +import { TableDataSafetyLimitViewOperationPlugin } from './TableDataSafetyLimitViewOperationPlugin'; + +type TableLimits = TableDataSafetyLimitConfig; + +const actorId = ActorId.create('system')._unsafeUnwrap(); + +const filterItem = { + fieldId: 'fldTest', + operator: 'is', + value: 'x', + isSymbol: false, +}; + +const createExecutionContext = (limits?: TableLimits) => + ({ + actorId, + config: limits ? { tableLimits: limits } : undefined, + }) as IExecutionContext; + +const createPlugin = (limits: TableLimits) => + new TableDataSafetyLimitViewOperationPlugin( + new TableDataSafetyLimitComposer([new StaticTableDataSafetyLimitPlugin(limits)]) + ); + +const runPlugin = async ( + plugin: TableDataSafetyLimitViewOperationPlugin, + context: ViewOperationPluginContext +) => { + const preparedResult = await plugin.prepare(context); + if (preparedResult.isErr()) return preparedResult; + return plugin.guard(context, preparedResult.value); +}; + +const createContext = ( + kind: ViewOperationKind, + payload: Record, + limits?: TableLimits +): ViewOperationPluginContext => + ({ + kind, + executionContext: createExecutionContext(limits), + payload, + isTransactionBound: false, + }) as unknown as ViewOperationPluginContext; + +describe('TableDataSafetyLimitViewOperationPlugin', () => { + it('supports all view operation kinds', () => { + const plugin = createPlugin({}); + + expect(plugin.supports(ViewOperationKind.create)).toBe(true); + expect(plugin.supports(ViewOperationKind.duplicate)).toBe(true); + expect(plugin.supports(ViewOperationKind.update)).toBe(true); + }); + + it.each([ + [ViewOperationKind.create, { tableId: 'tblTest', currentViewCount: 1, view: { name: 'Ok' } }], + [ + ViewOperationKind.duplicate, + { tableId: 'tblTest', currentViewCount: 1, addedViewCount: 1, view: { name: 'Ok' } }, + ], + [ViewOperationKind.update, { tableId: 'tblTest', viewId: 'viwTest', patch: { name: 'Ok' } }], + ] satisfies ReadonlyArray]>)( + 'allows %s at configured view operation boundaries', + async (kind, payload) => { + const plugin = createPlugin({ + displayText: { maxNameLength: 2 }, + tableSchema: { maxViewsPerTable: 2 }, + }); + + const result = await runPlugin(plugin, createContext(kind, payload)); + + expect(result.isOk()).toBe(true); + } + ); + + it.each([ + [ + 'validation.limit.views_per_table_max', + ViewOperationKind.create, + { tableId: 'tblTest', currentViewCount: 2, view: {} }, + { tableSchema: { maxViewsPerTable: 2 } }, + ], + [ + 'validation.limit.views_per_table_max', + ViewOperationKind.duplicate, + { tableId: 'tblTest', currentViewCount: 1, addedViewCount: 2, view: {} }, + { tableSchema: { maxViewsPerTable: 2 } }, + ], + [ + 'validation.limit.name_max_length', + ViewOperationKind.update, + { tableId: 'tblTest', viewId: 'viwTest', patch: { name: 'Long' } }, + { displayText: { maxNameLength: 3 } }, + ], + [ + 'validation.limit.description_max_length', + ViewOperationKind.update, + { tableId: 'tblTest', viewId: 'viwTest', patch: { description: 'Long' } }, + { displayText: { maxDescriptionLength: 3 } }, + ], + [ + 'validation.limit.view_filter_items_max', + ViewOperationKind.update, + { + tableId: 'tblTest', + viewId: 'viwTest', + patch: { filter: { conjunction: 'and', filterSet: [filterItem, filterItem] } }, + }, + { viewConfig: { maxFilterItems: 1 } }, + ], + [ + 'validation.limit.view_filter_depth_max', + ViewOperationKind.update, + { + tableId: 'tblTest', + viewId: 'viwTest', + patch: { + filter: { + conjunction: 'and', + filterSet: [{ conjunction: 'and', filterSet: [filterItem] }], + }, + }, + }, + { viewConfig: { maxFilterDepth: 1 } }, + ], + [ + 'validation.limit.view_sort_items_max', + ViewOperationKind.update, + { + tableId: 'tblTest', + viewId: 'viwTest', + patch: { + sort: { + sortObjs: [ + { fieldId: 'fldA', order: 'asc' }, + { fieldId: 'fldB', order: 'desc' }, + ], + }, + }, + }, + { viewConfig: { maxSortItems: 1 } }, + ], + [ + 'validation.limit.view_sort_items_max', + ViewOperationKind.update, + { + tableId: 'tblTest', + viewId: 'viwTest', + patch: { + sort: [ + { fieldId: 'fldA', order: 'asc' }, + { fieldId: 'fldB', order: 'desc' }, + ], + }, + }, + { viewConfig: { maxSortItems: 1 } }, + ], + [ + 'validation.limit.view_group_items_max', + ViewOperationKind.update, + { + tableId: 'tblTest', + viewId: 'viwTest', + patch: { + group: [ + { fieldId: 'fldA', order: 'asc' }, + { fieldId: 'fldB', order: 'desc' }, + ], + }, + }, + { viewConfig: { maxGroupItems: 1 } }, + ], + [ + 'validation.limit.view_options_max_bytes', + ViewOperationKind.update, + { tableId: 'tblTest', viewId: 'viwTest', patch: { options: { rowHeight: 1 } } }, + { viewConfig: { maxOptionsBytes: 4 } }, + ], + ] satisfies ReadonlyArray< + readonly [string, ViewOperationKind, Record, TableLimits] + >)('rejects %s', async (expectedCode, kind, payload, limits) => { + const result = await runPlugin(createPlugin(limits), createContext(kind, payload)); + + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.error.code).toBe(expectedCode); + } + }); + + it('uses execution context table limits through the shared composer', async () => { + const plugin = new TableDataSafetyLimitViewOperationPlugin(); + const context = createContext( + ViewOperationKind.update, + { tableId: 'tblTest', viewId: 'viwTest', patch: { name: 'Long' } }, + { displayText: { maxNameLength: 3 } } + ); + + const result = await runPlugin(plugin, context); + + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.error.code).toBe('validation.limit.name_max_length'); + } + }); +}); diff --git a/packages/v2/core/src/application/services/TableDataSafetyLimitViewOperationPlugin.ts b/packages/v2/core/src/application/services/TableDataSafetyLimitViewOperationPlugin.ts new file mode 100644 index 0000000000..d5a256b1df --- /dev/null +++ b/packages/v2/core/src/application/services/TableDataSafetyLimitViewOperationPlugin.ts @@ -0,0 +1,204 @@ +import { err, ok } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import type { DomainError } from '../../domain/shared/DomainError'; +import { + ensureWithinTableDataSafetyLimit, + measureJsonBytes, + resolveTableDataSafetyLimits, + type ResolvedTableDataSafetyLimitConfig, +} from '../../domain/shared/TableDataSafetyLimits'; +import { + ViewOperationKind, + type IViewOperationPlugin, + type ViewOperationPayloadViewConfig, + type ViewOperationPluginContext, +} from '../../ports/ViewOperationPlugin'; +import { + createDefaultTableDataSafetyLimitComposer, + TableDataSafetyLimitComposer, +} from './TableDataSafetyLimitComposer'; + +type PreparedTableDataSafetyViewLimitState = { + readonly limits: ResolvedTableDataSafetyLimitConfig; +}; + +type FilterSetLike = { + readonly filterSet: ReadonlyArray; +}; + +type FilterNode = FilterSetLike | Readonly>; +type FilterMeasureResult = { itemCount: number; depth: number }; + +const isFilterSet = (value: unknown): value is FilterSetLike => + Boolean( + value && + typeof value === 'object' && + 'filterSet' in value && + Array.isArray((value as { filterSet?: unknown }).filterSet) + ); + +const measureFilter = (filter: unknown): FilterMeasureResult => { + if (filter == null) return { itemCount: 0, depth: 0 }; + + const visit = (node: unknown, depth: number): FilterMeasureResult => { + if (!isFilterSet(node)) return { itemCount: 1, depth }; + + return node.filterSet.reduce( + (acc, child) => { + const childResult = visit(child, depth + 1); + return { + itemCount: acc.itemCount + childResult.itemCount, + depth: Math.max(acc.depth, childResult.depth), + }; + }, + { itemCount: 0, depth } + ); + }; + + return visit(filter, 1); +}; + +const sortItemCount = (sort: unknown): number => { + if (sort == null) return 0; + if (Array.isArray(sort)) return sort.length; + if (typeof sort !== 'object') return 0; + const sortObjs = (sort as { sortObjs?: unknown }).sortObjs; + return Array.isArray(sortObjs) ? sortObjs.length : 0; +}; + +const groupItemCount = (group: unknown): number => (Array.isArray(group) ? group.length : 0); + +export const ensureTableDataSafetyViewOperationLimits = ( + context: ViewOperationPluginContext, + limits: ResolvedTableDataSafetyLimitConfig +): Result => { + if (context.kind === ViewOperationKind.create || context.kind === ViewOperationKind.duplicate) { + const addedViewCount = context.payload.addedViewCount ?? 1; + const viewsPerTableResult = ensureWithinTableDataSafetyLimit( + 'validation.limit.views_per_table_max', + context.payload.currentViewCount + addedViewCount, + limits.tableSchema.maxViewsPerTable, + { + target: 'table.views', + tableId: context.payload.tableId, + currentViewCount: context.payload.currentViewCount, + addedViewCount, + } + ); + if (viewsPerTableResult.isErr()) return viewsPerTableResult; + + return ensureTableDataSafetyViewConfigLimits(context.payload.view, limits); + } + + return ensureTableDataSafetyViewConfigLimits(context.payload.patch, limits); +}; + +export const ensureTableDataSafetyViewConfigLimits = ( + view: ViewOperationPayloadViewConfig, + limits: ResolvedTableDataSafetyLimitConfig +): Result => { + if (view.name != null) { + const nameResult = ensureWithinTableDataSafetyLimit( + 'validation.limit.name_max_length', + view.name.length, + limits.displayText.maxNameLength, + { target: 'view.name' } + ); + if (nameResult.isErr()) return nameResult; + } + + if (view.description != null) { + const descriptionResult = ensureWithinTableDataSafetyLimit( + 'validation.limit.description_max_length', + view.description.length, + limits.displayText.maxDescriptionLength, + { target: 'view.description' } + ); + if (descriptionResult.isErr()) return descriptionResult; + } + + if (view.filter !== undefined) { + const { itemCount, depth } = measureFilter(view.filter); + const filterItemsResult = ensureWithinTableDataSafetyLimit( + 'validation.limit.view_filter_items_max', + itemCount, + limits.viewConfig.maxFilterItems, + { target: 'view.filter' } + ); + if (filterItemsResult.isErr()) return filterItemsResult; + + const filterDepthResult = ensureWithinTableDataSafetyLimit( + 'validation.limit.view_filter_depth_max', + depth, + limits.viewConfig.maxFilterDepth, + { target: 'view.filter' } + ); + if (filterDepthResult.isErr()) return filterDepthResult; + } + + if (view.sort !== undefined) { + const sortResult = ensureWithinTableDataSafetyLimit( + 'validation.limit.view_sort_items_max', + sortItemCount(view.sort), + limits.viewConfig.maxSortItems, + { target: 'view.sort' } + ); + if (sortResult.isErr()) return sortResult; + } + + if (view.group !== undefined) { + const groupResult = ensureWithinTableDataSafetyLimit( + 'validation.limit.view_group_items_max', + groupItemCount(view.group), + limits.viewConfig.maxGroupItems, + { target: 'view.group' } + ); + if (groupResult.isErr()) return groupResult; + } + + if (view.options !== undefined) { + const optionsResult = ensureWithinTableDataSafetyLimit( + 'validation.limit.view_options_max_bytes', + measureJsonBytes(view.options), + limits.viewConfig.maxOptionsBytes, + { target: 'view.options' } + ); + if (optionsResult.isErr()) return optionsResult; + } + + return ok(undefined); +}; + +export class TableDataSafetyLimitViewOperationPlugin + implements IViewOperationPlugin +{ + readonly name = 'table-data-safety-view-operation-limit'; + readonly enforce = 'post' as const; + + constructor( + private readonly limitComposer: TableDataSafetyLimitComposer = createDefaultTableDataSafetyLimitComposer() + ) {} + + supports(_operation: ViewOperationKind): boolean { + return true; + } + + async prepare( + context: ViewOperationPluginContext + ): Promise> { + const configResult = await this.limitComposer.compose(context.executionContext); + if (configResult.isErr()) return err(configResult.error); + return ok({ limits: resolveTableDataSafetyLimits(configResult.value) }); + } + + guard( + context: ViewOperationPluginContext, + preparedState: PreparedTableDataSafetyViewLimitState | undefined + ): Result { + return ensureTableDataSafetyViewOperationLimits( + context, + preparedState?.limits ?? resolveTableDataSafetyLimits() + ); + } +} diff --git a/packages/v2/core/src/application/services/ViewOperationPluginRunner.ts b/packages/v2/core/src/application/services/ViewOperationPluginRunner.ts new file mode 100644 index 0000000000..6a87f30e5d --- /dev/null +++ b/packages/v2/core/src/application/services/ViewOperationPluginRunner.ts @@ -0,0 +1,161 @@ +import { inject, injectable } from '@teable/v2-di'; +import { err, ok } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { domainError, type DomainError } from '../../domain/shared/DomainError'; +import type { IExecutionContext } from '../../ports/ExecutionContext'; +import { NoopLogger } from '../../ports/defaults/NoopLogger'; +import * as LoggerPort from '../../ports/Logger'; +import { v2CoreTokens } from '../../ports/tokens'; +import { + type IViewOperationPlugin, + type ViewOperationPluginContext, + type ViewOperationPluginEnforce, +} from '../../ports/ViewOperationPlugin'; + +type PreparedPluginEntry = { + readonly plugin: IViewOperationPlugin; + readonly preparedState: unknown; +}; + +const enforceOrder = (enforce?: ViewOperationPluginEnforce): number => { + if (enforce === 'pre') return 0; + if (enforce === 'post') return 2; + return 1; +}; + +const createEnforceGroups = ( + items: ReadonlyArray, + getEnforce: (item: T) => ViewOperationPluginEnforce | undefined +): T[][] => { + const groups: [T[], T[], T[]] = [[], [], []]; + + for (const item of items) { + groups[enforceOrder(getEnforce(item))].push(item); + } + + return groups.filter((group) => group.length > 0); +}; + +const withTransactionBoundContext = ( + context: ViewOperationPluginContext, + executionContext: IExecutionContext +): ViewOperationPluginContext => { + return { + ...context, + executionContext, + isTransactionBound: true, + } as ViewOperationPluginContext; +}; + +export class ViewOperationPluginExecution { + constructor( + private readonly logger: LoggerPort.ILogger, + private readonly context: ViewOperationPluginContext, + private readonly preparedPlugins: ReadonlyArray + ) {} + + async guard(executionContext?: IExecutionContext): Promise> { + const context = executionContext + ? withTransactionBoundContext(this.context, executionContext) + : this.context; + + for (const group of createEnforceGroups( + this.preparedPlugins, + (entry) => entry.plugin.enforce + )) { + const results = await Promise.all(group.map((entry) => this.invokeGuard(context, entry))); + + for (const result of results) { + if (result.isErr()) return err(result.error); + } + } + + return ok(undefined); + } + + private async invokeGuard( + context: ViewOperationPluginContext, + entry: PreparedPluginEntry + ): Promise> { + const plugin = entry.plugin; + if (!plugin.guard) return ok(undefined); + + try { + const result = await plugin.guard.call(plugin, context, entry.preparedState); + if (result.isErr()) return err(result.error); + return ok(undefined); + } catch (error) { + return err( + domainError.fromUnknown(error, { + code: 'view_operation_plugin.guard_failed', + details: { + operation: context.kind, + plugin: plugin.name, + }, + }) + ); + } + } + + logSkippedAfterError(pluginName: string, error: DomainError): void { + this.logger.error('View operation plugin failed', { + operation: this.context.kind, + plugin: pluginName, + error, + }); + } +} + +@injectable() +export class ViewOperationPluginRunner { + constructor( + @inject(v2CoreTokens.viewOperationPlugins) + private readonly plugins?: IViewOperationPlugin[], + @inject(v2CoreTokens.logger) + private readonly logger?: LoggerPort.ILogger + ) {} + + async prepare( + context: ViewOperationPluginContext + ): Promise> { + const matchedPlugins = (this.plugins ?? []).filter((plugin) => plugin.supports(context.kind)); + const preparedPlugins: PreparedPluginEntry[] = []; + + for (const group of createEnforceGroups(matchedPlugins, (plugin) => plugin.enforce)) { + const results = await Promise.all(group.map((plugin) => this.preparePlugin(plugin, context))); + + for (const result of results) { + if (result.isErr()) return err(result.error); + preparedPlugins.push(result.value); + } + } + + return ok( + new ViewOperationPluginExecution(this.logger ?? new NoopLogger(), context, preparedPlugins) + ); + } + + private async preparePlugin( + plugin: IViewOperationPlugin, + context: ViewOperationPluginContext + ): Promise> { + if (!plugin.prepare) return ok({ plugin, preparedState: undefined }); + + try { + const result = await plugin.prepare.call(plugin, context); + if (result.isErr()) return err(result.error); + return ok({ plugin, preparedState: result.value }); + } catch (error) { + return err( + domainError.fromUnknown(error, { + code: 'view_operation_plugin.prepare_failed', + details: { + operation: context.kind, + plugin: plugin.name, + }, + }) + ); + } + } +} diff --git a/packages/v2/core/src/commands/CreateTableHandler.ts b/packages/v2/core/src/commands/CreateTableHandler.ts index 583c8f393d..629170de94 100644 --- a/packages/v2/core/src/commands/CreateTableHandler.ts +++ b/packages/v2/core/src/commands/CreateTableHandler.ts @@ -103,6 +103,7 @@ export class CreateTableHandler implements ICommandHandler ({ baseId: command.baseId, tableName: table.name(), + table, fieldCount: table.getFields().length, viewCount: table.views().length, recordCount: tableCommands[index]?.records.length ?? 0, diff --git a/packages/v2/core/src/commands/DuplicateTableHandler.ts b/packages/v2/core/src/commands/DuplicateTableHandler.ts index e397f11509..c97204a36c 100644 --- a/packages/v2/core/src/commands/DuplicateTableHandler.ts +++ b/packages/v2/core/src/commands/DuplicateTableHandler.ts @@ -114,6 +114,7 @@ export class DuplicateTableHandler payload: { baseId: command.baseId, tableName: command.name, + table: duplicated.table, includeRecords: command.includeRecords, }, isTransactionBound: false, diff --git a/packages/v2/core/src/commands/ImportCsvHandler.ts b/packages/v2/core/src/commands/ImportCsvHandler.ts index c065b938a3..eb8f5ba4e8 100644 --- a/packages/v2/core/src/commands/ImportCsvHandler.ts +++ b/packages/v2/core/src/commands/ImportCsvHandler.ts @@ -146,6 +146,7 @@ export class ImportCsvHandler implements ICommandHandler + ): Promise> { return err({ code: 'not_implemented', message: 'not implemented', @@ -76,7 +89,11 @@ class FakeTableRepository implements ITableRepository { }); } - async find() { + async find( + _: IExecutionContext, + __: ISpecification, + ___?: IFindOptions + ): Promise, DomainError>> { return ok([]); } @@ -146,7 +163,7 @@ class FakeTableCreationService { ): Promise> { const persisted = await this.persistMetadata(context, input); if (persisted.isErr()) { - return persisted; + return err(persisted.error); } return this.provisionData(context, { ...input, @@ -403,4 +420,54 @@ describe('ImportDotTeaStructureHandler', () => { expect(firstValue.fieldIdMap[fieldId]).not.toBe(secondValue.fieldIdMap[fieldId]); expect(firstValue.viewIdMap[viewId]).not.toBe(secondValue.viewIdMap[viewId]); }); + + it('runs table operation safety limits for imported structures', async () => { + const parser = new FakeDotTeaParser( + ok({ + tables: [ + { + name: 'Products', + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { name: 'Sku', type: 'singleLineText' }, + ], + }, + ], + }) + ); + const tableRepository = new FakeTableRepository(); + const tableCreationService = new FakeTableCreationService(); + const tableOperationPluginRunner = createTableOperationPluginRunner([ + new TableDataSafetyLimitTableOperationPlugin( + tableRepository, + new TableDataSafetyLimitComposer([ + new StaticTableDataSafetyLimitPlugin({ + tableSchema: { + maxFieldsPerTable: 1, + }, + }), + ]) + ), + ]); + const handler = new ImportDotTeaStructureHandler( + parser, + new FakeForeignTableLoaderService() as never, + tableRepository, + tableCreationService as never, + new FakeEventBus(), + new FakeUnitOfWork(), + tableOperationPluginRunner + ); + + const command = ImportDotTeaStructureCommand.createFromBuffer({ + baseId, + dotTeaData: new Uint8Array([1]), + })._unsafeUnwrap(); + + const result = await handler.handle(createContext(), command); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr().code).toBe('validation.limit.fields_per_table_max'); + expect(tableCreationService.lastInput).toBeUndefined(); + }); }); diff --git a/packages/v2/core/src/commands/ImportDotTeaStructureHandler.ts b/packages/v2/core/src/commands/ImportDotTeaStructureHandler.ts index 6e782eed4c..582b82f8c8 100644 --- a/packages/v2/core/src/commands/ImportDotTeaStructureHandler.ts +++ b/packages/v2/core/src/commands/ImportDotTeaStructureHandler.ts @@ -4,6 +4,7 @@ import type { Result } from 'neverthrow'; import { ForeignTableLoaderService } from '../application/services/ForeignTableLoaderService'; import { TableCreationService } from '../application/services/TableCreationService'; +import { TableOperationPluginRunner } from '../application/services/TableOperationPluginRunner'; import { beginTablesSchemaOperation, completeTablesSchemaOperation, @@ -20,11 +21,13 @@ import { TableId } from '../domain/table/TableId'; import { ViewId } from '../domain/table/views/ViewId'; import * as DotTeaParserPort from '../ports/DotTeaParser'; import type { NormalizedDotTeaStructure } from '../ports/DotTeaParser'; +import { NoopLogger } from '../ports/defaults/NoopLogger'; import * as EventBusPort from '../ports/EventBus'; import * as ExecutionContextPort from '../ports/ExecutionContext'; import * as TableRepositoryPort from '../ports/TableRepository'; import { v2CoreTokens } from '../ports/tokens'; import { TraceSpan } from '../ports/TraceSpan'; +import { TableOperationKind } from '../ports/TableOperationPlugin'; import * as UnitOfWorkPort from '../ports/UnitOfWork'; import type { ITableFieldInput } from '../schemas/field'; import { CommandHandler, type ICommandHandler } from './CommandHandler'; @@ -177,7 +180,12 @@ export class ImportDotTeaStructureHandler @inject(v2CoreTokens.eventBus) private readonly eventBus: EventBusPort.IEventBus, @inject(v2CoreTokens.unitOfWork) - private readonly unitOfWork: UnitOfWorkPort.IUnitOfWork + private readonly unitOfWork: UnitOfWorkPort.IUnitOfWork, + @inject(v2CoreTokens.tableOperationPluginRunner) + private readonly tableOperationPluginRunner: TableOperationPluginRunner = new TableOperationPluginRunner( + [], + new NoopLogger() + ) ) {} @TraceSpan() @@ -310,6 +318,25 @@ export class ImportDotTeaStructureHandler // Extract tables and foreign references from build results const builtTables = buildResults.map((r) => r.table); + const tablePluginExecution = yield* await handler.tableOperationPluginRunner.prepare({ + kind: TableOperationKind.createMany, + executionContext: context, + payload: { + baseId: command.baseId, + tables: builtTables.map((table) => ({ + baseId: command.baseId, + tableName: table.name(), + table, + fieldCount: table.getFields().length, + viewCount: table.views().length, + recordCount: 0, + viewNames: table.views().map((view) => view.name().toString()), + })), + }, + isTransactionBound: false, + }); + yield* await tablePluginExecution.guard(); + const recordCountByTableId = Object.fromEntries( builtTables.map((table) => [table.id().toString(), 0]) ); diff --git a/packages/v2/core/src/commands/ImportRecordsHandler.spec.ts b/packages/v2/core/src/commands/ImportRecordsHandler.spec.ts index 2612148452..7b33383c26 100644 --- a/packages/v2/core/src/commands/ImportRecordsHandler.spec.ts +++ b/packages/v2/core/src/commands/ImportRecordsHandler.spec.ts @@ -427,7 +427,7 @@ describe('ImportRecordsHandler', () => { expectRecordWritePluginToBeSkipped(calls, RecordWriteOperationKind.importAppend); }); - it('returns validation.max_row_limit for async sources that exceed maxRowCount', async () => { + it('returns validation.limit.rows_per_table_max for async sources that exceed maxRowCount', async () => { const { table, textFieldId } = buildTable(); async function* rowsAsync() { yield ['row 1']; @@ -472,7 +472,7 @@ describe('ImportRecordsHandler', () => { ); expect(result.isErr()).toBe(true); - expect(result._unsafeUnwrapErr().code).toBe('validation.max_row_limit'); + expect(result._unsafeUnwrapErr().code).toBe('validation.limit.rows_per_table_max'); expect(tableRecordRepository.inserted).toHaveLength(0); expect(calls.prepare).toHaveLength(0); expect(calls.guard).toHaveLength(0); diff --git a/packages/v2/core/src/commands/ImportRecordsHandler.ts b/packages/v2/core/src/commands/ImportRecordsHandler.ts index dbf3f16110..8e97804f6c 100644 --- a/packages/v2/core/src/commands/ImportRecordsHandler.ts +++ b/packages/v2/core/src/commands/ImportRecordsHandler.ts @@ -134,9 +134,10 @@ export class ImportRecordsHandler if (error instanceof MaxRowCountExceededError) { return err( domainError.validation({ - code: 'validation.max_row_limit', + code: 'validation.limit.rows_per_table_max', message: `Exceed max row limit: ${error.maxRowCount}`, details: { + max: error.maxRowCount, maxRowCount: error.maxRowCount, rowCount: error.rowCount, }, diff --git a/packages/v2/core/src/di/registerCoreServices.ts b/packages/v2/core/src/di/registerCoreServices.ts index 6895ba6861..541664b6e5 100644 --- a/packages/v2/core/src/di/registerCoreServices.ts +++ b/packages/v2/core/src/di/registerCoreServices.ts @@ -40,12 +40,14 @@ import { TableFieldLimitFieldOperationPlugin } from '../application/services/Tab import { TableDataSafetyLimitFieldOperationPlugin } from '../application/services/TableDataSafetyLimitFieldOperationPlugin'; import { TableDataSafetyLimitRecordWritePlugin } from '../application/services/TableDataSafetyLimitRecordWritePlugin'; import { TableDataSafetyLimitTableOperationPlugin } from '../application/services/TableDataSafetyLimitTableOperationPlugin'; +import { TableDataSafetyLimitViewOperationPlugin } from '../application/services/TableDataSafetyLimitViewOperationPlugin'; import { TableOperationPluginRunner } from '../application/services/TableOperationPluginRunner'; import { TableQueryService } from '../application/services/TableQueryService'; import { TableSchemaOperationRepairHandler } from '../application/services/TableSchemaOperationRepairHandler'; import { TableUpdateFlow } from '../application/services/TableUpdateFlow'; import { UndoRedoStackService } from '../application/services/UndoRedoStackService'; import { UserValueResolverService } from '../application/services/UserValueResolverService'; +import { ViewOperationPluginRunner } from '../application/services/ViewOperationPluginRunner'; import { PasteStreamApplicationService } from '../commands/PasteHandler'; import { NoopAttachmentUrlSignerService } from '../ports/defaults/NoopAttachmentUrlSignerService'; import { NoopRecordOrderCalculator } from '../ports/defaults/NoopRecordOrderCalculator'; @@ -54,11 +56,13 @@ import type { IFieldOperationPlugin } from '../ports/FieldOperationPlugin'; import type { IRecordWritePlugin } from '../ports/RecordWritePlugin'; import type { ITableDataSafetyLimitPlugin } from '../ports/TableDataSafetyLimitPlugin'; import type { ITableOperationPlugin } from '../ports/TableOperationPlugin'; +import type { IViewOperationPlugin } from '../ports/ViewOperationPlugin'; import { v2CoreTokens } from '../ports/tokens'; import { registerFieldOperationPlugin } from './registerFieldOperationPlugin'; import { registerRecordWritePlugin } from './registerRecordWritePlugin'; import { registerTableDataSafetyLimitPlugin } from './registerTableDataSafetyLimitPlugin'; import { registerTableOperationPlugin } from './registerTableOperationPlugin'; +import { registerViewOperationPlugin } from './registerViewOperationPlugin'; /** * Register all v2 core internal application services. @@ -393,6 +397,24 @@ export const registerV2CoreServices = ( }); } + if (!container.isRegistered(v2CoreTokens.viewOperationPlugins)) { + container.registerInstance(v2CoreTokens.viewOperationPlugins, [] as IViewOperationPlugin[]); + } + + registerViewOperationPlugin( + container, + new TableDataSafetyLimitViewOperationPlugin(tableDataSafetyLimitComposer), + { + source: 'registerV2CoreServices', + } + ); + + if (!container.isRegistered(v2CoreTokens.viewOperationPluginRunner)) { + container.register(v2CoreTokens.viewOperationPluginRunner, ViewOperationPluginRunner, { + lifecycle, + }); + } + // RecordMutationSpecResolverService - resolve external values in specs if (!container.isRegistered(v2CoreTokens.recordMutationSpecResolverService)) { container.register( diff --git a/packages/v2/core/src/di/registerViewOperationPlugin.ts b/packages/v2/core/src/di/registerViewOperationPlugin.ts new file mode 100644 index 0000000000..5a58853d2c --- /dev/null +++ b/packages/v2/core/src/di/registerViewOperationPlugin.ts @@ -0,0 +1,68 @@ +import type { DependencyContainer } from '@teable/v2-di'; + +import { NoopLogger } from '../ports/defaults/NoopLogger'; +import type { ILogger } from '../ports/Logger'; +import { v2CoreTokens } from '../ports/tokens'; +import type { IViewOperationPlugin } from '../ports/ViewOperationPlugin'; + +export interface IRegisterViewOperationPluginOptions { + source?: string; + logger?: ILogger; +} + +export interface IRegisterViewOperationPluginResult { + plugin: IViewOperationPlugin; + registered: boolean; + totalPlugins: number; +} + +const resolveLogger = (container: DependencyContainer, explicitLogger?: ILogger): ILogger => { + if (explicitLogger) return explicitLogger; + if (container.isRegistered(v2CoreTokens.logger)) { + return container.resolve(v2CoreTokens.logger); + } + return new NoopLogger(); +}; + +const ensurePluginRegistry = (container: DependencyContainer): IViewOperationPlugin[] => { + if (!container.isRegistered(v2CoreTokens.viewOperationPlugins)) { + container.registerInstance(v2CoreTokens.viewOperationPlugins, [] as IViewOperationPlugin[]); + } + + return container.resolve(v2CoreTokens.viewOperationPlugins); +}; + +export const registerViewOperationPlugin = ( + container: DependencyContainer, + plugin: IViewOperationPlugin, + options: IRegisterViewOperationPluginOptions = {} +): IRegisterViewOperationPluginResult => { + const plugins = ensurePluginRegistry(container); + const logger = resolveLogger(container, options.logger).scope('viewOperationPlugin', { + plugin: plugin.name, + source: options.source, + }); + + const existingPlugin = plugins.find((registeredPlugin) => registeredPlugin.name === plugin.name); + if (existingPlugin) { + logger.info('View operation plugin already registered', { + totalPlugins: plugins.length, + }); + return { + plugin: existingPlugin, + registered: false, + totalPlugins: plugins.length, + }; + } + + plugins.push(plugin); + logger.info('View operation plugin registered', { + totalPlugins: plugins.length, + }); + + return { + plugin, + registered: true, + totalPlugins: plugins.length, + }; +}; diff --git a/packages/v2/core/src/domain/table/Table.ts b/packages/v2/core/src/domain/table/Table.ts index 2728272db2..b1d0dda697 100644 --- a/packages/v2/core/src/domain/table/Table.ts +++ b/packages/v2/core/src/domain/table/Table.ts @@ -1138,12 +1138,13 @@ export class Table extends AggregateRoot { } } - // Check for name uniqueness if name changed (excluding the current field) - const nameConflict = this.fieldsValue.some( - (f) => !f.id().equals(fieldId) && f.name().equals(newField.name()) - ); - if (nameConflict) { - return err(domainError.conflict({ message: 'Field names must be unique' })); + if (!oldField.name().equals(newField.name())) { + const nameConflict = this.fieldsValue.some( + (f) => !f.id().equals(fieldId) && f.name().equals(newField.name()) + ); + if (nameConflict) { + return err(domainError.conflict({ message: 'Field names must be unique' })); + } } const nextFields = this.fieldsValue.map((f) => (f.id().equals(fieldId) ? newField : f)); diff --git a/packages/v2/core/src/index.ts b/packages/v2/core/src/index.ts index c2f5bf371b..71ddb4b00f 100644 --- a/packages/v2/core/src/index.ts +++ b/packages/v2/core/src/index.ts @@ -31,7 +31,9 @@ export * from './application/services/TableDataSafetyLimitComposer'; export * from './application/services/TableDataSafetyLimitFieldOperationPlugin'; export * from './application/services/TableDataSafetyLimitRecordWritePlugin'; export * from './application/services/TableDataSafetyLimitTableOperationPlugin'; +export * from './application/services/TableDataSafetyLimitViewOperationPlugin'; export * from './application/services/TableOperationPluginRunner'; +export * from './application/services/ViewOperationPluginRunner'; export * from './application/services/FieldKeyResolverService'; export * from './application/services/FieldDeletionSideEffectService'; export * from './application/services/FieldUndoRedoReplayService'; @@ -155,6 +157,7 @@ export * from './ports/ComputedUpdateDrainService'; export * from './ports/FieldOperationPlugin'; export * from './ports/TableDataSafetyLimitPlugin'; export * from './ports/TableOperationPlugin'; +export * from './ports/ViewOperationPlugin'; export * from './ports/UserRenamePropagationService'; export * from './ports/UserLookupService'; export * from './ports/UserAvatarUrl'; @@ -183,6 +186,7 @@ export * from './di/registerFieldOperationPlugin'; export * from './di/registerRecordWritePlugin'; export * from './di/registerTableDataSafetyLimitPlugin'; export * from './di/registerTableOperationPlugin'; +export * from './di/registerViewOperationPlugin'; export * from './domain/table/resolveFormulaFields'; export { FunctionName, FormulaFuncType } from './domain/formula/functions/common'; export { normalizeFunctionNameAlias } from './domain/formula/function-aliases'; diff --git a/packages/v2/core/src/ports/TableOperationPlugin.ts b/packages/v2/core/src/ports/TableOperationPlugin.ts index 4757ad644f..346e835bd5 100644 --- a/packages/v2/core/src/ports/TableOperationPlugin.ts +++ b/packages/v2/core/src/ports/TableOperationPlugin.ts @@ -2,6 +2,7 @@ import type { Result } from 'neverthrow'; import type { BaseId } from '../domain/base/BaseId'; import type { DomainError } from '../domain/shared/DomainError'; +import type { Table } from '../domain/table/Table'; import type { TableName } from '../domain/table/TableName'; import type { IExecutionContext } from './ExecutionContext'; import type { PluginTraceContext } from './Tracer'; @@ -31,6 +32,7 @@ interface ITableOperationPluginContextBase = Result | Promise>; + +export type ViewOperationPayloadViewConfig = { + readonly name?: string | null; + readonly description?: string | null; + readonly filter?: unknown; + readonly sort?: unknown; + readonly group?: unknown; + readonly options?: unknown; +}; + +type ViewOperationCountLimitPayload = { + readonly tableId: string; + readonly currentViewCount: number; + readonly addedViewCount?: number; +}; + +export type ViewOperationCreatePayload = ViewOperationCountLimitPayload & { + readonly view: ViewOperationPayloadViewConfig; +}; + +export type ViewOperationDuplicatePayload = ViewOperationCountLimitPayload & { + readonly sourceViewId?: string; + readonly view: ViewOperationPayloadViewConfig; +}; + +export type ViewOperationUpdatePayload = { + readonly tableId: string; + readonly viewId: string; + readonly patch: ViewOperationPayloadViewConfig; +}; + +interface IViewOperationPluginContextBase { + readonly kind: TKind; + readonly executionContext: IExecutionContext; + readonly payload: TPayload; + readonly trace?: PluginTraceContext; + readonly isTransactionBound: boolean; +} + +export type IViewOperationCreateContext = IViewOperationPluginContextBase< + 'create', + ViewOperationCreatePayload +>; + +export type IViewOperationDuplicateContext = IViewOperationPluginContextBase< + 'duplicate', + ViewOperationDuplicatePayload +>; + +export type IViewOperationUpdateContext = IViewOperationPluginContextBase< + 'update', + ViewOperationUpdatePayload +>; + +export type ViewOperationPluginContextMap = { + create: IViewOperationCreateContext; + duplicate: IViewOperationDuplicateContext; + update: IViewOperationUpdateContext; +}; + +export type ViewOperationPluginContext = ViewOperationPluginContextMap[ViewOperationKind]; + +export interface IViewOperationPlugin { + readonly name: string; + readonly enforce?: ViewOperationPluginEnforce; + + supports(operation: ViewOperationKind): boolean; + + prepare?(context: ViewOperationPluginContext): ViewOperationPluginHookResult; + + guard?( + context: ViewOperationPluginContext, + preparedState: TPreparedState | undefined + ): ViewOperationPluginHookResult; +} diff --git a/packages/v2/core/src/ports/tokens.ts b/packages/v2/core/src/ports/tokens.ts index e2c9a3212f..ab4fa76ac6 100644 --- a/packages/v2/core/src/ports/tokens.ts +++ b/packages/v2/core/src/ports/tokens.ts @@ -47,6 +47,8 @@ export const v2CoreTokens = { fieldOperationPlugins: Symbol('v2.core.fieldOperationPlugins'), tableOperationPluginRunner: Symbol('v2.core.tableOperationPluginRunner'), tableOperationPlugins: Symbol('v2.core.tableOperationPlugins'), + viewOperationPluginRunner: Symbol('v2.core.viewOperationPluginRunner'), + viewOperationPlugins: Symbol('v2.core.viewOperationPlugins'), tableDataSafetyLimitComposer: Symbol('v2.core.tableDataSafetyLimitComposer'), tableDataSafetyLimitPlugins: Symbol('v2.core.tableDataSafetyLimitPlugins'), tableMapper: Symbol('v2.core.tableMapper'), diff --git a/packages/v2/e2e/src/deleteTable.e2e.spec.ts b/packages/v2/e2e/src/deleteTable.e2e.spec.ts index 51675ff69c..6c0b330522 100644 --- a/packages/v2/e2e/src/deleteTable.e2e.spec.ts +++ b/packages/v2/e2e/src/deleteTable.e2e.spec.ts @@ -665,6 +665,94 @@ describe('v2 http deleteTable (e2e)', () => { } }); + it('deletes a foreign table when referencing link fields already have duplicate names', async () => { + let foreignTableId: string | undefined; + let hostTableId: string | undefined; + + try { + const foreignTable = await ctx.createTable({ + baseId: ctx.baseId, + name: nextName('DeleteTable Duplicate Link Name Foreign'), + fields: [{ type: 'singleLineText', name: 'Name', isPrimary: true }], + }); + foreignTableId = foreignTable.id; + + const foreignPrimaryFieldId = foreignTable.fields.find((field) => field.isPrimary)?.id; + if (!foreignPrimaryFieldId) { + throw new Error('Missing duplicate-name foreign primary field'); + } + + const hostTable = await ctx.createTable({ + baseId: ctx.baseId, + name: nextName('DeleteTable Duplicate Link Name Host'), + fields: [{ type: 'singleLineText', name: 'Host Name', isPrimary: true }], + }); + hostTableId = hostTable.id; + + const tableWithFirstLink = await ctx.createField({ + baseId: ctx.baseId, + tableId: hostTable.id, + field: { + type: 'link', + name: 'Public Release Drafts A', + options: { + relationship: 'manyOne', + foreignTableId: foreignTable.id, + lookupFieldId: foreignPrimaryFieldId, + isOneWay: true, + }, + }, + }); + const firstLinkFieldId = tableWithFirstLink.fields.find( + (field) => field.name === 'Public Release Drafts A' + )?.id; + if (!firstLinkFieldId) { + throw new Error('Missing first duplicate-name link field'); + } + + const tableWithSecondLink = await ctx.createField({ + baseId: ctx.baseId, + tableId: hostTable.id, + field: { + type: 'link', + name: 'Public Release Drafts B', + options: { + relationship: 'manyOne', + foreignTableId: foreignTable.id, + lookupFieldId: foreignPrimaryFieldId, + isOneWay: true, + }, + }, + }); + const secondLinkFieldId = tableWithSecondLink.fields.find( + (field) => field.name === 'Public Release Drafts B' + )?.id; + if (!secondLinkFieldId) { + throw new Error('Missing second duplicate-name link field'); + } + + await sql` + UPDATE "field" + SET "name" = 'Public Release Drafts' + WHERE "id" IN (${firstLinkFieldId}, ${secondLinkFieldId}) + `.execute(ctx.testContainer.db); + + await ctx.deleteTable(foreignTable.id, { mode: 'soft' }); + + const refreshedHost = await ctx.getTableById(hostTable.id); + const convertedFields = refreshedHost.fields.filter((field) => + [firstLinkFieldId, secondLinkFieldId].includes(field.id) + ); + + expect(convertedFields).toHaveLength(2); + expect(convertedFields.every((field) => field.name === 'Public Release Drafts')).toBe(true); + expect(convertedFields.every((field) => field.type === 'singleLineText')).toBe(true); + } finally { + await safeDeleteTable(hostTableId); + await safeDeleteTable(foreignTableId); + } + }); + it('publishes schema refresh action triggers for affected host tables during delete-table side effects', async () => { let foreignTableId: string | undefined; let hostTableId: string | undefined; diff --git a/packages/v2/formula-sql-pg/src/FormulaSqlPgExpressionBuilder.ts b/packages/v2/formula-sql-pg/src/FormulaSqlPgExpressionBuilder.ts index c7c9a7301c..f03aeddef9 100644 --- a/packages/v2/formula-sql-pg/src/FormulaSqlPgExpressionBuilder.ts +++ b/packages/v2/formula-sql-pg/src/FormulaSqlPgExpressionBuilder.ts @@ -1157,7 +1157,8 @@ export class FormulaSqlPgExpressionBuilder { } const textValue = this.coerceToString(base); - const numericCast = this.buildLooseNumericCast(textValue.valueSql); + const numericTextSql = this.nullifyBlankCaseBranches(textValue.valueSql); + const numericCast = this.buildLooseNumericCast(numericTextSql); const valueSql = numericCast.valueSql; const errorCondition = numericCast.invalidSql; const combinedErrorCondition = combineErrorConditions([ @@ -1476,6 +1477,10 @@ export class FormulaSqlPgExpressionBuilder { ); } + private nullifyBlankCaseBranches(valueSql: string): string { + return valueSql.replace(/\b(THEN|ELSE)\s+''(?=\s|$)/g, '$1 NULL'); + } + protected getFieldTypeName(expr: SqlExpr): string | undefined { return expr.field?.type().toString(); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8a971dde3..1ce8d29507 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31824,7 +31824,7 @@ snapshots: '@types/acorn@4.0.6': dependencies: - '@types/estree': 1.0.6 + '@types/estree': 1.0.8 '@types/archiver@6.0.3': dependencies: @@ -32060,7 +32060,7 @@ snapshots: '@types/estree-jsx@1.0.5': dependencies: - '@types/estree': 1.0.6 + '@types/estree': 1.0.8 '@types/estree@0.0.39': {} @@ -40083,7 +40083,7 @@ snapshots: micromark-extension-mdx-expression@3.0.0: dependencies: - '@types/estree': 1.0.6 + '@types/estree': 1.0.8 devlop: 1.1.0 micromark-factory-mdx-expression: 2.0.2 micromark-factory-space: 2.0.1 @@ -40095,7 +40095,7 @@ snapshots: micromark-extension-mdx-jsx@3.0.1: dependencies: '@types/acorn': 4.0.6 - '@types/estree': 1.0.6 + '@types/estree': 1.0.8 devlop: 1.1.0 estree-util-is-identifier-name: 3.0.0 micromark-factory-mdx-expression: 2.0.2 @@ -40112,7 +40112,7 @@ snapshots: micromark-extension-mdxjs-esm@3.0.0: dependencies: - '@types/estree': 1.0.6 + '@types/estree': 1.0.8 devlop: 1.1.0 micromark-core-commonmark: 2.0.2 micromark-util-character: 2.1.1 @@ -40148,7 +40148,7 @@ snapshots: micromark-factory-mdx-expression@2.0.2: dependencies: - '@types/estree': 1.0.6 + '@types/estree': 1.0.8 devlop: 1.1.0 micromark-factory-space: 2.0.1 micromark-util-character: 2.1.1 @@ -40213,7 +40213,7 @@ snapshots: micromark-util-events-to-acorn@2.0.2: dependencies: '@types/acorn': 4.0.6 - '@types/estree': 1.0.6 + '@types/estree': 1.0.8 '@types/unist': 3.0.3 devlop: 1.1.0 estree-util-visit: 2.0.0