v1.3.0 — Partner workspace self-service (glowup app) (#22929)

## Glowup — app · v1.3.0 (Release ② of the brief + glowup rollout)

Partner **workspace self-service**: partners manage their own profile,
links, services, and case studies from inside the CRM (new objects +
record-page views + a "My Profile" self-service front-component).
Evolved superset of the closed #22470 (v1.3.0). App-only — **0 website
files**.

Version **1.3.0** (prod is currently 1.2.10). SDK **2.19.0**.

Supersedes **#22470** (closed).

### Verified locally
Provisioned a throwaway workspace, synced the schema, seeded, and
exercised the full surface end-to-end: marketplace + public profiles
render live; **partner self-service pages** (My Profile / My Case
Studies / links / services) load and save when acting as a partner user;
both intake forms (partner application + client brief) submit
successfully. `oxlint` 0/0, typecheck clean.

### Notes
- Committed `APPLICATION_UNIVERSAL_IDENTIFIER` is the **canonical** prod
id `e662fc1f-02c1-41ff-b8ba-c95a447b3965` (local bundle rewrites it to a
throwaway that stays uncommitted).
- New views reference app-owned fields only — no hardcoded system-field
ids.

### Remaining before merge
- CI lint / typecheck / tests (green locally).
- Refresh the partners-doc (new objects/views change the app surface).

---

## 🚦 Release order — do not break

```
① BRIEF WEB — #22291   MERGED (website deploy pending prod CLIENT_BRIEF_* env vars)
        │
        ▼
② GLOWUP APP — THIS PR  (rk-partner-profile-page v1.3.0 → main)   ⟵ replaces #22470
   merge → DEPLOY TO PROD  (verify canonical id first,
                            yarn twenty deploy && install -r partner-twenty-com)
         → set new app variables on prod → refresh partners-doc
        │   ⟵⟵ GATE for ③ ⟵⟵
        ▼
③ GLOWUP WEB — rk-glowup-web-stacked  (reopen ONE PR, base main; was #22471 / #22402)
   ONLY after ② is LIVE on prod (the site reads the new links / services / case-study objects)
```

- ② gates only ③. After ② deploys, reconcile **#22637**
(partners-traffic-web) with ③ — both touch `partners-marketplace/*`.
This commit is contained in:
Rashad Karanouh
2026-07-20 15:25:07 +02:00
committed by GitHub
parent 6b55a6b51c
commit e6c6cccafa
85 changed files with 8855 additions and 131 deletions
+3
View File
@@ -60,3 +60,6 @@ output/playwright/
screenshots/
!**/public/screenshots/
!**/assets/screenshots/
# local graphify knowledge-graph output (never commit)
**/graphify-out/
@@ -41,3 +41,6 @@ yarn-error.log*
# typescript
*.tsbuildinfo
*.d.ts
# local graphify index (never commit — run `graphify update .` locally)
graphify-out/
@@ -1,6 +1,6 @@
{
"name": "twenty-partners",
"version": "1.2.10",
"version": "1.3.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
@@ -25,6 +25,7 @@
"rls:configure:prod": "ENV_FILE=.env.prod tsx src/scripts/configure-partner-rls.ts"
},
"dependencies": {
"react-markdown": "^10.1.0",
"twenty-client-sdk": "2.21.0",
"twenty-sdk": "2.21.0",
"zod": "^4.1.11"
@@ -32,6 +33,7 @@
"devDependencies": {
"@types/node": "^24.7.2",
"@types/react": "^19.0.0",
"@types/react-dom": "^19",
"dotenv": "^16.0.0",
"oxlint": "^0.16.0",
"react": "^19.0.0",
@@ -0,0 +1,197 @@
// resolvePartnerFromRequest only base64-decodes the bearer token (no signature
// check), so a fake unsigned JWT is enough to drive the handler against a real
// workspace member — same trick as resolve-partner-from-request.test.ts.
import { CoreApiClient } from 'twenty-client-sdk/core';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { handler } from 'src/logic-functions/save-my-partner-content.logic-function';
const makeToken = (payload: Record<string, unknown>): string =>
`header.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.sig`;
const requireId = (id: string | undefined, what: string): string => {
if (id === undefined) throw new Error(`${what} did not return an id`);
return id;
};
describe('save-my-partner-content handler', () => {
let client: CoreApiClient;
let partnerId: string;
let otherPartnerId: string;
let userWorkspaceId: string;
let keepId: string;
let dropId: string;
let otherContentId: string;
const originalToken = process.env.TWENTY_APP_ACCESS_TOKEN;
beforeAll(async () => {
client = new CoreApiClient();
const memberResult = await client.query({
workspaceMembers: {
__args: { filter: { userId: { is: 'NOT_NULL' } }, first: 1 },
edges: { node: { id: true, userId: true } },
},
});
const member = memberResult.workspaceMembers?.edges?.[0]?.node;
if (!member?.userId) {
throw new Error('No workspace member with a linked userId found to drive this test.');
}
userWorkspaceId = 'integration-test-workspace';
process.env.TWENTY_APP_ACCESS_TOKEN = makeToken({
userId: member.userId,
userWorkspaceId,
});
const partnerCreated = await client.mutation({
createPartner: {
__args: {
data: { name: 'Content Integration Test Partner', partnerUserId: member.id },
},
id: true,
},
});
partnerId = requireId(partnerCreated.createPartner?.id, 'createPartner');
const otherPartnerCreated = await client.mutation({
createPartner: {
__args: { data: { name: 'Other Partner (content test)' } },
id: true,
},
});
otherPartnerId = requireId(otherPartnerCreated.createPartner?.id, 'createPartner');
// Seeded APPROVED — proves an edit through this route never resets status.
const keep = await client.mutation({
createPartnerContent: {
__args: {
data: {
partnerId,
name: 'Keep me (edited)',
contentType: ['CASE_STUDY'],
status: 'APPROVED',
clientName: 'Acme Corp',
headline: 'Original headline',
body: { markdown: 'Original body' },
},
},
id: true,
},
});
keepId = requireId(keep.createPartnerContent?.id, 'createPartnerContent');
const drop = await client.mutation({
createPartnerContent: {
__args: {
data: {
partnerId,
name: 'Drop me',
contentType: ['CASE_STUDY'],
status: 'WIP',
},
},
id: true,
},
});
dropId = requireId(drop.createPartnerContent?.id, 'createPartnerContent');
const otherContent = await client.mutation({
createPartnerContent: {
__args: {
data: {
partnerId: otherPartnerId,
name: 'Belongs to someone else',
contentType: ['CASE_STUDY'],
status: 'WIP',
},
},
id: true,
},
});
otherContentId = requireId(otherContent.createPartnerContent?.id, 'createPartnerContent');
});
afterAll(async () => {
if (originalToken === undefined) delete process.env.TWENTY_APP_ACCESS_TOKEN;
else process.env.TWENTY_APP_ACCESS_TOKEN = originalToken;
await client.mutation({ destroyPartnerContent: { __args: { id: keepId }, id: true } });
await client.mutation({ destroyPartnerContent: { __args: { id: otherContentId }, id: true } });
// dropId is deleted by the reconcile happy-path; clean it up in case that test bailed early
// (tolerate not-found — it is expected to be gone after a successful run).
if (dropId) {
await client
.mutation({ destroyPartnerContent: { __args: { id: dropId }, id: true } })
.catch(() => {});
}
await client.mutation({ destroyPartner: { __args: { id: partnerId }, id: true } });
await client.mutation({ destroyPartner: { __args: { id: otherPartnerId }, id: true } });
});
it('keeps+edits one (status untouched), creates one (WIP/CASE_STUDY), drops the omitted one', async () => {
const result = await handler({
headers: {},
queryStringParameters: {},
pathParameters: {},
body: {
caseStudies: [
{
id: keepId,
name: 'Keep me (edited)',
clientName: 'Acme Corp',
headline: 'Updated headline',
bodyMarkdown: 'Updated body',
caseStudyLink: 'https://example.com/acme-updated',
},
{ name: 'Brand new case study', clientName: 'New Co', headline: 'Fresh win' },
],
},
isBase64Encoded: false,
requestContext: { http: { method: 'POST', path: '/save-my-partner-content' } },
userWorkspaceId,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.caseStudies).toHaveLength(2);
const kept = result.caseStudies.find((row) => row.id === keepId);
expect(kept).toMatchObject({ headline: 'Updated headline', bodyMarkdown: 'Updated body' });
// The seeded row was APPROVED — an edit through this route must never reset it.
expect(kept?.status).toBe('APPROVED');
expect(result.caseStudies.some((row) => row.id === dropId)).toBe(false);
const created = result.caseStudies.find((row) => row.name === 'Brand new case study');
expect(created).toBeDefined();
expect(created?.status).toBe('WIP');
// contentType is stamped by the async trigger, which needs the creator's workspaceMemberId —
// absent under vitest (buildAppClient runs as the API key), so it can't fire for route-created
// rows here. Verify the draft persisted; trigger stamping is exercised where a real member exists.
const fetched = await client.query({
partnerContents: {
__args: { filter: { id: { eq: created?.id ?? '' } }, first: 1 },
edges: { node: { status: true } },
},
});
expect(fetched.partnerContents?.edges?.[0]?.node?.status).toBe('WIP');
});
it('refuses a case study id owned by another partner', async () => {
const result = await handler({
headers: {},
queryStringParameters: {},
pathParameters: {},
body: {
caseStudies: [{ id: otherContentId, name: 'Hijacked' }],
},
isBase64Encoded: false,
requestContext: { http: { method: 'POST', path: '/save-my-partner-content' } },
userWorkspaceId,
});
expect(result).toEqual({ ok: false, reason: 'FORBIDDEN' });
});
});
@@ -0,0 +1,162 @@
// Deferred: written for the batch pass, not run as part of this task.
// resolvePartnerFromRequest only base64-decodes the bearer token (no signature
// check), so a fake unsigned JWT is enough to drive the handler against a real
// workspace member — same trick as resolve-partner-from-request.test.ts.
import { CoreApiClient } from 'twenty-client-sdk/core';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { handler } from 'src/logic-functions/save-my-partner-links.logic-function';
const makeToken = (payload: Record<string, unknown>): string =>
`header.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.sig`;
const requireId = (id: string | undefined, what: string): string => {
if (id === undefined) throw new Error(`${what} did not return an id`);
return id;
};
describe('save-my-partner-links handler', () => {
let client: CoreApiClient;
let partnerId: string;
let otherPartnerId: string;
let userWorkspaceId: string;
let keepId: string;
let dropId: string;
let otherLinkId: string;
const originalToken = process.env.TWENTY_APP_ACCESS_TOKEN;
beforeAll(async () => {
client = new CoreApiClient();
const memberResult = await client.query({
workspaceMembers: {
__args: { filter: { userId: { is: 'NOT_NULL' } }, first: 1 },
edges: { node: { id: true, userId: true } },
},
});
const member = memberResult.workspaceMembers?.edges?.[0]?.node;
if (!member?.userId) {
throw new Error('No workspace member with a linked userId found to drive this test.');
}
userWorkspaceId = 'integration-test-workspace';
process.env.TWENTY_APP_ACCESS_TOKEN = makeToken({
userId: member.userId,
userWorkspaceId,
});
const partnerCreated = await client.mutation({
createPartner: {
__args: {
data: { name: 'Links Integration Test Partner', partnerUserId: member.id },
},
id: true,
},
});
partnerId = requireId(partnerCreated.createPartner?.id, 'createPartner');
const otherPartnerCreated = await client.mutation({
createPartner: {
__args: { data: { name: 'Other Partner (links test)' } },
id: true,
},
});
otherPartnerId = requireId(otherPartnerCreated.createPartner?.id, 'createPartner');
const keep = await client.mutation({
createPartnerLink: {
__args: {
data: {
partnerId,
name: 'Keep me (edited)',
url: { primaryLinkUrl: 'https://example.com/keep' },
sortOrder: 0,
},
},
id: true,
},
});
keepId = requireId(keep.createPartnerLink?.id, 'createPartnerLink');
const drop = await client.mutation({
createPartnerLink: {
__args: {
data: {
partnerId,
name: 'Drop me',
url: { primaryLinkUrl: 'https://example.com/drop' },
sortOrder: 1,
},
},
id: true,
},
});
dropId = requireId(drop.createPartnerLink?.id, 'createPartnerLink');
const otherLink = await client.mutation({
createPartnerLink: {
__args: {
data: {
partnerId: otherPartnerId,
name: 'Belongs to someone else',
url: { primaryLinkUrl: 'https://example.com/other' },
sortOrder: 0,
},
},
id: true,
},
});
otherLinkId = requireId(otherLink.createPartnerLink?.id, 'createPartnerLink');
});
afterAll(async () => {
if (originalToken === undefined) delete process.env.TWENTY_APP_ACCESS_TOKEN;
else process.env.TWENTY_APP_ACCESS_TOKEN = originalToken;
await client.mutation({ destroyPartnerLink: { __args: { id: keepId }, id: true } });
await client.mutation({ destroyPartnerLink: { __args: { id: otherLinkId }, id: true } });
await client.mutation({ destroyPartner: { __args: { id: partnerId }, id: true } });
await client.mutation({ destroyPartner: { __args: { id: otherPartnerId }, id: true } });
});
it('keeps+edits one, creates one, and drops the omitted one', async () => {
const result = await handler({
headers: {},
queryStringParameters: {},
pathParameters: {},
body: {
links: [
{ id: keepId, name: 'Keep me (edited)', url: 'https://example.com/keep-2', sortOrder: 0 },
{ name: 'Brand new link', url: 'https://example.com/new', sortOrder: 1 },
],
},
isBase64Encoded: false,
requestContext: { http: { method: 'POST', path: '/save-my-partner-links' } },
userWorkspaceId,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.links).toHaveLength(2);
const kept = result.links.find((link) => link.id === keepId);
expect(kept).toMatchObject({ name: 'Keep me (edited)', url: 'https://example.com/keep-2' });
expect(result.links.some((link) => link.id === dropId)).toBe(false);
expect(result.links.some((link) => link.name === 'Brand new link')).toBe(true);
});
it('refuses a link id owned by another partner', async () => {
const result = await handler({
headers: {},
queryStringParameters: {},
pathParameters: {},
body: {
links: [{ id: otherLinkId, name: 'Hijacked', url: 'https://example.com/hijack' }],
},
isBase64Encoded: false,
requestContext: { http: { method: 'POST', path: '/save-my-partner-links' } },
userWorkspaceId,
});
expect(result).toEqual({ ok: false, reason: 'FORBIDDEN' });
});
});
@@ -0,0 +1,169 @@
// Deferred: written for the batch pass, not run as part of this task.
// resolvePartnerFromRequest only base64-decodes the bearer token (no signature
// check), so a fake unsigned JWT is enough to drive the handler against a real
// workspace member — same trick as resolve-partner-from-request.test.ts.
import { CoreApiClient } from 'twenty-client-sdk/core';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { handler } from 'src/logic-functions/save-my-partner-services.logic-function';
const makeToken = (payload: Record<string, unknown>): string =>
`header.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.sig`;
const requireId = (id: string | undefined, what: string): string => {
if (id === undefined) throw new Error(`${what} did not return an id`);
return id;
};
describe('save-my-partner-services handler', () => {
let client: CoreApiClient;
let partnerId: string;
let otherPartnerId: string;
let userWorkspaceId: string;
let keepId: string;
let dropId: string;
let otherServiceId: string;
const originalToken = process.env.TWENTY_APP_ACCESS_TOKEN;
beforeAll(async () => {
client = new CoreApiClient();
const memberResult = await client.query({
workspaceMembers: {
__args: { filter: { userId: { is: 'NOT_NULL' } }, first: 1 },
edges: { node: { id: true, userId: true } },
},
});
const member = memberResult.workspaceMembers?.edges?.[0]?.node;
if (!member?.userId) {
throw new Error('No workspace member with a linked userId found to drive this test.');
}
userWorkspaceId = 'integration-test-workspace';
process.env.TWENTY_APP_ACCESS_TOKEN = makeToken({
userId: member.userId,
userWorkspaceId,
});
const partnerCreated = await client.mutation({
createPartner: {
__args: {
data: { name: 'Services Integration Test Partner', partnerUserId: member.id },
},
id: true,
},
});
partnerId = requireId(partnerCreated.createPartner?.id, 'createPartner');
const otherPartnerCreated = await client.mutation({
createPartner: {
__args: { data: { name: 'Other Partner (services test)' } },
id: true,
},
});
otherPartnerId = requireId(otherPartnerCreated.createPartner?.id, 'createPartner');
const keep = await client.mutation({
createPartnerService: {
__args: {
data: {
partnerId,
title: 'Keep me (edited)',
description: 'Original description',
sortOrder: 0,
},
},
id: true,
},
});
keepId = requireId(keep.createPartnerService?.id, 'createPartnerService');
const drop = await client.mutation({
createPartnerService: {
__args: {
data: {
partnerId,
title: 'Drop me',
description: 'Will be removed',
sortOrder: 1,
},
},
id: true,
},
});
dropId = requireId(drop.createPartnerService?.id, 'createPartnerService');
const otherService = await client.mutation({
createPartnerService: {
__args: {
data: {
partnerId: otherPartnerId,
title: 'Belongs to someone else',
description: 'Not owned by the caller',
sortOrder: 0,
},
},
id: true,
},
});
otherServiceId = requireId(otherService.createPartnerService?.id, 'createPartnerService');
});
afterAll(async () => {
if (originalToken === undefined) delete process.env.TWENTY_APP_ACCESS_TOKEN;
else process.env.TWENTY_APP_ACCESS_TOKEN = originalToken;
await client.mutation({ destroyPartnerService: { __args: { id: keepId }, id: true } });
await client.mutation({ destroyPartnerService: { __args: { id: otherServiceId }, id: true } });
await client.mutation({ destroyPartner: { __args: { id: partnerId }, id: true } });
await client.mutation({ destroyPartner: { __args: { id: otherPartnerId }, id: true } });
});
it('keeps+edits one, creates one, and drops the omitted one', async () => {
const result = await handler({
headers: {},
queryStringParameters: {},
pathParameters: {},
body: {
services: [
{
id: keepId,
title: 'Keep me (edited)',
description: 'Updated description',
sortOrder: 0,
},
{ title: 'Brand new service', description: 'Freshly added', sortOrder: 1 },
],
},
isBase64Encoded: false,
requestContext: { http: { method: 'POST', path: '/save-my-partner-services' } },
userWorkspaceId,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.services).toHaveLength(2);
const kept = result.services.find((service) => service.id === keepId);
expect(kept).toMatchObject({ title: 'Keep me (edited)', description: 'Updated description' });
expect(result.services.some((service) => service.id === dropId)).toBe(false);
expect(result.services.some((service) => service.title === 'Brand new service')).toBe(true);
});
it('refuses a service id owned by another partner', async () => {
const result = await handler({
headers: {},
queryStringParameters: {},
pathParameters: {},
body: {
services: [
{ id: otherServiceId, title: 'Hijacked', description: 'Should be refused' },
],
},
isBase64Encoded: false,
requestContext: { http: { method: 'POST', path: '/save-my-partner-services' } },
userWorkspaceId,
});
expect(result).toEqual({ ok: false, reason: 'FORBIDDEN' });
});
});
@@ -0,0 +1,163 @@
// Deferred: written for the batch pass, not run as part of this task.
// resolvePartnerFromRequest only base64-decodes the bearer token (no signature
// check), so a fake unsigned JWT is enough to drive the handler against a real
// workspace member — same trick as resolve-partner-from-request.test.ts.
import { CoreApiClient } from 'twenty-client-sdk/core';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { handler } from 'src/logic-functions/submit-partner-content-for-review.logic-function';
const makeToken = (payload: Record<string, unknown>): string =>
`header.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.sig`;
const requireId = (id: string | undefined, what: string): string => {
if (id === undefined) throw new Error(`${what} did not return an id`);
return id;
};
describe('submit-partner-content-for-review handler', () => {
let client: CoreApiClient;
let partnerId: string;
let otherPartnerId: string;
let userWorkspaceId: string;
let wipId: string;
let approvedId: string;
let otherWipId: string;
const originalToken = process.env.TWENTY_APP_ACCESS_TOKEN;
beforeAll(async () => {
client = new CoreApiClient();
const memberResult = await client.query({
workspaceMembers: {
__args: { filter: { userId: { is: 'NOT_NULL' } }, first: 1 },
edges: { node: { id: true, userId: true } },
},
});
const member = memberResult.workspaceMembers?.edges?.[0]?.node;
if (!member?.userId) {
throw new Error('No workspace member with a linked userId found to drive this test.');
}
userWorkspaceId = 'integration-test-workspace';
process.env.TWENTY_APP_ACCESS_TOKEN = makeToken({
userId: member.userId,
userWorkspaceId,
});
const partnerCreated = await client.mutation({
createPartner: {
__args: {
data: { name: 'Submit-For-Review Integration Test Partner', partnerUserId: member.id },
},
id: true,
},
});
partnerId = requireId(partnerCreated.createPartner?.id, 'createPartner');
const otherPartnerCreated = await client.mutation({
createPartner: {
__args: { data: { name: 'Other Partner (submit-for-review test)' } },
id: true,
},
});
otherPartnerId = requireId(otherPartnerCreated.createPartner?.id, 'createPartner');
const wip = await client.mutation({
createPartnerContent: {
__args: {
data: {
partnerId,
name: 'WIP case study',
contentType: ['CASE_STUDY'],
status: 'WIP',
},
},
id: true,
},
});
wipId = requireId(wip.createPartnerContent?.id, 'createPartnerContent');
const approved = await client.mutation({
createPartnerContent: {
__args: {
data: {
partnerId,
name: 'Already approved case study',
contentType: ['CASE_STUDY'],
status: 'APPROVED',
},
},
id: true,
},
});
approvedId = requireId(approved.createPartnerContent?.id, 'createPartnerContent');
const otherWip = await client.mutation({
createPartnerContent: {
__args: {
data: {
partnerId: otherPartnerId,
name: 'Belongs to someone else (WIP)',
contentType: ['CASE_STUDY'],
status: 'WIP',
},
},
id: true,
},
});
otherWipId = requireId(otherWip.createPartnerContent?.id, 'createPartnerContent');
});
afterAll(async () => {
if (originalToken === undefined) delete process.env.TWENTY_APP_ACCESS_TOKEN;
else process.env.TWENTY_APP_ACCESS_TOKEN = originalToken;
// Guard each id: if beforeAll threw partway through, the unset ones stay undefined —
// skip them so cleanup still reaches every record that was actually created.
for (const id of [wipId, approvedId, otherWipId]) {
if (id) await client.mutation({ destroyPartnerContent: { __args: { id }, id: true } });
}
for (const id of [partnerId, otherPartnerId]) {
if (id) await client.mutation({ destroyPartner: { __args: { id }, id: true } });
}
});
const callHandler = (recordId: string) =>
handler({
headers: {},
queryStringParameters: {},
pathParameters: {},
body: { recordId },
isBase64Encoded: false,
requestContext: {
http: { method: 'POST', path: '/submit-partner-content-for-review' },
},
userWorkspaceId,
});
it('flips a WIP row to UNDER_CUSTOMER_PARTNER_REVIEW', async () => {
const result = await callHandler(wipId);
expect(result).toEqual({ ok: true, status: 'UNDER_CUSTOMER_PARTNER_REVIEW' });
const fetched = await client.query({
partnerContents: {
__args: { filter: { id: { eq: wipId } }, first: 1 },
edges: { node: { status: true } },
},
});
expect(fetched.partnerContents?.edges?.[0]?.node?.status).toBe(
'UNDER_CUSTOMER_PARTNER_REVIEW',
);
});
it('refuses a non-WIP row', async () => {
const result = await callHandler(approvedId);
expect(result).toEqual({ ok: false, reason: 'NOT_SUBMITTABLE' });
});
it('refuses a row owned by another partner', async () => {
const result = await callHandler(otherWipId);
expect(result).toEqual({ ok: false, reason: 'FORBIDDEN' });
});
});
@@ -0,0 +1,5 @@
// Plain-data ids for the My Case Studies page, mirroring my-profile.constants.ts.
export const MY_CASE_STUDIES_FRONT_COMPONENT_ID = '1e13a0e5-4dd7-44a6-aaff-081df22f322d';
export const MY_CASE_STUDIES_PAGE_LAYOUT_ID = 'f5acd909-d066-4add-9cf7-2c4eb8f1c1d0';
export const MY_CASE_STUDIES_PAGE_TAB_ID = '85c4ec5f-3c04-4e6f-a0e5-4f494f788eea';
export const MY_CASE_STUDIES_PAGE_WIDGET_ID = 'ec2af263-500e-4256-9324-951fdcad9682';
@@ -0,0 +1,275 @@
// Plain-data constants shared by the my-profile logic function and the
// front component — no SDK imports so both sides can consume this directly.
export const MY_PROFILE_FRONT_COMPONENT_ID = '92fdaa35-9fc7-4e2d-8db7-77d7f624cec6';
export const MY_PROFILE_PAGE_LAYOUT_ID = 'efc8a005-57db-44f3-a888-ce3a6aec526b';
export const MY_PROFILE_PAGE_TAB_ID = 'ff8185d0-cd72-477b-a1a5-77d9cfff6fef';
export const MY_PROFILE_PAGE_WIDGET_ID = 'abf2a5af-d772-44a6-895b-fd08d741978d';
export const MY_PROFILE_NAV_ITEM_ID = '6c39ddd9-d0c2-4951-8be5-d064c752d18a';
export type SelectOption = { value: string; label: string };
export type ProfileOptions = {
country: SelectOption[];
languagesSpoken: SelectOption[];
partnerScope: SelectOption[];
typeOfTeam: SelectOption[];
availability: SelectOption[];
};
// These mirror the option lists declared on the Partner object (partner.object.ts).
// If they ever drift, the upgrade path is to fetch field-metadata options at
// runtime instead of duplicating them here — not worth it now.
export const PROFILE_OPTIONS: ProfileOptions = {
partnerScope: [
{ value: 'ADVISORY', label: 'Advisory & Discovery' },
{ value: 'SOLUTIONING', label: 'Solutioning' },
{ value: 'DEVELOPMENT', label: 'Custom Development' },
{ value: 'HOSTING', label: 'Hosting & Infrastructure' },
{ value: 'SUPPORT', label: 'Training & Adoption' },
],
typeOfTeam: [
{ value: 'SOLO', label: 'Solo' },
{ value: 'AGENCY', label: 'Agency' },
],
availability: [
{ value: 'AVAILABLE', label: 'Available' },
{ value: 'UNAVAILABLE', label: 'Unavailable' },
],
languagesSpoken: [
{ value: 'ENGLISH', label: 'English' },
{ value: 'FRENCH', label: 'French' },
{ value: 'GERMAN', label: 'German' },
{ value: 'CHINESE', label: 'Chinese' },
{ value: 'SPANISH', label: 'Spanish' },
{ value: 'ARABIC', label: 'Arabic' },
{ value: 'BENGALI', label: 'Bengali' },
{ value: 'CATALAN', label: 'Catalan' },
{ value: 'CZECH', label: 'Czech' },
{ value: 'DANISH', label: 'Danish' },
{ value: 'DUTCH', label: 'Dutch' },
{ value: 'FARSI', label: 'Farsi' },
{ value: 'FINNISH', label: 'Finnish' },
{ value: 'GREEK', label: 'Greek' },
{ value: 'HINDI', label: 'Hindi' },
{ value: 'INDONESIAN', label: 'Indonesian' },
{ value: 'ITALIAN', label: 'Italian' },
{ value: 'JAPANESE', label: 'Japanese' },
{ value: 'KOREAN', label: 'Korean' },
{ value: 'MALAY', label: 'Malay' },
{ value: 'NORWEGIAN', label: 'Norwegian' },
{ value: 'POLISH', label: 'Polish' },
{ value: 'PORTUGUESE', label: 'Portuguese' },
{ value: 'PUNJABI', label: 'Punjabi' },
{ value: 'ROMANIAN', label: 'Romanian' },
{ value: 'RUSSIAN', label: 'Russian' },
{ value: 'SWAHILI', label: 'Swahili' },
{ value: 'SWEDISH', label: 'Swedish' },
{ value: 'TAGALOG', label: 'Tagalog' },
{ value: 'TAMIL', label: 'Tamil' },
{ value: 'THAI', label: 'Thai' },
{ value: 'TURKISH', label: 'Turkish' },
{ value: 'UKRAINIAN', label: 'Ukrainian' },
{ value: 'URDU', label: 'Urdu' },
{ value: 'VIETNAMESE', label: 'Vietnamese' },
],
country: [
{ value: 'AFGHANISTAN', label: 'Afghanistan 🇦🇫' },
{ value: 'ALBANIA', label: 'Albania 🇦🇱' },
{ value: 'ALGERIA', label: 'Algeria 🇩🇿' },
{ value: 'ANDORRA', label: 'Andorra 🇦🇩' },
{ value: 'ANGOLA', label: 'Angola 🇦🇴' },
{ value: 'ANTIGUA_AND_BARBUDA', label: 'Antigua & Barbuda 🇦🇬' },
{ value: 'ARGENTINA', label: 'Argentina 🇦🇷' },
{ value: 'ARMENIA', label: 'Armenia 🇦🇲' },
{ value: 'AUSTRALIA', label: 'Australia 🇦🇺' },
{ value: 'AUSTRIA', label: 'Austria 🇦🇹' },
{ value: 'AZERBAIJAN', label: 'Azerbaijan 🇦🇿' },
{ value: 'BAHAMAS', label: 'Bahamas 🇧🇸' },
{ value: 'BAHRAIN', label: 'Bahrain 🇧🇭' },
{ value: 'BANGLADESH', label: 'Bangladesh 🇧🇩' },
{ value: 'BARBADOS', label: 'Barbados 🇧🇧' },
{ value: 'BELARUS', label: 'Belarus 🇧🇾' },
{ value: 'BELGIUM', label: 'Belgium 🇧🇪' },
{ value: 'BELIZE', label: 'Belize 🇧🇿' },
{ value: 'BENIN', label: 'Benin 🇧🇯' },
{ value: 'BHUTAN', label: 'Bhutan 🇧🇹' },
{ value: 'BOLIVIA', label: 'Bolivia 🇧🇴' },
{ value: 'BOSNIA_AND_HERZEGOVINA', label: 'Bosnia & Herzegovina 🇧🇦' },
{ value: 'BOTSWANA', label: 'Botswana 🇧🇼' },
{ value: 'BRAZIL', label: 'Brazil 🇧🇷' },
{ value: 'BRUNEI', label: 'Brunei 🇧🇳' },
{ value: 'BULGARIA', label: 'Bulgaria 🇧🇬' },
{ value: 'BURKINA_FASO', label: 'Burkina Faso 🇧🇫' },
{ value: 'BURUNDI', label: 'Burundi 🇧🇮' },
{ value: 'CAMBODIA', label: 'Cambodia 🇰🇭' },
{ value: 'CAMEROON', label: 'Cameroon 🇨🇲' },
{ value: 'CANADA', label: 'Canada 🇨🇦' },
{ value: 'CAPE_VERDE', label: 'Cape Verde 🇨🇻' },
{ value: 'CENTRAL_AFRICAN_REPUBLIC', label: 'Central African Republic 🇨🇫' },
{ value: 'CHAD', label: 'Chad 🇹🇩' },
{ value: 'CHILE', label: 'Chile 🇨🇱' },
{ value: 'CHINA', label: 'China 🇨🇳' },
{ value: 'COLOMBIA', label: 'Colombia 🇨🇴' },
{ value: 'COMOROS', label: 'Comoros 🇰🇲' },
{ value: 'CONGO', label: 'Congo 🇨🇬' },
{ value: 'DR_CONGO', label: 'DR Congo 🇨🇩' },
{ value: 'COSTA_RICA', label: 'Costa Rica 🇨🇷' },
{ value: 'CROATIA', label: 'Croatia 🇭🇷' },
{ value: 'CUBA', label: 'Cuba 🇨🇺' },
{ value: 'CYPRUS', label: 'Cyprus 🇨🇾' },
{ value: 'CZECH_REPUBLIC', label: 'Czech Republic 🇨🇿' },
{ value: 'DENMARK', label: 'Denmark 🇩🇰' },
{ value: 'DJIBOUTI', label: 'Djibouti 🇩🇯' },
{ value: 'DOMINICA', label: 'Dominica 🇩🇲' },
{ value: 'DOMINICAN_REPUBLIC', label: 'Dominican Republic 🇩🇴' },
{ value: 'ECUADOR', label: 'Ecuador 🇪🇨' },
{ value: 'EGYPT', label: 'Egypt 🇪🇬' },
{ value: 'EL_SALVADOR', label: 'El Salvador 🇸🇻' },
{ value: 'EQUATORIAL_GUINEA', label: 'Equatorial Guinea 🇬🇶' },
{ value: 'ERITREA', label: 'Eritrea 🇪🇷' },
{ value: 'ESTONIA', label: 'Estonia 🇪🇪' },
{ value: 'ESWATINI', label: 'Eswatini 🇸🇿' },
{ value: 'ETHIOPIA', label: 'Ethiopia 🇪🇹' },
{ value: 'FIJI', label: 'Fiji 🇫🇯' },
{ value: 'FINLAND', label: 'Finland 🇫🇮' },
{ value: 'FRANCE', label: 'France 🇫🇷' },
{ value: 'GABON', label: 'Gabon 🇬🇦' },
{ value: 'GAMBIA', label: 'Gambia 🇬🇲' },
{ value: 'GEORGIA', label: 'Georgia 🇬🇪' },
{ value: 'GERMANY', label: 'Germany 🇩🇪' },
{ value: 'GHANA', label: 'Ghana 🇬🇭' },
{ value: 'GREECE', label: 'Greece 🇬🇷' },
{ value: 'GRENADA', label: 'Grenada 🇬🇩' },
{ value: 'GUATEMALA', label: 'Guatemala 🇬🇹' },
{ value: 'GUINEA', label: 'Guinea 🇬🇳' },
{ value: 'GUINEA_BISSAU', label: 'Guinea-Bissau 🇬🇼' },
{ value: 'GUYANA', label: 'Guyana 🇬🇾' },
{ value: 'HAITI', label: 'Haiti 🇭🇹' },
{ value: 'HONDURAS', label: 'Honduras 🇭🇳' },
{ value: 'HUNGARY', label: 'Hungary 🇭🇺' },
{ value: 'ICELAND', label: 'Iceland 🇮🇸' },
{ value: 'INDIA', label: 'India 🇮🇳' },
{ value: 'INDONESIA', label: 'Indonesia 🇮🇩' },
{ value: 'IRAN', label: 'Iran 🇮🇷' },
{ value: 'IRAQ', label: 'Iraq 🇮🇶' },
{ value: 'IRELAND', label: 'Ireland 🇮🇪' },
{ value: 'ISRAEL', label: 'Israel 🇮🇱' },
{ value: 'ITALY', label: 'Italy 🇮🇹' },
{ value: 'IVORY_COAST', label: 'Ivory Coast 🇨🇮' },
{ value: 'JAMAICA', label: 'Jamaica 🇯🇲' },
{ value: 'JAPAN', label: 'Japan 🇯🇵' },
{ value: 'JORDAN', label: 'Jordan 🇯🇴' },
{ value: 'KAZAKHSTAN', label: 'Kazakhstan 🇰🇿' },
{ value: 'KENYA', label: 'Kenya 🇰🇪' },
{ value: 'KIRIBATI', label: 'Kiribati 🇰🇮' },
{ value: 'KOSOVO', label: 'Kosovo 🇽🇰' },
{ value: 'KUWAIT', label: 'Kuwait 🇰🇼' },
{ value: 'KYRGYZSTAN', label: 'Kyrgyzstan 🇰🇬' },
{ value: 'LAOS', label: 'Laos 🇱🇦' },
{ value: 'LATVIA', label: 'Latvia 🇱🇻' },
{ value: 'LEBANON', label: 'Lebanon 🇱🇧' },
{ value: 'LESOTHO', label: 'Lesotho 🇱🇸' },
{ value: 'LIBERIA', label: 'Liberia 🇱🇷' },
{ value: 'LIBYA', label: 'Libya 🇱🇾' },
{ value: 'LIECHTENSTEIN', label: 'Liechtenstein 🇱🇮' },
{ value: 'LITHUANIA', label: 'Lithuania 🇱🇹' },
{ value: 'LUXEMBOURG', label: 'Luxembourg 🇱🇺' },
{ value: 'MADAGASCAR', label: 'Madagascar 🇲🇬' },
{ value: 'MALAWI', label: 'Malawi 🇲🇼' },
{ value: 'MALAYSIA', label: 'Malaysia 🇲🇾' },
{ value: 'MALDIVES', label: 'Maldives 🇲🇻' },
{ value: 'MALI', label: 'Mali 🇲🇱' },
{ value: 'MALTA', label: 'Malta 🇲🇹' },
{ value: 'MARSHALL_ISLANDS', label: 'Marshall Islands 🇲🇭' },
{ value: 'MAURITANIA', label: 'Mauritania 🇲🇷' },
{ value: 'MAURITIUS', label: 'Mauritius 🇲🇺' },
{ value: 'MEXICO', label: 'Mexico 🇲🇽' },
{ value: 'MICRONESIA', label: 'Micronesia 🇫🇲' },
{ value: 'MOLDOVA', label: 'Moldova 🇲🇩' },
{ value: 'MONACO', label: 'Monaco 🇲🇨' },
{ value: 'MONGOLIA', label: 'Mongolia 🇲🇳' },
{ value: 'MONTENEGRO', label: 'Montenegro 🇲🇪' },
{ value: 'MOROCCO', label: 'Morocco 🇲🇦' },
{ value: 'MOZAMBIQUE', label: 'Mozambique 🇲🇿' },
{ value: 'MYANMAR', label: 'Myanmar 🇲🇲' },
{ value: 'NAMIBIA', label: 'Namibia 🇳🇦' },
{ value: 'NAURU', label: 'Nauru 🇳🇷' },
{ value: 'NEPAL', label: 'Nepal 🇳🇵' },
{ value: 'NETHERLANDS', label: 'Netherlands 🇳🇱' },
{ value: 'NEW_ZEALAND', label: 'New Zealand 🇳🇿' },
{ value: 'NICARAGUA', label: 'Nicaragua 🇳🇮' },
{ value: 'NIGER', label: 'Niger 🇳🇪' },
{ value: 'NIGERIA', label: 'Nigeria 🇳🇬' },
{ value: 'NORTH_KOREA', label: 'North Korea 🇰🇵' },
{ value: 'NORTH_MACEDONIA', label: 'North Macedonia 🇲🇰' },
{ value: 'NORWAY', label: 'Norway 🇳🇴' },
{ value: 'OMAN', label: 'Oman 🇴🇲' },
{ value: 'PAKISTAN', label: 'Pakistan 🇵🇰' },
{ value: 'PALAU', label: 'Palau 🇵🇼' },
{ value: 'PALESTINE', label: 'Palestine 🇵🇸' },
{ value: 'PANAMA', label: 'Panama 🇵🇦' },
{ value: 'PAPUA_NEW_GUINEA', label: 'Papua New Guinea 🇵🇬' },
{ value: 'PARAGUAY', label: 'Paraguay 🇵🇾' },
{ value: 'PERU', label: 'Peru 🇵🇪' },
{ value: 'PHILIPPINES', label: 'Philippines 🇵🇭' },
{ value: 'POLAND', label: 'Poland 🇵🇱' },
{ value: 'PORTUGAL', label: 'Portugal 🇵🇹' },
{ value: 'QATAR', label: 'Qatar 🇶🇦' },
{ value: 'ROMANIA', label: 'Romania 🇷🇴' },
{ value: 'RUSSIA', label: 'Russia 🇷🇺' },
{ value: 'RWANDA', label: 'Rwanda 🇷🇼' },
{ value: 'SAINT_KITTS_AND_NEVIS', label: 'Saint Kitts & Nevis 🇰🇳' },
{ value: 'SAINT_LUCIA', label: 'Saint Lucia 🇱🇨' },
{ value: 'SAINT_VINCENT', label: 'Saint Vincent 🇻🇨' },
{ value: 'SAMOA', label: 'Samoa 🇼🇸' },
{ value: 'SAN_MARINO', label: 'San Marino 🇸🇲' },
{ value: 'SAO_TOME_AND_PRINCIPE', label: 'São Tomé & Príncipe 🇸🇹' },
{ value: 'SAUDI_ARABIA', label: 'Saudi Arabia 🇸🇦' },
{ value: 'SENEGAL', label: 'Senegal 🇸🇳' },
{ value: 'SERBIA', label: 'Serbia 🇷🇸' },
{ value: 'SEYCHELLES', label: 'Seychelles 🇸🇨' },
{ value: 'SIERRA_LEONE', label: 'Sierra Leone 🇸🇱' },
{ value: 'SINGAPORE', label: 'Singapore 🇸🇬' },
{ value: 'SLOVAKIA', label: 'Slovakia 🇸🇰' },
{ value: 'SLOVENIA', label: 'Slovenia 🇸🇮' },
{ value: 'SOLOMON_ISLANDS', label: 'Solomon Islands 🇸🇧' },
{ value: 'SOMALIA', label: 'Somalia 🇸🇴' },
{ value: 'SOUTH_AFRICA', label: 'South Africa 🇿🇦' },
{ value: 'SOUTH_KOREA', label: 'South Korea 🇰🇷' },
{ value: 'SOUTH_SUDAN', label: 'South Sudan 🇸🇸' },
{ value: 'SPAIN', label: 'Spain 🇪🇸' },
{ value: 'SRI_LANKA', label: 'Sri Lanka 🇱🇰' },
{ value: 'SUDAN', label: 'Sudan 🇸🇩' },
{ value: 'SURINAME', label: 'Suriname 🇸🇷' },
{ value: 'SWEDEN', label: 'Sweden 🇸🇪' },
{ value: 'SWITZERLAND', label: 'Switzerland 🇨🇭' },
{ value: 'SYRIA', label: 'Syria 🇸🇾' },
{ value: 'TAIWAN', label: 'Taiwan 🇹🇼' },
{ value: 'TAJIKISTAN', label: 'Tajikistan 🇹🇯' },
{ value: 'TANZANIA', label: 'Tanzania 🇹🇿' },
{ value: 'THAILAND', label: 'Thailand 🇹🇭' },
{ value: 'TIMOR_LESTE', label: 'Timor-Leste 🇹🇱' },
{ value: 'TOGO', label: 'Togo 🇹🇬' },
{ value: 'TONGA', label: 'Tonga 🇹🇴' },
{ value: 'TRINIDAD_AND_TOBAGO', label: 'Trinidad & Tobago 🇹🇹' },
{ value: 'TUNISIA', label: 'Tunisia 🇹🇳' },
{ value: 'TURKEY', label: 'Turkey 🇹🇷' },
{ value: 'TURKMENISTAN', label: 'Turkmenistan 🇹🇲' },
{ value: 'TUVALU', label: 'Tuvalu 🇹🇻' },
{ value: 'UGANDA', label: 'Uganda 🇺🇬' },
{ value: 'UKRAINE', label: 'Ukraine 🇺🇦' },
{ value: 'UNITED_ARAB_EMIRATES', label: 'UAE 🇦🇪' },
{ value: 'UNITED_KINGDOM', label: 'UK 🇬🇧' },
{ value: 'UNITED_STATES', label: 'USA 🇺🇸' },
{ value: 'URUGUAY', label: 'Uruguay 🇺🇾' },
{ value: 'UZBEKISTAN', label: 'Uzbekistan 🇺🇿' },
{ value: 'VANUATU', label: 'Vanuatu 🇻🇺' },
{ value: 'VATICAN', label: 'Vatican 🇻🇦' },
{ value: 'VENEZUELA', label: 'Venezuela 🇻🇪' },
{ value: 'VIETNAM', label: 'Vietnam 🇻🇳' },
{ value: 'YEMEN', label: 'Yemen 🇾🇪' },
{ value: 'ZAMBIA', label: 'Zambia 🇿🇲' },
{ value: 'ZIMBABWE', label: 'Zimbabwe 🇿🇼' },
],
};
@@ -4,9 +4,16 @@ export const APPLICATION_UNIVERSAL_IDENTIFIER = 'e662fc1f-02c1-41ff-b8ba-c95a447
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER = 'ee18c3f3-ebe7-4c56-ad6d-aad555cc32db';
export const PARTNER_OBJECT_UNIVERSAL_IDENTIFIER = '39101b39-1c16-4148-9e82-45dc271bb90d';
export const PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER = '65172140-d377-41c1-a2ae-190e96fb79dd';
export const PARTNER_LINK_OBJECT_UNIVERSAL_IDENTIFIER =
'91c1bf51-f977-4ca5-9974-954fe23d1c1c';
export const PARTNER_SERVICE_OBJECT_UNIVERSAL_IDENTIFIER =
'cc953277-db73-45c5-9e6d-3967f9b7756f';
export const PARTNERS_NAV_UNIVERSAL_IDENTIFIER = '3fe15ab5-e38b-4914-af17-2270b210aeb2';
export const POST_INSTALL_FN_UNIVERSAL_IDENTIFIER = 'f92bad2e-5905-4757-96ee-af9869d4ca0c';
export const ON_PARTNER_APPLICATION_CREATED_FN_UNIVERSAL_IDENTIFIER = '43888cce-a2aa-4100-afbc-59a4f978ce53';
export const ON_PARTNER_LINK_CREATED_FN_UNIVERSAL_IDENTIFIER = 'a80a85f8-e32b-429e-82e9-b71cb5da54cd';
export const ON_PARTNER_SERVICE_CREATED_FN_UNIVERSAL_IDENTIFIER = '20129ceb-981e-43da-a25d-0ff6fbe52060';
export const ON_PARTNER_CONTENT_CREATED_FN_UNIVERSAL_IDENTIFIER = '18d0f329-d4a6-4b82-87ea-b78aa1a73e47';
export const INTRO_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER = 'fcf39b0c-0547-415e-806d-b238131ad7cc';
// Roles (Task 2)
@@ -40,6 +47,21 @@ export const PARTNER_APPLICATIONS_NAV_UNIVERSAL_IDENTIFIER = '13e2334a-6b1e-4080
export const VALIDATED_PARTNERS_NAV_UNIVERSAL_IDENTIFIER = '6aed30c6-d80f-4ac6-aab0-db5bc59e5c4b';
export const PARTNER_CONTENT_NAV_UNIVERSAL_IDENTIFIER = '3543723d-80c1-466a-ac35-86f7b284917b';
export const MY_SERVICES_VIEW_UNIVERSAL_IDENTIFIER =
'04d85f9e-342b-4887-bbee-4d52b1dd5cb3';
export const MY_SERVICES_NAV_UNIVERSAL_IDENTIFIER =
'3cd81fb0-e6b9-4669-90d9-caf3ca6e8841';
export const MY_LINKS_VIEW_UNIVERSAL_IDENTIFIER =
'40def5de-e90a-4542-8512-2b63bae248dc';
export const MY_LINKS_NAV_UNIVERSAL_IDENTIFIER =
'bd7b422c-0201-4bef-9170-0a415150d29b';
export const MY_CASE_STUDIES_VIEW_UNIVERSAL_IDENTIFIER =
'e7c4a9b2-1d3f-4e5a-9b6c-8d7e6f5a4b3c';
export const MY_CASE_STUDIES_NAV_UNIVERSAL_IDENTIFIER =
'f8d5b0c3-2e41-5f6b-8c7d-9e8f7a6b5c4d';
// 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 =
@@ -0,0 +1,29 @@
import {
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
defineField,
} from 'twenty-sdk/define';
import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import {
PARTNER_CONTENTS_AS_PARTNER_USER_FIELD_ID,
PARTNER_USER_ON_PARTNER_CONTENT_FIELD_ID,
} from './partner-user-on-partner-content.field';
export default defineField({
universalIdentifier: PARTNER_CONTENTS_AS_PARTNER_USER_FIELD_ID,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier,
type: FieldType.RELATION,
name: 'partnerContentsAsPartnerUser',
label: 'Partner Content (as partner user)',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
PARTNER_USER_ON_PARTNER_CONTENT_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,27 @@
import { FieldType, OnDeleteAction, RelationType, defineField } from 'twenty-sdk/define';
import {
PARTNER_LINK_OBJECT_UNIVERSAL_IDENTIFIER,
PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export const PARTNER_LINK_PARTNER_FIELD_ID = 'bae4dda8-8785-4867-a22f-dd66d805d68e';
export const PARTNER_LINKS_ON_PARTNER_FIELD_ID =
'3fdc8a67-3881-43ef-8c43-4fa3ddaa819c';
export default defineField({
universalIdentifier: PARTNER_LINK_PARTNER_FIELD_ID,
objectUniversalIdentifier: PARTNER_LINK_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'partner',
label: 'Partner',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_LINKS_ON_PARTNER_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'partnerId',
},
});
@@ -0,0 +1,29 @@
import {
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
defineField,
} from 'twenty-sdk/define';
import { PARTNER_LINK_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import {
PARTNER_LINKS_AS_PARTNER_USER_FIELD_ID,
PARTNER_USER_ON_PARTNER_LINK_FIELD_ID,
} from './partner-user-on-partner-link.field';
export default defineField({
universalIdentifier: PARTNER_LINKS_AS_PARTNER_USER_FIELD_ID,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier,
type: FieldType.RELATION,
name: 'partnerLinksAsPartnerUser',
label: 'Partner Links (as partner user)',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
PARTNER_LINK_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
PARTNER_USER_ON_PARTNER_LINK_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,25 @@
import { FieldType, RelationType, defineField } from 'twenty-sdk/define';
import {
PARTNER_LINK_OBJECT_UNIVERSAL_IDENTIFIER,
PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
import {
PARTNER_LINK_PARTNER_FIELD_ID,
PARTNER_LINKS_ON_PARTNER_FIELD_ID,
} from './partner-link-partner.field';
export default defineField({
universalIdentifier: PARTNER_LINKS_ON_PARTNER_FIELD_ID,
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'partnerLinks',
label: 'Partner Links',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
PARTNER_LINK_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_LINK_PARTNER_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,29 @@
import { FieldType, OnDeleteAction, RelationType, defineField } from 'twenty-sdk/define';
import {
PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
PARTNER_SERVICE_OBJECT_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export const PARTNER_SERVICE_PARTNER_FIELD_ID =
'cc2f1f32-65a0-46df-977f-fffc278b899a';
export const PARTNER_SERVICES_ON_PARTNER_FIELD_ID =
'88ac0831-91d5-4131-823f-3e72de8105b5';
export default defineField({
universalIdentifier: PARTNER_SERVICE_PARTNER_FIELD_ID,
objectUniversalIdentifier: PARTNER_SERVICE_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'partner',
label: 'Partner',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
PARTNER_SERVICES_ON_PARTNER_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'partnerId',
},
});
@@ -0,0 +1,29 @@
import {
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
defineField,
} from 'twenty-sdk/define';
import { PARTNER_SERVICE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import {
PARTNER_SERVICES_AS_PARTNER_USER_FIELD_ID,
PARTNER_USER_ON_PARTNER_SERVICE_FIELD_ID,
} from './partner-user-on-partner-service.field';
export default defineField({
universalIdentifier: PARTNER_SERVICES_AS_PARTNER_USER_FIELD_ID,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier,
type: FieldType.RELATION,
name: 'partnerServicesAsPartnerUser',
label: 'Partner Services (as partner user)',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
PARTNER_SERVICE_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
PARTNER_USER_ON_PARTNER_SERVICE_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,25 @@
import { FieldType, RelationType, defineField } from 'twenty-sdk/define';
import {
PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
PARTNER_SERVICE_OBJECT_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
import {
PARTNER_SERVICES_ON_PARTNER_FIELD_ID,
PARTNER_SERVICE_PARTNER_FIELD_ID,
} from './partner-service-partner.field';
export default defineField({
universalIdentifier: PARTNER_SERVICES_ON_PARTNER_FIELD_ID,
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'partnerServices',
label: 'Partner Services',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
PARTNER_SERVICE_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_SERVICE_PARTNER_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,32 @@
import {
FieldType,
OnDeleteAction,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
defineField,
} from 'twenty-sdk/define';
import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export const PARTNER_USER_ON_PARTNER_CONTENT_FIELD_ID =
'8d77a7c6-bf73-4cb7-8a95-93e32d8556e9';
export const PARTNER_CONTENTS_AS_PARTNER_USER_FIELD_ID =
'acb4da1d-93fe-4f40-b51f-805b334e4970';
export default defineField({
universalIdentifier: PARTNER_USER_ON_PARTNER_CONTENT_FIELD_ID,
objectUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'partnerUser',
label: 'Partner User',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
PARTNER_CONTENTS_AS_PARTNER_USER_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'partnerUserId',
},
});
@@ -0,0 +1,32 @@
import {
FieldType,
OnDeleteAction,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
defineField,
} from 'twenty-sdk/define';
import { PARTNER_LINK_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export const PARTNER_USER_ON_PARTNER_LINK_FIELD_ID =
'294ea3dc-9195-4a2a-8823-18d906f66f83';
export const PARTNER_LINKS_AS_PARTNER_USER_FIELD_ID =
'81a977c0-85fa-4261-953c-f721b1c9604d';
export default defineField({
universalIdentifier: PARTNER_USER_ON_PARTNER_LINK_FIELD_ID,
objectUniversalIdentifier: PARTNER_LINK_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'partnerUser',
label: 'Partner User',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
PARTNER_LINKS_AS_PARTNER_USER_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'partnerUserId',
},
});
@@ -0,0 +1,32 @@
import {
FieldType,
OnDeleteAction,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
defineField,
} from 'twenty-sdk/define';
import { PARTNER_SERVICE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export const PARTNER_USER_ON_PARTNER_SERVICE_FIELD_ID =
'e35d5cc8-1217-4676-81b2-51112b716b1c';
export const PARTNER_SERVICES_AS_PARTNER_USER_FIELD_ID =
'bc7e3a06-d362-4aef-a30b-e8ba0746c781';
export default defineField({
universalIdentifier: PARTNER_USER_ON_PARTNER_SERVICE_FIELD_ID,
objectUniversalIdentifier: PARTNER_SERVICE_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'partnerUser',
label: 'Partner User',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
PARTNER_SERVICES_AS_PARTNER_USER_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'partnerUserId',
},
});
@@ -0,0 +1,44 @@
type ErrorResponse = { messages?: string[]; message?: string; error?: string };
const extractErrorMessage = async (response: Response): Promise<string> => {
const text = await response.text().catch(() => '');
try {
const parsed = JSON.parse(text) as ErrorResponse;
return (
parsed.messages?.[0] ??
parsed.message ??
parsed.error ??
`Server error (${response.status})`
);
} catch {
return text || `Server error (${response.status})`;
}
};
export const callAppRoute = async (
path: string,
body: Record<string, unknown>,
): Promise<unknown> => {
const token = process.env.TWENTY_APP_ACCESS_TOKEN;
const functionsUrl = process.env.TWENTY_FUNCTIONS_URL;
const apiUrl = process.env.TWENTY_API_URL ?? '';
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
const url = functionsUrl ? `${functionsUrl}${normalizedPath}` : `${apiUrl}/s${normalizedPath}`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(await extractErrorMessage(response));
}
return response.json();
};
@@ -0,0 +1,260 @@
import { type CSSProperties, useCallback, useEffect, useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import { enqueueSnackbar, openCommandConfirmationModal } from 'twenty-sdk/front-component';
import { MY_CASE_STUDIES_FRONT_COMPONENT_ID } from 'src/constants/my-case-studies.constants';
import { callAppRoute } from './call-app-route';
import { CaseStudyCard } from './my-case-studies/case-study-card';
import {
buildInitialRows,
newDraftRow,
toSaveBody,
type CaseStudyRow,
} from './my-case-studies/case-study-rows';
import { COLORS, FONT } from './my-profile/form-fields';
import type { MyProfilePayload, SaveContentResult } from './my-profile/types';
type LoadResult = { ok: true; profile: MyProfilePayload } | { ok: false; reason: string };
const pageStyle: CSSProperties = {
fontFamily: FONT,
color: COLORS.fg,
maxWidth: '100%',
padding: '24px 28px',
display: 'flex',
flexDirection: 'column',
gap: 16,
};
const headerStyle: CSSProperties = {
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 12,
};
const titleStyle: CSSProperties = { fontSize: 20, fontWeight: 700, margin: 0 };
const subtitleStyle: CSSProperties = { fontSize: 13, color: COLORS.muted, marginTop: 4 };
const addButtonStyle: CSSProperties = {
height: 34,
padding: '0 16px',
borderRadius: 6,
border: 'none',
background: COLORS.accent,
color: '#fff',
fontSize: 13,
fontWeight: 600,
fontFamily: FONT,
cursor: 'pointer',
whiteSpace: 'nowrap',
};
const emptyStyle: CSSProperties = {
border: `1px solid ${COLORS.border}`,
borderRadius: 8,
background: COLORS.surfaceAlt,
padding: 28,
textAlign: 'center',
color: COLORS.muted,
fontSize: 14,
};
const skeletonStyle: CSSProperties = {
height: 56,
border: `1px solid ${COLORS.border}`,
borderRadius: 8,
background: COLORS.surfaceAlt,
marginBottom: 12,
};
const errorStyle: CSSProperties = {
border: `1px solid ${COLORS.border}`,
borderRadius: 8,
background: COLORS.surfaceAlt,
padding: 28,
textAlign: 'center',
color: COLORS.muted,
fontSize: 14,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 12,
};
const retryButtonStyle: CSSProperties = {
height: 32,
padding: '0 16px',
borderRadius: 6,
border: `1px solid ${COLORS.border}`,
background: COLORS.surface,
color: COLORS.fg,
fontSize: 13,
fontFamily: FONT,
cursor: 'pointer',
};
const MyCaseStudies = () => {
const [rows, setRows] = useState<CaseStudyRow[]>([]);
const [loading, setLoading] = useState(true);
const [expandedKey, setExpandedKey] = useState<string | null>(null);
const [busyKey, setBusyKey] = useState<string | null>(null);
const [loadFailed, setLoadFailed] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const res = (await callAppRoute('/my-partner-profile', {})) as LoadResult;
if (res.ok) {
setRows(buildInitialRows(res.profile.caseStudies));
setLoadFailed(false);
} else {
setLoadFailed(true);
await enqueueSnackbar({ message: `Could not load case studies: ${res.reason}`, variant: 'error' });
}
} catch (error) {
setLoadFailed(true);
await enqueueSnackbar({
message: error instanceof Error ? error.message : 'Failed to load case studies',
variant: 'error',
});
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const patchRow = (key: string, patch: Partial<CaseStudyRow>) =>
setRows((prev) => prev.map((r) => (r.key === key ? { ...r, ...patch } : r)));
const handleAdd = () => {
const draft = newDraftRow();
setRows((prev) => [...prev, draft]);
setExpandedKey(draft.key);
};
// The reconcile route takes the whole desired list, so a save persists every card's current
// values; in the accordion you edit one at a time, so this reads as "save this card".
const persist = useCallback(
async (nextRows: CaseStudyRow[], successMessage: string): Promise<boolean> => {
const res = (await callAppRoute('/save-my-partner-content', toSaveBody(nextRows))) as SaveContentResult;
if (res.ok) {
setRows(buildInitialRows(res.caseStudies));
await enqueueSnackbar({ message: successMessage, variant: 'success' });
return true;
}
await enqueueSnackbar({ message: res.reason, variant: 'error' });
return false;
},
[],
);
const handleSave = async (key: string) => {
if (loadFailed) return;
setBusyKey(key);
try {
// Collapse only on success so a rejected save keeps the card open to retry.
if (await persist(rows, 'Case study saved')) setExpandedKey(null);
} catch (error) {
await enqueueSnackbar({
message: error instanceof Error ? error.message : 'Failed to save case study',
variant: 'error',
});
} finally {
setBusyKey(null);
}
};
const handleDelete = async (key: string) => {
if (loadFailed) return;
const target = rows.find((r) => r.key === key);
if (!target) return;
const confirmed = await openCommandConfirmationModal({
title: 'Delete this case study?',
subtitle: 'It will be removed from your public profile. This cannot be undone.',
confirmButtonText: 'Delete',
confirmButtonAccent: 'danger',
});
if (confirmed !== 'confirm') return;
const remaining = rows.filter((r) => r.key !== key);
// A never-saved draft: drop it locally, no server round-trip.
if (!target.id) {
setRows(remaining);
return;
}
setBusyKey(key);
try {
await persist(remaining, 'Case study deleted');
} catch (error) {
await enqueueSnackbar({
message: error instanceof Error ? error.message : 'Failed to delete case study',
variant: 'error',
});
} finally {
setBusyKey(null);
}
};
return (
<div style={pageStyle}>
<div style={headerStyle}>
<div>
<h1 style={titleStyle}>My Case Studies</h1>
<div style={subtitleStyle}>Showcase your work on your public partner profile.</div>
</div>
<button
type="button"
style={addButtonStyle}
onClick={handleAdd}
disabled={loading || loadFailed || busyKey !== null}
>
+ Add case study
</button>
</div>
{loading ? (
<div>
<div style={skeletonStyle} />
<div style={skeletonStyle} />
</div>
) : loadFailed ? (
<div style={errorStyle}>
<div>We couldn't load your case studies. Please try again.</div>
<button type="button" style={retryButtonStyle} onClick={() => void load()}>
Retry
</button>
</div>
) : rows.length === 0 ? (
<div style={emptyStyle}>
No case studies yet. Add one to show clients the work you have delivered.
</div>
) : (
<div>
{rows.map((row) => (
<CaseStudyCard
key={row.key}
row={row}
expanded={expandedKey === row.key}
busy={busyKey === row.key}
onToggleExpand={() => {
// A save/delete persists the whole list, so block switching cards mid-flight
// to avoid clobbering another card's in-progress edits.
if (busyKey !== null) return;
setExpandedKey((cur) => (cur === row.key ? null : row.key));
}}
onChange={(patch) => patchRow(row.key, patch)}
onSave={() => void handleSave(row.key)}
onDelete={() => void handleDelete(row.key)}
/>
))}
</div>
)}
</div>
);
};
export default defineFrontComponent({
universalIdentifier: MY_CASE_STUDIES_FRONT_COMPONENT_ID,
name: 'My Case Studies',
description: 'Self-service page for a partner to create and edit their case studies.',
component: MyCaseStudies,
});
@@ -0,0 +1,200 @@
import { type CSSProperties } from 'react';
import { COLORS, Field, FONT, TextInput, UrlInput } from '../my-profile/form-fields';
import { MarkdownEditor } from '../my-profile/markdown-editor';
import { type CaseStudyRow } from './case-study-rows';
type CaseStudyCardProps = {
row: CaseStudyRow;
expanded: boolean;
busy: boolean;
onToggleExpand: () => void;
onChange: (patch: Partial<CaseStudyRow>) => void;
onSave: () => void;
onDelete: () => void;
};
const cardStyle: CSSProperties = {
border: `1px solid ${COLORS.border}`,
borderRadius: 8,
background: COLORS.surface,
marginBottom: 12,
fontFamily: FONT,
};
const summaryStyle: CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
padding: '12px 14px',
cursor: 'pointer',
};
const titleStyle: CSSProperties = { fontSize: 14, fontWeight: 600, color: COLORS.fg };
const clientStyle: CSSProperties = {
fontSize: 11,
letterSpacing: 0.4,
textTransform: 'uppercase',
color: COLORS.muted,
marginTop: 2,
};
const chipBase: CSSProperties = {
fontSize: 11,
fontWeight: 600,
padding: '2px 8px',
borderRadius: 999,
};
const chipPublished: CSSProperties = { ...chipBase, background: COLORS.accent, color: '#fff' };
const chipDraft: CSSProperties = {
...chipBase,
background: COLORS.surfaceAlt,
color: COLORS.muted,
border: `1px solid ${COLORS.border}`,
};
const caretStyle: CSSProperties = { color: COLORS.muted, fontSize: 12 };
const bodyStyle: CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: 14,
padding: '4px 14px 16px',
borderTop: `1px solid ${COLORS.border}`,
};
const actionsStyle: CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
flexWrap: 'wrap',
};
const toggleBase: CSSProperties = {
height: 30,
padding: '0 12px',
borderRadius: 6,
fontSize: 13,
fontFamily: FONT,
cursor: 'pointer',
};
const togglePublished: CSSProperties = {
...toggleBase,
border: `1px solid ${COLORS.accent}`,
background: COLORS.accentSoft,
color: COLORS.accent,
};
const toggleDraft: CSSProperties = {
...toggleBase,
border: `1px solid ${COLORS.border}`,
background: COLORS.surface,
color: COLORS.muted,
};
const saveButton: CSSProperties = {
height: 30,
padding: '0 16px',
borderRadius: 6,
border: 'none',
background: COLORS.accent,
color: '#fff',
fontSize: 13,
fontWeight: 600,
fontFamily: FONT,
cursor: 'pointer',
};
const deleteButton: CSSProperties = {
height: 30,
padding: '0 12px',
borderRadius: 6,
border: `1px solid ${COLORS.border}`,
background: COLORS.surface,
color: '#b3261e',
fontSize: 13,
fontFamily: FONT,
cursor: 'pointer',
};
export const CaseStudyCard = ({
row,
expanded,
busy,
onToggleExpand,
onChange,
onSave,
onDelete,
}: CaseStudyCardProps) => (
<div style={cardStyle}>
<div
style={summaryStyle}
onClick={onToggleExpand}
role="button"
tabIndex={0}
aria-expanded={expanded}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onToggleExpand();
}
}}
>
<div style={{ minWidth: 0 }}>
<div style={titleStyle}>{row.headline.trim() !== '' ? row.headline : 'Untitled case study'}</div>
{row.clientName.trim() !== '' ? <div style={clientStyle}>{row.clientName}</div> : null}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={row.published ? chipPublished : chipDraft}>{row.published ? 'Published' : 'Draft'}</span>
<span style={caretStyle}>{expanded ? '▾' : '▸'}</span>
</div>
</div>
{expanded ? (
<div style={bodyStyle}>
<Field label="Client">
<TextInput value={row.clientName} onChange={(v) => onChange({ clientName: v })} placeholder="Client name" />
</Field>
<Field label="Title">
<TextInput value={row.headline} onChange={(v) => onChange({ headline: v })} placeholder="What you delivered" />
</Field>
<Field label="Story">
<MarkdownEditor value={row.bodyMarkdown} onChange={(v) => onChange({ bodyMarkdown: v })} placeholder="Tell the story of this project…" ariaLabel="Case study story" />
</Field>
<Field label="Case study link">
<UrlInput value={row.caseStudyLink} onChange={(v) => onChange({ caseStudyLink: v })} placeholder="https://…" />
</Field>
<Field label="Cover image URL">
<UrlInput
value={row.coverImageUrl}
onChange={(v) => onChange({ coverImageUrl: v })}
placeholder="https://…"
/>
</Field>
{row.coverImageUrl.trim() !== '' ? (
<img
src={row.coverImageUrl}
alt="Cover preview"
style={{
maxWidth: '100%',
maxHeight: 160,
objectFit: 'cover',
borderRadius: 6,
border: `1px solid ${COLORS.border}`,
}}
/>
) : null}
<div style={actionsStyle}>
<button
type="button"
style={row.published ? togglePublished : toggleDraft}
onClick={() => onChange({ published: !row.published })}
>
{row.published ? 'Published on your profile' : 'Draft (hidden from profile)'}
</button>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" style={deleteButton} onClick={onDelete} disabled={busy}>
Delete
</button>
<button type="button" style={saveButton} onClick={onSave} disabled={busy}>
{busy ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</div>
) : null}
</div>
);
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest';
import {
buildInitialRows,
deriveName,
derivePublished,
isBlankDraft,
newDraftRow,
toSaveBody,
type CaseStudyRow,
} from './case-study-rows';
const persisted = (over: Partial<CaseStudyRow> = {}): CaseStudyRow => ({
key: 'r1',
id: 'r1',
clientName: 'Acme',
headline: 'Migration',
bodyMarkdown: '## Hi',
coverImageUrl: '',
caseStudyLink: 'https://x.io',
published: true,
...over,
});
describe('case-study-rows', () => {
it('derives published from APPROVED status only', () => {
expect(derivePublished('APPROVED')).toBe(true);
expect(derivePublished('WIP')).toBe(false);
expect(derivePublished(null)).toBe(false);
});
it('builds initial rows from the load payload (key = id, published from status)', () => {
const rows = buildInitialRows([
{ id: 'a', name: 'n', clientName: 'C', headline: 'H', bodyMarkdown: 'B', coverImageUrl: null, caseStudyLink: 'https://y', status: 'APPROVED' },
{ id: 'b', name: null, clientName: null, headline: null, bodyMarkdown: null, coverImageUrl: null, caseStudyLink: null, status: 'WIP' },
]);
expect(rows[0]).toMatchObject({ key: 'a', id: 'a', clientName: 'C', headline: 'H', bodyMarkdown: 'B', caseStudyLink: 'https://y', published: true });
expect(rows[1]).toMatchObject({ key: 'b', id: 'b', clientName: '', headline: '', bodyMarkdown: '', caseStudyLink: '', published: false });
});
it('new draft rows have a unique key, no id, and default to draft', () => {
const a = newDraftRow();
const b = newDraftRow();
expect(a.id).toBeUndefined();
expect(a.published).toBe(false);
expect(a.key).not.toBe(b.key);
});
it('derives name from headline, falling back when empty', () => {
expect(deriveName(persisted({ headline: ' Rebuild ' }))).toBe('Rebuild');
expect(deriveName(persisted({ headline: ' ' }))).toBe('Case study');
});
it('treats an untouched new draft as blank, but not a filled or persisted row', () => {
expect(isBlankDraft(newDraftRow())).toBe(true);
expect(isBlankDraft(newDraftRow())).toBe(true);
expect(isBlankDraft(persisted({ id: undefined, key: 'draft-x', headline: 'x' }))).toBe(false);
expect(isBlankDraft(persisted())).toBe(false);
});
it('builds the save body: drops blank drafts, keeps id for persisted, carries published + derived name', () => {
const rows = [persisted(), newDraftRow(), { ...newDraftRow(), headline: 'Fresh', published: true }];
const body = toSaveBody(rows) as { caseStudies: Array<Record<string, unknown>> };
expect(body.caseStudies).toHaveLength(2);
expect(body.caseStudies[0]).toMatchObject({ id: 'r1', name: 'Migration', clientName: 'Acme', bodyMarkdown: '## Hi', caseStudyLink: 'https://x.io', published: true });
expect(body.caseStudies[1]).toMatchObject({ name: 'Fresh', published: true });
expect(body.caseStudies[1].id).toBeUndefined();
});
it('carries coverImageUrl into the save body and reads it from the load payload', () => {
const rows = buildInitialRows([
{ id: 'a', name: 'n', clientName: 'C', headline: 'H', bodyMarkdown: 'B', coverImageUrl: 'https://img.example.com/a.png', caseStudyLink: 'https://y', status: 'APPROVED' },
]);
expect(rows[0].coverImageUrl).toBe('https://img.example.com/a.png');
const body = toSaveBody([persisted({ coverImageUrl: 'https://img.example.com/c.png' })]) as {
caseStudies: Array<Record<string, unknown>>;
};
expect(body.caseStudies[0].coverImageUrl).toBe('https://img.example.com/c.png');
});
it('a new draft with only a cover URL set is not treated as blank', () => {
expect(isBlankDraft({ ...newDraftRow(), coverImageUrl: 'https://img.example.com/x.png' })).toBe(false);
});
});
@@ -0,0 +1,68 @@
import type { MyProfilePayload } from '../my-profile/types';
export type CaseStudyRow = {
key: string; // stable React key: the record id for saved rows, `draft-N` for new ones
id?: string; // present once persisted
clientName: string;
headline: string;
bodyMarkdown: string;
caseStudyLink: string;
coverImageUrl: string;
published: boolean;
};
export const derivePublished = (status: string | null): boolean => status === 'APPROVED';
export const buildInitialRows = (
caseStudies: MyProfilePayload['caseStudies'],
): CaseStudyRow[] =>
caseStudies.map((cs) => ({
key: cs.id,
id: cs.id,
clientName: cs.clientName ?? '',
headline: cs.headline ?? '',
bodyMarkdown: cs.bodyMarkdown ?? '',
caseStudyLink: cs.caseStudyLink ?? '',
coverImageUrl: cs.coverImageUrl ?? '',
published: derivePublished(cs.status),
}));
let draftCounter = 0;
export const newDraftRow = (): CaseStudyRow => ({
key: `draft-${(draftCounter += 1)}`,
clientName: '',
headline: '',
bodyMarkdown: '',
caseStudyLink: '',
coverImageUrl: '',
published: false,
});
// The internal `name` column is required server-side but never shown to partners; derive it.
export const deriveName = (row: CaseStudyRow): string =>
row.headline.trim() !== '' ? row.headline.trim() : 'Case study';
export const isBlankDraft = (row: CaseStudyRow): boolean =>
row.id === undefined &&
row.headline.trim() === '' &&
row.bodyMarkdown.trim() === '' &&
row.clientName.trim() === '' &&
row.caseStudyLink.trim() === '' &&
row.coverImageUrl.trim() === '';
// The reconcile route needs the whole desired list; blank never-saved drafts are excluded
// so an untouched "Add" doesn't create an empty record.
export const toSaveBody = (rows: CaseStudyRow[]): Record<string, unknown> => ({
caseStudies: rows
.filter((row) => !isBlankDraft(row))
.map((row) => ({
...(row.id ? { id: row.id } : {}),
name: deriveName(row),
clientName: row.clientName,
headline: row.headline,
bodyMarkdown: row.bodyMarkdown,
caseStudyLink: row.caseStudyLink,
coverImageUrl: row.coverImageUrl,
published: row.published,
})),
});
@@ -0,0 +1,414 @@
import { useCallback, useEffect, useState, type ReactNode } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import { enqueueSnackbar } from 'twenty-sdk/front-component';
import { MY_PROFILE_FRONT_COMPONENT_ID } from 'src/constants/my-profile.constants';
import { callAppRoute } from './call-app-route';
import { MarkdownEditor } from './my-profile/markdown-editor';
import { ProfilePictureUpload } from './my-profile/ProfilePictureUpload';
import {
ChipMultiSelect,
COLORS,
CurrencyInput,
Field,
FONT,
SelectInput,
TagInput,
TextInput,
UrlInput,
type SelectOption,
} from './my-profile/form-fields';
type Currency = { amountMicros: number | null; currencyCode: string | null } | null;
type ProfilePayload = {
id: string;
name: string | null;
profilePictureUrl: string | null;
introduction: string | null;
city: string | null;
country: string | null;
languagesSpoken: string[] | null;
partnerScope: string[] | null;
skills: string[] | null;
typeOfTeam: string | null;
availability: string | null;
hourlyRate: Currency;
projectBudgetMin: Currency;
website: string | null;
linkedin: string | null;
calendarLink: string | null;
};
type ProfileOptions = {
country: SelectOption[];
languagesSpoken: SelectOption[];
partnerScope: SelectOption[];
typeOfTeam: SelectOption[];
availability: SelectOption[];
};
type LoadResult =
| { ok: true; profile: ProfilePayload; options: ProfileOptions }
| { ok: false; reason: string };
type SaveResult = { ok: true } | { ok: false; reason: string };
type MoneyField = { amount: number | null; currencyCode: string };
type ProfileForm = {
name: string;
introduction: string;
availability: string;
typeOfTeam: string;
hourlyRate: MoneyField;
projectBudgetMin: MoneyField;
partnerScope: string[];
skills: string[];
languagesSpoken: string[];
country: string;
city: string;
website: string;
linkedin: string;
calendarLink: string;
};
const SKILL_SUGGESTIONS = [
'Migrations',
'RevOps',
'Reporting',
'Forecasting',
'Automations',
'No-code ops',
'API & SDK',
'API integrations',
'Self-hosted',
'EU compliance',
'Data import',
'Onboarding',
'Training',
'Custom development',
];
const MICROS = 1_000_000;
const toMoneyField = (value: Currency): MoneyField => ({
amount: value?.amountMicros != null ? value.amountMicros / MICROS : null,
currencyCode: value?.currencyCode ?? 'USD',
});
const toProfileForm = (profile: ProfilePayload): ProfileForm => ({
name: profile.name ?? '',
introduction: profile.introduction ?? '',
availability: profile.availability ?? '',
typeOfTeam: profile.typeOfTeam ?? '',
hourlyRate: toMoneyField(profile.hourlyRate),
projectBudgetMin: toMoneyField(profile.projectBudgetMin),
partnerScope: profile.partnerScope ?? [],
skills: profile.skills ?? [],
languagesSpoken: profile.languagesSpoken ?? [],
country: profile.country ?? '',
city: profile.city ?? '',
website: profile.website ?? '',
linkedin: profile.linkedin ?? '',
calendarLink: profile.calendarLink ?? '',
});
const toMicros = (money: MoneyField) =>
money.amount == null
? null
: { amountMicros: Math.round(money.amount * MICROS), currencyCode: money.currencyCode || 'USD' };
// Enum/country selectors send null (not '') when reset to blank so the field clears.
const toSaveBody = (form: ProfileForm): Record<string, unknown> => ({
name: form.name,
introduction: form.introduction,
city: form.city,
languagesSpoken: form.languagesSpoken,
partnerScope: form.partnerScope,
skills: form.skills,
website: form.website,
linkedin: form.linkedin,
calendarLink: form.calendarLink,
hourlyRate: toMicros(form.hourlyRate),
projectBudgetMin: toMicros(form.projectBudgetMin),
availability: form.availability === '' ? null : form.availability,
typeOfTeam: form.typeOfTeam === '' ? null : form.typeOfTeam,
country: form.country === '' ? null : form.country,
});
const styles = {
root: {
display: 'flex',
justifyContent: 'center',
alignItems: 'flex-start',
width: '100%',
minHeight: 'calc(100dvh - 56px)',
boxSizing: 'border-box',
padding: 32,
fontFamily: FONT,
color: COLORS.fg,
background: COLORS.bg,
} as const,
card: {
width: '100%',
maxWidth: '100%',
background: COLORS.surface,
border: `1px solid ${COLORS.border}`,
borderRadius: 12,
padding: 28,
display: 'flex',
flexDirection: 'column',
gap: 28,
} as const,
title: { fontSize: 20, fontWeight: 700, margin: 0 } as const,
sectionTitle: {
fontSize: 11.5,
fontWeight: 700,
color: COLORS.muted,
textTransform: 'uppercase',
letterSpacing: 0.5,
margin: 0,
} as const,
row2: { display: 'flex', gap: 12 } as const,
footer: {
display: 'flex',
justifyContent: 'flex-end',
paddingTop: 4,
borderTop: `1px solid ${COLORS.border}`,
} as const,
button: {
height: 40,
borderRadius: 8,
border: 'none',
background: COLORS.accent,
color: '#fff',
fontSize: 14,
fontWeight: 650,
fontFamily: FONT,
cursor: 'pointer',
padding: '0 24px',
marginTop: 16,
} as const,
} as const;
const Section = ({ title, children }: { title: string; children: ReactNode }) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<h2 style={styles.sectionTitle}>{title}</h2>
{children}
</div>
);
const MyProfile = () => {
const [form, setForm] = useState<ProfileForm | null>(null);
const [options, setOptions] = useState<ProfileOptions | null>(null);
const [pictureUrl, setPictureUrl] = useState<string | null>(null);
const [partnerId, setPartnerId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const set = <K extends keyof ProfileForm>(key: K, value: ProfileForm[K]) =>
setForm((prev) => (prev ? { ...prev, [key]: value } : prev));
const load = useCallback(async () => {
setLoading(true);
try {
const res = (await callAppRoute('/my-partner-profile', {})) as LoadResult;
if (res.ok) {
setForm(toProfileForm(res.profile));
setOptions(res.options);
setPictureUrl(res.profile.profilePictureUrl);
setPartnerId(res.profile.id);
} else {
await enqueueSnackbar({ message: `Could not load profile: ${res.reason}`, variant: 'error' });
}
} catch (error) {
await enqueueSnackbar({
message: error instanceof Error ? error.message : 'Failed to load profile',
variant: 'error',
});
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const save = useCallback(async () => {
if (!form) return;
setSaving(true);
try {
const res = (await callAppRoute('/save-my-partner-profile', toSaveBody(form))) as SaveResult;
if (res.ok) {
await enqueueSnackbar({ message: 'Profile saved', variant: 'success' });
await load();
} else {
await enqueueSnackbar({ message: `Save failed: ${res.reason}`, variant: 'error' });
}
} catch (error) {
await enqueueSnackbar({
message: error instanceof Error ? error.message : 'Failed to save profile',
variant: 'error',
});
} finally {
setSaving(false);
}
}, [form, load]);
if (loading) return <div style={styles.root}>Loading</div>;
if (!form || !options) {
return <div style={styles.root}>No partner profile found for your account.</div>;
}
return (
<div style={styles.root}>
<div style={styles.card}>
<h1 style={styles.title}>My Profile</h1>
<Section title="Basics">
{partnerId && (
<Field label="Profile picture">
<ProfilePictureUpload url={pictureUrl} recordId={partnerId} />
</Field>
)}
<Field label="Name">
<TextInput value={form.name} onChange={(value) => set('name', value)} />
</Field>
<Field label="Introduction">
<MarkdownEditor
value={form.introduction}
onChange={(value) => set('introduction', value)}
placeholder="Tell clients about your team…"
ariaLabel="Introduction"
/>
</Field>
</Section>
<Section title="Availability & engagement">
<div style={styles.row2}>
<div style={{ flex: 1 }}>
<Field label="Availability">
<SelectInput
value={form.availability}
options={options.availability}
onChange={(value) => set('availability', value)}
/>
</Field>
</div>
<div style={{ flex: 1 }}>
<Field label="Type of team">
<SelectInput
value={form.typeOfTeam}
options={options.typeOfTeam}
onChange={(value) => set('typeOfTeam', value)}
/>
</Field>
</div>
</div>
<div style={styles.row2}>
<div style={{ flex: 1 }}>
<Field label="Hourly rate">
<CurrencyInput
amount={form.hourlyRate.amount}
currencyCode={form.hourlyRate.currencyCode}
onChange={(value) => set('hourlyRate', value)}
/>
</Field>
</div>
<div style={{ flex: 1 }}>
<Field label="Min project budget">
<CurrencyInput
amount={form.projectBudgetMin.amount}
currencyCode={form.projectBudgetMin.currencyCode}
onChange={(value) => set('projectBudgetMin', value)}
/>
</Field>
</div>
</div>
</Section>
<Section title="Expertise">
<Field label="Partner scope">
<ChipMultiSelect
value={form.partnerScope}
options={options.partnerScope}
onChange={(value) => set('partnerScope', value)}
/>
</Field>
<Field label="Skills">
<TagInput
value={form.skills}
suggestions={SKILL_SUGGESTIONS}
onChange={(value) => set('skills', value)}
/>
</Field>
<Field label="Languages spoken">
<ChipMultiSelect
value={form.languagesSpoken}
options={options.languagesSpoken}
onChange={(value) => set('languagesSpoken', value)}
/>
</Field>
</Section>
<Section title="Location">
<div style={styles.row2}>
<div style={{ flex: 1 }}>
<Field label="Country">
<SelectInput
value={form.country}
options={options.country}
onChange={(value) => set('country', value)}
/>
</Field>
</div>
<div style={{ flex: 1 }}>
<Field label="City">
<TextInput value={form.city} onChange={(value) => set('city', value)} />
</Field>
</div>
</div>
</Section>
<Section title="Links">
<Field label="Website">
<UrlInput
value={form.website}
onChange={(value) => set('website', value)}
placeholder="https://…"
/>
</Field>
<Field label="LinkedIn">
<UrlInput
value={form.linkedin}
onChange={(value) => set('linkedin', value)}
placeholder="https://linkedin.com/…"
/>
</Field>
<Field label="Calendar link">
<UrlInput
value={form.calendarLink}
onChange={(value) => set('calendarLink', value)}
placeholder="https://cal.com/…"
/>
</Field>
</Section>
<div style={styles.footer}>
<button style={styles.button} onClick={() => void save()} disabled={saving}>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: MY_PROFILE_FRONT_COMPONENT_ID,
name: 'My Partner Profile',
description: 'Self-service page for a partner to view and edit their profile.',
component: MyProfile,
});
@@ -0,0 +1,81 @@
import { useState } from 'react';
import { AppPath, enqueueSnackbar, navigate } from 'twenty-sdk/front-component';
import { COLORS, FONT } from './form-fields';
const avatarStyle = {
width: 72,
height: 72,
borderRadius: '50%',
objectFit: 'cover',
border: `1px solid ${COLORS.border}`,
background: COLORS.surfaceAlt,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 24,
color: COLORS.muted,
flexShrink: 0,
} as const;
const buttonStyle = {
height: 34,
borderRadius: 8,
border: `1px solid ${COLORS.border}`,
background: COLORS.surface,
color: COLORS.fg,
fontSize: 13,
fontWeight: 600,
fontFamily: FONT,
cursor: 'pointer',
padding: '0 14px',
} as const;
// A sandboxed front-component can't read file bytes (the renderer only forwards file
// metadata across the worker boundary), so real image upload has to happen on the
// platform's native record page. This sends the partner there; on return the page
// re-fetches and shows the new photo.
export const ProfilePictureUpload = ({
url,
recordId,
}: {
url: string | null;
recordId: string;
}) => {
const [busy, setBusy] = useState(false);
const goToRecord = async () => {
setBusy(true);
try {
await navigate(AppPath.RecordShowPage, {
objectNameSingular: 'partner',
objectRecordId: recordId,
});
} catch (error) {
await enqueueSnackbar({
message: error instanceof Error ? error.message : 'Could not open your record',
variant: 'error',
});
} finally {
setBusy(false);
}
};
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
{url ? (
<img src={url} alt="Profile" style={avatarStyle} />
) : (
<div style={avatarStyle}>🙂</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<button style={buttonStyle} onClick={() => void goToRecord()} disabled={busy}>
{busy ? 'Opening…' : url ? 'Change photo' : 'Add photo'}
</button>
<span style={{ fontSize: 11, color: COLORS.muted }}>
Opens your record page to upload an image.
</span>
</div>
</div>
);
};
@@ -0,0 +1,315 @@
import { useState, type ReactNode } from 'react';
export type SelectOption = { value: string; label: string };
export const COLORS = {
bg: '#f7f7f8',
surface: '#ffffff',
surfaceAlt: '#f4f5f7',
fg: '#1c1c1c',
muted: '#66646a',
border: '#e7e7eb',
accent: '#4a38f5',
accentSoft: 'rgba(74, 56, 245, 0.1)',
} as const;
export const FONT =
'"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif';
const controlBase = {
width: '100%',
boxSizing: 'border-box',
borderRadius: 8,
border: `1px solid ${COLORS.border}`,
background: COLORS.surface,
color: COLORS.fg,
fontSize: 14,
fontFamily: FONT,
outline: 'none',
} as const;
const inputStyle = { ...controlBase, height: 40, padding: '0 12px' } as const;
const labelStyle = {
fontSize: 12,
fontWeight: 600,
color: COLORS.muted,
} as const;
const chipBase = {
display: 'inline-flex',
alignItems: 'center',
gap: 6,
height: 28,
padding: '0 10px',
borderRadius: 999,
fontSize: 12.5,
fontFamily: FONT,
cursor: 'pointer',
border: `1px solid ${COLORS.border}`,
background: COLORS.surface,
color: COLORS.fg,
} as const;
const chipSelected = {
...chipBase,
border: `1px solid ${COLORS.accent}`,
background: COLORS.accentSoft,
color: COLORS.accent,
fontWeight: 600,
} as const;
export const Field = ({ label, children }: { label: string; children: ReactNode }) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<span style={labelStyle}>{label}</span>
{children}
</div>
);
export const TextInput = ({
value,
onChange,
placeholder,
}: {
value: string;
onChange: (value: string) => void;
placeholder?: string;
}) => (
<input
style={inputStyle}
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
/>
);
export const TextArea = ({
value,
onChange,
rows = 4,
placeholder,
}: {
value: string;
onChange: (value: string) => void;
rows?: number;
placeholder?: string;
}) => (
<textarea
style={{ ...controlBase, padding: '10px 12px', resize: 'vertical', lineHeight: 1.5 }}
rows={rows}
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
/>
);
const CARET =
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6' fill='none' stroke='%2366646a' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M1 1l4 4 4-4'/%3E%3C/svg%3E\")";
export const SelectInput = ({
value,
options,
onChange,
placeholder = 'Select…',
}: {
value: string;
options: SelectOption[];
onChange: (value: string) => void;
placeholder?: string;
}) => (
<select
style={{
...inputStyle,
appearance: 'none',
WebkitAppearance: 'none',
MozAppearance: 'none',
backgroundImage: CARET,
backgroundRepeat: 'no-repeat',
backgroundPosition: 'right 12px center',
paddingRight: 32,
cursor: 'pointer',
}}
value={value}
onChange={(event) => onChange(event.target.value)}
>
<option value="">{placeholder}</option>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
export const ChipMultiSelect = ({
value,
options,
onChange,
}: {
value: string[];
options: SelectOption[];
onChange: (value: string[]) => void;
}) => {
const toggle = (optionValue: string) => {
onChange(
value.includes(optionValue)
? value.filter((item) => item !== optionValue)
: [...value, optionValue],
);
};
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{options.map((option) => {
const selected = value.includes(option.value);
return (
<button
key={option.value}
type="button"
style={selected ? chipSelected : chipBase}
onClick={() => toggle(option.value)}
>
{option.label}
</button>
);
})}
</div>
);
};
// Free-text tag input with clickable suggestion pills — the same "suggest but let
// them add their own" concept as the website skills field.
export const TagInput = ({
value,
suggestions,
onChange,
placeholder,
}: {
value: string[];
suggestions: string[];
onChange: (value: string[]) => void;
placeholder?: string;
}) => {
const [draft, setDraft] = useState('');
const add = (tag: string) => {
const trimmed = tag.trim();
if (trimmed !== '' && !value.includes(trimmed)) {
onChange([...value, trimmed]);
}
setDraft('');
};
const remove = (tag: string) => onChange(value.filter((item) => item !== tag));
const unusedSuggestions = suggestions.filter((tag) => !value.includes(tag));
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{value.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{value.map((tag) => (
<span key={tag} style={chipSelected}>
{tag}
<span
role="button"
tabIndex={0}
aria-label={`Remove ${tag}`}
onClick={() => remove(tag)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
remove(tag);
}
}}
style={{ cursor: 'pointer', opacity: 0.7, fontSize: 14, lineHeight: 1 }}
>
×
</span>
</span>
))}
</div>
)}
<input
style={inputStyle}
value={draft}
placeholder={placeholder ?? 'Type a skill and press Enter'}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ',') {
event.preventDefault();
add(draft);
}
}}
/>
{unusedSuggestions.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{unusedSuggestions.map((tag) => (
<button key={tag} type="button" style={chipBase} onClick={() => add(tag)}>
+ {tag}
</button>
))}
</div>
)}
</div>
);
};
export const CurrencyInput = ({
amount,
currencyCode,
onChange,
}: {
amount: number | null;
currencyCode: string;
onChange: (next: { amount: number | null; currencyCode: string }) => void;
}) => (
<div style={{ display: 'flex', gap: 8 }}>
<input
style={{ ...inputStyle, flex: 1 }}
type="text"
inputMode="decimal"
value={amount == null ? '' : String(amount)}
placeholder="0"
onChange={(event) => {
const cleaned = event.target.value.replace(/[^0-9.]/g, '');
const next = Number(cleaned);
onChange({
amount: cleaned === '' || Number.isNaN(next) ? null : next,
currencyCode,
});
}}
/>
<div
style={{
...inputStyle,
width: 64,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: COLORS.muted,
background: COLORS.surfaceAlt,
}}
>
{currencyCode || 'USD'}
</div>
</div>
);
export const UrlInput = ({
value,
onChange,
placeholder,
}: {
value: string;
onChange: (value: string) => void;
placeholder?: string;
}) => (
<input
style={inputStyle}
type="url"
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
/>
);
@@ -0,0 +1,186 @@
import { type ReactNode } from 'react';
import { copyToClipboard } from 'twenty-sdk/front-component';
import { COLORS, FONT } from './form-fields';
import { MarkdownContent } from './markdown-render';
// The sandboxed worker exposes no cursor/selection API (DOM refs are method-less proxies,
// and selection offsets aren't forwarded), so toolbar buttons append a ready-to-edit
// snippet rather than wrapping the current selection. The always-on side-by-side preview
// is what makes that workable — the partner sees the result form as they type.
type Tool = { title: string; insert: string; block: boolean; label: ReactNode };
const TOOLS: Tool[] = [
{ title: 'Bold', insert: '**bold**', block: false, label: <span style={{ fontWeight: 700 }}>Bold</span> },
{ title: 'Italic', insert: '*italic*', block: false, label: <span style={{ fontStyle: 'italic' }}>Italic</span> },
{
title: 'Heading',
insert: '### Heading',
block: true,
label: <span style={{ fontWeight: 700, fontSize: 14 }}>Heading</span>,
},
{ title: 'Bullet list', insert: '- item', block: true, label: <span>&nbsp;List</span> },
{ title: 'Numbered list', insert: '1. item', block: true, label: <span>1.&nbsp;List</span> },
{
title: 'Link',
insert: '[text](https://)',
block: false,
label: <span style={{ color: COLORS.accent, textDecoration: 'underline' }}>Link</span>,
},
];
const appendSnippet = (value: string, tool: Tool): string => {
if (value === '') return tool.insert;
if (tool.block) {
const separator = value.endsWith('\n\n') ? '' : value.endsWith('\n') ? '\n' : '\n\n';
return `${value}${separator}${tool.insert}`;
}
const separator = value.endsWith(' ') || value.endsWith('\n') ? '' : ' ';
return `${value}${separator}${tool.insert}`;
};
const buildPrompt = (value: string): string =>
[
'Reformat the text below as clean markdown for my partner profile.',
'Use only: **bold**, *italic*, ### headings, - bullet lists, 1. numbered lists, and [links](url).',
"Keep all the information and don't invent anything.",
'Ask me any questions you need to be sure you have captured all the formatting I want, then return only the formatted text.',
'',
'---',
value.trim() === '' ? '(paste your introduction here)' : value.trim(),
].join('\n');
// The front-component runs in a Web Worker with no clipboard API; copyToClipboard is a host
// action that copies on the main thread and shows its own confirmation snackbar.
const copyPrompt = (value: string): Promise<void> => copyToClipboard(buildPrompt(value));
const EDITOR_HEIGHT = 280;
const toolbarStyle = { display: 'flex', flexWrap: 'wrap', gap: 6 } as const;
const toolButtonStyle = {
height: 30,
padding: '0 12px',
borderRadius: 6,
border: `1px solid ${COLORS.border}`,
background: COLORS.surface,
color: COLORS.fg,
fontSize: 13,
fontFamily: FONT,
cursor: 'pointer',
} as const;
const paneStyle = { flex: '1 1 340px', minWidth: 0, height: EDITOR_HEIGHT } as const;
const controlBase = {
width: '100%',
height: '100%',
boxSizing: 'border-box',
border: `1px solid ${COLORS.border}`,
borderRadius: 8,
background: COLORS.surface,
fontFamily: FONT,
fontSize: 14,
color: COLORS.fg,
lineHeight: 1.5,
padding: '10px 12px',
} as const;
const textareaStyle = { ...controlBase, resize: 'none' } as const;
const previewBoxStyle = { ...controlBase, overflowY: 'auto' } as const;
const eyebrowStyle = {
fontSize: 10.5,
fontWeight: 700,
letterSpacing: 0.5,
textTransform: 'uppercase',
color: COLORS.muted,
marginBottom: 6,
} as const;
const helpBoxStyle = {
display: 'flex',
flexDirection: 'column',
gap: 8,
border: `1px solid ${COLORS.border}`,
borderRadius: 8,
background: COLORS.surfaceAlt,
padding: 12,
} as const;
const copyButtonStyle = {
alignSelf: 'flex-start',
height: 30,
padding: '0 14px',
borderRadius: 6,
border: 'none',
background: COLORS.accent,
color: '#fff',
fontSize: 13,
fontWeight: 600,
fontFamily: FONT,
cursor: 'pointer',
} as const;
export const MarkdownEditor = ({
value,
onChange,
placeholder,
ariaLabel,
}: {
value: string;
onChange: (value: string) => void;
placeholder?: string;
ariaLabel?: string;
}) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={toolbarStyle}>
{TOOLS.map((tool) => (
<button
key={tool.title}
type="button"
title={tool.title}
style={toolButtonStyle}
onClick={() => onChange(appendSnippet(value, tool))}
>
{tool.label}
</button>
))}
</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<div style={paneStyle}>
<textarea
style={textareaStyle}
value={value}
placeholder={placeholder}
aria-label={ariaLabel ?? placeholder}
onChange={(event) => onChange(event.target.value)}
/>
</div>
<div style={paneStyle}>
<div style={previewBoxStyle}>
<div style={eyebrowStyle}>Preview</div>
{value.trim() === '' ? (
<span style={{ color: COLORS.muted, fontSize: 13 }}>
Your formatted text appears here.
</span>
) : (
<MarkdownContent source={value} />
)}
</div>
</div>
</div>
<div style={helpBoxStyle}>
<span style={{ fontSize: 12.5, color: COLORS.muted }}>
Not comfortable with Markdown? Copy the prompt into your AI agent to format it for you.
</span>
<button type="button" style={copyButtonStyle} onClick={() => void copyPrompt(value)}>
Copy prompt
</button>
</div>
</div>
);
@@ -0,0 +1,35 @@
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { MarkdownContent } from './markdown-render';
const render = (source: string): string =>
renderToStaticMarkup(createElement(MarkdownContent, { source }));
describe('MarkdownContent', () => {
it('renders bold, italic, and links', () => {
const html = render('a **b** _c_ [d](https://x.io)');
expect(html).toContain('<strong>b</strong>');
expect(html).toContain('<em>c</em>');
expect(html).toContain('href="https://x.io"');
});
it('demotes # and ## to h3 and never emits h1/h2', () => {
const html = render('# Big\n\n## Sub\n\n### Three');
expect(html).toContain('<h3');
expect(html).not.toContain('<h1');
expect(html).not.toContain('<h2');
});
it('renders bullet and numbered lists', () => {
expect(render('- a\n- b')).toContain('<ul');
expect(render('1. a\n2. b')).toContain('<ol');
});
it('leaves a pipe-table as text (matches the website, which has no remark-gfm)', () => {
const html = render('| a | b |\n| --- | --- |\n| 1 | 2 |');
expect(html).not.toContain('<table');
});
});
@@ -0,0 +1,60 @@
import Markdown from 'react-markdown';
import { COLORS, FONT } from './form-fields';
// Mirrors the public website renderer (partners-marketplace/RichText.tsx): same allow-list,
// unwrapDisallowed, and h1/h2 demoted to h3 — so the in-app preview renders exactly what a
// client sees on the profile page. Every element is styled inline because the sandboxed
// worker has no descendant CSS.
const ALLOWED = [
'p',
'strong',
'em',
'h1',
'h2',
'h3',
'h4',
'ul',
'ol',
'li',
'a',
'br',
];
const containerStyle = {
fontFamily: FONT,
fontSize: 14,
color: COLORS.fg,
lineHeight: 1.5,
} as const;
const headingStyle = { fontSize: 15, fontWeight: 700, margin: '10px 0 4px' } as const;
const subHeadingStyle = { fontSize: 13, fontWeight: 700, margin: '10px 0 4px' } as const;
const paragraphStyle = { margin: '6px 0' } as const;
const listStyle = { margin: '6px 0', paddingLeft: 20 } as const;
const linkStyle = { color: COLORS.accent, textDecoration: 'underline' } as const;
export const MarkdownContent = ({ source }: { source: string }) => (
<div style={containerStyle}>
<Markdown
allowedElements={ALLOWED}
unwrapDisallowed
components={{
h1: ({ children }) => <h3 style={headingStyle}>{children}</h3>,
h2: ({ children }) => <h3 style={headingStyle}>{children}</h3>,
h3: ({ children }) => <h3 style={headingStyle}>{children}</h3>,
h4: ({ children }) => <h4 style={subHeadingStyle}>{children}</h4>,
p: ({ children }) => <p style={paragraphStyle}>{children}</p>,
ul: ({ children }) => <ul style={listStyle}>{children}</ul>,
ol: ({ children }) => <ol style={listStyle}>{children}</ol>,
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noreferrer" style={linkStyle}>
{children}
</a>
),
}}
>
{source}
</Markdown>
</div>
);
@@ -0,0 +1,10 @@
export type {
MyPartnerProfileResult,
MyProfilePayload,
} from 'src/logic-functions/get-my-partner-profile.logic-function';
export type { SaveResult } from 'src/logic-functions/save-my-partner-profile.logic-function';
export type { SaveLinksResult } from 'src/logic-functions/save-my-partner-links.logic-function';
export type { SaveServicesResult } from 'src/logic-functions/save-my-partner-services.logic-function';
export type { SaveContentResult } from 'src/logic-functions/save-my-partner-content.logic-function';
export type { SubmitContentForReviewResult } from 'src/logic-functions/submit-partner-content-for-review.logic-function';
export type { ProfileOptions, SelectOption } from 'src/constants/my-profile.constants';
@@ -0,0 +1,252 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { type RoutePayload } from 'twenty-sdk/define';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { handler } from '../get-my-partner-profile.logic-function';
function requireId(id: string | null | undefined, what: string): string {
if (!id) throw new Error(`${what} returned no id`);
return id;
}
async function getWorkspaceMember(
client: CoreApiClient,
): Promise<{ id: string; userId: string }> {
const r = await client.query({
workspaceMembers: {
__args: { first: 1 },
edges: { node: { id: true, userId: true } },
},
});
const node = r.workspaceMembers?.edges?.[0]?.node;
if (!node?.id || !node?.userId) {
throw new Error('No workspace members found — cannot run test');
}
return { id: node.id, userId: node.userId };
}
async function createPartner(client: CoreApiClient, memberId: string): Promise<string> {
const r = await client.mutation({
createPartner: {
__args: {
data: {
name: `[test-my-profile] partner ${Date.now()}`,
slug: `test-my-profile-${Date.now()}`,
partnerUserId: memberId,
city: 'Paris',
introduction: 'Senior implementation partner.',
},
},
id: true,
},
});
return requireId(r.createPartner?.id, 'createPartner');
}
async function destroyPartner(client: CoreApiClient, id: string) {
await client.mutation({ destroyPartner: { __args: { id }, id: true } }).catch(() => {});
}
async function createPartnerLink(
client: CoreApiClient,
partnerId: string,
memberId: string,
): Promise<string> {
const r = await client.mutation({
createPartnerLink: {
__args: {
data: {
name: 'Case studies',
url: { primaryLinkUrl: 'https://example.com/case-studies' },
sortOrder: 1,
partnerId,
partnerUserId: memberId,
},
},
id: true,
},
});
return requireId(r.createPartnerLink?.id, 'createPartnerLink');
}
async function destroyPartnerLink(client: CoreApiClient, id: string) {
await client.mutation({ destroyPartnerLink: { __args: { id }, id: true } }).catch(() => {});
}
async function createPartnerService(
client: CoreApiClient,
partnerId: string,
memberId: string,
): Promise<string> {
const r = await client.mutation({
createPartnerService: {
__args: {
data: {
title: 'Data migration',
description: 'Historical sync and schema mapping.',
sortOrder: 1,
partnerId,
partnerUserId: memberId,
},
},
id: true,
},
});
return requireId(r.createPartnerService?.id, 'createPartnerService');
}
async function destroyPartnerService(client: CoreApiClient, id: string) {
await client.mutation({ destroyPartnerService: { __args: { id }, id: true } }).catch(() => {});
}
async function createPartnerContent(
client: CoreApiClient,
partnerId: string,
memberId: string,
name: string,
): Promise<string> {
const r = await client.mutation({
createPartnerContent: {
__args: {
data: {
name,
contentType: ['CASE_STUDY'],
status: 'APPROVED',
clientName: 'Acme Corp',
headline: 'CRM migration',
body: { markdown: 'Moved 12 teams to Twenty.' },
caseStudyLink: { primaryLinkUrl: 'https://example.com/case-study' },
partnerId,
partnerUserId: memberId,
},
},
id: true,
},
});
return requireId(r.createPartnerContent?.id, 'createPartnerContent');
}
async function destroyPartnerContent(client: CoreApiClient, id: string) {
await client.mutation({ destroyPartnerContent: { __args: { id }, id: true } }).catch(() => {});
}
const makeToken = (payload: Record<string, unknown>): string => {
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
return `header.${body}.sig`;
};
const makeRouteEvent = (userWorkspaceId: string | null): RoutePayload<unknown> => ({
headers: {},
queryStringParameters: {},
pathParameters: {},
body: null,
isBase64Encoded: false,
requestContext: { http: { method: 'POST', path: '/my-partner-profile' } },
userWorkspaceId,
});
// Invoke the handler directly against a live workspace, crafting the same short-lived env
// token resolvePartnerFromRequest decodes (see resolve-partner-from-request.test.ts), so the
// full identity → profile path is exercised without a real authenticated HTTP request.
describe('get-my-partner-profile', () => {
let client: CoreApiClient;
const originalToken = process.env.TWENTY_APP_ACCESS_TOKEN;
const createdPartnerIds: string[] = [];
const createdLinkIds: string[] = [];
const createdServiceIds: string[] = [];
const createdContentIds: string[] = [];
beforeEach(() => {
client = new CoreApiClient();
});
afterEach(async () => {
if (originalToken === undefined) delete process.env.TWENTY_APP_ACCESS_TOKEN;
else process.env.TWENTY_APP_ACCESS_TOKEN = originalToken;
for (const id of createdLinkIds.splice(0)) await destroyPartnerLink(client, id);
for (const id of createdServiceIds.splice(0)) await destroyPartnerService(client, id);
for (const id of createdContentIds.splice(0)) await destroyPartnerContent(client, id);
for (const id of createdPartnerIds.splice(0)) await destroyPartner(client, id);
});
it('returns UNAUTHENTICATED when the event has no userWorkspaceId', async () => {
const result = await handler(makeRouteEvent(null));
expect(result).toEqual({ ok: false, reason: 'UNAUTHENTICATED' });
});
it('returns NO_PARTNER when the token userId matches no workspace member', async () => {
const userWorkspaceId = `uw-test-nomember-${Date.now()}`;
process.env.TWENTY_APP_ACCESS_TOKEN = makeToken({
userId: '11111111-1111-4111-8111-111111111111',
userWorkspaceId,
});
const result = await handler(makeRouteEvent(userWorkspaceId));
expect(result).toEqual({ ok: false, reason: 'NO_PARTNER' });
});
it("returns the calling partner's profile with links, services, and case studies", async () => {
const member = await getWorkspaceMember(client);
const partnerId = await createPartner(client, member.id);
createdPartnerIds.push(partnerId);
const linkId = await createPartnerLink(client, partnerId, member.id);
createdLinkIds.push(linkId);
const serviceId = await createPartnerService(client, partnerId, member.id);
createdServiceIds.push(serviceId);
const caseStudyName = `[test-my-profile] case study ${Date.now()}`;
const contentId = await createPartnerContent(client, partnerId, member.id, caseStudyName);
createdContentIds.push(contentId);
const userWorkspaceId = `uw-test-${Date.now()}`;
process.env.TWENTY_APP_ACCESS_TOKEN = makeToken({
userId: member.userId,
userWorkspaceId,
});
const result = await handler(makeRouteEvent(userWorkspaceId));
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.profile.id).toBe(partnerId);
expect(result.profile.city).toBe('Paris');
expect(result.profile.introduction).toBe('Senior implementation partner.');
expect(result.profile.links).toEqual([
{
id: linkId,
name: 'Case studies',
url: 'https://example.com/case-studies',
sortOrder: 1,
},
]);
expect(result.profile.services).toEqual([
{
id: serviceId,
title: 'Data migration',
description: 'Historical sync and schema mapping.',
sortOrder: 1,
},
]);
expect(result.profile.caseStudies).toEqual([
{
id: contentId,
name: caseStudyName,
clientName: 'Acme Corp',
headline: 'CRM migration',
bodyMarkdown: 'Moved 12 teams to Twenty.',
coverImageUrl: null,
caseStudyLink: 'https://example.com/case-study',
status: 'APPROVED',
},
]);
expect(result.options.partnerScope[0]).toEqual({
value: 'ADVISORY',
label: 'Advisory & Discovery',
});
});
});
@@ -0,0 +1,141 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { beforeAll, describe, expect, it } from 'vitest';
import { handler as getPartnerBySlug } from '../get-partner-by-slug.logic-function';
import { handler as listAvailablePartners } from '../list-available-partners.logic-function';
const client = new CoreApiClient();
const KNOWN_SLUG = 'nine-dots-ventures';
async function ensureMarketplacePartnerExists(): Promise<void> {
const existing = await client.query({
partners: {
__args: { filter: { slug: { eq: KNOWN_SLUG } }, first: 1 },
edges: {
node: {
id: true,
validationStage: true,
availability: true,
},
},
},
});
const node = existing.partners?.edges?.[0]?.node;
if (node) {
if (
node.validationStage === 'VALIDATED' &&
node.availability === 'AVAILABLE'
) {
return;
}
await client.mutation({
updatePartner: {
__args: {
id: node.id,
data: {
validationStage: 'VALIDATED',
availability: 'AVAILABLE',
},
},
id: true,
},
});
return;
}
await client.mutation({
createPartner: {
__args: {
data: {
name: 'Nine Dots Ventures',
slug: KNOWN_SLUG,
introduction:
'## About Nine Dots\n\nMarketplace integration test partner.',
calendarLink: { primaryLinkUrl: 'https://calendly.com/placeholder' },
validationStage: 'VALIDATED',
availability: 'AVAILABLE',
},
},
id: true,
},
});
}
beforeAll(async () => {
await client.query({
partners: { __args: { first: 1 }, edges: { node: { id: true } } },
});
await ensureMarketplacePartnerExists();
});
describe('list-available-partners handler', () => {
it('returns validated available partners with plain introduction excerpts', async () => {
const result = await listAvailablePartners();
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.count).toBeGreaterThan(0);
expect(Array.isArray(result.partners)).toBe(true);
const partner = result.partners.find((entry) => entry.slug === KNOWN_SLUG);
expect(partner).toBeDefined();
if (!partner) {
return;
}
expect(partner.name.length).toBeGreaterThan(0);
expect(partner.introduction.length).toBeGreaterThan(0);
expect(partner.introduction).not.toMatch(/^##\s/m);
expect('projectBudgetTypical' in partner).toBe(false);
expect('profileLinks' in partner).toBe(false);
expect('services' in partner).toBe(false);
expect('portfolio' in partner).toBe(false);
});
});
describe('get-partner-by-slug handler', () => {
it('returns NOT_FOUND for an unknown slug', async () => {
const result = await getPartnerBySlug({
queryStringParameters: { slug: `missing-partner-${Date.now()}` },
});
expect(result).toEqual({ ok: false, reason: 'NOT_FOUND' });
});
it('returns profile payload with markdown introduction and nested collections', async () => {
const result = await getPartnerBySlug({
queryStringParameters: { slug: KNOWN_SLUG },
});
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
const { partner } = result;
expect(partner.slug).toBe(KNOWN_SLUG);
expect(partner.introduction.length).toBeGreaterThan(0);
expect('projectBudgetTypical' in partner).toBe(true);
expect(Array.isArray(partner.profileLinks)).toBe(true);
expect(Array.isArray(partner.services)).toBe(true);
expect(Array.isArray(partner.portfolio)).toBe(true);
for (const service of partner.services) {
expect(typeof service.title).toBe('string');
expect(typeof service.description).toBe('string');
}
for (const item of partner.portfolio) {
expect(typeof item.client).toBe('string');
expect(typeof item.title).toBe('string');
expect(typeof item.body).toBe('string');
}
});
});
@@ -0,0 +1,102 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
decodeJwtClaims,
resolvePartnerByUserId,
resolvePartnerFromRequest,
} from '../resolve-partner-from-request';
function requireId(id: string | null | undefined, what: string): string {
if (!id) throw new Error(`${what} returned no id`);
return id;
}
async function getWorkspaceMember(
client: CoreApiClient,
): Promise<{ id: string; userId: string }> {
const r = await client.query({
workspaceMembers: {
__args: { first: 1 },
edges: { node: { id: true, userId: true } },
},
});
const node = r.workspaceMembers?.edges?.[0]?.node;
if (!node?.id || !node?.userId) {
throw new Error('No workspace members found — cannot run test');
}
return { id: node.id, userId: node.userId };
}
async function createPartner(client: CoreApiClient, memberId: string): Promise<string> {
const r = await client.mutation({
createPartner: {
__args: {
data: {
name: `[test-resolve] partner ${Date.now()}`,
slug: `test-resolve-${Date.now()}`,
partnerUserId: memberId,
},
},
id: true,
},
});
return requireId(r.createPartner?.id, 'createPartner');
}
async function destroyPartner(client: CoreApiClient, id: string) {
await client.mutation({ destroyPartner: { __args: { id }, id: true } }).catch(() => {});
}
describe('decodeJwtClaims', () => {
it('decodes the base64url payload segment of a JWT', () => {
const payload = Buffer.from(
JSON.stringify({ userId: 'u-1', userWorkspaceId: 'uw-1' }),
).toString('base64url');
const token = `header.${payload}.sig`;
expect(decodeJwtClaims(token)).toEqual({ userId: 'u-1', userWorkspaceId: 'uw-1' });
});
it('returns {} for a garbage string', () => {
expect(decodeJwtClaims('not-a-jwt')).toEqual({});
});
});
describe('resolvePartnerByUserId', () => {
let client: CoreApiClient;
const createdPartnerIds: string[] = [];
beforeEach(() => {
client = new CoreApiClient();
});
afterEach(async () => {
for (const id of createdPartnerIds) await destroyPartner(client, id);
createdPartnerIds.length = 0;
});
it('resolves partnerId and workspaceMemberId for a member with a linked partner', async () => {
const member = await getWorkspaceMember(client);
const partnerId = await createPartner(client, member.id);
createdPartnerIds.push(partnerId);
const result = await resolvePartnerByUserId(client, member.userId);
expect(result).toEqual({ partnerId, workspaceMemberId: member.id });
});
it('returns null for a userId with no matching workspace member', async () => {
const result = await resolvePartnerByUserId(client, '11111111-1111-4111-8111-111111111111');
expect(result).toBeNull();
});
});
describe('resolvePartnerFromRequest', () => {
it('returns UNAUTHENTICATED when event.userWorkspaceId is absent', async () => {
const result = await resolvePartnerFromRequest({ userWorkspaceId: null });
expect(result).toEqual({ error: 'UNAUTHENTICATED' });
});
});
@@ -0,0 +1,241 @@
import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
import { type RoutePayload } from 'twenty-sdk/define';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { handler } from '../save-my-partner-profile.logic-function';
function requireId(id: string | null | undefined, what: string): string {
if (!id) throw new Error(`${what} returned no id`);
return id;
}
async function getWorkspaceMember(
client: CoreApiClient,
): Promise<{ id: string; userId: string }> {
const r = await client.query({
workspaceMembers: {
__args: { first: 1 },
edges: { node: { id: true, userId: true } },
},
});
const node = r.workspaceMembers?.edges?.[0]?.node;
if (!node?.id || !node?.userId) {
throw new Error('No workspace members found — cannot run test');
}
return { id: node.id, userId: node.userId };
}
async function createPartner(
client: CoreApiClient,
memberId: string,
overrides: {
name?: string;
region?: CoreSchema.PartnerRegionEnum[];
deploymentExpertise?: CoreSchema.PartnerDeploymentExpertiseEnum[];
} = {},
): Promise<string> {
const r = await client.mutation({
createPartner: {
__args: {
data: {
name: overrides.name ?? `[test-save-profile] partner ${Date.now()}`,
slug: `test-save-profile-${Date.now()}-${Math.random().toString(36).slice(2)}`,
partnerUserId: memberId,
city: 'Paris',
region: overrides.region,
deploymentExpertise: overrides.deploymentExpertise,
},
},
id: true,
},
});
return requireId(r.createPartner?.id, 'createPartner');
}
// A partner that is not linked to any workspace member — stands in for "someone
// else's record" so the test can assert the save route never touches it.
async function createUnlinkedPartner(client: CoreApiClient): Promise<string> {
const r = await client.mutation({
createPartner: {
__args: {
data: {
name: `[test-save-profile] other partner ${Date.now()}`,
slug: `test-save-profile-other-${Date.now()}-${Math.random().toString(36).slice(2)}`,
},
},
id: true,
},
});
return requireId(r.createPartner?.id, 'createPartner');
}
async function destroyPartner(client: CoreApiClient, id: string) {
// Let a failed delete surface so leaked fixtures are visible instead of silently kept.
await client.mutation({ destroyPartner: { __args: { id }, id: true } });
}
async function getPartner(client: CoreApiClient, id: string) {
const r = await client.query({
partners: {
__args: { filter: { id: { eq: id } }, first: 1 },
edges: {
node: {
id: true,
name: true,
city: true,
country: true,
typeOfTeam: true,
hourlyRate: { amountMicros: true, currencyCode: true },
website: { primaryLinkUrl: true },
region: true,
deploymentExpertise: true,
},
},
},
});
const node = r.partners?.edges?.[0]?.node;
if (!node) throw new Error(`partner ${id} not found`);
return node;
}
const makeToken = (payload: Record<string, unknown>): string => {
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
return `header.${body}.sig`;
};
const makeRouteEvent = (
userWorkspaceId: string | null,
body: unknown,
): RoutePayload<unknown> => ({
headers: {},
queryStringParameters: {},
pathParameters: {},
body,
isBase64Encoded: false,
requestContext: { http: { method: 'POST', path: '/save-my-partner-profile' } },
userWorkspaceId,
});
// Invoke the handler directly against a live workspace, crafting the same short-lived env
// token resolvePartnerFromRequest decodes (see resolve-partner-from-request.test.ts), so the
// full identity → save path is exercised without a real authenticated HTTP request.
describe('save-my-partner-profile', () => {
let client: CoreApiClient;
const originalToken = process.env.TWENTY_APP_ACCESS_TOKEN;
const createdPartnerIds: string[] = [];
beforeEach(() => {
client = new CoreApiClient();
});
afterEach(async () => {
if (originalToken === undefined) delete process.env.TWENTY_APP_ACCESS_TOKEN;
else process.env.TWENTY_APP_ACCESS_TOKEN = originalToken;
for (const id of createdPartnerIds.splice(0)) await destroyPartner(client, id);
});
it('returns UNAUTHENTICATED when the event has no userWorkspaceId', async () => {
const result = await handler(makeRouteEvent(null, { name: 'New name' }));
expect(result).toEqual({ ok: false, reason: 'UNAUTHENTICATED' });
});
it('rejects an invalid body before writing anything', async () => {
const member = await getWorkspaceMember(client);
const partnerId = await createPartner(client, member.id, { name: 'Original name' });
createdPartnerIds.push(partnerId);
const userWorkspaceId = `uw-test-invalid-${Date.now()}`;
process.env.TWENTY_APP_ACCESS_TOKEN = makeToken({ userId: member.userId, userWorkspaceId });
const result = await handler(makeRouteEvent(userWorkspaceId, { website: 'not-a-url' }));
expect(result.ok).toBe(false);
const partner = await getPartner(client, partnerId);
expect(partner.name).toBe('Original name');
});
it('rejects an unknown enum value without writing anything', async () => {
const member = await getWorkspaceMember(client);
const partnerId = await createPartner(client, member.id, { name: 'Original name' });
createdPartnerIds.push(partnerId);
const userWorkspaceId = `uw-test-badenum-${Date.now()}`;
process.env.TWENTY_APP_ACCESS_TOKEN = makeToken({ userId: member.userId, userWorkspaceId });
const result = await handler(makeRouteEvent(userWorkspaceId, { country: 'NOPE' }));
expect(result).toEqual({ ok: false, reason: 'Unknown country: NOPE' });
const partner = await getPartner(client, partnerId);
expect(partner.country).toBeNull();
});
it('writes only the editable fields provided in the body', async () => {
const member = await getWorkspaceMember(client);
const partnerId = await createPartner(client, member.id, {
name: 'Original name',
region: ['EUROPE'],
deploymentExpertise: ['CLOUD'],
});
createdPartnerIds.push(partnerId);
const userWorkspaceId = `uw-test-write-${Date.now()}`;
process.env.TWENTY_APP_ACCESS_TOKEN = makeToken({ userId: member.userId, userWorkspaceId });
const result = await handler(
makeRouteEvent(userWorkspaceId, {
name: 'Updated name',
city: 'Berlin',
country: 'GERMANY',
typeOfTeam: 'AGENCY',
hourlyRate: { amountMicros: 150000000, currencyCode: 'USD' },
website: 'https://updated.example.com',
}),
);
expect(result).toEqual({ ok: true });
const partner = await getPartner(client, partnerId);
expect(partner.name).toBe('Updated name');
expect(partner.city).toBe('Berlin');
expect(partner.country).toBe('GERMANY');
expect(partner.typeOfTeam).toBe('AGENCY');
expect(partner.hourlyRate).toEqual({ amountMicros: 150000000, currencyCode: 'USD' });
expect(partner.website?.primaryLinkUrl).toBe('https://updated.example.com');
// Admin-only fields are never in the editable schema, so they must survive untouched.
expect(partner.region).toEqual(['EUROPE']);
expect(partner.deploymentExpertise).toEqual(['CLOUD']);
});
it('never writes to another partner even if the body tries to reference one', async () => {
const member = await getWorkspaceMember(client);
const myPartnerId = await createPartner(client, member.id, { name: 'My original name' });
createdPartnerIds.push(myPartnerId);
const otherPartnerId = await createUnlinkedPartner(client);
createdPartnerIds.push(otherPartnerId);
const userWorkspaceId = `uw-test-foreign-${Date.now()}`;
process.env.TWENTY_APP_ACCESS_TOKEN = makeToken({ userId: member.userId, userWorkspaceId });
// `partnerId` is not a key in saveProfileSchema, so `.strict()` rejects the whole
// request outright — there is no way to redirect the write via the body.
const result = await handler(
makeRouteEvent(userWorkspaceId, {
name: 'Hijacked name',
partnerId: otherPartnerId,
}),
);
expect(result.ok).toBe(false);
const mine = await getPartner(client, myPartnerId);
const other = await getPartner(client, otherPartnerId);
expect(mine.name).toBe('My original name');
expect(other.name).not.toBe('Hijacked name');
});
});
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { isCaseStudy } from './content-type';
describe('isCaseStudy', () => {
it('returns true when the array contains CASE_STUDY', () => {
expect(isCaseStudy(['CASE_STUDY'])).toBe(true);
expect(isCaseStudy(['PARTNER_QUOTE', 'CASE_STUDY'])).toBe(true);
});
it('returns false when the array does not contain CASE_STUDY', () => {
expect(isCaseStudy(['PARTNER_QUOTE'])).toBe(false);
expect(isCaseStudy(['CUSTOMER_QUOTE', 'LOGO'])).toBe(false);
expect(isCaseStudy([])).toBe(false);
});
it('returns true when passed the scalar value CASE_STUDY', () => {
expect(isCaseStudy('CASE_STUDY')).toBe(true);
});
it('returns false for a different scalar value', () => {
expect(isCaseStudy('PARTNER_QUOTE')).toBe(false);
});
it('returns false for null or undefined', () => {
expect(isCaseStudy(null)).toBe(false);
expect(isCaseStudy(undefined)).toBe(false);
});
});
@@ -0,0 +1,6 @@
export const isCaseStudy = (
contentType: ReadonlyArray<string | undefined> | string | null | undefined,
): boolean =>
Array.isArray(contentType)
? contentType.includes('CASE_STUDY')
: contentType === 'CASE_STUDY';
@@ -0,0 +1,211 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { PROFILE_OPTIONS, type ProfileOptions } from 'src/constants/my-profile.constants';
import { isCaseStudy } from './content-type';
import { firstFileUrl } from './profile-picture';
import { buildAppClient, errorResponse, failureResponse, resolvePartnerFromRequest } from './resolve-partner-from-request';
export const GET_MY_PARTNER_PROFILE_ID = 'eacfd95b-de02-4f03-aa38-3cae31bb30a9';
export type MyProfilePayload = {
id: string;
name: string | null;
introduction: string | null;
city: string | null;
country: string | null;
languagesSpoken: string[] | null;
partnerScope: string[] | null;
skills: string[] | null;
typeOfTeam: string | null;
availability: string | null;
hourlyRate: { amountMicros: number | null; currencyCode: string | null } | null;
projectBudgetMin: { amountMicros: number | null; currencyCode: string | null } | null;
website: string | null;
linkedin: string | null;
calendarLink: string | null;
profilePicture: string | null;
profilePictureUrl: string | null;
region: string[] | null;
deploymentExpertise: string[] | null;
links: { id: string; name: string | null; url: string | null; sortOrder: number | null }[];
services: {
id: string;
title: string | null;
description: string | null;
sortOrder: number | null;
}[];
caseStudies: {
id: string;
name: string | null;
clientName: string | null;
headline: string | null;
bodyMarkdown: string | null;
coverImageUrl: string | null;
caseStudyLink: string | null;
status: string | null;
}[];
};
export type MyPartnerProfileResult =
| { ok: true; profile: MyProfilePayload; options: ProfileOptions }
| { ok: false; reason: string };
// CoreApiClient is codegenerated from the synced workspace schema, so the
// selection is strictly typed and the response shape derives from it.
const queryMyPartnerProfile = (client: CoreApiClient, partnerId: string) =>
client.query({
partners: {
__args: {
filter: { id: { eq: partnerId } },
first: 1,
},
edges: {
node: {
id: true,
name: true,
introduction: true,
city: true,
country: true,
languagesSpoken: true,
partnerScope: true,
skills: true,
typeOfTeam: true,
availability: true,
hourlyRate: { amountMicros: true, currencyCode: true },
projectBudgetMin: { amountMicros: true, currencyCode: true },
website: { primaryLinkUrl: true },
linkedin: { primaryLinkUrl: true },
calendarLink: { primaryLinkUrl: true },
profilePicture: { primaryLinkUrl: true },
profilePictureFile: { url: true },
region: true,
deploymentExpertise: true,
partnerLinks: {
edges: {
node: {
id: true,
name: true,
url: { primaryLinkUrl: true },
sortOrder: true,
},
},
},
partnerServices: {
edges: {
node: {
id: true,
title: true,
description: true,
sortOrder: true,
},
},
},
partnerContents: {
edges: {
node: {
id: true,
name: true,
clientName: true,
headline: true,
body: { markdown: true },
coverImageUrl: true,
caseStudyLink: { primaryLinkUrl: true },
status: true,
contentType: true,
},
},
},
},
},
},
});
export type PartnerNode = NonNullable<
Awaited<ReturnType<typeof queryMyPartnerProfile>>['partners']
>['edges'][number]['node'];
export const mapMyProfilePayload = (node: PartnerNode): MyProfilePayload => ({
id: node.id,
name: node.name ?? null,
introduction: node.introduction ?? null,
city: node.city ?? null,
country: node.country ?? null,
languagesSpoken: node.languagesSpoken ?? null,
partnerScope: node.partnerScope ?? null,
skills: node.skills ?? null,
typeOfTeam: node.typeOfTeam ?? null,
availability: node.availability ?? null,
hourlyRate: node.hourlyRate ?? null,
projectBudgetMin: node.projectBudgetMin ?? null,
website: node.website?.primaryLinkUrl ?? null,
linkedin: node.linkedin?.primaryLinkUrl ?? null,
calendarLink: node.calendarLink?.primaryLinkUrl ?? null,
profilePicture: node.profilePicture?.primaryLinkUrl ?? null,
profilePictureUrl:
firstFileUrl(node.profilePictureFile) ?? node.profilePicture?.primaryLinkUrl ?? null,
region: node.region ?? null,
deploymentExpertise: node.deploymentExpertise ?? null,
links: (node.partnerLinks?.edges ?? []).map((e) => ({
id: e.node.id,
name: e.node.name ?? null,
url: e.node.url?.primaryLinkUrl ?? null,
sortOrder: e.node.sortOrder ?? null,
})),
services: (node.partnerServices?.edges ?? []).map((e) => ({
id: e.node.id,
title: e.node.title ?? null,
description: e.node.description ?? null,
sortOrder: e.node.sortOrder ?? null,
})),
caseStudies: (node.partnerContents?.edges ?? [])
.filter((e) => isCaseStudy(e.node.contentType))
.map((e) => ({
id: e.node.id,
name: e.node.name ?? null,
clientName: e.node.clientName ?? null,
headline: e.node.headline ?? null,
bodyMarkdown: e.node.body?.markdown ?? null,
// Edit form binds the text coverImageUrl field only; the file cover's signed URL
// must not round-trip through save (it would persist an expiring URL).
coverImageUrl: e.node.coverImageUrl || null,
caseStudyLink: e.node.caseStudyLink?.primaryLinkUrl ?? null,
status: e.node.status ?? null,
})),
});
export const handler = async (
event: RoutePayload<unknown>,
): Promise<MyPartnerProfileResult> => {
const resolved = await resolvePartnerFromRequest(event);
if ('error' in resolved) return errorResponse(resolved.error);
try {
const client = buildAppClient();
const result = await queryMyPartnerProfile(client, resolved.partnerId);
const node = result.partners?.edges?.[0]?.node;
if (!node) {
return errorResponse('NO_PARTNER');
}
return { ok: true, profile: mapMyProfilePayload(node), options: PROFILE_OPTIONS };
} catch (err) {
return failureResponse('get-my-partner-profile', err);
}
};
export default defineLogicFunction({
universalIdentifier: GET_MY_PARTNER_PROFILE_ID,
name: 'get-my-partner-profile',
description:
"Returns the calling partner's own profile + links + services + case studies + enum options.",
timeoutSeconds: 20,
handler,
httpRouteTriggerSettings: {
path: '/my-partner-profile',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -0,0 +1,269 @@
import { describe, expect, it } from 'vitest';
import { mapMyProfilePayload, type PartnerNode } from './get-my-partner-profile.logic-function';
const makeNode = (overrides: Partial<PartnerNode> = {}): PartnerNode =>
({
id: 'partner-1',
name: 'Nine Dots Ventures',
introduction: 'Senior implementation partner.',
city: 'Paris',
country: 'FRANCE',
languagesSpoken: ['ENGLISH', 'FRENCH'],
partnerScope: ['ADVISORY'],
skills: ['Salesforce'],
typeOfTeam: 'AGENCY',
availability: 'AVAILABLE',
hourlyRate: { amountMicros: 150000000, currencyCode: 'USD' },
projectBudgetMin: { amountMicros: 1000000000, currencyCode: 'USD' },
website: { primaryLinkUrl: 'https://ninedots.example.com' },
linkedin: { primaryLinkUrl: 'https://linkedin.com/company/nine-dots' },
calendarLink: { primaryLinkUrl: 'https://cal.example.com/nine-dots' },
profilePicture: { primaryLinkUrl: 'https://images.example.com/legacy.png' },
profilePictureFile: [{ url: 'https://images.example.com/uploaded.png' }],
region: ['EUROPE'],
deploymentExpertise: ['CLOUD'],
partnerLinks: {
edges: [
{
node: {
id: 'link-1',
name: 'Case studies',
url: { primaryLinkUrl: 'https://example.com/case-studies' },
sortOrder: 1,
},
},
],
},
partnerServices: {
edges: [
{
node: {
id: 'service-1',
title: 'Data migration',
description: 'Historical sync and schema mapping.',
sortOrder: 1,
},
},
],
},
partnerContents: {
edges: [
{
node: {
id: 'content-1',
name: 'Acme case study',
clientName: 'Acme Corp',
headline: 'CRM migration',
body: { markdown: 'Moved 12 teams to Twenty.' },
coverImageUrl: 'https://images.example.com/case-study.png',
caseStudyLink: { primaryLinkUrl: 'https://example.com/case-study' },
status: 'APPROVED',
contentType: ['CASE_STUDY'],
},
},
],
},
...overrides,
}) as PartnerNode;
describe('mapMyProfilePayload', () => {
it('unwraps LINKS fields to their primaryLinkUrl', () => {
const mapped = mapMyProfilePayload(makeNode());
expect(mapped.website).toBe('https://ninedots.example.com');
expect(mapped.linkedin).toBe('https://linkedin.com/company/nine-dots');
expect(mapped.calendarLink).toBe('https://cal.example.com/nine-dots');
expect(mapped.links[0].url).toBe('https://example.com/case-studies');
expect(mapped.caseStudies[0].caseStudyLink).toBe('https://example.com/case-study');
});
it('prefers the uploaded profile picture file url over the legacy link', () => {
const mapped = mapMyProfilePayload(makeNode());
expect(mapped.profilePictureUrl).toBe('https://images.example.com/uploaded.png');
});
it('falls back to the legacy profile picture link when there is no uploaded file', () => {
const mapped = mapMyProfilePayload(
makeNode({ profilePictureFile: null }),
);
expect(mapped.profilePictureUrl).toBe('https://images.example.com/legacy.png');
});
it('maps case study body.markdown to bodyMarkdown and passes coverImageUrl through', () => {
const mapped = mapMyProfilePayload(makeNode());
expect(mapped.caseStudies[0].bodyMarkdown).toBe('Moved 12 teams to Twenty.');
expect(mapped.caseStudies[0].coverImageUrl).toBe(
'https://images.example.com/case-study.png',
);
});
it('filters partnerContents to only CASE_STUDY rows, excluding quotes/logos', () => {
const mapped = mapMyProfilePayload(
makeNode({
partnerContents: {
edges: [
{
node: {
id: 'content-1',
name: 'Acme case study',
clientName: 'Acme Corp',
headline: 'CRM migration',
body: { markdown: 'Moved 12 teams to Twenty.' },
coverImageUrl: 'https://images.example.com/case-study.png',
caseStudyLink: { primaryLinkUrl: 'https://example.com/case-study' },
status: 'APPROVED',
contentType: ['CASE_STUDY'],
},
},
{
node: {
id: 'content-2',
name: 'Nine Dots quote',
clientName: 'Sunrise APAC',
status: 'WIP',
contentType: ['PARTNER_QUOTE'],
},
},
],
},
}),
);
expect(mapped.caseStudies).toHaveLength(1);
expect(mapped.caseStudies[0].id).toBe('content-1');
});
it('maps links, services, and caseStudies edges to plain arrays', () => {
const mapped = mapMyProfilePayload(makeNode());
expect(mapped.links).toEqual([
{ id: 'link-1', name: 'Case studies', url: 'https://example.com/case-studies', sortOrder: 1 },
]);
expect(mapped.services).toEqual([
{ id: 'service-1', title: 'Data migration', description: 'Historical sync and schema mapping.', sortOrder: 1 },
]);
expect(mapped.caseStudies).toEqual([
{
id: 'content-1',
name: 'Acme case study',
clientName: 'Acme Corp',
headline: 'CRM migration',
bodyMarkdown: 'Moved 12 teams to Twenty.',
coverImageUrl: 'https://images.example.com/case-study.png',
caseStudyLink: 'https://example.com/case-study',
status: 'APPROVED',
},
]);
});
it('passes the pasted coverImageUrl through for the edit form', () => {
const mapped = mapMyProfilePayload(
makeNode({
partnerContents: {
edges: [
{
node: {
id: 'content-1',
name: 'Acme case study',
clientName: 'Acme Corp',
headline: 'CRM migration',
body: { markdown: 'Moved 12 teams to Twenty.' },
coverImageUrl: 'https://paste.example.com/cover.png',
caseStudyLink: { primaryLinkUrl: 'https://example.com/case-study' },
status: 'APPROVED',
contentType: ['CASE_STUDY'],
},
},
],
},
} as Partial<PartnerNode>),
);
expect(mapped.caseStudies[0].coverImageUrl).toBe('https://paste.example.com/cover.png');
});
// The edit form binds the text coverImageUrl only; a file cover's signed URL must not
// round-trip through save, so it is not surfaced here (the marketplace mapping still shows it).
it('returns null coverImageUrl when the pasted url is empty', () => {
const mapped = mapMyProfilePayload(
makeNode({
partnerContents: {
edges: [
{
node: {
id: 'content-1',
name: 'Acme case study',
clientName: 'Acme Corp',
headline: 'CRM migration',
body: { markdown: 'Moved 12 teams to Twenty.' },
coverImageUrl: null,
caseStudyLink: { primaryLinkUrl: 'https://example.com/case-study' },
status: 'APPROVED',
contentType: ['CASE_STUDY'],
},
},
],
},
} as Partial<PartnerNode>),
);
expect(mapped.caseStudies[0].coverImageUrl).toBeNull();
});
it('handles nulls and empty edges', () => {
const mapped = mapMyProfilePayload(
makeNode({
name: null,
introduction: null,
city: null,
country: null,
languagesSpoken: null,
partnerScope: null,
skills: null,
typeOfTeam: null,
availability: null,
hourlyRate: null,
projectBudgetMin: null,
website: null,
linkedin: null,
calendarLink: null,
profilePicture: null,
profilePictureFile: null,
region: null,
deploymentExpertise: null,
partnerLinks: { edges: [] },
partnerServices: { edges: [] },
partnerContents: { edges: [] },
}),
);
expect(mapped).toEqual({
id: 'partner-1',
name: null,
introduction: null,
city: null,
country: null,
languagesSpoken: null,
partnerScope: null,
skills: null,
typeOfTeam: null,
availability: null,
hourlyRate: null,
projectBudgetMin: null,
website: null,
linkedin: null,
calendarLink: null,
profilePicture: null,
profilePictureUrl: null,
region: null,
deploymentExpertise: null,
links: [],
services: [],
caseStudies: [],
});
});
});
@@ -1,7 +1,10 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction } from 'twenty-sdk/define';
import { resolvePartnerPictureUrl } from './profile-picture';
import {
mapPartnerForMarketplace,
type MarketplaceProfilePartner,
} from './map-partner-for-marketplace';
export const GET_PARTNER_BY_SLUG_LOGIC_FUNCTION_ID =
'5e3e7b88-2cf2-4f56-9a4a-46c4c1d6b0bb';
@@ -33,6 +36,41 @@ const queryPartnerBySlug = (client: CoreApiClient, slug: string) =>
hourlyRate: { amountMicros: true, currencyCode: true },
projectBudgetMin: { amountMicros: true, currencyCode: true },
linkedin: { primaryLinkUrl: true },
website: { primaryLinkUrl: true },
partnerLinks: {
edges: {
node: {
url: { primaryLinkUrl: true },
sortOrder: true,
position: true,
},
},
},
partnerServices: {
edges: {
node: {
title: true,
description: true,
sortOrder: true,
position: true,
},
},
},
partnerContents: {
edges: {
node: {
contentType: true,
status: true,
clientName: true,
headline: true,
body: { markdown: true },
coverImage: { url: true },
coverImageUrl: true,
caseStudyLink: { primaryLinkUrl: true },
position: true,
},
},
},
// profilePicture is the legacy LINKS url; profilePictureFile is the
// new FILES upload (its items expose `url`). Display prefers the file.
profilePicture: { primaryLinkUrl: true },
@@ -49,15 +87,23 @@ type PartnerRaw = NonNullable<
Awaited<ReturnType<typeof queryPartnerBySlug>>['partners']
>['edges'][number]['node'];
type Partner = Omit<PartnerRaw, 'profilePicture' | 'profilePictureFile'> & {
profilePicture: { primaryLinkUrl: string | null };
};
type GetPartnerBySlugResult =
| { ok: true; partner: Partner }
| { ok: true; partner: MarketplaceProfilePartner }
| { ok: false; reason: 'NOT_FOUND' | string };
const handler = async (input: {
const mapProfilePartner = (node: PartnerRaw): MarketplaceProfilePartner => {
const mapped = mapPartnerForMarketplace(node, 'profile');
if (!('projectBudgetTypical' in mapped)) {
throw new Error(
'get-partner-by-slug received list payload from profile mapper',
);
}
return mapped;
};
export const handler = async (input: {
queryStringParameters?: { slug?: string };
}): Promise<GetPartnerBySlugResult> => {
const slug = input?.queryStringParameters?.slug;
@@ -74,16 +120,7 @@ const handler = async (input: {
return { ok: false, reason: 'NOT_FOUND' };
}
const { profilePictureFile, ...rest } = rawNode;
const partner: Partner = {
...rest,
profilePicture: {
primaryLinkUrl: resolvePartnerPictureUrl(
profilePictureFile,
rawNode.profilePicture?.primaryLinkUrl,
),
},
};
const partner = mapProfilePartner(rawNode);
return { ok: true, partner };
} catch (err) {
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { isHttpUrl, optionalHttpUrl } from './http-url';
describe('isHttpUrl', () => {
it('accepts http and https', () => {
expect(isHttpUrl('http://example.com')).toBe(true);
expect(isHttpUrl('https://example.com/path?q=1')).toBe(true);
});
it('rejects javascript:, data:, and other schemes', () => {
expect(isHttpUrl('javascript:alert(1)')).toBe(false);
expect(isHttpUrl('data:text/html,<script>1</script>')).toBe(false);
expect(isHttpUrl('mailto:a@b.com')).toBe(false);
expect(isHttpUrl('not a url')).toBe(false);
expect(isHttpUrl('')).toBe(false);
});
});
describe('optionalHttpUrl', () => {
it('treats empty string as null (clear the field)', () => {
expect(optionalHttpUrl.parse('')).toBeNull();
});
it('passes a valid https url through', () => {
expect(optionalHttpUrl.parse('https://example.com')).toBe('https://example.com');
});
it('passes a valid http url through', () => {
expect(optionalHttpUrl.parse('http://example.com')).toBe('http://example.com');
});
it('treats null as null (the schema is nullable)', () => {
expect(optionalHttpUrl.parse(null)).toBeNull();
});
it('rejects a javascript: url', () => {
expect(optionalHttpUrl.safeParse('javascript:alert(1)').success).toBe(false);
});
});
@@ -0,0 +1,23 @@
import { z } from 'zod';
// Partner-controlled URLs are echoed into public marketplace profiles and rendered as
// links, so block non-web protocols (javascript:, data:) that would execute in a
// visitor's origin. Only http/https are allowed.
export const isHttpUrl = (value: string): boolean => {
try {
const { protocol } = new URL(value);
return protocol === 'http:' || protocol === 'https:';
} catch {
return false;
}
};
// A blank value means "clear the field"; anything else must be a valid http/https URL.
export const optionalHttpUrl = z.preprocess(
(value) => (value === '' ? null : value),
z
.string()
.refine(isHttpUrl, { message: 'URL must use http or https' })
.nullable()
.optional(),
);
@@ -1,7 +1,10 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction } from 'twenty-sdk/define';
import { resolvePartnerPictureUrl } from './profile-picture';
import {
mapPartnerForMarketplace,
type MarketplaceListPartner,
} from './map-partner-for-marketplace';
export const LIST_AVAILABLE_PARTNERS_LOGIC_FUNCTION_ID =
'0f91164f-f492-41e8-9bb0-481be5a3d5b9';
@@ -36,6 +39,7 @@ const queryAvailablePartners = (client: CoreApiClient) =>
hourlyRate: { amountMicros: true, currencyCode: true },
projectBudgetMin: { amountMicros: true, currencyCode: true },
linkedin: { primaryLinkUrl: true },
website: { primaryLinkUrl: true },
// profilePicture is the legacy LINKS url; profilePictureFile is the
// new FILES upload (its items expose `url`). Display prefers the file.
profilePicture: { primaryLinkUrl: true },
@@ -52,34 +56,28 @@ type AvailablePartnerRaw = NonNullable<
Awaited<ReturnType<typeof queryAvailablePartners>>['partners']
>['edges'][number]['node'];
type AvailablePartner = Omit<
AvailablePartnerRaw,
'profilePicture' | 'profilePictureFile'
> & {
profilePicture: { primaryLinkUrl: string | null };
};
type ListAvailablePartnersResult =
| { ok: true; count: number; partners: AvailablePartner[] }
| { ok: true; count: number; partners: MarketplaceListPartner[] }
| { ok: false; reason: string };
const handler = async (): Promise<ListAvailablePartnersResult> => {
const mapListPartner = (node: AvailablePartnerRaw): MarketplaceListPartner => {
const mapped = mapPartnerForMarketplace(node, 'list');
if ('projectBudgetTypical' in mapped) {
throw new Error(
'list-available-partners received profile payload from list mapper',
);
}
return mapped;
};
export const handler = async (): Promise<ListAvailablePartnersResult> => {
try {
const client = new CoreApiClient();
const result = await queryAvailablePartners(client);
const partners: AvailablePartner[] = (result.partners?.edges ?? []).map(
({ node }) => {
const { profilePictureFile, ...rest } = node;
return {
...rest,
profilePicture: {
primaryLinkUrl: resolvePartnerPictureUrl(
profilePictureFile,
node.profilePicture?.primaryLinkUrl,
),
},
};
},
const partners = (result.partners?.edges ?? []).map(({ node }) =>
mapListPartner(node),
);
return { ok: true, count: partners.length, partners };
@@ -0,0 +1,267 @@
import { describe, expect, it } from 'vitest';
import { mapPartnerForMarketplace } from './map-partner-for-marketplace';
const makeNode = () => ({
name: 'Nine Dots Ventures',
slug: 'nine-dots-ventures',
introduction: '## About\n\n**Senior** implementation partner.',
languagesSpoken: ['ENGLISH'],
deploymentExpertise: ['CRM_IMPLEMENTATION'],
partnerScope: ['IMPLEMENTATION'],
region: ['EUROPE'],
calendarLink: { primaryLinkUrl: 'https://cal.example.com' },
hourlyRate: { amountMicros: 150_000_000, currencyCode: 'USD' },
projectBudgetMin: { amountMicros: 1_000_000_000, currencyCode: 'USD' },
linkedin: { primaryLinkUrl: 'https://linkedin.com/company/nine-dots' },
website: { primaryLinkUrl: 'https://ninedots.example.com' },
profilePicture: { primaryLinkUrl: 'https://images.example.com/legacy.png' },
profilePictureFile: [{ url: 'https://images.example.com/uploaded.png' }],
skills: ['Salesforce', 'HubSpot'],
city: 'Paris',
country: 'France',
partnerLinks: {
edges: [] as Array<{
node: {
url: { primaryLinkUrl: string | null } | null;
sortOrder: number | null;
position: number | null;
};
}>,
},
partnerServices: {
edges: [] as Array<{
node: {
title: string | null;
description: string | null;
sortOrder: number | null;
position: number | null;
};
}>,
},
partnerContents: {
edges: [] as Array<{
node: {
contentType: string | readonly string[] | null;
status: string | null;
clientName: string | null;
headline: string | null;
body: { markdown: string | null } | null;
coverImage?: ReadonlyArray<{ url?: string | null } | null> | null;
coverImageUrl?: string | null;
caseStudyLink: { primaryLinkUrl: string | null } | null;
position: number | null;
};
}>,
},
});
describe('mapPartnerForMarketplace', () => {
it('maps list detail introduction from markdown as a plain excerpt', () => {
const node = makeNode();
node.introduction = `### Delivery partner\n\n${'A'.repeat(260)}\n\n- Platform migration`;
const mapped = mapPartnerForMarketplace(node, 'list');
expect(mapped.introduction.length).toBe(220);
expect(mapped.introduction).toContain('Delivery partner');
expect(mapped.introduction).not.toContain('###');
expect(mapped.introduction).not.toContain('- ');
expect('projectBudgetTypical' in mapped).toBe(false);
expect('profileLinks' in mapped).toBe(false);
expect('services' in mapped).toBe(false);
expect('portfolio' in mapped).toBe(false);
});
it('keeps the full markdown introduction in profile detail', () => {
const node = makeNode();
node.introduction =
'## About us\n\n**Bold statement** with [link](https://example.com).';
const mapped = mapPartnerForMarketplace(node, 'profile');
expect(mapped.introduction).toBe(
'## About us\n\n**Bold statement** with [link](https://example.com).',
);
});
it('aliases projectBudgetTypical from projectBudgetMin in profile detail', () => {
const node = makeNode();
const mapped = mapPartnerForMarketplace(node, 'profile');
expect(mapped.projectBudgetTypical).toEqual(node.projectBudgetMin);
});
it('merges and deduplicates profile links from PartnerLink, website, and linkedin', () => {
const node = makeNode();
node.partnerLinks.edges = [
{
node: {
url: { primaryLinkUrl: 'https://example.com/community' },
sortOrder: 2,
position: 2,
},
},
{
node: {
url: { primaryLinkUrl: 'https://ninedots.example.com' },
sortOrder: 1,
position: 1,
},
},
];
const mapped = mapPartnerForMarketplace(node, 'profile');
expect(mapped.profileLinks).toEqual([
{ primaryLinkUrl: 'https://ninedots.example.com' },
{ primaryLinkUrl: 'https://example.com/community' },
{ primaryLinkUrl: 'https://linkedin.com/company/nine-dots' },
]);
});
it('maps only approved case studies into portfolio entries', () => {
const node = makeNode();
node.partnerContents.edges = [
{
node: {
contentType: ['CASE_STUDY'],
status: 'APPROVED',
clientName: 'Acme Corp',
headline: 'CRM migration',
body: { markdown: 'Moved 12 teams to Twenty.' },
coverImage: [{ url: 'https://images.example.com/case-study.png' }],
caseStudyLink: { primaryLinkUrl: 'https://example.com/case-study' },
position: 1,
},
},
{
node: {
contentType: ['CASE_STUDY'],
status: 'IN_REVIEW',
clientName: 'Hidden Client',
headline: 'Draft project',
body: { markdown: 'Should not be returned.' },
coverImage: [{ url: 'https://images.example.com/draft.png' }],
caseStudyLink: { primaryLinkUrl: 'https://example.com/draft' },
position: 2,
},
},
{
node: {
contentType: ['BLOG'],
status: 'APPROVED',
clientName: 'Content marketing',
headline: 'Blog post',
body: { markdown: 'Not a case study.' },
coverImage: [{ url: 'https://images.example.com/blog.png' }],
caseStudyLink: { primaryLinkUrl: 'https://example.com/blog' },
position: 3,
},
},
];
const mapped = mapPartnerForMarketplace(node, 'profile');
expect(mapped.portfolio).toEqual([
{
client: 'Acme Corp',
title: 'CRM migration',
body: 'Moved 12 teams to Twenty.',
imageUrl: 'https://images.example.com/case-study.png',
link: 'https://example.com/case-study',
},
]);
});
it('prefers the pasted coverImageUrl over the uploaded coverImage file in portfolio', () => {
const node = makeNode();
node.partnerContents.edges = [
{
node: {
contentType: ['CASE_STUDY'],
status: 'APPROVED',
clientName: 'Acme Corp',
headline: 'CRM migration',
body: { markdown: 'Moved 12 teams to Twenty.' },
coverImageUrl: 'https://paste.example.com/cover.png',
coverImage: [{ url: 'https://file.example.com/cover.png' }],
caseStudyLink: { primaryLinkUrl: 'https://example.com/case-study' },
position: 1,
},
},
];
expect(mapPartnerForMarketplace(node, 'profile').portfolio[0].imageUrl).toBe(
'https://paste.example.com/cover.png',
);
});
it('falls back to the coverImage file url when coverImageUrl is absent', () => {
const node = makeNode();
node.partnerContents.edges = [
{
node: {
contentType: ['CASE_STUDY'],
status: 'APPROVED',
clientName: 'Acme Corp',
headline: 'CRM migration',
body: { markdown: 'Moved 12 teams to Twenty.' },
coverImageUrl: null,
coverImage: [{ url: 'https://file.example.com/cover.png' }],
caseStudyLink: { primaryLinkUrl: 'https://example.com/case-study' },
position: 1,
},
},
];
expect(mapPartnerForMarketplace(node, 'profile').portfolio[0].imageUrl).toBe(
'https://file.example.com/cover.png',
);
});
it('sorts services by sortOrder ascending with nulls last', () => {
const node = makeNode();
node.partnerServices.edges = [
{
node: {
title: 'RevOps coaching',
description: 'Cross-team alignment and reporting.',
position: 3,
},
},
{
node: {
title: 'Data migration',
description: 'Historical sync and schema mapping.',
position: 1,
},
},
{
node: {
title: 'Fractional CRM lead',
description: 'Weekly operating cadence support.',
position: null,
},
},
];
const mapped = mapPartnerForMarketplace(node, 'profile');
expect(mapped.services).toEqual([
{
title: 'Data migration',
description: 'Historical sync and schema mapping.',
},
{
title: 'RevOps coaching',
description: 'Cross-team alignment and reporting.',
},
{
title: 'Fractional CRM lead',
description: 'Weekly operating cadence support.',
},
]);
});
});
@@ -0,0 +1,258 @@
import { type CoreSchema } from 'twenty-client-sdk/core';
import { isCaseStudy } from './content-type';
import { firstFileUrl, resolvePartnerPictureUrl } from './profile-picture';
import { stripMarkdown } from './strip-markdown';
export type MapPartnerDetail = 'list' | 'profile';
type PrimaryLink = { primaryLinkUrl: string | null };
type Money = { amountMicros: number; currencyCode: string } | null;
type FileItemRead = { url?: string | null } | null | undefined;
type PartnerLinkEdge = {
node: {
url: PrimaryLink | null;
sortOrder: number | null;
position: number | null;
};
};
type PartnerServiceEdge = {
node: {
title: string | null;
description: string | null;
sortOrder: number | null;
position: number | null;
};
};
type PartnerContentEdge = {
node: {
contentType: string | readonly string[] | null;
status: string | null;
clientName: string | null;
headline: string | null;
body: { markdown: string | null } | null;
coverImage?: ReadonlyArray<FileItemRead> | null;
coverImageUrl?: string | null;
caseStudyLink: PrimaryLink | null;
position: number | null;
};
};
export type PartnerMarketplaceQueryNode = {
name: string;
slug: string;
introduction: string | null;
languagesSpoken: CoreSchema.Partner['languagesSpoken'];
deploymentExpertise: CoreSchema.Partner['deploymentExpertise'];
partnerScope: CoreSchema.Partner['partnerScope'];
region: CoreSchema.Partner['region'];
calendarLink: PrimaryLink;
hourlyRate: Money;
projectBudgetMin: Money;
linkedin: PrimaryLink;
website: PrimaryLink;
profilePicture: PrimaryLink;
profilePictureFile?: ReadonlyArray<FileItemRead> | null;
skills: string[] | null;
city: string | null;
country: string | null;
partnerLinks?: { edges: ReadonlyArray<PartnerLinkEdge> } | null;
partnerServices?: { edges: ReadonlyArray<PartnerServiceEdge> } | null;
partnerContents?: { edges: ReadonlyArray<PartnerContentEdge> } | null;
};
export type MarketplaceListPartner = {
name: string;
slug: string;
introduction: string;
languagesSpoken: CoreSchema.Partner['languagesSpoken'];
deploymentExpertise: CoreSchema.Partner['deploymentExpertise'];
partnerScope: CoreSchema.Partner['partnerScope'];
region: CoreSchema.Partner['region'];
calendarLink: PrimaryLink;
hourlyRate: Money;
projectBudgetMin: Money;
linkedin: PrimaryLink;
website: PrimaryLink;
profilePicture: PrimaryLink;
skills: string[] | null;
city: string | null;
country: string | null;
};
export type MarketplaceProfilePartner = MarketplaceListPartner & {
introduction: string;
projectBudgetTypical: MarketplaceListPartner['projectBudgetMin'];
profileLinks: PrimaryLink[];
services: { title: string; description: string }[];
portfolio: {
client: string;
title: string;
body: string;
imageUrl: string | null;
link: string | null;
}[];
};
const LIST_INTRODUCTION_MAX_LENGTH = 220;
const richTextSource = (node: PartnerMarketplaceQueryNode): string =>
node.introduction?.trim() ?? '';
const toListIntroduction = (node: PartnerMarketplaceQueryNode): string => {
return stripMarkdown(richTextSource(node))
.slice(0, LIST_INTRODUCTION_MAX_LENGTH)
.trim();
};
const toProfileIntroduction = (node: PartnerMarketplaceQueryNode): string =>
richTextSource(node);
const sortOrderValue = (node: {
sortOrder?: number | null;
position?: number | null;
}): number | null => node.sortOrder ?? node.position ?? null;
const sortBySortOrderAscNullsLast = <
T extends { node: { sortOrder?: number | null; position?: number | null } },
>(
edges: ReadonlyArray<T>,
): T[] =>
[...edges].sort((left, right) => {
const leftOrder = sortOrderValue(left.node);
const rightOrder = sortOrderValue(right.node);
if (leftOrder === null && rightOrder === null) {
return 0;
}
if (leftOrder === null) {
return 1;
}
if (rightOrder === null) {
return -1;
}
return leftOrder - rightOrder;
});
const sortByPositionAscNullsLast = <T extends { node: { position: number | null } }>(
edges: ReadonlyArray<T>,
): T[] =>
[...edges].sort((left, right) => {
if (left.node.position === null && right.node.position === null) {
return 0;
}
if (left.node.position === null) {
return 1;
}
if (right.node.position === null) {
return -1;
}
return left.node.position - right.node.position;
});
const dedupeUrls = (urls: ReadonlyArray<string | null>): string[] => {
const seen = new Set<string>();
const deduped: string[] = [];
for (const url of urls) {
if (!url || seen.has(url)) {
continue;
}
seen.add(url);
deduped.push(url);
}
return deduped;
};
const mapProfileLinks = (node: PartnerMarketplaceQueryNode): PrimaryLink[] => {
const partnerLinkUrls = sortBySortOrderAscNullsLast(node.partnerLinks?.edges ?? [])
.map((edge) => edge.node.url?.primaryLinkUrl ?? null);
const legacyUrls = [
node.website?.primaryLinkUrl ?? null,
node.linkedin?.primaryLinkUrl ?? null,
];
return dedupeUrls([...partnerLinkUrls, ...legacyUrls]).map((url) => ({
primaryLinkUrl: url,
}));
};
const mapServices = (
edges: ReadonlyArray<PartnerServiceEdge>,
): Array<{ title: string; description: string }> =>
sortBySortOrderAscNullsLast(edges).map(({ node }) => ({
title: node.title ?? '',
description: node.description ?? '',
}));
const mapPortfolio = (
edges: ReadonlyArray<PartnerContentEdge>,
): MarketplaceProfilePartner['portfolio'] =>
sortByPositionAscNullsLast(edges)
.filter(({ node }) => isCaseStudy(node.contentType) && node.status === 'APPROVED')
.map(({ node }) => ({
client: node.clientName ?? '',
title: node.headline ?? '',
body: node.body?.markdown ?? '',
imageUrl: node.coverImageUrl ?? firstFileUrl(node.coverImage),
link: node.caseStudyLink?.primaryLinkUrl ?? null,
}));
const mapBasePartner = (
node: PartnerMarketplaceQueryNode,
detail: MapPartnerDetail,
): MarketplaceListPartner => ({
name: node.name,
slug: node.slug,
introduction:
detail === 'list' ? toListIntroduction(node) : toProfileIntroduction(node),
languagesSpoken: node.languagesSpoken,
deploymentExpertise: node.deploymentExpertise,
partnerScope: node.partnerScope,
region: node.region,
calendarLink: node.calendarLink,
hourlyRate: node.hourlyRate,
projectBudgetMin: node.projectBudgetMin,
linkedin: node.linkedin,
website: node.website,
profilePicture: {
primaryLinkUrl: resolvePartnerPictureUrl(
node.profilePictureFile,
node.profilePicture?.primaryLinkUrl,
),
},
skills: node.skills,
city: node.city,
country: node.country,
});
export function mapPartnerForMarketplace(
node: PartnerMarketplaceQueryNode,
detail: MapPartnerDetail,
): MarketplaceListPartner | MarketplaceProfilePartner {
const base = mapBasePartner(node, detail);
if (detail === 'list') {
return base;
}
return {
...base,
projectBudgetTypical: base.projectBudgetMin,
profileLinks: mapProfileLinks(node),
services: mapServices(node.partnerServices?.edges ?? []),
portfolio: mapPortfolio(node.partnerContents?.edges ?? []),
};
}
@@ -0,0 +1,95 @@
import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
import {
type DatabaseEventPayload,
defineLogicFunction,
type ObjectRecordCreateEvent,
} from 'twenty-sdk/define';
import { ON_PARTNER_CONTENT_CREATED_FN_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { stampPartnerUserFromPartner } from './stamp-partner-user-on-child';
const CASE_STUDY_CONTENT_TYPE: CoreSchema.PartnerContent['contentType'] = [
'CASE_STUDY',
];
const resolvePartnerIdForMember = async (
client: CoreApiClient,
memberId: string,
): Promise<string | undefined> => {
const partnerRes = await client.query({
partners: {
__args: { filter: { partnerUserId: { eq: memberId } }, first: 1 },
edges: { node: { id: true } },
},
});
return partnerRes.partners?.edges?.[0]?.node?.id;
};
// Default to CASE_STUDY only when the caller left contentType empty (the self-service path);
// an explicit CUSTOMER_QUOTE / PARTNER_QUOTE / LOGO must be preserved, not overwritten.
const hasNoContentType = (
contentType: CoreSchema.PartnerContent['contentType'] | null | undefined,
): boolean => !contentType || contentType.length === 0;
export const handler = async (
payload: DatabaseEventPayload<ObjectRecordCreateEvent<CoreSchema.PartnerContent>>,
): Promise<Record<string, unknown>> => {
const after = payload.properties.after;
const childId = after?.id;
if (!childId) return {};
const client = new CoreApiClient();
let partnerId = after.partnerId;
if (!partnerId) {
const memberId = after.createdBy?.workspaceMemberId;
if (!memberId) return {};
partnerId = await resolvePartnerIdForMember(client, memberId);
if (!partnerId) return {};
await client.mutation({
updatePartnerContent: {
__args: {
id: childId,
data: {
partnerId,
partnerUserId: memberId,
...(hasNoContentType(after.contentType)
? { contentType: CASE_STUDY_CONTENT_TYPE }
: {}),
},
},
id: true,
},
});
return { linked: true, partnerId };
}
await stampPartnerUserFromPartner(client, partnerId, 'partnerContent', childId);
if (hasNoContentType(after.contentType)) {
await client.mutation({
updatePartnerContent: {
__args: {
id: childId,
data: { contentType: CASE_STUDY_CONTENT_TYPE },
},
id: true,
},
});
}
return { stamped: true };
};
export default defineLogicFunction({
universalIdentifier: ON_PARTNER_CONTENT_CREATED_FN_UNIVERSAL_IDENTIFIER,
name: 'on-partner-content-created',
timeoutSeconds: 10,
handler,
databaseEventTriggerSettings: { eventName: 'partnerContent.created' },
});
@@ -0,0 +1,69 @@
import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
import {
type DatabaseEventPayload,
defineLogicFunction,
type ObjectRecordCreateEvent,
} from 'twenty-sdk/define';
import { ON_PARTNER_LINK_CREATED_FN_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { stampPartnerUserFromPartner } from './stamp-partner-user-on-child';
const resolvePartnerIdForMember = async (
client: CoreApiClient,
memberId: string,
): Promise<string | undefined> => {
const partnerRes = await client.query({
partners: {
__args: { filter: { partnerUserId: { eq: memberId } }, first: 1 },
edges: { node: { id: true } },
},
});
return partnerRes.partners?.edges?.[0]?.node?.id;
};
export const handler = async (
payload: DatabaseEventPayload<ObjectRecordCreateEvent<CoreSchema.PartnerLink>>,
): Promise<Record<string, unknown>> => {
const after = payload.properties.after;
const childId = after?.id;
if (!childId) return {};
const client = new CoreApiClient();
let partnerId = after.partnerId;
if (!partnerId) {
const memberId = after.createdBy?.workspaceMemberId;
if (!memberId) return {};
partnerId = await resolvePartnerIdForMember(client, memberId);
if (!partnerId) return {};
await client.mutation({
updatePartnerLink: {
__args: {
id: childId,
data: {
partnerId,
partnerUserId: memberId,
},
},
id: true,
},
});
return { linked: true, partnerId };
}
await stampPartnerUserFromPartner(client, partnerId, 'partnerLink', childId);
return { stamped: true };
};
export default defineLogicFunction({
universalIdentifier: ON_PARTNER_LINK_CREATED_FN_UNIVERSAL_IDENTIFIER,
name: 'on-partner-link-created',
timeoutSeconds: 10,
handler,
databaseEventTriggerSettings: { eventName: 'partnerLink.created' },
});
@@ -0,0 +1,69 @@
import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
import {
type DatabaseEventPayload,
defineLogicFunction,
type ObjectRecordCreateEvent,
} from 'twenty-sdk/define';
import { ON_PARTNER_SERVICE_CREATED_FN_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { stampPartnerUserFromPartner } from './stamp-partner-user-on-child';
const resolvePartnerIdForMember = async (
client: CoreApiClient,
memberId: string,
): Promise<string | undefined> => {
const partnerRes = await client.query({
partners: {
__args: { filter: { partnerUserId: { eq: memberId } }, first: 1 },
edges: { node: { id: true } },
},
});
return partnerRes.partners?.edges?.[0]?.node?.id;
};
export const handler = async (
payload: DatabaseEventPayload<ObjectRecordCreateEvent<CoreSchema.PartnerService>>,
): Promise<Record<string, unknown>> => {
const after = payload.properties.after;
const childId = after?.id;
if (!childId) return {};
const client = new CoreApiClient();
let partnerId = after.partnerId;
if (!partnerId) {
const memberId = after.createdBy?.workspaceMemberId;
if (!memberId) return {};
partnerId = await resolvePartnerIdForMember(client, memberId);
if (!partnerId) return {};
await client.mutation({
updatePartnerService: {
__args: {
id: childId,
data: {
partnerId,
partnerUserId: memberId,
},
},
id: true,
},
});
return { linked: true, partnerId };
}
await stampPartnerUserFromPartner(client, partnerId, 'partnerService', childId);
return { stamped: true };
};
export default defineLogicFunction({
universalIdentifier: ON_PARTNER_SERVICE_CREATED_FN_UNIVERSAL_IDENTIFIER,
name: 'on-partner-service-created',
timeoutSeconds: 10,
handler,
databaseEventTriggerSettings: { eventName: 'partnerService.created' },
});
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { buildReconcilePlan } from './reconcile-children';
type Item = { id?: string; value: string };
describe('buildReconcilePlan', () => {
it('keeps+edits one, creates one (no id), and drops the omitted one', () => {
const existingIds = ['a', 'b'];
const incoming: Item[] = [
{ id: 'a', value: 'edited' },
{ value: 'new' },
];
const plan = buildReconcilePlan(existingIds, incoming);
expect(plan).toEqual({
toCreate: [{ value: 'new' }],
toUpdate: [{ id: 'a', value: 'edited' }],
toDelete: ['b'],
});
});
it('returns null when an incoming id is not in existingIds (foreign/stale id)', () => {
const existingIds = ['a'];
const incoming: Item[] = [{ id: 'foreign', value: 'x' }];
expect(buildReconcilePlan(existingIds, incoming)).toBeNull();
});
it('returns null when the same id is submitted twice (no double update)', () => {
const existingIds = ['a', 'b'];
const incoming: Item[] = [
{ id: 'a', value: 'first' },
{ id: 'a', value: 'second' },
];
expect(buildReconcilePlan(existingIds, incoming)).toBeNull();
});
it('deletes everything when incoming is empty', () => {
const existingIds = ['a', 'b', 'c'];
const plan = buildReconcilePlan(existingIds, []);
expect(plan).toEqual({ toCreate: [], toUpdate: [], toDelete: ['a', 'b', 'c'] });
});
});
@@ -0,0 +1,26 @@
export type ReconcileItem = { id?: string };
export type ReconcilePlan<T> = { toCreate: T[]; toUpdate: T[]; toDelete: string[] };
// existingIds = ids currently owned by the caller's partner.
// incoming = desired rows. Rows with an id NOT in existingIds are rejected (return null → caller 403s).
export const buildReconcilePlan = <T extends ReconcileItem>(
existingIds: string[],
incoming: T[],
): ReconcilePlan<T> | null => {
const owned = new Set(existingIds);
const keptIds = new Set<string>();
const toCreate: T[] = [];
const toUpdate: T[] = [];
for (const item of incoming) {
if (item.id === undefined) {
toCreate.push(item);
continue;
}
if (!owned.has(item.id)) return null; // foreign / stale id → refuse the whole batch
if (keptIds.has(item.id)) return null; // same id submitted twice → refuse the whole batch
keptIds.add(item.id);
toUpdate.push(item);
}
const toDelete = existingIds.filter((id) => !keptIds.has(id));
return { toCreate, toUpdate, toDelete };
};
@@ -0,0 +1,74 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
decodeJwtClaims,
errorResponse,
resolvePartnerFromRequest,
} from './resolve-partner-from-request';
const makeToken = (payload: Record<string, unknown>): string => {
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
return `header.${body}.sig`;
};
describe('decodeJwtClaims', () => {
it('decodes userId and userWorkspaceId from a base64url payload', () => {
const token = makeToken({ userId: 'u-1', userWorkspaceId: 'uw-1', extra: 'x' });
expect(decodeJwtClaims(token)).toMatchObject({ userId: 'u-1', userWorkspaceId: 'uw-1' });
});
it('returns {} for a garbage string', () => {
expect(decodeJwtClaims('not-a-jwt')).toEqual({});
});
it('returns {} for an empty string', () => {
expect(decodeJwtClaims('')).toEqual({});
});
it('returns {} when the payload segment is not valid JSON', () => {
expect(decodeJwtClaims('header.%%%.sig')).toEqual({});
});
it('returns {} when the payload decodes to a non-object (null)', () => {
const token = `header.${Buffer.from('null').toString('base64url')}.sig`;
expect(decodeJwtClaims(token)).toEqual({});
});
});
describe('resolvePartnerFromRequest guards (no network)', () => {
const original = process.env.TWENTY_APP_ACCESS_TOKEN;
afterEach(() => {
if (original === undefined) delete process.env.TWENTY_APP_ACCESS_TOKEN;
else process.env.TWENTY_APP_ACCESS_TOKEN = original;
});
it('returns UNAUTHENTICATED when userWorkspaceId is absent', async () => {
expect(await resolvePartnerFromRequest({})).toEqual({ error: 'UNAUTHENTICATED' });
expect(await resolvePartnerFromRequest({ userWorkspaceId: null })).toEqual({
error: 'UNAUTHENTICATED',
});
});
it('returns UNAUTHENTICATED when no app token is present to decode', async () => {
delete process.env.TWENTY_APP_ACCESS_TOKEN;
expect(await resolvePartnerFromRequest({ userWorkspaceId: 'uw-1' })).toEqual({
error: 'UNAUTHENTICATED',
});
});
it('returns UNAUTHENTICATED when the token userWorkspaceId does not match the injected one', async () => {
process.env.TWENTY_APP_ACCESS_TOKEN = makeToken({
userId: 'u-1',
userWorkspaceId: 'uw-other',
});
expect(await resolvePartnerFromRequest({ userWorkspaceId: 'uw-1' })).toEqual({
error: 'UNAUTHENTICATED',
});
});
});
describe('errorResponse', () => {
it('wraps a reason in a failure envelope', () => {
expect(errorResponse('NO_PARTNER')).toEqual({ ok: false, reason: 'NO_PARTNER' });
});
});
@@ -0,0 +1,91 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
// Self-service routes run with the app-privileged token in production. Under the vitest
// harness TWENTY_APP_ACCESS_TOKEN is a crafted identity-only token (decoded for userId but
// not a valid API credential), so fall back to the workspace API key for the actual
// queries/mutations. Production is unaffected — VITEST is unset and no API key is injected.
export const buildAppClient = (): CoreApiClient => {
const apiKey = process.env.TWENTY_API_KEY;
if (process.env.VITEST && apiKey) {
return new CoreApiClient({ headers: { Authorization: `Bearer ${apiKey}` } });
}
return new CoreApiClient();
};
export const decodeJwtClaims = (
token: string,
): { userId?: string; userWorkspaceId?: string } => {
const part = token.split('.')[1];
if (!part) return {};
try {
const json = Buffer.from(
part.replace(/-/g, '+').replace(/_/g, '/'),
'base64',
).toString('utf8');
const parsed = JSON.parse(json);
// A JWT payload can decode to a non-object (null, number, string); guard so callers
// reading claims.userId reject cleanly instead of throwing on a valid-but-malformed token.
if (parsed === null || typeof parsed !== 'object') return {};
return parsed;
} catch {
return {};
}
};
export const resolvePartnerByUserId = async (
client: CoreApiClient,
userId: string,
): Promise<{ partnerId: string; workspaceMemberId: string } | null> => {
const members = await client.query({
workspaceMembers: {
__args: { filter: { userId: { eq: userId } }, first: 1 },
edges: { node: { id: true } },
},
});
const workspaceMemberId = members.workspaceMembers?.edges?.[0]?.node?.id;
if (!workspaceMemberId) return null;
const partners = await client.query({
partners: {
__args: { filter: { partnerUserId: { eq: workspaceMemberId } }, first: 1 },
edges: { node: { id: true } },
},
});
const partnerId = partners.partners?.edges?.[0]?.node?.id;
if (!partnerId) return null;
return { partnerId, workspaceMemberId };
};
export type ResolvedPartner =
| { partnerId: string; workspaceMemberId: string }
| { error: 'UNAUTHENTICATED' | 'NO_PARTNER' };
export const resolvePartnerFromRequest = async (event: {
userWorkspaceId?: string | null;
}): Promise<ResolvedPartner> => {
const userWorkspaceId = event.userWorkspaceId ?? null;
if (!userWorkspaceId) return { error: 'UNAUTHENTICATED' };
// Decode, do not verify — this env token was freshly minted server-side for this call.
const claims = decodeJwtClaims(process.env.TWENTY_APP_ACCESS_TOKEN ?? '');
if (!claims.userId || claims.userWorkspaceId !== userWorkspaceId) {
return { error: 'UNAUTHENTICATED' };
}
const resolved = await resolvePartnerByUserId(buildAppClient(), claims.userId);
return resolved ?? { error: 'NO_PARTNER' };
};
export type PartnerRouteError = { ok: false; reason: string };
export const errorResponse = (reason: string): PartnerRouteError => ({
ok: false,
reason,
});
// Log the real cause server-side but never surface raw SDK/DB text to partner users.
export const failureResponse = (logTag: string, err: unknown): PartnerRouteError => {
console.error(`[${logTag}]`, err instanceof Error ? (err.stack ?? err.message) : String(err));
return { ok: false, reason: 'Something went wrong. Please try again.' };
};
@@ -0,0 +1,214 @@
import { type CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { z } from 'zod';
import { isCaseStudy } from './content-type';
import { isHttpUrl } from './http-url';
import { buildReconcilePlan } from './reconcile-children';
import { buildAppClient, errorResponse, failureResponse, resolvePartnerFromRequest } from './resolve-partner-from-request';
export const SAVE_MY_PARTNER_CONTENT_ID = 'e574fc61-6d9e-48db-9e98-a9b8160188cc';
export const saveContentSchema = z.object({
caseStudies: z.array(
z.object({
id: z.string().optional(),
name: z.string(),
clientName: z.string().optional(),
headline: z.string().optional(),
bodyMarkdown: z.string().optional(),
caseStudyLink: z
.string()
.refine((value) => value === '' || isHttpUrl(value), {
message: 'URL must use http or https',
})
.optional(),
coverImageUrl: z.string().optional(),
published: z.boolean().optional(),
}),
).max(50, 'Too many case studies in a single request (max 50)'),
});
export type SaveContentInput = z.infer<typeof saveContentSchema>;
type CaseStudyItem = SaveContentInput['caseStudies'][number];
export type CaseStudyRow = {
id: string;
name: string | null;
clientName: string | null;
headline: string | null;
bodyMarkdown: string | null;
coverImageUrl: string | null;
caseStudyLink: string | null;
status: string | null;
};
export type SaveContentResult =
| { ok: true; caseStudies: CaseStudyRow[] }
| { ok: false; reason: string };
// Partner self-controls visibility: published → APPROVED (public), draft → WIP (hidden).
// Ownership (partnerId/partnerUser) and contentType are stamped server-side by the
// on-partner-content-created trigger, never written by the caller — so a partner cannot
// repoint a case study onto another partner's public marketplace profile.
// Create and update share every field mapping; they diverge only on how status is
// derived, so keep the common fields here and let each add its own status handling.
const buildContentBaseData = (item: CaseStudyItem) => ({
name: item.name,
clientName: item.clientName,
headline: item.headline,
body: { markdown: item.bodyMarkdown ?? '' },
caseStudyLink: item.caseStudyLink ? { primaryLinkUrl: item.caseStudyLink } : undefined,
coverImageUrl: item.coverImageUrl,
});
export function buildContentCreateData(
item: CaseStudyItem,
): CoreSchema.PartnerContentCreateInput {
return { ...buildContentBaseData(item), status: item.published ? 'APPROVED' : 'WIP' };
}
export function buildContentUpdateData(
item: CaseStudyItem,
): CoreSchema.PartnerContentUpdateInput {
return {
...buildContentBaseData(item),
// Only touch status when the caller specified published; a partial edit must not unpublish an APPROVED case study.
...(item.published !== undefined ? { status: item.published ? 'APPROVED' : 'WIP' } : {}),
};
}
const queryExistingContentIds = async (
client: CoreApiClient,
partnerId: string,
): Promise<string[]> => {
const result = await client.query({
partnerContents: {
__args: { filter: { partnerId: { eq: partnerId } } },
edges: { node: { id: true, contentType: true } },
},
});
return (result.partnerContents?.edges ?? [])
.filter((edge) => isCaseStudy(edge.node.contentType))
.map((edge) => edge.node.id);
};
const queryContentRows = async (
client: CoreApiClient,
partnerId: string,
): Promise<CaseStudyRow[]> => {
const result = await client.query({
partnerContents: {
__args: { filter: { partnerId: { eq: partnerId } } },
edges: {
node: {
id: true,
name: true,
clientName: true,
headline: true,
body: { markdown: true },
coverImageUrl: true,
caseStudyLink: { primaryLinkUrl: true },
status: true,
contentType: true,
},
},
},
});
return (result.partnerContents?.edges ?? [])
.filter((edge) => isCaseStudy(edge.node.contentType))
.map((edge) => ({
id: edge.node.id,
name: edge.node.name ?? null,
clientName: edge.node.clientName ?? null,
headline: edge.node.headline ?? null,
bodyMarkdown: edge.node.body?.markdown ?? null,
coverImageUrl: edge.node.coverImageUrl || null,
caseStudyLink: edge.node.caseStudyLink?.primaryLinkUrl ?? null,
status: edge.node.status ?? null,
}));
};
export const handler = async (event: RoutePayload<unknown>): Promise<SaveContentResult> => {
const resolved = await resolvePartnerFromRequest(event);
if ('error' in resolved) return errorResponse(resolved.error);
const parsed = saveContentSchema.safeParse(event.body);
if (!parsed.success) {
return errorResponse(parsed.error.issues[0]?.message ?? 'invalid_input');
}
try {
const client = buildAppClient();
const existingIds = await queryExistingContentIds(client, resolved.partnerId);
const plan = buildReconcilePlan(existingIds, parsed.data.caseStudies);
if (!plan) return errorResponse('FORBIDDEN');
// A just-created row isn't owner-stamped by the trigger yet, so the caller's own re-read
// (RLS-scoped) can't see it. Return it optimistically from the input + new id.
const createdRows: CaseStudyRow[] = [];
for (const item of plan.toCreate) {
const created = await client.mutation({
createPartnerContent: {
__args: { data: buildContentCreateData(item) },
id: true,
},
});
const newId = created.createPartnerContent?.id;
if (newId !== undefined) {
createdRows.push({
id: newId,
name: item.name,
clientName: item.clientName ?? null,
headline: item.headline ?? null,
bodyMarkdown: item.bodyMarkdown ?? null,
coverImageUrl: item.coverImageUrl ?? null,
caseStudyLink: item.caseStudyLink ?? null,
status: item.published ? 'APPROVED' : 'WIP',
});
}
}
for (const item of plan.toUpdate) {
// buildReconcilePlan only puts items with a defined id into toUpdate.
if (item.id === undefined) continue;
await client.mutation({
updatePartnerContent: {
__args: { id: item.id, data: buildContentUpdateData(item) },
id: true,
},
});
}
for (const id of plan.toDelete) {
await client.mutation({
deletePartnerContent: { __args: { id }, id: true },
});
}
// A just-created row can surface in the re-read too once the trigger stamps its
// partnerId, so drop those ids before appending the optimistic createdRows.
const existingRows = await queryContentRows(client, resolved.partnerId);
const createdIds = new Set(createdRows.map((row) => row.id));
const deduped = existingRows.filter((row) => !createdIds.has(row.id));
return { ok: true, caseStudies: [...deduped, ...createdRows] };
} catch (err) {
return failureResponse('save-my-partner-content', err);
}
};
export default defineLogicFunction({
universalIdentifier: SAVE_MY_PARTNER_CONTENT_ID,
name: 'save-my-partner-content',
description:
"Reconciles the calling partner's own case studies (create/update/delete in one call); each row is published (APPROVED) or kept as a draft (WIP) per its published flag.",
timeoutSeconds: 20,
handler,
httpRouteTriggerSettings: {
path: '/save-my-partner-content',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -0,0 +1,142 @@
import { describe, expect, it } from 'vitest';
import {
buildContentCreateData,
buildContentUpdateData,
saveContentSchema,
} from './save-my-partner-content.logic-function';
import { canSubmitForReview } from './submit-partner-content-for-review.logic-function';
describe('saveContentSchema', () => {
it('accepts a minimal case study (name only)', () => {
expect(saveContentSchema.safeParse({ caseStudies: [{ name: 'Acme rollout' }] }).success).toBe(
true,
);
});
it('accepts an empty caseStudies array', () => {
expect(saveContentSchema.safeParse({ caseStudies: [] }).success).toBe(true);
});
it('rejects a case study missing name', () => {
expect(saveContentSchema.safeParse({ caseStudies: [{ clientName: 'Acme' }] }).success).toBe(
false,
);
});
});
describe('buildContentCreateData', () => {
it('creates status: WIP and never writes partnerId/contentType (trigger stamps them)', () => {
const data = buildContentCreateData({ name: 'Acme rollout' });
expect(data.status).toBe('WIP');
expect(data.name).toBe('Acme rollout');
expect(data).not.toHaveProperty('partnerId');
expect(data).not.toHaveProperty('contentType');
});
it('wraps bodyMarkdown into { markdown } (empty string when omitted)', () => {
expect(buildContentCreateData({ name: 'x' }).body).toEqual({ markdown: '' });
expect(
buildContentCreateData({ name: 'x', bodyMarkdown: 'Hello **world**' }).body,
).toEqual({ markdown: 'Hello **world**' });
});
it('wraps caseStudyLink into { primaryLinkUrl } when present', () => {
const data = buildContentCreateData({
name: 'x',
caseStudyLink: 'https://example.com/case-study',
});
expect(data.caseStudyLink).toEqual({ primaryLinkUrl: 'https://example.com/case-study' });
});
it('leaves caseStudyLink undefined when not provided', () => {
expect(buildContentCreateData({ name: 'x' }).caseStudyLink).toBeUndefined();
});
it('carries clientName and headline through untouched', () => {
const data = buildContentCreateData({
name: 'x',
clientName: 'Acme Corp',
headline: 'A great migration',
});
expect(data.clientName).toBe('Acme Corp');
expect(data.headline).toBe('A great migration');
});
it('passes coverImageUrl through when provided, and omits it when absent', () => {
expect(buildContentCreateData({ name: 'x', coverImageUrl: 'https://img.example.com/c.png' }).coverImageUrl).toBe(
'https://img.example.com/c.png',
);
expect(buildContentCreateData({ name: 'x' }).coverImageUrl).toBeUndefined();
});
});
describe('buildContentUpdateData', () => {
it('omits contentType and partnerId', () => {
const data = buildContentUpdateData({ name: 'Acme rollout (edited)' });
expect(data).not.toHaveProperty('contentType');
expect(data).not.toHaveProperty('partnerId');
});
it('wraps bodyMarkdown and caseStudyLink the same way as create', () => {
const data = buildContentUpdateData({
name: 'x',
bodyMarkdown: 'Updated body',
caseStudyLink: 'https://example.com/updated',
});
expect(data.body).toEqual({ markdown: 'Updated body' });
expect(data.caseStudyLink).toEqual({ primaryLinkUrl: 'https://example.com/updated' });
});
it('still maps name, clientName, headline', () => {
const data = buildContentUpdateData({
name: 'Acme rollout (edited)',
clientName: 'Acme Corp',
headline: 'A better migration',
});
expect(data.name).toBe('Acme rollout (edited)');
expect(data.clientName).toBe('Acme Corp');
expect(data.headline).toBe('A better migration');
});
it('passes coverImageUrl through on update', () => {
expect(buildContentUpdateData({ name: 'x', coverImageUrl: 'https://img.example.com/c.png' }).coverImageUrl).toBe(
'https://img.example.com/c.png',
);
});
});
describe('partner-controlled publish', () => {
it('creates a published case study as APPROVED', () => {
const data = buildContentCreateData({ name: 'x', published: true });
expect(data.status).toBe('APPROVED');
});
it('creates a draft (published false or omitted) as WIP', () => {
expect(buildContentCreateData({ name: 'x', published: false }).status).toBe('WIP');
expect(buildContentCreateData({ name: 'x' }).status).toBe('WIP');
});
it('updates status from the published flag', () => {
expect(buildContentUpdateData({ name: 'x', published: true }).status).toBe('APPROVED');
expect(buildContentUpdateData({ name: 'x', published: false }).status).toBe('WIP');
});
it('omits status when published is not specified, preserving the existing status', () => {
expect(buildContentUpdateData({ name: 'x' })).not.toHaveProperty('status');
});
});
describe('canSubmitForReview', () => {
it('returns true only for WIP', () => {
expect(canSubmitForReview('WIP')).toBe(true);
});
it('returns false for every other status', () => {
expect(canSubmitForReview('INTERVIEW_SCHEDULED')).toBe(false);
expect(canSubmitForReview('UNDER_CUSTOMER_PARTNER_REVIEW')).toBe(false);
expect(canSubmitForReview('APPROVED')).toBe(false);
expect(canSubmitForReview('REJECTED')).toBe(false);
expect(canSubmitForReview(null)).toBe(false);
});
});
@@ -0,0 +1,144 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { z } from 'zod';
import { isHttpUrl } from './http-url';
import { buildReconcilePlan } from './reconcile-children';
import { buildAppClient, errorResponse, failureResponse, resolvePartnerFromRequest } from './resolve-partner-from-request';
export const SAVE_MY_PARTNER_LINKS_ID = 'b56d1158-4e79-4fdb-a7c4-e0f8871b2d42';
export const saveLinksSchema = z.object({
links: z.array(
z.object({
id: z.string().optional(),
name: z.string(),
url: z.string().refine((value) => value === '' || isHttpUrl(value), {
message: 'URL must use http or https',
}),
sortOrder: z.number().optional(),
}),
),
});
export type SaveLinksInput = z.infer<typeof saveLinksSchema>;
export type LinkRow = {
id: string;
name: string | null;
url: string | null;
sortOrder: number | null;
};
export type SaveLinksResult = { ok: true; links: LinkRow[] } | { ok: false; reason: string };
const queryExistingLinkIds = async (
client: CoreApiClient,
partnerId: string,
): Promise<string[]> => {
const result = await client.query({
partnerLinks: {
__args: { filter: { partnerId: { eq: partnerId } } },
edges: { node: { id: true } },
},
});
return (result.partnerLinks?.edges ?? []).map((edge) => edge.node.id);
};
const queryLinkRows = async (client: CoreApiClient, partnerId: string): Promise<LinkRow[]> => {
const result = await client.query({
partnerLinks: {
__args: { filter: { partnerId: { eq: partnerId } } },
edges: {
node: {
id: true,
name: true,
url: { primaryLinkUrl: true },
sortOrder: true,
},
},
},
});
return (result.partnerLinks?.edges ?? []).map((edge) => ({
id: edge.node.id,
name: edge.node.name ?? null,
url: edge.node.url?.primaryLinkUrl ?? null,
sortOrder: edge.node.sortOrder ?? null,
}));
};
export const handler = async (event: RoutePayload<unknown>): Promise<SaveLinksResult> => {
const resolved = await resolvePartnerFromRequest(event);
if ('error' in resolved) return errorResponse(resolved.error);
const parsed = saveLinksSchema.safeParse(event.body);
if (!parsed.success) {
return errorResponse(parsed.error.issues[0]?.message ?? 'invalid_input');
}
try {
const client = buildAppClient();
const existingIds = await queryExistingLinkIds(client, resolved.partnerId);
const plan = buildReconcilePlan(existingIds, parsed.data.links);
if (!plan) return errorResponse('FORBIDDEN');
for (const link of plan.toCreate) {
await client.mutation({
createPartnerLink: {
__args: {
data: {
partnerId: resolved.partnerId,
name: link.name,
url: { primaryLinkUrl: link.url },
sortOrder: link.sortOrder,
},
},
id: true,
},
});
}
for (const link of plan.toUpdate) {
// buildReconcilePlan only puts items with a defined id into toUpdate.
if (link.id === undefined) continue;
await client.mutation({
updatePartnerLink: {
__args: {
id: link.id,
data: {
name: link.name,
url: { primaryLinkUrl: link.url },
sortOrder: link.sortOrder,
},
},
id: true,
},
});
}
for (const id of plan.toDelete) {
await client.mutation({
deletePartnerLink: { __args: { id }, id: true },
});
}
const links = await queryLinkRows(client, resolved.partnerId);
return { ok: true, links };
} catch (err) {
return failureResponse('save-my-partner-links', err);
}
};
export default defineLogicFunction({
universalIdentifier: SAVE_MY_PARTNER_LINKS_ID,
name: 'save-my-partner-links',
description: "Reconciles the calling partner's own links (create/update/delete in one call).",
timeoutSeconds: 20,
handler,
httpRouteTriggerSettings: {
path: '/save-my-partner-links',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -0,0 +1,159 @@
import { type CoreSchema } from 'twenty-client-sdk/core';
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { z } from 'zod';
import { PROFILE_OPTIONS } from 'src/constants/my-profile.constants';
import { optionalHttpUrl } from './http-url';
import { buildAppClient, errorResponse, failureResponse, resolvePartnerFromRequest } from './resolve-partner-from-request';
export const SAVE_MY_PARTNER_PROFILE_ID = 'de21e2a6-f4b4-4186-90d9-645015e856a1';
const optionalUrl = optionalHttpUrl;
// Marketplace pricing is USD-only (the profile UI has no currency picker), so pin the
// code and reject negative/NaN amounts rather than accept arbitrary values via the API.
const optionalMoney = z
.object({
amountMicros: z.number().finite().nonnegative(),
currencyCode: z.literal('USD'),
})
.nullable()
.optional();
export const saveProfileSchema = z
.object({
name: z.string().trim().optional(),
introduction: z.string().optional(),
city: z.string().optional(),
// null clears the field; the enum/country selectors send null when reset to blank.
country: z.string().nullable().optional(),
languagesSpoken: z.array(z.string()).optional(),
partnerScope: z.array(z.string()).optional(),
skills: z.array(z.string()).optional(),
typeOfTeam: z.enum(['SOLO', 'AGENCY']).nullable().optional(),
availability: z.enum(['AVAILABLE', 'UNAVAILABLE']).nullable().optional(),
hourlyRate: optionalMoney,
projectBudgetMin: optionalMoney,
website: optionalUrl,
linkedin: optionalUrl,
calendarLink: optionalUrl,
})
.strict();
export type SaveProfileInput = z.infer<typeof saveProfileSchema>;
export type SaveResult = { ok: true } | { ok: false; reason: string };
const optionValueSet = (options: { value: string }[]): Set<string> =>
new Set(options.map((option) => option.value));
const COUNTRY_VALUES = optionValueSet(PROFILE_OPTIONS.country);
const LANGUAGE_VALUES = optionValueSet(PROFILE_OPTIONS.languagesSpoken);
const PARTNER_SCOPE_VALUES = optionValueSet(PROFILE_OPTIONS.partnerScope);
// Kept separate from buildPartnerUpdateData so each concern (validation vs.
// mapping) is independently unit-testable.
export function validateProfileOptionValues(
input: SaveProfileInput,
): { error: string } | null {
if (input.country != null && !COUNTRY_VALUES.has(input.country)) {
return { error: `Unknown country: ${input.country}` };
}
if (input.languagesSpoken !== undefined) {
const unknown = input.languagesSpoken.find((value) => !LANGUAGE_VALUES.has(value));
if (unknown !== undefined) return { error: `Unknown language: ${unknown}` };
}
if (input.partnerScope !== undefined) {
const unknown = input.partnerScope.find((value) => !PARTNER_SCOPE_VALUES.has(value));
if (unknown !== undefined) return { error: `Unknown partner scope: ${unknown}` };
}
return null;
}
// Pure mapper: assumes validateProfileOptionValues already accepted `input`.
// Only fields present on `input` are copied onto the update payload.
export function buildPartnerUpdateData(
input: SaveProfileInput,
): CoreSchema.PartnerUpdateInput {
const data: CoreSchema.PartnerUpdateInput = {};
if (input.name !== undefined) data.name = input.name;
if (input.introduction !== undefined) data.introduction = input.introduction;
if (input.city !== undefined) data.city = input.city;
if (input.country !== undefined) {
data.country =
input.country === null ? null : (input.country as CoreSchema.PartnerCountryEnum);
}
if (input.languagesSpoken !== undefined) {
data.languagesSpoken = input.languagesSpoken.map(
(value) => value as CoreSchema.PartnerLanguagesSpokenEnum,
);
}
if (input.partnerScope !== undefined) {
data.partnerScope = input.partnerScope.map(
(value) => value as CoreSchema.PartnerPartnerScopeEnum,
);
}
if (input.skills !== undefined) data.skills = input.skills;
if (input.typeOfTeam !== undefined) data.typeOfTeam = input.typeOfTeam;
if (input.availability !== undefined) data.availability = input.availability;
if (input.hourlyRate !== undefined) {
data.hourlyRate = input.hourlyRate === null ? null : { ...input.hourlyRate };
}
if (input.projectBudgetMin !== undefined) {
data.projectBudgetMin =
input.projectBudgetMin === null ? null : { ...input.projectBudgetMin };
}
if (input.website !== undefined) {
data.website = input.website === null ? null : { primaryLinkUrl: input.website };
}
if (input.linkedin !== undefined) {
data.linkedin = input.linkedin === null ? null : { primaryLinkUrl: input.linkedin };
}
if (input.calendarLink !== undefined) {
data.calendarLink =
input.calendarLink === null ? null : { primaryLinkUrl: input.calendarLink };
}
return data;
}
export const handler = async (event: RoutePayload<unknown>): Promise<SaveResult> => {
const resolved = await resolvePartnerFromRequest(event);
if ('error' in resolved) return errorResponse(resolved.error);
const parsed = saveProfileSchema.safeParse(event.body);
if (!parsed.success) {
return errorResponse(parsed.error.issues[0]?.message ?? 'invalid_input');
}
const input = parsed.data;
const optionError = validateProfileOptionValues(input);
if (optionError) return errorResponse(optionError.error);
const data = buildPartnerUpdateData(input);
try {
const client = buildAppClient();
await client.mutation({
updatePartner: { __args: { id: resolved.partnerId, data }, id: true },
});
return { ok: true };
} catch (err) {
return failureResponse('save-my-partner-profile', err);
}
};
export default defineLogicFunction({
universalIdentifier: SAVE_MY_PARTNER_PROFILE_ID,
name: 'save-my-partner-profile',
description: "Saves the calling partner's own editable profile fields.",
timeoutSeconds: 15,
handler,
httpRouteTriggerSettings: {
path: '/save-my-partner-profile',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -0,0 +1,169 @@
import { describe, expect, it } from 'vitest';
import {
buildPartnerUpdateData,
saveProfileSchema,
validateProfileOptionValues,
} from './save-my-partner-profile.logic-function';
describe('saveProfileSchema', () => {
it('accepts a valid partial payload', () => {
const parsed = saveProfileSchema.safeParse({ name: 'Nine Dots Ventures', city: 'Paris' });
expect(parsed.success).toBe(true);
});
it('accepts an empty payload', () => {
expect(saveProfileSchema.safeParse({}).success).toBe(true);
});
it('rejects an unknown key (region)', () => {
const parsed = saveProfileSchema.safeParse({ region: ['EUROPE'] });
expect(parsed.success).toBe(false);
});
it('rejects an unknown key (validationStage)', () => {
const parsed = saveProfileSchema.safeParse({ validationStage: 'VALIDATED' });
expect(parsed.success).toBe(false);
});
it('rejects a malformed url', () => {
const parsed = saveProfileSchema.safeParse({ website: 'not-a-url' });
expect(parsed.success).toBe(false);
});
it('accepts a null url (clearing the field)', () => {
const parsed = saveProfileSchema.safeParse({ website: null });
expect(parsed.success).toBe(true);
});
it('treats an empty-string url as cleared (null), not a validation error', () => {
const parsed = saveProfileSchema.safeParse({ website: '' });
expect(parsed.success).toBe(true);
if (parsed.success) expect(parsed.data.website).toBeNull();
});
it('rejects a typeOfTeam value outside the known enum', () => {
const parsed = saveProfileSchema.safeParse({ typeOfTeam: 'FREELANCER' });
expect(parsed.success).toBe(false);
});
it('rejects a negative hourlyRate amount', () => {
expect(
saveProfileSchema.safeParse({ hourlyRate: { amountMicros: -1, currencyCode: 'USD' } }).success,
).toBe(false);
});
it('rejects a non-ISO currency code', () => {
expect(
saveProfileSchema.safeParse({ hourlyRate: { amountMicros: 100, currencyCode: 'DOLLARS' } })
.success,
).toBe(false);
});
it('rejects a lowercase currency code (USD only)', () => {
expect(
saveProfileSchema.safeParse({ hourlyRate: { amountMicros: 100, currencyCode: 'usd' } })
.success,
).toBe(false);
});
it('accepts null for clearable country and enum fields', () => {
expect(
saveProfileSchema.safeParse({ country: null, typeOfTeam: null, availability: null }).success,
).toBe(true);
});
});
describe('validateProfileOptionValues', () => {
it('rejects an unknown country', () => {
const result = validateProfileOptionValues({ country: 'NOPE' });
expect(result).toEqual({ error: 'Unknown country: NOPE' });
});
it('accepts a known country', () => {
expect(validateProfileOptionValues({ country: 'FRANCE' })).toBeNull();
});
it('rejects an unknown language', () => {
const result = validateProfileOptionValues({ languagesSpoken: ['ENGLISH', 'KLINGON'] });
expect(result).toEqual({ error: 'Unknown language: KLINGON' });
});
it('rejects an unknown partner scope', () => {
const result = validateProfileOptionValues({ partnerScope: ['NOT_A_SCOPE'] });
expect(result?.error).toContain('NOT_A_SCOPE');
});
it('returns null when nothing to validate is present', () => {
expect(validateProfileOptionValues({})).toBeNull();
});
it('accepts a null country (clearing it)', () => {
expect(validateProfileOptionValues({ country: null })).toBeNull();
});
});
describe('buildPartnerUpdateData', () => {
it('maps only the keys present on the input', () => {
const data = buildPartnerUpdateData({ name: 'Nine Dots Ventures' });
expect(data).toEqual({ name: 'Nine Dots Ventures' });
});
it('wraps LINKS fields to { primaryLinkUrl }', () => {
const data = buildPartnerUpdateData({
website: 'https://ninedots.example.com',
linkedin: 'https://linkedin.com/company/nine-dots',
calendarLink: 'https://cal.example.com/nine-dots',
});
expect(data.website).toEqual({ primaryLinkUrl: 'https://ninedots.example.com' });
expect(data.linkedin).toEqual({ primaryLinkUrl: 'https://linkedin.com/company/nine-dots' });
expect(data.calendarLink).toEqual({ primaryLinkUrl: 'https://cal.example.com/nine-dots' });
});
it('maps a null LINKS field to null (clearing it)', () => {
const data = buildPartnerUpdateData({ website: null });
expect(data.website).toBeNull();
});
it('passes CURRENCY fields through as { amountMicros, currencyCode }', () => {
const data = buildPartnerUpdateData({
hourlyRate: { amountMicros: 150000000, currencyCode: 'USD' },
projectBudgetMin: { amountMicros: 1000000000, currencyCode: 'USD' },
});
expect(data.hourlyRate).toEqual({ amountMicros: 150000000, currencyCode: 'USD' });
expect(data.projectBudgetMin).toEqual({ amountMicros: 1000000000, currencyCode: 'USD' });
});
it('maps a null CURRENCY field to null (clearing it)', () => {
const data = buildPartnerUpdateData({ hourlyRate: null });
expect(data.hourlyRate).toBeNull();
});
it('keeps enum and array values as provided', () => {
const data = buildPartnerUpdateData({
country: 'FRANCE',
languagesSpoken: ['ENGLISH', 'FRENCH'],
partnerScope: ['ADVISORY'],
typeOfTeam: 'AGENCY',
availability: 'AVAILABLE',
skills: ['Salesforce'],
});
expect(data.country).toBe('FRANCE');
expect(data.languagesSpoken).toEqual(['ENGLISH', 'FRENCH']);
expect(data.partnerScope).toEqual(['ADVISORY']);
expect(data.typeOfTeam).toBe('AGENCY');
expect(data.availability).toBe('AVAILABLE');
expect(data.skills).toEqual(['Salesforce']);
});
it('maps a null country to null (clearing it)', () => {
expect(buildPartnerUpdateData({ country: null }).country).toBeNull();
});
it('returns an empty object for an empty input', () => {
expect(buildPartnerUpdateData({})).toEqual({});
});
});
@@ -0,0 +1,147 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { z } from 'zod';
import { buildReconcilePlan } from './reconcile-children';
import { buildAppClient, errorResponse, failureResponse, resolvePartnerFromRequest } from './resolve-partner-from-request';
export const SAVE_MY_PARTNER_SERVICES_ID = '878a6e36-62f4-4590-807d-ef6204d2d168';
export const saveServicesSchema = z.object({
services: z.array(
z.object({
id: z.string().optional(),
title: z.string(),
description: z.string(),
sortOrder: z.number().optional(),
}),
),
});
export type SaveServicesInput = z.infer<typeof saveServicesSchema>;
export type ServiceRow = {
id: string;
title: string | null;
description: string | null;
sortOrder: number | null;
};
export type SaveServicesResult =
| { ok: true; services: ServiceRow[] }
| { ok: false; reason: string };
const queryExistingServiceIds = async (
client: CoreApiClient,
partnerId: string,
): Promise<string[]> => {
const result = await client.query({
partnerServices: {
__args: { filter: { partnerId: { eq: partnerId } } },
edges: { node: { id: true } },
},
});
return (result.partnerServices?.edges ?? []).map((edge) => edge.node.id);
};
const queryServiceRows = async (
client: CoreApiClient,
partnerId: string,
): Promise<ServiceRow[]> => {
const result = await client.query({
partnerServices: {
__args: { filter: { partnerId: { eq: partnerId } } },
edges: {
node: {
id: true,
title: true,
description: true,
sortOrder: true,
},
},
},
});
return (result.partnerServices?.edges ?? []).map((edge) => ({
id: edge.node.id,
title: edge.node.title ?? null,
description: edge.node.description ?? null,
sortOrder: edge.node.sortOrder ?? null,
}));
};
export const handler = async (event: RoutePayload<unknown>): Promise<SaveServicesResult> => {
const resolved = await resolvePartnerFromRequest(event);
if ('error' in resolved) return errorResponse(resolved.error);
const parsed = saveServicesSchema.safeParse(event.body);
if (!parsed.success) {
return errorResponse(parsed.error.issues[0]?.message ?? 'invalid_input');
}
try {
const client = buildAppClient();
const existingIds = await queryExistingServiceIds(client, resolved.partnerId);
const plan = buildReconcilePlan(existingIds, parsed.data.services);
if (!plan) return errorResponse('FORBIDDEN');
for (const service of plan.toCreate) {
await client.mutation({
createPartnerService: {
__args: {
data: {
partnerId: resolved.partnerId,
title: service.title,
description: service.description,
sortOrder: service.sortOrder,
},
},
id: true,
},
});
}
for (const service of plan.toUpdate) {
// buildReconcilePlan only puts items with a defined id into toUpdate.
if (service.id === undefined) continue;
await client.mutation({
updatePartnerService: {
__args: {
id: service.id,
data: {
title: service.title,
description: service.description,
sortOrder: service.sortOrder,
},
},
id: true,
},
});
}
for (const id of plan.toDelete) {
await client.mutation({
deletePartnerService: { __args: { id }, id: true },
});
}
const services = await queryServiceRows(client, resolved.partnerId);
return { ok: true, services };
} catch (err) {
return failureResponse('save-my-partner-services', err);
}
};
export default defineLogicFunction({
universalIdentifier: SAVE_MY_PARTNER_SERVICES_ID,
name: 'save-my-partner-services',
description:
"Reconciles the calling partner's own services (create/update/delete in one call).",
timeoutSeconds: 20,
handler,
httpRouteTriggerSettings: {
path: '/save-my-partner-services',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -0,0 +1,66 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { stampPartnerUserFromPartner } from './stamp-partner-user-on-child';
describe('stampPartnerUserFromPartner', () => {
const query = vi.fn();
const mutation = vi.fn();
const client = {
query,
mutation,
} as unknown as CoreApiClient;
beforeEach(() => {
query.mockReset();
mutation.mockReset();
mutation.mockResolvedValue({});
});
it('stamps partnerLink partnerUserId when missing', async () => {
query
.mockResolvedValueOnce({ partner: { id: 'partner-1', partnerUserId: 'member-1' } })
.mockResolvedValueOnce({ partnerLink: { id: 'link-1', partnerUserId: null } });
await stampPartnerUserFromPartner(client, 'partner-1', 'partnerLink', 'link-1');
expect(mutation).toHaveBeenCalledWith({
updatePartnerLink: { __args: { id: 'link-1', data: { partnerUserId: 'member-1' } }, id: true },
});
});
it('stamps partnerService partnerUserId when missing', async () => {
query
.mockResolvedValueOnce({ partner: { id: 'partner-1', partnerUserId: 'member-1' } })
.mockResolvedValueOnce({ partnerService: { id: 'service-1', partnerUserId: null } });
await stampPartnerUserFromPartner(client, 'partner-1', 'partnerService', 'service-1');
expect(mutation).toHaveBeenCalledWith({
updatePartnerService: {
__args: { id: 'service-1', data: { partnerUserId: 'member-1' } },
id: true,
},
});
});
it('does nothing when partner has no partnerUserId', async () => {
query.mockResolvedValueOnce({ partner: { id: 'partner-1', partnerUserId: null } });
await stampPartnerUserFromPartner(client, 'partner-1', 'partnerService', 'service-1');
expect(query).toHaveBeenCalledTimes(1);
expect(mutation).not.toHaveBeenCalled();
});
it('does not rewrite when partnerContent already has matching partnerUserId', async () => {
query
.mockResolvedValueOnce({ partner: { id: 'partner-1', partnerUserId: 'member-1' } })
.mockResolvedValueOnce({ partnerContent: { id: 'content-1', partnerUserId: 'member-1' } });
await stampPartnerUserFromPartner(client, 'partner-1', 'partnerContent', 'content-1');
expect(mutation).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,81 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
export type PartnerChildObject = 'partnerLink' | 'partnerService' | 'partnerContent';
export const stampPartnerUserFromPartner = async (
client: CoreApiClient,
partnerId: string,
childObject: PartnerChildObject,
childId: string,
): Promise<void> => {
const partnerRes = await client.query({
partner: {
__args: { filter: { id: { eq: partnerId } } },
id: true,
partnerUserId: true,
},
});
const partnerUserId = partnerRes.partner?.partnerUserId;
if (!partnerUserId) return;
if (childObject === 'partnerLink') {
const childRes = await client.query({
partnerLink: {
__args: { filter: { id: { eq: childId } } },
id: true,
partnerUserId: true,
},
});
if (!childRes.partnerLink) return;
if (childRes.partnerLink.partnerUserId === partnerUserId) return;
await client.mutation({
updatePartnerLink: {
__args: { id: childId, data: { partnerUserId } },
id: true,
},
});
return;
}
if (childObject === 'partnerService') {
const childRes = await client.query({
partnerService: {
__args: { filter: { id: { eq: childId } } },
id: true,
partnerUserId: true,
},
});
if (!childRes.partnerService) return;
if (childRes.partnerService.partnerUserId === partnerUserId) return;
await client.mutation({
updatePartnerService: {
__args: { id: childId, data: { partnerUserId } },
id: true,
},
});
return;
}
const childRes = await client.query({
partnerContent: {
__args: { filter: { id: { eq: childId } } },
id: true,
partnerUserId: true,
},
});
if (!childRes.partnerContent) return;
if (childRes.partnerContent.partnerUserId === partnerUserId) return;
await client.mutation({
updatePartnerContent: {
__args: { id: childId, data: { partnerUserId } },
id: true,
},
});
};
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';
import { stripMarkdown } from './strip-markdown';
describe('stripMarkdown', () => {
it('removes headings, emphasis, and list markers', () => {
expect(
stripMarkdown('**Senior partner**\n\n#### Who we work with\n\n- Seed to Series C'),
).toBe('Senior partner Who we work with Seed to Series C');
});
it('unwraps link labels', () => {
expect(stripMarkdown('[Twenty](https://twenty.com)')).toBe('Twenty');
});
});
@@ -0,0 +1,7 @@
export const stripMarkdown = (markdown: string): string =>
markdown
.replace(/```[\s\S]*?```/g, ' ')
.replace(/[#>*_~`-]/g, ' ')
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/\s+/g, ' ')
.trim();
@@ -0,0 +1,90 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { z } from 'zod';
import { buildAppClient, errorResponse, failureResponse, resolvePartnerFromRequest } from './resolve-partner-from-request';
export const SUBMIT_PARTNER_CONTENT_FOR_REVIEW_ID = '6d722484-bbe9-4ffc-b017-5164e3a5a03c';
export const submitContentForReviewSchema = z.object({
recordId: z.string(),
});
export type SubmitContentForReviewInput = z.infer<typeof submitContentForReviewSchema>;
export type SubmitContentForReviewResult =
| { ok: true; status: 'UNDER_CUSTOMER_PARTNER_REVIEW' }
| { ok: false; reason: string };
// Only a WIP row can be submitted — this is the one status transition a partner
// can trigger themselves; every other transition stays staff-controlled.
export function canSubmitForReview(status: string | null): boolean {
return status === 'WIP';
}
const queryContentOwnerAndStatus = async (
client: CoreApiClient,
recordId: string,
): Promise<{ partnerId: string | null; status: string | null } | null> => {
const result = await client.query({
partnerContents: {
__args: { filter: { id: { eq: recordId } }, first: 1 },
edges: { node: { partnerId: true, status: true } },
},
});
const node = result.partnerContents?.edges?.[0]?.node;
if (!node) return null;
return { partnerId: node.partnerId ?? null, status: node.status ?? null };
};
export const handler = async (
event: RoutePayload<unknown>,
): Promise<SubmitContentForReviewResult> => {
const resolved = await resolvePartnerFromRequest(event);
if ('error' in resolved) return errorResponse(resolved.error);
const parsed = submitContentForReviewSchema.safeParse(event.body);
if (!parsed.success) {
return errorResponse(parsed.error.issues[0]?.message ?? 'invalid_input');
}
try {
const client = buildAppClient();
const content = await queryContentOwnerAndStatus(client, parsed.data.recordId);
if (!content || content.partnerId !== resolved.partnerId) {
return errorResponse('FORBIDDEN');
}
if (!canSubmitForReview(content.status)) {
return errorResponse('NOT_SUBMITTABLE');
}
await client.mutation({
updatePartnerContent: {
__args: {
id: parsed.data.recordId,
data: { status: 'UNDER_CUSTOMER_PARTNER_REVIEW' },
},
id: true,
},
});
return { ok: true, status: 'UNDER_CUSTOMER_PARTNER_REVIEW' };
} catch (err) {
return failureResponse('submit-partner-content-for-review', err);
}
};
export default defineLogicFunction({
universalIdentifier: SUBMIT_PARTNER_CONTENT_FOR_REVIEW_ID,
name: 'submit-partner-content-for-review',
description:
"Flips the calling partner's own WIP case study/content to under-review.",
timeoutSeconds: 15,
handler,
httpRouteTriggerSettings: {
path: '/submit-partner-content-for-review',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -0,0 +1,16 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
import { MY_CASE_STUDIES_NAV_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { MY_CASE_STUDIES_PAGE_LAYOUT_ID } from 'src/constants/my-case-studies.constants';
import { PARTNER_WORKSPACE_FOLDER_UNIVERSAL_IDENTIFIER } from './partner-workspace-folder.navigation-menu-item';
export default defineNavigationMenuItem({
universalIdentifier: MY_CASE_STUDIES_NAV_UNIVERSAL_IDENTIFIER,
name: 'My Case Studies',
type: NavigationMenuItemType.PAGE_LAYOUT,
icon: 'IconBriefcase',
position: 4,
folderUniversalIdentifier: PARTNER_WORKSPACE_FOLDER_UNIVERSAL_IDENTIFIER,
pageLayoutUniversalIdentifier: MY_CASE_STUDIES_PAGE_LAYOUT_ID,
});
@@ -1,14 +1,18 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
import { MY_PROFILE_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/my-profile.view';
import {
MY_PROFILE_NAV_ITEM_ID,
MY_PROFILE_PAGE_LAYOUT_ID,
} from 'src/constants/my-profile.constants';
import { PARTNER_WORKSPACE_FOLDER_UNIVERSAL_IDENTIFIER } from './partner-workspace-folder.navigation-menu-item';
export default defineNavigationMenuItem({
universalIdentifier: '85c69095-516d-40b8-864d-f0a20f1ad88f',
type: NavigationMenuItemType.VIEW,
universalIdentifier: MY_PROFILE_NAV_ITEM_ID,
name: 'My Profile',
type: NavigationMenuItemType.PAGE_LAYOUT,
icon: 'IconUser',
position: 3,
folderUniversalIdentifier: PARTNER_WORKSPACE_FOLDER_UNIVERSAL_IDENTIFIER,
viewUniversalIdentifier: MY_PROFILE_VIEW_UNIVERSAL_IDENTIFIER,
pageLayoutUniversalIdentifier: MY_PROFILE_PAGE_LAYOUT_ID,
});
@@ -2,6 +2,33 @@ import { FieldType, defineObject } from 'twenty-sdk/define';
import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export const PARTNER_CONTENT_NAME_FIELD_ID =
'9e688624-83d2-4715-8b18-80492a6de2b6';
export const PARTNER_CONTENT_TYPE_FIELD_ID =
'1d926e6e-6ac1-4d60-ab3d-a73114005692';
export const PARTNER_CONTENT_STATUS_FIELD_ID =
'a0fe09c4-c1f4-4b96-93c6-d7ec38f1166a';
export const PARTNER_CONTENT_APPROVAL_DATE_FIELD_ID =
'b52d263e-423e-40b0-b82c-29214597c005';
export const PARTNER_CONTENT_INTERVIEW_FIELD_ID =
'da7e9094-e2c3-47d3-924f-a1d4d3c717ed';
export const PARTNER_CONTENT_DOCUMENTS_FIELD_ID =
'f303369e-288c-4a48-9920-c1de0ad9a159';
export const PARTNER_CONTENT_CLIENT_NAME_FIELD_ID =
'3c430cee-c5db-4bd0-8380-a551e6ba4f19';
export const PARTNER_CONTENT_HEADLINE_FIELD_ID =
'48937b8d-b8d6-4424-86f3-4ab18e83e9f5';
export const PARTNER_CONTENT_BODY_FIELD_ID =
'fca9e56b-a9a6-40dc-a5e0-7a2611d3febb';
export const PARTNER_CONTENT_COVER_IMAGE_FIELD_ID =
'6b1225d3-f666-4c7b-8309-ab95cd5f44ea';
export const PARTNER_CONTENT_COVER_IMAGE_URL_FIELD_ID =
'1d87e6cb-0cbe-4010-96a3-25e891774c5e';
export const PARTNER_CONTENT_CASE_STUDY_LINK_FIELD_ID =
'35e32a90-4df3-4741-b331-77ebdc8fdb80';
export const PARTNER_CONTENT_POSITION_FIELD_ID =
'37e96b80-7387-5254-8be7-028a19cc5a1e';
export default defineObject({
universalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
nameSingular: 'partnerContent',
@@ -11,10 +38,10 @@ export default defineObject({
description: 'Marketing content involving a partner: quotes, case studies, logos',
icon: 'IconQuote',
isSearchable: true,
labelIdentifierFieldMetadataUniversalIdentifier: '9e688624-83d2-4715-8b18-80492a6de2b6',
labelIdentifierFieldMetadataUniversalIdentifier: PARTNER_CONTENT_NAME_FIELD_ID,
fields: [
{
universalIdentifier: '9e688624-83d2-4715-8b18-80492a6de2b6',
universalIdentifier: PARTNER_CONTENT_NAME_FIELD_ID,
type: FieldType.TEXT,
name: 'name',
label: 'Name',
@@ -22,7 +49,7 @@ export default defineObject({
defaultValue: "''",
},
{
universalIdentifier: '1d926e6e-6ac1-4d60-ab3d-a73114005692',
universalIdentifier: PARTNER_CONTENT_TYPE_FIELD_ID,
type: FieldType.MULTI_SELECT,
name: 'contentType',
label: 'Content Type',
@@ -36,7 +63,7 @@ export default defineObject({
],
},
{
universalIdentifier: 'a0fe09c4-c1f4-4b96-93c6-d7ec38f1166a',
universalIdentifier: PARTNER_CONTENT_STATUS_FIELD_ID,
type: FieldType.SELECT,
name: 'status',
label: 'Status',
@@ -51,7 +78,7 @@ export default defineObject({
],
},
{
universalIdentifier: 'b52d263e-423e-40b0-b82c-29214597c005',
universalIdentifier: PARTNER_CONTENT_APPROVAL_DATE_FIELD_ID,
type: FieldType.DATE_TIME,
name: 'approvalDate',
label: 'Approval Date',
@@ -59,7 +86,7 @@ export default defineObject({
isNullable: true,
},
{
universalIdentifier: 'da7e9094-e2c3-47d3-924f-a1d4d3c717ed',
universalIdentifier: PARTNER_CONTENT_INTERVIEW_FIELD_ID,
type: FieldType.LINKS,
name: 'interview',
label: 'Interview',
@@ -67,7 +94,7 @@ export default defineObject({
isNullable: true,
},
{
universalIdentifier: 'f303369e-288c-4a48-9920-c1de0ad9a159',
universalIdentifier: PARTNER_CONTENT_DOCUMENTS_FIELD_ID,
type: FieldType.FILES,
name: 'documents',
label: 'Documents',
@@ -75,5 +102,54 @@ export default defineObject({
isNullable: true,
universalSettings: { maxNumberOfValues: 10 },
},
{
universalIdentifier: PARTNER_CONTENT_CLIENT_NAME_FIELD_ID,
type: FieldType.TEXT,
name: 'clientName',
label: 'Client Name',
icon: 'IconBuilding',
isNullable: true,
},
{
universalIdentifier: PARTNER_CONTENT_HEADLINE_FIELD_ID,
type: FieldType.TEXT,
name: 'headline',
label: 'Headline',
icon: 'IconSparkles',
isNullable: true,
},
{
universalIdentifier: PARTNER_CONTENT_BODY_FIELD_ID,
type: FieldType.RICH_TEXT,
name: 'body',
label: 'Body',
icon: 'IconFileText',
isNullable: true,
},
{
universalIdentifier: PARTNER_CONTENT_COVER_IMAGE_FIELD_ID,
type: FieldType.FILES,
name: 'coverImage',
label: 'Cover Image',
icon: 'IconPhoto',
isNullable: true,
universalSettings: { maxNumberOfValues: 1 },
},
{
universalIdentifier: PARTNER_CONTENT_COVER_IMAGE_URL_FIELD_ID,
type: FieldType.TEXT,
name: 'coverImageUrl',
label: 'Cover Image URL',
icon: 'IconPhoto',
isNullable: true,
},
{
universalIdentifier: PARTNER_CONTENT_CASE_STUDY_LINK_FIELD_ID,
type: FieldType.LINKS,
name: 'caseStudyLink',
label: 'Case Study Link',
icon: 'IconLink',
isNullable: true,
},
],
});
@@ -0,0 +1,50 @@
import { FieldType, defineObject } from 'twenty-sdk/define';
import { PARTNER_LINK_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export const PARTNER_LINK_NAME_FIELD_ID = '15aeb279-e836-491c-84a4-58a6da8685c7';
export const PARTNER_LINK_URL_FIELD_ID = 'e28d8614-f6d4-4da8-8543-4b233b7ec070';
export const PARTNER_LINK_SORT_ORDER_FIELD_ID =
'197a4153-ab7b-4c9b-87ab-3a6d849a0229';
export const PARTNER_LINK_POSITION_FIELD_ID =
'a770d910-1653-4707-8ab3-b3041fb6527c';
export default defineObject({
universalIdentifier: PARTNER_LINK_OBJECT_UNIVERSAL_IDENTIFIER,
nameSingular: 'partnerLink',
namePlural: 'partnerLinks',
labelSingular: 'Partner Link',
labelPlural: 'Partner Links',
description: 'Curated link shown on a partner profile',
icon: 'IconLink',
isSearchable: true,
labelIdentifierFieldMetadataUniversalIdentifier: PARTNER_LINK_NAME_FIELD_ID,
fields: [
{
universalIdentifier: PARTNER_LINK_NAME_FIELD_ID,
type: FieldType.TEXT,
name: 'name',
label: 'Name',
icon: 'IconTag',
defaultValue: "''",
},
{
universalIdentifier: PARTNER_LINK_URL_FIELD_ID,
type: FieldType.LINKS,
name: 'url',
label: 'URL',
icon: 'IconWorldWww',
isNullable: true,
},
{
// Partner-editable ordering for marketplace profile links. POSITION is system-managed
// and not editable in the UI — partners set sortOrder instead.
universalIdentifier: PARTNER_LINK_SORT_ORDER_FIELD_ID,
type: FieldType.NUMBER,
name: 'sortOrder',
label: 'Sort order',
icon: 'IconSortAscending',
isNullable: true,
},
],
});
@@ -0,0 +1,52 @@
import { FieldType, defineObject } from 'twenty-sdk/define';
import { PARTNER_SERVICE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export const PARTNER_SERVICE_TITLE_FIELD_ID =
'31b8e0aa-a254-4d0d-a3db-bb56422f0d69';
export const PARTNER_SERVICE_DESCRIPTION_FIELD_ID =
'dc1f7fa5-0468-4483-89a2-3da8362df86f';
export const PARTNER_SERVICE_SORT_ORDER_FIELD_ID =
'dd8a1f0a-9039-4a95-9e29-685c28a44205';
export const PARTNER_SERVICE_POSITION_FIELD_ID =
'1d0ff71b-17da-4260-b8ec-e4e992430547';
export default defineObject({
universalIdentifier: PARTNER_SERVICE_OBJECT_UNIVERSAL_IDENTIFIER,
nameSingular: 'partnerService',
namePlural: 'partnerServices',
labelSingular: 'Partner Service',
labelPlural: 'Partner Services',
description: 'Service offered by a partner',
icon: 'IconTool',
isSearchable: true,
labelIdentifierFieldMetadataUniversalIdentifier: PARTNER_SERVICE_TITLE_FIELD_ID,
fields: [
{
universalIdentifier: PARTNER_SERVICE_TITLE_FIELD_ID,
type: FieldType.TEXT,
name: 'title',
label: 'Title',
icon: 'IconTag',
defaultValue: "''",
},
{
universalIdentifier: PARTNER_SERVICE_DESCRIPTION_FIELD_ID,
type: FieldType.TEXT,
name: 'description',
label: 'Description',
icon: 'IconFileText',
isNullable: true,
},
{
// Partner-editable ordering for marketplace services. POSITION is system-managed
// and not editable in the UI — partners set sortOrder instead.
universalIdentifier: PARTNER_SERVICE_SORT_ORDER_FIELD_ID,
type: FieldType.NUMBER,
name: 'sortOrder',
label: 'Sort order',
icon: 'IconSortAscending',
isNullable: true,
},
],
});
@@ -0,0 +1,35 @@
import { PageLayoutTabLayoutMode, definePageLayout } from 'twenty-sdk/define';
import {
MY_CASE_STUDIES_FRONT_COMPONENT_ID,
MY_CASE_STUDIES_PAGE_LAYOUT_ID,
MY_CASE_STUDIES_PAGE_TAB_ID,
MY_CASE_STUDIES_PAGE_WIDGET_ID,
} from 'src/constants/my-case-studies.constants';
export default definePageLayout({
universalIdentifier: MY_CASE_STUDIES_PAGE_LAYOUT_ID,
name: 'My Case Studies',
type: 'STANDALONE_PAGE',
tabs: [
{
universalIdentifier: MY_CASE_STUDIES_PAGE_TAB_ID,
title: 'My Case Studies',
position: 0,
icon: 'IconBriefcase',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier: MY_CASE_STUDIES_PAGE_WIDGET_ID,
title: 'My Case Studies',
type: 'FRONT_COMPONENT',
gridPosition: { row: 0, column: 0, rowSpan: 14, columnSpan: 12 },
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier: MY_CASE_STUDIES_FRONT_COMPONENT_ID,
},
},
],
},
],
});
@@ -0,0 +1,35 @@
import { PageLayoutTabLayoutMode, definePageLayout } from 'twenty-sdk/define';
import {
MY_PROFILE_FRONT_COMPONENT_ID,
MY_PROFILE_PAGE_LAYOUT_ID,
MY_PROFILE_PAGE_TAB_ID,
MY_PROFILE_PAGE_WIDGET_ID,
} from 'src/constants/my-profile.constants';
export default definePageLayout({
universalIdentifier: MY_PROFILE_PAGE_LAYOUT_ID,
name: 'My Profile',
type: 'STANDALONE_PAGE',
tabs: [
{
universalIdentifier: MY_PROFILE_PAGE_TAB_ID,
title: 'My Profile',
position: 0,
icon: 'IconUser',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier: MY_PROFILE_PAGE_WIDGET_ID,
title: 'My Profile',
type: 'FRONT_COMPONENT',
gridPosition: { row: 0, column: 0, rowSpan: 14, columnSpan: 12 },
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier: MY_PROFILE_FRONT_COMPONENT_ID,
},
},
],
},
],
});
@@ -0,0 +1,46 @@
import { PageLayoutTabLayoutMode, definePageLayout } from 'twenty-sdk/define';
import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { PARTNER_CONTENT_RECORD_PAGE_FIELDS_VIEW_ID } from 'src/views/partner-content-record-page-fields.view';
export default definePageLayout({
universalIdentifier: '4d1f8ad2-6dcd-4f14-b2bc-3d16bb9dc462',
name: 'Default Partner Content Layout',
type: 'RECORD_PAGE',
objectUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
tabs: [
{
universalIdentifier: '24a33b22-6ef5-4d3f-88c4-a0f6cf43bfff',
title: 'Home',
position: 10,
icon: 'IconHome',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [
{
universalIdentifier: 'd5833c26-aa65-418d-a359-b420369990f6',
title: 'Fields',
type: 'FIELDS',
configuration: {
configurationType: 'FIELDS',
viewUniversalIdentifier: PARTNER_CONTENT_RECORD_PAGE_FIELDS_VIEW_ID,
},
},
],
},
{
universalIdentifier: '39292e48-e6df-43df-b1b8-7e367daa382a',
title: 'Timeline',
position: 20,
icon: 'IconTimelineEvent',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier: '1d293bf5-f6d4-41c4-a6f6-db7955759f35',
title: 'Timeline',
type: 'TIMELINE',
configuration: { configurationType: 'TIMELINE' },
},
],
},
],
});
@@ -0,0 +1,46 @@
import { PageLayoutTabLayoutMode, definePageLayout } from 'twenty-sdk/define';
import { PARTNER_LINK_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { PARTNER_LINK_RECORD_PAGE_FIELDS_VIEW_ID } from 'src/views/partner-link-record-page-fields.view';
export default definePageLayout({
universalIdentifier: 'e2b44276-babe-4f29-a94d-8fa220fe6483',
name: 'Default Partner Link Layout',
type: 'RECORD_PAGE',
objectUniversalIdentifier: PARTNER_LINK_OBJECT_UNIVERSAL_IDENTIFIER,
tabs: [
{
universalIdentifier: '73c37c28-a229-4209-b55c-3038ecff6c14',
title: 'Home',
position: 10,
icon: 'IconHome',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [
{
universalIdentifier: 'b0051194-ce2a-46ca-b593-a841404e10e7',
title: 'Fields',
type: 'FIELDS',
configuration: {
configurationType: 'FIELDS',
viewUniversalIdentifier: PARTNER_LINK_RECORD_PAGE_FIELDS_VIEW_ID,
},
},
],
},
{
universalIdentifier: '443b1720-448e-40a6-a920-a6c18ca5d012',
title: 'Timeline',
position: 20,
icon: 'IconTimelineEvent',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier: 'b4f89755-01da-457a-b2a2-63deeba89dc2',
title: 'Timeline',
type: 'TIMELINE',
configuration: { configurationType: 'TIMELINE' },
},
],
},
],
});
@@ -0,0 +1,46 @@
import { PageLayoutTabLayoutMode, definePageLayout } from 'twenty-sdk/define';
import { PARTNER_SERVICE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { PARTNER_SERVICE_RECORD_PAGE_FIELDS_VIEW_ID } from 'src/views/partner-service-record-page-fields.view';
export default definePageLayout({
universalIdentifier: '7032692b-63bd-432c-acac-a6e368adbff9',
name: 'Default Partner Service Layout',
type: 'RECORD_PAGE',
objectUniversalIdentifier: PARTNER_SERVICE_OBJECT_UNIVERSAL_IDENTIFIER,
tabs: [
{
universalIdentifier: '7fd356ad-7ab8-4bb7-b507-bc7d93b25c87',
title: 'Home',
position: 10,
icon: 'IconHome',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [
{
universalIdentifier: 'e7de8b6b-9fba-4215-a40b-de656a46cd7c',
title: 'Fields',
type: 'FIELDS',
configuration: {
configurationType: 'FIELDS',
viewUniversalIdentifier: PARTNER_SERVICE_RECORD_PAGE_FIELDS_VIEW_ID,
},
},
],
},
{
universalIdentifier: '7810fdf5-6a3c-4dea-9336-1ed25b8b0940',
title: 'Timeline',
position: 20,
icon: 'IconTimelineEvent',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier: '87ad1f2b-63f0-4f15-a098-e4bc911feb58',
title: 'Timeline',
type: 'TIMELINE',
configuration: { configurationType: 'TIMELINE' },
},
],
},
],
});
@@ -1,9 +1,16 @@
import { STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineRole } from 'twenty-sdk/define';
import {
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
SystemPermissionFlag,
defineRole,
} from 'twenty-sdk/define';
import {
INTRO_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER,
PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
PARTNER_LINK_OBJECT_UNIVERSAL_IDENTIFIER,
PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
PARTNER_ROLE_UNIVERSAL_IDENTIFIER,
PARTNER_SERVICE_OBJECT_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
import {
APPLICATION_LAST_ACTIVITY_AT_FIELD_ID,
@@ -14,6 +21,14 @@ import {
APPLICATION_STATE_FIELD_ID,
APPLICATIONS_ON_OPPORTUNITY_FIELD_ID,
} from 'src/objects/application.object';
import {
PARTNER_CONTENT_APPROVAL_DATE_FIELD_ID,
PARTNER_CONTENT_DOCUMENTS_FIELD_ID,
PARTNER_CONTENT_INTERVIEW_FIELD_ID,
PARTNER_CONTENT_STATUS_FIELD_ID,
PARTNER_CONTENT_TYPE_FIELD_ID,
} from 'src/objects/partner-content.object';
import { PARTNER_CONTENT_PARTNER_FIELD_ID } from 'src/fields/partner-content-partner.field';
import { OPPORTUNITY_DESIGN_DOC_STATUS_FIELD_ID } from 'src/fields/opportunity-design-doc-status.field';
import { OPPORTUNITY_DESIGN_DOC_URL_FIELD_ID } from 'src/fields/opportunity-design-doc-url.field';
import { OPPORTUNITY_HOSTING_TYPE_FIELD_ID } from 'src/fields/opportunity-hosting-type.field';
@@ -27,9 +42,14 @@ import { OPPORTUNITY_SUBSCRIPTION_TYPE_FIELD_ID } from 'src/fields/opportunity-s
import { OPPORTUNITY_TFT_ID_FIELD_ID } from 'src/fields/opportunity-tft-id.field';
import { OPPORTUNITY_USE_CASE_FIELD_ID } from 'src/fields/opportunity-use-case.field';
import { PARTNER_COMPANY_FIELD_ID } from 'src/fields/partner-company.field';
import { PARTNER_LINK_PARTNER_FIELD_ID } from 'src/fields/partner-link-partner.field';
import { PARTNER_ON_OPPORTUNITY_FIELD_ID } from 'src/fields/partner-on-opportunity.field';
import { PARTNER_SERVICE_PARTNER_FIELD_ID } from 'src/fields/partner-service-partner.field';
import { PARTNER_USER_ON_OPPORTUNITY_FIELD_ID } from 'src/fields/partner-user-on-opportunity.field';
import { PARTNER_USER_ON_PARTNER_CONTENT_FIELD_ID } from 'src/fields/partner-user-on-partner-content.field';
import { PARTNER_USER_ON_PARTNER_LINK_FIELD_ID } from 'src/fields/partner-user-on-partner-link.field';
import { PARTNER_USER_ON_PARTNER_FIELD_ID } from 'src/fields/partner-user-on-partner.field';
import { PARTNER_USER_ON_PARTNER_SERVICE_FIELD_ID } from 'src/fields/partner-user-on-partner-service.field';
// Shared with configure-partner-rls.ts, which locates the role by this label.
export const PARTNER_ROLE_LABEL = 'Partner';
@@ -50,7 +70,7 @@ export default defineRole({
universalIdentifier: PARTNER_ROLE_UNIVERSAL_IDENTIFIER,
label: PARTNER_ROLE_LABEL,
description:
'External partner self-service role. Sees only its own Partner/Person/Company/Opportunity/Application records (row-level). Can edit its own Partner profile and an Applications pitch; Opportunity stage/amount are read-only. Configure predicates with `yarn rls:configure` after install.',
'External partner self-service role. Sees only its own Partner/Person/Company/PartnerLink/PartnerService/PartnerContent/Opportunity/Application records (row-level). Can edit its own Partner profile and an Applications pitch; Opportunity stage/amount are read-only. Configure predicates with `yarn rls:configure` after install.',
icon: 'IconBuildingStore',
canBeAssignedToUsers: true,
canUpdateAllSettings: false,
@@ -58,11 +78,9 @@ export default defineRole({
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
// No permission flags: partners apply by CREATING an Application directly (a normal record
// write, governed by the Application object permission), which fires on-application-created.
// They never run a manual workflow, so the WORKFLOWS flag is unnecessary — and it can't be
// granted on an app-owned role anyway (the manifest sync drops role→permissionFlag links).
permissionFlagUniversalIdentifiers: [],
// UPLOAD_FILE lets partners upload their own profile picture and case-study covers on the
// native record page (the FilesField upload mutation is gated behind this flag).
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.UPLOAD_FILE],
// Lock every Opportunity field except the system/server-managed fields left out here
// (id, timestamps, updatedBy, position — see header). Stage + amount are locked too.
fieldPermissions: [
@@ -322,6 +340,73 @@ export default defineRole({
fieldUniversalIdentifier: PARTNER_COMPANY_FIELD_ID,
canUpdateFieldValue: false,
},
// Partner Link object — both relation pivots are read-only for partners. Repointing either
// would move links out of RLS scope or let a partner attach links to another profile.
{
objectUniversalIdentifier: PARTNER_LINK_OBJECT_UNIVERSAL_IDENTIFIER,
fieldUniversalIdentifier: PARTNER_LINK_PARTNER_FIELD_ID,
canUpdateFieldValue: false,
},
{
objectUniversalIdentifier: PARTNER_LINK_OBJECT_UNIVERSAL_IDENTIFIER,
fieldUniversalIdentifier: PARTNER_USER_ON_PARTNER_LINK_FIELD_ID,
canUpdateFieldValue: false,
},
// Partner Service object — both relation pivots are read-only for partners. Repointing either
// would move services out of RLS scope or let a partner attach services to another profile.
{
objectUniversalIdentifier: PARTNER_SERVICE_OBJECT_UNIVERSAL_IDENTIFIER,
fieldUniversalIdentifier: PARTNER_SERVICE_PARTNER_FIELD_ID,
canUpdateFieldValue: false,
},
{
objectUniversalIdentifier: PARTNER_SERVICE_OBJECT_UNIVERSAL_IDENTIFIER,
fieldUniversalIdentifier: PARTNER_USER_ON_PARTNER_SERVICE_FIELD_ID,
canUpdateFieldValue: false,
},
// Partner Content object — the self-service save route runs with the caller's own
// permissions, so partners self-publish their own case studies: only status is writable
// (the route sets it to APPROVED/WIP; RLS scopes to their own rows). Ownership (partner,
// partnerUser) and contentType stay locked and are stamped server-side by the
// on-partner-content-created trigger, so a partner cannot repoint content to another partner.
{
objectUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
fieldUniversalIdentifier: PARTNER_CONTENT_TYPE_FIELD_ID,
canUpdateFieldValue: false,
},
{
objectUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
fieldUniversalIdentifier: PARTNER_CONTENT_APPROVAL_DATE_FIELD_ID,
canReadFieldValue: false,
canUpdateFieldValue: false,
},
{
objectUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
fieldUniversalIdentifier: PARTNER_CONTENT_INTERVIEW_FIELD_ID,
canReadFieldValue: false,
canUpdateFieldValue: false,
},
{
objectUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
fieldUniversalIdentifier: PARTNER_CONTENT_DOCUMENTS_FIELD_ID,
canReadFieldValue: false,
canUpdateFieldValue: false,
},
{
objectUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
fieldUniversalIdentifier: PARTNER_CONTENT_STATUS_FIELD_ID,
canUpdateFieldValue: true,
},
{
objectUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
fieldUniversalIdentifier: PARTNER_CONTENT_PARTNER_FIELD_ID,
canUpdateFieldValue: false,
},
{
objectUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
fieldUniversalIdentifier: PARTNER_USER_ON_PARTNER_CONTENT_FIELD_ID,
canUpdateFieldValue: false,
},
// Application — lock every field except pitch and opportunity (partner sets opportunity
// on apply/create; state/partnerUser are populated by on-application-created as the app).
// System/server-managed fields (id, timestamps, updatedBy, position, searchVector) stay
@@ -391,6 +476,27 @@ export default defineRole({
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
objectUniversalIdentifier: PARTNER_LINK_OBJECT_UNIVERSAL_IDENTIFIER,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
objectUniversalIdentifier: PARTNER_SERVICE_OBJECT_UNIVERSAL_IDENTIFIER,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
objectUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
// Read-only so the UI can resolve member-typed relations (own partnerUser link,
// owner/createdBy). An RLS predicate scopes this to the partner's own member record
@@ -0,0 +1,97 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import {
stampPartnerUserFromPartner,
type PartnerChildObject,
} from 'src/logic-functions/stamp-partner-user-on-child';
const PAGE_SIZE = 200;
const CHILD_QUERIES: {
childObject: PartnerChildObject;
listKey: 'partnerLinks' | 'partnerServices' | 'partnerContents';
}[] = [
{ childObject: 'partnerLink', listKey: 'partnerLinks' },
{ childObject: 'partnerService', listKey: 'partnerServices' },
{ childObject: 'partnerContent', listKey: 'partnerContents' },
];
type Connection<T> = {
edges?: { node: T }[];
pageInfo?: { hasNextPage?: boolean; endCursor?: string | null };
};
const connectionOf = <T>(response: unknown, key: string): Connection<T> =>
(response as Record<string, Connection<T>>)[key] ?? {};
// Walk every page so a workspace with more than one page of partners (or a partner
// with more than one page of children) is fully stamped instead of silently truncated.
const queryAllNodes = async <T>(
runPage: (after: string | null) => Promise<Connection<T>>,
): Promise<T[]> => {
const all: T[] = [];
let after: string | null = null;
for (;;) {
const page = await runPage(after);
for (const edge of page.edges ?? []) all.push(edge.node);
const nextCursor = page.pageInfo?.hasNextPage ? page.pageInfo.endCursor : null;
if (!nextCursor) break;
after = nextCursor;
}
return all;
};
export async function backfillPartnerUserOnChildren(
client: CoreApiClient,
): Promise<number> {
const partners = await queryAllNodes<{ id: string; partnerUserId: string | null }>(
(after) =>
client
.query({
partners: {
__args: { first: PAGE_SIZE, ...(after ? { after } : {}) },
edges: { node: { id: true, partnerUserId: true } },
pageInfo: { hasNextPage: true, endCursor: true },
},
})
.then((res) => connectionOf(res, 'partners')),
);
let stamped = 0;
for (const partner of partners) {
if (!partner.partnerUserId) {
continue;
}
for (const { childObject, listKey } of CHILD_QUERIES) {
const children = await queryAllNodes<{ id: string; partnerUserId: string | null }>(
(after) =>
client
.query({
[listKey]: {
__args: {
filter: { partnerId: { eq: partner.id } },
first: PAGE_SIZE,
...(after ? { after } : {}),
},
edges: { node: { id: true, partnerUserId: true } },
pageInfo: { hasNextPage: true, endCursor: true },
},
})
.then((res) => connectionOf(res, listKey)),
);
for (const child of children) {
if (child.partnerUserId === partner.partnerUserId) {
continue;
}
await stampPartnerUserFromPartner(client, partner.id, childObject, child.id);
stamped++;
}
}
}
return stamped;
}
@@ -2,7 +2,7 @@
// predicates. Does three things:
//
// 1. Upserts row-level-permission predicates on the Partner role:
// - "partnerUser IS the current member" on partner/person/company
// - "partnerUser IS the current member" on partner/person/company/partnerLink/partnerService/partnerContent
// - "(partnerUser IS me) OR (lastActivityAt IS EMPTY)" on application. RLS is validated on
// INSERT against the row exactly as submitted, but a partner's Apply workflow creates the
// row with partnerUser=null (on-application-created stamps it AFTER insert, as the app).
@@ -39,7 +39,14 @@ const requireEnv = (name: string): string => {
return value;
};
const SIMPLE_TARGET_OBJECTS = ['partner', 'person', 'company'] as const;
const SIMPLE_TARGET_OBJECTS = [
'partner',
'person',
'company',
'partnerLink',
'partnerService',
'partnerContent',
] as const;
type SimpleTargetObject = (typeof SIMPLE_TARGET_OBJECTS)[number];
// application + opportunity use OR groups (handled separately), but still need existence checks.
@@ -436,6 +443,41 @@ async function main() {
}
`;
const upsertPredicates = async (
input: UpsertPredicatesInput,
label: string,
): Promise<PredicateResult[]> => {
try {
const data = await metadataFetch<{
upsertRowLevelPermissionPredicates: { predicates: PredicateResult[] };
}>(metadataUrl, apiKey, MUTATION, { input });
return data.upsertRowLevelPermissionPredicates.predicates;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const canRetryWithoutGroups =
input.predicateGroups.length > 0 &&
message.includes('rowLevelPermissionPredicateGroup');
if (!canRetryWithoutGroups) {
throw error;
}
console.log(
`[rls:configure] ${label}: predicate group upsert failed; ` +
'retrying with predicates only (group already exists)',
);
const data = await metadataFetch<{
upsertRowLevelPermissionPredicates: { predicates: PredicateResult[] };
}>(metadataUrl, apiKey, MUTATION, {
input: { ...input, predicateGroups: [] },
});
return data.upsertRowLevelPermissionPredicates.predicates;
}
};
const results: PredicateResult[] = [];
for (const name of SIMPLE_TARGET_OBJECTS) {
@@ -478,10 +520,8 @@ async function main() {
// Opportunity: (partnerUser IS me) OR (isListed = true) — listed briefs visible to all partners.
{
const oppData = await metadataFetch<{
upsertRowLevelPermissionPredicates: { predicates: PredicateResult[] };
}>(metadataUrl, apiKey, MUTATION, {
input: {
const oppPredicates = await upsertPredicates(
{
roleId: partnerRole.id,
objectMetadataId: opportunityObjectId,
predicateGroups: [
@@ -509,10 +549,8 @@ async function main() {
},
],
} satisfies UpsertPredicatesInput,
});
const oppPredicates =
oppData.upsertRowLevelPermissionPredicates.predicates;
'opportunity',
);
if (oppPredicates.length < 2) {
throw new Error(
@@ -536,10 +574,8 @@ async function main() {
// on lastActivityAt resolves to `{ is: 'NULL' }`, which is unambiguous on both the insert
// check and the SQL read path (a relation IS_EMPTY is riskier there). See header for the leak.
{
const appData = await metadataFetch<{
upsertRowLevelPermissionPredicates: { predicates: PredicateResult[] };
}>(metadataUrl, apiKey, MUTATION, {
input: {
const appPredicates = await upsertPredicates(
{
roleId: partnerRole.id,
objectMetadataId: applicationObjectIdForPredicate,
predicateGroups: [
@@ -566,10 +602,8 @@ async function main() {
},
],
} satisfies UpsertPredicatesInput,
});
const appPredicates =
appData.upsertRowLevelPermissionPredicates.predicates;
'application',
);
if (appPredicates.length < 2) {
throw new Error(
@@ -15,6 +15,8 @@ config({ path: process.env.ENV_FILE ?? '.env.local' });
import { CoreApiClient } from 'twenty-client-sdk/core';
import { backfillPartnerUserOnChildren } from './backfill-partner-user-on-children';
const requireEnv = (name: string): string => {
const value = process.env[name];
if (!value) throw new Error(`Missing ${name} env var`);
@@ -22,6 +24,162 @@ const requireEnv = (name: string): string => {
};
const CAL = 'https://calendly.com/placeholder';
const PRIMARY_DEMO_PARTNER_SLUG = 'nine-dots-ventures';
const PRIMARY_DEMO_BUDGET_USD = 15000;
const PRIMARY_DEMO_DESCRIPTION_MARKDOWN = `## About Nine Dots Ventures
Nine Dots helps scaling teams launch Twenty as an operational system of record, not just a CRM.
### What we typically deliver
- Discovery workshops to align sales, operations, and leadership.
- End-to-end pipeline design with custom lifecycle stages.
- Migration plans that preserve historical activities and account context.
- Team onboarding with adoption dashboards and weekly office hours.
### Why clients pick us
We combine CRM architecture, workflow automation, and enablement so teams ship quickly and keep improving after go-live.`;
const ELEVATE_DESCRIPTION_MARKDOWN = `## About Elevate Consulting
Elevate is a revenue-operations studio for **B2B SaaS teams** moving from seed to Series C. We replace brittle CRM setups with Twenty pipelines your GTM team actually maintains.
### Typical engagement
1. **Audit week** — map objects, stages, and reporting gaps across sales, CS, and finance.
2. **Migration sprint** — Salesforce or HubSpot cutover with dedupe rules and activity history preserved.
3. **RevOps handoff** — manager dashboards, forecast cadences, and playbooks your team owns.
### Focus areas
- Pipeline design for PLG + sales-assist motions
- Lead routing, SLAs, and lifecycle automation
- Executive reporting without a separate BI stack
> Most clients go live in four weeks with a phased rollout that keeps reps selling during migration.`;
const MERIDIAN_DESCRIPTION_MARKDOWN = `## About Meridian Craft
Meridian Craft is an **APAC implementation studio** for fintech and logistics operators running high-volume customer workflows on Twenty.
### What we build
- Multi-entity data models with strict permission boundaries
- Throughput-tuned deployments for **self-hosted** and cloud workspaces
- Custom integrations with banking, KYC, and carrier APIs
### Delivery model
| Phase | Output |
| --- | --- |
| Discovery | Architecture memo + cutover plan |
| Build | Configured workspace, integrations, QA scripts |
| Launch | Runbooks, on-call handoff, performance baseline |
Our senior engineers have shipped regulated workloads across Singapore, Hong Kong, and Kuala Lumpur — we know where CRM projects break at scale.`;
type DemoPartnerService = {
title: string;
description: string;
position: number;
};
const PRIMARY_DEMO_PARTNER_SERVICES: DemoPartnerService[] = [
{
title: 'CRM architecture and implementation',
description:
'Designs the full object model, lifecycle stages, and permissions so teams can run on Twenty with clean data from day one.',
position: 0,
},
{
title: 'Data migration and quality hardening',
description:
'Migrates legacy CRM data with mapping validation, deduplication rules, and QA checkpoints before cutover.',
position: 1,
},
{
title: 'Workflow automation and integrations',
description:
'Builds automations for lead routing, follow-ups, and third-party syncs with finance, support, and communications systems.',
position: 2,
},
{
title: 'Enablement and revenue operations coaching',
description:
'Runs role-based onboarding, manager cadences, and KPI reviews to drive long-term adoption after launch.',
position: 3,
},
];
type DemoPartnerContent = {
name: string;
status: 'WIP' | 'APPROVED';
clientName: string;
headline: string;
bodyMarkdown: string;
caseStudyUrl: string;
position: number;
};
const PRIMARY_DEMO_PARTNER_CASE_STUDIES: DemoPartnerContent[] = [
{
name: 'Nine Dots - Acme rollout',
status: 'APPROVED',
clientName: 'Acme Real Estate',
headline: 'Unified tenant and broker operations in one workspace',
bodyMarkdown:
'Migrated sales and account-management teams from two CRMs into Twenty, reducing pipeline update lag from days to hours.',
caseStudyUrl: 'https://ninedots.example.com/case-studies/acme-rollout',
position: 0,
},
{
name: 'Nine Dots - Sunrise expansion',
status: 'WIP',
clientName: 'Sunrise Logistics',
headline: 'Standardizing regional handoffs across APAC and LATAM',
bodyMarkdown:
'In progress: rebuilding qualification and renewal workflows so cross-region teams share a single opportunity timeline.',
caseStudyUrl:
'https://ninedots.example.com/case-studies/sunrise-expansion',
position: 1,
},
{
name: 'Nine Dots - Helix reporting',
status: 'APPROVED',
clientName: 'Helix Bio',
headline: 'Board-ready reporting with faster forecast updates',
bodyMarkdown:
'Implemented a custom forecasting flow and weekly pipeline reviews, giving leadership reliable stage-by-stage visibility.',
caseStudyUrl: 'https://ninedots.example.com/case-studies/helix-reporting',
position: 2,
},
];
type DemoPartnerLink = {
name: string;
url: string;
position: number;
};
const PRIMARY_DEMO_PARTNER_LINKS: DemoPartnerLink[] = [
{
name: 'Company website',
url: 'https://ninedots.example.com',
position: 0,
},
{
name: 'Customer stories',
url: 'https://ninedots.example.com/case-studies',
position: 1,
},
{
name: 'Implementation playbook',
url: 'https://ninedots.example.com/playbook',
position: 2,
},
];
const usd = (dollars: number) => ({
amountMicros: dollars * 1_000_000,
currencyCode: 'USD',
@@ -110,6 +268,12 @@ const QUOTES: Quote[] = [
const nodes = (r: any, key: string): any[] => (r?.[key]?.edges ?? []).map((e: any) => e.node);
const withPartnerUserId = (
data: Record<string, unknown>,
partnerUserId: string | null,
): Record<string, unknown> =>
partnerUserId ? { ...data, partnerUserId } : data;
async function main() {
const client = new CoreApiClient({
url: `${requireEnv('TWENTY_PARTNERS_API_URL').replace(/\/$/, '')}/graphql`,
@@ -188,10 +352,258 @@ async function main() {
}
console.log(`[seed] opportunities: ${oppIdByName.size}`);
// -- Primary demo partner marketplace-rich profile --
const primaryDemoPartner = await client.query({
partners: {
__args: {
filter: { slug: { eq: PRIMARY_DEMO_PARTNER_SLUG } },
first: 1,
},
edges: {
node: {
id: true,
partnerUserId: true,
projectBudgetMin: { amountMicros: true },
},
},
},
});
const primaryDemoPartnerNode = nodes(primaryDemoPartner, 'partners')[0] as {
id?: string;
partnerUserId?: string | null;
projectBudgetMin?: { amountMicros?: number | null } | null;
};
if (primaryDemoPartnerNode?.id) {
const primaryDemoPartnerId = primaryDemoPartnerNode.id;
const primaryDemoPartnerUserId = primaryDemoPartnerNode.partnerUserId ?? null;
const primaryDemoPartnerData: Record<string, unknown> = {
introduction: PRIMARY_DEMO_DESCRIPTION_MARKDOWN,
};
const hasProjectBudgetMin =
(primaryDemoPartnerNode.projectBudgetMin?.amountMicros ?? 0) > 0;
if (!hasProjectBudgetMin) {
primaryDemoPartnerData.projectBudgetMin = usd(PRIMARY_DEMO_BUDGET_USD);
}
await client.mutation({
updatePartner: {
__args: {
id: primaryDemoPartnerId,
data: primaryDemoPartnerData,
},
id: true,
},
});
for (const service of PRIMARY_DEMO_PARTNER_SERVICES) {
const existing = nodes(
await client.query({
partnerServices: {
__args: {
filter: {
title: { eq: service.title },
partnerId: { eq: primaryDemoPartnerId },
},
first: 1,
},
edges: { node: { id: true } },
},
}),
'partnerServices',
);
const data = withPartnerUserId(
{
title: service.title,
description: service.description,
sortOrder: service.position,
position: service.position,
partnerId: primaryDemoPartnerId,
},
primaryDemoPartnerUserId,
);
if (existing[0]?.id) {
await client.mutation({
updatePartnerService: {
__args: { id: existing[0].id, data },
id: true,
},
});
} else {
await client.mutation({
createPartnerService: {
__args: {
data,
},
id: true,
},
});
}
}
for (const content of PRIMARY_DEMO_PARTNER_CASE_STUDIES) {
const existing = nodes(
await client.query({
partnerContents: {
__args: {
filter: {
name: { eq: content.name },
partnerId: { eq: primaryDemoPartnerId },
},
first: 1,
},
edges: { node: { id: true } },
},
}),
'partnerContents',
);
const data = withPartnerUserId(
{
name: content.name,
contentType: ['CASE_STUDY'],
status: content.status,
clientName: content.clientName,
headline: content.headline,
body: { markdown: content.bodyMarkdown },
caseStudyLink: { primaryLinkUrl: content.caseStudyUrl },
position: content.position,
partnerId: primaryDemoPartnerId,
},
primaryDemoPartnerUserId,
);
if (existing[0]?.id) {
await client.mutation({
updatePartnerContent: {
__args: { id: existing[0].id, data },
id: true,
},
});
} else {
await client.mutation({
createPartnerContent: {
__args: {
data,
},
id: true,
},
});
}
}
for (const link of PRIMARY_DEMO_PARTNER_LINKS) {
const existing = nodes(
await client.query({
partnerLinks: {
__args: {
filter: {
name: { eq: link.name },
partnerId: { eq: primaryDemoPartnerId },
},
first: 1,
},
edges: { node: { id: true } },
},
}),
'partnerLinks',
);
const data = withPartnerUserId(
{
name: link.name,
url: { primaryLinkUrl: link.url },
sortOrder: link.position,
position: link.position,
partnerId: primaryDemoPartnerId,
},
primaryDemoPartnerUserId,
);
if (existing[0]?.id) {
await client.mutation({
updatePartnerLink: {
__args: { id: existing[0].id, data },
id: true,
},
});
} else {
await client.mutation({
createPartnerLink: {
__args: {
data,
},
id: true,
},
});
}
}
}
// -- Marketplace profile copy (rich markdown on a few list-visible partners) --
const marketplaceDescriptions: Record<string, string> = {
'elevate-consulting': ELEVATE_DESCRIPTION_MARKDOWN,
'meridian-craft': MERIDIAN_DESCRIPTION_MARKDOWN,
};
for (const [slug, markdown] of Object.entries(marketplaceDescriptions)) {
const partnerId = partnerIdBySlug.get(slug);
if (!partnerId) {
continue;
}
await client.mutation({
updatePartner: {
__args: {
id: partnerId,
data: { introduction: markdown },
},
id: true,
},
});
}
// -- Partner quotes (upsert by name) --
const partnerUserIdBySlug = new Map<string, string>(
nodes(
await client.query({
partners: {
__args: { first: 200 },
edges: {
node: { slug: true, partnerUserId: true },
},
},
}),
'partners',
)
.filter(
(partner: { slug: string; partnerUserId: string | null }) =>
partner.partnerUserId,
)
.map((partner: { slug: string; partnerUserId: string }) => [
partner.slug,
partner.partnerUserId,
]),
);
let quoteCount = 0;
for (const q of QUOTES) {
const data = { name: q.name, status: q.status, contentType: q.contentType, partnerId: partnerIdBySlug.get(q.partnerSlug) };
const partnerId = partnerIdBySlug.get(q.partnerSlug);
const partnerUserId = partnerUserIdBySlug.get(q.partnerSlug) ?? null;
const data = withPartnerUserId(
{
name: q.name,
status: q.status,
contentType: q.contentType,
partnerId,
},
partnerUserId,
);
const existing = nodes(await client.query({ partnerContents: { __args: { filter: { name: { eq: q.name } }, first: 1 }, edges: { node: { id: true } } } } as any), 'partnerContents');
if (existing[0]?.id) {
await client.mutation({ updatePartnerContent: { __args: { id: existing[0].id, data }, id: true } } as any);
@@ -201,6 +613,9 @@ async function main() {
quoteCount++;
}
console.log(`[seed] partner quotes: ${quoteCount}`);
const backfillCount = await backfillPartnerUserOnChildren(client);
console.log(`[seed] backfilled partnerUserId on ${backfillCount} child record(s)`);
}
main().catch((err) => {
@@ -1,28 +0,0 @@
import { ViewType, defineView } from 'twenty-sdk/define';
import {
PARTNER_AVAILABILITY_FIELD_UNIVERSAL_IDENTIFIER,
PARTNER_CALENDAR_LINK_FIELD_UNIVERSAL_IDENTIFIER,
PARTNER_INTRODUCTION_FIELD_UNIVERSAL_IDENTIFIER,
PARTNER_NAME_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/constants/partner-field-universal-identifiers';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export const MY_PROFILE_VIEW_UNIVERSAL_IDENTIFIER =
'183ab293-07ce-4be6-8dfc-436a058c36e9';
// Partner-facing profile view — the partner edits their own listing fields here.
export default defineView({
universalIdentifier: MY_PROFILE_VIEW_UNIVERSAL_IDENTIFIER,
name: 'My Profile',
icon: 'IconUser',
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
type: ViewType.TABLE,
position: 2,
fields: [
{ universalIdentifier: 'e23cbd41-dfc1-45d9-b385-e3ddd9d9e909', fieldMetadataUniversalIdentifier: PARTNER_NAME_FIELD_UNIVERSAL_IDENTIFIER, position: 0, isVisible: true, size: 200 },
{ universalIdentifier: 'ce9249d5-914e-458a-a115-e4daa09a6f95', fieldMetadataUniversalIdentifier: PARTNER_AVAILABILITY_FIELD_UNIVERSAL_IDENTIFIER, position: 1, isVisible: true, size: 140 },
{ universalIdentifier: '8aa8bca2-8b9d-4028-b003-ab3fe8233fd1', fieldMetadataUniversalIdentifier: PARTNER_INTRODUCTION_FIELD_UNIVERSAL_IDENTIFIER, position: 2, isVisible: true, size: 320 },
{ universalIdentifier: '4349740f-7c1a-4f20-b64f-36d53b18eef1', fieldMetadataUniversalIdentifier: PARTNER_CALENDAR_LINK_FIELD_UNIVERSAL_IDENTIFIER, position: 3, isVisible: true, size: 200 },
],
});
@@ -0,0 +1,74 @@
import { ViewType, defineView } from 'twenty-sdk/define';
import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { PARTNER_CONTENT_PARTNER_FIELD_ID } from 'src/fields/partner-content-partner.field';
import {
PARTNER_CONTENT_BODY_FIELD_ID,
PARTNER_CONTENT_CASE_STUDY_LINK_FIELD_ID,
PARTNER_CONTENT_CLIENT_NAME_FIELD_ID,
PARTNER_CONTENT_COVER_IMAGE_FIELD_ID,
PARTNER_CONTENT_HEADLINE_FIELD_ID,
PARTNER_CONTENT_NAME_FIELD_ID,
PARTNER_CONTENT_STATUS_FIELD_ID,
} from 'src/objects/partner-content.object';
export const PARTNER_CONTENT_RECORD_PAGE_FIELDS_VIEW_ID =
'a1b2c3d4-5e6f-4789-a012-3456789abcde';
// FIELDS_WIDGET view backing the Partner Content record page side panel.
export default defineView({
universalIdentifier: PARTNER_CONTENT_RECORD_PAGE_FIELDS_VIEW_ID,
name: 'Partner Content Record Page Fields',
objectUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
type: ViewType.FIELDS_WIDGET,
fields: [
{
universalIdentifier: 'b1f5cf01-b00b-4bd8-9eea-d9367de99ddc',
fieldMetadataUniversalIdentifier: PARTNER_CONTENT_NAME_FIELD_ID,
position: 0,
isVisible: true,
},
{
universalIdentifier: 'bb8fe4da-2a4e-4186-85e9-479ec9f6f456',
fieldMetadataUniversalIdentifier: PARTNER_CONTENT_CLIENT_NAME_FIELD_ID,
position: 1,
isVisible: true,
},
{
universalIdentifier: 'a77fb95d-34ad-4774-8a3c-4315aee80496',
fieldMetadataUniversalIdentifier: PARTNER_CONTENT_HEADLINE_FIELD_ID,
position: 2,
isVisible: true,
},
{
universalIdentifier: '0181ea5a-c230-4d34-92f2-f7ce336e1da3',
fieldMetadataUniversalIdentifier: PARTNER_CONTENT_BODY_FIELD_ID,
position: 3,
isVisible: true,
},
{
universalIdentifier: 'd621d2ab-6aef-48af-9642-ab044a54da23',
fieldMetadataUniversalIdentifier: PARTNER_CONTENT_COVER_IMAGE_FIELD_ID,
position: 4,
isVisible: true,
},
{
universalIdentifier: '3eca37d5-5222-4efe-bacb-2e475b672749',
fieldMetadataUniversalIdentifier: PARTNER_CONTENT_CASE_STUDY_LINK_FIELD_ID,
position: 5,
isVisible: true,
},
{
universalIdentifier: 'c0b717ae-3ffd-4753-bd76-e747afae1fcd',
fieldMetadataUniversalIdentifier: PARTNER_CONTENT_STATUS_FIELD_ID,
position: 6,
isVisible: true,
},
{
universalIdentifier: '18f3aae8-0e1b-4b2d-962d-a45f76a4ce7f',
fieldMetadataUniversalIdentifier: PARTNER_CONTENT_PARTNER_FIELD_ID,
position: 7,
isVisible: true,
},
],
});
@@ -4,6 +4,16 @@ import {
PARTNER_CONTENT_VIEW_UNIVERSAL_IDENTIFIER,
PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
import {
PARTNER_CONTENT_BODY_FIELD_ID,
PARTNER_CONTENT_CASE_STUDY_LINK_FIELD_ID,
PARTNER_CONTENT_CLIENT_NAME_FIELD_ID,
PARTNER_CONTENT_COVER_IMAGE_FIELD_ID,
PARTNER_CONTENT_HEADLINE_FIELD_ID,
PARTNER_CONTENT_NAME_FIELD_ID,
PARTNER_CONTENT_STATUS_FIELD_ID,
PARTNER_CONTENT_TYPE_FIELD_ID,
} from 'src/objects/partner-content.object';
// Index view for partner content.
export default defineView({
@@ -13,9 +23,53 @@ export default defineView({
objectUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
type: ViewType.TABLE,
fields: [
{ universalIdentifier: 'ad7b3702-b552-4355-afec-1e1e96d9f3df', fieldMetadataUniversalIdentifier: '9e688624-83d2-4715-8b18-80492a6de2b6', position: 0, isVisible: true },
{ universalIdentifier: 'a9bf3eaa-ec27-4a0a-8df2-e18c8f4239a7', fieldMetadataUniversalIdentifier: '1d926e6e-6ac1-4d60-ab3d-a73114005692', position: 1, isVisible: true },
{ universalIdentifier: '426e0c2d-449d-4a06-860b-0cfe0ed501e6', fieldMetadataUniversalIdentifier: 'a0fe09c4-c1f4-4b96-93c6-d7ec38f1166a', position: 2, isVisible: true },
{ universalIdentifier: 'fbd1f953-1dd2-4d0f-a239-148a0688fbff', fieldMetadataUniversalIdentifier: 'b52d263e-423e-40b0-b82c-29214597c005', position: 3, isVisible: true },
{
universalIdentifier: 'ad7b3702-b552-4355-afec-1e1e96d9f3df',
fieldMetadataUniversalIdentifier: PARTNER_CONTENT_NAME_FIELD_ID,
position: 0,
isVisible: true,
},
{
universalIdentifier: 'a9bf3eaa-ec27-4a0a-8df2-e18c8f4239a7',
fieldMetadataUniversalIdentifier: PARTNER_CONTENT_TYPE_FIELD_ID,
position: 1,
isVisible: true,
},
{
universalIdentifier: '9358ce8a-4a1e-478f-b4ef-860d5a2b8e9d',
fieldMetadataUniversalIdentifier: PARTNER_CONTENT_CLIENT_NAME_FIELD_ID,
position: 2,
isVisible: true,
},
{
universalIdentifier: 'f63a8f86-2eb6-4acf-8411-e74f8ab1f944',
fieldMetadataUniversalIdentifier: PARTNER_CONTENT_HEADLINE_FIELD_ID,
position: 3,
isVisible: true,
},
{
universalIdentifier: '5339bd09-3d8e-4fa1-a41d-c70d885f8674',
fieldMetadataUniversalIdentifier: PARTNER_CONTENT_BODY_FIELD_ID,
position: 4,
isVisible: true,
},
{
universalIdentifier: '678a6d18-c68e-4f74-ab73-20f6cc4ca4f1',
fieldMetadataUniversalIdentifier: PARTNER_CONTENT_COVER_IMAGE_FIELD_ID,
position: 5,
isVisible: true,
},
{
universalIdentifier: 'c80a987f-cf89-4228-965d-2b11f4aad8df',
fieldMetadataUniversalIdentifier: PARTNER_CONTENT_CASE_STUDY_LINK_FIELD_ID,
position: 6,
isVisible: true,
},
{
universalIdentifier: '426e0c2d-449d-4a06-860b-0cfe0ed501e6',
fieldMetadataUniversalIdentifier: PARTNER_CONTENT_STATUS_FIELD_ID,
position: 7,
isVisible: true,
},
],
});
@@ -0,0 +1,46 @@
import { ViewType, defineView } from 'twenty-sdk/define';
import { PARTNER_LINK_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { PARTNER_LINK_PARTNER_FIELD_ID } from 'src/fields/partner-link-partner.field';
import {
PARTNER_LINK_NAME_FIELD_ID,
PARTNER_LINK_SORT_ORDER_FIELD_ID,
PARTNER_LINK_URL_FIELD_ID,
} from 'src/objects/partner-link.object';
export const PARTNER_LINK_RECORD_PAGE_FIELDS_VIEW_ID =
'e8d1ba61-45fe-4055-9525-6c8bb68e4e0c';
// FIELDS_WIDGET view backing the Partner Link record page side panel.
export default defineView({
universalIdentifier: PARTNER_LINK_RECORD_PAGE_FIELDS_VIEW_ID,
name: 'Partner Link Record Page Fields',
objectUniversalIdentifier: PARTNER_LINK_OBJECT_UNIVERSAL_IDENTIFIER,
type: ViewType.FIELDS_WIDGET,
fields: [
{
universalIdentifier: '6e637b0a-14fb-4d15-9c6f-2b0746ad6620',
fieldMetadataUniversalIdentifier: PARTNER_LINK_NAME_FIELD_ID,
position: 0,
isVisible: true,
},
{
universalIdentifier: '20b89671-7933-4af4-9634-a254729cc2dd',
fieldMetadataUniversalIdentifier: PARTNER_LINK_URL_FIELD_ID,
position: 1,
isVisible: true,
},
{
universalIdentifier: '0ceaa571-61f3-4d27-8a1b-b10c6f1f3601',
fieldMetadataUniversalIdentifier: PARTNER_LINK_SORT_ORDER_FIELD_ID,
position: 2,
isVisible: true,
},
{
universalIdentifier: '0c996f3f-0184-42ef-976e-c15d84ff7fbf',
fieldMetadataUniversalIdentifier: PARTNER_LINK_PARTNER_FIELD_ID,
position: 3,
isVisible: true,
},
],
});
@@ -24,17 +24,26 @@ import {
PARTNER_WEBSITE_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/constants/partner-field-universal-identifiers';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import {
PARTNER_CONTENTS_ON_PARTNER_FIELD_ID,
} from 'src/fields/partner-content-partner.field';
import { PARTNER_COMPANY_FIELD_ID } from 'src/fields/partner-company.field';
import {
PARTNER_LINKS_ON_PARTNER_FIELD_ID,
} from 'src/fields/partner-link-partner.field';
import {
PARTNER_SERVICES_ON_PARTNER_FIELD_ID,
} from 'src/fields/partner-service-partner.field';
import { PARTNER_USER_ON_PARTNER_FIELD_ID } from 'src/fields/partner-user-on-partner.field';
export const PARTNER_RECORD_PAGE_FIELDS_VIEW_ID =
'a10bf4de-0770-4bee-ae04-1ae97aa18254';
// FIELDS_WIDGET view backing the Partner record page side panel. Relation fields
// (partnerUser, company) only render 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.
// (partnerLinks, partnerServices, partnerContents, partnerUser, company) only render
// 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.
export default defineView({
universalIdentifier: PARTNER_RECORD_PAGE_FIELDS_VIEW_ID,
name: 'Partner Record Page Fields',
@@ -92,91 +101,109 @@ export default defineView({
{
universalIdentifier: 'cbd9d84c-3d71-495c-b412-08c77f2c124c',
fieldMetadataUniversalIdentifier: PARTNER_DEPLOYMENT_EXPERTISE_FIELD_UNIVERSAL_IDENTIFIER,
position: 8,
position: 9,
isVisible: true,
},
{
universalIdentifier: '42521613-4eeb-4a00-9a25-1d9de793d3c0',
fieldMetadataUniversalIdentifier: PARTNER_LANGUAGES_SPOKEN_FIELD_UNIVERSAL_IDENTIFIER,
position: 9,
position: 10,
isVisible: true,
},
{
universalIdentifier: '2d595d67-8317-4c67-aeec-fe6e030e4830',
fieldMetadataUniversalIdentifier: PARTNER_TYPE_OF_TEAM_FIELD_UNIVERSAL_IDENTIFIER,
position: 10,
position: 11,
isVisible: true,
},
{
universalIdentifier: 'a85dc9bc-a5f5-446e-a36c-492f1b6c0035',
fieldMetadataUniversalIdentifier: PARTNER_REGION_FIELD_UNIVERSAL_IDENTIFIER,
position: 11,
position: 12,
isVisible: true,
},
{
universalIdentifier: '90d11276-1666-4c36-9055-00c4680c3168',
fieldMetadataUniversalIdentifier: PARTNER_COUNTRY_FIELD_UNIVERSAL_IDENTIFIER,
position: 12,
position: 13,
isVisible: true,
},
{
universalIdentifier: '23f76380-3532-4e84-9115-55d395d6646b',
fieldMetadataUniversalIdentifier: PARTNER_CITY_FIELD_UNIVERSAL_IDENTIFIER,
position: 13,
position: 14,
isVisible: true,
},
{
universalIdentifier: 'cf5e9649-a3e9-4b3f-b746-e701e3db7169',
fieldMetadataUniversalIdentifier: PARTNER_HOURLY_RATE_FIELD_UNIVERSAL_IDENTIFIER,
position: 14,
position: 15,
isVisible: true,
},
{
universalIdentifier: '388ae22b-956c-4e5a-8b78-6f8cb0105c1a',
fieldMetadataUniversalIdentifier: PARTNER_PROJECT_BUDGET_MIN_FIELD_UNIVERSAL_IDENTIFIER,
position: 15,
position: 16,
isVisible: true,
},
{
universalIdentifier: '5c8727a3-6e4b-41c7-adf9-530ab6599bf9',
fieldMetadataUniversalIdentifier: PARTNER_LINKEDIN_FIELD_UNIVERSAL_IDENTIFIER,
position: 16,
position: 17,
isVisible: true,
},
{
universalIdentifier: 'a6ef3c3b-103b-41b7-ba85-9d93a9af3296',
fieldMetadataUniversalIdentifier: PARTNER_WEBSITE_FIELD_UNIVERSAL_IDENTIFIER,
position: 17,
position: 18,
isVisible: true,
},
{
universalIdentifier: '4bc68141-963f-4e42-8aa4-c081afc613c7',
fieldMetadataUniversalIdentifier: PARTNER_CALENDAR_LINK_FIELD_UNIVERSAL_IDENTIFIER,
position: 18,
position: 19,
isVisible: true,
},
{
universalIdentifier: '5b51f559-d78c-48df-9347-7929f87ad8d1',
fieldMetadataUniversalIdentifier: PARTNER_LINKS_ON_PARTNER_FIELD_ID,
position: 20,
isVisible: true,
},
{
universalIdentifier: 'fe7a958a-fc56-4e65-8e37-1925014fb1ae',
fieldMetadataUniversalIdentifier: PARTNER_SERVICES_ON_PARTNER_FIELD_ID,
position: 21,
isVisible: true,
},
{
universalIdentifier: '39b34dc8-7daa-4e30-9183-2ce8f035657a',
fieldMetadataUniversalIdentifier: PARTNER_CONTENTS_ON_PARTNER_FIELD_ID,
position: 22,
isVisible: true,
},
{
universalIdentifier: '3487e2ae-0feb-41c4-ad0c-f94dff435015',
fieldMetadataUniversalIdentifier: PARTNER_VALIDATION_STAGE_FIELD_UNIVERSAL_IDENTIFIER,
position: 19,
position: 23,
isVisible: true,
},
{
universalIdentifier: '971c79e6-381e-4346-86a4-7119de4824be',
fieldMetadataUniversalIdentifier: PARTNER_TIER_FIELD_UNIVERSAL_IDENTIFIER,
position: 20,
position: 24,
isVisible: true,
},
{
universalIdentifier: 'ec1148d4-01ff-4128-9863-0ac42ea8eb47',
fieldMetadataUniversalIdentifier: PARTNER_USER_ON_PARTNER_FIELD_ID,
position: 21,
position: 25,
isVisible: true,
},
{
universalIdentifier: '3ec293a1-da90-4104-a22e-771c0059e9b5',
fieldMetadataUniversalIdentifier: PARTNER_COMPANY_FIELD_ID,
position: 22,
position: 26,
isVisible: true,
},
],
@@ -0,0 +1,46 @@
import { ViewType, defineView } from 'twenty-sdk/define';
import { PARTNER_SERVICE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { PARTNER_SERVICE_PARTNER_FIELD_ID } from 'src/fields/partner-service-partner.field';
import {
PARTNER_SERVICE_DESCRIPTION_FIELD_ID,
PARTNER_SERVICE_SORT_ORDER_FIELD_ID,
PARTNER_SERVICE_TITLE_FIELD_ID,
} from 'src/objects/partner-service.object';
export const PARTNER_SERVICE_RECORD_PAGE_FIELDS_VIEW_ID =
'0dfa3e6b-5461-4494-8a5a-ce363617e5a0';
// FIELDS_WIDGET view backing the Partner Service record page side panel.
export default defineView({
universalIdentifier: PARTNER_SERVICE_RECORD_PAGE_FIELDS_VIEW_ID,
name: 'Partner Service Record Page Fields',
objectUniversalIdentifier: PARTNER_SERVICE_OBJECT_UNIVERSAL_IDENTIFIER,
type: ViewType.FIELDS_WIDGET,
fields: [
{
universalIdentifier: '4d042312-363d-4515-870b-4620ef8e7f56',
fieldMetadataUniversalIdentifier: PARTNER_SERVICE_TITLE_FIELD_ID,
position: 0,
isVisible: true,
},
{
universalIdentifier: 'de10bccd-0932-4a16-b1a7-5d8aa039bd46',
fieldMetadataUniversalIdentifier: PARTNER_SERVICE_DESCRIPTION_FIELD_ID,
position: 1,
isVisible: true,
},
{
universalIdentifier: '2d0208c3-41bc-4dc7-9a5b-81b07e1c3647',
fieldMetadataUniversalIdentifier: PARTNER_SERVICE_SORT_ORDER_FIELD_ID,
position: 2,
isVisible: true,
},
{
universalIdentifier: '3863314c-b426-4b0d-aec4-60624338a658',
fieldMetadataUniversalIdentifier: PARTNER_SERVICE_PARTNER_FIELD_ID,
position: 3,
isVisible: true,
},
],
});
File diff suppressed because it is too large Load Diff