Compare commits

...
Author SHA1 Message Date
Félix Malfait b59b67ae03 fix(twenty-front): keep the AI side panel mounted across navigation
The AI chat side panel lived inside each per-page layout
(SettingsPageLayout and the record body container), so React unmounted
and remounted it on every navigation — the chat reloaded and lost its
state whenever you opened another record or settings page.

Hoist the side panel into a new persistent layout route,
MainAppLayoutWithSidePanel, that wraps every main-app route (records,
page layouts, settings). The panel is now a stable sibling of the routed
Outlet, so it stays mounted — and the AI chat keeps its state — while
only the page content reloads. PageChangeEffect already exempts the AI
chat from its close-on-navigation logic, so the panel now genuinely
persists instead of just re-opening.

- MainAppLayoutWithSidePanel owns the side panel + command-menu hotkeys;
  SettingsPageLayout and the record body container no longer render their
  own panel.
- MainContainerLayoutWithSidePanel renamed to MainContainerLayout since
  it no longer owns a panel.
- SidePanelForDesktop carries its own margin so it floats correctly as a
  top-level sibling; the margin collapses with the panel when closed, so
  pages render unchanged while it is closed.
- On a full reload the settings route shows the rounded-card skeleton
  (matching in-app navigation) instead of the legacy page skeleton.
