v1.4.0 — Raise partner bar: Twenty experience fields + triage (#23224)

## 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 <remote>` 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/`
This commit is contained in:
Rashad Karanouh
2026-07-23 21:26:41 +02:00
committed by GitHub
parent 59eead238d
commit 18fb0946e6
14 changed files with 834 additions and 71 deletions
@@ -1,6 +1,6 @@
{
"name": "twenty-partners",
"version": "1.3.2",
"version": "1.4.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
@@ -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<string, string | undefined> = {},
): Record<string, string | undefined> => ({
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',
);
}
});
});
@@ -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({
@@ -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);
});
});
@@ -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;
}
@@ -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<string, string | undefined | null>,
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<TwentyExperienceMilestone>();
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<string, string | undefined | null>,
): 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,
},
};
};
@@ -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
@@ -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'],
},
@@ -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';
@@ -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;
@@ -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,
},
],
@@ -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,
},
],
@@ -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,
},
@@ -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,