From 18fb0946e67c36e4dc0ac6305e8dd4f33d3b18ba Mon Sep 17 00:00:00 2001 From: Rashad Karanouh <11599358+rashad@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:26:41 +0200 Subject: [PATCH] =?UTF-8?q?v1.4.0=20=E2=80=94=20Raise=20partner=20bar:=20T?= =?UTF-8?q?wenty=20experience=20fields=20+=20triage=20(#23224)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary **Version:** `twenty-partners` **v1.4.0** (minor — new Partner fields + apply contract) - Add Partner fields `twentyExperience`, `twentyExperienceNotes`, `twentyExperienceProofLink` and persist them from `submit-partner-application` (≥200-char narrative at API boundary) - Surface Twenty experience on applications / validated / per-stage triage views and the Partner record side panel (drop empty Introduction from that panel) - Add pure Tally CSV match/map helpers (ops import script stays outside the repo) for backfilling existing partners by `partnerId` **Companion PR (website):** #23223 — Experience step on apply + thank-you without Cal. ## Test plan - [ ] `yarn twenty apply -r ` on a workspace — Partner gains the three experience fields - [ ] Website apply (with #23223) persists milestones / notes / proof link on create and email-linked update - [ ] Applications + Validated views show experience columns; record side panel lists experience fields - [ ] `yarn lint` clean; `yarn test:unit` covers schema + map-tally helpers - [ ] After Tally campaign: dry-run then apply CSV import via local ops script under `~/twenty/docs/superpowers-specs/raise-bar-import/` --- .../internal/twenty-partners/package.json | 2 +- ...ap-tally-experience-csv-row.mapper.test.ts | 176 ++++++++++++++ ...it-partner-application.integration-test.ts | 82 +++++++ .../submit-partner-application.schema.test.ts | 73 ++++++ .../mappers/build-partner-fields.mapper.ts | 15 ++ .../map-tally-experience-csv-row.mapper.ts | 219 ++++++++++++++++++ ...submit-partner-application-input.schema.ts | 87 +++++-- .../views/partner-applications.view.ts | 31 ++- .../partner-field-universal-identifiers.ts | 9 + .../partner-option-values.constant.ts | 10 + .../views/partner-record-page-fields.view.ts | 115 +++++---- .../views/partners-per-stage.view.ts | 11 +- .../views/partners-validated.view.ts | 21 +- .../modules/partner/objects/partner.object.ts | 54 +++++ 14 files changed, 834 insertions(+), 71 deletions(-) create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/__tests__/map-tally-experience-csv-row.mapper.test.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/mappers/map-tally-experience-csv-row.mapper.ts diff --git a/packages/twenty-apps/internal/twenty-partners/package.json b/packages/twenty-apps/internal/twenty-partners/package.json index 1a5dfafdf4..0c6eccc280 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.3.2", + "version": "1.4.0", "license": "MIT", "engines": { "node": "^24.5.0", diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/__tests__/map-tally-experience-csv-row.mapper.test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/__tests__/map-tally-experience-csv-row.mapper.test.ts new file mode 100644 index 0000000000..e969689d41 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/__tests__/map-tally-experience-csv-row.mapper.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from 'vitest'; + +import { + mapMilestoneLabels, + mapTallyExperienceCsvRow, + TWENTY_EXPERIENCE_NOTES_MIN_LENGTH, +} from '../mappers/map-tally-experience-csv-row.mapper'; + +const VALID_PARTNER_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + +const longNotes = (suffix = ''): string => { + const base = + 'Built a Twenty workspace for Acme with custom objects, roles, and a sales pipeline. '; + let notes = ''; + while (notes.length < TWENTY_EXPERIENCE_NOTES_MIN_LENGTH) { + notes += base; + } + return `${notes}${suffix}`; +}; + +const validRow = ( + overrides: Record = {}, +): Record => ({ + partnerId: VALID_PARTNER_ID, + "What you've built in Twenty": 'Custom apps, Workflows', + 'Tell us about the implementation': longNotes(), + 'Proof URL': 'https://www.loom.com/share/abc123', + Email: 'partner@example.com', + ...overrides, +}); + +describe('mapMilestoneLabels', () => { + it('maps the four canonical Tally labels to enums', () => { + expect( + mapMilestoneLabels( + 'Custom apps, Data models; Workflows\nFront components', + ), + ).toEqual({ + ok: true, + milestones: [ + 'CUSTOM_APPS', + 'DATA_MODELS', + 'WORKFLOWS', + 'FRONT_COMPONENTS', + ], + }); + }); + + it('dedupes repeated labels', () => { + expect(mapMilestoneLabels('Workflows, Workflows')).toEqual({ + ok: true, + milestones: ['WORKFLOWS'], + }); + }); + + it('rejects unknown labels without guessing', () => { + expect(mapMilestoneLabels('Custom apps, Integrations')).toEqual({ + ok: false, + reason: 'unknown_milestone_label:Integrations', + }); + }); + + it('rejects empty milestone input', () => { + expect(mapMilestoneLabels(' ')).toEqual({ + ok: false, + reason: 'missing_milestones', + }); + }); +}); + +describe('mapTallyExperienceCsvRow', () => { + it('maps a valid Tally row to an update intent by partnerId', () => { + const notes = longNotes(); + const result = mapTallyExperienceCsvRow( + validRow({ 'Tell us about the implementation': notes }), + ); + + expect(result).toEqual({ + ok: true, + intent: { + partnerId: VALID_PARTNER_ID, + twentyExperience: ['CUSTOM_APPS', 'WORKFLOWS'], + twentyExperienceNotes: notes.trim(), + twentyExperienceProofLink: 'https://www.loom.com/share/abc123', + }, + }); + }); + + it('never falls back to email when partnerId is missing', () => { + const result = mapTallyExperienceCsvRow( + validRow({ partnerId: undefined, Email: 'partner@example.com' }), + ); + + expect(result).toEqual({ ok: false, reason: 'missing_partner_id' }); + }); + + it('rejects invalid partnerId format', () => { + const result = mapTallyExperienceCsvRow( + validRow({ partnerId: 'not-a-uuid' }), + ); + + expect(result).toEqual({ + ok: false, + reason: 'invalid_partner_id', + partnerId: 'not-a-uuid', + }); + }); + + it('rejects narrative shorter than 200 characters', () => { + const shortNotes = 'Too short for a real implementation narrative.'; + const result = mapTallyExperienceCsvRow( + validRow({ 'Tell us about the implementation': shortNotes }), + ); + + expect(result).toEqual({ + ok: false, + reason: `notes_too_short:${shortNotes.length}`, + partnerId: VALID_PARTNER_ID, + }); + }); + + it('rejects missing proof URL', () => { + const result = mapTallyExperienceCsvRow( + validRow({ 'Proof URL': undefined }), + ); + + expect(result).toEqual({ + ok: false, + reason: 'missing_proof_url', + partnerId: VALID_PARTNER_ID, + }); + }); + + it('rejects non-http(s) proof URLs', () => { + const result = mapTallyExperienceCsvRow( + validRow({ 'Proof URL': 'ftp://files.example.com/case-study' }), + ); + + expect(result).toEqual({ + ok: false, + reason: 'invalid_proof_url', + partnerId: VALID_PARTNER_ID, + }); + }); + + it('rejects unknown milestone labels on the row', () => { + const result = mapTallyExperienceCsvRow( + validRow({ + "What you've built in Twenty": 'Custom apps, Self-hosting', + }), + ); + + expect(result).toEqual({ + ok: false, + reason: 'unknown_milestone_label:Self-hosting', + partnerId: VALID_PARTNER_ID, + }); + }); + + it('resolves Tally-style headers with helper copy via partial match', () => { + const result = mapTallyExperienceCsvRow({ + partnerId: VALID_PARTNER_ID, + "What you've built in Twenty (select every area)": 'Data models', + 'Tell us about the implementation — min 200 chars': longNotes('x'), + 'Proof URL (Loom ok)': 'http://example.com/proof', + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.intent.twentyExperience).toEqual(['DATA_MODELS']); + expect(result.intent.twentyExperienceProofLink).toBe( + 'http://example.com/proof', + ); + } + }); +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/__tests__/submit-partner-application.integration-test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/__tests__/submit-partner-application.integration-test.ts index 9cef48aa9f..14f359eb39 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/__tests__/submit-partner-application.integration-test.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/__tests__/submit-partner-application.integration-test.ts @@ -280,6 +280,88 @@ describe('submit-partner-application handler — upsert', () => { expect(result.reason).toBe('invalid_input'); }); + it('persists twenty experience fields on create', async () => { + const twentyExperienceNotes = + 'Implemented Twenty for a logistics client: custom apps for shipment intake, ' + + 'data models for carriers and lanes, workflows for exception routing, and a front ' + + 'component for the ops board with daily exception triage.'; + const result = await handler( + authedEvent( + baseInput({ + email: 'experience.create@example.com', + companyName: 'Experience Create Co', + twentyExperience: ['CUSTOM_APPS', 'DATA_MODELS', 'WORKFLOWS'], + twentyExperienceNotes, + twentyExperienceProofLink: 'https://www.loom.com/share/experience-create', + }), + ), + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + await trackCreated(result); + + const fetched = await client.query({ + partner: { + __args: { filter: { id: { eq: result.partnerId } } }, + twentyExperience: true, + twentyExperienceNotes: true, + twentyExperienceProofLink: { primaryLinkUrl: true }, + }, + }); + const node = fetched.partner; + expect(node?.twentyExperience).toEqual( + expect.arrayContaining(['CUSTOM_APPS', 'DATA_MODELS', 'WORKFLOWS']), + ); + expect(node?.twentyExperienceNotes).toBe(twentyExperienceNotes); + expect(node?.twentyExperienceProofLink?.primaryLinkUrl).toBe( + 'https://www.loom.com/share/experience-create', + ); + }); + + it('persists twenty experience fields on update for the same email', async () => { + const first = await handler( + authedEvent(baseInput({ email: 'experience.update@example.com' })), + ); + await trackCreated(first); + expect(first.ok).toBe(true); + if (!first.ok) return; + + const twentyExperienceNotes = + 'Updated narrative after resubmit: rebuilt the client data model, added workflows ' + + 'for renewals, and linked a Loom walkthrough of the front components we shipped for ' + + 'broker and ops roles across two workspaces.'; + const second = await handler( + authedEvent( + baseInput({ + email: 'experience.update@example.com', + twentyExperience: ['FRONT_COMPONENTS', 'WORKFLOWS'], + twentyExperienceNotes, + twentyExperienceProofLink: 'https://github.com/example/twenty-case-study', + }), + ), + ); + expect(second.ok).toBe(true); + if (!second.ok) return; + expect(second.created).toBe(false); + + const fetched = await client.query({ + partner: { + __args: { filter: { id: { eq: second.partnerId } } }, + twentyExperience: true, + twentyExperienceNotes: true, + twentyExperienceProofLink: { primaryLinkUrl: true }, + }, + }); + const node = fetched.partner; + expect(node?.twentyExperience).toEqual( + expect.arrayContaining(['FRONT_COMPONENTS', 'WORKFLOWS']), + ); + expect(node?.twentyExperienceNotes).toBe(twentyExperienceNotes); + expect(node?.twentyExperienceProofLink?.primaryLinkUrl).toBe( + 'https://github.com/example/twenty-case-study', + ); + }); + it('returns ok: false on malformed input (empty email)', async () => { const result = await handler( authedEvent({ diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/__tests__/submit-partner-application.schema.test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/__tests__/submit-partner-application.schema.test.ts index 07bf2a38b3..44de667b92 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/__tests__/submit-partner-application.schema.test.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/__tests__/submit-partner-application.schema.test.ts @@ -70,4 +70,77 @@ describe('submitPartnerApplicationSchema', () => { }); expect(result.success).toBe(true); }); + + const validExperienceNotes = + 'Built a custom Twenty app for a property-management client, modeled leases and ' + + 'tenants as data models, automated renewal workflows, and shipped a front component ' + + 'for the broker dashboard with role-based views.'; + + it('accepts twenty experience milestones, narrative, and proof link', () => { + const result = submitPartnerApplicationSchema.safeParse({ + ...base, + twentyExperience: [ + 'CUSTOM_APPS', + 'DATA_MODELS', + 'WORKFLOWS', + 'FRONT_COMPONENTS', + ], + twentyExperienceNotes: validExperienceNotes, + twentyExperienceProofLink: 'https://www.loom.com/share/example', + }); + expect(result.success).toBe(true); + }); + + it('rejects unknown twentyExperience milestone values', () => { + expect( + submitPartnerApplicationSchema.safeParse({ + ...base, + twentyExperience: ['INTEGRATIONS'], + twentyExperienceNotes: validExperienceNotes, + twentyExperienceProofLink: 'https://github.com/example/case', + }).success, + ).toBe(false); + }); + + it('rejects twentyExperienceNotes shorter than 200 characters', () => { + expect( + submitPartnerApplicationSchema.safeParse({ + ...base, + twentyExperience: ['WORKFLOWS'], + twentyExperienceNotes: 'Too short for a real implementation narrative.', + twentyExperienceProofLink: 'https://www.loom.com/share/example', + }).success, + ).toBe(false); + }); + + it('rejects a partial experience triad', () => { + expect( + submitPartnerApplicationSchema.safeParse({ + ...base, + twentyExperience: ['WORKFLOWS'], + }).success, + ).toBe(false); + }); + + it('rejects a non-http twentyExperienceProofLink', () => { + expect( + submitPartnerApplicationSchema.safeParse({ + ...base, + twentyExperience: ['WORKFLOWS'], + twentyExperienceNotes: validExperienceNotes, + twentyExperienceProofLink: 'javascript:alert(1)', + }).success, + ).toBe(false); + }); + + it('rejects an empty twentyExperience array when other experience fields are set', () => { + expect( + submitPartnerApplicationSchema.safeParse({ + ...base, + twentyExperience: [], + twentyExperienceNotes: validExperienceNotes, + twentyExperienceProofLink: 'https://www.loom.com/share/example', + }).success, + ).toBe(false); + }); }); diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/mappers/build-partner-fields.mapper.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/mappers/build-partner-fields.mapper.ts index c74a3fa184..14ddd47d7f 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/mappers/build-partner-fields.mapper.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/mappers/build-partner-fields.mapper.ts @@ -31,6 +31,9 @@ export type PartnerFieldsForUpsert = { projectBudgetMin?: { amountMicros: number; currencyCode: 'USD' }; calendarLink?: { primaryLinkUrl: string }; applicationNotes?: string | null; + twentyExperience?: CoreSchema.PartnerTwentyExperienceEnum[]; + twentyExperienceNotes?: string; + twentyExperienceProofLink?: { primaryLinkUrl: string }; }; export function buildPartnerFields(input: SubmitPartnerApplicationInput): PartnerFieldsForUpsert { @@ -56,6 +59,18 @@ export function buildPartnerFields(input: SubmitPartnerApplicationInput): Partne if (isNonEmptyString(input.calendarLink)) fields.calendarLink = { primaryLinkUrl: input.calendarLink.trim() }; const notes = buildApplicationNotes(input); if (notes !== null) fields.applicationNotes = notes; + if (input.twentyExperience !== undefined && input.twentyExperience.length > 0) { + fields.twentyExperience = + input.twentyExperience as CoreSchema.PartnerTwentyExperienceEnum[]; + } + if (isNonEmptyString(input.twentyExperienceNotes)) { + fields.twentyExperienceNotes = input.twentyExperienceNotes.trim(); + } + if (isNonEmptyString(input.twentyExperienceProofLink)) { + fields.twentyExperienceProofLink = { + primaryLinkUrl: input.twentyExperienceProofLink.trim(), + }; + } return fields; } diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/mappers/map-tally-experience-csv-row.mapper.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/mappers/map-tally-experience-csv-row.mapper.ts new file mode 100644 index 0000000000..72c1092a62 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/mappers/map-tally-experience-csv-row.mapper.ts @@ -0,0 +1,219 @@ +// Pure Tally CSV row → Partner experience update intent. +// Used by the local (uncommitted) raise-bar import ops script. +// Match key is partnerId only — never email. + +import { TWENTY_EXPERIENCE_NOTES_MIN_LENGTH } from 'src/modules/partner/constants/partner-option-values.constant'; +import { isNonEmptyString } from 'src/modules/shared/utils/is-non-empty-string.util'; + +export { TWENTY_EXPERIENCE_NOTES_MIN_LENGTH }; + +export const MILESTONE_LABEL_TO_ENUM = { + 'Custom apps': 'CUSTOM_APPS', + 'Data models': 'DATA_MODELS', + Workflows: 'WORKFLOWS', + 'Front components': 'FRONT_COMPONENTS', +} as const; + +export type TwentyExperienceMilestone = + (typeof MILESTONE_LABEL_TO_ENUM)[keyof typeof MILESTONE_LABEL_TO_ENUM]; + +export type PartnerExperienceUpdateIntent = { + partnerId: string; + twentyExperience: TwentyExperienceMilestone[]; + twentyExperienceNotes: string; + twentyExperienceProofLink: string; +}; + +export type MapTallyExperienceCsvRowResult = + | { ok: true; intent: PartnerExperienceUpdateIntent } + | { ok: false; reason: string; partnerId?: string }; + +const PARTNER_ID_HEADER_ALIASES = [ + 'partnerid', + 'partner id', + 'partner_id', + 'hidden partnerid', +] as const; + +const MILESTONES_HEADER_ALIASES = [ + "what you've built in twenty", + 'what youve built in twenty', + 'twenty experience', +] as const; + +const NOTES_HEADER_ALIASES = [ + 'tell us about the implementation', + 'twenty experience notes', +] as const; + +const PROOF_HEADER_ALIASES = [ + 'proof url', + 'twenty experience proof link', + 'proof link', +] as const; + +const PARTNER_ID_UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +const normalizeHeader = (header: string): string => + header + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + +export const getTallyExperienceCsvCell = ( + row: Record, + aliases: readonly string[], +): string | undefined => { + const normalizedEntries = Object.entries(row).map(([header, value]) => [ + normalizeHeader(header), + value, + ]); + + for (const alias of aliases) { + const normalizedAlias = normalizeHeader(alias); + for (const [header, value] of normalizedEntries) { + if (header === normalizedAlias && isNonEmptyString(value)) { + return value.trim(); + } + } + } + + // Partial match for long Tally titles that include helper copy. + for (const alias of aliases) { + const normalizedAlias = normalizeHeader(alias); + for (const [header, value] of normalizedEntries) { + if ( + header.includes(normalizedAlias) && + isNonEmptyString(value) && + // Never treat the optional email column as a match/notes/proof cell. + !header.includes('email') + ) { + return value.trim(); + } + } + } + + return undefined; +}; + +export const mapMilestoneLabels = ( + rawMilestones: string, +): + | { ok: true; milestones: TwentyExperienceMilestone[] } + | { ok: false; reason: string } => { + const labels = rawMilestones + .split(/[,;\n|]+/) + .map((label) => label.trim()) + .filter((label) => label.length > 0); + + if (labels.length === 0) { + return { ok: false, reason: 'missing_milestones' }; + } + + const milestones: TwentyExperienceMilestone[] = []; + const seen = new Set(); + + for (const label of labels) { + const mapped = + MILESTONE_LABEL_TO_ENUM[ + label as keyof typeof MILESTONE_LABEL_TO_ENUM + ]; + + if (mapped === undefined) { + return { ok: false, reason: `unknown_milestone_label:${label}` }; + } + + if (!seen.has(mapped)) { + seen.add(mapped); + milestones.push(mapped); + } + } + + return { ok: true, milestones }; +}; + +const isHttpOrHttpsUrl = (value: string): boolean => { + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +}; + +export const mapTallyExperienceCsvRow = ( + row: Record, +): MapTallyExperienceCsvRowResult => { + const partnerId = getTallyExperienceCsvCell(row, PARTNER_ID_HEADER_ALIASES); + + if (!isNonEmptyString(partnerId)) { + return { ok: false, reason: 'missing_partner_id' }; + } + + if (!PARTNER_ID_UUID_PATTERN.test(partnerId)) { + return { + ok: false, + reason: 'invalid_partner_id', + partnerId, + }; + } + + const rawMilestones = getTallyExperienceCsvCell(row, MILESTONES_HEADER_ALIASES); + if (!isNonEmptyString(rawMilestones)) { + return { ok: false, reason: 'missing_milestones', partnerId }; + } + + const mappedMilestones = mapMilestoneLabels(rawMilestones); + if (!mappedMilestones.ok) { + return { + ok: false, + reason: mappedMilestones.reason, + partnerId, + }; + } + + const twentyExperienceNotes = getTallyExperienceCsvCell( + row, + NOTES_HEADER_ALIASES, + ); + if (!isNonEmptyString(twentyExperienceNotes)) { + return { ok: false, reason: 'missing_notes', partnerId }; + } + + if (twentyExperienceNotes.length < TWENTY_EXPERIENCE_NOTES_MIN_LENGTH) { + return { + ok: false, + reason: `notes_too_short:${twentyExperienceNotes.length}`, + partnerId, + }; + } + + const twentyExperienceProofLink = getTallyExperienceCsvCell( + row, + PROOF_HEADER_ALIASES, + ); + if (!isNonEmptyString(twentyExperienceProofLink)) { + return { ok: false, reason: 'missing_proof_url', partnerId }; + } + + if (!isHttpOrHttpsUrl(twentyExperienceProofLink)) { + return { + ok: false, + reason: 'invalid_proof_url', + partnerId, + }; + } + + return { + ok: true, + intent: { + partnerId, + twentyExperience: mappedMilestones.milestones, + twentyExperienceNotes, + twentyExperienceProofLink, + }, + }; +}; diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/services/submit-partner-application-input.schema.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/services/submit-partner-application-input.schema.ts index ceea3c59b4..407f136ba3 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/services/submit-partner-application-input.schema.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/services/submit-partner-application-input.schema.ts @@ -5,30 +5,81 @@ import { PARTNER_LANGUAGE_VALUES, PARTNER_SCOPE_VALUES, PARTNER_TYPE_OF_TEAM_VALUES, + TWENTY_EXPERIENCE_NOTES_MIN_LENGTH, + TWENTY_EXPERIENCE_VALUES, } from 'src/modules/partner/constants/partner-option-values.constant'; +import { isHttpUrl } from 'src/modules/shared/utils/http-url.util'; // The request contract. zod is the single source of truth: it validates the // incoming body at runtime and the input type is inferred from it, so the two // can never drift. Enum-valued fields are constrained to the same option sets // the Partner object accepts. -export const submitPartnerApplicationSchema = z.object({ - firstName: z.string().trim().min(1), - lastName: z.string(), - email: z.string().trim().min(1), - companyName: z.string().trim().min(1), - domainName: z.string().optional(), - linkedin: z.string().optional(), - city: z.string().optional(), - country: z.enum(PARTNER_COUNTRY_VALUES).optional(), - languages: z.array(z.enum(PARTNER_LANGUAGE_VALUES)).optional(), - typeOfTeam: z.enum(PARTNER_TYPE_OF_TEAM_VALUES).optional(), - partnerScope: z.array(z.enum(PARTNER_SCOPE_VALUES)).optional(), - skills: z.array(z.string()).optional(), - applicationNotes: z.string().optional(), - hourlyRate: z.number().optional(), - projectBudgetMin: z.number().optional(), - calendarLink: z.string().optional(), -}); +export const submitPartnerApplicationSchema = z + .object({ + firstName: z.string().trim().min(1), + lastName: z.string(), + email: z.string().trim().min(1), + companyName: z.string().trim().min(1), + domainName: z.string().optional(), + linkedin: z.string().optional(), + city: z.string().optional(), + country: z.enum(PARTNER_COUNTRY_VALUES).optional(), + languages: z.array(z.enum(PARTNER_LANGUAGE_VALUES)).optional(), + typeOfTeam: z.enum(PARTNER_TYPE_OF_TEAM_VALUES).optional(), + partnerScope: z.array(z.enum(PARTNER_SCOPE_VALUES)).optional(), + skills: z.array(z.string()).optional(), + applicationNotes: z.string().optional(), + twentyExperience: z.array(z.enum(TWENTY_EXPERIENCE_VALUES)).optional(), + // Apply narrative floor at the API boundary (defense in depth vs website). + twentyExperienceNotes: z + .string() + .trim() + .min(TWENTY_EXPERIENCE_NOTES_MIN_LENGTH) + .optional(), + twentyExperienceProofLink: z + .string() + .trim() + .refine(isHttpUrl, { message: 'URL must use http or https' }) + .optional(), + hourlyRate: z.number().optional(), + projectBudgetMin: z.number().optional(), + calendarLink: z.string().optional(), + }) + .superRefine((data, context) => { + const hasMilestones = (data.twentyExperience?.length ?? 0) > 0; + const hasNotes = data.twentyExperienceNotes !== undefined; + const hasProof = data.twentyExperienceProofLink !== undefined; + const anyExperienceSet = hasMilestones || hasNotes || hasProof; + + if (!anyExperienceSet) { + return; + } + + // When any experience field is present, require the full triad (website parity). + if (!hasMilestones) { + context.addIssue({ + code: 'custom', + message: 'At least one Twenty experience milestone is required.', + path: ['twentyExperience'], + }); + } + + if (!hasNotes) { + context.addIssue({ + code: 'custom', + message: `Twenty experience notes must be at least ${TWENTY_EXPERIENCE_NOTES_MIN_LENGTH} characters.`, + path: ['twentyExperienceNotes'], + }); + } + + if (!hasProof) { + context.addIssue({ + code: 'custom', + message: 'Twenty experience proof link is required.', + path: ['twentyExperienceProofLink'], + }); + } + }); export type SubmitPartnerApplicationInput = z.infer< typeof submitPartnerApplicationSchema diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/views/partner-applications.view.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/views/partner-applications.view.ts index 5f733e5b50..9bab82e4f3 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/views/partner-applications.view.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/views/partner-applications.view.ts @@ -7,8 +7,10 @@ import { import { PARTNER_COUNTRY_FIELD_UNIVERSAL_IDENTIFIER, - PARTNER_INTRODUCTION_FIELD_UNIVERSAL_IDENTIFIER, PARTNER_NAME_FIELD_UNIVERSAL_IDENTIFIER, + PARTNER_SCOPE_FIELD_UNIVERSAL_IDENTIFIER, + PARTNER_TWENTY_EXPERIENCE_FIELD_UNIVERSAL_IDENTIFIER, + PARTNER_TWENTY_EXPERIENCE_NOTES_FIELD_UNIVERSAL_IDENTIFIER, PARTNER_VALIDATION_STAGE_FIELD_UNIVERSAL_IDENTIFIER, } from 'src/modules/partner/constants/partner-field-universal-identifiers'; import { @@ -52,23 +54,39 @@ export default defineView({ isVisible: true, }, { - universalIdentifier: '2d198c8a-0199-4b9f-ae07-255172ab6e7e', - fieldMetadataUniversalIdentifier: PARTNER_INTRODUCTION_FIELD_UNIVERSAL_IDENTIFIER, + universalIdentifier: '88d68442-514d-43a5-b974-8aa22b65cac8', + fieldMetadataUniversalIdentifier: PARTNER_SCOPE_FIELD_UNIVERSAL_IDENTIFIER, position: 2, isVisible: true, + size: 200, + }, + { + universalIdentifier: '1bb5fa0a-7724-4b72-812b-494e3cda7e9a', + fieldMetadataUniversalIdentifier: + PARTNER_TWENTY_EXPERIENCE_FIELD_UNIVERSAL_IDENTIFIER, + position: 3, + isVisible: true, + size: 220, + }, + { + universalIdentifier: '5f520664-38ba-4151-b1b8-3dfe7b640707', + fieldMetadataUniversalIdentifier: + PARTNER_TWENTY_EXPERIENCE_NOTES_FIELD_UNIVERSAL_IDENTIFIER, + position: 4, + isVisible: true, size: 280, }, { universalIdentifier: '835c9a7e-72ec-46c5-8d90-39a02998f561', fieldMetadataUniversalIdentifier: PARTNER_CREATED_AT_FIELD_ID, - position: 3, + position: 5, isVisible: true, size: 180, }, { universalIdentifier: '0175e169-377c-4ff5-b0f0-c3359cac48d9', fieldMetadataUniversalIdentifier: PARTNER_USER_ON_PARTNER_FIELD_ID, - position: 4, + position: 6, isVisible: true, size: 200, }, @@ -76,7 +94,8 @@ export default defineView({ filters: [ { universalIdentifier: 'a1ef0bcd-6f2c-44fc-ab3d-11df2523a952', - fieldMetadataUniversalIdentifier: PARTNER_VALIDATION_STAGE_FIELD_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: + PARTNER_VALIDATION_STAGE_FIELD_UNIVERSAL_IDENTIFIER, operand: ViewFilterOperand.IS, value: ['APPLICATION'], }, diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/constants/partner-field-universal-identifiers.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/constants/partner-field-universal-identifiers.ts index 795e63ec2f..752f4cd10c 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/constants/partner-field-universal-identifiers.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/constants/partner-field-universal-identifiers.ts @@ -61,3 +61,12 @@ export const PARTNER_WEBSITE_FIELD_UNIVERSAL_IDENTIFIER = export const PARTNER_CALENDAR_LINK_FIELD_UNIVERSAL_IDENTIFIER = 'a0000008-0000-4000-8000-000000000008'; + +export const PARTNER_TWENTY_EXPERIENCE_FIELD_UNIVERSAL_IDENTIFIER = + 'd8616962-3c4b-475f-a508-a237fffde0b9'; + +export const PARTNER_TWENTY_EXPERIENCE_NOTES_FIELD_UNIVERSAL_IDENTIFIER = + '7a0b7526-6695-4b47-8835-d31786174fcd'; + +export const PARTNER_TWENTY_EXPERIENCE_PROOF_LINK_FIELD_UNIVERSAL_IDENTIFIER = + '80e9b14e-450c-467f-9c06-9a84c07c21f0'; diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/constants/partner-option-values.constant.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/constants/partner-option-values.constant.ts index 25c1948da8..359b9be1ae 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/constants/partner-option-values.constant.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/constants/partner-option-values.constant.ts @@ -11,3 +11,13 @@ export const PARTNER_SCOPE_VALUES = [ ] as const; export const PARTNER_TYPE_OF_TEAM_VALUES = ['SOLO','AGENCY'] as const; + +export const TWENTY_EXPERIENCE_VALUES = [ + 'CUSTOM_APPS', + 'DATA_MODELS', + 'WORKFLOWS', + 'FRONT_COMPONENTS', +] as const; + +export const TWENTY_EXPERIENCE_NOTES_MIN_LENGTH = 200; + diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/views/partner-record-page-fields.view.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/views/partner-record-page-fields.view.ts index 647cea4840..fe82cc1312 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/views/partner-record-page-fields.view.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/views/partner-record-page-fields.view.ts @@ -7,7 +7,6 @@ import { PARTNER_COUNTRY_FIELD_UNIVERSAL_IDENTIFIER, PARTNER_DEPLOYMENT_EXPERTISE_FIELD_UNIVERSAL_IDENTIFIER, PARTNER_HOURLY_RATE_FIELD_UNIVERSAL_IDENTIFIER, - PARTNER_INTRODUCTION_FIELD_UNIVERSAL_IDENTIFIER, PARTNER_LANGUAGES_SPOKEN_FIELD_UNIVERSAL_IDENTIFIER, PARTNER_LINKEDIN_FIELD_UNIVERSAL_IDENTIFIER, PARTNER_NAME_FIELD_UNIVERSAL_IDENTIFIER, @@ -19,6 +18,9 @@ import { PARTNER_SCOPE_FIELD_UNIVERSAL_IDENTIFIER, PARTNER_SKILLS_FIELD_UNIVERSAL_IDENTIFIER, PARTNER_TIER_FIELD_UNIVERSAL_IDENTIFIER, + PARTNER_TWENTY_EXPERIENCE_FIELD_UNIVERSAL_IDENTIFIER, + PARTNER_TWENTY_EXPERIENCE_NOTES_FIELD_UNIVERSAL_IDENTIFIER, + PARTNER_TWENTY_EXPERIENCE_PROOF_LINK_FIELD_UNIVERSAL_IDENTIFIER, PARTNER_TYPE_OF_TEAM_FIELD_UNIVERSAL_IDENTIFIER, PARTNER_VALIDATION_STAGE_FIELD_UNIVERSAL_IDENTIFIER, PARTNER_WEBSITE_FIELD_UNIVERSAL_IDENTIFIER, @@ -44,6 +46,8 @@ export const PARTNER_RECORD_PAGE_FIELDS_VIEW_ID = // in the fields widget when an explicit view marks them visible — this is that view. // Validation Stage and Partner Tier stay listed for admins but are read-locked for // the Partner role (see partner.role.ts), so partners don't see them on My Profile. +// Introduction is omitted here (marketplace bio lives on My Profile / public site); +// Twenty experience fields are first-class for triage. export default defineView({ universalIdentifier: PARTNER_RECORD_PAGE_FIELDS_VIEW_ID, name: 'Partner Record Page Fields', @@ -64,146 +68,171 @@ export default defineView({ }, { universalIdentifier: 'c473b085-a043-48e3-8f30-2936667ae93b', - fieldMetadataUniversalIdentifier: PARTNER_PROFILE_PICTURE_FILE_FIELD_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: + PARTNER_PROFILE_PICTURE_FILE_FIELD_UNIVERSAL_IDENTIFIER, position: 2, isVisible: true, }, { universalIdentifier: '94bc6866-9a59-442d-9766-e0433420d61a', - fieldMetadataUniversalIdentifier: PARTNER_PROFILE_PICTURE_LEGACY_FIELD_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: + PARTNER_PROFILE_PICTURE_LEGACY_FIELD_UNIVERSAL_IDENTIFIER, position: 3, isVisible: true, }, - { - universalIdentifier: 'e82ab8fa-8d78-4ef4-97f6-5456c9df30b4', - fieldMetadataUniversalIdentifier: PARTNER_INTRODUCTION_FIELD_UNIVERSAL_IDENTIFIER, - position: 4, - isVisible: true, - }, { universalIdentifier: 'd61ac88b-139a-417d-8767-1db77a44d1a5', - fieldMetadataUniversalIdentifier: PARTNER_AVAILABILITY_FIELD_UNIVERSAL_IDENTIFIER, - position: 5, + fieldMetadataUniversalIdentifier: + PARTNER_AVAILABILITY_FIELD_UNIVERSAL_IDENTIFIER, + position: 4, isVisible: true, }, { universalIdentifier: '2304f4b9-4faf-4813-8ef7-fb7697d64a92', fieldMetadataUniversalIdentifier: PARTNER_SCOPE_FIELD_UNIVERSAL_IDENTIFIER, + position: 5, + isVisible: true, + }, + { + universalIdentifier: '6b1e51b9-a032-4223-8880-7b04559696f0', + fieldMetadataUniversalIdentifier: + PARTNER_TWENTY_EXPERIENCE_FIELD_UNIVERSAL_IDENTIFIER, position: 6, isVisible: true, }, + { + universalIdentifier: '0228335c-d634-4fed-a104-568cc181f637', + fieldMetadataUniversalIdentifier: + PARTNER_TWENTY_EXPERIENCE_NOTES_FIELD_UNIVERSAL_IDENTIFIER, + position: 7, + isVisible: true, + }, + { + universalIdentifier: '567860a8-af4c-4f45-b75b-73ef4d43584e', + fieldMetadataUniversalIdentifier: + PARTNER_TWENTY_EXPERIENCE_PROOF_LINK_FIELD_UNIVERSAL_IDENTIFIER, + position: 8, + isVisible: true, + }, { universalIdentifier: 'a0c47e12-6435-458a-b608-621bcee57474', fieldMetadataUniversalIdentifier: PARTNER_SKILLS_FIELD_UNIVERSAL_IDENTIFIER, - position: 7, - isVisible: true, - }, - { - universalIdentifier: 'cbd9d84c-3d71-495c-b412-08c77f2c124c', - fieldMetadataUniversalIdentifier: PARTNER_DEPLOYMENT_EXPERTISE_FIELD_UNIVERSAL_IDENTIFIER, position: 9, isVisible: true, }, { - universalIdentifier: '42521613-4eeb-4a00-9a25-1d9de793d3c0', - fieldMetadataUniversalIdentifier: PARTNER_LANGUAGES_SPOKEN_FIELD_UNIVERSAL_IDENTIFIER, + universalIdentifier: 'cbd9d84c-3d71-495c-b412-08c77f2c124c', + fieldMetadataUniversalIdentifier: + PARTNER_DEPLOYMENT_EXPERTISE_FIELD_UNIVERSAL_IDENTIFIER, position: 10, isVisible: true, }, { - universalIdentifier: '2d595d67-8317-4c67-aeec-fe6e030e4830', - fieldMetadataUniversalIdentifier: PARTNER_TYPE_OF_TEAM_FIELD_UNIVERSAL_IDENTIFIER, + universalIdentifier: '42521613-4eeb-4a00-9a25-1d9de793d3c0', + fieldMetadataUniversalIdentifier: + PARTNER_LANGUAGES_SPOKEN_FIELD_UNIVERSAL_IDENTIFIER, position: 11, isVisible: true, }, + { + universalIdentifier: '2d595d67-8317-4c67-aeec-fe6e030e4830', + fieldMetadataUniversalIdentifier: + PARTNER_TYPE_OF_TEAM_FIELD_UNIVERSAL_IDENTIFIER, + position: 12, + isVisible: true, + }, { universalIdentifier: 'a85dc9bc-a5f5-446e-a36c-492f1b6c0035', fieldMetadataUniversalIdentifier: PARTNER_REGION_FIELD_UNIVERSAL_IDENTIFIER, - position: 12, + position: 13, isVisible: true, }, { universalIdentifier: '90d11276-1666-4c36-9055-00c4680c3168', fieldMetadataUniversalIdentifier: PARTNER_COUNTRY_FIELD_UNIVERSAL_IDENTIFIER, - position: 13, + position: 14, isVisible: true, }, { universalIdentifier: '23f76380-3532-4e84-9115-55d395d6646b', fieldMetadataUniversalIdentifier: PARTNER_CITY_FIELD_UNIVERSAL_IDENTIFIER, - position: 14, - isVisible: true, - }, - { - universalIdentifier: 'cf5e9649-a3e9-4b3f-b746-e701e3db7169', - fieldMetadataUniversalIdentifier: PARTNER_HOURLY_RATE_FIELD_UNIVERSAL_IDENTIFIER, position: 15, isVisible: true, }, { - universalIdentifier: '388ae22b-956c-4e5a-8b78-6f8cb0105c1a', - fieldMetadataUniversalIdentifier: PARTNER_PROJECT_BUDGET_MIN_FIELD_UNIVERSAL_IDENTIFIER, + universalIdentifier: 'cf5e9649-a3e9-4b3f-b746-e701e3db7169', + fieldMetadataUniversalIdentifier: + PARTNER_HOURLY_RATE_FIELD_UNIVERSAL_IDENTIFIER, position: 16, isVisible: true, }, + { + universalIdentifier: '388ae22b-956c-4e5a-8b78-6f8cb0105c1a', + fieldMetadataUniversalIdentifier: + PARTNER_PROJECT_BUDGET_MIN_FIELD_UNIVERSAL_IDENTIFIER, + position: 17, + isVisible: true, + }, { universalIdentifier: '5c8727a3-6e4b-41c7-adf9-530ab6599bf9', fieldMetadataUniversalIdentifier: PARTNER_LINKEDIN_FIELD_UNIVERSAL_IDENTIFIER, - position: 17, + position: 18, isVisible: true, }, { universalIdentifier: 'a6ef3c3b-103b-41b7-ba85-9d93a9af3296', fieldMetadataUniversalIdentifier: PARTNER_WEBSITE_FIELD_UNIVERSAL_IDENTIFIER, - position: 18, + position: 19, isVisible: true, }, { universalIdentifier: '4bc68141-963f-4e42-8aa4-c081afc613c7', - fieldMetadataUniversalIdentifier: PARTNER_CALENDAR_LINK_FIELD_UNIVERSAL_IDENTIFIER, - position: 19, + fieldMetadataUniversalIdentifier: + PARTNER_CALENDAR_LINK_FIELD_UNIVERSAL_IDENTIFIER, + position: 20, isVisible: true, }, { universalIdentifier: '5b51f559-d78c-48df-9347-7929f87ad8d1', fieldMetadataUniversalIdentifier: PARTNER_LINKS_ON_PARTNER_FIELD_ID, - position: 20, + position: 21, isVisible: true, }, { universalIdentifier: 'fe7a958a-fc56-4e65-8e37-1925014fb1ae', fieldMetadataUniversalIdentifier: PARTNER_SERVICES_ON_PARTNER_FIELD_ID, - position: 21, + position: 22, isVisible: true, }, { universalIdentifier: '39b34dc8-7daa-4e30-9183-2ce8f035657a', fieldMetadataUniversalIdentifier: PARTNER_CONTENTS_ON_PARTNER_FIELD_ID, - position: 22, + position: 23, isVisible: true, }, { universalIdentifier: '3487e2ae-0feb-41c4-ad0c-f94dff435015', - fieldMetadataUniversalIdentifier: PARTNER_VALIDATION_STAGE_FIELD_UNIVERSAL_IDENTIFIER, - position: 23, + fieldMetadataUniversalIdentifier: + PARTNER_VALIDATION_STAGE_FIELD_UNIVERSAL_IDENTIFIER, + position: 24, isVisible: true, }, { universalIdentifier: '971c79e6-381e-4346-86a4-7119de4824be', fieldMetadataUniversalIdentifier: PARTNER_TIER_FIELD_UNIVERSAL_IDENTIFIER, - position: 24, + position: 25, isVisible: true, }, { universalIdentifier: 'ec1148d4-01ff-4128-9863-0ac42ea8eb47', fieldMetadataUniversalIdentifier: PARTNER_USER_ON_PARTNER_FIELD_ID, - position: 25, + position: 26, isVisible: true, }, { universalIdentifier: '3ec293a1-da90-4104-a22e-771c0059e9b5', fieldMetadataUniversalIdentifier: PARTNER_COMPANY_FIELD_ID, - position: 26, + position: 27, isVisible: true, }, ], diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/views/partners-per-stage.view.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/views/partners-per-stage.view.ts index 2f92a9892c..21c58d6dc5 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/views/partners-per-stage.view.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/views/partners-per-stage.view.ts @@ -5,6 +5,7 @@ import { PARTNER_NAME_FIELD_UNIVERSAL_IDENTIFIER, PARTNER_SCOPE_FIELD_UNIVERSAL_IDENTIFIER, PARTNER_TIER_FIELD_UNIVERSAL_IDENTIFIER, + PARTNER_TWENTY_EXPERIENCE_FIELD_UNIVERSAL_IDENTIFIER, PARTNER_VALIDATION_STAGE_FIELD_UNIVERSAL_IDENTIFIER, } from 'src/modules/partner/constants/partner-field-universal-identifiers'; import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; @@ -77,10 +78,18 @@ export default defineView({ isVisible: true, size: 200, }, + { + universalIdentifier: 'e9908bdb-bfaa-4c2f-a193-9b25a8a7b1d0', + fieldMetadataUniversalIdentifier: + PARTNER_TWENTY_EXPERIENCE_FIELD_UNIVERSAL_IDENTIFIER, + position: 3, + isVisible: true, + size: 220, + }, { universalIdentifier: 'ff01c4e3-b5c6-4832-90eb-ad05d8276e60', fieldMetadataUniversalIdentifier: PARTNER_TIER_FIELD_UNIVERSAL_IDENTIFIER, - position: 3, + position: 4, isVisible: true, }, ], diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/views/partners-validated.view.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/views/partners-validated.view.ts index 186c66f3e0..27e335bef9 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/views/partners-validated.view.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/views/partners-validated.view.ts @@ -4,7 +4,9 @@ import { PARTNER_AVAILABILITY_FIELD_UNIVERSAL_IDENTIFIER, PARTNER_NAME_FIELD_UNIVERSAL_IDENTIFIER, PARTNER_REGION_FIELD_UNIVERSAL_IDENTIFIER, + PARTNER_SCOPE_FIELD_UNIVERSAL_IDENTIFIER, PARTNER_TIER_FIELD_UNIVERSAL_IDENTIFIER, + PARTNER_TWENTY_EXPERIENCE_FIELD_UNIVERSAL_IDENTIFIER, PARTNER_VALIDATION_STAGE_FIELD_UNIVERSAL_IDENTIFIER, } from 'src/modules/partner/constants/partner-field-universal-identifiers'; import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; @@ -43,17 +45,32 @@ export default defineView({ position: 2, isVisible: true, }, + { + universalIdentifier: 'e301a359-d38b-4ae9-aecb-593f5744f06b', + fieldMetadataUniversalIdentifier: PARTNER_SCOPE_FIELD_UNIVERSAL_IDENTIFIER, + position: 3, + isVisible: true, + size: 200, + }, + { + universalIdentifier: '919124f8-46ae-449e-819f-c1b93ec4391e', + fieldMetadataUniversalIdentifier: + PARTNER_TWENTY_EXPERIENCE_FIELD_UNIVERSAL_IDENTIFIER, + position: 4, + isVisible: true, + size: 220, + }, { universalIdentifier: '0173bc1a-4884-4b58-b12d-77afa1fb1fe1', fieldMetadataUniversalIdentifier: PARTNER_REGION_FIELD_UNIVERSAL_IDENTIFIER, - position: 3, + position: 5, isVisible: true, size: 200, }, { universalIdentifier: '866578d7-8a53-4276-bda0-16e657102392', fieldMetadataUniversalIdentifier: PARTNER_USER_ON_PARTNER_FIELD_ID, - position: 4, + position: 6, isVisible: true, size: 200, }, diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/objects/partner.object.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/objects/partner.object.ts index c582611260..4bf0ebafb6 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/objects/partner.object.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/objects/partner.object.ts @@ -492,6 +492,60 @@ export default defineObject({ icon: 'IconClipboardText', isNullable: true, }, + { + universalIdentifier: 'd8616962-3c4b-475f-a508-a237fffde0b9', + type: FieldType.MULTI_SELECT, + name: 'twentyExperience', + label: 'Twenty Experience', + icon: 'IconTool', + isNullable: true, + options: [ + { + id: '879d6ae4-09a2-425e-8292-bfa9f4df3983', + value: 'CUSTOM_APPS', + label: 'Custom apps', + position: 0, + color: 'blue', + }, + { + id: '20d4f654-54dc-4e57-902f-f5f4550d0b63', + value: 'DATA_MODELS', + label: 'Data models', + position: 1, + color: 'green', + }, + { + id: '2919caa4-e6ec-47d7-870e-9e115fd9da58', + value: 'WORKFLOWS', + label: 'Workflows', + position: 2, + color: 'purple', + }, + { + id: '542f843f-ba27-40db-8087-a75d77bdda29', + value: 'FRONT_COMPONENTS', + label: 'Front components', + position: 3, + color: 'orange', + }, + ], + }, + { + universalIdentifier: '7a0b7526-6695-4b47-8835-d31786174fcd', + type: FieldType.TEXT, + name: 'twentyExperienceNotes', + label: 'Twenty Experience Notes', + icon: 'IconFileText', + isNullable: true, + }, + { + universalIdentifier: '80e9b14e-450c-467f-9c06-9a84c07c21f0', + type: FieldType.LINKS, + name: 'twentyExperienceProofLink', + label: 'Twenty Experience Proof Link', + icon: 'IconLink', + isNullable: true, + }, { universalIdentifier: 'a0000010-0000-4000-8000-000000000010', type: FieldType.DATE_TIME,