Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bda5f0d68 | ||
|
|
5a1ae372dc | ||
|
|
33a403c0bd |
@@ -0,0 +1,106 @@
|
||||
name: CI New UI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
merge_group:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
changed-files-check:
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: ./.github/workflows/changed-files.yaml
|
||||
with:
|
||||
files: |
|
||||
package.json
|
||||
yarn.lock
|
||||
packages/twenty-new-ui/**
|
||||
packages/twenty-shared/**
|
||||
new-ui-task:
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
task: [lint, typecheck, test]
|
||||
steps:
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
fetch-depth: 10
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Run ${{ matrix.task }}
|
||||
run: npx nx ${{ matrix.task }} twenty-new-ui
|
||||
new-ui-sb-build:
|
||||
needs: changed-files-check
|
||||
if: >-
|
||||
always() &&
|
||||
(github.event_name == 'push' ||
|
||||
needs.changed-files-check.outputs.any_changed == 'true')
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
fetch-depth: 10
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Build storybook
|
||||
run: npx nx storybook:build twenty-new-ui
|
||||
- name: Upload storybook build
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: storybook-twenty-new-ui
|
||||
path: packages/twenty-new-ui/storybook-static
|
||||
retention-days: 1
|
||||
new-ui-sb-test:
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
needs: new-ui-sb-build
|
||||
if: always() && needs.new-ui-sb-build.result == 'success'
|
||||
steps:
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
fetch-depth: 10
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Build dependencies
|
||||
run: npx nx build twenty-shared
|
||||
- name: Download storybook build
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: storybook-twenty-new-ui
|
||||
path: packages/twenty-new-ui/storybook-static
|
||||
- name: Install Playwright
|
||||
run: |
|
||||
cd packages/twenty-new-ui
|
||||
npx playwright install
|
||||
- name: Run storybook tests
|
||||
run: npx nx storybook:test twenty-new-ui
|
||||
- name: Upload screenshots for visual regression
|
||||
if: always() && !cancelled()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: argos-screenshots-twenty-new-ui
|
||||
path: packages/twenty-new-ui/screenshots
|
||||
retention-days: 1
|
||||
ci-new-ui-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changed-files-check, new-ui-task, new-ui-sb-build, new-ui-sb-test]
|
||||
steps:
|
||||
- name: Fail job if any needs failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
@@ -3,10 +3,13 @@ name: Visual Regression Dispatch
|
||||
# Dispatches visual regression processing to ci-privileged after CI completes.
|
||||
# Runs in the context of the base repo (not the fork) so it has access to secrets,
|
||||
# making it work for external contributor PRs.
|
||||
#
|
||||
# All dispatches use the same event_type=visual-regression with project/artifact_name
|
||||
# in the payload. ci-privileged routes to the correct Argos project based on these.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['CI UI']
|
||||
workflows: ['CI UI', 'CI New UI']
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
@@ -15,19 +18,37 @@ permissions:
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
dispatch-pr:
|
||||
if: >-
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
resolve-context:
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
workflow_name: ${{ steps.context.outputs.workflow_name }}
|
||||
artifact_name: ${{ steps.context.outputs.artifact_name }}
|
||||
has_artifact: ${{ steps.check-artifact.outputs.exists }}
|
||||
is_pr: ${{ steps.pr-info.outputs.has_pr }}
|
||||
pr_number: ${{ steps.pr-info.outputs.pr_number }}
|
||||
merge_base_sha: ${{ steps.merge-base.outputs.sha }}
|
||||
steps:
|
||||
- name: Resolve workflow context
|
||||
id: context
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
const name = context.payload.workflow_run.name;
|
||||
const artifactName = name === 'CI New UI'
|
||||
? 'argos-screenshots-twenty-new-ui'
|
||||
: 'argos-screenshots-twenty-ui';
|
||||
core.setOutput('workflow_name', name);
|
||||
core.setOutput('artifact_name', artifactName);
|
||||
core.info(`Workflow: ${name}, artifact: ${artifactName}`);
|
||||
|
||||
- name: Check if screenshots artifact exists
|
||||
id: check-artifact
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
const artifactName = 'argos-screenshots-twenty-ui';
|
||||
const artifactName = '${{ steps.context.outputs.artifact_name }}';
|
||||
const runId = context.payload.workflow_run.id;
|
||||
|
||||
const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
@@ -44,7 +65,9 @@ jobs:
|
||||
}
|
||||
|
||||
- name: Get PR number
|
||||
if: steps.check-artifact.outputs.exists == 'true'
|
||||
if: >-
|
||||
steps.check-artifact.outputs.exists == 'true' &&
|
||||
github.event.workflow_run.event == 'pull_request'
|
||||
id: pr-info
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
@@ -111,73 +134,152 @@ jobs:
|
||||
core.setOutput('sha', '');
|
||||
}
|
||||
|
||||
# ── Dispatch: twenty-ui pixel diff (CI UI, PRs + main) ──
|
||||
dispatch-twenty-ui:
|
||||
needs: resolve-context
|
||||
if: >-
|
||||
needs.resolve-context.outputs.workflow_name == 'CI UI' &&
|
||||
needs.resolve-context.outputs.has_artifact == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Dispatch to ci-privileged
|
||||
if: steps.check-artifact.outputs.exists == 'true' && steps.pr-info.outputs.has_pr == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
|
||||
PR_NUMBER: ${{ steps.pr-info.outputs.pr_number }}
|
||||
PR_NUMBER: ${{ needs.resolve-context.outputs.pr_number }}
|
||||
WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
COMMIT: ${{ github.event.workflow_run.head_sha }}
|
||||
REFERENCE_COMMIT: ${{ steps.merge-base.outputs.sha }}
|
||||
REFERENCE_COMMIT: ${{ needs.resolve-context.outputs.merge_base_sha }}
|
||||
ARTIFACT_NAME: ${{ needs.resolve-context.outputs.artifact_name }}
|
||||
run: |
|
||||
ARGS=(
|
||||
--method POST
|
||||
-f event_type=visual-regression
|
||||
-f "client_payload[pr_number]=$PR_NUMBER"
|
||||
-f "client_payload[project]=twenty-ui"
|
||||
-f "client_payload[artifact_name]=$ARTIFACT_NAME"
|
||||
-f "client_payload[run_id]=$WORKFLOW_RUN_ID"
|
||||
-f "client_payload[repo]=$REPOSITORY"
|
||||
-f "client_payload[branch]=$BRANCH"
|
||||
-f "client_payload[commit]=$COMMIT"
|
||||
)
|
||||
|
||||
if [ -n "$PR_NUMBER" ]; then
|
||||
ARGS+=(-f "client_payload[pr_number]=$PR_NUMBER")
|
||||
fi
|
||||
if [ -n "$REFERENCE_COMMIT" ]; then
|
||||
ARGS+=(-f "client_payload[reference_commit]=$REFERENCE_COMMIT")
|
||||
fi
|
||||
|
||||
gh api repos/twentyhq/ci-privileged/dispatches "${ARGS[@]}"
|
||||
|
||||
# ── Dispatch: twenty-new-ui pixel diff (CI New UI, PRs + main) ──
|
||||
dispatch-twenty-new-ui:
|
||||
needs: resolve-context
|
||||
if: >-
|
||||
needs.resolve-context.outputs.workflow_name == 'CI New UI' &&
|
||||
needs.resolve-context.outputs.has_artifact == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Dispatch to ci-privileged
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
|
||||
WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
COMMIT: ${{ github.event.workflow_run.head_sha }}
|
||||
PR_NUMBER: ${{ needs.resolve-context.outputs.pr_number }}
|
||||
REFERENCE_COMMIT: ${{ needs.resolve-context.outputs.merge_base_sha }}
|
||||
ARTIFACT_NAME: ${{ needs.resolve-context.outputs.artifact_name }}
|
||||
run: |
|
||||
ARGS=(
|
||||
--method POST
|
||||
-f event_type=visual-regression
|
||||
-f "client_payload[project]=twenty-new-ui"
|
||||
-f "client_payload[artifact_name]=$ARTIFACT_NAME"
|
||||
-f "client_payload[run_id]=$WORKFLOW_RUN_ID"
|
||||
-f "client_payload[repo]=$REPOSITORY"
|
||||
-f "client_payload[branch]=$BRANCH"
|
||||
-f "client_payload[commit]=$COMMIT"
|
||||
)
|
||||
|
||||
if [ -n "$PR_NUMBER" ]; then
|
||||
ARGS+=(-f "client_payload[pr_number]=$PR_NUMBER")
|
||||
fi
|
||||
if [ -n "$REFERENCE_COMMIT" ]; then
|
||||
ARGS+=(-f "client_payload[reference_commit]=$REFERENCE_COMMIT")
|
||||
fi
|
||||
|
||||
gh api repos/twentyhq/ci-privileged/dispatches "${ARGS[@]}"
|
||||
|
||||
# ── Dispatch: cross-comparison baseline (CI UI on main → twenty-ui-vs-new-ui) ──
|
||||
dispatch-comparison-baseline:
|
||||
needs: resolve-context
|
||||
if: >-
|
||||
needs.resolve-context.outputs.workflow_name == 'CI UI' &&
|
||||
needs.resolve-context.outputs.has_artifact == 'true' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.head_branch == 'main'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Dispatch to ci-privileged (comparison baseline)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
|
||||
WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
COMMIT: ${{ github.event.workflow_run.head_sha }}
|
||||
ARTIFACT_NAME: ${{ needs.resolve-context.outputs.artifact_name }}
|
||||
run: |
|
||||
gh api repos/twentyhq/ci-privileged/dispatches \
|
||||
--method POST \
|
||||
-f event_type=visual-regression \
|
||||
-f "client_payload[project]=twenty-ui-vs-new-ui" \
|
||||
-f "client_payload[artifact_name]=$ARTIFACT_NAME" \
|
||||
-f "client_payload[run_id]=$WORKFLOW_RUN_ID" \
|
||||
-f "client_payload[repo]=$REPOSITORY" \
|
||||
-f "client_payload[branch]=$BRANCH" \
|
||||
-f "client_payload[commit]=$COMMIT"
|
||||
|
||||
# ── Dispatch: cross-comparison PR (CI New UI on PRs → twenty-ui-vs-new-ui) ──
|
||||
dispatch-comparison-pr:
|
||||
needs: resolve-context
|
||||
if: >-
|
||||
needs.resolve-context.outputs.workflow_name == 'CI New UI' &&
|
||||
needs.resolve-context.outputs.has_artifact == 'true' &&
|
||||
needs.resolve-context.outputs.is_pr == 'true' &&
|
||||
github.event.workflow_run.event == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Dispatch to ci-privileged (comparison PR)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
|
||||
WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
COMMIT: ${{ github.event.workflow_run.head_sha }}
|
||||
PR_NUMBER: ${{ needs.resolve-context.outputs.pr_number }}
|
||||
REFERENCE_COMMIT: ${{ needs.resolve-context.outputs.merge_base_sha }}
|
||||
ARTIFACT_NAME: ${{ needs.resolve-context.outputs.artifact_name }}
|
||||
run: |
|
||||
ARGS=(
|
||||
--method POST
|
||||
-f event_type=visual-regression
|
||||
-f "client_payload[project]=twenty-ui-vs-new-ui"
|
||||
-f "client_payload[artifact_name]=$ARTIFACT_NAME"
|
||||
-f "client_payload[run_id]=$WORKFLOW_RUN_ID"
|
||||
-f "client_payload[repo]=$REPOSITORY"
|
||||
-f "client_payload[branch]=$BRANCH"
|
||||
-f "client_payload[commit]=$COMMIT"
|
||||
-f "client_payload[pr_number]=$PR_NUMBER"
|
||||
)
|
||||
|
||||
if [ -n "$REFERENCE_COMMIT" ]; then
|
||||
ARGS+=(-f "client_payload[reference_commit]=$REFERENCE_COMMIT")
|
||||
fi
|
||||
|
||||
gh api repos/twentyhq/ci-privileged/dispatches "${ARGS[@]}"
|
||||
|
||||
dispatch-main:
|
||||
if: >-
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.head_branch == 'main' &&
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check if screenshots artifact exists
|
||||
id: check-artifact
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
const runId = context.payload.workflow_run.id;
|
||||
const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: runId,
|
||||
});
|
||||
|
||||
const found = artifacts.artifacts.some(a => a.name === 'argos-screenshots-twenty-ui');
|
||||
core.setOutput('exists', found ? 'true' : 'false');
|
||||
|
||||
if (!found) {
|
||||
core.info(`Artifact not found in run ${runId} — skipping`);
|
||||
}
|
||||
|
||||
- name: Dispatch to ci-privileged
|
||||
if: steps.check-artifact.outputs.exists == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
|
||||
WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
COMMIT: ${{ github.event.workflow_run.head_sha }}
|
||||
run: |
|
||||
gh api repos/twentyhq/ci-privileged/dispatches \
|
||||
--method POST \
|
||||
-f event_type=visual-regression \
|
||||
-f "client_payload[run_id]=$WORKFLOW_RUN_ID" \
|
||||
-f "client_payload[repo]=$REPOSITORY" \
|
||||
-f "client_payload[branch]=$BRANCH" \
|
||||
-f "client_payload[commit]=$COMMIT"
|
||||
|
||||
@@ -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,6 +1434,11 @@ type EnterpriseSubscriptionStatusDTO {
|
||||
isCancellationScheduled: Boolean!
|
||||
}
|
||||
|
||||
type Analytics {
|
||||
"""Boolean that confirms query was dispatched"""
|
||||
success: Boolean!
|
||||
}
|
||||
|
||||
type VerificationRecord {
|
||||
type: String!
|
||||
key: String!
|
||||
@@ -2616,11 +2621,6 @@ type SendEmailOutput {
|
||||
error: String
|
||||
}
|
||||
|
||||
type Analytics {
|
||||
"""Boolean that confirms query was dispatched"""
|
||||
success: Boolean!
|
||||
}
|
||||
|
||||
type EventLogRecord {
|
||||
event: String!
|
||||
timestamp: DateTime!
|
||||
@@ -3007,8 +3007,8 @@ type Query {
|
||||
): IndexConnection!
|
||||
findManyAgents: [Agent!]!
|
||||
findOneAgent(input: AgentIdInput!): Agent!
|
||||
getRoles: [Role!]!
|
||||
myConnectedAccounts: [ConnectedAccountPublicDTO!]!
|
||||
getRoles: [Role!]!
|
||||
getToolIndex: [ToolIndexEntry!]!
|
||||
getToolInputSchema(toolName: String!): JSON
|
||||
webhooks: [Webhook!]!
|
||||
@@ -3200,6 +3200,8 @@ 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!
|
||||
@@ -3252,6 +3254,7 @@ type Mutation {
|
||||
createOneAgent(input: CreateAgentInput!): Agent!
|
||||
updateOneAgent(input: UpdateAgentInput!): Agent!
|
||||
deleteOneAgent(input: AgentIdInput!): Agent!
|
||||
deleteConnectedAccount(id: UUID!): ConnectedAccountPublicDTO!
|
||||
updateWorkspaceMemberRole(workspaceMemberId: UUID!, roleId: UUID!): WorkspaceMember!
|
||||
createOneRole(createRoleInput: CreateRoleInput!): Role!
|
||||
updateOneRole(updateRoleInput: UpdateRoleInput!): Role!
|
||||
@@ -3262,7 +3265,6 @@ type Mutation {
|
||||
upsertRowLevelPermissionPredicates(input: UpsertRowLevelPermissionPredicatesInput!): UpsertRowLevelPermissionPredicatesResult!
|
||||
assignRoleToAgent(agentId: UUID!, roleId: UUID!): Boolean!
|
||||
removeRoleFromAgent(agentId: UUID!): Boolean!
|
||||
deleteConnectedAccount(id: UUID!): ConnectedAccountPublicDTO!
|
||||
runAgent(input: RunAgentInput!): RunAgentResult!
|
||||
createWebhook(input: CreateWebhookInput!): Webhook!
|
||||
updateWebhook(input: UpdateWebhookInput!): Webhook!
|
||||
@@ -3342,8 +3344,6 @@ 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!, dryRun: Boolean): WorkspaceMigration!
|
||||
syncApplication(manifest: JSON!): WorkspaceMigration!
|
||||
uploadApplicationFile(file: Upload!, applicationUniversalIdentifier: String!, fileFolder: FileFolder!, filePath: String!): File!
|
||||
upgradeApplication(appRegistrationId: String!, targetVersion: String!): Boolean!
|
||||
renewApplicationToken(applicationRefreshToken: String!): ApplicationTokenPair!
|
||||
@@ -3716,6 +3716,11 @@ input RevokeApiKeyInput {
|
||||
id: UUID!
|
||||
}
|
||||
|
||||
enum AnalyticsType {
|
||||
PAGEVIEW
|
||||
TRACK
|
||||
}
|
||||
|
||||
input CreateApprovedAccessDomainInput {
|
||||
domain: String!
|
||||
email: String!
|
||||
@@ -4425,11 +4430,6 @@ input EditSsoInput {
|
||||
status: SSOIdentityProviderStatus!
|
||||
}
|
||||
|
||||
enum AnalyticsType {
|
||||
PAGEVIEW
|
||||
TRACK
|
||||
}
|
||||
|
||||
input SendEmailInput {
|
||||
connectedAccountId: String!
|
||||
to: String!
|
||||
@@ -4498,7 +4498,6 @@ type Subscription {
|
||||
onEventSubscription(eventStreamId: String!): EventSubscription
|
||||
logicFunctionLogs(input: LogicFunctionLogsInput!): LogicFunctionLogs!
|
||||
onAgentChatEvent(threadId: UUID!): AgentChatEvent!
|
||||
eventLogsLive(table: EventLogTable!): [EventLogRecord!]
|
||||
}
|
||||
|
||||
input LogicFunctionLogsInput {
|
||||
|
||||
@@ -1091,6 +1091,12 @@ 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']
|
||||
@@ -2308,12 +2314,6 @@ 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']
|
||||
@@ -2613,8 +2613,8 @@ export interface Query {
|
||||
indexMetadatas: IndexConnection
|
||||
findManyAgents: Agent[]
|
||||
findOneAgent: Agent
|
||||
getRoles: Role[]
|
||||
myConnectedAccounts: ConnectedAccountPublicDTO[]
|
||||
getRoles: Role[]
|
||||
getToolIndex: ToolIndexEntry[]
|
||||
getToolInputSchema?: Scalars['JSON']
|
||||
webhooks: Webhook[]
|
||||
@@ -2724,6 +2724,8 @@ export interface Mutation {
|
||||
updateApiKey?: ApiKey
|
||||
revokeApiKey?: ApiKey
|
||||
assignRoleToApiKey: Scalars['Boolean']
|
||||
createObjectEvent: Analytics
|
||||
trackAnalytics: Analytics
|
||||
skipSyncEmailOnboardingStep: OnboardingStepSuccess
|
||||
skipBookOnboardingStep: OnboardingStepSuccess
|
||||
checkoutSession: BillingSession
|
||||
@@ -2776,6 +2778,7 @@ export interface Mutation {
|
||||
createOneAgent: Agent
|
||||
updateOneAgent: Agent
|
||||
deleteOneAgent: Agent
|
||||
deleteConnectedAccount: ConnectedAccountPublicDTO
|
||||
updateWorkspaceMemberRole: WorkspaceMember
|
||||
createOneRole: Role
|
||||
updateOneRole: Role
|
||||
@@ -2786,7 +2789,6 @@ export interface Mutation {
|
||||
upsertRowLevelPermissionPredicates: UpsertRowLevelPermissionPredicatesResult
|
||||
assignRoleToAgent: Scalars['Boolean']
|
||||
removeRoleFromAgent: Scalars['Boolean']
|
||||
deleteConnectedAccount: ConnectedAccountPublicDTO
|
||||
runAgent: RunAgentResult
|
||||
createWebhook: Webhook
|
||||
updateWebhook: Webhook
|
||||
@@ -2866,8 +2868,6 @@ export interface Mutation {
|
||||
createSAMLIdentityProvider: SetupSso
|
||||
deleteSSOIdentityProvider: DeleteSso
|
||||
editSSOIdentityProvider: EditSso
|
||||
createObjectEvent: Analytics
|
||||
trackAnalytics: Analytics
|
||||
duplicateDashboard: DuplicatedDashboard
|
||||
impersonate: Impersonate
|
||||
sendEmail: SendEmailOutput
|
||||
@@ -2892,17 +2892,16 @@ export interface Mutation {
|
||||
__typename: 'Mutation'
|
||||
}
|
||||
|
||||
export type WorkspaceMigrationActionType = 'delete' | 'create' | 'update'
|
||||
|
||||
export type AnalyticsType = 'PAGEVIEW' | 'TRACK'
|
||||
|
||||
export type WorkspaceMigrationActionType = 'delete' | 'create' | 'update'
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
@@ -4050,6 +4049,13 @@ 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
|
||||
@@ -5358,13 +5364,6 @@ 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
|
||||
@@ -5672,8 +5671,8 @@ export interface QueryGenqlSelection{
|
||||
filter: IndexFilter} })
|
||||
findManyAgents?: AgentGenqlSelection
|
||||
findOneAgent?: (AgentGenqlSelection & { __args: {input: AgentIdInput} })
|
||||
getRoles?: RoleGenqlSelection
|
||||
myConnectedAccounts?: ConnectedAccountPublicDTOGenqlSelection
|
||||
getRoles?: RoleGenqlSelection
|
||||
getToolIndex?: ToolIndexEntryGenqlSelection
|
||||
getToolInputSchema?: { __args: {toolName: Scalars['String']} }
|
||||
webhooks?: WebhookGenqlSelection
|
||||
@@ -5812,6 +5811,8 @@ 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)} })
|
||||
@@ -5864,6 +5865,7 @@ export interface MutationGenqlSelection{
|
||||
createOneAgent?: (AgentGenqlSelection & { __args: {input: CreateAgentInput} })
|
||||
updateOneAgent?: (AgentGenqlSelection & { __args: {input: UpdateAgentInput} })
|
||||
deleteOneAgent?: (AgentGenqlSelection & { __args: {input: AgentIdInput} })
|
||||
deleteConnectedAccount?: (ConnectedAccountPublicDTOGenqlSelection & { __args: {id: Scalars['UUID']} })
|
||||
updateWorkspaceMemberRole?: (WorkspaceMemberGenqlSelection & { __args: {workspaceMemberId: Scalars['UUID'], roleId: Scalars['UUID']} })
|
||||
createOneRole?: (RoleGenqlSelection & { __args: {createRoleInput: CreateRoleInput} })
|
||||
updateOneRole?: (RoleGenqlSelection & { __args: {updateRoleInput: UpdateRoleInput} })
|
||||
@@ -5874,7 +5876,6 @@ export interface MutationGenqlSelection{
|
||||
upsertRowLevelPermissionPredicates?: (UpsertRowLevelPermissionPredicatesResultGenqlSelection & { __args: {input: UpsertRowLevelPermissionPredicatesInput} })
|
||||
assignRoleToAgent?: { __args: {agentId: Scalars['UUID'], roleId: Scalars['UUID']} }
|
||||
removeRoleFromAgent?: { __args: {agentId: Scalars['UUID']} }
|
||||
deleteConnectedAccount?: (ConnectedAccountPublicDTOGenqlSelection & { __args: {id: Scalars['UUID']} })
|
||||
runAgent?: (RunAgentResultGenqlSelection & { __args: {input: RunAgentInput} })
|
||||
createWebhook?: (WebhookGenqlSelection & { __args: {input: CreateWebhookInput} })
|
||||
updateWebhook?: (WebhookGenqlSelection & { __args: {input: UpdateWebhookInput} })
|
||||
@@ -5954,8 +5955,6 @@ 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} })
|
||||
@@ -5973,7 +5972,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'], dryRun?: (Scalars['Boolean'] | null)} })
|
||||
syncApplication?: (WorkspaceMigrationGenqlSelection & { __args: {manifest: Scalars['JSON']} })
|
||||
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']} })
|
||||
@@ -6357,7 +6356,6 @@ 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
|
||||
}
|
||||
@@ -7005,6 +7003,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 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"')
|
||||
@@ -8133,14 +8139,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 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"')
|
||||
@@ -8992,17 +8990,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,
|
||||
|
||||
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 70 KiB |
@@ -3315,7 +3315,6 @@ export type MutationStopAgentChatStreamArgs = {
|
||||
|
||||
|
||||
export type MutationSyncApplicationArgs = {
|
||||
dryRun?: InputMaybe<Scalars['Boolean']>;
|
||||
manifest: Scalars['JSON'];
|
||||
};
|
||||
|
||||
@@ -4933,18 +4932,12 @@ 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;
|
||||
};
|
||||
@@ -7410,14 +7403,7 @@ 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 }>, 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 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 UpdateLabPublicFeatureFlagMutationVariables = Exact<{
|
||||
input: UpdateLabPublicFeatureFlagInput;
|
||||
@@ -8268,8 +8254,7 @@ 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":"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 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 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>;
|
||||
|
||||
@@ -8,7 +8,6 @@ import { originalDragSelectionComponentState } from '@/object-record/record-drag
|
||||
|
||||
import { RECORD_INDEX_REMOVE_SORTING_MODAL_ID } from '@/object-record/record-index/constants/RecordIndexRemoveSortingModalId';
|
||||
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
|
||||
import { getBoardCardDropBehavior } from '@/object-record/record-board/utils/getBoardCardDropBehavior';
|
||||
import { useModal } from '@/ui/layout/modal/hooks/useModal';
|
||||
import { useAtomComponentSelectorCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorCallbackState';
|
||||
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
|
||||
@@ -83,13 +82,8 @@ export const RecordBoardDragDropContext = ({
|
||||
}
|
||||
|
||||
const existingRecordSorts = store.get(currentRecordSorts);
|
||||
const boardCardDropBehavior = getBoardCardDropBehavior({
|
||||
hasRecordSorts: existingRecordSorts.length > 0,
|
||||
sourceDroppableId: result.source.droppableId,
|
||||
destinationDroppableId: result.destination.droppableId,
|
||||
});
|
||||
|
||||
if (boardCardDropBehavior.shouldBlockDrop) {
|
||||
if (existingRecordSorts.length > 0) {
|
||||
store.set(isRecordBoardDropProcessingCallbackState, false);
|
||||
endRecordDrag();
|
||||
openModal(RECORD_INDEX_REMOVE_SORTING_MODAL_ID);
|
||||
@@ -97,9 +91,7 @@ export const RecordBoardDragDropContext = ({
|
||||
}
|
||||
|
||||
try {
|
||||
processBoardCardDrop(result, originalDragSelection, {
|
||||
shouldUpdatePosition: boardCardDropBehavior.shouldUpdatePosition,
|
||||
});
|
||||
processBoardCardDrop(result, originalDragSelection);
|
||||
} catch (error) {
|
||||
store.set(isRecordBoardDropProcessingCallbackState, false);
|
||||
endRecordDrag();
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { getBoardCardDropBehavior } from '@/object-record/record-board/utils/getBoardCardDropBehavior';
|
||||
|
||||
describe('getBoardCardDropBehavior', () => {
|
||||
it('should block same-column drops when record sorting is active', () => {
|
||||
expect(
|
||||
getBoardCardDropBehavior({
|
||||
hasRecordSorts: true,
|
||||
sourceDroppableId: 'new',
|
||||
destinationDroppableId: 'new',
|
||||
}),
|
||||
).toEqual({
|
||||
shouldBlockDrop: true,
|
||||
shouldUpdatePosition: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow cross-column drops without position updates when record sorting is active', () => {
|
||||
expect(
|
||||
getBoardCardDropBehavior({
|
||||
hasRecordSorts: true,
|
||||
sourceDroppableId: 'new',
|
||||
destinationDroppableId: 'won',
|
||||
}),
|
||||
).toEqual({
|
||||
shouldBlockDrop: false,
|
||||
shouldUpdatePosition: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow drops with position updates when record sorting is inactive', () => {
|
||||
expect(
|
||||
getBoardCardDropBehavior({
|
||||
hasRecordSorts: false,
|
||||
sourceDroppableId: 'new',
|
||||
destinationDroppableId: 'won',
|
||||
}),
|
||||
).toEqual({
|
||||
shouldBlockDrop: false,
|
||||
shouldUpdatePosition: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
export const getBoardCardDropBehavior = ({
|
||||
hasRecordSorts,
|
||||
sourceDroppableId,
|
||||
destinationDroppableId,
|
||||
}: {
|
||||
hasRecordSorts: boolean;
|
||||
sourceDroppableId: string;
|
||||
destinationDroppableId: string;
|
||||
}) => {
|
||||
const isMovingInsideSameRecordGroup =
|
||||
sourceDroppableId === destinationDroppableId;
|
||||
|
||||
return {
|
||||
shouldBlockDrop: hasRecordSorts && isMovingInsideSameRecordGroup,
|
||||
shouldUpdatePosition: !hasRecordSorts,
|
||||
};
|
||||
};
|
||||
@@ -40,15 +40,9 @@ export const useProcessBoardCardDrop = () => {
|
||||
);
|
||||
|
||||
const processBoardCardDrop = useCallback(
|
||||
(
|
||||
boardCardDropResult: DropResult,
|
||||
selectedRecordIds: string[],
|
||||
options?: { shouldUpdatePosition?: boolean },
|
||||
) => {
|
||||
(boardCardDropResult: DropResult, selectedRecordIds: string[]) => {
|
||||
if (!isDefined(selectFieldMetadataItem)) return;
|
||||
|
||||
const shouldUpdatePosition = options?.shouldUpdatePosition ?? true;
|
||||
|
||||
processGroupDrop({
|
||||
groupDropResult: boardCardDropResult,
|
||||
store,
|
||||
@@ -57,10 +51,7 @@ export const useProcessBoardCardDrop = () => {
|
||||
recordIndexRecordIdsByGroupCallbackFamilyState,
|
||||
onUpdateRecord: ({ recordId, position }, targetRecordGroupValue) => {
|
||||
updateDroppedRecordOnBoard(
|
||||
{
|
||||
recordId,
|
||||
position: shouldUpdatePosition ? position : undefined,
|
||||
},
|
||||
{ recordId, position },
|
||||
targetRecordGroupValue,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -45,6 +45,10 @@ export const useUpdateDroppedRecordOnBoard = () => {
|
||||
recordStoreFamilyState.atomFamily(recordId),
|
||||
) as Record<string, unknown> | null | undefined;
|
||||
|
||||
if (!isDefined(newPosition)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isDefined(initialRecord)) {
|
||||
return;
|
||||
}
|
||||
@@ -90,10 +94,7 @@ export const useUpdateDroppedRecordOnBoard = () => {
|
||||
|
||||
const isSamePosition = initialRecord.position === newPosition;
|
||||
|
||||
if (
|
||||
movingInsideSameRecordGroup &&
|
||||
(!isDefined(newPosition) || isSamePosition)
|
||||
) {
|
||||
if (movingInsideSameRecordGroup && isSamePosition) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -125,32 +126,25 @@ export const useUpdateDroppedRecordOnBoard = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (isDefined(newPosition)) {
|
||||
const targetGroupRecordsWithIds = extractRecordPositions(
|
||||
currentRecordIdsInTargetRecordGroup,
|
||||
store,
|
||||
);
|
||||
const targetGroupRecordsWithIds = extractRecordPositions(
|
||||
currentRecordIdsInTargetRecordGroup,
|
||||
store,
|
||||
);
|
||||
|
||||
const newTargetRecordGroupWithIds = [
|
||||
...targetGroupRecordsWithIds,
|
||||
{
|
||||
id: recordId,
|
||||
position: newPosition,
|
||||
},
|
||||
];
|
||||
const newTargetRecordGroupWithIds = [
|
||||
...targetGroupRecordsWithIds,
|
||||
{
|
||||
id: recordId,
|
||||
position: newPosition,
|
||||
},
|
||||
];
|
||||
|
||||
newTargetRecordGroupWithIds.sort(sortByProperty('position', 'asc'));
|
||||
newTargetRecordGroupWithIds.sort(sortByProperty('position', 'asc'));
|
||||
|
||||
store.set(
|
||||
recordIndexRecordIdsByGroupCallbackFamilyState(targetRecordGroupId),
|
||||
newTargetRecordGroupWithIds.map((record) => record.id),
|
||||
);
|
||||
} else {
|
||||
store.set(
|
||||
recordIndexRecordIdsByGroupCallbackFamilyState(targetRecordGroupId),
|
||||
[...currentRecordIdsInTargetRecordGroup, recordId],
|
||||
);
|
||||
}
|
||||
store.set(
|
||||
recordIndexRecordIdsByGroupCallbackFamilyState(targetRecordGroupId),
|
||||
newTargetRecordGroupWithIds.map((record) => record.id),
|
||||
);
|
||||
|
||||
upsertRecordsInStore({
|
||||
partialRecords: [
|
||||
@@ -161,7 +155,7 @@ export const useUpdateDroppedRecordOnBoard = () => {
|
||||
(initialRecord as { __typename?: string })?.__typename ??
|
||||
'Record',
|
||||
[selectFieldMetadataItem.name]: targetRecordGroupValue,
|
||||
...(isDefined(newPosition) && { position: newPosition }),
|
||||
position: newPosition,
|
||||
} as ObjectRecord,
|
||||
],
|
||||
});
|
||||
@@ -170,7 +164,7 @@ export const useUpdateDroppedRecordOnBoard = () => {
|
||||
idToUpdate: recordId,
|
||||
updateOneRecordInput: {
|
||||
[selectFieldMetadataItem.name]: targetRecordGroupValue,
|
||||
...(isDefined(newPosition) && { position: newPosition }),
|
||||
position: newPosition,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
@@ -1 +1,4 @@
|
||||
export { SettingsDatePickerInput as EventLogDatePickerInput } from '@/settings/components/SettingsDatePickerInput';
|
||||
export {
|
||||
SettingsDatePickerInput as EventLogDatePickerInput,
|
||||
type SettingsDatePickerInputProps as EventLogDatePickerInputProps,
|
||||
} from '@/settings/components/SettingsDatePickerInput';
|
||||
|
||||
@@ -12,16 +12,18 @@ 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,
|
||||
type EventLogTable,
|
||||
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[];
|
||||
@@ -104,6 +106,9 @@ 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>>(() =>
|
||||
@@ -112,6 +117,7 @@ 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])),
|
||||
@@ -236,21 +242,85 @@ export const EventLogResultsTable = ({
|
||||
</StyledResizableHeaderContainer>
|
||||
))}
|
||||
</TableRow>
|
||||
{records.map((record) => (
|
||||
{records.map((record, index) => (
|
||||
<TableRow
|
||||
key={`${record.timestamp}-${record.event}`}
|
||||
key={`${record.timestamp}-${record.event}-${index}`}
|
||||
gridTemplateColumns={gridTemplateColumns}
|
||||
>
|
||||
{baseColumns.map((column) => (
|
||||
<TableCell
|
||||
key={column.id}
|
||||
overflow="hidden"
|
||||
textOverflow="ellipsis"
|
||||
whiteSpace="nowrap"
|
||||
>
|
||||
{column.renderCell(record)}
|
||||
</TableCell>
|
||||
))}
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
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';
|
||||
@@ -10,26 +8,34 @@ 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 = (
|
||||
Object.entries(TABLE_LABELS) as [EventLogTable, MessageDescriptor][]
|
||||
).map(([table, label]) => ({
|
||||
value: table,
|
||||
label: t(label),
|
||||
}));
|
||||
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`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Select
|
||||
|
||||
@@ -1,51 +1,24 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { 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 { SettingsOptionCardContentButton } from '@/settings/components/SettingsOptions/SettingsOptionCardContentButton';
|
||||
import { SettingsEnterpriseFeatureGateCard } from '@/settings/components/SettingsEnterpriseFeatureGateCard';
|
||||
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 {
|
||||
IconArrowUp,
|
||||
IconLock,
|
||||
IconPlayerPause,
|
||||
IconPlayerPlay,
|
||||
} from 'twenty-ui/display';
|
||||
import { Button, IconButton } from 'twenty-ui/input';
|
||||
import { IconRefresh } from 'twenty-ui/display';
|
||||
import { IconButton } from 'twenty-ui/input';
|
||||
import { Card } from 'twenty-ui/layout';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
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%;
|
||||
`;
|
||||
import { EventLogTable } from '~/generated-metadata/graphql';
|
||||
|
||||
const StyledCardContent = styled.div`
|
||||
display: flex;
|
||||
@@ -67,10 +40,8 @@ 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`
|
||||
@@ -79,9 +50,10 @@ 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`
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 480px;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
@@ -92,64 +64,45 @@ export const SettingsLogs = () => {
|
||||
|
||||
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
|
||||
const isClickHouseConfigured = useAtomStateValue(isClickHouseConfiguredState);
|
||||
const billing = useAtomStateValue(billingState);
|
||||
const navigateSettings = useNavigateSettings();
|
||||
|
||||
const isBillingEnabled = billing?.isBillingEnabled ?? false;
|
||||
const hasAuditLogsEntitlement =
|
||||
currentWorkspace?.billingEntitlements?.some(
|
||||
(entitlement) =>
|
||||
entitlement.key === BillingEntitlementKey.AUDIT_LOGS &&
|
||||
entitlement.value,
|
||||
) === true;
|
||||
const hasEnterpriseAccess =
|
||||
currentWorkspace?.hasValidSignedEnterpriseKey === 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 || hasAuditLogsEntitlement);
|
||||
isClickHouseConfigured && (isApplicationLog || hasEnterpriseAccess);
|
||||
|
||||
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,
|
||||
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,
|
||||
},
|
||||
{ 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],
|
||||
first: RECORDS_PER_PAGE,
|
||||
},
|
||||
{ skip: !canQuery },
|
||||
);
|
||||
|
||||
const handleTableChange = (table: EventLogTable) => {
|
||||
@@ -161,63 +114,39 @@ 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 && !hasAuditLogsEntitlement) {
|
||||
return renderUpgradeCard();
|
||||
if (!isApplicationLog && !hasEnterpriseAccess) {
|
||||
return (
|
||||
<SettingsEnterpriseFeatureGateCard
|
||||
title={t`Enterprise feature`}
|
||||
description={t`Upgrade to Enterprise to access this log type.`}
|
||||
buttonTitle={t`Activate`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isClickHouseConfigured) {
|
||||
return (
|
||||
<SettingsEmptyPlaceholder>
|
||||
{t`Logs require ClickHouse to be configured. Please contact your administrator.`}
|
||||
{t`Audit 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 logs. Please try again.`}
|
||||
{t`Something went wrong while loading audit logs. Please try again.`}
|
||||
</SettingsEmptyPlaceholder>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledResults>
|
||||
<StyledRecordCount>{t`${displayedRecords.length} of ${totalCount + liveRecords.length}`}</StyledRecordCount>
|
||||
<StyledRecordCount>{t`${records.length} of ${totalCount}`}</StyledRecordCount>
|
||||
<StyledTableWrapper>
|
||||
<EventLogResultsTable
|
||||
records={displayedRecords}
|
||||
records={records}
|
||||
loading={loading}
|
||||
hasNextPage={hasNextPage}
|
||||
onLoadMore={loadMore}
|
||||
@@ -229,7 +158,7 @@ export const SettingsLogs = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledRoot>
|
||||
<>
|
||||
<Card rounded fullWidth>
|
||||
<StyledCardContent>
|
||||
<StyledSelectorRow>
|
||||
@@ -239,15 +168,17 @@ export const SettingsLogs = () => {
|
||||
onChange={handleTableChange}
|
||||
/>
|
||||
</StyledSelectorGrow>
|
||||
{canQuery && (
|
||||
<IconButton
|
||||
Icon={isPaused ? IconPlayerPlay : IconPlayerPause}
|
||||
variant="secondary"
|
||||
size="medium"
|
||||
ariaLabel={isPaused ? t`Resume` : t`Pause`}
|
||||
onClick={() => setIsPaused((previous) => !previous)}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
Icon={IconRefresh}
|
||||
variant="secondary"
|
||||
size="medium"
|
||||
ariaLabel={t`Refresh`}
|
||||
onClick={() => {
|
||||
if (canQuery) {
|
||||
void refetch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</StyledSelectorRow>
|
||||
<EventLogFilters
|
||||
table={selectedTable}
|
||||
@@ -258,6 +189,6 @@ export const SettingsLogs = () => {
|
||||
</Card>
|
||||
|
||||
{renderResults()}
|
||||
</StyledRoot>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ export const GET_EVENT_LOGS = gql`
|
||||
properties
|
||||
recordId
|
||||
objectMetadataId
|
||||
isCustom
|
||||
}
|
||||
totalCount
|
||||
pageInfo {
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -1,68 +0,0 @@
|
||||
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, fetchMore } = useQuery<
|
||||
const { data, loading, error, refetch, fetchMore } = useQuery<
|
||||
EventLogsData,
|
||||
EventLogsVariables
|
||||
>(GET_EVENT_LOGS, {
|
||||
@@ -70,6 +70,7 @@ export const useEventLogs = (
|
||||
hasNextPage,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
loadMore,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
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;
|
||||
};
|
||||
@@ -1,125 +0,0 @@
|
||||
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;
|
||||
};
|
||||
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 111 KiB |
@@ -0,0 +1,169 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 90 KiB |
@@ -50,6 +50,10 @@ export const SettingsGeneral = () => {
|
||||
);
|
||||
|
||||
const renderActiveTabContent = () => {
|
||||
if (activeTabId === GENERAL_TAB_LOGS) {
|
||||
return <SettingsLogs />;
|
||||
}
|
||||
|
||||
if (activeTabId === GENERAL_TAB_SECURITY) {
|
||||
return <SettingsSecuritySettings />;
|
||||
}
|
||||
@@ -93,13 +97,7 @@ export const SettingsGeneral = () => {
|
||||
}
|
||||
links={[{ children: t`Workspace` }, { children: t`General` }]}
|
||||
>
|
||||
{activeTabId === GENERAL_TAB_LOGS ? (
|
||||
<SettingsLogs />
|
||||
) : (
|
||||
<SettingsPageContainer>
|
||||
{renderActiveTabContent()}
|
||||
</SettingsPageContainer>
|
||||
)}
|
||||
<SettingsPageContainer>{renderActiveTabContent()}</SettingsPageContainer>
|
||||
</SettingsPageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 94 KiB |
@@ -215,11 +215,63 @@ Apollo error formatting, and the icon/theme-color pickers tied to Twenty's icon
|
||||
- **Workbench** — Storybook (`@storybook/react-vite`). Every component has stories covering variants, sizes, and states (via `storybook-addon-pseudo-states`), in light and dark, with `autodocs`.
|
||||
- **Functional** — component/interaction tests via `@storybook/addon-vitest` (real browser); unit tests (Jest) for hooks/utilities; coverage gate via `@storybook/addon-coverage`.
|
||||
- **Accessibility** — Storybook a11y addon (axe-core) with `parameters.a11y.test = 'error'` so violations fail CI.
|
||||
- **Visual parity** — visual regression (Chromatic or test-runner image snapshots) plus side-by-side stories rendering the old and new component with identical props; a pixel-diff threshold is the per-component acceptance gate.
|
||||
- **Visual parity** — visual regression via Argos (self-hosted) plus a cross-package comparison project that diffs `twenty-new-ui` stories against `twenty-ui` stories with identical names; a pixel-diff threshold is the per-component acceptance gate. See [Visual regression](#visual-regression) below.
|
||||
- **Performance & size** — `size-limit` per entry point with budgets; tree-shaking fixtures (importing one component must not pull the library); build-time tracking; render benchmarks via React Profiler; load-time via Lighthouse/Playwright on the built Storybook. As one concrete benchmark, a dedicated **stress story** renders a very large number of a single component (e.g. 10,000 buttons) and measures total render time — compared against the `twenty-ui` equivalent and gated against a budget to catch per-instance overhead regressions.
|
||||
|
||||
CI surfaces a per-PR diff table (`twenty-ui` vs `twenty-new-ui`) for size, a11y, and visual changes.
|
||||
|
||||
## Visual regression
|
||||
|
||||
Two Argos projects (on argos.twenty-internal.com) provide visual regression in CI:
|
||||
|
||||
1. **`twenty-new-ui`** — pixel diff of `twenty-new-ui` stories against the `main` branch baseline. Catches regressions introduced by a PR.
|
||||
2. **`twenty-ui-vs-new-ui`** — cross-package comparison. The baseline is always `twenty-ui` screenshots from `main`; PR builds upload `twenty-new-ui` screenshots and diff them against the `twenty-ui` baseline. This shows exactly which components still differ between the two implementations.
|
||||
|
||||
For the cross-package comparison to produce meaningful diffs, stories in `twenty-new-ui` must use the **same title hierarchy** as `twenty-ui` (e.g. `UI/Input/Toggle`).
|
||||
|
||||
### Local visual diff
|
||||
|
||||
Run a pixel diff of `twenty-new-ui` components against `twenty-ui` using the self-hosted Argos instance.
|
||||
|
||||
**Prerequisites:**
|
||||
- AWS SSO configured and logged in (`aws sso login --profile twenty-dev`)
|
||||
- `twenty-infra/super-cli` cloned (sibling of this repo)
|
||||
|
||||
**1. Start the Argos tunnel**
|
||||
|
||||
In the `twenty-infra/super-cli` directory:
|
||||
|
||||
yarn cli argos-tunnel
|
||||
|
||||
This port-forwards the Argos service to `http://127.0.0.1:4002`.
|
||||
Wait until the CLI shows "Argos tunnel is running".
|
||||
|
||||
**2. Set your Argos token**
|
||||
|
||||
Create a `.env` file in `packages/twenty-new-ui/` (gitignored):
|
||||
|
||||
ARGOS_TOKEN=<your-token-from-argos-project-settings>
|
||||
|
||||
**3. Run the visual diff**
|
||||
|
||||
From the repo root:
|
||||
|
||||
npx nx storybook:visual-diff twenty-new-ui
|
||||
|
||||
This builds Storybook, captures screenshots of every story, and uploads
|
||||
them to Argos with build name `<username>/twenty-new-ui`. The diff
|
||||
compares against the latest approved baseline.
|
||||
|
||||
To run `twenty-ui`'s visual diff in the same Argos instance (to build the
|
||||
cross-package comparison baseline):
|
||||
|
||||
npx nx storybook:visual-diff twenty-ui
|
||||
|
||||
**4. View results**
|
||||
|
||||
Open `http://127.0.0.1:4002` in your browser (while the tunnel is running)
|
||||
to review diffs.
|
||||
|
||||
## Build & publishing
|
||||
|
||||
- Vite library mode, dual ESM/CJS, `vite-plugin-dts`, `vite-plugin-svgr`; SCSS via Vite's built-in `sass`; no Babel.
|
||||
@@ -267,7 +319,7 @@ a passing visual-parity diff, and a within-budget size entry.
|
||||
1. Published package name: `twenty-new-ui` now, renamed to `twenty-ui` at cut-over (Phase 6).
|
||||
2. Styling: confirm SCSS Modules vs vanilla-extract vs plain CSS Modules.
|
||||
3. Variants helper: `clsx` + `data-*` vs `cva`.
|
||||
4. Visual regression tooling: Chromatic vs self-hosted image snapshots.
|
||||
4. ~~Visual regression tooling: Chromatic vs self-hosted image snapshots.~~ **Resolved:** Argos (self-hosted at argos.twenty-internal.com). See [Visual regression](#visual-regression).
|
||||
5. How aggressively to drop `framer-motion` in favor of CSS/Base UI transitions.
|
||||
6. Scope of `assets` / `testing` / `json-visualizer`: port verbatim or modernize.
|
||||
7. Where to draw the generic-vs-app-specific line for `modules/ui`, and whether hybrid components live as a headless core in `twenty-new-ui` with a thin app wrapper in `twenty-front`.
|
||||
|
||||
@@ -7,7 +7,6 @@ import chalk from 'chalk';
|
||||
export type AppDevOnceCommandOptions = {
|
||||
appPath?: string;
|
||||
verbose?: boolean;
|
||||
dryRun?: boolean;
|
||||
};
|
||||
|
||||
export class AppDevOnceCommand {
|
||||
@@ -18,17 +17,12 @@ export class AppDevOnceCommand {
|
||||
|
||||
const remoteName = ConfigService.getActiveRemote();
|
||||
|
||||
console.log(
|
||||
chalk.blue(
|
||||
`${options.dryRun ? 'Previewing application diff' : 'Syncing application'} on ${remoteName}...`,
|
||||
),
|
||||
);
|
||||
console.log(chalk.blue(`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)),
|
||||
});
|
||||
|
||||
@@ -37,16 +31,6 @@ 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,7 +22,6 @@ export const registerDevCommands = (program: Command): void => {
|
||||
verbose?: boolean;
|
||||
debug?: boolean;
|
||||
debounceMs?: string;
|
||||
dryRun?: boolean;
|
||||
},
|
||||
) => {
|
||||
const commonOptions = {
|
||||
@@ -34,10 +33,7 @@ export const registerDevCommands = (program: Command): void => {
|
||||
};
|
||||
|
||||
if (options.once) {
|
||||
await devOnceCommand.execute({
|
||||
...commonOptions,
|
||||
dryRun: options.dryRun,
|
||||
});
|
||||
await devOnceCommand.execute(commonOptions);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -52,10 +48,6 @@ 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,6 +1,5 @@
|
||||
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 {
|
||||
@@ -14,7 +13,6 @@ 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';
|
||||
@@ -24,7 +22,6 @@ import { APP_ERROR_CODES, type CommandResult } from '@/cli/types';
|
||||
export type AppDevOnceOptions = {
|
||||
appPath: string;
|
||||
verbose?: boolean;
|
||||
dryRun?: boolean;
|
||||
onProgress?: (message: string) => void;
|
||||
};
|
||||
|
||||
@@ -35,19 +32,10 @@ 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, dryRun } = options;
|
||||
const { appPath, onProgress, verbose } = options;
|
||||
|
||||
onProgress?.('Checking server...');
|
||||
|
||||
@@ -132,47 +120,6 @@ 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();
|
||||
@@ -265,8 +212,6 @@ const innerAppDevOnce = async (
|
||||
};
|
||||
}
|
||||
|
||||
reportMetadataChanges(syncResult.data, onProgress);
|
||||
|
||||
onProgress?.('Generating API client...');
|
||||
|
||||
try {
|
||||
|
||||
@@ -73,16 +73,13 @@ export class ApiService {
|
||||
return this.applicationApi.createDevelopmentApplication(...args);
|
||||
}
|
||||
|
||||
syncApplication(
|
||||
manifest: Manifest,
|
||||
options?: { dryRun?: boolean },
|
||||
): Promise<
|
||||
syncApplication(manifest: Manifest): Promise<
|
||||
ApiResponse<{
|
||||
applicationUniversalIdentifier: string;
|
||||
actions: SyncAction[];
|
||||
}>
|
||||
> {
|
||||
return this.applicationApi.syncApplication(manifest, options);
|
||||
return this.applicationApi.syncApplication(manifest);
|
||||
}
|
||||
|
||||
uninstallApplication(universalIdentifier: string): Promise<ApiResponse> {
|
||||
|
||||
@@ -252,10 +252,7 @@ export class ApplicationApi {
|
||||
}
|
||||
}
|
||||
|
||||
async syncApplication(
|
||||
manifest: Manifest,
|
||||
options?: { dryRun?: boolean },
|
||||
): Promise<
|
||||
async syncApplication(manifest: Manifest): Promise<
|
||||
ApiResponse<{
|
||||
applicationUniversalIdentifier: string;
|
||||
actions: SyncAction[];
|
||||
@@ -263,15 +260,15 @@ export class ApplicationApi {
|
||||
> {
|
||||
try {
|
||||
const mutation = `
|
||||
mutation SyncApplication($manifest: JSON!, $dryRun: Boolean) {
|
||||
syncApplication(manifest: $manifest, dryRun: $dryRun) {
|
||||
mutation SyncApplication($manifest: JSON!) {
|
||||
syncApplication(manifest: $manifest) {
|
||||
applicationUniversalIdentifier
|
||||
actions
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const variables = { manifest, dryRun: options?.dryRun ?? false };
|
||||
const variables = { manifest };
|
||||
|
||||
const response: AxiosResponse = await this.client.post(
|
||||
'/metadata',
|
||||
|
||||
@@ -10,12 +10,6 @@ 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,17 +24,15 @@ const getActionLabel = (action: SyncAction): string => {
|
||||
};
|
||||
|
||||
export const formatSyncActionsSummary = (
|
||||
actions: SyncAction[] | undefined,
|
||||
actions: SyncAction[],
|
||||
): OrchestratorStateStepEvent[] => {
|
||||
const definedActions = actions ?? [];
|
||||
|
||||
if (definedActions.length === 0) {
|
||||
if (actions.length === 0) {
|
||||
return [{ message: 'No metadata changes', status: 'info' }];
|
||||
}
|
||||
|
||||
const counts = { create: 0, update: 0, delete: 0 };
|
||||
|
||||
for (const action of definedActions) {
|
||||
for (const action of actions) {
|
||||
counts[action.type] += 1;
|
||||
}
|
||||
|
||||
@@ -48,7 +46,7 @@ export const formatSyncActionsSummary = (
|
||||
{ message: `Metadata changes: ${summaryParts.join(', ')}`, status: 'info' },
|
||||
];
|
||||
|
||||
const visibleActions = definedActions.slice(0, MAX_DETAIL_LINES);
|
||||
const visibleActions = actions.slice(0, MAX_DETAIL_LINES);
|
||||
|
||||
for (const action of visibleActions) {
|
||||
events.push({
|
||||
@@ -57,7 +55,7 @@ export const formatSyncActionsSummary = (
|
||||
});
|
||||
}
|
||||
|
||||
const hiddenCount = definedActions.length - visibleActions.length;
|
||||
const hiddenCount = actions.length - visibleActions.length;
|
||||
|
||||
if (hiddenCount > 0) {
|
||||
events.push({
|
||||
|
||||
@@ -35,6 +35,7 @@ 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,10 +105,6 @@ describe('ClickHouseService', () => {
|
||||
table: 'test_table',
|
||||
values: testData,
|
||||
format: 'JSONEachRow',
|
||||
clickhouse_settings: {
|
||||
async_insert: 1,
|
||||
wait_for_async_insert: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -28,6 +28,10 @@ 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 },
|
||||
});
|
||||
@@ -84,6 +88,10 @@ 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 },
|
||||
});
|
||||
@@ -274,10 +282,6 @@ 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,8 +12,3 @@ 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/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 { 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 { 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';
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
|
||||
import { ReassignWorkflowCreatorToEmailAccountOwnerCommand } from 'src/database/commands/upgrade-version-command/2-11/2-11-workspace-command-1799100000000-reassign-workflow-creator-to-email-account-owner.command';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ConnectedAccountEntity, UserWorkspaceEntity]),
|
||||
WorkspaceIteratorModule,
|
||||
],
|
||||
providers: [ReassignWorkflowCreatorToEmailAccountOwnerCommand],
|
||||
})
|
||||
export class V2_11_UpgradeVersionCommandModule {}
|
||||
@@ -1,253 +0,0 @@
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { Command } from 'nest-commander';
|
||||
import { isDefined, isValidUuid } from 'twenty-shared/utils';
|
||||
import { In, Repository } from 'typeorm';
|
||||
|
||||
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
|
||||
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
|
||||
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
|
||||
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import {
|
||||
WorkflowVersionStatus,
|
||||
type WorkflowVersionWorkspaceEntity,
|
||||
} from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
|
||||
import { type WorkflowWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity';
|
||||
import {
|
||||
type WorkflowAction,
|
||||
WorkflowActionType,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
|
||||
|
||||
const EMAIL_ACTION_TYPES: WorkflowActionType[] = [
|
||||
WorkflowActionType.SEND_EMAIL,
|
||||
WorkflowActionType.DRAFT_EMAIL,
|
||||
];
|
||||
|
||||
@RegisteredWorkspaceCommand('2.11.0', 1799100000000)
|
||||
@Command({
|
||||
name: 'upgrade:2-11:reassign-workflow-creator-to-email-account-owner',
|
||||
description:
|
||||
'Reassign the creator of active workflows whose email steps use a user-visibility connected account owned by another member, so workflow runs can resolve the account through the acting-user access check',
|
||||
})
|
||||
export class ReassignWorkflowCreatorToEmailAccountOwnerCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
|
||||
constructor(
|
||||
protected readonly workspaceIteratorService: WorkspaceIteratorService,
|
||||
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
|
||||
@InjectRepository(ConnectedAccountEntity)
|
||||
private readonly connectedAccountRepository: Repository<ConnectedAccountEntity>,
|
||||
@InjectRepository(UserWorkspaceEntity)
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
|
||||
) {
|
||||
super(workspaceIteratorService);
|
||||
}
|
||||
|
||||
override async runOnWorkspace({
|
||||
workspaceId,
|
||||
options,
|
||||
}: RunOnWorkspaceArgs): Promise<void> {
|
||||
const isDryRun = options.dryRun ?? false;
|
||||
|
||||
const accountIdsByWorkflowId =
|
||||
await this.getEmailStepAccountIdsByWorkflowId(workspaceId);
|
||||
|
||||
if (accountIdsByWorkflowId.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const referencedAccountIds = [
|
||||
...new Set([...accountIdsByWorkflowId.values()].flat()),
|
||||
];
|
||||
|
||||
const userVisibilityAccounts = await this.connectedAccountRepository.find({
|
||||
where: {
|
||||
id: In(referencedAccountIds),
|
||||
workspaceId,
|
||||
visibility: 'user',
|
||||
},
|
||||
});
|
||||
|
||||
if (userVisibilityAccounts.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const accountById = new Map(
|
||||
userVisibilityAccounts.map((account) => [account.id, account]),
|
||||
);
|
||||
|
||||
const workflowRepository =
|
||||
await this.globalWorkspaceOrmManager.getRepository<WorkflowWorkspaceEntity>(
|
||||
workspaceId,
|
||||
'workflow',
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
const workflows = await workflowRepository.find({
|
||||
where: { id: In([...accountIdsByWorkflowId.keys()]) },
|
||||
});
|
||||
|
||||
const memberByUserWorkspaceId = await this.getMemberByUserWorkspaceId({
|
||||
workspaceId,
|
||||
userWorkspaceIds: userVisibilityAccounts
|
||||
.map((account) => account.userWorkspaceId)
|
||||
.filter(isDefined),
|
||||
});
|
||||
|
||||
let reassignedCount = 0;
|
||||
|
||||
for (const workflow of workflows) {
|
||||
const ownerUserWorkspaceIds = [
|
||||
...new Set(
|
||||
(accountIdsByWorkflowId.get(workflow.id) ?? [])
|
||||
.map((accountId) => accountById.get(accountId)?.userWorkspaceId)
|
||||
.filter(isDefined),
|
||||
),
|
||||
];
|
||||
|
||||
if (ownerUserWorkspaceIds.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ownerUserWorkspaceIds.length > 1) {
|
||||
this.logger.warn(
|
||||
`Workflow ${workflow.id} in workspace ${workspaceId} references user-visibility accounts of ${ownerUserWorkspaceIds.length} different owners; skipping`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const ownerMember = memberByUserWorkspaceId.get(ownerUserWorkspaceIds[0]);
|
||||
|
||||
if (!isDefined(ownerMember)) {
|
||||
this.logger.warn(
|
||||
`Workflow ${workflow.id} in workspace ${workspaceId} references an account whose owner has no workspace member; skipping`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (workflow.createdBy?.workspaceMemberId === ownerMember.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const ownerFullName =
|
||||
`${ownerMember.name?.firstName ?? ''} ${ownerMember.name?.lastName ?? ''}`.trim();
|
||||
|
||||
this.logger.log(
|
||||
`${isDryRun ? '[DRY RUN] ' : ''}Reassigning creator of workflow ${workflow.id} in workspace ${workspaceId} to account owner member ${ownerMember.id}`,
|
||||
);
|
||||
|
||||
if (isDryRun) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await workflowRepository.update(workflow.id, {
|
||||
createdBy: {
|
||||
...workflow.createdBy,
|
||||
workspaceMemberId: ownerMember.id,
|
||||
name: ownerFullName,
|
||||
},
|
||||
});
|
||||
reassignedCount++;
|
||||
}
|
||||
|
||||
if (reassignedCount > 0) {
|
||||
this.logger.log(
|
||||
`Reassigned creator on ${reassignedCount} workflow(s) for workspace ${workspaceId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async getEmailStepAccountIdsByWorkflowId(
|
||||
workspaceId: string,
|
||||
): Promise<Map<string, string[]>> {
|
||||
const workflowVersionRepository =
|
||||
await this.globalWorkspaceOrmManager.getRepository<WorkflowVersionWorkspaceEntity>(
|
||||
workspaceId,
|
||||
'workflowVersion',
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
const activeVersions = await workflowVersionRepository.find({
|
||||
where: { status: WorkflowVersionStatus.ACTIVE },
|
||||
});
|
||||
|
||||
const accountIdsByWorkflowId = new Map<string, string[]>();
|
||||
|
||||
for (const version of activeVersions) {
|
||||
if (!Array.isArray(version.steps)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const accountIds = version.steps
|
||||
.filter((step: WorkflowAction) =>
|
||||
EMAIL_ACTION_TYPES.includes(step.type),
|
||||
)
|
||||
.map(
|
||||
(step: WorkflowAction) =>
|
||||
(step.settings?.input as { connectedAccountId?: string })
|
||||
?.connectedAccountId,
|
||||
)
|
||||
.filter(
|
||||
(accountId): accountId is string =>
|
||||
isNonEmptyString(accountId) && isValidUuid(accountId),
|
||||
);
|
||||
|
||||
if (accountIds.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
accountIdsByWorkflowId.set(version.workflowId, [
|
||||
...(accountIdsByWorkflowId.get(version.workflowId) ?? []),
|
||||
...accountIds,
|
||||
]);
|
||||
}
|
||||
|
||||
return accountIdsByWorkflowId;
|
||||
}
|
||||
|
||||
private async getMemberByUserWorkspaceId({
|
||||
workspaceId,
|
||||
userWorkspaceIds,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
userWorkspaceIds: string[];
|
||||
}): Promise<Map<string, WorkspaceMemberWorkspaceEntity>> {
|
||||
if (userWorkspaceIds.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const userWorkspaces = await this.userWorkspaceRepository.find({
|
||||
where: { id: In(userWorkspaceIds), workspaceId },
|
||||
});
|
||||
|
||||
const workspaceMemberRepository =
|
||||
await this.globalWorkspaceOrmManager.getRepository<WorkspaceMemberWorkspaceEntity>(
|
||||
workspaceId,
|
||||
'workspaceMember',
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
const members = await workspaceMemberRepository.find({
|
||||
where: { userId: In(userWorkspaces.map((uw) => uw.userId)) },
|
||||
});
|
||||
|
||||
const memberByUserId = new Map(
|
||||
members.map((member) => [member.userId, member]),
|
||||
);
|
||||
|
||||
return new Map(
|
||||
userWorkspaces
|
||||
.map(
|
||||
(userWorkspace): [string, WorkspaceMemberWorkspaceEntity] | null => {
|
||||
const member = memberByUserId.get(userWorkspace.userId);
|
||||
|
||||
return isDefined(member) ? [userWorkspace.id, member] : null;
|
||||
},
|
||||
)
|
||||
.filter(isDefined),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import { V2_7_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-
|
||||
import { V2_8_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-8/2-8-upgrade-version-command.module';
|
||||
import { V2_9_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-9/2-9-upgrade-version-command.module';
|
||||
import { V2_10_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-10/2-10-upgrade-version-command.module';
|
||||
import { V2_11_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-11/2-11-upgrade-version-command.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -30,7 +29,6 @@ import { V2_11_UpgradeVersionCommandModule } from 'src/database/commands/upgrade
|
||||
V2_8_UpgradeVersionCommandModule,
|
||||
V2_9_UpgradeVersionCommandModule,
|
||||
V2_10_UpgradeVersionCommandModule,
|
||||
V2_11_UpgradeVersionCommandModule,
|
||||
],
|
||||
})
|
||||
export class WorkspaceCommandProviderModule {}
|
||||
|
||||
@@ -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 { CreateEventLogFromInternalEvent } from 'src/engine/core-modules/event-logs/ingest/create-event-log-from-internal-event';
|
||||
import { CreateAuditLogFromInternalEvent } from 'src/engine/core-modules/audit/jobs/create-audit-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,22 +107,24 @@ export class EntityEventsToDbListener {
|
||||
),
|
||||
);
|
||||
|
||||
if (isAuditLogBatchEvent && action !== DatabaseEventAction.DESTROYED) {
|
||||
if (isAuditLogBatchEvent) {
|
||||
promises.push(
|
||||
this.entityEventsToDbQueueService.add<WorkspaceEventBatch<T>>(
|
||||
CreateEventLogFromInternalEvent.name,
|
||||
CreateAuditLogFromInternalEvent.name,
|
||||
batchEvent,
|
||||
),
|
||||
);
|
||||
|
||||
promises.push(
|
||||
this.entityEventsToDbQueueService.add<
|
||||
WorkspaceEventBatch<ObjectRecordNonDestructiveEvent>
|
||||
>(
|
||||
UpsertTimelineActivityFromInternalEvent.name,
|
||||
batchEvent as WorkspaceEventBatch<ObjectRecordNonDestructiveEvent>,
|
||||
),
|
||||
);
|
||||
if (action !== DatabaseEventAction.DESTROYED) {
|
||||
promises.push(
|
||||
this.entityEventsToDbQueueService.add<
|
||||
WorkspaceEventBatch<ObjectRecordNonDestructiveEvent>
|
||||
>(
|
||||
UpsertTimelineActivityFromInternalEvent.name,
|
||||
batchEvent as WorkspaceEventBatch<ObjectRecordNonDestructiveEvent>,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
@@ -2,18 +2,34 @@ 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 telemetryService: TelemetryService) {}
|
||||
constructor(
|
||||
private readonly auditService: AuditService,
|
||||
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,6 +3,7 @@ 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';
|
||||
@@ -18,6 +19,7 @@ import { EntityEventsToDbListener } from './listeners/entity-events-to-db.listen
|
||||
WorkspaceDataSourceModule,
|
||||
WorkspaceQueryHookModule,
|
||||
TypeOrmModule.forFeature([FeatureFlagEntity]),
|
||||
AuditModule,
|
||||
TelemetryModule,
|
||||
FileModule,
|
||||
RecordTransformerModule,
|
||||
|
||||
@@ -20,6 +20,7 @@ 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';
|
||||
@@ -66,6 +67,7 @@ import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspac
|
||||
TerminusModule,
|
||||
MetricsModule,
|
||||
FeatureFlagModule,
|
||||
AuditModule,
|
||||
TelemetryModule,
|
||||
ImpersonationModule,
|
||||
PermissionsModule,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const APPLICATION_LOG_DRIVER = Symbol('APPLICATION_LOG_DRIVER');
|
||||
@@ -0,0 +1,14 @@
|
||||
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();
|
||||
@@ -0,0 +1,14 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum ApplicationLogDriver {
|
||||
DISABLED = 'DISABLED',
|
||||
CONSOLE = 'CONSOLE',
|
||||
CLICKHOUSE = 'CLICKHOUSE',
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { type ApplicationLogEntry } from 'src/engine/core-modules/application-logs/interfaces/application-log-entry.interface';
|
||||
|
||||
export interface ApplicationLogDriverInterface {
|
||||
writeLogs(entries: ApplicationLogEntry[]): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { type ApplicationLogDriver } from 'src/engine/core-modules/application-logs/interfaces/application-log-driver.enum';
|
||||
|
||||
export type ApplicationLogsModuleOptions = {
|
||||
type: ApplicationLogDriver;
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type ParsedLogLine } from 'src/engine/core-modules/event-logs/producers/application-log/parsed-log-line.type';
|
||||
import { stripAnsiEscapes } from 'src/engine/core-modules/event-logs/producers/application-log/strip-ansi-escapes.util';
|
||||
import { type ParsedLogLine } from 'src/engine/core-modules/application-logs/types/parsed-log-line.type';
|
||||
import { stripAnsiEscapes } from 'src/engine/core-modules/application-logs/utils/strip-ansi-escapes.util';
|
||||
|
||||
// Matches: 2024-01-01T00:00:00.000Z INFO some message
|
||||
const LOG_LINE_REGEX =
|
||||
@@ -133,7 +133,7 @@ export class ApplicationDevelopmentResolver {
|
||||
|
||||
@Mutation(() => WorkspaceMigrationDTO)
|
||||
async syncApplication(
|
||||
@Args() { manifest, dryRun }: ApplicationInput,
|
||||
@Args() { manifest }: ApplicationInput,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<WorkspaceMigrationDTO> {
|
||||
await this.throttlePerApplication(
|
||||
@@ -141,21 +141,6 @@ 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,7 +7,4 @@ import { Manifest } from 'twenty-shared/application';
|
||||
export class ApplicationInput {
|
||||
@Field(() => GraphQLJSON, { nullable: false })
|
||||
manifest: Manifest;
|
||||
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
dryRun?: boolean;
|
||||
}
|
||||
|
||||
@@ -162,12 +162,10 @@ export class ApplicationManifestMigrationService {
|
||||
manifest,
|
||||
workspaceId,
|
||||
ownerFlatApplication,
|
||||
dryRun = false,
|
||||
}: {
|
||||
manifest: Manifest;
|
||||
workspaceId: string;
|
||||
ownerFlatApplication: FlatApplication;
|
||||
dryRun?: boolean;
|
||||
}): Promise<{
|
||||
workspaceMigration: WorkspaceMigration;
|
||||
hasSchemaMetadataChanged: boolean;
|
||||
@@ -227,7 +225,6 @@ export class ApplicationManifestMigrationService {
|
||||
workspaceId,
|
||||
dependencyAllFlatEntityMaps,
|
||||
additionalCacheDataMaps: { featureFlagsMap },
|
||||
dryRun,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -239,16 +236,14 @@ export class ApplicationManifestMigrationService {
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Metadata migration ${dryRun ? 'plan computed (dry run)' : 'completed'} for application ${ownerFlatApplication.universalIdentifier}`,
|
||||
`Metadata migration completed for application ${ownerFlatApplication.universalIdentifier}`,
|
||||
);
|
||||
|
||||
if (!dryRun) {
|
||||
await this.syncDefaultRoleAndSettingsCustomTab({
|
||||
manifest,
|
||||
workspaceId,
|
||||
ownerFlatApplication,
|
||||
});
|
||||
}
|
||||
await this.syncDefaultRoleAndSettingsCustomTab({
|
||||
manifest,
|
||||
workspaceId,
|
||||
ownerFlatApplication,
|
||||
});
|
||||
|
||||
return {
|
||||
workspaceMigration: validateAndBuildResult.workspaceMigration,
|
||||
|
||||
@@ -19,7 +19,6 @@ 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';
|
||||
@@ -49,17 +48,12 @@ export class ApplicationManifestResolver {
|
||||
},
|
||||
);
|
||||
|
||||
const workspaceMigration = enrichCreateWorkspaceMigrationActionsWithIds({
|
||||
await this.workspaceMigrationRunnerService.run({
|
||||
workspaceMigration: {
|
||||
actions: actions as AllUniversalWorkspaceMigrationAction[],
|
||||
applicationUniversalIdentifier:
|
||||
workspaceCustomFlatApplication.universalIdentifier,
|
||||
},
|
||||
idByUniversalIdentifierByMetadataName: {},
|
||||
});
|
||||
|
||||
await this.workspaceMigrationRunnerService.run({
|
||||
workspaceMigration,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
|
||||
@@ -41,38 +41,30 @@ export class ApplicationSyncService {
|
||||
workspaceId,
|
||||
manifest,
|
||||
applicationRegistrationId,
|
||||
dryRun = false,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
manifest: Manifest;
|
||||
applicationRegistrationId?: string;
|
||||
dryRun?: boolean;
|
||||
}): Promise<{
|
||||
workspaceMigration: WorkspaceMigration;
|
||||
hasSchemaMetadataChanged: boolean;
|
||||
}> {
|
||||
const ownerFlatApplication: FlatApplication = dryRun
|
||||
? await this.applicationService.findOneApplicationOrThrow({
|
||||
universalIdentifier: manifest.application.universalIdentifier,
|
||||
workspaceId,
|
||||
})
|
||||
: await this.syncApplication({
|
||||
workspaceId,
|
||||
manifest,
|
||||
applicationRegistrationId,
|
||||
});
|
||||
const application = await this.syncApplication({
|
||||
workspaceId,
|
||||
manifest,
|
||||
applicationRegistrationId,
|
||||
});
|
||||
|
||||
const ownerFlatApplication: FlatApplication = application;
|
||||
|
||||
const syncResult =
|
||||
await this.applicationManifestMigrationService.syncMetadataFromManifest({
|
||||
manifest,
|
||||
workspaceId,
|
||||
ownerFlatApplication,
|
||||
dryRun,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Application sync from manifest ${dryRun ? 'plan computed (dry run)' : 'completed'}`,
|
||||
);
|
||||
this.logger.log('Application sync from manifest completed');
|
||||
|
||||
return syncResult;
|
||||
}
|
||||
@@ -137,13 +129,20 @@ export class ApplicationSyncService {
|
||||
).toString('utf-8'),
|
||||
) as PackageJson;
|
||||
|
||||
const application = await this.applicationService.findOneApplicationOrThrow(
|
||||
const application = await this.applicationService.findByUniversalIdentifier(
|
||||
{
|
||||
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;
|
||||
|
||||
@@ -166,10 +165,17 @@ export class ApplicationSyncService {
|
||||
workspaceId: string;
|
||||
applicationUniversalIdentifier: string;
|
||||
}): Promise<WorkspaceMigration> {
|
||||
const application = await this.applicationService.findOneApplicationOrThrow(
|
||||
const application = await this.applicationService.findByUniversalIdentifier(
|
||||
{ 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.',
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,23 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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,19 +1,19 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import {
|
||||
EventLogEmitterException,
|
||||
EventLogEmitterExceptionCode,
|
||||
} from 'src/engine/core-modules/event-logs/emit/event-log-emitter.exception';
|
||||
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 { EventLogEmitterResolver } from './event-log-emitter.resolver';
|
||||
import { AuditResolver } from './audit.resolver';
|
||||
|
||||
import { EventLogEmitterService } from './event-log-emitter.service';
|
||||
import { AuditService } from './services/audit.service';
|
||||
|
||||
describe('EventLogEmitterResolver', () => {
|
||||
let resolver: EventLogEmitterResolver;
|
||||
let auditService: jest.Mocked<EventLogEmitterService>;
|
||||
describe('AuditResolver', () => {
|
||||
let resolver: AuditResolver;
|
||||
let auditService: jest.Mocked<AuditService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
auditService = {
|
||||
@@ -22,15 +22,15 @@ describe('EventLogEmitterResolver', () => {
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
EventLogEmitterResolver,
|
||||
AuditResolver,
|
||||
{
|
||||
provide: EventLogEmitterService,
|
||||
provide: AuditService,
|
||||
useValue: auditService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
resolver = module.get<EventLogEmitterResolver>(EventLogEmitterResolver);
|
||||
resolver = module.get<AuditResolver>(AuditResolver);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
@@ -141,20 +141,20 @@ describe('EventLogEmitterResolver', () => {
|
||||
expect(result).toBe('Object event created');
|
||||
});
|
||||
|
||||
it('should throw an EventLogEmitterException for invalid input', async () => {
|
||||
it('should throw an AuditException for invalid input', async () => {
|
||||
const invalidInput = { type: 'invalid' };
|
||||
|
||||
await expect(
|
||||
resolver.trackAnalytics(invalidInput as any, undefined, undefined),
|
||||
).rejects.toThrowError(
|
||||
new EventLogEmitterException(
|
||||
new AuditException(
|
||||
'Invalid analytics input',
|
||||
EventLogEmitterExceptionCode.INVALID_TYPE,
|
||||
AuditExceptionCode.INVALID_TYPE,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an EventLogEmitterException when workspace is missing for createObjectEvent', async () => {
|
||||
it('should throw an AuditException when workspace is missing for createObjectEvent', async () => {
|
||||
const input = {
|
||||
event: 'Object Record Created' as const,
|
||||
recordId: 'test-record-id',
|
||||
@@ -164,10 +164,7 @@ describe('EventLogEmitterResolver', () => {
|
||||
await expect(
|
||||
resolver.createObjectEvent(input, undefined, undefined),
|
||||
).rejects.toThrowError(
|
||||
new EventLogEmitterException(
|
||||
'Missing workspace',
|
||||
EventLogEmitterExceptionCode.INVALID_INPUT,
|
||||
),
|
||||
new AuditException('Missing workspace', AuditExceptionCode.INVALID_INPUT),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,12 +2,12 @@ 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 { EventLogEmitterExceptionFilter } from 'src/engine/core-modules/event-logs/emit/event-log-emitter-exception.filter';
|
||||
import { AuditExceptionFilter } from 'src/engine/core-modules/audit/audit-exception-filter';
|
||||
import {
|
||||
EventLogEmitterException,
|
||||
EventLogEmitterExceptionCode,
|
||||
} from 'src/engine/core-modules/event-logs/emit/event-log-emitter.exception';
|
||||
import { CreateObjectEventInput } from 'src/engine/core-modules/event-logs/emit/dtos/create-object-event.input';
|
||||
AuditException,
|
||||
AuditExceptionCode,
|
||||
} from 'src/engine/core-modules/audit/audit.exception';
|
||||
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';
|
||||
@@ -24,18 +24,24 @@ import {
|
||||
isPageviewAnalyticsInput,
|
||||
isTrackAnalyticsInput,
|
||||
} from './dtos/create-analytics.input';
|
||||
import { EventLogEmitterService } from './event-log-emitter.service';
|
||||
import { AuditService } from './services/audit.service';
|
||||
|
||||
@MetadataResolver(() => Analytics)
|
||||
@UsePipes(ResolverValidationPipe)
|
||||
@UseFilters(
|
||||
EventLogEmitterExceptionFilter,
|
||||
PreventNestToAutoLogGraphqlErrorsFilter,
|
||||
)
|
||||
export class EventLogEmitterResolver {
|
||||
constructor(
|
||||
private readonly eventLogEmitterService: EventLogEmitterService,
|
||||
) {}
|
||||
@UseFilters(AuditExceptionFilter, PreventNestToAutoLogGraphqlErrorsFilter)
|
||||
export class AuditResolver {
|
||||
constructor(private readonly auditService: AuditService) {}
|
||||
|
||||
// preparing for new name
|
||||
async createPageview(
|
||||
@Args()
|
||||
createAnalyticsInput: CreateAnalyticsInputV2,
|
||||
@AuthWorkspace({ allowUndefined: true })
|
||||
workspace: WorkspaceEntity | undefined,
|
||||
@AuthUser({ allowUndefined: true }) user: UserEntity | undefined,
|
||||
) {
|
||||
return this.trackAnalytics(createAnalyticsInput, workspace, user);
|
||||
}
|
||||
|
||||
@Mutation(() => Analytics)
|
||||
@UseGuards(WorkspaceAuthGuard, NoPermissionGuard)
|
||||
@@ -46,18 +52,18 @@ export class EventLogEmitterResolver {
|
||||
@AuthUser({ allowUndefined: true }) user: UserEntity | undefined,
|
||||
) {
|
||||
if (!workspace) {
|
||||
throw new EventLogEmitterException(
|
||||
throw new AuditException(
|
||||
'Missing workspace',
|
||||
EventLogEmitterExceptionCode.INVALID_INPUT,
|
||||
AuditExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
const eventLogContext = this.eventLogEmitterService.createContext({
|
||||
const analyticsContext = this.auditService.createContext({
|
||||
workspaceId: workspace.id,
|
||||
userId: user?.id,
|
||||
});
|
||||
|
||||
return eventLogContext.createObjectEvent(createObjectEventInput.event, {
|
||||
return analyticsContext.createObjectEvent(createObjectEventInput.event, {
|
||||
...createObjectEventInput.properties,
|
||||
recordId: createObjectEventInput.recordId,
|
||||
objectMetadataId: createObjectEventInput.objectMetadataId,
|
||||
@@ -74,28 +80,30 @@ export class EventLogEmitterResolver {
|
||||
workspace: WorkspaceEntity | undefined,
|
||||
@AuthUser({ allowUndefined: true }) user: UserEntity | undefined,
|
||||
) {
|
||||
const eventLogContext = this.eventLogEmitterService.createContext({
|
||||
const analyticsContext = this.auditService.createContext({
|
||||
workspaceId: workspace?.id,
|
||||
userId: user?.id,
|
||||
});
|
||||
|
||||
if (isPageviewAnalyticsInput(createAnalyticsInput)) {
|
||||
return eventLogContext.createPageviewEvent(
|
||||
return analyticsContext.createPageviewEvent(
|
||||
createAnalyticsInput.name,
|
||||
createAnalyticsInput.properties ?? {},
|
||||
);
|
||||
}
|
||||
|
||||
if (isTrackAnalyticsInput(createAnalyticsInput)) {
|
||||
return eventLogContext.insertWorkspaceEvent(
|
||||
// For track events, we need to determine if it's a workspace or object event
|
||||
// Since we don't have recordId and objectMetadataId in the input, we use insertWorkspaceEvent
|
||||
return analyticsContext.insertWorkspaceEvent(
|
||||
createAnalyticsInput.event,
|
||||
createAnalyticsInput.properties ?? {},
|
||||
);
|
||||
}
|
||||
|
||||
throw new EventLogEmitterException(
|
||||
throw new AuditException(
|
||||
'Invalid analytics input',
|
||||
EventLogEmitterExceptionCode.INVALID_TYPE,
|
||||
AuditExceptionCode.INVALID_TYPE,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
import { ArgsType, Field, registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
import { IsEnum, IsObject, IsOptional, IsString } from 'class-validator';
|
||||
import {
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
} from 'class-validator';
|
||||
import GraphQLJSON from 'graphql-type-json';
|
||||
|
||||
import { TrackEventName } from 'src/engine/core-modules/event-logs/emit/events.type';
|
||||
import { type PageviewProperties } from 'src/engine/core-modules/event-logs/emit/events/pageview/pageview';
|
||||
import { TrackEventName } from 'src/engine/core-modules/audit/types/events.type';
|
||||
import { type PageviewProperties } from 'src/engine/core-modules/audit/utils/events/pageview/pageview';
|
||||
|
||||
enum AnalyticsType {
|
||||
PAGEVIEW = 'pageview',
|
||||
@@ -15,6 +21,19 @@ registerEnumType(AnalyticsType, {
|
||||
name: 'AnalyticsType',
|
||||
});
|
||||
|
||||
// deprecated
|
||||
@ArgsType()
|
||||
export class CreateAnalyticsInput {
|
||||
@Field({ description: 'Type of the event' })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
action: string;
|
||||
|
||||
@Field(() => GraphQLJSON, { description: 'Event payload in JSON format' })
|
||||
@IsObject()
|
||||
payload: JSON;
|
||||
}
|
||||
|
||||
@ArgsType()
|
||||
export class CreateAnalyticsInputV2 {
|
||||
@Field(() => AnalyticsType)
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import GraphQLJSON from 'graphql-type-json';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { TrackEventName } from 'src/engine/core-modules/event-logs/emit/events.type';
|
||||
import { TrackEventName } from 'src/engine/core-modules/audit/types/events.type';
|
||||
|
||||
@ArgsType()
|
||||
export class CreateObjectEventInput {
|
||||
@@ -0,0 +1,11 @@
|
||||
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 {}
|
||||
@@ -0,0 +1,64 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,21 +3,21 @@ import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { AuditContextMock } from 'test/utils/audit-context.mock';
|
||||
|
||||
import { ClickHouseService } from 'src/database/clickHouse/clickHouse.service';
|
||||
import { OBJECT_RECORD_CREATED_EVENT } from 'src/engine/core-modules/event-logs/emit/events/object-event/object-record-created';
|
||||
import { CUSTOM_DOMAIN_ACTIVATED_EVENT } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/custom-domain/custom-domain-activated';
|
||||
import { OBJECT_RECORD_CREATED_EVENT } from 'src/engine/core-modules/audit/utils/events/object-event/object-record-created';
|
||||
import { CUSTOM_DOMAIN_ACTIVATED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/custom-domain/custom-domain-activated';
|
||||
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
import { EventLogEmitterService } from './event-log-emitter.service';
|
||||
import { AuditService } from './audit.service';
|
||||
|
||||
describe('EventLogEmitterService', () => {
|
||||
let service: EventLogEmitterService;
|
||||
describe('AuditService', () => {
|
||||
let service: AuditService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
{
|
||||
provide: EventLogEmitterService,
|
||||
provide: AuditService,
|
||||
useValue: {
|
||||
createContext: AuditContextMock,
|
||||
},
|
||||
@@ -43,7 +43,7 @@ describe('EventLogEmitterService', () => {
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<EventLogEmitterService>(EventLogEmitterService);
|
||||
service = module.get<AuditService>(AuditService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
@@ -0,0 +1,98 @@
|
||||
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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type AuditCommonPropertiesType = 'timestamp' | 'version';
|
||||
export type IdentifierType = 'workspaceId' | 'userId';
|
||||
@@ -1,58 +1,64 @@
|
||||
import {
|
||||
type OBJECT_RECORD_CREATED_EVENT,
|
||||
type ObjectRecordCreatedTrackEvent,
|
||||
} from 'src/engine/core-modules/event-logs/emit/events/object-event/object-record-created';
|
||||
} from 'src/engine/core-modules/audit/utils/events/object-event/object-record-created';
|
||||
import {
|
||||
type OBJECT_RECORD_DELETED_EVENT,
|
||||
type ObjectRecordDeletedTrackEvent,
|
||||
} from 'src/engine/core-modules/event-logs/emit/events/object-event/object-record-delete';
|
||||
} from 'src/engine/core-modules/audit/utils/events/object-event/object-record-delete';
|
||||
import {
|
||||
type OBJECT_RECORD_UPDATED_EVENT,
|
||||
type ObjectRecordUpdatedTrackEvent,
|
||||
} from 'src/engine/core-modules/event-logs/emit/events/object-event/object-record-updated';
|
||||
} from 'src/engine/core-modules/audit/utils/events/object-event/object-record-updated';
|
||||
import {
|
||||
type OBJECT_RECORD_UPSERTED_EVENT,
|
||||
type ObjectRecordUpsertedTrackEvent,
|
||||
} from 'src/engine/core-modules/event-logs/emit/events/object-event/object-record-upserted';
|
||||
} from 'src/engine/core-modules/audit/utils/events/object-event/object-record-upserted';
|
||||
import {
|
||||
type CUSTOM_DOMAIN_ACTIVATED_EVENT,
|
||||
type CustomDomainActivatedTrackEvent,
|
||||
} from 'src/engine/core-modules/event-logs/emit/events/workspace-event/custom-domain/custom-domain-activated';
|
||||
} from 'src/engine/core-modules/audit/utils/events/workspace-event/custom-domain/custom-domain-activated';
|
||||
import {
|
||||
type CUSTOM_DOMAIN_DEACTIVATED_EVENT,
|
||||
type CustomDomainDeactivatedTrackEvent,
|
||||
} from 'src/engine/core-modules/event-logs/emit/events/workspace-event/custom-domain/custom-domain-deactivated';
|
||||
} from 'src/engine/core-modules/audit/utils/events/workspace-event/custom-domain/custom-domain-deactivated';
|
||||
import {
|
||||
type LOGIC_FUNCTION_EXECUTED_EVENT,
|
||||
type LogicFunctionExecutedTrackEvent,
|
||||
} from 'src/engine/core-modules/event-logs/emit/events/workspace-event/logic-function/logic-function-executed';
|
||||
} from 'src/engine/core-modules/audit/utils/events/workspace-event/logic-function/logic-function-executed';
|
||||
import {
|
||||
type IMPERSONATION_EVENT,
|
||||
type ImpersonationTrackEvent,
|
||||
} from 'src/engine/core-modules/event-logs/emit/events/workspace-event/impersonation/impersonation';
|
||||
type MONITORING_EVENT,
|
||||
type MonitoringTrackEvent,
|
||||
} from 'src/engine/core-modules/audit/utils/events/workspace-event/monitoring/monitoring';
|
||||
import {
|
||||
type USER_SIGNUP_EVENT,
|
||||
type UserSignupTrackEvent,
|
||||
} from 'src/engine/core-modules/event-logs/emit/events/workspace-event/user/user-signup';
|
||||
} from 'src/engine/core-modules/audit/utils/events/workspace-event/user/user-signup';
|
||||
import {
|
||||
type WEBHOOK_RESPONSE_EVENT,
|
||||
type WebhookResponseTrackEvent,
|
||||
} from 'src/engine/core-modules/event-logs/emit/events/workspace-event/webhook/webhook-response';
|
||||
} from 'src/engine/core-modules/audit/utils/events/workspace-event/webhook/webhook-response';
|
||||
import {
|
||||
type PAYMENT_RECEIVED_EVENT,
|
||||
type PaymentReceivedTrackEvent,
|
||||
} from 'src/engine/core-modules/event-logs/emit/events/workspace-event/billing/payment-received';
|
||||
} from 'src/engine/core-modules/audit/utils/events/workspace-event/billing/payment-received';
|
||||
import {
|
||||
type WORKSPACE_CREATED_EVENT,
|
||||
type WorkspaceCreatedTrackEvent,
|
||||
} from 'src/engine/core-modules/event-logs/emit/events/workspace-event/workspace/workspace-created';
|
||||
} from 'src/engine/core-modules/audit/utils/events/workspace-event/workspace/workspace-created';
|
||||
import {
|
||||
type WORKSPACE_ENTITY_CREATED_EVENT,
|
||||
type WorkspaceEntityCreatedTrackEvent,
|
||||
} from 'src/engine/core-modules/audit/utils/events/workspace-event/workspace-entity/workspace-entity-created';
|
||||
|
||||
// Define all track event names
|
||||
export type TrackEventName =
|
||||
| typeof CUSTOM_DOMAIN_ACTIVATED_EVENT
|
||||
| typeof CUSTOM_DOMAIN_DEACTIVATED_EVENT
|
||||
| typeof LOGIC_FUNCTION_EXECUTED_EVENT
|
||||
| typeof WEBHOOK_RESPONSE_EVENT
|
||||
| typeof IMPERSONATION_EVENT
|
||||
| typeof WORKSPACE_ENTITY_CREATED_EVENT
|
||||
| typeof MONITORING_EVENT
|
||||
| typeof OBJECT_RECORD_CREATED_EVENT
|
||||
| typeof OBJECT_RECORD_UPDATED_EVENT
|
||||
| typeof OBJECT_RECORD_DELETED_EVENT
|
||||
@@ -61,13 +67,15 @@ export type TrackEventName =
|
||||
| typeof WORKSPACE_CREATED_EVENT
|
||||
| typeof PAYMENT_RECEIVED_EVENT;
|
||||
|
||||
// Map event names to their corresponding event types
|
||||
export interface TrackEvents {
|
||||
[CUSTOM_DOMAIN_ACTIVATED_EVENT]: CustomDomainActivatedTrackEvent;
|
||||
[CUSTOM_DOMAIN_DEACTIVATED_EVENT]: CustomDomainDeactivatedTrackEvent;
|
||||
[LOGIC_FUNCTION_EXECUTED_EVENT]: LogicFunctionExecutedTrackEvent;
|
||||
[WEBHOOK_RESPONSE_EVENT]: WebhookResponseTrackEvent;
|
||||
[WORKSPACE_ENTITY_CREATED_EVENT]: WorkspaceEntityCreatedTrackEvent;
|
||||
[USER_SIGNUP_EVENT]: UserSignupTrackEvent;
|
||||
[IMPERSONATION_EVENT]: ImpersonationTrackEvent;
|
||||
[MONITORING_EVENT]: MonitoringTrackEvent;
|
||||
[OBJECT_RECORD_DELETED_EVENT]: ObjectRecordDeletedTrackEvent;
|
||||
[OBJECT_RECORD_CREATED_EVENT]: ObjectRecordCreatedTrackEvent;
|
||||
[OBJECT_RECORD_UPDATED_EVENT]: ObjectRecordUpdatedTrackEvent;
|
||||
@@ -1,20 +1,21 @@
|
||||
import { formatDateTimeForClickHouse } from 'src/database/clickHouse/clickHouse.util';
|
||||
import { type EventCommonPropertiesType } from 'src/engine/core-modules/event-logs/emit/common.type';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
import { type AuditCommonPropertiesType } from 'src/engine/core-modules/audit/types/common.type';
|
||||
import {
|
||||
type TrackEventName,
|
||||
type TrackEventProperties,
|
||||
} from 'src/engine/core-modules/event-logs/emit/events.type';
|
||||
} from 'src/engine/core-modules/audit/types/events.type';
|
||||
import {
|
||||
type PageviewProperties,
|
||||
pageviewSchema,
|
||||
} from 'src/engine/core-modules/event-logs/emit/events/pageview/pageview';
|
||||
} from 'src/engine/core-modules/audit/utils/events/pageview/pageview';
|
||||
import {
|
||||
eventsRegistry,
|
||||
type GenericTrackEvent,
|
||||
} from 'src/engine/core-modules/event-logs/emit/events/workspace-event/track';
|
||||
} from 'src/engine/core-modules/audit/utils/events/workspace-event/track';
|
||||
|
||||
const common = (): Record<EventCommonPropertiesType, string> => ({
|
||||
timestamp: formatDateTimeForClickHouse(new Date()),
|
||||
const common = (): Record<AuditCommonPropertiesType, string> => ({
|
||||
timestamp: format(new Date(), 'yyyy-MM-dd HH:mm:ss'),
|
||||
version: '1',
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { registerEvent } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/track';
|
||||
import { registerEvent } from 'src/engine/core-modules/audit/utils/events/workspace-event/track';
|
||||
|
||||
export const OBJECT_RECORD_CREATED_EVENT = 'Object Record Created' as const;
|
||||
export const objectRecordCreatedSchema = z.object({
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { registerEvent } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/track';
|
||||
import { registerEvent } from 'src/engine/core-modules/audit/utils/events/workspace-event/track';
|
||||
|
||||
export const OBJECT_RECORD_DELETED_EVENT = 'Object Record Deleted' as const;
|
||||
export const objectRecordDeletedSchema = z.object({
|
||||