Compare commits

..
Author SHA1 Message Date
Sonarly Claude Code d9aa0f9b5b chore: improve monitoring for fix(front): guard undefined recordNode in cache ma
Added code-level monitoring in `getRecordsFromRecordConnection` for malformed connection payloads: edges with missing `node` are now filtered out (to avoid downstream failures) and reported to Sentry as a warning-level message with grouped fingerprint and counts (`invalidEdgeCount`, `edgesCount`). This reduces noisy high-severity exception capture while preserving actionable signal about data-shape anomalies.
2026-05-12 09:48:51 +00:00
Sonarly Claude Code a9b7ec8cb0 fix(front): guard undefined recordNode in cache mapper
https://sonarly.com/issue/36844?type=bug

A frontend runtime error crashes a notes-page record picker flow when a record edge has an undefined node. The exception is handled by Sentry but leaves the user in a broken interaction state.

Fix: Implemented a defensive guard in `getRecordFromRecordNode` so undefined `recordNode` no longer throws during destructuring (`const { id, __typename } = recordNode`). The function now returns an empty object when `recordNode` is missing, preventing the runtime crash seen in Notes relation picker flow. Added a unit regression test and snapshot to lock this behavior.
2026-05-12 09:48:51 +00:00
9 changed files with 45 additions and 84 deletions
@@ -12,20 +12,6 @@ import { WorkspaceActivationStatus } from '~/generated-metadata/graphql';
enableFetchMocks();
const mockCaptureException = jest.fn();
jest.mock('@sentry/react', () => ({
captureException: (...args: unknown[]) => mockCaptureException(...args),
withScope: (
callback: (scope: { setExtra: jest.Mock; setFingerprint: jest.Mock }) => void,
) => {
callback({
setExtra: jest.fn(),
setFingerprint: jest.fn(),
});
},
}));
jest.mock('@/auth/services/AuthService', () => {
const initialAuthService = jest.requireActual('@/auth/services/AuthService');
return {
@@ -276,29 +262,4 @@ describe('ApolloFactory', () => {
);
}
}, 10000);
it('should not send GRAPHQL_VALIDATION_FAILED errors to Sentry', async () => {
mockCaptureException.mockClear();
const errors = [
{
message: 'Cannot query field "licensedProducts" on type "BillingPlan".',
extensions: {
code: 'GRAPHQL_VALIDATION_FAILED',
},
},
];
fetchMock.mockResponse(() =>
Promise.resolve({
body: JSON.stringify({
data: {},
errors,
}),
}),
);
await expect(makeRequest()).rejects.toBeInstanceOf(CombinedGraphQLErrors);
expect(mockCaptureException).not.toHaveBeenCalled();
});
});
@@ -294,8 +294,7 @@ export class ApolloFactory implements ApolloManager {
case 'BAD_USER_INPUT':
case 'FORBIDDEN':
case 'CONFLICT':
case 'METADATA_VALIDATION_FAILED':
case 'GRAPHQL_VALIDATION_FAILED': {
case 'METADATA_VALIDATION_FAILED': {
return;
}
case 'USER_INPUT_ERROR': {
@@ -105,3 +105,5 @@ exports[`getRecordFromRecordNode should handle null and undefined values 1`] = `
"phone": undefined,
}
`;
exports[`getRecordFromRecordNode should return an empty object when record node is undefined 1`] = `{}`;
@@ -137,4 +137,12 @@ describe('getRecordFromRecordNode', () => {
expect(result).toMatchSnapshot();
});
it('should return an empty object when record node is undefined', () => {
const result = getRecordFromRecordNode({
recordNode: undefined,
});
expect(result).toMatchSnapshot();
});
});
@@ -7,8 +7,12 @@ import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
export const getRecordFromRecordNode = <T extends ObjectRecord>({
recordNode,
}: {
recordNode: RecordGqlNode;
recordNode: RecordGqlNode | undefined;
}): T => {
if (!isDefined(recordNode)) {
return {} as T;
}
const { id, __typename } = recordNode;
return {
@@ -1,13 +1,31 @@
import { captureMessage, withScope } from '@sentry/react';
import { getRecordFromRecordNode } from '@/object-record/cache/utils/getRecordFromRecordNode';
import { type RecordGqlConnectionEdgesRequired } from '@/object-record/graphql/types/RecordGqlConnectionEdgesRequired';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isDefined } from 'twenty-shared/utils';
export const getRecordsFromRecordConnection = <T extends ObjectRecord>({
recordConnection,
}: {
recordConnection: RecordGqlConnectionEdgesRequired;
}): T[] => {
return recordConnection?.edges?.map((edge) =>
getRecordFromRecordNode<T>({ recordNode: edge.node }),
);
const edges = recordConnection?.edges ?? [];
const invalidEdgeCount = edges.filter((edge) => !isDefined(edge?.node)).length;
if (invalidEdgeCount > 0) {
withScope((scope) => {
scope.setFingerprint(['record-connection-missing-node']);
scope.setLevel('warning');
scope.setExtra('invalidEdgeCount', invalidEdgeCount);
scope.setExtra('edgesCount', edges.length);
captureMessage('Record connection contains edge without node');
});
}
return edges
.filter((edge) => isDefined(edge?.node))
.map((edge) => getRecordFromRecordNode<T>({ recordNode: edge.node }));
};
@@ -16,11 +16,6 @@ export class BillingPlanDTO {
@Field(() => [BillingLicensedProduct])
baseProducts: BillingLicensedProduct[];
@Field(() => [BillingLicensedProduct], {
deprecationReason: 'Use baseProducts instead.',
})
licensedProducts: BillingLicensedProduct[];
@Field(() => [BillingLicensedProduct])
resourceCreditProducts: BillingLicensedProduct[];
@@ -76,29 +76,6 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
],
},
],
licensedProducts: [
{
id: 'product-1',
name: 'Test Licensed Product',
billingPrices: [
{
interval: SubscriptionInterval.Month,
unitAmount: 1500,
stripePriceId: 'price_123',
priceUsageType: BillingUsageType.LICENSED,
},
],
prices: [
{
recurringInterval: SubscriptionInterval.Month,
unitAmount: 1500,
stripePriceId: 'price_123',
priceUsageType: BillingUsageType.LICENSED,
creditAmount: null,
},
],
},
],
resourceCreditProducts: [],
meteredProducts: [
{
@@ -16,19 +16,16 @@ import {
export const formatBillingDatabaseProductToGraphqlDTO = (
plan: BillingGetPlanResult,
): BillingPlanDTO => {
const baseProducts = plan.baseProducts.map((product) => {
return {
...product,
prices: product.billingPrices.map(
formatBillingDatabasePriceToLicensedPriceDTO,
),
};
});
return {
planKey: plan.planKey,
baseProducts,
licensedProducts: baseProducts,
baseProducts: plan.baseProducts.map((product) => {
return {
...product,
prices: product.billingPrices.map(
formatBillingDatabasePriceToLicensedPriceDTO,
),
};
}),
resourceCreditProducts: plan.resourceCreditProducts.map((product) => {
return {
...product,