Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ node_modules
/build
/dist/

# Next.js auto-generated type definitions
**/next-env.d.ts

# Cache
*.tsbuildinfo
**/.eslintcache
Expand Down
4 changes: 2 additions & 2 deletions apps/nestjs-backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@
"dotenv-flow-cli": "1.1.1",
"es-check": "7.1.1",
"eslint": "8.57.0",
"eslint-config-next": "14.2.14",
"eslint-config-next": "15.5.9",
"get-tsconfig": "4.7.3",
"istanbul-merge": "2.0.0",
"npm-run-all2": "6.1.2",
Expand Down Expand Up @@ -213,7 +213,7 @@
"nestjs-i18n": "10.5.1",
"nestjs-pino": "4.4.1",
"nestjs-redoc": "2.2.2",
"next": "14.2.35",
"next": "16.1.3",
"node-fetch": "2.7.0",
"node-sql-parser": "5.3.8",
"nodemailer": "6.9.13",
Expand Down
3 changes: 2 additions & 1 deletion apps/nestjs-backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { AuthModule } from './features/auth/auth.module';
import { BaseModule } from './features/base/base.module';
import { BaseNodeModule } from './features/base-node/base-node.module';
import { BuiltinAssetsInitModule } from './features/builtin-assets-init';
import { CanaryModule } from './features/canary';
import { ChatModule } from './features/chat/chat.module';
import { CollaboratorModule } from './features/collaborator/collaborator.module';
import { CommentOpenApiModule } from './features/comment/comment-open-api.module';
Expand All @@ -36,7 +37,6 @@ import { PluginModule } from './features/plugin/plugin.module';
import { PluginContextMenuModule } from './features/plugin-context-menu/plugin-context-menu.module';
import { PluginPanelModule } from './features/plugin-panel/plugin-panel.module';
import { SelectionModule } from './features/selection/selection.module';
import { CanaryModule } from './features/canary';
import { AdminOpenApiModule } from './features/setting/open-api/admin-open-api.module';
import { SettingOpenApiModule } from './features/setting/open-api/setting-open-api.module';
import { ShareModule } from './features/share/share.module';
Expand Down Expand Up @@ -115,6 +115,7 @@ export const appModules = {
}
const redis = new Redis(redisUri, { lazyConnect: true, maxRetriesPerRequest: null });
await redis.connect();

return {
connection: redis,
};
Expand Down
4 changes: 3 additions & 1 deletion apps/nestjs-backend/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ export async function setUpAppMiddleware(app: INestApplication, configService: C
app.useGlobalPipes(
new ValidationPipe({ transform: true, stopAtFirstError: true, forbidUnknownValues: false })
);
app.use(helmet());
// HSTS is configured at the WAF level. Disable it here to avoid sending duplicate
// `Strict-Transport-Security` headers with potentially different max-age values.
app.use(helmet({ hsts: false }));
app.use(json({ limit: '50mb' }));
app.use(urlencoded({ limit: '50mb', extended: true }));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ConfigurableModuleBuilder, Module } from '@nestjs/common';
import { EventEmitterModule as BaseEventEmitterModule } from '@nestjs/event-emitter';
import { AttachmentsTableModule } from '../features/attachments/attachments-table.module';
import { NotificationModule } from '../features/notification/notification.module';
import { RecordModule } from '../features/record/record.module';
import { ShareDbModule } from '../share-db/share-db.module';
import { EventEmitterService } from './event-emitter.service';
import { ActionTriggerListener } from './listeners/action-trigger.listener';
Expand Down Expand Up @@ -32,7 +33,7 @@ export class EventEmitterModule extends EventEmitterModuleClass {
});

