Compare commits

...

9 Commits

Author SHA1 Message Date
Félix Malfait 2e23b774b2 Add clickhouse prod migration command (#11695)
Add a missing command to launch clickhouse migrations the same way we
launch postgres migrations
2025-04-23 13:03:07 +02:00
Félix Malfait 814b1f2d24 Rename webhook and open api urls (#11684)
We want to have fewer base path for routing.

We will have:
- /files
- /webhooks
- /graphql
- /metadata
- /rest
- /auth
- /healthz

I'm moving /open-api under /rest, and centralizing the webhooks
(removing /stripe and /cloudflare)
2025-04-22 22:30:53 +02:00
Félix Malfait 8f2dc7817f Fix email verification (#11660)
Email verification modal had been broken and looked bad

I also added stories to make it more evident if this happens again
2025-04-20 14:20:29 +02:00
Marie Stoppa 80d0721e68 Bump version 2025-04-18 14:21:19 +02:00
Marie 43e92f7257 Fix metadata cache flush (#11646)
Attempt to fix
https://twenty-v7.sentry.io/issues/6545999328/?environment=prod&project=4507072499810304&query=is%3Aunresolved%20issue.priority%3A%5Bhigh%2C%20medium%5D&referrer=issue-stream&sort=date&stream_index=2
2025-04-18 14:19:05 +02:00
etiennejouan 5b80432b9b revert #11537 2025-04-18 10:44:18 +02:00
etiennejouan 153942c1c6 bump 0.51.4 2025-04-18 10:41:09 +02:00
Etienne 3a8a048342 Fix mismatching stripe subscription metadata plan/planKey (#11634)
for later : https://github.com/twentyhq/core-team-issues/issues/867
2025-04-17 18:23:54 +02:00
etiennejouan 4ce2d6827c bump 0.51.3 2025-04-17 18:23:24 +02:00
24 changed files with 187 additions and 47 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-e2e-testing",
"version": "0.52.0-canary",
"version": "0.51.5",
"description": "",
"author": "",
"private": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-emails",
"version": "0.52.0-canary",
"version": "0.51.5",
"description": "",
"author": "",
"private": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-front",
"version": "0.52.0-canary",
"version": "0.51.5",
"private": true,
"type": "module",
"scripts": {
@@ -5,6 +5,7 @@ import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useVerifyLogin } from '@/auth/hooks/useVerifyLogin';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { useLingui } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
@@ -70,7 +71,11 @@ export const VerifyEmailEffect = () => {
}, []);
if (isError) {
return <EmailVerificationSent email={email} isError={true} />;
return (
<Modal.Content isVerticalCentered isHorizontalCentered>
<EmailVerificationSent email={email} isError={true} />
</Modal.Content>
);
}
return <></>;
@@ -0,0 +1,75 @@
import { Meta, StoryObj } from '@storybook/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { RecoilRoot } from 'recoil';
import { VerifyEmailEffect } from '../VerifyEmailEffect';
// Mock component that just renders the error state of VerifyEmailEffect directly
// (since normal VerifyEmailEffect has async logic that's hard to test in Storybook)
import { Modal } from '@/ui/layout/modal/components/Modal';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { EmailVerificationSent } from '../../sign-in-up/components/EmailVerificationSent';
const VerifyEmailEffectErrorState = ({ email = 'user@example.com' }) => {
return (
<Modal.Content isVerticalCentered isHorizontalCentered>
<EmailVerificationSent email={email} isError={true} />
</Modal.Content>
);
};
const meta: Meta<typeof VerifyEmailEffectErrorState> = {
title: 'Pages/Auth/VerifyEmailEffect',
component: VerifyEmailEffectErrorState,
decorators: [
(Story) => (
<div style={{ padding: '24px' }}>
<RecoilRoot>
<Story />
</RecoilRoot>
</div>
),
SnackBarDecorator,
],
parameters: {
codeSection: {
docs: 'IMPORTANT: When rendering EmailVerificationSent from VerifyEmailEffect, always wrap it with Modal.Content to maintain consistent styling.',
},
},
};
export default meta;
type Story = StoryObj<typeof VerifyEmailEffect>;
export const ErrorState: Story = {
args: {
email: 'user@example.com',
},
};
export const IntegratedExample: StoryObj<typeof VerifyEmailEffect> = {
render: () => (
<RecoilRoot>
<MemoryRouter
initialEntries={[
'/verify-email?email=user@example.com&emailVerificationToken=invalid-token',
]}
>
<Routes>
<Route
path="/verify-email"
element={<VerifyEmailEffectErrorState email="user@example.com" />}
/>
</Routes>
</MemoryRouter>
</RecoilRoot>
),
parameters: {
docs: {
description: {
story:
'This demonstrates how the component should look when rendered in the app with proper Modal.Content wrapping.',
},
},
},
decorators: [SnackBarDecorator],
};
@@ -0,0 +1,48 @@
import { Meta, StoryObj } from '@storybook/react';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { ComponentDecorator } from 'twenty-ui/testing';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { EmailVerificationSent } from '../EmailVerificationSent';
// Wrap the component in Modal.Content to reflect how it's used in the app
const RenderWithModal = (
args: React.ComponentProps<typeof EmailVerificationSent>,
) => {
return (
<Modal padding="none" modalVariant="primary">
<Modal.Content isVerticalCentered isHorizontalCentered>
<EmailVerificationSent email={args.email} isError={args.isError} />
</Modal.Content>
</Modal>
);
};
const meta: Meta<typeof EmailVerificationSent> = {
title: 'Pages/Auth/EmailVerificationSent',
component: EmailVerificationSent,
decorators: [ComponentDecorator, SnackBarDecorator],
parameters: {
codeSection: {
docs: 'This component should always be wrapped with Modal.Content in the app.\n\nCorrect usage:\n```tsx\n<Modal.Content isVerticalCentered isHorizontalCentered>\n <EmailVerificationSent email={email} />\n</Modal.Content>\n```\n',
},
},
render: RenderWithModal,
};
export default meta;
type Story = StoryObj<typeof EmailVerificationSent>;
export const Default: Story = {
args: {
email: 'user@example.com',
isError: false,
},
};
export const Error: Story = {
args: {
email: 'user@example.com',
isError: true,
},
};
@@ -59,7 +59,7 @@ export const PlaygroundSetupForm = () => {
try {
// Validate by fetching the schema (but not storing it)
const response = await fetch(
`${REACT_APP_SERVER_BASE_URL}/open-api/${values.schema}`,
`${REACT_APP_SERVER_BASE_URL}/rest/open-api/${values.schema}`,
{
headers: { Authorization: `Bearer ${values.apiKeyForPlayground}` },
},
@@ -68,7 +68,7 @@ export const RestPlayground = ({ onError, schema }: RestPlaygroundProps) => {
<ApiReferenceReact
configuration={{
spec: {
url: `${REACT_APP_SERVER_BASE_URL}/open-api/${schema}?token=${playgroundApiKey}`,
url: `${REACT_APP_SERVER_BASE_URL}/rest/open-api/${schema}?token=${playgroundApiKey}`,
},
authentication: {
http: {
@@ -2,7 +2,6 @@ import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetada
import { getOperandLabelShort } from '@/object-record/object-filter-dropdown/utils/getOperandLabel';
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { SortOrFilterChip } from '@/views/components/SortOrFilterChip';
import { isNonEmptyString } from '@sniptt/guards';
import { useIcons } from 'twenty-ui/display';
type EditableFilterChipProps = {
@@ -24,13 +23,11 @@ export const EditableFilterChip = ({
const operandLabelShort = getOperandLabelShort(viewFilter.operand);
const labelKey = `${viewFilter.label}${isNonEmptyString(viewFilter.value) ? operandLabelShort : ''}`;
return (
<SortOrFilterChip
key={viewFilter.id}
testId={viewFilter.id}
labelKey={labelKey}
labelKey={`${viewFilter.label}${getOperandLabelShort(viewFilter.operand)}`}
labelValue={viewFilter.displayValue}
Icon={FieldMetadataItemIcon}
onRemove={onRemove}
@@ -120,7 +120,11 @@ export const SignInUp = () => {
]);
if (signInUpStep === SignInUpStep.EmailVerification) {
return <EmailVerificationSent email={searchParams.get('email')} />;
return (
<Modal.Content isVerticalCentered isHorizontalCentered>
<EmailVerificationSent email={searchParams.get('email')} />
</Modal.Content>
);
}
return (
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-server",
"version": "0.52.0-canary",
"version": "0.51.5",
"description": "",
"author": "",
"private": true,
@@ -12,6 +12,7 @@
"worker:prod": "node dist/src/queue-worker/queue-worker",
"database:init:prod": "npx ts-node ./scripts/setup-db.ts && yarn database:migrate:prod",
"database:migrate:prod": "npx -y typeorm migration:run -d dist/src/database/typeorm/metadata/metadata.datasource && npx -y typeorm migration:run -d dist/src/database/typeorm/core/core.datasource",
"clickhouse:migrate:prod": "node dist/src/database/clickhouse/migrations/run-migrations.js",
"typeorm": "../../node_modules/typeorm/.bin/typeorm"
},
"dependencies": {
@@ -28,7 +28,7 @@ export class RestApiCoreController {
private readonly restApiCoreServiceV2: RestApiCoreServiceV2,
) {}
@Post('/duplicates')
@Post('duplicates')
@UseFilters(RestApiExceptionFilter)
async handleApiFindDuplicates(@Req() request: Request, @Res() res: Response) {
const result = await this.restApiCoreService.findDuplicates(request);
@@ -29,7 +29,7 @@ import { BillingWebhookInvoiceService } from 'src/engine/core-modules/billing/we
import { BillingWebhookPriceService } from 'src/engine/core-modules/billing/webhooks/services/billing-webhook-price.service';
import { BillingWebhookProductService } from 'src/engine/core-modules/billing/webhooks/services/billing-webhook-product.service';
import { BillingWebhookSubscriptionService } from 'src/engine/core-modules/billing/webhooks/services/billing-webhook-subscription.service';
@Controller('billing')
@Controller()
@UseFilters(BillingRestApiExceptionFilter)
export class BillingController {
protected readonly logger = new Logger(BillingController.name);
@@ -46,7 +46,7 @@ export class BillingController {
private readonly billingWebhookCustomerService: BillingWebhookCustomerService,
) {}
@Post('/webhooks')
@Post(['billing/webhooks', 'webhooks/stripe'])
async handleWebhooks(
@Headers('stripe-signature') signature: string,
@Req() req: RawBodyRequest<Request>,
@@ -4,9 +4,9 @@ import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-pl
export const getPlanKeyFromSubscription = (
subscription: BillingSubscription,
): BillingPlanKey => {
const planKey = subscription.metadata?.planKey;
const plan = subscription.metadata?.plan; //To do : #867 Naming issue decide if we should rename stripe product metadata planKey to plan (+ productKey to product) OR at session checkout creating subscription with metadata planKey (and not plan)
switch (planKey) {
switch (plan) {
case 'PRO':
return BillingPlanKey.PRO;
case 'ENTERPRISE':
@@ -10,24 +10,24 @@ import {
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Response, Request } from 'express';
import { Request, Response } from 'express';
import { Repository } from 'typeorm';
import { AnalyticsService } from 'src/engine/core-modules/analytics/services/analytics.service';
import { CUSTOM_DOMAIN_ACTIVATED_EVENT } from 'src/engine/core-modules/analytics/utils/events/track/custom-domain/custom-domain-activated';
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import {
DomainManagerException,
DomainManagerExceptionCode,
} from 'src/engine/core-modules/domain-manager/domain-manager.exception';
import { handleException } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { CloudflareSecretMatchGuard } from 'src/engine/core-modules/domain-manager/guards/cloudflare-secret.guard';
import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service';
import { AnalyticsService } from 'src/engine/core-modules/analytics/services/analytics.service';
import { CUSTOM_DOMAIN_ACTIVATED_EVENT } from 'src/engine/core-modules/analytics/utils/events/track/custom-domain/custom-domain-activated';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { handleException } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Controller('cloudflare')
@Controller()
@UseFilters(AuthRestApiExceptionFilter)
export class CloudflareController {
constructor(
@@ -39,7 +39,7 @@ export class CloudflareController {
private readonly analyticsService: AnalyticsService,
) {}
@Post('custom-hostname-webhooks')
@Post(['cloudflare/custom-hostname-webhooks', 'webhooks/cloudflare'])
@UseGuards(CloudflareSecretMatchGuard)
async customHostnameWebhooks(@Req() req: Request, @Res() res: Response) {
if (!req.body?.data?.data?.hostname) {
@@ -24,7 +24,7 @@ export class HealthController {
return this.health.check([]);
}
@Get('/:indicatorId')
@Get(':indicatorId')
@HealthCheck()
checkService(@Param('indicatorId') indicatorId: HealthIndicatorId) {
const checks = {
@@ -4,11 +4,11 @@ import { Request, Response } from 'express';
import { OpenApiService } from 'src/engine/core-modules/open-api/open-api.service';
@Controller('open-api')
@Controller()
export class OpenApiController {
constructor(private readonly openApiService: OpenApiService) {}
@Get('core')
@Get(['open-api/core', 'rest/open-api/core'])
async generateOpenApiSchemaCore(
@Req() request: Request,
@Res() res: Response,
@@ -18,7 +18,7 @@ export class OpenApiController {
res.send(data);
}
@Get('metadata')
@Get(['open-api/metadata', 'rest/open-api/metadata'])
async generateOpenApiSchemaMetaData(
@Req() request: Request,
@Res() res: Response,
@@ -1,19 +1,19 @@
import { Controller, Get, Param, Post, Req, UseFilters } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { Request } from 'express';
import { isDefined } from 'twenty-shared/utils';
import { WorkflowTriggerWorkspaceService } from 'src/modules/workflow/workflow-trigger/workspace-services/workflow-trigger.workspace-service';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { WorkflowWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity';
import { FieldActorSource } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { WorkflowTriggerRestApiExceptionFilter } from 'src/engine/core-modules/workflow/filters/workflow-trigger-rest-api-exception.filter';
import { FieldActorSource } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { WorkflowVersionWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
import { WorkflowWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity';
import {
WorkflowTriggerException,
WorkflowTriggerExceptionCode,
} from 'src/modules/workflow/workflow-trigger/exceptions/workflow-trigger.exception';
import { WorkflowTriggerType } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
import { WorkflowVersionWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
import { WorkflowTriggerWorkspaceService } from 'src/modules/workflow/workflow-trigger/workspace-services/workflow-trigger.workspace-service';
@Controller('webhooks')
@UseFilters(WorkflowTriggerRestApiExceptionFilter)
@@ -55,7 +55,10 @@ export class WorkspaceMetadataCacheService {
}
if (currentCacheVersion !== undefined) {
this.workspaceCacheStorageService.flush(workspaceId, currentCacheVersion);
this.workspaceCacheStorageService.flushVersionedMetadata(
workspaceId,
currentCacheVersion,
);
}
await this.workspaceCacheStorageService.addObjectMetadataCollectionOngoingCachingLock(
@@ -253,7 +253,10 @@ export class WorkspaceCacheStorageService {
);
}
async flush(workspaceId: string, metadataVersion: number): Promise<void> {
async flushVersionedMetadata(
workspaceId: string,
metadataVersion: number,
): Promise<void> {
await this.cacheStorageService.del(
`${WorkspaceCacheKeys.MetadataObjectMetadataMaps}:${workspaceId}:${metadataVersion}`,
);
@@ -272,6 +275,10 @@ export class WorkspaceCacheStorageService {
await this.cacheStorageService.del(
`${WorkspaceCacheKeys.MetadataObjectMetadataOngoingCachingLock}:${workspaceId}:${metadataVersion}`,
);
}
async flush(workspaceId: string, metadataVersion: number): Promise<void> {
await this.flushVersionedMetadata(workspaceId, metadataVersion);
await this.cacheStorageService.del(
`${WorkspaceCacheKeys.MetadataPermissionsRolesPermissions}:${workspaceId}`,
@@ -18,7 +18,7 @@ describe('BillingController (integration)', () => {
};
await client
.post('/billing/webhooks')
.post('/webhooks/stripe')
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
.set('stripe-signature', 'correct-signature')
.set('Content-Type', 'application/json')
@@ -29,7 +29,7 @@ describe('BillingController (integration)', () => {
});
await client
.post('/billing/webhooks')
.post('/webhooks/stripe')
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
.set('stripe-signature', 'correct-signature')
.set('Content-Type', 'application/json')
@@ -51,7 +51,7 @@ describe('BillingController (integration)', () => {
};
await client
.post('/billing/webhooks')
.post('/webhooks/stripe')
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
.set('stripe-signature', 'correct-signature')
.set('Content-Type', 'application/json')
@@ -63,7 +63,7 @@ describe('BillingController (integration)', () => {
});
await client
.post('/billing/webhooks')
.post('/webhooks/stripe')
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
.set('stripe-signature', 'correct-signature')
.set('Content-Type', 'application/json')
@@ -83,7 +83,7 @@ describe('BillingController (integration)', () => {
};
await client
.post('/billing/webhooks')
.post('/webhooks/stripe')
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
.set('stripe-signature', 'correct-signature')
.set('Content-Type', 'application/json')
@@ -102,7 +102,7 @@ describe('BillingController (integration)', () => {
};
await client
.post('/billing/webhooks')
.post('/webhooks/stripe')
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
.set('stripe-signature', 'invalid-signature')
.set('Content-Type', 'application/json')
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-shared",
"version": "0.52.0-canary",
"version": "0.51.5",
"main": "dist/twenty-shared.cjs.js",
"module": "dist/twenty-shared.esm.js",
"license": "AGPL-3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-ui",
"version": "0.52.0-canary",
"version": "0.51.5",
"main": "dist/index.cjs",
"module": "dist/index.mjs",
"style": "./dist/style.css",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-website",
"version": "0.52.0-canary",
"version": "0.51.5",
"private": true,
"scripts": {
"nx": "NX_DEFAULT_PROJECT=twenty-website node ../../node_modules/nx/bin/nx.js",