Fix empty user id clickhouse (#18238)

- Fixes:
- Make Workspace User select work; previously, it didn't work as we were
not fetching the workspace users correctly
  - Send Object Events with valid record id and object id

## Audit logs demo


https://github.com/user-attachments/assets/92437037-d253-4810-a138-7c709550755d
This commit is contained in:
Baptiste Devessier
2026-02-27 16:58:44 +01:00
committed by GitHub
parent 86fbf69e95
commit cfad24da48
29 changed files with 146 additions and 171 deletions
File diff suppressed because one or more lines are too long
@@ -10,7 +10,6 @@ export type PartialWorkspaceMember = Omit<
| 'calendarStartDay'
| 'createdAt'
| 'updatedAt'
| 'userId'
>;
export type RoleWithPartialMembers = Omit<Role, 'workspaceMembers'> & {
@@ -9,5 +9,6 @@ export const PARTIAL_WORKSPACE_MEMBER_QUERY_FRAGMENT = gql`
}
avatarUrl
userEmail
userWorkspaceId
}
`;
@@ -11,6 +11,7 @@ export const WORKSPACE_MEMBER_QUERY_FRAGMENT = gql`
avatarUrl
locale
userEmail
userWorkspaceId
timeZone
dateFormat
timeFormat
@@ -1,11 +1,11 @@
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { Select } from '@/ui/input/components/Select';
import { TextInput } from '@/ui/input/components/TextInput';
import { IconUser, IconBox, useIcons } from 'twenty-ui/display';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { IconBox, IconUser, useIcons } from 'twenty-ui/display';
import { type SelectOption } from 'twenty-ui/input';
import { EventLogTable } from '~/generated-metadata/graphql';
@@ -42,8 +42,8 @@ const DEFAULT_COLUMNS: ColumnConfig[] = [
{ id: 'event', label: msg`Event`, minWidth: 100, defaultWidth: 200 },
{ id: 'timestamp', label: msg`Timestamp`, minWidth: 100, defaultWidth: 150 },
{
id: 'userWorkspaceId',
label: msg`User Workspace`,
id: 'userId',
label: msg`User`,
minWidth: 100,
defaultWidth: 150,
},
@@ -59,8 +59,8 @@ const OBJECT_EVENT_COLUMNS: ColumnConfig[] = [
{ id: 'event', label: msg`Event`, minWidth: 100, defaultWidth: 180 },
{ id: 'timestamp', label: msg`Timestamp`, minWidth: 100, defaultWidth: 130 },
{
id: 'userWorkspaceId',
label: msg`User Workspace`,
id: 'userId',
label: msg`User`,
minWidth: 100,
defaultWidth: 130,
},
@@ -318,7 +318,7 @@ export const EventLogResultsTable = ({
<StyledTableCell>
{beautifyPastDateRelativeToNow(record.timestamp)}
</StyledTableCell>
<StyledTableCell>{record.userWorkspaceId ?? '-'}</StyledTableCell>
<StyledTableCell>{record.userId ?? '-'}</StyledTableCell>
{showObjectEventColumns && (
<>
<StyledTableCell>{record.recordId ?? '-'}</StyledTableCell>
@@ -6,7 +6,7 @@ export const GET_EVENT_LOGS = gql`
records {
event
timestamp
userWorkspaceId
userId
properties
recordId
objectMetadataId
@@ -7,4 +7,5 @@ CREATE TABLE IF NOT EXISTS workspaceEvent
`properties` JSON
)
ENGINE = MergeTree
ORDER BY (workspaceId, event, userId, timestamp);
ORDER BY (workspaceId, timestamp, event, userId)
TTL timestamp + INTERVAL 3 YEAR DELETE;
@@ -1,10 +1,11 @@
CREATE TABLE IF NOT EXISTS pageview
(
`name` LowCardinality(String) NOT NULL,
`timestamp` DateTime64(3) NOT NULL,
`name` LowCardinality(String) NOT NULL,
`timestamp` DateTime64(3) NOT NULL,
`userId` String DEFAULT '',
`workspaceId` String DEFAULT '',
`properties` JSON
)
ENGINE = MergeTree
ORDER BY (workspaceId, name, userId, timestamp);
ORDER BY (workspaceId, timestamp, name, userId)
TTL timestamp + INTERVAL 3 YEAR DELETE;
@@ -1,13 +1,14 @@
CREATE TABLE IF NOT EXISTS objectEvent
(
`event` LowCardinality(String) NOT NULL,
`timestamp` DateTime64(3) NOT NULL,
`userId` String DEFAULT '',
`workspaceId` String NOT NULL,
`recordId` String NOT NULL,
`objectMetadataId` String NOT NULL,
`properties` JSON,
`isCustom` Boolean DEFAULT FALSE,
`event` LowCardinality(String) NOT NULL,
`timestamp` DateTime64(3) NOT NULL,
`userId` String DEFAULT '',
`workspaceId` String NOT NULL,
`recordId` String NOT NULL,
`objectMetadataId` String NOT NULL,
`properties` JSON,
`isCustom` Boolean DEFAULT FALSE
)
ENGINE = MergeTree
ORDER BY (workspaceId, event, userId, timestamp);
ORDER BY (workspaceId, timestamp, event, userId)
TTL timestamp + INTERVAL 3 YEAR DELETE;
@@ -1,26 +0,0 @@
-- Optimize workspaceEvent table for time-series queries
-- ClickHouse doesn't allow changing ORDER BY, so we create a new table and migrate data
-- Step 1: Create new table with optimized structure
CREATE TABLE IF NOT EXISTS workspaceEvent_v2
(
`event` LowCardinality(String) NOT NULL,
`timestamp` DateTime64(3) NOT NULL,
`userWorkspaceId` String DEFAULT '',
`workspaceId` String NOT NULL,
`properties` JSON
)
ENGINE = MergeTree
ORDER BY (workspaceId, timestamp, event, userWorkspaceId)
TTL timestamp + INTERVAL 3 YEAR DELETE;
-- Step 2: Migrate existing data (userWorkspaceId will be empty for historical data)
INSERT INTO workspaceEvent_v2
SELECT event, timestamp, '' as userWorkspaceId, workspaceId, properties
FROM workspaceEvent;
-- Step 3: Atomic swap (EXCHANGE is atomic and instant)
EXCHANGE TABLES workspaceEvent AND workspaceEvent_v2;
-- Step 4: Drop old table (now named workspaceEvent_v2 after swap)
DROP TABLE IF EXISTS workspaceEvent_v2;
@@ -1,26 +0,0 @@
-- Optimize pageview table for time-series queries
-- ClickHouse doesn't allow changing ORDER BY, so we create a new table and migrate data
-- Step 1: Create new table with optimized structure
CREATE TABLE IF NOT EXISTS pageview_v2
(
`name` LowCardinality(String) NOT NULL,
`timestamp` DateTime64(3) NOT NULL,
`userWorkspaceId` String DEFAULT '',
`workspaceId` String DEFAULT '',
`properties` JSON
)
ENGINE = MergeTree
ORDER BY (workspaceId, timestamp, name, userWorkspaceId)
TTL timestamp + INTERVAL 3 YEAR DELETE;
-- Step 2: Migrate existing data (userWorkspaceId will be empty for historical data)
INSERT INTO pageview_v2
SELECT name, timestamp, '' as userWorkspaceId, workspaceId, properties
FROM pageview;
-- Step 3: Atomic swap
EXCHANGE TABLES pageview AND pageview_v2;
-- Step 4: Drop old table
DROP TABLE IF EXISTS pageview_v2;
@@ -1,29 +0,0 @@
-- Optimize objectEvent table for time-series queries and object filtering
-- ClickHouse doesn't allow changing ORDER BY, so we create a new table and migrate data
-- Step 1: Create new table with optimized structure
CREATE TABLE IF NOT EXISTS objectEvent_v2
(
`event` LowCardinality(String) NOT NULL,
`timestamp` DateTime64(3) NOT NULL,
`userWorkspaceId` String DEFAULT '',
`workspaceId` String NOT NULL,
`recordId` String NOT NULL,
`objectMetadataId` String NOT NULL,
`properties` JSON,
`isCustom` Boolean DEFAULT FALSE
)
ENGINE = MergeTree
ORDER BY (workspaceId, timestamp, objectMetadataId, recordId, event, userWorkspaceId)
TTL timestamp + INTERVAL 3 YEAR DELETE;
-- Step 2: Migrate existing data (userWorkspaceId will be empty for historical data)
INSERT INTO objectEvent_v2
SELECT event, timestamp, '' as userWorkspaceId, workspaceId, recordId, objectMetadataId, properties, isCustom
FROM objectEvent;
-- Step 3: Atomic swap
EXCHANGE TABLES objectEvent AND objectEvent_v2;
-- Step 4: Drop old table
DROP TABLE IF EXISTS objectEvent_v2;
@@ -5,13 +5,19 @@ import { CUSTOM_DOMAIN_ACTIVATED_EVENT } from 'src/engine/core-modules/audit/uti
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';
export const fixtures: Array<GenericTrackEvent> = [
export type ObjectEventFixture = GenericTrackEvent & {
recordId: string;
objectMetadataId: string;
isCustom?: boolean;
};
export const workspaceEventFixtures: Array<GenericTrackEvent> = [
{
type: 'track',
event: CUSTOM_DOMAIN_ACTIVATED_EVENT,
timestamp: '2024-10-24T15:55:35.177',
version: '1',
userWorkspaceId: '20202020-3957-45c9-be39-337dc4d9100a',
userId: '20202020-3957-45c9-be39-337dc4d9100a',
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
properties: {},
},
@@ -20,35 +26,44 @@ export const fixtures: Array<GenericTrackEvent> = [
event: CUSTOM_DOMAIN_DEACTIVATED_EVENT,
timestamp: '2024-10-24T15:55:35.177',
version: '1',
userWorkspaceId: '20202020-3957-45c9-be39-337dc4d9100a',
userId: '20202020-3957-45c9-be39-337dc4d9100a',
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
properties: {},
},
];
export const objectEventFixtures: Array<ObjectEventFixture> = [
{
type: 'track',
event: OBJECT_RECORD_CREATED_EVENT,
timestamp: '2024-10-24T15:55:35.177',
version: '1',
userWorkspaceId: '20202020-3957-45c9-be39-337dc4d9100a',
userId: '20202020-3957-45c9-be39-337dc4d9100a',
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
properties: {},
recordId: '20202020-c21e-4ec2-873b-de4264d89025',
objectMetadataId: '20202020-1f76-4e46-b33b-58a70e007ba0',
},
{
type: 'track',
event: OBJECT_RECORD_UPDATED_EVENT,
timestamp: '2024-10-24T15:55:35.177',
version: '1',
userWorkspaceId: '20202020-3957-45c9-be39-337dc4d9100a',
userId: '20202020-3957-45c9-be39-337dc4d9100a',
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
properties: {},
recordId: '20202020-c21e-4ec2-873b-de4264d89025',
objectMetadataId: '20202020-1f76-4e46-b33b-58a70e007ba0',
},
{
type: 'track',
event: OBJECT_RECORD_DELETED_EVENT,
timestamp: '2024-10-24T15:55:35.177',
version: '1',
userWorkspaceId: '20202020-3957-45c9-be39-337dc4d9100a',
userId: '20202020-3957-45c9-be39-337dc4d9100a',
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
properties: {},
recordId: '20202020-c21e-4ec2-873b-de4264d89025',
objectMetadataId: '20202020-1f76-4e46-b33b-58a70e007ba0',
},
];
@@ -2,7 +2,7 @@
import { createClient } from '@clickhouse/client';
import { config } from 'dotenv';
import { fixtures } from './fixtures';
import { objectEventFixtures, workspaceEventFixtures } from './fixtures';
config({
path: process.env.NODE_ENV === 'test' ? '.env.test' : '.env',
@@ -15,11 +15,21 @@ const client = createClient({
async function seedEvents() {
try {
console.log(`⚡ Seeding ${fixtures.length} events...`);
console.log(
`⚡ Seeding ${workspaceEventFixtures.length} workspace events...`,
);
await client.insert({
table: 'workspaceEvent',
values: fixtures,
values: workspaceEventFixtures,
format: 'JSONEachRow',
});
console.log(`⚡ Seeding ${objectEventFixtures.length} object events...`);
await client.insert({
table: 'objectEvent',
values: objectEventFixtures,
format: 'JSONEachRow',
});
@@ -24,7 +24,7 @@ export class TelemetryListener {
payload.events.map(async (eventPayload) => {
this.auditService
.createContext({
userWorkspaceId: eventPayload.userWorkspaceId,
userId: eventPayload.userId,
workspaceId: payload.workspaceId,
})
.insertWorkspaceEvent(USER_SIGNUP_EVENT, {});
@@ -4,6 +4,7 @@ import {
AuditException,
AuditExceptionCode,
} from 'src/engine/core-modules/audit/audit.exception';
import { type UserEntity } from 'src/engine/core-modules/user/user.entity';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuditResolver } from './audit.resolver';
@@ -55,12 +56,12 @@ describe('AuditResolver', () => {
const result = await resolver.trackAnalytics(
input,
{ id: 'workspace-1' } as WorkspaceEntity,
'user-workspace-1',
{ id: 'user-1' } as UserEntity,
);
expect(auditService.createContext).toHaveBeenCalledWith({
workspaceId: 'workspace-1',
userWorkspaceId: 'user-workspace-1',
userId: 'user-1',
});
expect(mockInsertPageviewEvent).toHaveBeenCalledWith('Test Page', {});
expect(result).toBe('Pageview created');
@@ -85,12 +86,12 @@ describe('AuditResolver', () => {
const result = await resolver.trackAnalytics(
input,
{ id: 'workspace-2' } as WorkspaceEntity,
'user-workspace-2',
{ id: 'user-2' } as UserEntity,
);
expect(auditService.createContext).toHaveBeenCalledWith({
workspaceId: 'workspace-2',
userWorkspaceId: 'user-workspace-2',
userId: 'user-2',
});
expect(mockInsertWorkspaceEvent).toHaveBeenCalledWith(
'Custom Domain Activated',
@@ -120,12 +121,12 @@ describe('AuditResolver', () => {
const result = await resolver.createObjectEvent(
input,
{ id: 'workspace-3' } as WorkspaceEntity,
'user-workspace-3',
{ id: 'user-3' } as UserEntity,
);
expect(auditService.createContext).toHaveBeenCalledWith({
workspaceId: 'workspace-3',
userWorkspaceId: 'user-workspace-3',
userId: 'user-3',
});
expect(mockInsertObjectEvent).toHaveBeenCalledWith(
@@ -1,6 +1,7 @@
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { Args, Mutation } from '@nestjs/graphql';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { AuditExceptionFilter } from 'src/engine/core-modules/audit/audit-exception-filter';
import {
AuditException,
@@ -9,10 +10,10 @@ import {
import { CreateObjectEventInput } from 'src/engine/core-modules/audit/dtos/create-object-event.input';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@@ -37,13 +38,9 @@ export class AuditResolver {
createAnalyticsInput: CreateAnalyticsInputV2,
@AuthWorkspace({ allowUndefined: true })
workspace: WorkspaceEntity | undefined,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
@AuthUser({ allowUndefined: true }) user: UserEntity | undefined,
) {
return this.trackAnalytics(
createAnalyticsInput,
workspace,
userWorkspaceId,
);
return this.trackAnalytics(createAnalyticsInput, workspace, user);
}
@Mutation(() => Analytics)
@@ -52,7 +49,7 @@ export class AuditResolver {
@Args()
createObjectEventInput: CreateObjectEventInput,
@AuthWorkspace() workspace: WorkspaceEntity | undefined,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
@AuthUser({ allowUndefined: true }) user: UserEntity | undefined,
) {
if (!workspace) {
throw new AuditException(
@@ -63,7 +60,7 @@ export class AuditResolver {
const analyticsContext = this.auditService.createContext({
workspaceId: workspace.id,
userWorkspaceId,
userId: user?.id,
});
return analyticsContext.createObjectEvent(createObjectEventInput.event, {
@@ -81,11 +78,11 @@ export class AuditResolver {
createAnalyticsInput: CreateAnalyticsInputV2,
@AuthWorkspace({ allowUndefined: true })
workspace: WorkspaceEntity | undefined,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
@AuthUser({ allowUndefined: true }) user: UserEntity | undefined,
) {
const analyticsContext = this.auditService.createContext({
workspaceId: workspace?.id,
userWorkspaceId,
userId: user?.id,
});
if (isPageviewAnalyticsInput(createAnalyticsInput)) {
@@ -30,7 +30,7 @@ export class CreateAuditLogFromInternalEvent {
const auditService = this.auditService.createContext({
workspaceId: workspaceEventBatch.workspaceId,
userWorkspaceId: eventData.userWorkspaceId,
userId: eventData.userId,
});
// Since these are object record events, we use createObjectEvent
@@ -25,14 +25,12 @@ export class AuditService {
createContext(context?: {
workspaceId?: string | null | undefined;
userWorkspaceId?: string | null | undefined;
userId?: string | null | undefined;
}) {
const contextFields = context
? {
...(context.workspaceId ? { workspaceId: context.workspaceId } : {}),
...(context.userWorkspaceId
? { userWorkspaceId: context.userWorkspaceId }
: {}),
...(context.userId ? { userId: context.userId } : {}),
}
: {};
@@ -51,13 +49,27 @@ export class AuditService {
properties: TrackEventProperties<T> & {
recordId: string;
objectMetadataId: string;
isCustom?: boolean;
},
) =>
this.preventIfDisabled(() =>
) => {
const { recordId, objectMetadataId, isCustom, ...restProperties } =
properties;
return this.preventIfDisabled(() =>
this.clickHouseService.insert('objectEvent', [
{ ...contextFields, ...makeTrackEvent(event, properties) },
{
...contextFields,
...makeTrackEvent(
event,
restProperties as unknown as TrackEventProperties<T>,
),
recordId,
objectMetadataId,
isCustom,
},
]),
),
);
},
createPageviewEvent: (
name: string,
properties: Partial<PageviewProperties>,
@@ -15,7 +15,7 @@ export type GenericTrackEvent<E extends string = string> = {
properties: any;
timestamp: string;
version: string;
userWorkspaceId?: string;
userId?: string;
workspaceId?: string;
};
@@ -3,11 +3,11 @@ import { Args, Context, Mutation, Query } from '@nestjs/graphql';
import { InjectRepository } from '@nestjs/typeorm';
import omit from 'lodash.omit';
import { PermissionFlagType } from 'twenty-shared/constants';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { TwoFactorAuthenticationStrategy } from 'twenty-shared/types';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { PermissionFlagType } from 'twenty-shared/constants';
import { ApiKeyTokenInput } from 'src/engine/core-modules/auth/dto/api-key-token.input';
import { AppTokenInput } from 'src/engine/core-modules/auth/dto/app-token.input';
@@ -22,6 +22,7 @@ import { ValidatePasswordResetTokenOutput } from 'src/engine/core-modules/auth/d
import { ValidatePasswordResetTokenInput } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.input';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
// import { OAuthService } from 'src/engine/core-modules/auth/services/oauth.service';
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';
@@ -70,7 +71,6 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent
import { AuthProvider } from 'src/engine/decorators/auth/auth-provider.decorator';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
@@ -700,7 +700,7 @@ export class AuthResolver {
const auditService = this.auditService.createContext({
workspaceId: workspace.id,
userWorkspaceId: impersonatorUserWorkspace.id,
userId: impersonatorUserWorkspace.user.id,
});
auditService.insertWorkspaceEvent(MONITORING_EVENT, {
@@ -419,7 +419,7 @@ export class AuthService {
const analytics = this.auditService.createContext({
workspaceId,
userWorkspaceId: impersonatorUserWorkspaceId,
userId: _impersonatorUserId,
});
analytics.insertWorkspaceEvent('Monitoring', {
@@ -11,7 +11,7 @@ export class EventLogRecord {
timestamp: Date;
@Field(() => String, { nullable: true })
userWorkspaceId?: string;
userId?: string;
@Field(() => GraphQLJSON, { nullable: true })
properties?: Record<string, unknown>;
@@ -1,10 +1,12 @@
/* @license Enterprise */
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ClickHouseModule } from 'src/database/clickHouse/clickHouse.module';
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
import { GuardRedirectModule } from 'src/engine/core-modules/guard-redirect/guard-redirect.module';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { EventLogsResolver } from './event-logs.resolver';
@@ -16,6 +18,7 @@ import { EventLogsService } from './event-logs.service';
PermissionsModule,
BillingModule,
GuardRedirectModule,
TypeOrmModule.forFeature([UserWorkspaceEntity]),
],
providers: [EventLogsResolver, EventLogsService],
exports: [EventLogsService],
@@ -1,14 +1,17 @@
/* @license Enterprise */
import { BadRequestException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { EventLogTable } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { ClickHouseService } from 'src/database/clickHouse/clickHouse.service';
import { formatDateForClickHouse } from 'src/database/clickHouse/clickHouse.util';
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 { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import {
EventLogsException,
@@ -26,7 +29,7 @@ type ClickHouseEventRecord = {
event?: string;
name?: string;
timestamp: string;
userWorkspaceId?: string;
userId?: string;
properties?: Record<string, unknown>;
recordId?: string;
objectMetadataId?: string;
@@ -47,6 +50,8 @@ export class EventLogsService {
constructor(
private readonly clickHouseService: ClickHouseService,
private readonly billingService: BillingService,
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
) {}
async queryEventLogs(
@@ -67,7 +72,7 @@ export class EventLogsService {
const whereClauses: string[] = ['"workspaceId" = {workspaceId:String}'];
const params: Record<string, unknown> = { workspaceId };
this.applyFilters(
await this.applyFilters(
whereClauses,
params,
input.filters,
@@ -155,13 +160,13 @@ export class EventLogsService {
}
}
private applyFilters(
private async applyFilters(
whereClauses: string[],
params: Record<string, unknown>,
filters: EventLogFiltersInput | undefined,
eventFieldName: string,
table: EventLogTable,
): void {
): Promise<void> {
if (!isDefined(filters)) {
return;
}
@@ -174,8 +179,15 @@ export class EventLogsService {
}
if (isDefined(filters.userWorkspaceId)) {
whereClauses.push('"userWorkspaceId" = {userWorkspaceId:String}');
params.userWorkspaceId = filters.userWorkspaceId;
const userWorkspace = await this.userWorkspaceRepository.findOne({
where: { id: filters.userWorkspaceId },
select: ['userId'],
});
if (isDefined(userWorkspace)) {
whereClauses.push('"userId" = {userId:String}');
params.userId = userWorkspace.userId;
}
}
if (isDefined(filters.dateRange?.start)) {
@@ -222,7 +234,7 @@ export class EventLogsService {
return {
event: eventName,
timestamp: new Date(record.timestamp),
userWorkspaceId: record.userWorkspaceId,
userId: record.userId,
properties: record.properties,
recordId: record.recordId,
objectMetadataId: record.objectMetadataId,
@@ -1,9 +1,9 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { PermissionFlagType } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { PermissionFlagType } from 'twenty-shared/constants';
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
@@ -141,7 +141,7 @@ export class ImpersonationService {
) {
const auditService = this.auditService.createContext({
workspaceId: impersonatorUserWorkspace.workspace.id,
userWorkspaceId: impersonatorUserWorkspace.id,
userId: impersonatorUserWorkspace.userId,
});
auditService.insertWorkspaceEvent(MONITORING_EVENT, {
@@ -68,9 +68,9 @@ describe('ClickHouse Event Registration (integration)', () => {
expect(rows.length).toEqual(1);
expect(rows[0].properties).toEqual(variables.properties);
expect(rows[0].event).toEqual(variables.event);
// workspaceId and userWorkspaceId are empty/undefined for unauthenticated requests
// workspaceId and userId are empty/undefined for unauthenticated requests
expect(rows[0].workspaceId ?? '').toEqual('');
expect(rows[0].userWorkspaceId ?? '').toEqual('');
expect(rows[0].userId ?? '').toEqual('');
expect(rows[0].timestamp).toHaveLength(23);
});
});
@@ -36,7 +36,7 @@ describe('Event Logs (integration)', () => {
const pageviewRecords = Array.from({ length: 25 }, (_, i) => ({
workspaceId: testWorkspaceId,
userWorkspaceId: testUserWorkspaceId,
userId: testUserWorkspaceId,
name: i % 2 === 0 ? 'settings/profile' : 'objects/companies',
timestamp: new Date(now.getTime() - i * 60000)
.toISOString()
@@ -47,7 +47,7 @@ describe('Event Logs (integration)', () => {
const workspaceEventRecords = Array.from({ length: 15 }, (_, i) => ({
workspaceId: testWorkspaceId,
userWorkspaceId: testUserWorkspaceId,
userId: testUserWorkspaceId,
event:
i % 3 === 0
? 'user.login'
@@ -63,7 +63,7 @@ describe('Event Logs (integration)', () => {
const objectEventRecords = Array.from({ length: 20 }, (_, i) => ({
workspaceId: testWorkspaceId,
userWorkspaceId: testUserWorkspaceId,
userId: testUserWorkspaceId,
event: i % 2 === 0 ? 'company.created' : 'company.updated',
timestamp: new Date(now.getTime() - i * 90000)
.toISOString()
@@ -127,7 +127,7 @@ describe('Event Logs (integration)', () => {
records {
event
timestamp
userWorkspaceId
userId
properties
recordId
objectMetadataId
@@ -462,7 +462,7 @@ describe('Event Logs (integration)', () => {
expect(record).toHaveProperty('event');
expect(record).toHaveProperty('timestamp');
expect(record).toHaveProperty('userWorkspaceId');
expect(record).toHaveProperty('userId');
expect(record).toHaveProperty('properties');
expect(typeof record.event).toBe('string');
expect(typeof record.timestamp).toBe('string');