47689e676bbac1f4e7aced1e3a0bb81da12ac565
16 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
02120aac42 |
Add v2 create-workspace onboarding screen (#22075)
https://github.com/user-attachments/assets/30d69db2-ef50-48b5-8233-d9a36511b5e8 Builds the second step of the new onboarding flow on top of #22027: the v2 "Create your workspace" screen, shown inside `/welcome-v2` at the `WorkspaceCreation` step. What changed: - New `SignInUpV2Header` (back chevron + Twenty logo) and `SignInUpWorkspaceCreationFormV2` (left-aligned title/subtitle, logo upload, Name + Subdomain fields, "Create workspace"), wired into `SignInUpV2` for the workspace-creation step. - When a subdomain is taken, a box now lists 3 server-verified-available alternatives. Backend `SubdomainAvailabilityDTO` returns `suggestedSubdomains` via a new `findAvailableSubdomains` helper. - The shared `useWorkspaceSubdomainField` hook is extended additively (new `suggestions` + `applySuggestionValue`) so the v1 `/welcome` screen is untouched. Reviewer notes: - `generated-metadata/graphql.ts` was hand-patched (metadata codegen needs a running server). - Storybook: `Pages/Auth/SignInUpV2 → WorkspaceCreation`. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22075?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
614bc7b7e6 |
feat: serve HTTP logic functions on isolated *.withtwenty.com domain (#22045)
## Summary Implements [core-team-issues#2473](https://github.com/twentyhq/core-team-issues/issues/2473): serve HTTP-triggered logic functions from a dedicated, **cookieless** public domain (`{workspaceSubdomain}.withtwenty.com`) instead of the same-site `/s/` route, so functions can safely return **arbitrary headers** — custom headers, `Permissions-Policy` (camera/mic/geolocation), `Cross-Origin-Opener-Policy: same-origin`, `Cross-Origin-Embedder-Policy: require-corp`, `Set-Cookie`, etc. The `/s/` route stays the strict, same-site path it is today. **Self-hosting is unchanged** — everything new is gated on `PUBLIC_DOMAIN_URL` being set. ### Why Today user-authored function responses are served same-site with the Twenty app, so the response-header allow-list is restricted to 5 safe headers and request headers are limited to a per-function allow-list. Serving from an origin that shares nothing with `*.twenty.com` removes that constraint safely — the same "user content domain" pattern as GitHub (`*.githubusercontent.com`) and CodeSandbox (`*.csb.app`). ## What's in here **Routing** - The **root-path → `/s` rewrite happens at the nginx ingress**, not in app code. The existing `api-ingress.yaml` already rewrites root paths onto `/s` (host-agnostically) when the edge sets `X-Twenty-Public-Domain: true`, so `*.withtwenty.com` and registered custom public domains are handled by the same mechanism. (An earlier in-app middleware was removed as a redundant, wrong-layer duplicate.) - `WorkspaceDomainsService.resolveWorkspaceAndPublicDomain` recognizes `*.` subdomains, resolves the workspace by subdomain, and returns `isIsolatedOrigin`. Explicitly registered public-domain rows still take precedence and keep their application scoping. The ingress preserves the `Host` header, so this resolution still fires. **Headers (server)** - Isolated origin → all response headers pass through and all request headers are forwarded. Same-site `/s/` keeps the strict allow-lists. (Global CORS already handles preflight/ACAO.) **`/s/` deprecation for new routes (cloud only)** - New `LOGIC_FUNCTION_LEGACY_ROUTE_CUTOFF` config var (ISO date, optional). When `PUBLIC_DOMAIN_URL` is set, functions created on/after the cutoff return **410 Gone** on `/s/` with the new URL. Existing routes and self-hosted instances are untouched. **Frontend education** - `publicFunctionDomain` added to `ClientConfig` (from `PUBLIC_DOMAIN_URL`). - The logic-function **Live URL** now resolves to `https://{workspaceSubdomain}.{publicFunctionDomain}{path}` on cloud, falling back to `/s/` for self-hosting. - Front components call their functions through the SDK (`RestApiClient`), which now targets the isolated domain via the injected `TWENTY_FUNCTIONS_URL`. - New **"Public URL"** section on the application **Settings** tab explaining the isolated domain (shown when the app exposes HTTP-triggered functions). **Docs**: note the `withtwenty.com` domain for external callers in the apps guide. ## Infra prerequisites (not code — needs dashboard work) - Wildcard DNS `*.withtwenty.com` (proxied) + wildcard TLS in the public-domain Cloudflare zone. - Edge (Cloudflare) sets `X-Twenty-Public-Domain: true` for `*.withtwenty.com` requests, so the existing nginx ingress rewrites them onto `/s` (same header the custom-domain flow already relies on). - Set `PUBLIC_DOMAIN_URL=https://withtwenty.com` on cloud. - Submit `withtwenty.com` to the **Public Suffix List** (required for cross-tenant cookie isolation before relying on `Set-Cookie`). ## Test plan - [x] `nx typecheck twenty-server`, `nx typecheck twenty-front` - [x] `lint:diff-with-main` + oxfmt clean (server + front) - [x] `npx jest route-trigger public-function-domain domain-server-config workspace-domains build-logic-function-event client-config` → server unit tests passing (resolution tiers, header passthrough vs allow-list, `/s/` cutoff 410) - [x] `npx jest getLogicFunctionHttpUrl` (front) and `nx test twenty-client-sdk` (RestApiClient routing) passing - [x] CI green (server, front, sdk, renderer, ui, zapier, example apps) - [ ] Manual: hit `{subdomain}.withtwenty.com/` end-to-end once infra is provisioned <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22045?utm_source=github" rel="nofollow noreferrer noopener" target="_blank">``<img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg">``</a> |
||
|
|
2e099c91e1 |
fix(domains): show custom domain DNS records and activation status without a page reload (#22037)
## Problem
Setting up a custom domain had two confusing UX issues, both caused by
local state not being refreshed after the relevant mutation:
1. **DNS records didn't appear after saving.** After hitting save you
got the green "Custom domain updated" snackbar, but the "Domain Setup"
section (the Cloudflare/DNS records to configure) stayed empty. You had
to leave the page and come back for the records to show up.
2. **The "Custom Domain" card stayed "Inactive"** even after the DNS
records validated as "Success". Only a full page reload flipped it to
"Active".
## Root cause
**Issue 1 — stale closure.** In `useSettingsCustomDomain.handleSave`,
the `updateWorkspace` `onCompleted` callback called
`setCurrentWorkspace({ ...currentWorkspace, customDomain })` and then
`checkCustomDomainRecords()`. But `checkCustomDomainRecords` guarded on
the closed-over `currentWorkspace.customDomain`, which was still `null`
at that render. The `setCurrentWorkspace` call doesn't synchronously
update that captured value, so the guard returned early and the records
were never fetched. Remounting the page (navigate away/back) ran the
on-mount effect with a fresh workspace, which is why the trip "fixed"
it.
**Issue 2 — `isCustomDomainEnabled` never refreshed locally.** The
Active/Inactive badge is driven by
`currentWorkspace.isCustomDomainEnabled`. The backend flips this flag
inside `checkCustomDomainValidRecords`
(`custom-domain-manager.service.ts`), but the mutation didn't return it,
so the local `currentWorkspaceState` stayed stale until a full reload
re-ran the bootstrap query. The green "Success" DNS rows read from a
different source (`record.status`), which is why the rows and the badge
disagreed.
## Changes
**Issue 1**
- `checkCustomDomainRecords` now accepts the domain explicitly
(defaulting to the workspace value), so the freshly-saved domain can be
passed straight from `handleSave` instead of relying on the stale
closure. No new `useEffect` introduced.
- Fixed the Reload button so it no longer passes its click event as the
domain argument.
**Issue 2**
- Added a nullable `isCustomDomainEnabled` field to the
`DomainValidRecords` GraphQL type, populated only by the custom-domain
check (the shared public-domain flow leaves it null, so it's backward
compatible).
- The frontend now writes that value back into `currentWorkspaceState`
when the check completes, using a **functional** Jotai update so a
concurrent `customDomain` update is never clobbered. The badge flips to
"Active" as soon as validation passes — on mount, on Reload, and right
after save.
I deliberately kept this targeted rather than introducing real-time
workspace sync: `isCustomDomainEnabled` only changes server-side during
the on-demand DNS check (mount/Reload/cron), so returning it from that
mutation is sufficient and far lower risk.
## Notes
- `packages/twenty-front/src/generated-metadata/graphql.ts` was updated
to match what `graphql:generate` produces for the new schema field
(codegen requires a running backend, which isn't available in this
environment). Worth re-running codegen in CI to confirm it's
byte-identical.
- No existing unit or integration tests reference these paths.
## Test plan
- [ ] Set a custom domain → DNS records appear immediately (no
navigation needed).
- [ ] Once DNS validates, the "Custom Domain" card flips to "Active"
without a reload.
- [ ] Reload button still refreshes records.
- [ ] Public domain validation flow is unaffected.
https://claude.ai/code/session_01BB6C6bpPZMUbMzKSCydEaj
---
_Generated by [Claude
Code](https://claude.ai/code/session_01BB6C6bpPZMUbMzKSCydEaj)_
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22037?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
|
||
|
|
eeed998c9e |
Let users pick their workspace subdomain during sign-up (#21641)
## What & why
During onboarding the workspace subdomain was auto-generated at sign-up
and only editable later in Settings. This adds a subdomain picker to the
workspace-creation flow, with **live availability checking** and
**name-driven auto-fill**.
The subdomain is chosen **on the central sign-up domain, before the
redirect onto the workspace subdomain** — so there's no mid-onboarding
domain switch (which would otherwise force a re-auth, like the Settings
"this logs everyone out" flow). It works uniformly for credentials and
SSO, since workspace creation is a post-auth mutation.
## Flow
Authenticate → **Create a workspace** → new step (workspace name +
address with live availability + auto-fill, seeded from the work email)
→ workspace is created with the chosen subdomain → the single redirect
lands on the final subdomain → onboarding modal (name pre-filled).
## Changes
**twenty-shared**
- `getSubdomainSlugFromDisplayName` — friendly slug from a display name,
built on the existing `transliteration` package (also transliterates
non-Latin names, e.g. 日本語 → `ri-ben-yu`).
**twenty-server**
- `checkWorkspaceSubdomainAvailability(subdomain)` query
(workspace-agnostic, `UserAuthGuard`) → `{ isValid, available,
suggestedSubdomain }`.
- `SubdomainManagerService`: availability + suggestion logic with
friendly numbered suffixes (`acme`, `acme-2`, …) instead of random hex;
`generateSubdomain` reuses it.
- `signUpInNewWorkspace` accepts an optional `{ displayName, subdomain
}` input (validated; falls back to auto-generation when omitted —
backward compatible, so existing callers are unaffected). Concurrent
same-subdomain sign-ups return a clear "already taken" error instead of
a generic DB error.
**twenty-front**
- New `SignInUpStep.WorkspaceCreation` step +
`useWorkspaceSubdomainField` hook (debounced, stale-response-safe;
auto-fills from the name until the user edits it, with a one-click "use
suggested" when taken; ignores Enter during IME composition; surfaces a
clear error if the availability check fails).
- Onboarding modal name pre-filled from the chosen name.
## Testing
- Unit tests: shared slug util, the `useWorkspaceSubdomainField` hook
(real auto-fill/availability flows via `MockedProvider`), and the
workspace-creation component; existing sign-up tests still pass.
- Typecheck, lint, and format green across twenty-shared / twenty-server
/ twenty-front.
## Notes / out of scope
- No DB migration — the `subdomain` column already existed.
- Self-hosted single-workspace sign-up is unchanged; the step is gated
to multi-workspace (global scope).
- Low-priority follow-ups: length bounds on the subdomain / displayName
inputs, and an integration test for the availability query.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
5d892bdfd0 |
[WIP] Feat/marketing emails (#21173)
Marketing/campaign emails on top of the emailing-domain (SES) feature:
send a broadcast to a hand-picked list, with per-customer-domain
unsubscribe links and opt-out-only **unsubscribe topics**.
## Model
Standard objects (workspace schema, flat-metadata):
- `messageCampaign` — a campaign send (subject, body template, from
address, status, list, optional unsubscribe topic).
- `messageList` + `messageListMember` — the hand-picked audience (person
↔ list join). A campaign's recipients are its list's members; everyone
is sendable unless suppressed.
Core entities (`core` schema, workspace-scoped — readable by the public
unsubscribe flow without a workspace context):
- `unsubscribeTopic` — an opt-out-only category (name, description,
visibility). There is no opt-in subscription state.
- `messageSuppression` — the single consent store: a row with
`unsubscribeTopicId` NULL is a global block; a row with an
`unsubscribeTopicId` and reason `UNSUBSCRIBE` is a per-topic opt-out.
Two partial unique indexes dedupe global vs per-topic rows (Postgres
treats NULLs as distinct).
- `emailingDomain` — the workspace's SES sending domain,
auto-provisioned when an email channel is added (and cleaned up when its
last channel is removed), with verification status + DNS records.
Campaign messages reuse the existing `message` / `messageThread` /
`messageParticipant` model — one outbound `message` per recipient with a
`deliveryStatus` state machine.
## Sending
- `sendMessageCampaign` resolves the audience **under the caller's
permissions**, creates the campaign, and enqueues a single fan-out job
(the request never materializes per-recipient rows or jobs).
- The fan-out job materializes one QUEUED message per recipient
(deterministic ids → idempotent re-runs, reconciles crash-orphaned rows)
and fans out per-recipient send jobs carrying **only ids**.
- Each send job renders per-recipient `{{variable}}` merge fields and
sends via `EmailingDomainSenderService`, which applies suppression
(global + per-topic) and the unsubscribe footer/headers. Suppressed
recipients are recorded `SKIPPED`.
- The campaign finalizes `SENT`, or `SENT_WITH_ERRORS` if any recipient
terminally failed.
- `previewMessageCampaignAudience` returns a pre-send breakdown (total /
without-email / duplicate / globally-unsubscribed / topic-unsubscribed /
sendable), shown as a hint under the composer pickers.
## Unsubscribe
- Encrypted (AES-256-GCM) token carrying workspaceId, address, optional
`unsubscribeTopicId`, `issuedAt`, and a `preview` flag.
- One-click POST (RFC 8058) + `mailto:` — topic-scoped when the token
carries a topic, global otherwise.
- Preferences page: a checkbox per visible topic (checked = still
receiving); submitting creates per-topic opt-outs for unchecked topics
and lifts re-checked ones (UNSUBSCRIBE only — never
`BOUNCE`/`COMPLAINT`, never a global block).
- A **Preview** action in settings opens the live page via a
preview-claim token; opt-out POSTs are no-ops for preview tokens, so
previewing never mutates state.
- SES webhooks: inbound unsubscribe + outbound bounce/complaint →
suppression (race-safe against at-least-once delivery, with reason
escalation that never downgrades).
- Per-customer unsubscribe hostname (Cloudflare DNS); sends are gated on
it being active, except in LOG/demo mode.
## Architecture
Campaign orchestration, suppression, the sender, the unsubscribe
controller, and the SES webhook handlers live in `src/modules/emailing`
+ `src/modules/messaging-webhooks` (the workspace-feature layer).
`core-modules/emailing-domain` keeps the SES driver, domain
provisioning, the `unsubscribeTopic` / `messageSuppression` core
entities, and the unsubscribe token/hostname plumbing. Domain creation
is validated (`CreateEmailingDomainInput` — domain-format regex,
lowercased) before any value reaches SES or the unsubscribe hostname.
## Frontend
- Campaign composer side panel (from / list / unsubscribe topic /
subject / body) with a live audience-preview hint.
- Email settings: email channels each showing their auto-provisioned
sending domain in a single section (status + DNS records + a "Check
verification" action), plus an **Unsubscribe Topics** section to
create/manage topics and preview the recipient page. A demo-mode banner
is shown when the LOG driver is active.
---------
Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
|
||
|
|
91f2f08995 |
feat(server): unify workspace-event ingestion behind one EventSink pipeline (#21197)
## Why The five event-log streams (`workspaceEvent`, `pageview`, `objectEvent`, `usageEvent`, `applicationLog`) each wrote to ClickHouse through their own fire-and-forget writer (`AuditService`, `UsageEventWriterService`, and the `application-logs` driver), with the per-type knowledge (table names, normalization, access rules) spread across several modules. Three of them reimplemented the same ClickHouse insert, and the read side, the live stream, and the producers lived in different modules under two different names. This consolidates them into one `core-modules/event-logs/` subsystem (emit, write, live, read), with the per-type config in a single registry so adding an event type is roughly one file. The base Logs settings tab and free application logs shipped separately in #21180 (merged). This PR adds the unified backend, the registry, and the viewer's live mode and entitlement gating. ## Pipeline ```mermaid flowchart TB subgraph PROD["Producers"] A["auth, billing, impersonation,<br/>webhook, custom-domain"] U["usage listener"] F["logic-function executor (app logs)"] R["record CRUD (entity events)"] end EM["EventLogEmitterService<br/>createContext().insert* / dispatch()"] EQ(["entityEventsToDbQueue<br/>(existing, shared with timeline)"]) CIE["CreateEventLogFromInternalEvent"] SINK["WorkspaceEventSinkService.ingest()"] C1["ClickHouseEventSink"] C2["ConsoleEventSink"] LIVE["EventLogLiveService.publishWatched()<br/>(presence-gated)"] CH[("ClickHouse, 5 tables, async_insert")] CHAN(["WORKSPACE_EVENTS_CHANNEL"]) RS["EventLogsService (registry-driven read)"] LR["EventLogsLiveResolver"] UI["Settings > Logs"] A --> EM U --> EM F --> EM EM -->|direct| SINK R --> EQ --> CIE -->|ingest| SINK SINK --> C1 --> CH SINK --> C2 SINK --> LIVE -.->|if a viewer is watching| CHAN --> LR --> UI CH --> RS --> UI ``` ## What it does - Producers call `EventLogEmitterService.createContext().insert*()`, which builds a typed `WorkspaceEventEnvelope` and writes it through `WorkspaceEventSinkService` to the configured sinks (ClickHouse, Console) plus a presence-gated live fan-out. Record/CRUD events reach the same sink through the existing `entityEventsToDbQueue`. There is no dedicated queue; ClickHouse `async_insert` batches server-side. Writes are best-effort, as on main today. - `EVENT_LOG_TYPES[table]` is the per-type source of truth: the ClickHouse table, the required entitlement, the free-text filter column, and the row-to-GraphQL mapping. Read row shapes derive from the write rows. - Four modules along their dependency boundaries: `EventLogEmitterModule` (producer API), `EventLogIngestionModule` (sink layer), `EventLogLiveModule` (fan-out), and `EventLogsViewerModule` (the entitlement-gated GraphQL read, which is where billing/enterprise/permissions stay so producers stay light). - Logs viewer: per-table columns, filters (text, date, record), live mode, and an upgrade card that points to Billing on Cloud or the Admin Panel on self-hosted. Application logs are free on every plan; the other four require the `AUDIT_LOGS` entitlement (with a `NO_ENTITLEMENT` fallback to the upgrade card). - Renames `AuditService` to `EventLogEmitterService`, and the generic `Monitoring` event to a typed `Impersonation` event (`level` + `action`). - Removes `UsageEventWriterService`, the `application-logs` driver/module, and `AuditService`'s direct inserts. ## Durability Writes are best-effort, the same as main today (the old writers were fire-and-forget). A dedicated queue was tried mid-PR and removed: `async_insert` already batches server-side, so the queue only added durability, which isn't a requirement right now. The `EventSink` seam keeps a durable transport (e.g. a Redis-Streams buffer) easy to add later without touching producers. ## Out of scope S3 peer sink (seam only), Postgres or any second read path, `ReplicatedMergeTree`, ClickHouse table-schema changes, and the record-data `EVENT_STREAM_CHANNEL` (unchanged, separate concern). ## Testing Unit tests cover the registry definitions and row normalization, the entitlement gating, the envelope builders, and the producers. Integration tests cover the write paths (record create produces an `objectEvent`; the track mutation produces a `workspaceEvent`) and the read/query path across all five tables. Verified with typecheck, lint, a server boot, and GraphQL/SDK codegen. |
||
|
|
4797d2f270 |
feat(twenty-orm): introduce WorkspaceScopedRepository for core/metadata workspace-scoped entities (#20953)
## Summary Adds a third tenancy enforcement layer for entities that live in shared schemas (`core`, `metadata`) and carry a `workspaceId` column — previously the only safeguard at this layer was developer discipline (remembering to put `workspaceId` in every WHERE clause). ### The three layers, after this PR | Layer | Scope | How it's enforced | |---|---|---| | 1. Workspace data | per-workspace schema (companies, people, custom objects) | `twentyORMManager.getRepository(workspace, E)` — physical isolation (own data source) | | 2. Metadata | shared `metadata` schema (objectMetadata, fieldMetadata, views, roles…) | Flat-entity-maps cache — workspace-scoped in-memory map, lookups by id within it | | 3. Core (new) | shared `core` schema (agent threads/turns/messages, app tokens, etc.) | `WorkspaceScopedRepository<T>` — `workspaceId` is a required positional argument on every read/write | ## What's in the PR ### The wrapper (`packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/`) - `WorkspaceScopedRepository<T extends WorkspaceScopedEntity>` — wraps a TypeORM `Repository<T>`, requires `workspaceId` on every `find`/`findOne`/`findOneOrFail`/`update`/`delete`/`softDelete`/`insert`/`save`/`count` call, merging it into the WHERE or stamping it on the entity. `createQueryBuilder` is an explicit escape hatch (caller scopes manually). - Provided via Nest DI with `@InjectWorkspaceScopedRepository(EntityClass)` and the `provideWorkspaceScopedRepository(EntityClass)` provider factory. - 19 unit tests cover the merge behavior, override-on-conflict, and the array-where (OR) case. ### Lint enforcement (`packages/twenty-oxlint-rules/rules/prefer-workspace-scoped-repository.ts`) - New `twenty/prefer-workspace-scoped-repository` rule (level: **error**). - Blacklist of entity names: raw `@InjectRepository(E)` is rejected if `E` is on the list. - Initial list: `AgentTurnEntity`, `AgentMessageEntity`, `AgentMessagePartEntity`, `AgentChatThreadEntity`, `AgentTurnEvaluationEntity`, `AgentEntity`. - Designed to grow over time as more consumers are migrated. - 5 rule tests. ### Migration in this PR All consumers of the six blacklisted entities, including: - AI agent / chat / monitor resolvers, services, and jobs - `AgentService`, `AiAgentRoleService`, `AiAgentWorkflowAction`, `ApplicationService`, `WorkspaceFlatAgentMapCacheService` - Admin-panel chat (migrated where the lookup is workspace-known; one documented `eslint-disable` on the threadId-discovery lookup that necessarily precedes the `allowImpersonation` permission check) - `AiAgentRoleService` unit spec updated to mock the scoped wrapper ## Future work (deliberately not in this PR) A standalone audit identified ~14 additional `core`/`metadata` entities with `workspaceId` that currently use raw `@InjectRepository` and could be added to the blacklist. Notable candidates: `UserWorkspaceEntity` (42 sites), `AppTokenEntity` (10), `FileEntity` (7), `BillingCustomerEntity`/`BillingSubscriptionEntity` (~22 combined). Each should be its own PR — the migration is mechanical but the surface is wide. ## Test plan - [x] `npx nx typecheck twenty-server` — clean - [x] `npx nx lint twenty-server` — 0 warnings, 0 errors - [x] `npx jest workspace-scoped-repository` — 19/19 pass - [x] `npx nx test twenty-oxlint-rules` — 215/215 pass - [x] `npx jest src/engine/metadata-modules/ai` — 44/44 pass - [ ] Manual smoke: end-to-end AI agent chat send/receive (reviewer) - [ ] Manual smoke: AI agent monitor — list turns, run evaluation (reviewer) - [ ] Manual smoke: admin-panel chat thread inspection (reviewer) |
||
|
|
99a5a038bc |
fix(server): add Apple seed workspace as fallback for single-workspace mode (#20498)
## Issue
As per the developer docs, the local setup uses the `npx nx
database:reset twenty-server` command, which seeds 4 workspaces. This
works correctly for multi-workspace mode
(`IS_MULTIWORKSPACE_ENABLED=true`) and integration tests but causes
issues in single-workspace mode (`IS_MULTIWORKSPACE_ENABLED=false`) or
when switching from multi-workspace mode back to single-workspace mode.
Also, the default mode is single-workspace but 4 workspaces are already
seeded in the database. As a result,
`WorkspaceDomainsService.getDefaultWorkspace()` selects the newest
workspace (Empty4), which is intended only for integration testing and
contains no user data.
There is also an existing warning log mentioning fallback to the Apple
seed workspace when multiple workspaces are found in single-workspace
mode i.e `IS_MULTIWORKSPACE_ENABLED=true`, but it was never implemented.
```
if (workspaces.length > 1) {
Logger.warn(
` ${workspaces.length} workspaces found in database. In single-workspace mode, there should be only one workspace. Apple seed workspace will be used as fallback if it found.`,
);
}
```
Although we could replace with `"nx command-no-deps --
workspace:seed:dev --light"` in `project.json` for `database:reset`,
which will only seed one workspace but it wont resolve issue when
switching from multi-workspace mode back to single-workspace mode or for
integration test `with-db-reset`.
## Changes
- Implement fallback behavior already hinted by existing warning logs.
- Ensure the Apple seed workspace is used as the fallback in
single-workspace mode when multiple workspaces exist.
This improves:
- Local developer onboarding experience.
- Switching between multi-workspace and single-workspace development
modes.
- Consistency during local development and integration testing.
## Related PR
- #19822
- #20464
These PRs only resolves the issue for docker environments but not for
local development setup.
---------
Co-authored-by: Charles Bochet <charles@twenty.com>
|
||
|
|
a159a68e2c |
[twenty-server] no floating promises lint rule (#20499)
## Introduction
That's an audit + RFC
## Fire-and-forget (`void`) -- Intentional, correct
These are telemetry, metrics, and audit logging in hot paths or
non-critical contexts. `void` is the right choice.
| File | What was voided |
|---|---|
| `sign-in-up.service.ts` | `metricsService.incrementCounter` (sign-up
metric) + `auditService.insertWorkspaceEvent` (workspace created) |
| `use-graphql-error-handler.hook.ts` | 5x
`metricsService.incrementCounter` (GraphQL operation metrics) |
| `bullmq.driver.ts` | 2x `metricsService.incrementCounter` (job
completed/failed metrics) |
| `call-webhook.job.ts` | 2x `auditService.insertWorkspaceEvent` + 1x
`metricsService.incrementCounter` |
| `custom-domain-manager.service.ts` | `analytics.insertWorkspaceEvent`
(domain activation event) |
| `logic-function-executor.service.ts` |
`auditService.insertWorkspaceEvent` (function execution) |
| `workflow-runner.workspace-service.ts` |
`metricsService.incrementCounter` (throttle metric) |
| `cleaner.workspace-service.ts` | `metricsService.incrementCounter`
(deleted workspace metric) |
| `stream-agent-chat.job.ts` | Detached IIFE for streaming chunks
(intentional concurrent pipeline) |
| `workspace-auth-context.middleware.ts` |
`withWorkspaceAuthContext(...)` (AsyncLocalStorage, returns void anyway)
|
## Top-level script entry points (`void bootstrap()`)
These are module-level calls where the promise has no consumer. `void`
makes the lint rule happy and documents the intent.
| File | What changed |
|---|---|
| `main.ts` | `void bootstrap()` |
| `command.ts` | `void bootstrap()` |
| `queue-worker.ts` | `void bootstrap()` |
| `truncate-db.ts` | `void dropSchemasSequentially()` |
| `codegen/index.ts` | `void generateTests(forceArg)` |
## Now properly awaited -- Real bug fixes
These were floating promises that could silently fail, lose data, or
cause race conditions.
| File | What was fixed |
|---|---|
| `billing-sync-plans-data.command.ts` | `meters.map(async ...)` wrapped
in `Promise.all` -- was returning before upserts finished |
| `cache-storage.service.ts` | `setAdd` and `setPop` had `.then()`
chains that weren't returned/awaited |
| `create-audit-log-from-internal-event.ts` | 4x
`auditService.createObjectEvent` now awaited inside a job |
| `cleaner.workspace-service.ts` | 2x `emailService.send(...)` now
awaited -- emails could silently fail |
| `agent-async-executor.service.ts` | `calculateAndBillUsage` +
`billNativeWebSearchUsage` in `finally` block now awaited |
| `repair-tool-call.util.ts` | `calculateAndBillUsage` now awaited |
| `agent-title-generation.service.ts` | `calculateAndBillUsage` now
awaited |
| `chat-execution.service.ts` | `billNativeWebSearchUsage` now awaited |
| `ai-generate-text.controller.ts` | `calculateAndBillUsage` in
`finally` block now awaited |
| `agent-turn.resolver.ts` | `messageQueueService.add(...)` now awaited
|
| `command.ts` | `app.close()` now awaited (was exiting before graceful
shutdown) |
| `i18n.service.ts` | `loadTranslations()` in `onModuleInit` now awaited
|
| `workspace-query-hook.explorer.ts` | `explore()` in `onModuleInit` now
awaited |
| `message-queue.explorer.ts` | `handleProcessorGroupCollection` in
`onModuleInit` now awaited |
| `ai-billing.service.spec.ts` | Test now properly `await`s the async
call |
| `messaging-messages-import.service.spec.ts` | `expect(...)` now
properly `await`ed for async assertion |
| `archive.finalize()` (3 files) | Voided -- promise resolution already
handled by `pipeline()` / `on('end')` |
## Impersonation & security audit trail -- Upgraded from `void` to
`await`
These were previously fire-and-forget but are
security/compliance-critical events that must be reliably persisted.
| File | What was fixed |
|---|---|
| `impersonation.service.ts` | 4x `auditService.insertWorkspaceEvent`
now awaited (impersonation attempt, token generation
attempt/success/failure) |
| `auth.resolver.ts` | 5x `auditService.insertWorkspaceEvent` now
awaited (impersonation token exchange attempt/success/failure at server
and workspace levels) |
| `auth.service.ts` | 2x `analytics.insertWorkspaceEvent` now awaited
(impersonation attempted/issued) |
## Billing audit -- Upgraded from `void` to `await`
Payment events should be reliably persisted for financial/compliance
reporting.
| File | What was fixed |
|---|---|
| `billing-webhook-invoice.service.ts` |
`auditService.insertWorkspaceEvent(PAYMENT_RECEIVED_EVENT)` now awaited
inside Stripe webhook handler |
## Fire-and-forget with proper error handling -- Upgraded from bare
`void`
These remain non-blocking but now catch and log errors instead of
risking unhandled rejections.
| File | What was fixed |
|---|---|
| `logic-function-executor.service.ts` |
`applicationLogsService.writeLogs` now uses `.catch()` instead of bare
`void` -- user-facing logs should surface errors |
## Systemic infrastructure fixes
| File | What was fixed |
|---|---|
| `metrics.service.ts` | `incrementCounter`: Redis cache write
(`metricsCacheService.updateCounter`) now uses `.catch()` internally
instead of raw `await` -- prevents unhandled rejections across all `void
metricsService.incrementCounter(...)` call sites when Redis is unhealthy
|
| `audit.service.ts` | `preventIfDisabled`: made properly `async` with
`await` and consistent `Promise<{ success: boolean }>` return type.
Removed broken `catch` that returned an `AuditException` as a value
(wrong constructor args, unreachable dead code). Removed unused
`AuditException` import |
## Fixed in this session (beyond original PR)
| File | What changed |
|---|---|
| `telemetry.listener.ts` | Removed misleading `Promise.all` + `void`
combo; replaced with simple `for...of` + `void` |
| `message-queue.explorer.ts` | Changed from `void` to `await` so
startup crashes on registration failure |
|
||
|
|
34b927ff23 |
feat(public-domain): bind public domains to apps + reorganize settings (#20360)
## Summary - **Public domains can now be bound to a specific app.** When a request hits an app-bound public domain, route resolution restricts logic-function matching to that app's HTTP-routed functions only — isolating each app's routes to its own domain instead of letting routes from other apps in the workspace match nondeterministically. - **Settings sidebar reorganized.** Removed the standalone Domains page. Workspace Domain → General. Approved Domains + Invitations → Members "Access" tab. Emailing Domains + Public Domains → Apps "Developer" tab. Roles → Members "Roles" tab. ## Why The use case: someone building a partner portal app or a lead-collection app declares private objects (leads, partners…) plus a few public HTTP routes. Each app needs its own domain (`partners.acme.com`, `leads.acme.com`) without those domains exposing every other app's routes in the same workspace. Today's PublicDomainEntity is workspace-scoped only, so all HTTP-routed logic functions in a workspace compete for any public domain — first match wins nondeterministically. ## Backend - Added nullable `applicationId` FK to `PublicDomainEntity` (cascade-deleted with the app); indexed for the route-trigger lookup. - New fast instance command `2-4-instance-command-fast-1798000003000-add-application-id-to-public-domain` adds the column, index, and FK constraint. - `createPublicDomain(domain, applicationId)` accepts an optional app binding; new `updatePublicDomain(domain, applicationId)` mutation rebinds/unbinds an existing domain. Both validate the application belongs to the workspace. - `WorkspaceDomainsService.resolveWorkspaceAndPublicDomain(origin)` returns both the workspace and the matched public domain in one query — replacing the old back-to-back lookups in the route-trigger hot path. `getWorkspaceByOriginOrDefaultWorkspace` is preserved as a thin wrapper. - `RouteTriggerService` filters `logicFunction` by `applicationId` when the matched public domain is app-scoped; falls back to workspace-wide when unbound. - Three sequential validation queries in `createPublicDomain` now run in parallel via `Promise.all`. ## Frontend | Old location | New location | |---|---| | Settings sidebar → Domains (standalone page) | Removed | | Domains page → Workspace Domain | General page | | Domains page → Approved Domains | Members → Access tab | | Domains page → Emailing Domains | Apps → Developer tab | | Domains page → Public Domains | Apps → Developer tab | | Settings sidebar → Roles (standalone) | Members → Roles tab | | `pages/settings/roles/` | `pages/settings/members/roles/` | - The Public Domain detail page has an Application picker that uses `Select`'s native `emptyOption` + `null` value pattern (matches `SettingsDataModelObjectIdentifiersForm`). - Members page tabs use the existing `TabListFromUrlOptionalEffect` mechanism (rendered automatically by `TabList`) for hash-based tab activation. - `/settings/members/roles` redirects to `/settings/members#roles` so role sub-pages' `navigate(SettingsPath.Roles)` lands on the Members page with the Roles tab pre-selected. - All affected breadcrumbs updated to nest under their new parents. - `SettingsPath.Roles` and friends now nest under `members/`; `Subdomain` and `CustomDomain` under `general/`; `PublicDomain` and `EmailingDomain` under `applications/`. ## Test plan - [x] `nx typecheck twenty-front` passes - [x] `nx typecheck twenty-server` passes - [x] `oxlint --type-aware` clean on all touched files - [x] `prettier --check` clean on all touched files - [x] Migration applied locally; `publicDomain.applicationId` (uuid, nullable) confirmed in DB - [x] GraphQL schema exposes `PublicDomain.applicationId`, `createPublicDomain.applicationId`, `updatePublicDomain` mutation - [x] **End-to-end route resolution scenarios verified locally:** - Domain bound to App A, function in App A → route matches ✅ - Domain bound to App B, function in App A → route does NOT match (HTTP 404 `TRIGGER_NOT_FOUND`) ✅ - Domain unbound (`applicationId = NULL`) → route matches workspace-wide ✅ - Unknown path on bound domain → returns 404 cleanly ✅ - [x] UI sanity (browser-tested at `apple.localhost:3001`): - General page shows Workspace Domain card - Members page shows Team / Access / Roles tabs - Access tab combines Invite by link + by email + Approved Domains - Roles tab embeds the role list - `/settings/members/roles` direct URL → redirects + Roles tab pre-selected - Apps Developer tab shows Emailing Domains + Public Domains sections - Public Domain detail page has Application picker dropdown listing workspace apps - Sidebar nav: "Domains" and "Roles" no longer present (now folded into General/Members) ## Notes for reviewers - Creating a public domain via the UI still requires Cloudflare credentials in the dev `.env` (`CLOUDFLARE_API_KEY`, `CLOUDFLARE_PUBLIC_DOMAIN_ZONE_ID`, `PUBLIC_DOMAIN_URL`). The DNS step is unchanged from main. - The `applicationId` column is nullable, so existing public-domain rows continue to work workspace-wide — no data backfill required. - `SettingsRolesContainer` was deleted (no longer referenced after `SettingsRoles` index page was removed). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
cd651f57cb |
fix: prevent blank subdomain from being saved (#18812)
## Summary Fixes #17941 — Saving a blank subdomain causes a redirect to `.website.com`, effectively breaking the workspace. **Root cause:** Three layers all fail to reject an empty string `""`: 1. **Frontend (`SettingsDomain.tsx`):** `SaveButton` has both `onClick={onSave}` and `type="submit"`. The `onClick` fires first, calling `handleSave()` directly without running Zod validation. So `isDefined("")` returns `true`, the confirmation modal opens, and the blank subdomain is submitted. 2. **Backend DTO (`update-workspace-input.ts`):** The `subdomain` field has `@IsString()` + `@IsOptional()` but no pattern validation, so an empty string passes the DTO layer. 3. **Backend service (`workspace.service.ts:152`):** `if (payload.subdomain && ...)` — empty string is falsy in JS, so it skips `validateSubdomainOrThrow()` entirely and writes `subdomain: ""` to the database. **The crash:** After save, the redirect logic does `"myworkspace.website.com".replace("myworkspace", "")` → `".website.com"`, sending the user to an invalid URL. ## Fix - **Frontend:** Call `form.trigger()` at the start of `handleSave` to run Zod validation regardless of whether the function was invoked via `onClick` or `form.handleSubmit`. Returns early with validation error if invalid. - **Backend DTO:** Add `@Matches(/^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$/)` to reject invalid subdomains at the request validation layer (defense-in-depth). - **Backend service:** Change `if (payload.subdomain && ...)` to `if (isDefined(payload.subdomain) && ...)` so empty strings route through `validateSubdomainOrThrow()` instead of being silently skipped. ## Test plan - [x] Existing `is-subdomain-valid.util.spec.ts` tests pass (36/36) - [x] TypeScript type checks pass for both `twenty-server` and `twenty-front` - [x] oxlint passes on all changed files - [x] Prettier passes on all changed files - [ ] Manual: Navigate to Settings > Domains, clear the subdomain field, click Save — should show validation error, not redirect --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Charles Bochet <charles@twenty.com> Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com> |
||
|
|
0cab2b49fc |
(breaking change) Allow users with a single workspace to update their email. (#15736)
- Users with a single workspace are allowed to update their email across `core.user` and `workspace_xyz.workspaceMember`. - The latter happens asynchronously (built it like this for non-blocking with multiple workspaces), but since we restrict the email update functionality to a single user, we can also update the email in workspaceMember synchronously - I left asynchronous there to receive feedback on whether we should move to synchronous or not. - Merged main and resolved conflicts to ensure we use the `SettingsPermissionGuard` and the updated `workspace.service.ts` code. One edge-case that I was trying to communicate on Discord: Say that an admin is a member of multiple workspaces. Therefore, they can allow roles with PROFILE_INFORMATION permission to update their email. <p align="center"> <img width="553" height="115" alt="image" src="https://github.com/user-attachments/assets/80382b1f-a9e3-4dac-b606-c2defeb2c330" /> </p> However, since the admin is part of multiple workspaces, he/she cannot even update own email - the field stays disabled, leading to some confusion. <p align="center"> <img width="545" height="255" alt="image" src="https://github.com/user-attachments/assets/5e6d27db-c9a8-4d5e-9ab6-65c77beae5b4" /> </p> However, the workspace can have another member with admin role or some other role that has PROFILE_INFORMATION permission flag. That user will be and should be allowed to update email, so we cannot hide `email` from dropdown options. <p align="center"> <img width="585" height="283" alt="image" src="https://github.com/user-attachments/assets/a670d3ac-cf48-4865-a425-b909093d8420" /> </p> The behavior is fine imo, just a little confusing for members with more than one workspace. I have also tested the flow by signing up to YC workspace with my org google account (twenty.com), then changing email to my personal address. - After changing, I need to login using Google with my personal account to access YC workspace again. - If I login using Google with org google account (twenty.com), a new user account is created. This behavior is consistent with Notion and Linear. Finally, as for the verification of email, the user is asked to verify email while they're logged in, but just in case they logout without verifying, the next login would force them to verify their email in the email/password flow. However, for Social/SSO, they must verify before they logout or else they'd have to contact support for assistance. I have not looked into how to show verification screen while logging in via Social/SSO yet, but if that's something critical for completeness here, I shall revisit it. --------- Co-authored-by: Félix Malfait <felix@twenty.com> |
||
|
|
9fef0752a1 | Fix subdomain generation at workspace creation (#15649) | ||
|
|
198bf5a333 |
Complete color refactoring (#15414)
# Complete color refactoring Closes https://github.com/twentyhq/core-team-issues/issues/1779 - Updated all colors to use Radix colors with P3 color space allowing for brighter colors - Created our own gray scale interpolated on Radix's scale to have the same values for grays as the old ones in the app - Introduced dark and light colors as well as there transparent versions - Added many new colors from radix that can be used in the tags or in the graphs - Updated multiple color utilities to match new behaviors - Changed the computation of Avatar colors to return only colors from the theme (before it was random hsl) These changes allow the user to use new colors in tags or charts, the colors are brighter and with better contrast. We have a full range of color variations from 1 to 12 where before we only had 4 adaptative colors. All these changes will allow us to develop custom themes for the user soon, where users can choose their accent colors, background colors and there contrast. |
||
|
|
c5564d9bd0 |
[BREAKING CHANGE] refactor: Add Entity suffix to TypeORM entity classes (#15239)
## Summary This PR refactors all TypeORM entity classes in the Twenty codebase to include an 'Entity' suffix (e.g., User → UserEntity, Workspace → WorkspaceEntity) to improve code clarity and follow TypeORM naming conventions. ## Changes ### Entity Renaming - ✅ Renamed **57 core TypeORM entities** with 'Entity' suffix - ✅ Updated all related imports, decorators, and type references - ✅ Fixed Repository<T>, @InjectRepository(), and TypeOrmModule.forFeature() patterns - ✅ Fixed @ManyToOne/@OneToMany/@OneToOne decorator references ### Backward Compatibility - ✅ Preserved GraphQL schema names using @ObjectType('OriginalName') decorators - ✅ **No breaking changes** to GraphQL API - ✅ **No database migrations** required - ✅ File names unchanged (user.entity.ts remains as-is) ### Code Quality - ✅ Fixed **497 TypeScript errors** (82% reduction from 606 to 109) - ✅ **All linter checks passing** - ✅ Improved type safety across the codebase ## Entities Renamed ``` User → UserEntity Workspace → WorkspaceEntity ApiKey → ApiKeyEntity AppToken → AppTokenEntity UserWorkspace → UserWorkspaceEntity Webhook → WebhookEntity FeatureFlag → FeatureFlagEntity ApprovedAccessDomain → ApprovedAccessDomainEntity TwoFactorAuthenticationMethod → TwoFactorAuthenticationMethodEntity WorkspaceSSOIdentityProvider → WorkspaceSSOIdentityProviderEntity EmailingDomain → EmailingDomainEntity KeyValuePair → KeyValuePairEntity PublicDomain → PublicDomainEntity PostgresCredentials → PostgresCredentialsEntity ...and 43 more entities ``` ## Impact ### Files Changed - **400 files** modified - **2,575 insertions**, **2,191 deletions** ### Progress - ✅ **82% complete** (497/606 errors fixed) - ⚠️ **109 TypeScript errors** remain (18% of original) ## Remaining Work The 109 remaining TypeScript errors are primarily: 1. **Function signature mismatches** (~15 errors) - Test mocks with incorrect parameter counts 2. **Entity type mismatches** (~25 errors) - UserEntity vs UserWorkspaceEntity confusion 3. **Pre-existing issues** (~50 errors) - Null safety and DTO compatibility (unrelated to refactoring) 4. **Import type issues** (~10 errors) - Entities imported with 'import type' but used as values 5. **Minor decorator issues** (~9 errors) - onDelete property configurations These can be addressed in follow-up PRs without blocking this refactoring. ## Testing Checklist - [x] Linter passing - [ ] Unit tests should be run (CI will verify) - [ ] Integration tests should be run (CI will verify) - [ ] Manual testing recommended for critical user flows ## Breaking Changes **None** - This is a pure refactoring with full backward compatibility: - GraphQL API unchanged (uses original entity names) - Database schema unchanged - External APIs unchanged ## Notes - Created comprehensive `REFACTORING_STATUS.md` documenting the entire process - All temporary scripts have been cleaned up - Branch: `refactor/add-entity-suffix-to-typeorm-entities` ## Reviewers Please review especially: - Entity renaming patterns - GraphQL backward compatibility - Any areas where entity types are confused (UserEntity vs UserWorkspaceEntity) --------- Co-authored-by: Charles Bochet <charles@twenty.com> |
||
|
|
26e432d8e2 |
fix: Add reserved subdomains constant and update validation on generateSubdomain (#15217)
## Description Fixes #15160 - Moved the reserved subdomains to a separate shared constant file: `packages/twenty-server/src/engine/core-modules/workspace/constants/reserved-subdomains.constant.ts` - Updated the validation while generating subdomain to check if the extracted subdomain (from email or display name) is reserved - When a reserved subdomain is detected, the server will automatically fall back to a random subdomain --------- Co-authored-by: Naineel Soyantar <naineelsoyantar@gmail.com> Co-authored-by: Charles Bochet <charles@twenty.com> |