return {
imports: [module, ShareDbModule, NotificationModule, AttachmentsTableModule],
imports: [module, ShareDbModule, NotificationModule, AttachmentsTableModule, RecordModule],
module: EventEmitterModule,
global,
providers: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import type { IRecord, IUserCellValue } from '@teable/core';
import { FieldType } from '@teable/core';
import { PrismaService } from '@teable/db-main-prisma';
import { Knex } from 'knex';
import { has, intersection, isEmpty, keyBy } from 'lodash';
import { has, intersection, isEmpty, keyBy, uniq } from 'lodash';
import { InjectModel } from 'nest-knexjs';
import { NotificationService } from '../../features/notification/notification.service';
import { RecordService } from '../../features/record/record.service';
import type { IChangeRecord, IChangeValue, RecordCreateEvent, RecordUpdateEvent } from '../events';
import { Events } from '../events';

Expand All @@ -20,13 +21,17 @@ type IUserField = {
fieldOptions: string;
};

// Maximum number of record titles to fetch for notification display
const maxRecordTitles = 10;

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

constructor(
private readonly prismaService: PrismaService,
private readonly notificationService: NotificationService,
private readonly recordService: RecordService,
@InjectModel('CUSTOM_KNEX') private readonly knex: Knex
) {}

Expand Down Expand Up @@ -78,9 +83,20 @@ export class CollaboratorNotificationListener {

const notificationData = this.extractNotificationData(recordSets, userFieldIds);

// Collect record IDs that need titles (limited to maxRecordTitles per user)
const recordIdsNeedingTitles = uniq(
Object.values(notificationData).flatMap((data) => data.recordIds.slice(0, maxRecordTitles))
);
const recordTitles =
recordIdsNeedingTitles.length > 0
? await this.recordService.getRecordsHeadWithIds(tableId, recordIdsNeedingTitles)
: [];
const recordTitlesMap = keyBy(recordTitles, 'id');

for (const userId in notificationData) {
const { fieldId, recordIds } = notificationData[userId];
const field = userFields[fieldId];
const recordIdsForTitles = recordIds.slice(0, maxRecordTitles);

await this.notificationService.sendCollaboratorNotify({
fromUserId: user?.id || '',
Expand All @@ -91,6 +107,7 @@ export class CollaboratorNotificationListener {
tableName: field.tableName,
fieldName: field.fieldName,
recordIds: recordIds,
recordTitles: recordIdsForTitles.map((id) => recordTitlesMap[id]).filter(Boolean),
},
});
}
Expand Down
11 changes: 10 additions & 1 deletion apps/nestjs-backend/src/features/base/base-export.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -993,7 +993,16 @@ export class BaseExportService {
({ type, isLookup }) =>
isLookup || type === FieldType.Rollup || type === FieldType.ConditionalRollup
)
.filter(({ lookupOptions }) => {
.filter((field) => {
const { lookupOptions, type, options } = field;

// Case 1: lookup field that is itself a cross-base link (type === 'link' && isLookup && options.baseId)
// This happens when you lookup a cross-base link field through a local link field
if (type === FieldType.Link && (options as ILinkFieldOptions)?.baseId) {
return true;
}

// Case 2: lookup/rollup field that depends on a cross-base link field
if (!lookupOptions || !isLinkLookupOptions(lookupOptions)) {
return false;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -800,7 +800,42 @@ export class FieldSupplementService {
return { ...oldFieldVo, ...fieldRo };
}

return this.prepareFormulaField(fieldRo);
// For formula field updates, we need to handle a Zod validation edge case:
// When the request only specifies partial options (e.g., {timeZone: 'America/New_York'}),
// Zod's union schema may incorrectly match to lastModifiedTimeFieldOptionsRoSchema
// and add a default expression like 'LAST_MODIFIED_TIME()'.
//
// To fix this, we preserve the old expression when the new one is a known Zod default.
const oldOptions = (oldFieldVo.options ?? {}) as IFormulaFieldOptions;
const newOptions = (fieldRo.options ?? {}) as IFormulaFieldOptions;

// Known Zod default expressions that should not override user's actual expression
const zodDefaultExpressions = ['LAST_MODIFIED_TIME()', 'CREATED_TIME()'];
const isZodDefault = zodDefaultExpressions.includes(newOptions.expression);

// Determine which expression to use:
// - If new expression is a Zod default and old expression exists, preserve old
// - Otherwise use new expression (user explicitly set it)
const expression =
isZodDefault && oldOptions.expression ? oldOptions.expression : newOptions.expression;

// Only preserve timeZone from old options. Do NOT preserve formatting/showAs because:
// - The expression might change the cellValueType (e.g., Number -> String)
// - Old formatting may be incompatible with the new cellValueType
// - prepareFormulaField will generate appropriate default formatting based on new cellValueType
const mergedOptions: IFormulaFieldOptions = {
...newOptions,
expression,
// Preserve timeZone if not explicitly set in newOptions
timeZone: newOptions.timeZone ?? oldOptions.timeZone,
};

const mergedFieldRo: IFieldRo = {
...fieldRo,
options: mergedOptions,
};

return this.prepareFormulaField(mergedFieldRo);
}

private async prepareRollupField(field: IFieldRo, batchFieldVos?: IFieldVo[]) {
Expand Down Expand Up @@ -2035,7 +2070,8 @@ export class FieldSupplementService {
const existingFieldIdSet = new Set(existingFieldIds.map(({ id }) => id));
const { type } = aiConfig ?? {};

if (type === FieldAIActionType.Customization) {
// Both Customization and ImageCustomization use prompt with {fieldId} syntax
if (type === FieldAIActionType.Customization || type === FieldAIActionType.ImageCustomization) {
const { prompt } = aiConfig as ITextFieldCustomizeAIConfig;
const fieldIds = extractFieldReferences(prompt);
const fieldIdsToCreate = fieldIds.filter((id) => existingFieldIdSet.has(id));
Expand Down Expand Up @@ -2085,7 +2121,11 @@ export class FieldSupplementService {
const { type } = aiConfig ?? {};
if (!type) continue;

if (type === FieldAIActionType.Customization) {
// Both Customization and ImageCustomization use prompt with {fieldId} syntax
if (
type === FieldAIActionType.Customization ||
type === FieldAIActionType.ImageCustomization
) {
const { prompt } = aiConfig as ITextFieldCustomizeAIConfig;
const fieldIds = extractFieldReferences(prompt);
const fieldIdsToCreate = fieldIds.filter((id) => existingFieldIdSet.has(id));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -238,12 +238,13 @@ export class MailSenderService {
tableName: string;
fieldName: string;
recordIds: string[];
recordTitles: { id: string; title: string }[];
};
}) {
const {
notifyId,
fromUserName,
refRecord: { baseId, tableId, fieldName, tableName, recordIds },
refRecord: { baseId, tableId, fieldName, tableName, recordIds, recordTitles },
} = info;
let subject, partialBody;
const refLength = recordIds.length;
Expand Down Expand Up @@ -273,6 +274,12 @@ export class MailSenderService {
tableName,
fieldName,
recordIds,
recordTitles: recordTitles.map((r) => {
return {
...r,
title: r.title || this.i18n.t('sdk.common.unnamedRecord'),
};
}),
viewRecordUrlPrefix,
partialBody,
brandName,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
{{{title}}}:
</p>
<div style="display: inline-block; max-width: 560px; width: 100%; text-align: left;">
{{#each recordIds}}
{{#each recordTitles}}
<a
href="{{../viewRecordUrlPrefix}}?recordId={{this}}&fromNotify={{../notifyId}}"
href="{{../viewRecordUrlPrefix}}?recordId={{this.id}}&fromNotify={{../notifyId}}"
target="_blank"
style="
display: inline-block;
Expand All @@ -30,8 +30,8 @@
"
onmouseover="this.style.backgroundColor='#e4e4e7'"
onmouseout="this.style.backgroundColor='#f4f4f5'"
title="{{this}}"
>{{this}}</a>
title="{{this.title}}"
>{{this.title}}</a>
{{/each}}
</div>
</td>
Expand Down
3 changes: 1 addition & 2 deletions apps/nestjs-backend/src/features/next/next.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,13 @@ import { ConfigService } from '@nestjs/config';
import { generateQueryId } from '@teable/core';
import type { IQueryParamsRo, IQueryParamsVo } from '@teable/openapi';
import createServer from 'next';
import type { NextServer } from 'next/dist/server/next';
import { CacheService } from '../../cache/cache.service';
import type { ICacheStore } from '../../cache/types';

@Injectable()
export class NextService implements OnModuleInit, OnModuleDestroy {
private logger = new Logger(NextService.name);
public server!: NextServer;
public server!: ReturnType<typeof createServer>;
constructor(
private configService: ConfigService,
private readonly cacheService: CacheService<ICacheStore>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export class NotificationService {
tableName: string;
fieldName: string;
recordIds: string[];
recordTitles: { id: string; title: string }[];
};
}): Promise<void> {
const { fromUserId, toUserId, refRecord } = params;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ export class SettingOpenApiController {
})) ?? [],
chatModel: aiConfig?.chatModel,
capabilities: aiConfig?.capabilities,
// Include gateway models for space-level AI config
gatewayModels: aiConfig?.gatewayModels,
},
appGenerationEnabled: Boolean(appConfig?.apiKey),
turnstileSiteKey: this.turnstileService.getTurnstileSiteKey(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const actTestPdfToken = 'actTestPDF';
// Test file paths
const testImagePath = 'static/test/test-image.png';
const testPdfPath = 'static/test/test-pdf.pdf';
// Expected letter in test files
// Expected letter in test files - use uppercase K for stricter matching
const expectedLetter = 'k';

@Injectable()
Expand Down Expand Up @@ -128,8 +128,11 @@ export class SettingOpenApiService {
data: string,
contentType: string
): Promise<boolean> {
// Request AI to put the letter in quotes for strict validation
const testPrompt =
'What letter or character do you see in this file? Please respond with just the letter.';
'What letter or character do you see in this image/file? ' +
'Please respond with ONLY the letter wrapped in double quotes, like "X". ' +
'Do not add any other text.';

try {
const textPart: TextPart = {
Expand All @@ -154,17 +157,58 @@ export class SettingOpenApiService {
temperature: 0,
});

// Check if AI response contains the expected letter (case insensitive)
const responseText = res.text.toLowerCase();
const containsExpected = responseText.includes(expectedLetter);
const responseText = res.text.trim();

// Log the full response for debugging
this.logger.log(
`testAttachment result: response="${res.text}", expected="${expectedLetter}", contains=${containsExpected}`
`[testAttachment] Full AI response: "${responseText}", data preview: "${data.substring(0, 100)}..."`
);
return containsExpected;

// Strict validation: expect exactly "K" or "k" in quotes
const quotedLetterMatch = responseText.match(/"([^"]+)"/);
const letterInQuotes = quotedLetterMatch ? quotedLetterMatch[1].toLowerCase() : null;
const containsExpectedInQuotes = letterInQuotes === expectedLetter;

// Fallback: also check if response is just the letter (some models might not follow format)
const isJustTheLetter =
responseText.toLowerCase() === expectedLetter ||
responseText.toLowerCase() === expectedLetter.toUpperCase();

// Anti-hallucination checks:
// 1. Response should be short (< 30 chars) - a direct answer
const isShortResponse = responseText.length < 30;

// 2. Response should not indicate inability to see the file
const cannotSeeIndicators = [
'cannot see',
"can't see",
'unable to',
'no image',
'no file',
"don't see",
'not visible',
'not able to',
'sorry',
'error',
];
const indicatesCannotSee = cannotSeeIndicators.some((indicator) =>
responseText.toLowerCase().includes(indicator)
);

const isValid =
(containsExpectedInQuotes || isJustTheLetter) && isShortResponse && !indicatesCannotSee;

this.logger.log(
`[testAttachment] Validation: letterInQuotes="${letterInQuotes}", ` +
`containsExpectedInQuotes=${containsExpectedInQuotes}, isJustTheLetter=${isJustTheLetter}, ` +
`isShortResponse=${isShortResponse}, indicatesCannotSee=${indicatesCannotSee}, ` +
`isValid=${isValid}`
);

return isValid;
} catch (error) {
this.logger.error(
`testAttachment error: ${error instanceof Error ? error.message : unknownErrorMsg}`
`[testAttachment] Error: ${error instanceof Error ? error.message : unknownErrorMsg}`
);
return false;
}
Expand Down
Loading
Loading