2026-06-06 14:40:33 +02:00
Félix MalfaitandGitHub a2fa941ce3 chore(twenty-front): remove dead SettingsAiMCP component and covers (#21281)
## What

`SettingsAiMCP` is dead code — nothing imports or renders it. The
redesign moved MCP setup to the **APIs & Webhooks** page
(`SettingsMcpSetup`, on the MCP tab), and the AI settings page now
**deep-links** there (`ApiWebhooks#mcp`) instead of rendering this
component.

Removes the orphaned component and its two cover SVGs:
- `src/pages/settings/ai/components/SettingsAiMCP.tsx`
- `public/images/ai/ai-mcp-cover-{light,dark}.svg`

## Notes

- Verified nothing references the component or the SVGs in source (only
stale `.po` source-reference comments remain, which `lingui` extraction
reconciles separately — not hand-edited here).
- The hero on the APIs & Webhooks page (incl. its MCP tab) is
unaffected; that's the `playground/cover` image.
2026-06-06 12:23:42 +02:00
Félix MalfaitandGitHub 898713bd49 fix(server): finalize dangling tool calls when persisting agent chat messages (#21276)
## Problem

Interrupting an AI chat turn mid tool-call batch permanently bricks the
thread. Every subsequent message fails with:

> Tool results are missing for tool calls toolu_…, toolu_…

## Root cause

When the model fires a parallel batch of tool calls, it streams all the
calls first, then results come back one by one. If the stream is aborted
(user hits stop, credit cutoff, etc.) after only some have resolved, the
AI SDK's `onFinish` still fires with the partial assistant message —
including tool parts left in `input-available` state (a tool call with
no result).

`addMessage` persists that message verbatim. On the next turn the
history is rebuilt and `streamText` validates it: every `tool-call` must
be cleared by a `tool-result` before the next user message, or it throws
`MissingToolResultsError` (`ai/dist`, the `MissingToolResultsError`
check). The orphaned calls are now in the DB, so the thread fails on
every turn from then on.

## Fix

Enforce the invariant at the single write chokepoint. Every chat message
is persisted through `AgentChatService.addMessage`, so
`finalizeDanglingToolParts` runs there once: any tool part still in
`input-available` is rewritten to `output-error` ("Tool execution was
interrupted.") before mapping to DB rows.

`output-error` converts to a real `tool-result`, so the persisted turn
is always self-consistent and the next request is valid. Interrupted
calls are kept (not dropped) and surfaced as errored rather than
perpetually "running" — honest, since a partially-executed call may have
committed side effects the model should be able to reconcile.

One guard at one point covers every abort source — no read-side
patching, no migration, no schema change.

## Caching impact

None on the happy path. A completed turn has no `input-available` parts,
so the helper is a no-op and the persisted bytes (and therefore the
cached prefix) are identical to before. For an interrupted turn, the
finalized content is deterministic and written once, so it caches
cleanly on the following request and stays stable across later turns —
there is no scenario where this invalidates an existing cache entry. Net
effect: turns a hard failure into a normally-cached continuation.

## Testing

- New unit test covering finalize / no-op cases (7 cases, passing)
- `oxlint --type-aware` + `oxfmt` clean on changed files
2026-06-06 11:22:45 +02:00
Félix MalfaitandGitHub 4658d44d8b fix(settings): ship borderless hero cover images (#21277)
## What

The settings discovery hero images (AI, Applications, Page Layouts,
Members, Data Model, APIs & Webhooks) baked the rounded border into the
pixels — transparent rounded corners plus a 1px edge stroke. Rendered
inside `Card rounded` — which already draws a 1px border + border-radius
and clips children with `overflow: hidden` — this produced a doubled,
slightly misaligned border.

This replaces all 12 files (light + dark per section) with clean
full-bleed exports (opaque square corners), so the border and rounding
come entirely from CSS.

## Notes

- Pure asset swap, no component changes.
- The MCP section's `.svg` cover is untouched (no new export provided).
Billing's unused cover is left as-is.

## Verification

- Each new image confirmed 1388×300, opaque square corners (no baked
border), correct light/dark variant.
- `Card` (twenty-ui) provides `border` + `border-radius` + `overflow:
hidden`, so the square images are clipped to the rounded card.
2026-06-06 11:21:06 +02:00
a596e7d904 fix: add missing space in README heading (#21271)
The h2 heading in README.md was missing a space between the `>` closing
angle bracket and "The", causing minor formatting inconsistency.

Changed:
`<h2 align="center" >The #1 Open-Source CRM</h2>`

To:
`<h2 align="center"> The #1 Open-Source CRM</h2>`

---------

Co-authored-by: Félix Malfait <[email protected]>
2026-06-06 10:41:16 +02:00
Félix MalfaitandGitHub 91f2f08995 feat(server): unify workspace-event ingestion behind one EventSink pipeline (#21197)
## Why

The five event-log streams (`workspaceEvent`, `pageview`, `objectEvent`,
`usageEvent`, `applicationLog`) each wrote to ClickHouse through their
own fire-and-forget writer (`AuditService`, `UsageEventWriterService`,
and the `application-logs` driver), with the per-type knowledge (table
names, normalization, access rules) spread across several modules. Three
of them reimplemented the same ClickHouse insert, and the read side, the
live stream, and the producers lived in different modules under two
different names.

This consolidates them into one `core-modules/event-logs/` subsystem
(emit, write, live, read), with the per-type config in a single registry
so adding an event type is roughly one file.

The base Logs settings tab and free application logs shipped separately
in #21180 (merged). This PR adds the unified backend, the registry, and
the viewer's live mode and entitlement gating.

## Pipeline

```mermaid
flowchart TB
    subgraph PROD["Producers"]
      A["auth, billing, impersonation,<br/>webhook, custom-domain"]
      U["usage listener"]
      F["logic-function executor (app logs)"]
      R["record CRUD (entity events)"]
    end
    EM["EventLogEmitterService<br/>createContext().insert* / dispatch()"]
    EQ(["entityEventsToDbQueue<br/>(existing, shared with timeline)"])
    CIE["CreateEventLogFromInternalEvent"]
    SINK["WorkspaceEventSinkService.ingest()"]
    C1["ClickHouseEventSink"]
    C2["ConsoleEventSink"]
    LIVE["EventLogLiveService.publishWatched()<br/>(presence-gated)"]
    CH[("ClickHouse, 5 tables, async_insert")]
    CHAN(["WORKSPACE_EVENTS_CHANNEL"])
    RS["EventLogsService (registry-driven read)"]
    LR["EventLogsLiveResolver"]
    UI["Settings > Logs"]

    A --> EM
    U --> EM
    F --> EM
    EM -->|direct| SINK
    R --> EQ --> CIE -->|ingest| SINK
    SINK --> C1 --> CH
    SINK --> C2
    SINK --> LIVE -.->|if a viewer is watching| CHAN --> LR --> UI
    CH --> RS --> UI
```

## What it does

- Producers call `EventLogEmitterService.createContext().insert*()`,
which builds a typed `WorkspaceEventEnvelope` and writes it through
`WorkspaceEventSinkService` to the configured sinks (ClickHouse,
Console) plus a presence-gated live fan-out. Record/CRUD events reach
the same sink through the existing `entityEventsToDbQueue`. There is no
dedicated queue; ClickHouse `async_insert` batches server-side. Writes
are best-effort, as on main today.
- `EVENT_LOG_TYPES[table]` is the per-type source of truth: the
ClickHouse table, the required entitlement, the free-text filter column,
and the row-to-GraphQL mapping. Read row shapes derive from the write
rows.
- Four modules along their dependency boundaries:
`EventLogEmitterModule` (producer API), `EventLogIngestionModule` (sink
layer), `EventLogLiveModule` (fan-out), and `EventLogsViewerModule` (the
entitlement-gated GraphQL read, which is where
billing/enterprise/permissions stay so producers stay light).
- Logs viewer: per-table columns, filters (text, date, record), live
mode, and an upgrade card that points to Billing on Cloud or the Admin
Panel on self-hosted. Application logs are free on every plan; the other
four require the `AUDIT_LOGS` entitlement (with a `NO_ENTITLEMENT`
fallback to the upgrade card).
- Renames `AuditService` to `EventLogEmitterService`, and the generic
`Monitoring` event to a typed `Impersonation` event (`level` +
`action`).
- Removes `UsageEventWriterService`, the `application-logs`
driver/module, and `AuditService`'s direct inserts.

## Durability

Writes are best-effort, the same as main today (the old writers were
fire-and-forget). A dedicated queue was tried mid-PR and removed:
`async_insert` already batches server-side, so the queue only added
durability, which isn't a requirement right now. The `EventSink` seam
keeps a durable transport (e.g. a Redis-Streams buffer) easy to add
later without touching producers.

## Out of scope

S3 peer sink (seam only), Postgres or any second read path,
`ReplicatedMergeTree`, ClickHouse table-schema changes, and the
record-data `EVENT_STREAM_CHANNEL` (unchanged, separate concern).

## Testing

Unit tests cover the registry definitions and row normalization, the
entitlement gating, the envelope builders, and the producers.
Integration tests cover the write paths (record create produces an
`objectEvent`; the track mutation produces a `workspaceEvent`) and the
read/query path across all five tables. Verified with typecheck, lint, a
server boot, and GraphQL/SDK codegen.
2026-06-06 10:32:56 +02:00
martmullandGitHub 6c65d26ced feat(app-dev): add dry-run preview to dev sync (#21251)
Split out of #21240. Stacked on #21250 (review/merge that first).

`yarn twenty dev --once --dry-run` computes the migration plan and
prints the diff **without applying anything** (no migration, no
app-record update, no SDK generation). Also renders the diff on a normal
`dev --once` sync.

<img width="646" height="179" alt="image"
src="https://github.com/user-attachments/assets/59f3ddcd-2a5b-4b8a-b21a-c659abe16af0"
/>
2026-06-05 17:49:02 +00:00
bfb83e93b2 fix(metadata): resolve junction targets order-independently during mgration (#21193)
A junction relation points at a target field on the join object that
another action may create later (two junctions into the same join
reference each other). The builder validator now also looks up the
target in the to be created set, and the runner mints every field id up
front so the target resolves regardless of action order, the same way
relation pairs are already handled.

---------

Co-authored-by: Félix Malfait <[email protected]>
2026-06-05 17:32:25 +00:00
20d9244639 fix(server): gate viewFilter.relationTargetFieldMetadataId behind its 2.6 upgrade command (#21267)
## Problem

Self-hosted upgrades crossing 2.6 (e.g. `2.4 → 2.6/2.9`) can abort with:

```
column ViewFilterEntity.relationTargetFieldMetadataId does not exist
  at WorkspaceFlatViewFilterMapCacheService.computeForCache
  at WorkspaceCacheService.recomputeDataFromProvider
[UpgradeSequenceRunnerService] Workspace steps ended with 1 failure(s). Aborting
```

This is **Failure #1** from #20841 — the counterpart to the
role-permission cache crash fixed in #21257 (Failure #2). Same shape: a
workspace **cache recompute runs mid-upgrade and reads schema that the
target version's migration hasn't applied yet**.

## Root cause

`ViewFilterEntity.relationTargetFieldMetadataId` is added to
`core.viewFilter` only at the **2.6.0** cursor
(`AddRelationTargetFieldMetadataIdToViewFilterFastInstanceCommand`, ts
`1798000005000`). But the workspace cache recompute SELECTs every column
of the entity, and it runs during *earlier* (2.5) workspace steps.
Unlike `RolePermissionFlagEntity.permissionFlag`, this column has **no
`@WasIntroducedInUpgrade` gate**, so the proxy can't hide it — and the
SELECT fails when the column isn't there yet.

There are three `IF NOT EXISTS` backport commands (2.3/2.4/2.5) meant to
add the column sooner, but they use **low timestamps** that sort to the
front of their version bundles. An instance whose cursor has already
advanced past those positions (e.g. it reached 2.4, or a prior failed
attempt advanced it through 2.5 instance commands) treats them as
already-applied and **skips them** — so the column is never created, yet
the entity keeps selecting it.

## Fix

Gate the column with `@WasIntroducedInUpgrade` pointing at the **2.6.0**
command that adds it:

```ts
@WasIntroducedInUpgrade({
  upgradeCommandName:
    '2.6.0_AddRelationTargetFieldMetadataIdToViewFilterFastInstanceCommand_1798000005000',
})
@Column({ nullable: true, type: 'uuid', default: null })
relationTargetFieldMetadataId: string | null;
```

`UpgradeAwareRepositoryProxy` then hides the column from reads while the
cursor is < 2.6, so the cache recompute simply omits it — no crash — and
it becomes visible once the 2.6.0 command has run (where it's guaranteed
to exist). Gating to **2.6.0** specifically (not the earlier backports)
is what fixes the cursor-skip case: 2.6.0 is the first point where the
column is reliably present regardless of whether the backports ran.

Validator-safe: the referenced command resolves to a real step
(`computeCommandName` = `${version}_${className}_${timestamp}`), so
`validate-upgrade-aware-entity-decorators` accepts it. The existing
backport commands are left untouched (committed instance commands).

## Recovery for already-stuck instances

This prevents *new* failures. An instance already aborted mid-upgrade
needs the column added manually before retrying:

```sql
ALTER TABLE core."viewFilter" ADD COLUMN IF NOT EXISTS "relationTargetFieldMetadataId" uuid;
DELETE FROM core."upgradeMigration" WHERE status='failed';
```
then re-run the upgrade on a build that includes this fix.

Refs #20841

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-06-05 18:56:02 +02:00
201 changed files with 3873 additions and 2468 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
</a>
</p>
<h2 align="center" >The #1 Open-Source CRM</h2>
<h2 align="center">The #1 Open-Source CRM</h2>
<p align="center"><a href="https://twenty.com"><img src="./packages/twenty-website/public/images/readme/globe-icon.svg" width="12" height="12"/> Website</a> · <a href="https://docs.twenty.com"><img src="./packages/twenty-website/public/images/readme/book-icon.svg" width="12" height="12"/> Documentation</a> · <a href="https://github.com/orgs/twentyhq/projects/1"><img src="./packages/twenty-website/public/images/readme/map-icon.svg" width="12" height="12"/> Roadmap </a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://www.figma.com/file/xt8O9mFeLl46C5InWwoMrN/Twenty"><img src="./packages/twenty-website/public/images/readme/figma-icon.webp" width="12" height="12"/> Figma</a></p>
@@ -1434,11 +1434,6 @@ type EnterpriseSubscriptionStatusDTO {
isCancellationScheduled: Boolean!
}
type Analytics {
"""Boolean that confirms query was dispatched"""
success: Boolean!
}
type VerificationRecord {
type: String!
key: String!
@@ -2621,6 +2616,11 @@ type SendEmailOutput {
error: String
}
type Analytics {
"""Boolean that confirms query was dispatched"""
success: Boolean!
}
type EventLogRecord {
event: String!
timestamp: DateTime!
@@ -3200,8 +3200,6 @@ type Mutation {
updateApiKey(input: UpdateApiKeyInput!): ApiKey
revokeApiKey(input: RevokeApiKeyInput!): ApiKey
assignRoleToApiKey(apiKeyId: UUID!, roleId: UUID!): Boolean!
createObjectEvent(event: String!, recordId: UUID!, objectMetadataId: UUID!, properties: JSON): Analytics!
trackAnalytics(type: AnalyticsType!, name: String, event: String, properties: JSON): Analytics!
skipSyncEmailOnboardingStep: OnboardingStepSuccess!
skipBookOnboardingStep: OnboardingStepSuccess!
checkoutSession(recurringInterval: SubscriptionInterval!, plan: BillingPlanKey! = PRO, requirePaymentMethod: Boolean! = true, successUrlPath: String): BillingSession!
@@ -3344,6 +3342,8 @@ type Mutation {
createSAMLIdentityProvider(input: SetupSAMLSsoInput!): SetupSso!
deleteSSOIdentityProvider(input: DeleteSsoInput!): DeleteSso!
editSSOIdentityProvider(input: EditSsoInput!): EditSso!
createObjectEvent(event: String!, recordId: UUID!, objectMetadataId: UUID!, properties: JSON): Analytics!
trackAnalytics(type: AnalyticsType!, name: String, event: String, properties: JSON): Analytics!
duplicateDashboard(id: UUID!): DuplicatedDashboard!
impersonate(userId: UUID!, workspaceId: UUID!): Impersonate!
sendEmail(input: SendEmailInput!): SendEmailOutput!
@@ -3360,7 +3360,7 @@ type Mutation {
syncMarketplaceCatalog: Boolean!
createDevelopmentApplication(universalIdentifier: String!, name: String!): DevelopmentApplication!
generateApplicationToken(applicationId: UUID!): ApplicationTokenPair!
syncApplication(manifest: JSON!): WorkspaceMigration!
syncApplication(manifest: JSON!, dryRun: Boolean): WorkspaceMigration!
uploadApplicationFile(file: Upload!, applicationUniversalIdentifier: String!, fileFolder: FileFolder!, filePath: String!): File!
upgradeApplication(appRegistrationId: String!, targetVersion: String!): Boolean!
renewApplicationToken(applicationRefreshToken: String!): ApplicationTokenPair!
@@ -3716,11 +3716,6 @@ input RevokeApiKeyInput {
id: UUID!
}
enum AnalyticsType {
PAGEVIEW
TRACK
}
input CreateApprovedAccessDomainInput {
domain: String!
email: String!
@@ -4430,6 +4425,11 @@ input EditSsoInput {
status: SSOIdentityProviderStatus!
}
enum AnalyticsType {
PAGEVIEW
TRACK
}
input SendEmailInput {
connectedAccountId: String!
to: String!
@@ -4498,6 +4498,7 @@ type Subscription {
onEventSubscription(eventStreamId: String!): EventSubscription
logicFunctionLogs(input: LogicFunctionLogsInput!): LogicFunctionLogs!
onAgentChatEvent(threadId: UUID!): AgentChatEvent!
eventLogsLive(table: EventLogTable!): [EventLogRecord!]
}
input LogicFunctionLogsInput {
@@ -1091,12 +1091,6 @@ export interface EnterpriseSubscriptionStatusDTO {
__typename: 'EnterpriseSubscriptionStatusDTO'
}
export interface Analytics {
/** Boolean that confirms query was dispatched */
success: Scalars['Boolean']
__typename: 'Analytics'
}
export interface VerificationRecord {
type: Scalars['String']
key: Scalars['String']
@@ -2314,6 +2308,12 @@ export interface SendEmailOutput {
__typename: 'SendEmailOutput'
}
export interface Analytics {
/** Boolean that confirms query was dispatched */
success: Scalars['Boolean']
__typename: 'Analytics'
}
export interface EventLogRecord {
event: Scalars['String']
timestamp: Scalars['DateTime']
@@ -2724,8 +2724,6 @@ export interface Mutation {
updateApiKey?: ApiKey
revokeApiKey?: ApiKey
assignRoleToApiKey: Scalars['Boolean']
createObjectEvent: Analytics
trackAnalytics: Analytics
skipSyncEmailOnboardingStep: OnboardingStepSuccess
skipBookOnboardingStep: OnboardingStepSuccess
checkoutSession: BillingSession
@@ -2868,6 +2866,8 @@ export interface Mutation {
createSAMLIdentityProvider: SetupSso
deleteSSOIdentityProvider: DeleteSso
editSSOIdentityProvider: EditSso
createObjectEvent: Analytics
trackAnalytics: Analytics
duplicateDashboard: DuplicatedDashboard
impersonate: Impersonate
sendEmail: SendEmailOutput
@@ -2892,16 +2892,17 @@ export interface Mutation {
__typename: 'Mutation'
}
export type AnalyticsType = 'PAGEVIEW' | 'TRACK'
export type WorkspaceMigrationActionType = 'delete' | 'create' | 'update'
export type AnalyticsType = 'PAGEVIEW' | 'TRACK'
export type FileFolder = 'ProfilePicture' | 'WorkspaceLogo' | 'Attachment' | 'PersonPicture' | 'CorePicture' | 'File' | 'AgentChat' | 'BuiltLogicFunction' | 'BuiltFrontComponent' | 'PublicAsset' | 'Source' | 'FilesField' | 'Dependencies' | 'Workflow' | 'EmailAttachment' | 'AppTarball' | 'GeneratedSdkClient'
export interface Subscription {
onEventSubscription?: EventSubscription
logicFunctionLogs: LogicFunctionLogs
onAgentChatEvent: AgentChatEvent
eventLogsLive?: EventLogRecord[]
__typename: 'Subscription'
}
@@ -4049,13 +4050,6 @@ export interface EnterpriseSubscriptionStatusDTOGenqlSelection{
__scalar?: boolean | number
}
export interface AnalyticsGenqlSelection{
/** Boolean that confirms query was dispatched */
success?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface VerificationRecordGenqlSelection{
type?: boolean | number
key?: boolean | number
@@ -5364,6 +5358,13 @@ export interface SendEmailOutputGenqlSelection{
__scalar?: boolean | number
}
export interface AnalyticsGenqlSelection{
/** Boolean that confirms query was dispatched */
success?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface EventLogRecordGenqlSelection{
event?: boolean | number
timestamp?: boolean | number
@@ -5811,8 +5812,6 @@ export interface MutationGenqlSelection{
updateApiKey?: (ApiKeyGenqlSelection & { __args: {input: UpdateApiKeyInput} })
revokeApiKey?: (ApiKeyGenqlSelection & { __args: {input: RevokeApiKeyInput} })
assignRoleToApiKey?: { __args: {apiKeyId: Scalars['UUID'], roleId: Scalars['UUID']} }
createObjectEvent?: (AnalyticsGenqlSelection & { __args: {event: Scalars['String'], recordId: Scalars['UUID'], objectMetadataId: Scalars['UUID'], properties?: (Scalars['JSON'] | null)} })
trackAnalytics?: (AnalyticsGenqlSelection & { __args: {type: AnalyticsType, name?: (Scalars['String'] | null), event?: (Scalars['String'] | null), properties?: (Scalars['JSON'] | null)} })
skipSyncEmailOnboardingStep?: OnboardingStepSuccessGenqlSelection
skipBookOnboardingStep?: OnboardingStepSuccessGenqlSelection
checkoutSession?: (BillingSessionGenqlSelection & { __args: {recurringInterval: SubscriptionInterval, plan: BillingPlanKey, requirePaymentMethod: Scalars['Boolean'], successUrlPath?: (Scalars['String'] | null)} })
@@ -5955,6 +5954,8 @@ export interface MutationGenqlSelection{
createSAMLIdentityProvider?: (SetupSsoGenqlSelection & { __args: {input: SetupSAMLSsoInput} })
deleteSSOIdentityProvider?: (DeleteSsoGenqlSelection & { __args: {input: DeleteSsoInput} })
editSSOIdentityProvider?: (EditSsoGenqlSelection & { __args: {input: EditSsoInput} })
createObjectEvent?: (AnalyticsGenqlSelection & { __args: {event: Scalars['String'], recordId: Scalars['UUID'], objectMetadataId: Scalars['UUID'], properties?: (Scalars['JSON'] | null)} })
trackAnalytics?: (AnalyticsGenqlSelection & { __args: {type: AnalyticsType, name?: (Scalars['String'] | null), event?: (Scalars['String'] | null), properties?: (Scalars['JSON'] | null)} })
duplicateDashboard?: (DuplicatedDashboardGenqlSelection & { __args: {id: Scalars['UUID']} })
impersonate?: (ImpersonateGenqlSelection & { __args: {userId: Scalars['UUID'], workspaceId: Scalars['UUID']} })
sendEmail?: (SendEmailOutputGenqlSelection & { __args: {input: SendEmailInput} })
@@ -5972,7 +5973,7 @@ export interface MutationGenqlSelection{
syncMarketplaceCatalog?: boolean | number
createDevelopmentApplication?: (DevelopmentApplicationGenqlSelection & { __args: {universalIdentifier: Scalars['String'], name: Scalars['String']} })
generateApplicationToken?: (ApplicationTokenPairGenqlSelection & { __args: {applicationId: Scalars['UUID']} })
syncApplication?: (WorkspaceMigrationGenqlSelection & { __args: {manifest: Scalars['JSON']} })
syncApplication?: (WorkspaceMigrationGenqlSelection & { __args: {manifest: Scalars['JSON'], dryRun?: (Scalars['Boolean'] | null)} })
uploadApplicationFile?: (FileGenqlSelection & { __args: {file: Scalars['Upload'], applicationUniversalIdentifier: Scalars['String'], fileFolder: FileFolder, filePath: Scalars['String']} })
upgradeApplication?: { __args: {appRegistrationId: Scalars['String'], targetVersion: Scalars['String']} }
renewApplicationToken?: (ApplicationTokenPairGenqlSelection & { __args: {applicationRefreshToken: Scalars['String']} })
@@ -6356,6 +6357,7 @@ export interface SubscriptionGenqlSelection{
onEventSubscription?: (EventSubscriptionGenqlSelection & { __args: {eventStreamId: Scalars['String']} })
logicFunctionLogs?: (LogicFunctionLogsGenqlSelection & { __args: {input: LogicFunctionLogsInput} })
onAgentChatEvent?: (AgentChatEventGenqlSelection & { __args: {threadId: Scalars['UUID']} })
eventLogsLive?: (EventLogRecordGenqlSelection & { __args: {table: EventLogTable} })
__typename?: boolean | number
__scalar?: boolean | number
}
@@ -7003,14 +7005,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const Analytics_possibleTypes: string[] = ['Analytics']
export const isAnalytics = (obj?: { __typename?: any } | null): obj is Analytics => {
if (!obj?.__typename) throw new Error('__typename is missing in "isAnalytics"')
return Analytics_possibleTypes.includes(obj.__typename)
}
const VerificationRecord_possibleTypes: string[] = ['VerificationRecord']
export const isVerificationRecord = (obj?: { __typename?: any } | null): obj is VerificationRecord => {
if (!obj?.__typename) throw new Error('__typename is missing in "isVerificationRecord"')
@@ -8139,6 +8133,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const Analytics_possibleTypes: string[] = ['Analytics']
export const isAnalytics = (obj?: { __typename?: any } | null): obj is Analytics => {
if (!obj?.__typename) throw new Error('__typename is missing in "isAnalytics"')
return Analytics_possibleTypes.includes(obj.__typename)
}
const EventLogRecord_possibleTypes: string[] = ['EventLogRecord']
export const isEventLogRecord = (obj?: { __typename?: any } | null): obj is EventLogRecord => {
if (!obj?.__typename) throw new Error('__typename is missing in "isEventLogRecord"')
@@ -8990,17 +8992,17 @@ export const enumUsageOperationType = {
WEB_SEARCH: 'WEB_SEARCH' as const
}
export const enumAnalyticsType = {
PAGEVIEW: 'PAGEVIEW' as const,
TRACK: 'TRACK' as const
}
export const enumWorkspaceMigrationActionType = {
delete: 'delete' as const,
create: 'create' as const,
update: 'update' as const
}
export const enumAnalyticsType = {
PAGEVIEW: 'PAGEVIEW' as const,
TRACK: 'TRACK' as const
}
export const enumFileFolder = {
ProfilePicture: 'ProfilePicture' as const,
WorkspaceLogo: 'WorkspaceLogo' as const,
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 124 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 69 KiB

@@ -3315,6 +3315,7 @@ export type MutationStopAgentChatStreamArgs = {
export type MutationSyncApplicationArgs = {
dryRun?: InputMaybe<Scalars['Boolean']>;
manifest: Scalars['JSON'];
};
@@ -4932,12 +4933,18 @@ export type StandardOverrides = {
export type Subscription = {
__typename?: 'Subscription';
eventLogsLive?: Maybe<Array<EventLogRecord>>;
logicFunctionLogs: LogicFunctionLogs;
onAgentChatEvent: AgentChatEvent;
onEventSubscription?: Maybe<EventSubscription>;
};
export type SubscriptionEventLogsLiveArgs = {
table: EventLogTable;
};
export type SubscriptionLogicFunctionLogsArgs = {
input: LogicFunctionLogsInput;
};
@@ -7403,7 +7410,14 @@ export type EventLogsQueryVariables = Exact<{
}>;
export type EventLogsQuery = { __typename?: 'Query', eventLogs: { __typename?: 'EventLogQueryResult', totalCount: number, records: Array<{ __typename?: 'EventLogRecord', event: string, timestamp: string, userId?: string | null, properties?: any | null, recordId?: string | null, objectMetadataId?: string | null, isCustom?: boolean | null }>, pageInfo: { __typename?: 'EventLogPageInfo', endCursor?: string | null, hasNextPage: boolean } } };
export type EventLogsQuery = { __typename?: 'Query', eventLogs: { __typename?: 'EventLogQueryResult', totalCount: number, records: Array<{ __typename?: 'EventLogRecord', event: string, timestamp: string, userId?: string | null, properties?: any | null, recordId?: string | null, objectMetadataId?: string | null }>, pageInfo: { __typename?: 'EventLogPageInfo', endCursor?: string | null, hasNextPage: boolean } } };
export type EventLogsLiveSubscriptionVariables = Exact<{
table: EventLogTable;
}>;
export type EventLogsLiveSubscription = { __typename?: 'Subscription', eventLogsLive?: Array<{ __typename?: 'EventLogRecord', event: string, timestamp: string, userId?: string | null, properties?: any | null, recordId?: string | null, objectMetadataId?: string | null }> | null };
export type UpdateLabPublicFeatureFlagMutationVariables = Exact<{
input: UpdateLabPublicFeatureFlagInput;
@@ -8254,7 +8268,8 @@ export const SetEnterpriseKeyDocument = {"kind":"Document","definitions":[{"kind
export const EnterpriseCheckoutSessionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EnterpriseCheckoutSession"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"billingInterval"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"enterpriseCheckoutSession"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"billingInterval"},"value":{"kind":"Variable","name":{"kind":"Name","value":"billingInterval"}}}]}]}}]} as unknown as DocumentNode<EnterpriseCheckoutSessionQuery, EnterpriseCheckoutSessionQueryVariables>;
export const EnterprisePortalSessionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EnterprisePortalSession"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"returnUrlPath"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"enterprisePortalSession"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"returnUrlPath"},"value":{"kind":"Variable","name":{"kind":"Name","value":"returnUrlPath"}}}]}]}}]} as unknown as DocumentNode<EnterprisePortalSessionQuery, EnterprisePortalSessionQueryVariables>;
export const EnterpriseSubscriptionStatusDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EnterpriseSubscriptionStatus"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"enterpriseSubscriptionStatus"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"licensee"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}},{"kind":"Field","name":{"kind":"Name","value":"cancelAt"}},{"kind":"Field","name":{"kind":"Name","value":"currentPeriodEnd"}},{"kind":"Field","name":{"kind":"Name","value":"isCancellationScheduled"}}]}}]}}]} as unknown as DocumentNode<EnterpriseSubscriptionStatusQuery, EnterpriseSubscriptionStatusQueryVariables>;
export const EventLogsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EventLogs"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EventLogQueryInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"eventLogs"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"records"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"event"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"userId"}},{"kind":"Field","name":{"kind":"Name","value":"properties"}},{"kind":"Field","name":{"kind":"Name","value":"recordId"}},{"kind":"Field","name":{"kind":"Name","value":"objectMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"isCustom"}}]}},{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"endCursor"}},{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}}]}}]}}]}}]} as unknown as DocumentNode<EventLogsQuery, EventLogsQueryVariables>;
export const EventLogsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EventLogs"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EventLogQueryInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"eventLogs"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"records"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"event"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"userId"}},{"kind":"Field","name":{"kind":"Name","value":"properties"}},{"kind":"Field","name":{"kind":"Name","value":"recordId"}},{"kind":"Field","name":{"kind":"Name","value":"objectMetadataId"}}]}},{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"endCursor"}},{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}}]}}]}}]}}]} as unknown as DocumentNode<EventLogsQuery, EventLogsQueryVariables>;
export const EventLogsLiveDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"EventLogsLive"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"table"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EventLogTable"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"eventLogsLive"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"table"},"value":{"kind":"Variable","name":{"kind":"Name","value":"table"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"event"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"userId"}},{"kind":"Field","name":{"kind":"Name","value":"properties"}},{"kind":"Field","name":{"kind":"Name","value":"recordId"}},{"kind":"Field","name":{"kind":"Name","value":"objectMetadataId"}}]}}]}}]} as unknown as DocumentNode<EventLogsLiveSubscription, EventLogsLiveSubscriptionVariables>;
export const UpdateLabPublicFeatureFlagDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateLabPublicFeatureFlag"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateLabPublicFeatureFlagInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateLabPublicFeatureFlag"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}}]}}]}}]} as unknown as DocumentNode<UpdateLabPublicFeatureFlagMutation, UpdateLabPublicFeatureFlagMutationVariables>;
export const UploadWorkspaceMemberProfilePictureDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UploadWorkspaceMemberProfilePicture"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"file"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Upload"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uploadWorkspaceMemberProfilePicture"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"file"},"value":{"kind":"Variable","name":{"kind":"Name","value":"file"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode<UploadWorkspaceMemberProfilePictureMutation, UploadWorkspaceMemberProfilePictureMutationVariables>;
export const UpdateUserEmailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateUserEmail"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"newEmail"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"verifyEmailRedirectPath"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateUserEmail"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"newEmail"},"value":{"kind":"Variable","name":{"kind":"Name","value":"newEmail"}}},{"kind":"Argument","name":{"kind":"Name","value":"verifyEmailRedirectPath"},"value":{"kind":"Variable","name":{"kind":"Name","value":"verifyEmailRedirectPath"}}}]}]}}]} as unknown as DocumentNode<UpdateUserEmailMutation, UpdateUserEmailMutationVariables>;
@@ -1,5 +1,9 @@
import { PageContentSkeletonLoader } from '~/loading/components/PageContentSkeletonLoader';
import { SettingsSkeletonLoader } from '@/settings/components/SettingsSkeletonLoader';
import { styled } from '@linaria/react';
import { useLocation } from 'react-router-dom';
import { AppPath } from 'twenty-shared/types';
import { PageContentSkeletonLoader } from '~/loading/components/PageContentSkeletonLoader';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
const StyledRightPanelContainer = styled.div`
display: flex;
@@ -7,8 +11,17 @@ const StyledRightPanelContainer = styled.div`
width: 100%;
`;
export const RightPanelSkeletonLoader = () => (
<StyledRightPanelContainer>
<PageContentSkeletonLoader />
</StyledRightPanelContainer>
);
export const RightPanelSkeletonLoader = () => {
const location = useLocation();
const isSettingsPage = isMatchingLocation(location, AppPath.SettingsCatchAll);
return (
<StyledRightPanelContainer>
{isSettingsPage ? (
<SettingsSkeletonLoader />
) : (
<PageContentSkeletonLoader />
)}
</StyledRightPanelContainer>
);
};
@@ -7,6 +7,7 @@ import { VerifyEmailEffect } from '@/auth/components/VerifyEmailEffect';
import indexAppPath from '@/navigation/utils/indexAppPath';
import { BlankLayout } from '@/ui/layout/page/components/BlankLayout';
import { DefaultLayout } from '@/ui/layout/page/components/DefaultLayout';
import { MainAppLayoutWithSidePanel } from '@/ui/layout/page/components/MainAppLayoutWithSidePanel';
import { AppPath } from 'twenty-shared/types';
import { lazy } from 'react';
@@ -209,48 +210,50 @@ export const useCreateAppRouter = (
</LazyRoute>
}
/>
<Route path={indexAppPath.getIndexAppPath()} element={<></>} />
<Route
path={AppPath.RecordIndexPage}
element={
<LazyRoute>
<RecordIndexPage />
</LazyRoute>
}
/>
<Route
path={AppPath.RecordShowPage}
element={
<LazyRoute>
<RecordShowPage />
</LazyRoute>
}
/>
<Route
path={AppPath.PageLayoutPage}
element={
<LazyRoute>
<StandalonePageLayoutPage />
</LazyRoute>
}
/>
<Route
path={AppPath.SettingsCatchAll}
element={
<SettingsRoutes
isFunctionSettingsEnabled={isFunctionSettingsEnabled}
isAdminPageEnabled={isAdminPageEnabled}
/>
}
/>
<Route
path={AppPath.NotFoundWildcard}
element={
<LazyRoute>
<NotFound />
</LazyRoute>
}
/>
<Route element={<MainAppLayoutWithSidePanel />}>
<Route path={indexAppPath.getIndexAppPath()} element={<></>} />
<Route
path={AppPath.RecordIndexPage}
element={
<LazyRoute>
<RecordIndexPage />
</LazyRoute>
}
/>
<Route
path={AppPath.RecordShowPage}
element={
<LazyRoute>
<RecordShowPage />
</LazyRoute>
}
/>
<Route
path={AppPath.PageLayoutPage}
element={
<LazyRoute>
<StandalonePageLayoutPage />
</LazyRoute>
}
/>
<Route
path={AppPath.SettingsCatchAll}
element={
<SettingsRoutes
isFunctionSettingsEnabled={isFunctionSettingsEnabled}
isAdminPageEnabled={isAdminPageEnabled}
/>
}
/>
<Route
path={AppPath.NotFoundWildcard}
element={
<LazyRoute>
<NotFound />
</LazyRoute>
}
/>
</Route>
</Route>
<Route element={<BlankLayout />}>
<Route
@@ -1,13 +1,10 @@
import { CommandMenuForMobile } from '@/command-menu/components/CommandMenuForMobile';
import { SidePanelForDesktop } from '@/side-panel/components/SidePanelForDesktop';
import { useCommandMenuHotKeys } from '@/command-menu/hooks/useCommandMenuHotKeys';
import { PageBody } from '@/ui/layout/page/components/PageBody';
import { styled } from '@linaria/react';
import { type ReactNode } from 'react';
import { useIsMobile } from 'twenty-ui/utilities';
import { themeCssVariables } from 'twenty-ui/theme-constants';
type MainContainerLayoutWithSidePanelProps = {
type MainContainerLayoutProps = {
children: ReactNode;
};
@@ -54,20 +51,15 @@ const StyledPageBodyForMobileContainer = styled.div`
}
`;
export const MainContainerLayoutWithSidePanel = ({
children,
}: MainContainerLayoutWithSidePanelProps) => {
export const MainContainerLayout = ({ children }: MainContainerLayoutProps) => {
const isMobile = useIsMobile();
useCommandMenuHotKeys();
if (isMobile) {
return (
<StyledMainContainerLayoutForMobile>
<StyledPageBodyForMobileContainer>
<PageBody>{children}</PageBody>
</StyledPageBodyForMobileContainer>
<CommandMenuForMobile />
</StyledMainContainerLayoutForMobile>
);
}
@@ -77,7 +69,6 @@ export const MainContainerLayoutWithSidePanel = ({
<StyledPageBodyForDesktopContainer>
<PageBody>{children}</PageBody>
</StyledPageBodyForDesktopContainer>
<SidePanelForDesktop />
</StyledMainContainerLayoutForDesktop>
);
};
@@ -3,7 +3,7 @@ import { RecordIndexContextProvider } from '@/object-record/record-index/context
import { getCommandMenuIdFromRecordIndexId } from '@/command-menu-item/utils/getCommandMenuIdFromRecordIndexId';
import { CommandMenuComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuComponentInstanceContext';
import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject';
import { MainContainerLayoutWithSidePanel } from '@/object-record/components/MainContainerLayoutWithSidePanel';
import { MainContainerLayout } from '@/object-record/components/MainContainerLayout';
import { RecordComponentInstanceContextsWrapper } from '@/object-record/components/RecordComponentInstanceContextsWrapper';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { lastShowPageRecordIdState } from '@/object-record/record-field/ui/states/lastShowPageRecordId';
@@ -92,7 +92,7 @@ export const RecordIndexContainerGater = () => {
>
<PageTitle title={objectMetadataItem.labelPlural} />
<RecordIndexPageHeader />
<MainContainerLayoutWithSidePanel>
<MainContainerLayout>
<StyledIndexContainer
className={RECORD_INDEX_DRAG_SELECT_BOUNDARY_CLASS}
>
@@ -105,7 +105,7 @@ export const RecordIndexContainerGater = () => {
<RecordIndexEmptyStateNotShared />
)}
</StyledIndexContainer>
</MainContainerLayoutWithSidePanel>
</MainContainerLayout>
</CommandMenuComponentInstanceContext.Provider>
</RecordComponentInstanceContextsWrapper>
<RecordIndexLoadBaseOnContextStoreEffect />
@@ -1,9 +1,6 @@
import { CommandMenuForMobile } from '@/command-menu/components/CommandMenuForMobile';
import { useCommandMenuHotKeys } from '@/command-menu/hooks/useCommandMenuHotKeys';
import { InformationBannerWrapper } from '@/information-banner/components/InformationBannerWrapper';
import { SettingsPageHeader } from '@/settings/components/layout/SettingsPageHeader';
import { SettingsSecondaryBar } from '@/settings/components/layout/SettingsSecondaryBar';
import { SidePanelForDesktop } from '@/side-panel/components/SidePanelForDesktop';
import { type BreadcrumbProps } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { styled } from '@linaria/react';
@@ -68,8 +65,6 @@ export const SettingsPageLayout = ({
}: SettingsPageLayoutProps) => {
const isMobile = useIsMobile();
useCommandMenuHotKeys();
return (
<StyledRoot isMobile={isMobile}>
<StyledMainCardWrapper>
@@ -89,7 +84,6 @@ export const SettingsPageLayout = ({
</StyledBodyContent>
</StyledCard>
</StyledMainCardWrapper>
{isMobile ? <CommandMenuForMobile /> : <SidePanelForDesktop />}
</StyledRoot>
);
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

@@ -1,4 +1 @@
export {
SettingsDatePickerInput as EventLogDatePickerInput,
type SettingsDatePickerInputProps as EventLogDatePickerInputProps,
} from '@/settings/components/SettingsDatePickerInput';
export { SettingsDatePickerInput as EventLogDatePickerInput } from '@/settings/components/SettingsDatePickerInput';
@@ -12,18 +12,16 @@ import { TableRow } from '@/ui/layout/table/components/TableRow';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
import {
type EventLogRecord,
EventLogTable,
type EventLogTable,
} from '~/generated-metadata/graphql';
import {
type ColumnConfig,
getColumnsForEventLogTable,
} from '@/settings/event-logs/utils/getColumnsForEventLogTable';
import { EventLogJsonCell } from '@/settings/event-logs/components/EventLogJsonCell';
type EventLogResultsTableProps = {
records: EventLogRecord[];
@@ -106,9 +104,6 @@ export const EventLogResultsTable = ({
const { theme } = useContext(ThemeContext);
const { t } = useLingui();
const showObjectEventColumns = selectedTable === EventLogTable.OBJECT_EVENT;
const showApplicationLogColumns =
selectedTable === EventLogTable.APPLICATION_LOG;
const baseColumns = getColumnsForEventLogTable(selectedTable);
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(() =>
@@ -117,7 +112,6 @@ export const EventLogResultsTable = ({
const [resizingColumn, setResizingColumn] = useState<string | null>(null);
// Reset column widths when switching tables to avoid undefined widths for new columns
useEffect(() => {
setColumnWidths(
Object.fromEntries(baseColumns.map((col) => [col.id, col.defaultWidth])),
@@ -242,85 +236,21 @@ export const EventLogResultsTable = ({
</StyledResizableHeaderContainer>
))}
</TableRow>
{records.map((record, index) => (
{records.map((record) => (
<TableRow
key={`${record.timestamp}-${record.event}-${index}`}
key={`${record.timestamp}-${record.event}`}
gridTemplateColumns={gridTemplateColumns}
>
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{record.event}
</TableCell>
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{beautifyPastDateRelativeToNow(record.timestamp)}
</TableCell>
{showApplicationLogColumns ? (
<>
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{record.properties?.level ?? '-'}
</TableCell>
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{record.properties?.message ?? '-'}
</TableCell>
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{record.properties?.executionId ?? '-'}
</TableCell>
</>
) : (
<>
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{record.userId ?? '-'}
</TableCell>
{showObjectEventColumns && (
<>
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{record.recordId ?? '-'}
</TableCell>
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{record.objectMetadataId ?? '-'}
</TableCell>
</>
)}
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
<EventLogJsonCell value={record.properties} />
</TableCell>
</>
)}
{baseColumns.map((column) => (
<TableCell
key={column.id}
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{column.renderCell(record)}
</TableCell>
))}
</TableRow>
))}
</Table>
@@ -1,3 +1,5 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Select } from '@/ui/input/components/Select';
@@ -8,34 +10,26 @@ type EventLogTableSelectorProps = {
onChange: (value: EventLogTable) => void;
};
const TABLE_LABELS: Record<EventLogTable, MessageDescriptor> = {
[EventLogTable.PAGEVIEW]: msg`Page Views`,
[EventLogTable.WORKSPACE_EVENT]: msg`Workspace Events`,
[EventLogTable.OBJECT_EVENT]: msg`Object Events`,
[EventLogTable.USAGE_EVENT]: msg`Usage Events`,
[EventLogTable.APPLICATION_LOG]: msg`Application Logs`,
};
export const EventLogTableSelector = ({
value,
onChange,
}: EventLogTableSelectorProps) => {
const { t } = useLingui();
const options = [
{
value: EventLogTable.PAGEVIEW,
label: t`Page Views`,
},
{
value: EventLogTable.WORKSPACE_EVENT,
label: t`Workspace Events`,
},
{
value: EventLogTable.OBJECT_EVENT,
label: t`Object Events`,
},
{
value: EventLogTable.USAGE_EVENT,
label: t`Usage Events`,
},
{
value: EventLogTable.APPLICATION_LOG,
label: t`Application Logs`,
},
];
const options = (
Object.entries(TABLE_LABELS) as [EventLogTable, MessageDescriptor][]
).map(([table, label]) => ({
value: table,
label: t(label),
}));
return (
<Select
@@ -1,24 +1,51 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { billingState } from '@/client-config/states/billingState';
import { isClickHouseConfiguredState } from '@/client-config/states/isClickHouseConfiguredState';
import { SettingsEmptyPlaceholder } from '@/settings/components/SettingsEmptyPlaceholder';
import { SettingsEnterpriseFeatureGateCard } from '@/settings/components/SettingsEnterpriseFeatureGateCard';
import { SettingsOptionCardContentButton } from '@/settings/components/SettingsOptions/SettingsOptionCardContentButton';
import { EventLogFilters } from '@/settings/event-logs/components/EventLogFilters';
import { EventLogResultsTable } from '@/settings/event-logs/components/EventLogResultsTable';
import { EventLogTableSelector } from '@/settings/event-logs/components/EventLogTableSelector';
import { useEventLogsLiveStream } from '@/settings/event-logs/hooks/useEventLogsLiveStream';
import { useEventLogs } from '@/settings/event-logs/hooks/useQueryEventLogs';
import { type EventLogFiltersState } from '@/settings/event-logs/types/EventLogFiltersState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { SettingsPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { IconRefresh } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
import {
IconArrowUp,
IconLock,
IconPlayerPause,
IconPlayerPlay,
} from 'twenty-ui/display';
import { Button, IconButton } from 'twenty-ui/input';
import { Card } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { EventLogTable } from '~/generated-metadata/graphql';
import {
BillingEntitlementKey,
EventLogTable,
} from '~/generated-metadata/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { isGraphqlErrorOfType } from '~/utils/is-graphql-error-of-type.util';
const StyledRoot = styled.div`
box-sizing: border-box;
display: flex;
flex: 1;
flex-direction: column;
gap: ${themeCssVariables.spacing[6]};
margin: 0 auto;
max-width: 760px;
min-height: 0;
padding: ${themeCssVariables.spacing[6]} ${themeCssVariables.spacing[8]}
${themeCssVariables.spacing[8]};
width: 100%;
`;
const StyledCardContent = styled.div`
display: flex;
@@ -40,8 +67,10 @@ const StyledSelectorGrow = styled.div`
const StyledResults = styled.div`
display: flex;
flex: 1;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
min-height: 0;
`;
const StyledRecordCount = styled.span`
@@ -50,10 +79,9 @@ const StyledRecordCount = styled.span`
font-size: ${themeCssVariables.font.size.sm};
`;
// The results table scrolls internally and loads more as you reach the bottom,
// so it needs a bounded height.
const StyledTableWrapper = styled.div`
height: 480px;
flex: 1;
min-height: 0;
overflow: hidden;
`;
@@ -64,45 +92,64 @@ export const SettingsLogs = () => {
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const isClickHouseConfigured = useAtomStateValue(isClickHouseConfiguredState);
const billing = useAtomStateValue(billingState);
const navigateSettings = useNavigateSettings();
const hasEnterpriseAccess =
currentWorkspace?.hasValidSignedEnterpriseKey === true;
const isBillingEnabled = billing?.isBillingEnabled ?? false;
const hasAuditLogsEntitlement =
currentWorkspace?.billingEntitlements?.some(
(entitlement) =>
entitlement.key === BillingEntitlementKey.AUDIT_LOGS &&
entitlement.value,
) === true;
const [selectedTable, setSelectedTable] = useState<EventLogTable>(
EventLogTable.PAGEVIEW,
);
const [filters, setFilters] = useState<EventLogFiltersState>({});
const [isPaused, setIsPaused] = useState(false);
const isApplicationLog = selectedTable === EventLogTable.APPLICATION_LOG;
const canQuery =
isClickHouseConfigured && (isApplicationLog || hasEnterpriseAccess);
isClickHouseConfigured && (isApplicationLog || hasAuditLogsEntitlement);
const {
records,
totalCount,
hasNextPage,
loading,
error,
refetch,
loadMore,
} = useEventLogs(
{
table: selectedTable,
filters: {
eventType: filters.eventType,
userWorkspaceId: filters.userWorkspaceId,
dateRange: filters.dateRange
? {
start: filters.dateRange.start?.toISOString(),
end: filters.dateRange.end?.toISOString(),
}
: undefined,
recordId: filters.recordId,
objectMetadataId: filters.objectMetadataId,
const { records, totalCount, hasNextPage, loading, error, loadMore } =
useEventLogs(
{
table: selectedTable,
filters: {
eventType: filters.eventType,
userWorkspaceId: filters.userWorkspaceId,
dateRange: filters.dateRange
? {
start: filters.dateRange.start?.toISOString(),
end: filters.dateRange.end?.toISOString(),
}
: undefined,
recordId: filters.recordId,
objectMetadataId: filters.objectMetadataId,
},
first: RECORDS_PER_PAGE,
},
first: RECORDS_PER_PAGE,
},
{ skip: !canQuery },
{ skip: !canQuery },
);
const hasActiveFilters =
isDefined(filters.eventType) ||
isDefined(filters.userWorkspaceId) ||
isDefined(filters.recordId) ||
isDefined(filters.objectMetadataId) ||
isDefined(filters.dateRange?.start) ||
isDefined(filters.dateRange?.end);
const liveRecords = useEventLogsLiveStream({
table: selectedTable,
enabled: !isPaused && !hasActiveFilters && canQuery,
});
const displayedRecords = useMemo(
() => [...liveRecords, ...records],
[liveRecords, records],
);
const handleTableChange = (table: EventLogTable) => {
@@ -114,39 +161,63 @@ export const SettingsLogs = () => {
setFilters(newFilters);
};
const renderUpgradeCard = () => (
<Card rounded>
<SettingsOptionCardContentButton
Icon={IconLock}
title={t`Upgrade to access audit logs`}
description={t`Only application logs are available on your current plan. Other log types require an Enterprise subscription.`}
Button={
<Button
title={t`Upgrade`}
variant="primary"
accent="blue"
size="small"
Icon={IconArrowUp}
onClick={() =>
navigateSettings(
isBillingEnabled
? SettingsPath.Billing
: SettingsPath.AdminPanelEnterprise,
)
}
/>
}
/>
</Card>
);
const renderResults = () => {
if (!isApplicationLog && !hasEnterpriseAccess) {
return (
<SettingsEnterpriseFeatureGateCard
title={t`Enterprise feature`}
description={t`Upgrade to Enterprise to access this log type.`}
buttonTitle={t`Activate`}
/>
);
if (!isApplicationLog && !hasAuditLogsEntitlement) {
return renderUpgradeCard();
}
if (!isClickHouseConfigured) {
return (
<SettingsEmptyPlaceholder>
{t`Audit logs require ClickHouse to be configured. Please contact your administrator.`}
{t`Logs require ClickHouse to be configured. Please contact your administrator.`}
</SettingsEmptyPlaceholder>
);
}
if (isDefined(error)) {
if (isGraphqlErrorOfType(error, 'NO_ENTITLEMENT')) {
return renderUpgradeCard();
}
return (
<SettingsEmptyPlaceholder>
{t`Something went wrong while loading audit logs. Please try again.`}
{t`Something went wrong while loading logs. Please try again.`}
</SettingsEmptyPlaceholder>
);
}
return (
<StyledResults>
<StyledRecordCount>{t`${records.length} of ${totalCount}`}</StyledRecordCount>
<StyledRecordCount>{t`${displayedRecords.length} of ${totalCount + liveRecords.length}`}</StyledRecordCount>
<StyledTableWrapper>
<EventLogResultsTable
records={records}
records={displayedRecords}
loading={loading}
hasNextPage={hasNextPage}
onLoadMore={loadMore}
@@ -158,7 +229,7 @@ export const SettingsLogs = () => {
};
return (
<>
<StyledRoot>
<Card rounded fullWidth>
<StyledCardContent>
<StyledSelectorRow>
@@ -168,17 +239,15 @@ export const SettingsLogs = () => {
onChange={handleTableChange}
/>
</StyledSelectorGrow>
<IconButton
Icon={IconRefresh}
variant="secondary"
size="medium"
ariaLabel={t`Refresh`}
onClick={() => {
if (canQuery) {
void refetch();
}
}}
/>
{canQuery && (
<IconButton
Icon={isPaused ? IconPlayerPlay : IconPlayerPause}
variant="secondary"
size="medium"
ariaLabel={isPaused ? t`Resume` : t`Pause`}
onClick={() => setIsPaused((previous) => !previous)}
/>
)}
</StyledSelectorRow>
<EventLogFilters
table={selectedTable}
@@ -189,6 +258,6 @@ export const SettingsLogs = () => {
</Card>
{renderResults()}
</>
</StyledRoot>
);
};
@@ -10,7 +10,6 @@ export const GET_EVENT_LOGS = gql`
properties
recordId
objectMetadataId
isCustom
}
totalCount
pageInfo {
@@ -0,0 +1,14 @@
import { gql } from '@apollo/client';
export const EVENT_LOGS_LIVE_SUBSCRIPTION = gql`
subscription EventLogsLive($table: EventLogTable!) {
eventLogsLive(table: $table) {
event
timestamp
userId
properties
recordId
objectMetadataId
}
}
`;
@@ -0,0 +1,68 @@
import { print, type ExecutionResult } from 'graphql';
import { useEffect, useState } from 'react';
import { EVENT_LOGS_LIVE_SUBSCRIPTION } from '@/settings/event-logs/graphql/subscriptions/EventLogsLiveSubscription';
import { sseClientState } from '@/sse-db-event/states/sseClientState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { captureException } from '@sentry/react';
import { isDefined } from 'twenty-shared/utils';
import {
type EventLogRecord,
type EventLogTable,
} from '~/generated-metadata/graphql';
type EventLogsLivePayload = {
eventLogsLive: EventLogRecord[] | null;
};
const EVENT_LOGS_LIVE_SUBSCRIPTION_QUERY = print(EVENT_LOGS_LIVE_SUBSCRIPTION);
export const useEventLogsLiveStream = ({
table,
enabled,
}: {
table: EventLogTable;
enabled: boolean;
}): EventLogRecord[] => {
const sseClient = useAtomStateValue(sseClientState);
const [liveRecords, setLiveRecords] = useState<EventLogRecord[]>([]);
useEffect(() => {
setLiveRecords([]);
}, [table]);
useEffect(() => {
if (!enabled) {
setLiveRecords([]);
}
}, [enabled]);
useEffect(() => {
if (!enabled || !isDefined(sseClient)) {
return;
}
const dispose = sseClient.subscribe<EventLogsLivePayload>(
{
query: EVENT_LOGS_LIVE_SUBSCRIPTION_QUERY,
variables: { table },
},
{
next: (value: ExecutionResult<EventLogsLivePayload>) => {
const incoming = value.data?.eventLogsLive;
if (isDefined(incoming) && incoming.length > 0) {
setLiveRecords((previous) => [...incoming, ...previous]);
}
},
error: (error) => captureException(error),
complete: () => {},
},
);
return () => dispose();
}, [enabled, sseClient, table]);
return liveRecords;
};
@@ -20,7 +20,7 @@ export const useEventLogs = (
input: EventLogQueryInput,
options?: { skip?: boolean },
) => {
const { data, loading, error, refetch, fetchMore } = useQuery<
const { data, loading, error, fetchMore } = useQuery<
EventLogsData,
EventLogsVariables
>(GET_EVENT_LOGS, {
@@ -70,7 +70,6 @@ export const useEventLogs = (
hasNextPage,
loading,
error,
refetch,
loadMore,
};
};
@@ -1,121 +0,0 @@
import { msg } from '@lingui/core/macro';
import { type MessageDescriptor } from '@lingui/core';
import { EventLogTable } from '~/generated-metadata/graphql';
export type ColumnConfig = {
id: string;
label: MessageDescriptor;
minWidth: number;
defaultWidth: number;
};
const DEFAULT_COLUMNS: ColumnConfig[] = [
{ id: 'event', label: msg`Event`, minWidth: 100, defaultWidth: 200 },
{
id: 'timestamp',
label: msg`Timestamp`,
minWidth: 100,
defaultWidth: 150,
},
{ id: 'userId', label: msg`User`, minWidth: 100, defaultWidth: 150 },
{
id: 'properties',
label: msg`Properties`,
minWidth: 200,
defaultWidth: 400,
},
];
const OBJECT_EVENT_COLUMNS: ColumnConfig[] = [
{ id: 'event', label: msg`Event`, minWidth: 100, defaultWidth: 180 },
{
id: 'timestamp',
label: msg`Timestamp`,
minWidth: 100,
defaultWidth: 130,
},
{ id: 'userId', label: msg`User`, minWidth: 100, defaultWidth: 130 },
{
id: 'recordId',
label: msg`Record ID`,
minWidth: 100,
defaultWidth: 130,
},
{
id: 'objectMetadataId',
label: msg`Object ID`,
minWidth: 100,
defaultWidth: 130,
},
{
id: 'properties',
label: msg`Properties`,
minWidth: 150,
defaultWidth: 300,
},
];
const USAGE_EVENT_COLUMNS: ColumnConfig[] = [
{
id: 'event',
label: msg`Resource Type`,
minWidth: 100,
defaultWidth: 130,
},
{
id: 'timestamp',
label: msg`Timestamp`,
minWidth: 100,
defaultWidth: 140,
},
{ id: 'userId', label: msg`User`, minWidth: 100, defaultWidth: 130 },
{
id: 'properties',
label: msg`Details`,
minWidth: 200,
defaultWidth: 400,
},
];
const APPLICATION_LOG_COLUMNS: ColumnConfig[] = [
{
id: 'event',
label: msg`Function`,
minWidth: 100,
defaultWidth: 160,
},
{
id: 'timestamp',
label: msg`Timestamp`,
minWidth: 100,
defaultWidth: 140,
},
{ id: 'level', label: msg`Level`, minWidth: 60, defaultWidth: 80 },
{
id: 'message',
label: msg`Message`,
minWidth: 200,
defaultWidth: 400,
},
{
id: 'executionId',
label: msg`Execution ID`,
minWidth: 100,
defaultWidth: 140,
},
];
const COLUMNS_BY_TABLE: Record<EventLogTable, ColumnConfig[]> = {
[EventLogTable.OBJECT_EVENT]: OBJECT_EVENT_COLUMNS,
[EventLogTable.USAGE_EVENT]: USAGE_EVENT_COLUMNS,
[EventLogTable.APPLICATION_LOG]: APPLICATION_LOG_COLUMNS,
[EventLogTable.WORKSPACE_EVENT]: DEFAULT_COLUMNS,
[EventLogTable.PAGEVIEW]: DEFAULT_COLUMNS,
};
export const getColumnsForEventLogTable = (
table: EventLogTable,
): ColumnConfig[] => {
return COLUMNS_BY_TABLE[table] ?? DEFAULT_COLUMNS;
};
@@ -0,0 +1,125 @@
import { type ReactNode } from 'react';
import { msg } from '@lingui/core/macro';
import { type MessageDescriptor } from '@lingui/core';
import { EventLogJsonCell } from '@/settings/event-logs/components/EventLogJsonCell';
import {
type EventLogRecord,
EventLogTable,
} from '~/generated-metadata/graphql';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
export type ColumnConfig = {
id: string;
label: MessageDescriptor;
minWidth: number;
defaultWidth: number;
renderCell: (record: EventLogRecord) => ReactNode;
};
const EVENT_COLUMN: ColumnConfig = {
id: 'event',
label: msg`Event`,
minWidth: 100,
defaultWidth: 200,
renderCell: (record) => record.event,
};
const TIMESTAMP_COLUMN: ColumnConfig = {
id: 'timestamp',
label: msg`Timestamp`,
minWidth: 100,
defaultWidth: 150,
renderCell: (record) => beautifyPastDateRelativeToNow(record.timestamp),
};
const USER_COLUMN: ColumnConfig = {
id: 'userId',
label: msg`User`,
minWidth: 100,
defaultWidth: 150,
renderCell: (record) => record.userId ?? '-',
};
const PROPERTIES_COLUMN: ColumnConfig = {
id: 'properties',
label: msg`Properties`,
minWidth: 200,
defaultWidth: 400,
renderCell: (record) => <EventLogJsonCell value={record.properties} />,
};
const DEFAULT_COLUMNS: ColumnConfig[] = [
EVENT_COLUMN,
TIMESTAMP_COLUMN,
USER_COLUMN,
PROPERTIES_COLUMN,
];
const OBJECT_EVENT_COLUMNS: ColumnConfig[] = [
{ ...EVENT_COLUMN, defaultWidth: 180 },
{ ...TIMESTAMP_COLUMN, defaultWidth: 130 },
{ ...USER_COLUMN, defaultWidth: 130 },
{
id: 'recordId',
label: msg`Record ID`,
minWidth: 100,
defaultWidth: 130,
renderCell: (record) => record.recordId ?? '-',
},
{
id: 'objectMetadataId',
label: msg`Object ID`,
minWidth: 100,
defaultWidth: 130,
renderCell: (record) => record.objectMetadataId ?? '-',
},
{ ...PROPERTIES_COLUMN, minWidth: 150, defaultWidth: 300 },
];
const USAGE_EVENT_COLUMNS: ColumnConfig[] = [
{ ...EVENT_COLUMN, label: msg`Resource Type`, defaultWidth: 130 },
{ ...TIMESTAMP_COLUMN, defaultWidth: 140 },
{ ...USER_COLUMN, defaultWidth: 130 },
{ ...PROPERTIES_COLUMN, label: msg`Details` },
];
const APPLICATION_LOG_COLUMNS: ColumnConfig[] = [
{ ...EVENT_COLUMN, label: msg`Function`, defaultWidth: 160 },
{ ...TIMESTAMP_COLUMN, defaultWidth: 140 },
{
id: 'level',
label: msg`Level`,
minWidth: 60,
defaultWidth: 80,
renderCell: (record) => record.properties?.level ?? '-',
},
{
id: 'message',
label: msg`Message`,
minWidth: 200,
defaultWidth: 400,
renderCell: (record) => record.properties?.message ?? '-',
},
{
id: 'executionId',
label: msg`Execution ID`,
minWidth: 100,
defaultWidth: 140,
renderCell: (record) => record.properties?.executionId ?? '-',
},
];
const COLUMNS_BY_TABLE: Record<EventLogTable, ColumnConfig[]> = {
[EventLogTable.OBJECT_EVENT]: OBJECT_EVENT_COLUMNS,
[EventLogTable.USAGE_EVENT]: USAGE_EVENT_COLUMNS,
[EventLogTable.APPLICATION_LOG]: APPLICATION_LOG_COLUMNS,
[EventLogTable.WORKSPACE_EVENT]: DEFAULT_COLUMNS,
[EventLogTable.PAGEVIEW]: DEFAULT_COLUMNS,
};
export const getColumnsForEventLogTable = (
table: EventLogTable,
): ColumnConfig[] => {
return COLUMNS_BY_TABLE[table] ?? DEFAULT_COLUMNS;
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 110 KiB

@@ -25,13 +25,18 @@ const StyledSidePanelWrapper = styled.div<{
isOpen: boolean;
isResizing: boolean;
}>`
box-sizing: border-box;
flex-shrink: 0;
min-width: 0;
overflow: hidden;
padding: ${({ isOpen }) =>
isOpen
? `${themeCssVariables.spacing[2]} ${themeCssVariables.spacing[2]} ${themeCssVariables.spacing[2]} 0`
: '0'};
transition: ${({ isResizing }) =>
isResizing
? 'none'
: `width calc(${themeCssVariables.animation.duration.normal} * 1s)`};
: `width calc(${themeCssVariables.animation.duration.normal} * 1s), padding calc(${themeCssVariables.animation.duration.normal} * 1s)`};
width: ${({ isOpen }) => (isOpen ? `var(${SIDE_PANEL_WIDTH_VAR})` : '0px')};
`;
@@ -0,0 +1,37 @@
import { CommandMenuForMobile } from '@/command-menu/components/CommandMenuForMobile';
import { useCommandMenuHotKeys } from '@/command-menu/hooks/useCommandMenuHotKeys';
import { SidePanelForDesktop } from '@/side-panel/components/SidePanelForDesktop';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { styled } from '@linaria/react';
import { Outlet } from 'react-router-dom';
const StyledRow = styled.div`
display: flex;
flex: 1;
flex-direction: row;
min-height: 0;
min-width: 0;
`;
const StyledContent = styled.div`
display: flex;
flex: 1 1 0;
min-height: 0;
min-width: 0;
overflow: hidden;
`;
export const MainAppLayoutWithSidePanel = () => {
const isMobile = useIsMobile();
useCommandMenuHotKeys();
return (
<StyledRow>
<StyledContent>
<Outlet />
</StyledContent>
{isMobile ? <CommandMenuForMobile /> : <SidePanelForDesktop />}
</StyledRow>
);
};
@@ -7,7 +7,7 @@ import { TimelineActivityContext } from '@/activities/timeline-activities/contex
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { MainContainerLayoutWithSidePanel } from '@/object-record/components/MainContainerLayoutWithSidePanel';
import { MainContainerLayout } from '@/object-record/components/MainContainerLayout';
import { RecordComponentInstanceContextsWrapper } from '@/object-record/components/RecordComponentInstanceContextsWrapper';
import { PageLayoutRecordPageRenderer } from '@/object-record/record-show/components/PageLayoutRecordPageRenderer';
import { RecordShowPageSSESubscribeEffect } from '@/object-record/record-show/components/RecordShowPageSSESubscribeEffect';
@@ -58,7 +58,7 @@ export const RecordShowPage = () => {
<RecordShowCommandMenu />
{!isLayoutCustomizationModeEnabled && <SidePanelToggleButton />}
</RecordShowPageHeader>
<MainContainerLayoutWithSidePanel>
<MainContainerLayout>
<TimelineActivityContext.Provider
value={{
recordId: objectRecordId,
@@ -76,7 +76,7 @@ export const RecordShowPage = () => {
recordId={objectRecordId}
/>
</TimelineActivityContext.Provider>
</MainContainerLayoutWithSidePanel>
</MainContainerLayout>
</PageContainer>
</CommandMenuComponentInstanceContext.Provider>
</ContextStoreComponentInstanceContext.Provider>
@@ -3,7 +3,7 @@ import { useParams } from 'react-router-dom';
import { CommandMenuComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuComponentInstanceContext';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { MainContainerLayoutWithSidePanel } from '@/object-record/components/MainContainerLayoutWithSidePanel';
import { MainContainerLayout } from '@/object-record/components/MainContainerLayout';
import { PageLayoutRenderer } from '@/page-layout/components/PageLayoutRenderer';
import { LayoutRenderingProvider } from '@/ui/layout/contexts/LayoutRenderingContext';
import { PageContainer } from '@/ui/layout/page/components/PageContainer';
@@ -34,9 +34,9 @@ export const StandalonePageLayoutPage = () => {
isInSidePanel: false,
}}
>
<MainContainerLayoutWithSidePanel>
<MainContainerLayout>
<PageLayoutRenderer pageLayoutId={pageLayoutId} />
</MainContainerLayoutWithSidePanel>
</MainContainerLayout>
</LayoutRenderingProvider>
</CommandMenuComponentInstanceContext.Provider>
</ContextStoreComponentInstanceContext.Provider>
@@ -1,169 +0,0 @@
import { useContext, useState } from 'react';
import { Select } from '@/ui/input/components/Select';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { H2Title, IconCopy } from 'twenty-ui/display';
import { CodeEditor, IconButton } from 'twenty-ui/input';
import { Card, CardContent, Section } from 'twenty-ui/layout';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
const StyledCoverImage = styled.div`
background-position: center;
background-size: cover;
height: 160px;
overflow: hidden;
`;
const StyledConfigButtonsContainer = styled.div`
display: flex;
flex-direction: row;
gap: ${themeCssVariables.spacing[2]};
position: absolute;
right: ${themeCssVariables.spacing[3]};
top: ${themeCssVariables.spacing[3]};
z-index: 1;
`;
const StyledCoverCardContent = styled(CardContent)`
padding: 0;
`;
const StyledCopyButton = styled(IconButton)`
background-color: ${themeCssVariables.background.transparent.lighter};
border: 1px solid ${themeCssVariables.border.color.medium};
`;
const StyledEditorContainer = styled.div`
.monaco-editor,
.monaco-editor .overflow-guard {
background-color: transparent !important;
border: none !important;
}
.monaco-editor .line-hover {
background-color: transparent !important;
}
`;
type McpAuthMethod = 'oauth' | 'api-key';
export const SettingsAiMCP = () => {
const { t } = useLingui();
const { copyToClipboard } = useCopyToClipboard();
const [authMethod, setAuthMethod] = useState<McpAuthMethod>('oauth');
const { colorScheme } = useContext(ThemeContext);
const coverImage =
colorScheme === 'light'
? '/images/ai/ai-mcp-cover-light.svg'
: '/images/ai/ai-mcp-cover-dark.svg';
const oauthConfig = JSON.stringify(
{
mcpServers: {
twenty: {
type: 'streamable-http',
url: `${REACT_APP_SERVER_BASE_URL}/mcp`,
},
},
},
null,
2,
);
const apiKeyConfig = JSON.stringify(
{
mcpServers: {
twenty: {
type: 'streamable-http',
url: `${REACT_APP_SERVER_BASE_URL}/mcp`,
headers: {
Authorization: 'Bearer [API_KEY]',
},
},
},
},
null,
2,
);
const isOAuth = authMethod === 'oauth';
const activeConfig = isOAuth ? oauthConfig : apiKeyConfig;
const editorHeight = isOAuth ? 170 : 230;
const codeEditorOptions = {
readOnly: true,
domReadOnly: true,
renderLineHighlight: 'none' as const,
renderLineHighlightOnlyWhenFocus: false,
lineNumbers: 'off' as const,
folding: false,
selectionHighlight: false,
occurrencesHighlight: 'off' as const,
scrollBeyondLastLine: false,
hover: {
enabled: false,
},
guides: {
indentation: false,
bracketPairs: false,
bracketPairsHorizontal: false,
},
padding: {
top: 12,
},
};
return (
<Section>
<H2Title
title={t`MCP Server`}
description={t`Access your workspace data from your favorite MCP client like Claude Desktop, Windsurf or Cursor.`}
/>
<Card rounded>
<StyledCoverCardContent divider>
<StyledCoverImage
style={{ backgroundImage: `url('${coverImage}')` }}
/>
</StyledCoverCardContent>
<StyledCoverCardContent>
<StyledEditorContainer style={{ position: 'relative' }}>
<StyledConfigButtonsContainer>
<Select
dropdownId="mcp-auth-method-select"
value={authMethod}
onChange={(value) => setAuthMethod(value as McpAuthMethod)}
options={[
{ label: t`OAuth`, value: 'oauth' },
{ label: t`API Key`, value: 'api-key' },
]}
selectSizeVariant="small"
dropdownWidth={GenericDropdownContentWidth.Medium}
dropdownOffset={{ x: 0, y: 4 }}
/>
<StyledCopyButton
Icon={IconCopy}
onClick={() => {
copyToClipboard(
activeConfig,
t`MCP Configuration copied to clipboard`,
);
}}
size="small"
/>
</StyledConfigButtonsContainer>
<CodeEditor
value={activeConfig}
language="json"
options={codeEditorOptions}
height={editorHeight}
/>
</StyledEditorContainer>
</StyledCoverCardContent>
</Card>
</Section>
);
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 92 KiB

@@ -50,10 +50,6 @@ export const SettingsGeneral = () => {
);
const renderActiveTabContent = () => {
if (activeTabId === GENERAL_TAB_LOGS) {
return <SettingsLogs />;
}
if (activeTabId === GENERAL_TAB_SECURITY) {
return <SettingsSecuritySettings />;
}
@@ -97,7 +93,13 @@ export const SettingsGeneral = () => {
}
links={[{ children: t`Workspace` }, { children: t`General` }]}
>
<SettingsPageContainer>{renderActiveTabContent()}</SettingsPageContainer>
{activeTabId === GENERAL_TAB_LOGS ? (
<SettingsLogs />
) : (
<SettingsPageContainer>
{renderActiveTabContent()}
</SettingsPageContainer>
)}
</SettingsPageLayout>
);
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 88 KiB

@@ -7,6 +7,7 @@ import chalk from 'chalk';
export type AppDevOnceCommandOptions = {
appPath?: string;
verbose?: boolean;
dryRun?: boolean;
};
export class AppDevOnceCommand {
@@ -17,12 +18,17 @@ export class AppDevOnceCommand {
const remoteName = ConfigService.getActiveRemote();
console.log(chalk.blue(`Syncing application on ${remoteName}...`));
console.log(
chalk.blue(
`${options.dryRun ? 'Previewing application diff' : 'Syncing application'} on ${remoteName}...`,
),
);
console.log(chalk.gray(`App path: ${appPath}\n`));
const result = await appDevOnce({
appPath,
verbose: options.verbose,
dryRun: options.dryRun,
onProgress: (message) => console.log(chalk.gray(message)),
});
@@ -31,6 +37,16 @@ export class AppDevOnceCommand {
process.exit(1);
}
if (options.dryRun) {
console.log(
chalk.green(
`\n✓ Dry run complete for ${result.data.applicationDisplayName} — no changes were applied`,
),
);
return;
}
console.log(
chalk.green(
`\n✓ Synced ${result.data.applicationDisplayName} (${result.data.fileCount} file${result.data.fileCount === 1 ? '' : 's'})`,
@@ -22,6 +22,7 @@ export const registerDevCommands = (program: Command): void => {
verbose?: boolean;
debug?: boolean;
debounceMs?: string;
dryRun?: boolean;
},
) => {
const commonOptions = {
@@ -33,7 +34,10 @@ export const registerDevCommands = (program: Command): void => {
};
if (options.once) {
await devOnceCommand.execute(commonOptions);
await devOnceCommand.execute({
...commonOptions,
dryRun: options.dryRun,
});
return;
}
@@ -48,6 +52,10 @@ export const registerDevCommands = (program: Command): void => {
'-o, --once',
'Build and sync once, then exit (useful for CI, scripts, and pre-commit hooks)',
)
.option(
'--dry-run',
'Preview the metadata changes without applying them (requires --once)',
)
.option('--debounceMs <ms>', 'Debounce in ms (default: 2 000)')
.option('-v, --verbose', 'Show detailed logs')
.option('-d, --debug', 'Show detailed logs (alias for --verbose)')
@@ -1,5 +1,6 @@
import path from 'path';
import { OUTPUT_DIR, type Manifest } from 'twenty-shared/application';
import { type SyncAction } from 'twenty-shared/metadata';
import { ApiService } from '@/cli/utilities/api/api-service';
import {
@@ -13,6 +14,7 @@ import { manifestUpdateChecksums } from '@/cli/utilities/build/manifest/manifest
import { writeManifestToOutput } from '@/cli/utilities/build/manifest/manifest-writer';
import { ClientService } from '@/cli/utilities/client/client-service';
import { ConfigService } from '@/cli/utilities/config/config-service';
import { formatSyncActionsSummary } from '@/cli/utilities/dev/orchestrator/steps/format-sync-actions-summary';
import { formatManifestValidationErrors } from '@/cli/utilities/error/format-manifest-validation-errors';
import { serializeError } from '@/cli/utilities/error/serialize-error';
import { FileUploader } from '@/cli/utilities/file/file-uploader';
@@ -22,6 +24,7 @@ import { APP_ERROR_CODES, type CommandResult } from '@/cli/types';
export type AppDevOnceOptions = {
appPath: string;
verbose?: boolean;
dryRun?: boolean;
onProgress?: (message: string) => void;
};
@@ -32,10 +35,19 @@ export type AppDevOnceResult = {
applicationUniversalIdentifier: string;
};
const reportMetadataChanges = (
data: { actions: SyncAction[] },
onProgress?: (message: string) => void,
): void => {
for (const event of formatSyncActionsSummary(data.actions)) {
onProgress?.(event.message);
}
};
const innerAppDevOnce = async (
options: AppDevOnceOptions,
): Promise<CommandResult<AppDevOnceResult>> => {
const { appPath, onProgress, verbose } = options;
const { appPath, onProgress, verbose, dryRun } = options;
onProgress?.('Checking server...');
@@ -120,6 +132,47 @@ const innerAppDevOnce = async (
await writeManifestToOutput(appPath, manifest);
if (dryRun) {
onProgress?.(
'Computing metadata diff (dry run, nothing will be applied)...',
);
const dryRunResult = await apiService.syncApplication(manifest, {
dryRun: true,
});
if (!dryRunResult.success) {
const errorEvents = verbose
? null
: formatManifestValidationErrors(dryRunResult.error);
const message = errorEvents
? errorEvents.map((event) => event.message).join('\n')
: `Dry run failed with error: ${serializeError(dryRunResult.error)}`;
return {
success: false,
error: {
code: APP_ERROR_CODES.SYNC_FAILED,
message,
},
};
}
reportMetadataChanges(dryRunResult.data, onProgress);
return {
success: true,
data: {
outputDir: path.join(appPath, OUTPUT_DIR),
fileCount: buildResult.builtFileInfos.size,
applicationDisplayName: manifest.application.displayName,
applicationUniversalIdentifier:
manifest.application.universalIdentifier,
},
};
}
onProgress?.('Registering application...');
const configService = new ConfigService();
@@ -212,6 +265,8 @@ const innerAppDevOnce = async (
};
}
reportMetadataChanges(syncResult.data, onProgress);
onProgress?.('Generating API client...');
try {
@@ -73,13 +73,16 @@ export class ApiService {
return this.applicationApi.createDevelopmentApplication(...args);
}
syncApplication(manifest: Manifest): Promise<
syncApplication(
manifest: Manifest,
options?: { dryRun?: boolean },
): Promise<
ApiResponse<{
applicationUniversalIdentifier: string;
actions: SyncAction[];
}>
> {
return this.applicationApi.syncApplication(manifest);
return this.applicationApi.syncApplication(manifest, options);
}
uninstallApplication(universalIdentifier: string): Promise<ApiResponse> {
@@ -252,7 +252,10 @@ export class ApplicationApi {
}
}
async syncApplication(manifest: Manifest): Promise<
async syncApplication(
manifest: Manifest,
options?: { dryRun?: boolean },
): Promise<
ApiResponse<{
applicationUniversalIdentifier: string;
actions: SyncAction[];
@@ -260,15 +263,15 @@ export class ApplicationApi {
> {
try {
const mutation = `
mutation SyncApplication($manifest: JSON!) {
syncApplication(manifest: $manifest) {
mutation SyncApplication($manifest: JSON!, $dryRun: Boolean) {
syncApplication(manifest: $manifest, dryRun: $dryRun) {
applicationUniversalIdentifier
actions
}
}
`;
const variables = { manifest };
const variables = { manifest, dryRun: options?.dryRun ?? false };
const response: AxiosResponse = await this.client.post(
'/metadata',
@@ -10,6 +10,12 @@ describe('formatSyncActionsSummary', () => {
]);
});
it('reports no changes when actions are missing from the response', () => {
expect(formatSyncActionsSummary(undefined)).toEqual([
{ message: 'No metadata changes', status: 'info' },
]);
});
it('summarizes created, updated and deleted actions with their identifiers', () => {
const events = formatSyncActionsSummary([
{
@@ -24,15 +24,17 @@ const getActionLabel = (action: SyncAction): string => {
};
export const formatSyncActionsSummary = (
actions: SyncAction[],
actions: SyncAction[] | undefined,
): OrchestratorStateStepEvent[] => {
if (actions.length === 0) {
const definedActions = actions ?? [];
if (definedActions.length === 0) {
return [{ message: 'No metadata changes', status: 'info' }];
}
const counts = { create: 0, update: 0, delete: 0 };
for (const action of actions) {
for (const action of definedActions) {
counts[action.type] += 1;
}
@@ -46,7 +48,7 @@ export const formatSyncActionsSummary = (
{ message: `Metadata changes: ${summaryParts.join(', ')}`, status: 'info' },
];
const visibleActions = actions.slice(0, MAX_DETAIL_LINES);
const visibleActions = definedActions.slice(0, MAX_DETAIL_LINES);
for (const action of visibleActions) {
events.push({
@@ -55,7 +57,7 @@ export const formatSyncActionsSummary = (
});
}
const hiddenCount = actions.length - visibleActions.length;
const hiddenCount = definedActions.length - visibleActions.length;
if (hiddenCount > 0) {
events.push({
-1
View File
@@ -35,7 +35,6 @@ FRONTEND_URL=http://localhost:3001
# AUTH_GOOGLE_APIS_CALLBACK_URL=http://localhost:3000/auth/google-apis/get-access-token
# CODE_INTERPRETER_TYPE=LOCAL
# LOGIC_FUNCTION_TYPE=LOCAL
# LOGIC_FUNCTION_LOGS_ENABLED=true
# STORAGE_TYPE=local
# STORAGE_LOCAL_PATH=.local-storage
# SUPPORT_DRIVER=front
@@ -105,6 +105,10 @@ describe('ClickHouseService', () => {
table: 'test_table',
values: testData,
format: 'JSONEachRow',
clickhouse_settings: {
async_insert: 1,
wait_for_async_insert: 1,
},
});
});
@@ -28,10 +28,6 @@ export class ClickHouseService implements OnModuleInit, OnModuleDestroy {
response: true,
request: true,
},
clickhouse_settings: {
async_insert: 1,
wait_for_async_insert: 1,
},
application: 'twenty',
log: { level: ClickHouseLogLevel.OFF },
});
@@ -88,10 +84,6 @@ export class ClickHouseService implements OnModuleInit, OnModuleDestroy {
response: true,
request: true,
},
clickhouse_settings: {
async_insert: 1,
wait_for_async_insert: 1,
},
application: 'twenty',
log: { level: ClickHouseLogLevel.OFF },
});
@@ -282,6 +274,10 @@ export class ClickHouseService implements OnModuleInit, OnModuleDestroy {
table,
values: chunk,
format: 'JSONEachRow',
clickhouse_settings: {
async_insert: 1,
wait_for_async_insert: 1,
},
});
chunk = [];
currentSizeBytes = 0;
@@ -12,3 +12,8 @@ export const formatDateTimeForClickHouse = (date: Date | string): string => {
export const formatDateForClickHouse = (date: Date): string =>
date.toISOString().slice(0, 10);
// ClickHouse returns DateTime64 values as naive strings (YYYY-MM-DD HH:mm:ss.SSS) in UTC.
// Parse them as UTC explicitly, otherwise `new Date` assumes the server's local timezone.
export const parseClickHouseDateTime = (value: string): Date =>
new Date(`${value.replace(' ', 'T')}Z`);
@@ -1,9 +1,9 @@
import { OBJECT_RECORD_CREATED_EVENT } from 'src/engine/core-modules/audit/utils/events/object-event/object-record-created';
import { OBJECT_RECORD_DELETED_EVENT } from 'src/engine/core-modules/audit/utils/events/object-event/object-record-delete';
import { OBJECT_RECORD_UPDATED_EVENT } from 'src/engine/core-modules/audit/utils/events/object-event/object-record-updated';
import { CUSTOM_DOMAIN_ACTIVATED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/custom-domain/custom-domain-activated';
import { CUSTOM_DOMAIN_DEACTIVATED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/custom-domain/custom-domain-deactivated';
import { type GenericTrackEvent } from 'src/engine/core-modules/audit/utils/events/workspace-event/track';
import { OBJECT_RECORD_CREATED_EVENT } from 'src/engine/core-modules/event-logs/emit/events/object-event/object-record-created';
import { OBJECT_RECORD_DELETED_EVENT } from 'src/engine/core-modules/event-logs/emit/events/object-event/object-record-delete';
import { OBJECT_RECORD_UPDATED_EVENT } from 'src/engine/core-modules/event-logs/emit/events/object-event/object-record-updated';
import { CUSTOM_DOMAIN_ACTIVATED_EVENT } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/custom-domain/custom-domain-activated';
import { CUSTOM_DOMAIN_DEACTIVATED_EVENT } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/custom-domain/custom-domain-deactivated';
import { type GenericTrackEvent } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/track';
import { formatDateTimeForClickHouse } from 'src/database/clickHouse/clickHouse.util';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
import { USER_WORKSPACE_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util';
@@ -13,7 +13,7 @@ import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { CreateAuditLogFromInternalEvent } from 'src/engine/core-modules/audit/jobs/create-audit-log-from-internal-event';
import { CreateEventLogFromInternalEvent } from 'src/engine/core-modules/event-logs/ingest/create-event-log-from-internal-event';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
@@ -107,24 +107,22 @@ export class EntityEventsToDbListener {
),
);
if (isAuditLogBatchEvent) {
if (isAuditLogBatchEvent && action !== DatabaseEventAction.DESTROYED) {
promises.push(
this.entityEventsToDbQueueService.add<WorkspaceEventBatch<T>>(
CreateAuditLogFromInternalEvent.name,
CreateEventLogFromInternalEvent.name,
batchEvent,
),
);
if (action !== DatabaseEventAction.DESTROYED) {
promises.push(
this.entityEventsToDbQueueService.add<
WorkspaceEventBatch<ObjectRecordNonDestructiveEvent>
>(
UpsertTimelineActivityFromInternalEvent.name,
batchEvent as WorkspaceEventBatch<ObjectRecordNonDestructiveEvent>,
),
);
}
promises.push(
this.entityEventsToDbQueueService.add<
WorkspaceEventBatch<ObjectRecordNonDestructiveEvent>
>(
UpsertTimelineActivityFromInternalEvent.name,
batchEvent as WorkspaceEventBatch<ObjectRecordNonDestructiveEvent>,
),
);
}
await Promise.all(promises);
@@ -2,34 +2,18 @@ import { Injectable } from '@nestjs/common';
import { OnCustomBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-custom-batch-event.decorator';
import { USER_SIGNUP_EVENT_NAME } from 'src/engine/api/graphql/workspace-query-runner/constants/user-signup-event-name.constants';
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
import { USER_SIGNUP_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/user/user-signup';
import { TelemetryEventType } from 'src/engine/core-modules/telemetry/telemetry-event.type';
import { TelemetryService } from 'src/engine/core-modules/telemetry/telemetry.service';
import { CustomWorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/custom-workspace-batch-event.type';
@Injectable()
export class TelemetryListener {
constructor(
private readonly auditService: AuditService,
private readonly telemetryService: TelemetryService,
) {}
constructor(private readonly telemetryService: TelemetryService) {}
@OnCustomBatchEvent(USER_SIGNUP_EVENT_NAME)
async handleUserSignup(
payload: CustomWorkspaceEventBatch<TelemetryEventType>,
) {
await Promise.all(
payload.events.map(async (eventPayload) =>
this.auditService
.createContext({
userId: eventPayload.userId,
workspaceId: payload.workspaceId,
})
.insertWorkspaceEvent(USER_SIGNUP_EVENT, {}),
),
);
await this.telemetryService.publish({
action: USER_SIGNUP_EVENT_NAME,
events: payload.events,
@@ -3,7 +3,6 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { TelemetryListener } from 'src/engine/api/graphql/workspace-query-runner/listeners/telemetry.listener';
import { WorkspaceQueryHookModule } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.module';
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { RecordPositionModule } from 'src/engine/core-modules/record-position/record-position.module';
@@ -19,7 +18,6 @@ import { EntityEventsToDbListener } from './listeners/entity-events-to-db.listen
WorkspaceDataSourceModule,
WorkspaceQueryHookModule,
TypeOrmModule.forFeature([FeatureFlagEntity]),
AuditModule,
TelemetryModule,
FileModule,
RecordTransformerModule,
@@ -20,7 +20,6 @@ import { AdminPanelStatisticsService } from 'src/engine/core-modules/admin-panel
import { AdminPanelUserLookupService } from 'src/engine/core-modules/admin-panel/services/admin-panel-user-lookup.service';
import { AdminPanelVersionService } from 'src/engine/core-modules/admin-panel/services/admin-panel-version.service';
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
@@ -67,7 +66,6 @@ import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspac
TerminusModule,
MetricsModule,
FeatureFlagModule,
AuditModule,
TelemetryModule,
ImpersonationModule,
PermissionsModule,
@@ -1 +0,0 @@
export const APPLICATION_LOG_DRIVER = Symbol('APPLICATION_LOG_DRIVER');
@@ -1,14 +0,0 @@
import { ConfigurableModuleBuilder } from '@nestjs/common';
import { type ApplicationLogsModuleOptions } from 'src/engine/core-modules/application-logs/interfaces/application-logs-module-options.type';
export const {
ConfigurableModuleClass,
MODULE_OPTIONS_TOKEN,
OPTIONS_TYPE,
ASYNC_OPTIONS_TYPE,
} = new ConfigurableModuleBuilder<ApplicationLogsModuleOptions>({
moduleName: 'ApplicationLogsModule',
})
.setClassMethodName('forRoot')
.build();
@@ -1,14 +0,0 @@
import { type TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { type OPTIONS_TYPE } from 'src/engine/core-modules/application-logs/application-logs.module-definition';
import { ApplicationLogDriver } from 'src/engine/core-modules/application-logs/interfaces/application-log-driver.enum';
export const applicationLogsModuleFactory = async (
twentyConfigService: TwentyConfigService,
): Promise<typeof OPTIONS_TYPE> => {
const driverType = twentyConfigService.get('APPLICATION_LOG_DRIVER');
return {
type: driverType as ApplicationLogDriver,
};
};
@@ -1,86 +0,0 @@
import { type DynamicModule, Global, Module } from '@nestjs/common';
import { ClickHouseModule } from 'src/database/clickHouse/clickHouse.module';
import { ClickHouseService } from 'src/database/clickHouse/clickHouse.service';
import { APPLICATION_LOG_DRIVER } from 'src/engine/core-modules/application-logs/application-logs.constants';
import {
type ASYNC_OPTIONS_TYPE,
ConfigurableModuleClass,
type OPTIONS_TYPE,
} from 'src/engine/core-modules/application-logs/application-logs.module-definition';
import { ApplicationLogsService } from 'src/engine/core-modules/application-logs/application-logs.service';
import { ClickHouseApplicationLogDriver } from 'src/engine/core-modules/application-logs/drivers/clickhouse.driver';
import { ConsoleApplicationLogDriver } from 'src/engine/core-modules/application-logs/drivers/console.driver';
import { DisabledApplicationLogDriver } from 'src/engine/core-modules/application-logs/drivers/disabled.driver';
import { ApplicationLogDriver } from 'src/engine/core-modules/application-logs/interfaces/application-log-driver.enum';
@Global()
@Module({
imports: [ClickHouseModule],
providers: [ApplicationLogsService],
exports: [ApplicationLogsService],
})
export class ApplicationLogsModule extends ConfigurableModuleClass {
static forRoot(options: typeof OPTIONS_TYPE): DynamicModule {
const provider = {
provide: APPLICATION_LOG_DRIVER,
useValue: ApplicationLogsModule.createDriver(options.type),
};
const dynamicModule = super.forRoot(options);
return {
...dynamicModule,
providers: [...(dynamicModule.providers ?? []), provider],
};
}
static forRootAsync(options: typeof ASYNC_OPTIONS_TYPE): DynamicModule {
const provider = {
provide: APPLICATION_LOG_DRIVER,
// oxlint-disable-next-line typescript/no-explicit-any
useFactory: async (
clickHouseService: ClickHouseService,
...args: unknown[]
) => {
const config = await options?.useFactory?.(...args);
if (!config) {
return new DisabledApplicationLogDriver();
}
return ApplicationLogsModule.createDriver(
config.type,
clickHouseService,
);
},
inject: [ClickHouseService, ...(options.inject || [])],
};
const dynamicModule = super.forRootAsync(options);
return {
...dynamicModule,
providers: [...(dynamicModule.providers ?? []), provider],
};
}
private static createDriver(
type: ApplicationLogDriver,
clickHouseService?: ClickHouseService,
) {
switch (type) {
case ApplicationLogDriver.CONSOLE:
return new ConsoleApplicationLogDriver();
case ApplicationLogDriver.CLICKHOUSE:
if (!clickHouseService) {
throw new Error(
'ClickHouseService is required for the ClickHouse application log driver',
);
}
return new ClickHouseApplicationLogDriver(clickHouseService);
case ApplicationLogDriver.DISABLED:
default:
return new DisabledApplicationLogDriver();
}
}
}
@@ -1,17 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import { APPLICATION_LOG_DRIVER } from 'src/engine/core-modules/application-logs/application-logs.constants';
import { type ApplicationLogDriverInterface } from 'src/engine/core-modules/application-logs/interfaces/application-log-driver.interface';
import { type ApplicationLogEntry } from 'src/engine/core-modules/application-logs/interfaces/application-log-entry.interface';
@Injectable()
export class ApplicationLogsService {
constructor(
@Inject(APPLICATION_LOG_DRIVER)
private driver: ApplicationLogDriverInterface,
) {}
async writeLogs(entries: ApplicationLogEntry[]): Promise<void> {
return this.driver.writeLogs(entries);
}
}
@@ -1,35 +0,0 @@
import { Logger } from '@nestjs/common';
import { ClickHouseService } from 'src/database/clickHouse/clickHouse.service';
import { formatDateTimeForClickHouse } from 'src/database/clickHouse/clickHouse.util';
import { type ApplicationLogEntry } from 'src/engine/core-modules/application-logs/interfaces/application-log-entry.interface';
import { type ApplicationLogDriverInterface } from 'src/engine/core-modules/application-logs/interfaces/application-log-driver.interface';
export class ClickHouseApplicationLogDriver implements ApplicationLogDriverInterface {
private readonly logger = new Logger(ClickHouseApplicationLogDriver.name);
constructor(private readonly clickHouseService: ClickHouseService) {}
async writeLogs(entries: ApplicationLogEntry[]): Promise<void> {
if (entries.length === 0) {
return;
}
const rows = entries.map((entry) => ({
timestamp: formatDateTimeForClickHouse(entry.timestamp),
workspaceId: entry.workspaceId,
applicationId: entry.applicationId,
logicFunctionId: entry.logicFunctionId,
logicFunctionName: entry.logicFunctionName,
executionId: entry.executionId,
level: entry.level,
message: entry.message,
}));
const result = await this.clickHouseService.insert('applicationLog', rows);
if (!result.success) {
this.logger.error('Failed to insert application logs into ClickHouse');
}
}
}
@@ -1,29 +0,0 @@
import { Logger } from '@nestjs/common';
import { type ApplicationLogDriverInterface } from 'src/engine/core-modules/application-logs/interfaces/application-log-driver.interface';
import { type ApplicationLogEntry } from 'src/engine/core-modules/application-logs/interfaces/application-log-entry.interface';
export class ConsoleApplicationLogDriver implements ApplicationLogDriverInterface {
private readonly logger = new Logger(ConsoleApplicationLogDriver.name);
async writeLogs(entries: ApplicationLogEntry[]): Promise<void> {
for (const entry of entries) {
const context = `${entry.logicFunctionName}:${entry.executionId}`;
switch (entry.level) {
case 'ERROR':
this.logger.error(entry.message, undefined, context);
break;
case 'WARN':
this.logger.warn(entry.message, context);
break;
case 'DEBUG':
this.logger.debug(entry.message, context);
break;
default:
this.logger.log(entry.message, context);
break;
}
}
}
}
@@ -1,7 +0,0 @@
import { type ApplicationLogDriverInterface } from 'src/engine/core-modules/application-logs/interfaces/application-log-driver.interface';
export class DisabledApplicationLogDriver implements ApplicationLogDriverInterface {
async writeLogs(): Promise<void> {
return;
}
}
@@ -1,5 +0,0 @@
export enum ApplicationLogDriver {
DISABLED = 'DISABLED',
CONSOLE = 'CONSOLE',
CLICKHOUSE = 'CLICKHOUSE',
}
@@ -1,5 +0,0 @@
import { type ApplicationLogEntry } from 'src/engine/core-modules/application-logs/interfaces/application-log-entry.interface';
export interface ApplicationLogDriverInterface {
writeLogs(entries: ApplicationLogEntry[]): Promise<void>;
}
@@ -1,5 +0,0 @@
import { type ApplicationLogDriver } from 'src/engine/core-modules/application-logs/interfaces/application-log-driver.enum';
export type ApplicationLogsModuleOptions = {
type: ApplicationLogDriver;
};
@@ -133,7 +133,7 @@ export class ApplicationDevelopmentResolver {
@Mutation(() => WorkspaceMigrationDTO)
async syncApplication(
@Args() { manifest }: ApplicationInput,
@Args() { manifest, dryRun }: ApplicationInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<WorkspaceMigrationDTO> {
await this.throttlePerApplication(
@@ -141,6 +141,21 @@ export class ApplicationDevelopmentResolver {
workspaceId,
);
if (dryRun === true) {
const { workspaceMigration } =
await this.applicationSyncService.synchronizeFromManifest({
workspaceId,
manifest,
dryRun: true,
});
return {
applicationUniversalIdentifier:
workspaceMigration.applicationUniversalIdentifier,
actions: workspaceMigration.actions,
};
}
return this.cacheLockService.withLock(
() => this.applyManifestSync(manifest, workspaceId),
`app-sync:${workspaceId}`,
@@ -7,4 +7,7 @@ import { Manifest } from 'twenty-shared/application';
export class ApplicationInput {
@Field(() => GraphQLJSON, { nullable: false })
manifest: Manifest;
@Field(() => Boolean, { nullable: true })
dryRun?: boolean;
}
@@ -162,10 +162,12 @@ export class ApplicationManifestMigrationService {
manifest,
workspaceId,
ownerFlatApplication,
dryRun = false,
}: {
manifest: Manifest;
workspaceId: string;
ownerFlatApplication: FlatApplication;
dryRun?: boolean;
}): Promise<{
workspaceMigration: WorkspaceMigration;
hasSchemaMetadataChanged: boolean;
@@ -225,6 +227,7 @@ export class ApplicationManifestMigrationService {
workspaceId,
dependencyAllFlatEntityMaps,
additionalCacheDataMaps: { featureFlagsMap },
dryRun,
},
);
@@ -236,14 +239,16 @@ export class ApplicationManifestMigrationService {
}
this.logger.log(
`Metadata migration completed for application ${ownerFlatApplication.universalIdentifier}`,
`Metadata migration ${dryRun ? 'plan computed (dry run)' : 'completed'} for application ${ownerFlatApplication.universalIdentifier}`,
);
await this.syncDefaultRoleAndSettingsCustomTab({
manifest,
workspaceId,
ownerFlatApplication,
});
if (!dryRun) {
await this.syncDefaultRoleAndSettingsCustomTab({
manifest,
workspaceId,
ownerFlatApplication,
});
}
return {
workspaceMigration: validateAndBuildResult.workspaceMigration,
@@ -19,6 +19,7 @@ import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspac
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { enrichCreateWorkspaceMigrationActionsWithIds } from 'src/engine/workspace-manager/workspace-migration/services/utils/enrich-create-workspace-migration-action-with-ids.util';
import { AllUniversalWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-action-common';
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service';
@@ -48,12 +49,17 @@ export class ApplicationManifestResolver {
},
);
await this.workspaceMigrationRunnerService.run({
const workspaceMigration = enrichCreateWorkspaceMigrationActionsWithIds({
workspaceMigration: {
actions: actions as AllUniversalWorkspaceMigrationAction[],
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
},
idByUniversalIdentifierByMetadataName: {},
});
await this.workspaceMigrationRunnerService.run({
workspaceMigration,
workspaceId,
});
@@ -41,30 +41,38 @@ export class ApplicationSyncService {
workspaceId,
manifest,
applicationRegistrationId,
dryRun = false,
}: {
workspaceId: string;
manifest: Manifest;
applicationRegistrationId?: string;
dryRun?: boolean;
}): Promise<{
workspaceMigration: WorkspaceMigration;
hasSchemaMetadataChanged: boolean;
}> {
const application = await this.syncApplication({
workspaceId,
manifest,
applicationRegistrationId,
});
const ownerFlatApplication: FlatApplication = application;
const ownerFlatApplication: FlatApplication = dryRun
? await this.applicationService.findOneApplicationOrThrow({
universalIdentifier: manifest.application.universalIdentifier,
workspaceId,
})
: await this.syncApplication({
workspaceId,
manifest,
applicationRegistrationId,
});
const syncResult =
await this.applicationManifestMigrationService.syncMetadataFromManifest({
manifest,
workspaceId,
ownerFlatApplication,
dryRun,
});
this.logger.log('Application sync from manifest completed');
this.logger.log(
`Application sync from manifest ${dryRun ? 'plan computed (dry run)' : 'completed'}`,
);
return syncResult;
}
@@ -129,20 +137,13 @@ export class ApplicationSyncService {
).toString('utf-8'),
) as PackageJson;
const application = await this.applicationService.findByUniversalIdentifier(
const application = await this.applicationService.findOneApplicationOrThrow(
{
universalIdentifier: manifest.application.universalIdentifier,
workspaceId,
},
);
if (!application) {
throw new ApplicationException(
`Application "${manifest.application.universalIdentifier}" is not installed in workspace "${workspaceId}". Install it first.`,
ApplicationExceptionCode.APP_NOT_INSTALLED,
);
}
const resolvedRegistrationId =
applicationRegistrationId ?? application.applicationRegistrationId;
@@ -165,17 +166,10 @@ export class ApplicationSyncService {
workspaceId: string;
applicationUniversalIdentifier: string;
}): Promise<WorkspaceMigration> {
const application = await this.applicationService.findByUniversalIdentifier(
const application = await this.applicationService.findOneApplicationOrThrow(
{ universalIdentifier: applicationUniversalIdentifier, workspaceId },
);
if (!isDefined(application)) {
throw new ApplicationException(
`Application with universalIdentifier ${applicationUniversalIdentifier} not found`,
ApplicationExceptionCode.ENTITY_NOT_FOUND,
);
}
if (!application.canBeUninstalled) {
throw new ApplicationException(
'This application cannot be uninstalled.',
@@ -1,148 +0,0 @@
# Analytics Module
This module provides analytics tracking functionality for the Twenty application.
## Usage
### Tracking Events
The `AuditService` provides a `createContext` method that returns an object with three methods:
- `insertWorkspaceEvent`: For tracking workspace-level events
- `createObjectEvent`: For tracking object-level events that include record and metadata IDs
- `createPageviewEvent`: For tracking page views
```typescript
import { Injectable } from '@nestjs/common';
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
import { CUSTOM_DOMAIN_ACTIVATED_EVENT } from 'src/engine/core-modules/audit/utils/events/track/custom-domain/custom-domain-activated';
@Injectable()
export class MyService {
constructor(private readonly auditService: AuditService) {}
async doSomething() {
// Create an analytics context
const auditService = this.auditService.createContext({
workspaceId: 'workspace-id',
userId: 'user-id',
});
// Track a workspace event
auditService.insertWorkspaceEvent(CUSTOM_DOMAIN_ACTIVATED_EVENT, {});
// Track an object event
auditService.createObjectEvent(OBJECT_RECORD_CREATED_EVENT, {
recordId: 'record-id',
objectMetadataId: 'object-metadata-id',
// other properties
});
// Track a pageview
auditService.createPageviewEvent('page-name', {
href: '/path',
locale: 'en-US',
// other properties
});
}
}
```
### Adding New Events
To add a new event:
1. Create a new file in the `src/engine/core-modules/analytics/utils/events/track` directory
2. Define the event name, schema, and type
3. Register the event using the `registerEvent` function
4. Update the `TrackEventName` and `TrackEventProperties` types in `src/engine/core-modules/analytics/utils/events/event-types.ts`
Example:
```typescript
// src/engine/core-modules/analytics/utils/events/track/my-feature/my-event.ts
import { z } from 'zod';
import { registerEvent } from 'src/engine/core-modules/analytics/utils/events/track/track';
export const MY_EVENT = 'My Event' as const;
export const myEventSchema = z.object({
event: z.literal(MY_EVENT),
properties: z.object({
myProperty: z.string(),
}),
});
export type MyEventTrackEvent = z.infer<typeof myEventSchema>;
registerEvent(MY_EVENT, myEventSchema);
```
Then update the `events.type.ts` file:
```typescript
// src/engine/core-modules/analytics/types/events.type.ts
import {
MY_EVENT,
MyEventTrackEvent,
} from '../utils/events/track/my-feature/my-event';
// Add to the union type
export type TrackEventName = typeof MY_EVENT;
// ... other event names;
// Add to the TrackEvents interface
export interface TrackEvents {
[MY_EVENT]: MyEventTrackEvent;
// ... other event types
}
// The TrackEventProperties type will automatically use the new event
export type TrackEventProperties<T extends TrackEventName> =
T extends keyof TrackEvents ? TrackEvents[T]['properties'] : object;
```
## API
### AuditService
#### createContext(context?)
Creates an analytics context with the given user ID and workspace ID.
- `context` (optional): An object with `userId` and `workspaceId` properties
Returns an object with the following methods:
- `insertWorkspaceEvent<T extends TrackEventName>(event: T, properties: TrackEventProperties<T>)`: Tracks a workspace-level event
- `createObjectEvent<T extends TrackEventName>(event: T, properties: TrackEventProperties<T> & { recordId: string; objectMetadataId: string })`: Tracks an object-level event
- `createPageviewEvent(name: string, properties: Partial<PageviewProperties>)`: Tracks a pageview
### Types
#### TrackEventName
A union type of all registered event names, plus `string` for backward compatibility.
#### TrackEventProperties<T>
A mapped type that maps each event name to its corresponding properties type. It uses the `TrackEvents` interface to provide a more maintainable and type-safe way to map event names to their properties.
```typescript
// Define the mapping between event names and their event types
export interface TrackEvents {
[EVENT_NAME_1]: Event1Type;
[EVENT_NAME_2]: Event2Type;
// ... other event types
}
// Use the mapping to extract properties for each event type
export type TrackEventProperties<T extends TrackEventName> =
T extends keyof TrackEvents ? TrackEvents[T]['properties'] : object;
```
This approach makes it easier to add new events without having to modify a complex nested conditional type.
#### PageviewProperties
Properties for pageview events, including href, locale, pathname, referrer, sessionId, timeZone, and userAgent.
@@ -1,23 +0,0 @@
import { Catch, type ExceptionFilter } from '@nestjs/common';
import { assertUnreachable } from 'twenty-shared/utils';
import {
AuditException,
AuditExceptionCode,
} from 'src/engine/core-modules/audit/audit.exception';
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
@Catch(AuditException)
export class AuditExceptionFilter implements ExceptionFilter {
catch(exception: AuditException) {
switch (exception.code) {
case AuditExceptionCode.INVALID_TYPE:
case AuditExceptionCode.INVALID_INPUT:
throw new UserInputError(exception);
default: {
assertUnreachable(exception.code);
}
}
}
}
@@ -1,34 +0,0 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
import { CustomException } from 'src/utils/custom-exception';
export enum AuditExceptionCode {
INVALID_TYPE = 'INVALID_TYPE',
INVALID_INPUT = 'INVALID_INPUT',
}
const getAuditExceptionUserFriendlyMessage = (code: AuditExceptionCode) => {
switch (code) {
case AuditExceptionCode.INVALID_TYPE:
return msg`Invalid audit type.`;
case AuditExceptionCode.INVALID_INPUT:
return msg`Invalid audit input.`;
default:
assertUnreachable(code);
}
};
export class AuditException extends CustomException<AuditExceptionCode> {
constructor(
message: string,
code: AuditExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ?? getAuditExceptionUserFriendlyMessage(code),
});
}
}
@@ -1,15 +0,0 @@
import { Module } from '@nestjs/common';
import { ClickHouseModule } from 'src/database/clickHouse/clickHouse.module';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
import { AuditResolver } from './audit.resolver';
import { AuditService } from './services/audit.service';
@Module({
providers: [AuditResolver, AuditService],
imports: [JwtModule, ClickHouseModule],
exports: [AuditService],
})
export class AuditModule {}
@@ -1,11 +0,0 @@
import { Module } from '@nestjs/common';
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
import { CreateAuditLogFromInternalEvent } from 'src/engine/core-modules/audit/jobs/create-audit-log-from-internal-event';
import { TimelineActivityModule } from 'src/modules/timeline/timeline-activity.module';
@Module({
imports: [TimelineActivityModule, AuditModule],
providers: [CreateAuditLogFromInternalEvent],
})
export class AuditJobModule {}
@@ -1,64 +0,0 @@
import { type ObjectRecordEvent } from 'twenty-shared/database-events';
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
import { OBJECT_RECORD_CREATED_EVENT } from 'src/engine/core-modules/audit/utils/events/object-event/object-record-created';
import { OBJECT_RECORD_DELETED_EVENT } from 'src/engine/core-modules/audit/utils/events/object-event/object-record-delete';
import { OBJECT_RECORD_UPDATED_EVENT } from 'src/engine/core-modules/audit/utils/events/object-event/object-record-updated';
import { OBJECT_RECORD_UPSERTED_EVENT } from 'src/engine/core-modules/audit/utils/events/object-event/object-record-upserted';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type';
@Processor(MessageQueue.entityEventsToDbQueue)
export class CreateAuditLogFromInternalEvent {
constructor(private readonly auditService: AuditService) {}
@Process(CreateAuditLogFromInternalEvent.name)
async handle(
workspaceEventBatch: WorkspaceEventBatch<ObjectRecordEvent>,
): Promise<void> {
for (const eventData of workspaceEventBatch.events) {
// We remove "before" and "after" property for a cleaner/slimmer event payload
const eventProperties =
'diff' in eventData.properties
? {
...eventData.properties,
diff: eventData.properties.diff,
}
: eventData.properties;
const auditService = this.auditService.createContext({
workspaceId: workspaceEventBatch.workspaceId,
userId: eventData.userId,
});
// Since these are object record events, we use createObjectEvent
if (workspaceEventBatch.name.endsWith('.updated')) {
await auditService.createObjectEvent(OBJECT_RECORD_UPDATED_EVENT, {
...eventProperties,
recordId: eventData.recordId,
objectMetadataId: workspaceEventBatch.objectMetadata.id,
});
} else if (workspaceEventBatch.name.endsWith('.created')) {
await auditService.createObjectEvent(OBJECT_RECORD_CREATED_EVENT, {
...eventProperties,
recordId: eventData.recordId,
objectMetadataId: workspaceEventBatch.objectMetadata.id,
});
} else if (workspaceEventBatch.name.endsWith('.deleted')) {
await auditService.createObjectEvent(OBJECT_RECORD_DELETED_EVENT, {
...eventProperties,
recordId: eventData.recordId,
objectMetadataId: workspaceEventBatch.objectMetadata.id,
});
} else if (workspaceEventBatch.name.endsWith('.upserted')) {
await auditService.createObjectEvent(OBJECT_RECORD_UPSERTED_EVENT, {
...eventProperties,
recordId: eventData.recordId,
objectMetadataId: workspaceEventBatch.objectMetadata.id,
});
}
}
}
}
@@ -1,98 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { ClickHouseService } from 'src/database/clickHouse/clickHouse.service';
import {
type TrackEventName,
type TrackEventProperties,
} from 'src/engine/core-modules/audit/types/events.type';
import {
makePageview,
makeTrackEvent,
} from 'src/engine/core-modules/audit/utils/analytics.utils';
import { type PageviewProperties } from 'src/engine/core-modules/audit/utils/events/pageview/pageview';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@Injectable()
export class AuditService {
private readonly logger = new Logger(AuditService.name);
constructor(
private readonly twentyConfigService: TwentyConfigService,
private readonly clickHouseService: ClickHouseService,
) {}
createContext(context?: {
workspaceId?: string | null | undefined;
userId?: string | null | undefined;
}) {
const contextFields = context
? {
...(context.workspaceId ? { workspaceId: context.workspaceId } : {}),
...(context.userId ? { userId: context.userId } : {}),
}
: {};
return {
insertWorkspaceEvent: <T extends TrackEventName>(
event: T,
properties: TrackEventProperties<T>,
) =>
this.preventIfDisabled(() =>
this.clickHouseService.insert('workspaceEvent', [
{ ...contextFields, ...makeTrackEvent(event, properties) },
]),
),
createObjectEvent: <T extends TrackEventName>(
event: T,
properties: TrackEventProperties<T> & {
recordId: string;
objectMetadataId: string;
isCustom?: boolean;
},
) => {
const { recordId, objectMetadataId, isCustom, ...restProperties } =
properties;
return this.preventIfDisabled(() =>
this.clickHouseService.insert('objectEvent', [
{
...contextFields,
...makeTrackEvent(
event,
restProperties as unknown as TrackEventProperties<T>,
),
recordId,
objectMetadataId,
isCustom,
},
]),
);
},
createPageviewEvent: (
name: string,
properties: Partial<PageviewProperties>,
) =>
this.preventIfDisabled(() =>
this.clickHouseService.insert('pageview', [
{ ...contextFields, ...makePageview(name, properties) },
]),
),
};
}
private async preventIfDisabled(
sendEventOrPageviewFunction: () => Promise<{ success: boolean }>,
): Promise<{ success: boolean }> {
if (!this.twentyConfigService.get('CLICKHOUSE_URL')) {
return { success: true };
}
try {
return await sendEventOrPageviewFunction();
} catch (error) {
this.logger.error('Failed to persist audit event to ClickHouse', error);
return { success: false };
}
}
}
@@ -1,2 +0,0 @@
export type AuditCommonPropertiesType = 'timestamp' | 'version';
export type IdentifierType = 'workspaceId' | 'userId';
@@ -1,18 +0,0 @@
import { z } from 'zod';
import { registerEvent } from 'src/engine/core-modules/audit/utils/events/workspace-event/track';
export const MONITORING_EVENT = 'Monitoring' as const;
export const monitoringSchema = z.strictObject({
event: z.literal(MONITORING_EVENT),
properties: z.strictObject({
eventName: z.string(),
connectedAccountId: z.string().optional(),
messageChannelId: z.string().optional(),
message: z.string().optional(),
}),
});
export type MonitoringTrackEvent = z.infer<typeof monitoringSchema>;
registerEvent(MONITORING_EVENT, monitoringSchema);
@@ -1,18 +0,0 @@
import { z } from 'zod';
import { registerEvent } from 'src/engine/core-modules/audit/utils/events/workspace-event/track';
export const WORKSPACE_ENTITY_CREATED_EVENT =
'Workspace Entity Created' as const;
export const workspaceEntityCreatedSchema = z.strictObject({
event: z.literal(WORKSPACE_ENTITY_CREATED_EVENT),
properties: z.strictObject({
name: z.string(),
}),
});
export type WorkspaceEntityCreatedTrackEvent = z.infer<
typeof workspaceEntityCreatedSchema
>;
registerEvent(WORKSPACE_ENTITY_CREATED_EVENT, workspaceEntityCreatedSchema);
@@ -10,7 +10,7 @@ import { ApplicationModule } from 'src/engine/core-modules/application/applicati
import { ConnectionProviderModule } from 'src/engine/core-modules/application/connection-provider/connection-provider.module';
import { ConnectionProviderOAuthController } from 'src/engine/core-modules/application/connection-provider/connection-provider-oauth.controller';
import { ApplicationConnectionsModule } from 'src/engine/core-modules/application/connection-provider/connections/application-connections.module';
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
import { EventLogEmitterModule } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.module';
import { GoogleAPIsAuthController } from 'src/engine/core-modules/auth/controllers/google-apis-auth.controller';
import { GoogleAuthController } from 'src/engine/core-modules/auth/controllers/google-auth.controller';
import { MicrosoftAPIsAuthController } from 'src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller';
@@ -113,7 +113,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
PermissionsModule,
TwoFactorAuthenticationModule,
ApiKeyModule,
AuditModule,
EventLogEmitterModule,
SubdomainManagerModule,
DomainServerConfigModule,
ApplicationRegistrationModule,
@@ -4,7 +4,7 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { ApiKeyService } from 'src/engine/core-modules/api-key/services/api-key.service';
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
import { EventLogEmitterService } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.service';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
@@ -136,7 +136,7 @@ describe('AuthResolver', () => {
useValue: {},
},
{
provide: AuditService,
provide: EventLogEmitterService,
useValue: {
createContext: jest.fn().mockReturnValue({
insertWorkspaceEvent: jest.fn(),
@@ -12,8 +12,8 @@ import { Repository } from 'typeorm';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { ApiKeyService } from 'src/engine/core-modules/api-key/services/api-key.service';
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
import { MONITORING_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/monitoring/monitoring';
import { EventLogEmitterService } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.service';
import { IMPERSONATION_EVENT } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/impersonation/impersonation';
import {
AuthException,
AuthExceptionCode,
@@ -125,7 +125,7 @@ export class AuthResolver {
private userWorkspaceService: UserWorkspaceService,
private emailVerificationTokenService: EmailVerificationTokenService,
private ssoService: SSOService,
private readonly auditService: AuditService,
private readonly eventLogEmitterService: EventLogEmitterService,
private readonly permissionsService: PermissionsService,
) {}
@@ -717,13 +717,14 @@ export class AuthResolver {
toImpersonateUserWorkspace.workspace.id !==
impersonatorUserWorkspace.workspace.id;
const auditService = this.auditService.createContext({
const eventLogContext = this.eventLogEmitterService.createContext({
workspaceId: workspace.id,
userId: impersonatorUserWorkspace.user.id,
});
await auditService.insertWorkspaceEvent(MONITORING_EVENT, {
eventName: `${isServerLevelImpersonation ? 'server' : 'workspace'}.impersonation.token_exchange_attempt`,
void eventLogContext.insertWorkspaceEvent(IMPERSONATION_EVENT, {
level: isServerLevelImpersonation ? 'server' : 'workspace',
action: 'token_exchange_attempt',
message: `Impersonation token exchange attempt for ${targetUserEmail} by ${impersonatorUserWorkspace.user.id}`,
});
@@ -733,8 +734,9 @@ export class AuthResolver {
if (isServerLevelImpersonation) {
if (!hasServerLevelImpersonatePermission) {
await auditService.insertWorkspaceEvent(MONITORING_EVENT, {
eventName: 'server.impersonation.token_exchange_failed',
void eventLogContext.insertWorkspaceEvent(IMPERSONATION_EVENT, {
level: 'server',
action: 'token_exchange_failed',
message: `Server level impersonation not allowed for ${targetUserEmail} by userId ${impersonatorUserWorkspace.user.id}`,
});
@@ -744,8 +746,9 @@ export class AuthResolver {
);
}
await auditService.insertWorkspaceEvent(MONITORING_EVENT, {
eventName: `server.impersonation.token_exchange_success`,
void eventLogContext.insertWorkspaceEvent(IMPERSONATION_EVENT, {
level: 'server',
action: 'token_exchange_success',
message: `Impersonation token exchanged for ${targetUserEmail} by userId ${impersonatorUserWorkspace.user.id}`,
});
@@ -766,8 +769,9 @@ export class AuthResolver {
});
if (!hasWorkspaceLevelImpersonatePermission) {
await auditService.insertWorkspaceEvent(MONITORING_EVENT, {
eventName: 'workspace.impersonation.token_exchange_failed',
void eventLogContext.insertWorkspaceEvent(IMPERSONATION_EVENT, {
level: 'workspace',
action: 'token_exchange_failed',
message: `Impersonation not allowed for ${targetUserEmail} by userId ${impersonatorUserWorkspace.user.id}`,
});
throw new AuthException(
@@ -776,8 +780,9 @@ export class AuthResolver {
);
}
await auditService.insertWorkspaceEvent(MONITORING_EVENT, {
eventName: 'workspace.impersonation.token_exchange_success',
void eventLogContext.insertWorkspaceEvent(IMPERSONATION_EVENT, {
level: 'workspace',
action: 'token_exchange_success',
message: `Impersonation token exchanged for ${targetUserEmail} by userId ${impersonatorUserWorkspace.user.id}`,
});
@@ -5,7 +5,7 @@ import bcrypt from 'bcrypt';
import { type Repository } from 'typeorm';
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
import { EventLogEmitterService } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.service';
import {
AuthException,
AuthExceptionCode,
@@ -161,7 +161,7 @@ describe('AuthService', () => {
},
},
{
provide: AuditService,
provide: EventLogEmitterService,
useValue: {},
},
{
@@ -19,7 +19,8 @@ import {
AppTokenType,
} from 'src/engine/core-modules/app-token/app-token.entity';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
import { EventLogEmitterService } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.service';
import { IMPERSONATION_EVENT } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/impersonation/impersonation';
import {
AuthException,
AuthExceptionCode,
@@ -98,7 +99,7 @@ export class AuthService {
@InjectRepository(AppTokenEntity)
private readonly appTokenRepository: Repository<AppTokenEntity>,
private readonly i18nService: I18nService,
private readonly auditService: AuditService,
private readonly eventLogEmitterService: EventLogEmitterService,
private readonly applicationRegistrationService: ApplicationRegistrationService,
private readonly featureFlagService: FeatureFlagService,
private readonly createSSOConnectedAccountService: CreateSSOConnectedAccountService,
@@ -426,13 +427,14 @@ export class AuthService {
}): Promise<AuthTokens> {
const correlationId = randomUUID();
const analytics = this.auditService.createContext({
const eventLogContext = this.eventLogEmitterService.createContext({
workspaceId,
userId: _impersonatorUserId,
});
await analytics.insertWorkspaceEvent('Monitoring', {
eventName: 'workspace.impersonation.attempted',
void eventLogContext.insertWorkspaceEvent(IMPERSONATION_EVENT, {
level: 'workspace',
action: 'attempted',
message: `correlationId=${correlationId}; impersonatorUserWorkspaceId=${impersonatorUserWorkspaceId}; targetUserWorkspaceId=${impersonatedUserWorkspaceId}; workspaceId=${workspaceId}`,
});
@@ -457,8 +459,9 @@ export class AuthService {
true,
);
await analytics.insertWorkspaceEvent('Monitoring', {
eventName: 'workspace.impersonation.issued',
void eventLogContext.insertWorkspaceEvent(IMPERSONATION_EVENT, {
level: 'workspace',
action: 'issued',
message: `correlationId=${correlationId}; impersonatorUserWorkspaceId=${impersonatorUserWorkspaceId}; targetUserWorkspaceId=${impersonatedUserWorkspaceId}; workspaceId=${workspaceId}`,
});
@@ -9,8 +9,9 @@ import { Repository, type DataSource, type QueryRunner } from 'typeorm';
import { v4 } from 'uuid';
import { USER_SIGNUP_EVENT_NAME } from 'src/engine/api/graphql/workspace-query-runner/constants/user-signup-event-name.constants';
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
import { WORKSPACE_CREATED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/workspace/workspace-created';
import { EventLogEmitterService } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.service';
import { USER_SIGNUP_EVENT } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/user/user-signup';
import { WORKSPACE_CREATED_EVENT } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/workspace/workspace-created';
import { type AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import {
@@ -69,7 +70,7 @@ export class SignInUpService {
private readonly applicationService: ApplicationService,
private readonly fileCorePictureService: FileCorePictureService,
private readonly enterprisePlanService: EnterprisePlanService,
private readonly auditService: AuditService,
private readonly eventLogEmitterService: EventLogEmitterService,
@InjectDataSource()
private readonly dataSource: DataSource,
) {}
@@ -384,6 +385,13 @@ export class SignInUpService {
undefined,
);
void this.eventLogEmitterService
.createContext({
workspaceId: savedUser.currentWorkspace?.id,
userId: savedUser.id,
})
.insertWorkspaceEvent(USER_SIGNUP_EVENT, {});
void this.metricsService.incrementCounterForEvent({
key: MetricsKeys.SignUpSuccess,
shouldStoreInCache: false,
@@ -600,7 +608,7 @@ export class SignInUpService {
},
);
void this.auditService
void this.eventLogEmitterService
.createContext({ workspaceId })
.insertWorkspaceEvent(WORKSPACE_CREATED_EVENT, {});
@@ -1,7 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
import { EventLogEmitterModule } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.module';
import { BillingWebhookController } from 'src/engine/core-modules/billing-webhook/billing-webhook.controller';
import { BillingWebhookCustomerService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-customer.service';
import { BillingWebhookEntitlementService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-entitlement.service';
@@ -32,7 +32,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
@Module({
imports: [
AuditModule,
EventLogEmitterModule,
FeatureFlagModule,
StripeModule,
MessageQueueModule,
@@ -7,8 +7,8 @@ import { type Repository } from 'typeorm';
import type Stripe from 'stripe';
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
import { PAYMENT_RECEIVED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/billing/payment-received';
import { EventLogEmitterService } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.service';
import { PAYMENT_RECEIVED_EVENT } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/billing/payment-received';
import { getSubscriptionIdFromInvoice } from 'src/engine/core-modules/billing-webhook/utils/get-subscription-id-from-invoice.util';
import {
BillingException,
@@ -43,7 +43,7 @@ export class BillingWebhookInvoiceService {
private readonly billingCreditRolloverService: BillingCreditRolloverService,
private readonly resourceCreditService: ResourceCreditService,
private readonly stripeInvoiceService: StripeInvoiceService,
private readonly auditService: AuditService,
private readonly eventLogEmitterService: EventLogEmitterService,
) {}
async processStripeEvent(
@@ -169,7 +169,7 @@ export class BillingWebhookInvoiceService {
if (isDefined(billingCustomer)) {
await this.delaySuspendedWorkspaceCleanup(billingCustomer);
await this.auditService
void this.eventLogEmitterService
.createContext({ workspaceId: billingCustomer.workspaceId })
.insertWorkspaceEvent(PAYMENT_RECEIVED_EVENT, {
amountPaid: data.object.amount_paid,
@@ -4,8 +4,6 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
import { WorkspaceQueryRunnerModule } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.module';
import { ActorModule } from 'src/engine/core-modules/actor/actor.module';
import { ApplicationLogsModule } from 'src/engine/core-modules/application-logs/application-logs.module';
import { applicationLogsModuleFactory } from 'src/engine/core-modules/application-logs/application-logs.module-factory';
import { AdminPanelModule } from 'src/engine/core-modules/admin-panel/admin-panel.module';
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
import { AppTokenModule } from 'src/engine/core-modules/app-token/app-token.module';
@@ -75,9 +73,8 @@ import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/
import { ChannelSyncModule } from 'src/modules/connected-account/channel-sync/channel-sync.module';
import { DashboardModule } from 'src/modules/dashboard/dashboard.module';
import { SendEmailModule } from 'src/modules/messaging/message-outbound-manager/send-email.module';
import { AuditModule } from './audit/audit.module';
import { ClientConfigModule } from './client-config/client-config.module';
import { EventLogsModule } from './event-logs/event-logs.module';
import { EventLogsViewerModule } from './event-logs/event-logs-viewer.module';
import { FileModule } from './file/file.module';
@Module({
@@ -85,7 +82,6 @@ import { FileModule } from './file/file.module';
EnvironmentModule,
TwentyConfigModule.forRoot(),
HealthModule,
AuditModule,
AuthModule,
BillingModule,
BillingWebhookModule,
@@ -143,10 +139,6 @@ import { FileModule } from './file/file.module';
useFactory: exceptionHandlerModuleFactory,
inject: [TwentyConfigService, HttpAdapterHost],
}),
ApplicationLogsModule.forRootAsync({
useFactory: applicationLogsModuleFactory,
inject: [TwentyConfigService],
}),
EmailModule.forRoot(),
CaptchaModule.forRoot(),
EventEmitterModule.forRoot({
@@ -163,7 +155,7 @@ import { FileModule } from './file/file.module';
ImpersonationModule,
TrashCleanupModule,
DashboardModule,
EventLogsModule,
EventLogsViewerModule,
PreInstalledAppsModule,
AppBillingModule,
],
@@ -174,7 +166,7 @@ import { FileModule } from './file/file.module';
},
],
exports: [
AuditModule,
EventLogsViewerModule,
AuthModule,
FeatureFlagModule,
TimelineMessagingModule,
@@ -5,7 +5,7 @@ import Cloudflare from 'cloudflare';
import { type CustomHostnameCreateResponse } from 'cloudflare/resources/custom-hostnames/custom-hostnames';
import { AuditContextMock } from 'test/utils/audit-context.mock';
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
import { EventLogEmitterService } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.service';
import { DnsManagerException } from 'src/engine/core-modules/dns-manager/exceptions/dns-manager.exception';
import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service';
import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service';
@@ -30,7 +30,7 @@ describe('DnsManagerService', () => {
},
},
{
provide: AuditService,
provide: EventLogEmitterService,
useValue: {
createContext: AuditContextMock,
},
@@ -1,7 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
import { EventLogEmitterModule } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.module';
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
import { DnsManagerModule } from 'src/engine/core-modules/dns-manager/dns-manager.module';
import { CustomDomainManagerService } from 'src/engine/core-modules/domain/custom-domain-manager/services/custom-domain-manager.service';
@@ -13,7 +13,7 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent
TypeOrmModule.forFeature([WorkspaceEntity, PublicDomainEntity]),
DnsManagerModule,
BillingModule,
AuditModule,
EventLogEmitterModule,
],
providers: [CustomDomainManagerService],
exports: [CustomDomainManagerService],
@@ -5,9 +5,9 @@ import { msg } from '@lingui/core/macro';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
import { CUSTOM_DOMAIN_ACTIVATED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/custom-domain/custom-domain-activated';
import { CUSTOM_DOMAIN_DEACTIVATED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/custom-domain/custom-domain-deactivated';
import { EventLogEmitterService } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.service';
import { CUSTOM_DOMAIN_ACTIVATED_EVENT } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/custom-domain/custom-domain-activated';
import { CUSTOM_DOMAIN_DEACTIVATED_EVENT } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/custom-domain/custom-domain-deactivated';
import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum';
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
import { DomainValidRecords } from 'src/engine/core-modules/dns-manager/dtos/domain-valid-records';
@@ -30,7 +30,7 @@ export class CustomDomainManagerService {
private readonly publicDomainRepository: Repository<PublicDomainEntity>,
private readonly billingService: BillingService,
private readonly dnsManagerService: DnsManagerService,
private readonly auditService: AuditService,
private readonly eventLogEmitterService: EventLogEmitterService,
) {}
async isCustomDomainEnabled(workspaceId: string) {
@@ -112,11 +112,11 @@ export class CustomDomainManagerService {
await this.workspaceRepository.save(workspace);
const analytics = this.auditService.createContext({
const eventLogContext = this.eventLogEmitterService.createContext({
workspaceId: workspace.id,
});
void analytics.insertWorkspaceEvent(
void eventLogContext.insertWorkspaceEvent(
workspace.isCustomDomainEnabled
? CUSTOM_DOMAIN_ACTIVATED_EVENT
: CUSTOM_DOMAIN_DEACTIVATED_EVENT,
@@ -6,14 +6,7 @@ import { EventLogTable } from 'twenty-shared/types';
import { ClickHouseService } from 'src/database/clickHouse/clickHouse.service';
import { formatDateTimeForClickHouse } from 'src/database/clickHouse/clickHouse.util';
const CLICKHOUSE_TABLE_NAMES: Record<EventLogTable, string> = {
[EventLogTable.WORKSPACE_EVENT]: 'workspaceEvent',
[EventLogTable.PAGEVIEW]: 'pageview',
[EventLogTable.OBJECT_EVENT]: 'objectEvent',
[EventLogTable.USAGE_EVENT]: 'usageEvent',
[EventLogTable.APPLICATION_LOG]: 'applicationLog',
};
import { getClickHouseTableName } from 'src/engine/core-modules/event-logs/registry/event-log-registry';
export type EventLogCleanupParams = {
workspaceId: string;
@@ -43,11 +36,9 @@ export class EventLogCleanupService {
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
for (const table of Object.values(EventLogTable)) {
const tableName = CLICKHOUSE_TABLE_NAMES[table];
const tableName = getClickHouseTableName(table);
try {
// ClickHouse ALTER TABLE DELETE is async by default
// We use lightweight deletes (mutations) which are efficient
const success = await this.clickHouseService.executeCommand(
`ALTER TABLE ${tableName} DELETE WHERE "workspaceId" = {workspaceId:String} AND "timestamp" < {cutoffDate:DateTime64(3)}`,
{
@@ -7,5 +7,3 @@ export const registerEventLogTableEnum = () => {
name: 'EventLogTable',
});
};
export { EventLogTable };
@@ -1,21 +1,20 @@
import { format } from 'date-fns';
import { type AuditCommonPropertiesType } from 'src/engine/core-modules/audit/types/common.type';
import { formatDateTimeForClickHouse } from 'src/database/clickHouse/clickHouse.util';
import { type EventCommonPropertiesType } from 'src/engine/core-modules/event-logs/emit/common.type';
import {
type TrackEventName,
type TrackEventProperties,
} from 'src/engine/core-modules/audit/types/events.type';
} from 'src/engine/core-modules/event-logs/emit/events.type';
import {
type PageviewProperties,
pageviewSchema,
} from 'src/engine/core-modules/audit/utils/events/pageview/pageview';
} from 'src/engine/core-modules/event-logs/emit/events/pageview/pageview';
import {
eventsRegistry,
type GenericTrackEvent,
} from 'src/engine/core-modules/audit/utils/events/workspace-event/track';
} from 'src/engine/core-modules/event-logs/emit/events/workspace-event/track';
const common = (): Record<AuditCommonPropertiesType, string> => ({
timestamp: format(new Date(), 'yyyy-MM-dd HH:mm:ss'),
const common = (): Record<EventCommonPropertiesType, string> => ({
timestamp: formatDateTimeForClickHouse(new Date()),
version: '1',
});
@@ -0,0 +1,36 @@
import {
buildPageviewEnvelope,
computeEventContextFields,
} from 'src/engine/core-modules/event-logs/emit/build-event-envelope';
describe('build-event-envelope', () => {
describe('computeEventContextFields', () => {
it('keeps defined ids and drops null/undefined', () => {
expect(
computeEventContextFields({ workspaceId: 'w', userId: 'u' }),
).toEqual({ workspaceId: 'w', userId: 'u' });
expect(
computeEventContextFields({ workspaceId: 'w', userId: null }),
).toEqual({ workspaceId: 'w' });
expect(computeEventContextFields()).toEqual({});
});
});
describe('buildPageviewEnvelope', () => {
it('tags the envelope with the pageview table and merges context', () => {
const envelope = buildPageviewEnvelope(
{ workspaceId: 'w', userId: 'u' },
'home',
{},
);
expect(envelope.table).toBe('pageview');
expect(envelope.row).toMatchObject({
workspaceId: 'w',
userId: 'u',
type: 'page',
name: 'home',
});
});
});
});
@@ -0,0 +1,65 @@
import {
type TrackEventName,
type TrackEventProperties,
} from 'src/engine/core-modules/event-logs/emit/events.type';
import {
type EventContextFields,
type WorkspaceEventEnvelope,
} from 'src/engine/core-modules/event-logs/types/workspace-event-envelope.type';
import {
makePageview,
makeTrackEvent,
} from 'src/engine/core-modules/event-logs/emit/analytics.utils';
import { type PageviewProperties } from 'src/engine/core-modules/event-logs/emit/events/pageview/pageview';
export const computeEventContextFields = (
context?: EventContextFields,
): EventContextFields => ({
...(context?.workspaceId ? { workspaceId: context.workspaceId } : {}),
...(context?.userId ? { userId: context.userId } : {}),
});
export const buildWorkspaceEventEnvelope = <T extends TrackEventName>(
contextFields: EventContextFields,
event: T,
properties: TrackEventProperties<T>,
): WorkspaceEventEnvelope => ({
table: 'workspaceEvent',
row: { ...contextFields, ...makeTrackEvent(event, properties) },
});
export const buildObjectEventEnvelope = <T extends TrackEventName>(
contextFields: EventContextFields,
event: T,
properties: TrackEventProperties<T> & {
recordId: string;
objectMetadataId: string;
isCustom?: boolean;
},
): WorkspaceEventEnvelope => {
const { recordId, objectMetadataId, isCustom, ...restProperties } =
properties;
return {
table: 'objectEvent',
row: {
...contextFields,
...makeTrackEvent(
event,
restProperties as unknown as TrackEventProperties<T>,
),
recordId,
objectMetadataId,
isCustom,
},
};
};
export const buildPageviewEnvelope = (
contextFields: EventContextFields,
name: string,
properties: Partial<PageviewProperties>,
): WorkspaceEventEnvelope => ({
table: 'pageview',
row: { ...contextFields, ...makePageview(name, properties) },
});

Some files were not shown because too many files have changed in this diff Show More