From e0ebe0e0297cb200075e51a4b0b56183bfb52570 Mon Sep 17 00:00:00 2001
From: Rashad Karanouh <11599358+rashad@users.noreply.github.com>
Date: Tue, 30 Jun 2026 14:08:50 +0200
Subject: [PATCH] =?UTF-8?q?v1.2.0=20=E2=80=94=20Client=20brief=20intake=20?=
=?UTF-8?q?(marketplace=20B2)=20(#22290)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
**Version:** 1.2.0
(`packages/twenty-apps/internal/twenty-partners/package.json`)
Adds marketplace B2 client brief intake to the partners app:
- `POST /s/client-briefs` logic function — validates payload, creates an
unlisted Opportunity (`isListed = false`) with `— client brief` name
suffix
- **Client briefs** ops view for review before listing
- Unit + integration tests
Merge this **before** the website PR (`rk-client-brief-web`).
## Test plan
- [ ] `yarn twenty dev --once` syncs schema
- [ ] `yarn test` passes in `twenty-partners`
- [ ] Smoke: `POST /s/client-briefs` with `x-application-secret` creates
Opportunity with `isListed = false`
- [ ] Client briefs view visible in nav
---
.../internal/twenty-partners/package.json | 2 +-
.../src/constants/universal-identifiers.ts | 13 ++
.../submit-client-brief.integration-test.ts | 120 ++++++++++++++
.../__tests__/submit-client-brief.test.ts | 44 ++++++
.../find-or-create-company-and-person.ts | 84 ++++++++++
...ort-opportunity-from-tft.logic-function.ts | 25 ++-
.../submit-client-brief.logic-function.ts | 149 ++++++++++++++++++
.../page-layouts/opportunity.page-layout.ts | 8 +-
...ity-record-page-applications.view-field.ts | 21 +++
...tunity-record-page-is-listed.view-field.ts | 21 +++
...opportunity-record-page-need.view-field.ts | 20 +++
...ortunity-record-page-partner.view-field.ts | 21 +++
...ity-record-page-requirements.view-field.ts | 21 +++
.../opportunity-record-page-fields.view.ts | 85 ----------
14 files changed, 529 insertions(+), 105 deletions(-)
create mode 100644 packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/submit-client-brief.integration-test.ts
create mode 100644 packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/submit-client-brief.test.ts
create mode 100644 packages/twenty-apps/internal/twenty-partners/src/logic-functions/find-or-create-company-and-person.ts
create mode 100644 packages/twenty-apps/internal/twenty-partners/src/logic-functions/submit-client-brief.logic-function.ts
create mode 100644 packages/twenty-apps/internal/twenty-partners/src/view-fields/opportunity-record-page-applications.view-field.ts
create mode 100644 packages/twenty-apps/internal/twenty-partners/src/view-fields/opportunity-record-page-is-listed.view-field.ts
create mode 100644 packages/twenty-apps/internal/twenty-partners/src/view-fields/opportunity-record-page-need.view-field.ts
create mode 100644 packages/twenty-apps/internal/twenty-partners/src/view-fields/opportunity-record-page-partner.view-field.ts
create mode 100644 packages/twenty-apps/internal/twenty-partners/src/view-fields/opportunity-record-page-requirements.view-field.ts
delete mode 100644 packages/twenty-apps/internal/twenty-partners/src/views/opportunity-record-page-fields.view.ts
diff --git a/packages/twenty-apps/internal/twenty-partners/package.json b/packages/twenty-apps/internal/twenty-partners/package.json
index 5a796c4bc2..bcd29373c5 100644
--- a/packages/twenty-apps/internal/twenty-partners/package.json
+++ b/packages/twenty-apps/internal/twenty-partners/package.json
@@ -1,6 +1,6 @@
{
"name": "twenty-partners",
- "version": "1.1.16",
+ "version": "1.2.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
diff --git a/packages/twenty-apps/internal/twenty-partners/src/constants/universal-identifiers.ts b/packages/twenty-apps/internal/twenty-partners/src/constants/universal-identifiers.ts
index 1e9768f36b..48b27d3a84 100644
--- a/packages/twenty-apps/internal/twenty-partners/src/constants/universal-identifiers.ts
+++ b/packages/twenty-apps/internal/twenty-partners/src/constants/universal-identifiers.ts
@@ -39,3 +39,16 @@ export const PARTNER_CONTENT_VIEW_UNIVERSAL_IDENTIFIER = 'd9db705c-795a-4a14-b89
export const PARTNER_APPLICATIONS_NAV_UNIVERSAL_IDENTIFIER = '13e2334a-6b1e-4080-8c74-d11109990cc1';
export const VALIDATED_PARTNERS_NAV_UNIVERSAL_IDENTIFIER = '6aed30c6-d80f-4ac6-aab0-db5bc59e5c4b';
export const PARTNER_CONTENT_NAV_UNIVERSAL_IDENTIFIER = '3543723d-80c1-466a-ac35-86f7b284917b';
+
+// Opportunity record page (standard side panel Fields widget) — view-field UIDs
+// reused from the former custom FIELDS_WIDGET view so sync updates in place.
+export const OPPORTUNITY_RECORD_PAGE_IS_LISTED_VIEW_FIELD_UNIVERSAL_IDENTIFIER =
+ '5f8c539e-82df-4db6-af05-d4c4f9b262b1';
+export const OPPORTUNITY_RECORD_PAGE_NEED_VIEW_FIELD_UNIVERSAL_IDENTIFIER =
+ 'd44a8293-f9c5-41f6-9e08-85441475a1ce';
+export const OPPORTUNITY_RECORD_PAGE_REQUIREMENTS_VIEW_FIELD_UNIVERSAL_IDENTIFIER =
+ '3a97a3ce-ecfd-417f-8518-120899f1111e';
+export const OPPORTUNITY_RECORD_PAGE_PARTNER_VIEW_FIELD_UNIVERSAL_IDENTIFIER =
+ 'd186d83a-2f6b-4693-b7eb-abc47d110ae8';
+export const OPPORTUNITY_RECORD_PAGE_APPLICATIONS_VIEW_FIELD_UNIVERSAL_IDENTIFIER =
+ '5c286d5d-c849-4210-ba55-7a2e7dc28ff1';
diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/submit-client-brief.integration-test.ts b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/submit-client-brief.integration-test.ts
new file mode 100644
index 0000000000..06c0dd66c3
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/submit-client-brief.integration-test.ts
@@ -0,0 +1,120 @@
+import { CoreApiClient } from 'twenty-client-sdk/core';
+import { afterEach, beforeAll, describe, expect, it } from 'vitest';
+
+import { handler, type SubmitClientBriefInput } from '../submit-client-brief.logic-function';
+
+const TEST_SECRET = 'test-secret-abc123';
+process.env.PARTNER_APPLICATION_SECRET = TEST_SECRET;
+
+const client = new CoreApiClient();
+
+const baseInput = (overrides: Partial = {}): SubmitClientBriefInput => ({
+ firstName: 'Jane',
+ lastName: 'Smith',
+ email: `jane.brief.${Date.now()}@example.com`,
+ companyName: `Acme Brief Co ${Date.now()}`,
+ need: 'Set up CRM pipelines for sales',
+ ...overrides,
+});
+
+const authedEvent = (input: SubmitClientBriefInput) => ({
+ body: input,
+ headers: { 'x-application-secret': TEST_SECRET },
+});
+
+const createdOpportunityIds: string[] = [];
+const createdPersonIds: string[] = [];
+const createdCompanyIds: string[] = [];
+
+async function cleanup(): Promise {
+ for (const id of createdOpportunityIds.splice(0)) {
+ await client.mutation({ destroyOpportunity: { __args: { id }, id: true } }).catch(() => {});
+ }
+ for (const id of createdPersonIds.splice(0)) {
+ await client.mutation({ destroyPerson: { __args: { id }, id: true } }).catch(() => {});
+ }
+ for (const id of createdCompanyIds.splice(0)) {
+ await client.mutation({ destroyCompany: { __args: { id }, id: true } }).catch(() => {});
+ }
+}
+
+async function trackCreatedOpportunity(opportunityId: string): Promise {
+ createdOpportunityIds.push(opportunityId);
+
+ const fetched = await client.query({
+ opportunity: {
+ __args: { filter: { id: { eq: opportunityId } } },
+ company: { id: true },
+ pointOfContact: { id: true },
+ },
+ });
+
+ if (fetched.opportunity?.company?.id) createdCompanyIds.push(fetched.opportunity.company.id);
+ if (fetched.opportunity?.pointOfContact?.id) {
+ createdPersonIds.push(fetched.opportunity.pointOfContact.id);
+ }
+}
+
+afterEach(async () => {
+ await cleanup();
+});
+
+beforeAll(async () => {
+ await client.query({ opportunities: { __args: { first: 1 }, edges: { node: { id: true } } } });
+});
+
+describe('submit-client-brief handler', () => {
+ it('returns unauthorized without secret', async () => {
+ const result = await handler({ body: baseInput(), headers: {} });
+ expect(result).toEqual({ ok: false, reason: 'unauthorized' });
+ });
+
+ it('creates an unlisted opportunity with marketplace brief name suffix', async () => {
+ const input = baseInput({
+ requirements: 'French UI',
+ hostingType: 'CLOUD',
+ seatCount: '~30',
+ });
+ const result = await handler(authedEvent(input));
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+
+ await trackCreatedOpportunity(result.opportunityId);
+
+ const fetched = await client.query({
+ opportunity: {
+ __args: { filter: { id: { eq: result.opportunityId } } },
+ id: true,
+ name: true,
+ need: true,
+ requirements: true,
+ isListed: true,
+ stage: true,
+ company: { id: true, name: true },
+ pointOfContact: { id: true, emails: { primaryEmail: true } },
+ },
+ });
+
+ const opp = fetched.opportunity;
+ expect(opp?.name).toBe(`${input.companyName} — marketplace brief`);
+ expect(opp?.need).toBe(input.need);
+ expect(opp?.requirements).toContain('French UI');
+ expect(opp?.requirements).toContain('Additional context:');
+ expect(opp?.isListed).toBe(false);
+ expect(opp?.stage).toBe('NEW');
+ expect(opp?.company?.name).toBe(input.companyName);
+ expect(opp?.pointOfContact?.emails?.primaryEmail).toBe(input.email);
+ });
+
+ it('creates a second opportunity for the same company with different need', async () => {
+ const companyName = `Repeat Co ${Date.now()}`;
+ const email = `repeat.${Date.now()}@example.com`;
+ const first = await handler(authedEvent(baseInput({ companyName, email, need: 'Project A' })));
+ const second = await handler(authedEvent(baseInput({ companyName, email, need: 'Project B' })));
+ expect(first.ok && second.ok).toBe(true);
+ if (!first.ok || !second.ok) return;
+ expect(first.opportunityId).not.toBe(second.opportunityId);
+ await trackCreatedOpportunity(first.opportunityId);
+ await trackCreatedOpportunity(second.opportunityId);
+ });
+});
diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/submit-client-brief.test.ts b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/submit-client-brief.test.ts
new file mode 100644
index 0000000000..619bba97da
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/submit-client-brief.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from 'vitest';
+
+import { buildRequirementsText } from '../submit-client-brief.logic-function';
+
+const base = {
+ firstName: 'Jane',
+ lastName: 'Smith',
+ email: 'jane@acme.com',
+ companyName: 'Acme Real Estate',
+ need: 'Migrate from HubSpot',
+};
+
+describe('buildRequirementsText', () => {
+ it('returns null when no requirements or context fields', () => {
+ expect(buildRequirementsText(base)).toBeNull();
+ });
+
+ it('returns only base requirements when no context', () => {
+ expect(buildRequirementsText({ ...base, requirements: 'Must go live Q4' })).toBe(
+ 'Must go live Q4',
+ );
+ });
+
+ it('appends additional context block when context fields present', () => {
+ const text = buildRequirementsText({
+ ...base,
+ requirements: 'French UI',
+ hostingType: 'CLOUD',
+ seatCount: '~30',
+ country: 'France',
+ });
+ expect(text).toContain('French UI');
+ expect(text).toContain('Additional context:');
+ expect(text).toContain('• Hosting: Cloud');
+ expect(text).toContain('• Seats: ~30');
+ expect(text).toContain('• Country: France');
+ });
+
+ it('omits empty context bullets', () => {
+ const text = buildRequirementsText({ ...base, hostingType: 'SELF_HOSTING' });
+ expect(text).toContain('• Hosting: Self-hosting');
+ expect(text).not.toContain('• Seats:');
+ });
+});
diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/find-or-create-company-and-person.ts b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/find-or-create-company-and-person.ts
new file mode 100644
index 0000000000..ad920c3d75
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/find-or-create-company-and-person.ts
@@ -0,0 +1,84 @@
+import type { CoreApiClient } from 'twenty-client-sdk/core';
+
+export async function findCompanyIdByExactName(
+ client: CoreApiClient,
+ companyName: string,
+): Promise {
+ const name = companyName.trim();
+
+ const lookup = await client.query({
+ companies: {
+ __args: { filter: { name: { eq: name } }, first: 1 },
+ edges: { node: { id: true } },
+ },
+ });
+
+ return lookup.companies?.edges?.[0]?.node?.id;
+}
+
+export async function findOrCreateCompanyByName(
+ client: CoreApiClient,
+ companyName: string,
+): Promise {
+ const existing = await findCompanyIdByExactName(client, companyName);
+ if (existing !== undefined) return existing;
+
+ const name = companyName.trim();
+ const result = await client.mutation({
+ createCompany: { __args: { data: { name } }, id: true },
+ });
+ const id = result.createCompany?.id;
+ if (id === undefined) throw new Error('createCompany did not return an id');
+ return id;
+}
+
+export async function findPersonIdByPrimaryEmail(
+ client: CoreApiClient,
+ email: string,
+): Promise {
+ const primaryEmail = email.trim();
+
+ const lookup = await client.query({
+ people: {
+ __args: { filter: { emails: { primaryEmail: { eq: primaryEmail } } }, first: 1 },
+ edges: { node: { id: true } },
+ },
+ });
+
+ return lookup.people?.edges?.[0]?.node?.id;
+}
+
+export type FindOrCreatePersonByEmailInput = {
+ email: string;
+ firstName: string;
+ lastName: string;
+ companyId: string;
+};
+
+export async function findOrCreatePersonByEmail(
+ client: CoreApiClient,
+ input: FindOrCreatePersonByEmailInput,
+): Promise {
+ const email = input.email.trim();
+ const firstName = input.firstName.trim();
+ const lastName = input.lastName.trim();
+
+ const existing = await findPersonIdByPrimaryEmail(client, email);
+ if (existing !== undefined) return existing;
+
+ const result = await client.mutation({
+ createPerson: {
+ __args: {
+ data: {
+ name: { firstName, lastName },
+ emails: { primaryEmail: email },
+ companyId: input.companyId,
+ },
+ },
+ id: true,
+ },
+ });
+ const id = result.createPerson?.id;
+ if (id === undefined) throw new Error('createPerson did not return an id');
+ return id;
+}
diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/import-opportunity-from-tft.logic-function.ts b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/import-opportunity-from-tft.logic-function.ts
index 0b43b2c11d..35bb3b829d 100644
--- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/import-opportunity-from-tft.logic-function.ts
+++ b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/import-opportunity-from-tft.logic-function.ts
@@ -2,6 +2,11 @@ import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
import { defineLogicFunction } from 'twenty-sdk/define';
import { z } from 'zod';
+import {
+ findCompanyIdByExactName,
+ findPersonIdByPrimaryEmail,
+} from './find-or-create-company-and-person';
+
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
@@ -71,14 +76,8 @@ async function findOrCreateCompanyId(
if (name === undefined && domain === undefined) return undefined;
if (name !== undefined) {
- const lookup = await client.query({
- companies: {
- __args: { filter: { name: { eq: name } }, first: 1 },
- edges: { node: { id: true } },
- },
- });
- const existing = lookup.companies?.edges?.[0]?.node;
- if (existing) return existing.id;
+ const existing = await findCompanyIdByExactName(client, name);
+ if (existing !== undefined) return existing;
}
const companyData: CoreSchema.CompanyCreateInput = { name: name ?? domain! };
@@ -110,14 +109,8 @@ async function findOrCreatePersonId(
if (email === undefined && firstName === '' && lastName === '') return undefined;
if (email !== undefined) {
- const lookup = await client.query({
- people: {
- __args: { filter: { emails: { primaryEmail: { eq: email } } }, first: 1 },
- edges: { node: { id: true } },
- },
- });
- const existing = lookup.people?.edges?.[0]?.node;
- if (existing) return existing.id;
+ const existing = await findPersonIdByPrimaryEmail(client, email);
+ if (existing !== undefined) return existing;
}
const personData: CoreSchema.PersonCreateInput = { name: { firstName, lastName } };
diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/submit-client-brief.logic-function.ts b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/submit-client-brief.logic-function.ts
new file mode 100644
index 0000000000..da0fda42d3
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/submit-client-brief.logic-function.ts
@@ -0,0 +1,149 @@
+import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
+import { defineLogicFunction } from 'twenty-sdk/define';
+import { z } from 'zod';
+
+import {
+ findOrCreateCompanyByName,
+ findOrCreatePersonByEmail,
+} from './find-or-create-company-and-person';
+
+export const SUBMIT_CLIENT_BRIEF_LOGIC_FUNCTION_ID =
+ 'a8f3c2e1-9b4d-4a7f-8c6e-1d2f3a4b5c6d';
+
+const HOSTING_LABEL: Record<'CLOUD' | 'SELF_HOSTING', string> = {
+ CLOUD: 'Cloud',
+ SELF_HOSTING: 'Self-hosting',
+};
+
+export const submitClientBriefSchema = z.object({
+ firstName: z.string().trim().min(1),
+ lastName: z.string(),
+ email: z.string().trim().email(),
+ companyName: z.string().trim().min(1),
+ need: z.string().trim().min(1),
+ requirements: z.string().optional(),
+ hostingType: z.enum(['CLOUD', 'SELF_HOSTING']).optional(),
+ country: z.string().optional(),
+ languages: z.array(z.string()).optional(),
+ seatCount: z.string().optional(),
+ timeline: z.string().optional(),
+ budgetRange: z.string().optional(),
+});
+
+export type SubmitClientBriefInput = z.infer;
+
+export type SubmitClientBriefResult =
+ | { ok: true; opportunityId: string }
+ | { ok: false; reason: string };
+
+function isNonEmptyString(value: unknown): value is string {
+ return typeof value === 'string' && value.trim().length > 0;
+}
+
+export function buildRequirementsText(input: SubmitClientBriefInput): string | null {
+ const base = isNonEmptyString(input.requirements) ? input.requirements.trim() : '';
+ const bullets: string[] = [];
+ if (input.hostingType !== undefined) {
+ bullets.push(`• Hosting: ${HOSTING_LABEL[input.hostingType]}`);
+ }
+ if (isNonEmptyString(input.seatCount)) bullets.push(`• Seats: ${input.seatCount.trim()}`);
+ if (isNonEmptyString(input.country)) bullets.push(`• Country: ${input.country.trim()}`);
+ if (input.languages !== undefined && input.languages.length > 0) {
+ bullets.push(`• Languages: ${input.languages.join(', ')}`);
+ }
+ if (isNonEmptyString(input.timeline)) bullets.push(`• Timeline: ${input.timeline.trim()}`);
+ if (isNonEmptyString(input.budgetRange)) bullets.push(`• Budget: ${input.budgetRange.trim()}`);
+ if (bullets.length === 0) return base.length > 0 ? base : null;
+ const block = `---\nAdditional context:\n${bullets.join('\n')}`;
+ return base ? `${base}\n\n${block}` : block;
+}
+
+type SubmitClientBriefEvent = {
+ headers?: Record;
+ body?: unknown;
+};
+
+const APPLICATION_SECRET_HEADER = 'x-application-secret';
+
+export const handler = async (
+ event: SubmitClientBriefEvent | SubmitClientBriefInput,
+): Promise => {
+ const looksLikeEvent =
+ typeof event === 'object' &&
+ event !== null &&
+ ('body' in event || 'headers' in event);
+
+ const headers = looksLikeEvent
+ ? (event as SubmitClientBriefEvent).headers ?? {}
+ : {};
+ const rawInput = looksLikeEvent
+ ? (event as SubmitClientBriefEvent).body
+ : event;
+
+ const expectedSecret = process.env.PARTNER_APPLICATION_SECRET;
+ if (!isNonEmptyString(expectedSecret)) {
+ return { ok: false, reason: 'unauthorized' };
+ }
+ const providedSecret = headers[APPLICATION_SECRET_HEADER];
+ if (providedSecret !== expectedSecret) {
+ return { ok: false, reason: 'unauthorized' };
+ }
+
+ const parsed = submitClientBriefSchema.safeParse(rawInput);
+ if (!parsed.success) {
+ return { ok: false, reason: 'invalid_input' };
+ }
+ const input = parsed.data;
+
+ try {
+ const client = new CoreApiClient();
+ const name = `${input.companyName.trim()} — marketplace brief`;
+ const requirements = buildRequirementsText(input);
+
+ const companyId = await findOrCreateCompanyByName(client, input.companyName);
+ const pointOfContactId = await findOrCreatePersonByEmail(client, {
+ email: input.email,
+ firstName: input.firstName,
+ lastName: input.lastName,
+ companyId,
+ });
+
+ const opportunityData: CoreSchema.OpportunityCreateInput = {
+ name,
+ need: input.need,
+ isListed: false,
+ stage: 'NEW',
+ companyId,
+ pointOfContactId,
+ };
+ if (requirements !== null) {
+ opportunityData.requirements = requirements;
+ }
+
+ const result = await client.mutation({
+ createOpportunity: { __args: { data: opportunityData }, id: true },
+ });
+ const opportunityId = result.createOpportunity?.id;
+ if (opportunityId === undefined) {
+ throw new Error('createOpportunity did not return an id');
+ }
+
+ return { ok: true, opportunityId };
+ } catch (err) {
+ return { ok: false, reason: err instanceof Error ? err.message : String(err) };
+ }
+};
+
+export default defineLogicFunction({
+ universalIdentifier: SUBMIT_CLIENT_BRIEF_LOGIC_FUNCTION_ID,
+ name: 'submit-client-brief',
+ description: 'Create an unlisted Opportunity from the public marketplace brief form.',
+ timeoutSeconds: 15,
+ handler,
+ httpRouteTriggerSettings: {
+ path: '/client-briefs',
+ httpMethod: 'POST',
+ isAuthRequired: false,
+ forwardedRequestHeaders: [APPLICATION_SECRET_HEADER],
+ },
+});
diff --git a/packages/twenty-apps/internal/twenty-partners/src/page-layouts/opportunity.page-layout.ts b/packages/twenty-apps/internal/twenty-partners/src/page-layouts/opportunity.page-layout.ts
index d59a947b56..bee7533d5f 100644
--- a/packages/twenty-apps/internal/twenty-partners/src/page-layouts/opportunity.page-layout.ts
+++ b/packages/twenty-apps/internal/twenty-partners/src/page-layouts/opportunity.page-layout.ts
@@ -4,11 +4,13 @@ import {
definePageLayout,
} from 'twenty-sdk/define';
-import { OPPORTUNITY_RECORD_PAGE_FIELDS_VIEW_ID } from 'src/views/opportunity-record-page-fields.view';
+const OPPORTUNITY_RECORD_PAGE_FIELDS_VIEW_ID =
+ STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.views
+ .opportunityRecordPageFields.universalIdentifier;
// Opportunity is a standard object, but we override its record page so the Fields widget
-// points at a FIELDS_WIDGET view — surfacing partner + applications relations in the side
-// panel (hidden by the platform default).
+// points at the standard record-page view — extended with app view-fields for brief
+// fields plus partner + applications relations in the side panel.
export default definePageLayout({
universalIdentifier: 'cf2c66e4-0a4b-48ce-8669-fdf39dd64148',
name: 'Default Opportunity Layout',
diff --git a/packages/twenty-apps/internal/twenty-partners/src/view-fields/opportunity-record-page-applications.view-field.ts b/packages/twenty-apps/internal/twenty-partners/src/view-fields/opportunity-record-page-applications.view-field.ts
new file mode 100644
index 0000000000..70d479fffe
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-partners/src/view-fields/opportunity-record-page-applications.view-field.ts
@@ -0,0 +1,21 @@
+import {
+ defineViewField,
+ STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
+} from 'twenty-sdk/define';
+
+import { OPPORTUNITY_RECORD_PAGE_APPLICATIONS_VIEW_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
+import { APPLICATIONS_ON_OPPORTUNITY_FIELD_ID } from 'src/objects/application.object';
+
+const OPPORTUNITY_RECORD_PAGE_FIELDS =
+ STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.views.opportunityRecordPageFields;
+
+export default defineViewField({
+ universalIdentifier:
+ OPPORTUNITY_RECORD_PAGE_APPLICATIONS_VIEW_FIELD_UNIVERSAL_IDENTIFIER,
+ viewUniversalIdentifier: OPPORTUNITY_RECORD_PAGE_FIELDS.universalIdentifier,
+ fieldMetadataUniversalIdentifier: APPLICATIONS_ON_OPPORTUNITY_FIELD_ID,
+ viewFieldGroupUniversalIdentifier:
+ OPPORTUNITY_RECORD_PAGE_FIELDS.viewFieldGroups.relations.universalIdentifier,
+ position: 4,
+ isVisible: true,
+});
diff --git a/packages/twenty-apps/internal/twenty-partners/src/view-fields/opportunity-record-page-is-listed.view-field.ts b/packages/twenty-apps/internal/twenty-partners/src/view-fields/opportunity-record-page-is-listed.view-field.ts
new file mode 100644
index 0000000000..3caea4ee32
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-partners/src/view-fields/opportunity-record-page-is-listed.view-field.ts
@@ -0,0 +1,21 @@
+import {
+ defineViewField,
+ STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
+} from 'twenty-sdk/define';
+
+import { OPPORTUNITY_RECORD_PAGE_IS_LISTED_VIEW_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
+import { OPPORTUNITY_IS_LISTED_FIELD_ID } from 'src/fields/opportunity-is-listed.field';
+
+const OPPORTUNITY_RECORD_PAGE_FIELDS =
+ STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.views.opportunityRecordPageFields;
+
+export default defineViewField({
+ universalIdentifier:
+ OPPORTUNITY_RECORD_PAGE_IS_LISTED_VIEW_FIELD_UNIVERSAL_IDENTIFIER,
+ viewUniversalIdentifier: OPPORTUNITY_RECORD_PAGE_FIELDS.universalIdentifier,
+ fieldMetadataUniversalIdentifier: OPPORTUNITY_IS_LISTED_FIELD_ID,
+ viewFieldGroupUniversalIdentifier:
+ OPPORTUNITY_RECORD_PAGE_FIELDS.viewFieldGroups.deal.universalIdentifier,
+ position: 3,
+ isVisible: true,
+});
diff --git a/packages/twenty-apps/internal/twenty-partners/src/view-fields/opportunity-record-page-need.view-field.ts b/packages/twenty-apps/internal/twenty-partners/src/view-fields/opportunity-record-page-need.view-field.ts
new file mode 100644
index 0000000000..48d06ae21b
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-partners/src/view-fields/opportunity-record-page-need.view-field.ts
@@ -0,0 +1,20 @@
+import {
+ defineViewField,
+ STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
+} from 'twenty-sdk/define';
+
+import { OPPORTUNITY_RECORD_PAGE_NEED_VIEW_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
+import { OPPORTUNITY_NEED_FIELD_ID } from 'src/fields/opportunity-need.field';
+
+const OPPORTUNITY_RECORD_PAGE_FIELDS =
+ STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.views.opportunityRecordPageFields;
+
+export default defineViewField({
+ universalIdentifier: OPPORTUNITY_RECORD_PAGE_NEED_VIEW_FIELD_UNIVERSAL_IDENTIFIER,
+ viewUniversalIdentifier: OPPORTUNITY_RECORD_PAGE_FIELDS.universalIdentifier,
+ fieldMetadataUniversalIdentifier: OPPORTUNITY_NEED_FIELD_ID,
+ viewFieldGroupUniversalIdentifier:
+ OPPORTUNITY_RECORD_PAGE_FIELDS.viewFieldGroups.deal.universalIdentifier,
+ position: 8,
+ isVisible: true,
+});
diff --git a/packages/twenty-apps/internal/twenty-partners/src/view-fields/opportunity-record-page-partner.view-field.ts b/packages/twenty-apps/internal/twenty-partners/src/view-fields/opportunity-record-page-partner.view-field.ts
new file mode 100644
index 0000000000..69ab044283
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-partners/src/view-fields/opportunity-record-page-partner.view-field.ts
@@ -0,0 +1,21 @@
+import {
+ defineViewField,
+ STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
+} from 'twenty-sdk/define';
+
+import { OPPORTUNITY_RECORD_PAGE_PARTNER_VIEW_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
+import { PARTNER_ON_OPPORTUNITY_FIELD_ID } from 'src/fields/partner-on-opportunity.field';
+
+const OPPORTUNITY_RECORD_PAGE_FIELDS =
+ STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.views.opportunityRecordPageFields;
+
+export default defineViewField({
+ universalIdentifier:
+ OPPORTUNITY_RECORD_PAGE_PARTNER_VIEW_FIELD_UNIVERSAL_IDENTIFIER,
+ viewUniversalIdentifier: OPPORTUNITY_RECORD_PAGE_FIELDS.universalIdentifier,
+ fieldMetadataUniversalIdentifier: PARTNER_ON_OPPORTUNITY_FIELD_ID,
+ viewFieldGroupUniversalIdentifier:
+ OPPORTUNITY_RECORD_PAGE_FIELDS.viewFieldGroups.relations.universalIdentifier,
+ position: 3,
+ isVisible: true,
+});
diff --git a/packages/twenty-apps/internal/twenty-partners/src/view-fields/opportunity-record-page-requirements.view-field.ts b/packages/twenty-apps/internal/twenty-partners/src/view-fields/opportunity-record-page-requirements.view-field.ts
new file mode 100644
index 0000000000..af543eb02a
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-partners/src/view-fields/opportunity-record-page-requirements.view-field.ts
@@ -0,0 +1,21 @@
+import {
+ defineViewField,
+ STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
+} from 'twenty-sdk/define';
+
+import { OPPORTUNITY_RECORD_PAGE_REQUIREMENTS_VIEW_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
+import { OPPORTUNITY_REQUIREMENTS_FIELD_ID } from 'src/fields/opportunity-requirements.field';
+
+const OPPORTUNITY_RECORD_PAGE_FIELDS =
+ STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.views.opportunityRecordPageFields;
+
+export default defineViewField({
+ universalIdentifier:
+ OPPORTUNITY_RECORD_PAGE_REQUIREMENTS_VIEW_FIELD_UNIVERSAL_IDENTIFIER,
+ viewUniversalIdentifier: OPPORTUNITY_RECORD_PAGE_FIELDS.universalIdentifier,
+ fieldMetadataUniversalIdentifier: OPPORTUNITY_REQUIREMENTS_FIELD_ID,
+ viewFieldGroupUniversalIdentifier:
+ OPPORTUNITY_RECORD_PAGE_FIELDS.viewFieldGroups.deal.universalIdentifier,
+ position: 9,
+ isVisible: true,
+});
diff --git a/packages/twenty-apps/internal/twenty-partners/src/views/opportunity-record-page-fields.view.ts b/packages/twenty-apps/internal/twenty-partners/src/views/opportunity-record-page-fields.view.ts
deleted file mode 100644
index 92e216b923..0000000000
--- a/packages/twenty-apps/internal/twenty-partners/src/views/opportunity-record-page-fields.view.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-import {
- STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
- ViewType,
- defineView,
-} from 'twenty-sdk/define';
-
-import { OPPORTUNITY_IS_LISTED_FIELD_ID } from 'src/fields/opportunity-is-listed.field';
-import { OPPORTUNITY_NEED_FIELD_ID } from 'src/fields/opportunity-need.field';
-import { OPPORTUNITY_REQUIREMENTS_FIELD_ID } from 'src/fields/opportunity-requirements.field';
-import { PARTNER_ON_OPPORTUNITY_FIELD_ID } from 'src/fields/partner-on-opportunity.field';
-import { APPLICATIONS_ON_OPPORTUNITY_FIELD_ID } from 'src/objects/application.object';
-
-const OPPORTUNITY_STAGE_FIELD_ID =
- STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.stage
- .universalIdentifier;
-const OPPORTUNITY_AMOUNT_FIELD_ID =
- STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.amount
- .universalIdentifier;
-const OPPORTUNITY_CLOSE_DATE_FIELD_ID =
- STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.closeDate
- .universalIdentifier;
-
-export const OPPORTUNITY_RECORD_PAGE_FIELDS_VIEW_ID =
- 'aa8a976c-aca6-4ca1-811e-fc3035e055aa';
-
-// FIELDS_WIDGET view backing the Opportunity record page side panel. Relation fields
-// (partner, applications) only render in the fields widget when an explicit view marks
-// them visible — this is that view.
-export default defineView({
- universalIdentifier: OPPORTUNITY_RECORD_PAGE_FIELDS_VIEW_ID,
- name: 'Opportunity Record Page Fields',
- objectUniversalIdentifier:
- STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
- type: ViewType.FIELDS_WIDGET,
- fields: [
- {
- universalIdentifier: '979ac0d4-2db4-48a2-8474-ff8cebd56669',
- fieldMetadataUniversalIdentifier: OPPORTUNITY_STAGE_FIELD_ID,
- position: 0,
- isVisible: true,
- },
- {
- universalIdentifier: '3c0f8ac5-914d-4840-b124-36f089b49dcc',
- fieldMetadataUniversalIdentifier: OPPORTUNITY_AMOUNT_FIELD_ID,
- position: 1,
- isVisible: true,
- },
- {
- universalIdentifier: 'a6ee4ae4-c905-4143-b7af-fffa0ceabf2d',
- fieldMetadataUniversalIdentifier: OPPORTUNITY_CLOSE_DATE_FIELD_ID,
- position: 2,
- isVisible: true,
- },
- {
- universalIdentifier: '5f8c539e-82df-4db6-af05-d4c4f9b262b1',
- fieldMetadataUniversalIdentifier: OPPORTUNITY_IS_LISTED_FIELD_ID,
- position: 3,
- isVisible: true,
- },
- {
- universalIdentifier: 'd44a8293-f9c5-41f6-9e08-85441475a1ce',
- fieldMetadataUniversalIdentifier: OPPORTUNITY_NEED_FIELD_ID,
- position: 4,
- isVisible: true,
- },
- {
- universalIdentifier: '3a97a3ce-ecfd-417f-8518-120899f1111e',
- fieldMetadataUniversalIdentifier: OPPORTUNITY_REQUIREMENTS_FIELD_ID,
- position: 5,
- isVisible: true,
- },
- {
- universalIdentifier: 'd186d83a-2f6b-4693-b7eb-abc47d110ae8',
- fieldMetadataUniversalIdentifier: PARTNER_ON_OPPORTUNITY_FIELD_ID,
- position: 6,
- isVisible: true,
- },
- {
- universalIdentifier: '5c286d5d-c849-4210-ba55-7a2e7dc28ff1',
- fieldMetadataUniversalIdentifier: APPLICATIONS_ON_OPPORTUNITY_FIELD_ID,
- position: 7,
- isVisible: true,
- },
- ],
-});