v1.3.2 — Modularize partners app into vertical-slice modules/ (#23168)
**Version:** `twenty-partners@1.3.2` (patch — internal refactor, no
visible behavior change)
## What & why
Reorganizes the `twenty-partners` SDK app from a flat, type-first layout
(`src/{objects,fields,views,logic-functions,front-components,…}`) into a
**vertical-slice** layout under `src/modules/<domain>/<feature>/`. Files
that
change together now live together; each SDK entrypoint is a thin
discoverable
shim over a service + graphql-ops + mapper/connector layer.
This is a pure structural refactor — **no object, field, view, enum,
logic
function, trigger, role, or application variable changed.**
## Final layout
```
src/modules/
shared/ http · services · graphql · utils · front-components · navigation-menu-items (cross-domain nav folders)
opportunity/ fields·view-fields·views·navigation-menu-items·page-layouts·constants + intake/ + matching/
partner/ objects·fields·constants·utils + directory/ · self-service/ · marketplace/ · application-intake/ (Discord connector/)
application/ objects·fields·views·navigation-menu-items·page-layouts + services · graphql
```
Every `defineLogicFunction` entrypoint is now a thin
`*.logic-function.ts`
(all < 40 lines) at its domain/feature root, delegating to a
`*.service.ts`;
graphql operations live in `graphql/{queries,mutations}/`, pure
transforms in
`mappers/`, outbound APIs (the Discord webhook) in `connector/`, pure
helpers
in `utils/`.
## Safety — the load-bearing invariant
The server diffs app primitives by `universalIdentifier`, so a
changed/dropped
UUID would drop-and-recreate the object on prod (data loss). This branch
holds
that line:
- **887 `universalIdentifier`s byte-identical** to the branch base
(every
relocation is a `git mv`; every extracted entrypoint keeps its original
UUID/name/trigger verbatim). Re-verified byte-identical across the
rebase.
- `yarn twenty dev --once` against a live workspace = **"No changes.
Twenty
metadata matches your manifest."**, confirmed idempotent on a second run
—
the whole refactor is a metadata no-op (zero create/delete/identity
change).
- Every extracted graphql op was verified **byte-identical** to its
original
(args, `first:` caps, pagination, selection sets), and the
partner-application
Discord embed's deliberate PII omission (no email / hourly rate) is
preserved.
## Rebased onto latest `main`
This branch is rebased onto `main` (`d20e5378fd`) and now carries main's
`twenty-sdk` / `twenty-client-sdk` **2.23.0-alpha.2** bump.
Note for reviewers: main had independently bumped this package to
`1.3.1`, so
the original `1.3.0 → 1.3.1` commit here was redundant and git dropped
it during
the rebase (`patch contents already upstream`) — with **no textual
conflict**,
since both sides wrote the same version string. The bump is therefore
now
**`1.3.2`**. The rebase touched only `package.json` and `yarn.lock`;
**every line
of refactored source is byte-identical** to the pre-rebase tree.
## Verification
All run on the rebased tree, against SDK `2.23.0-alpha.2` and a live
Twenty
server `v2.23.2`:
| Check | Result |
|---|---|
| `universalIdentifier` set | 887, byte-identical |
| `yarn twenty dev --once` | "No changes" (idempotent on re-run) |
| Typecheck | pass |
| `yarn lint` | 0 warnings, 0 errors (287 files) |
| `yarn test:unit` | 158/158 (23 files) |
| `yarn test:integration` | 45/45 (13 files) |
## Also in this PR
- **Architecture convention doc** — `AGENTS.md` (+ a one-line
`CLAUDE.md` pointer)
at the package root documents the vertical-slice conventions this
refactor
establishes: the layout, the dependency rule (`logic-function → service
→
graphql/connector`), file naming, connector = outbound-only (inbound
webhooks
are logic-functions), and the UUID invariant. It ships here so the doc
and the
structure it describes land together.
- **`modules/shared/`** dedup: the secret-guarded intake envelope, the
find-or-create-company/person helpers + their graphql ops, `collectAll`
pagination, `http-url`/`strip-markdown`/`is-non-empty-string` utils.
- **Vitest configs collapsed** into one `vitest.config.ts` with `unit` +
`integration` projects (`yarn test:unit` / `yarn test:integration`).
- Cross-domain nav folders (`pipeline-folder`,
`partner-workspace-folder`)
hoisted to `modules/shared/navigation-menu-items/`.
## Deferred (non-blocking, tracked follow-ups)
- Add direct unit tests for the shared `collectAll` / `isNonEmptyString`
utils
(currently covered indirectly).
- Move `submit-client-brief`'s zod schema out of its mapper file into
its own
schema file (mirroring the partner side).
- Route `stamp-partner-user-on-child` through the shared self-service
mutation ops.
- `find-partner-by-member.ts` is duplicated identically in the
`application` and
`self-service` domains; a candidate to hoist into `modules/shared/`.
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
# Architecture — twenty-partners
|
||||
|
||||
Vertical-slice modules, one folder per domain. Adapted from `twentyhq/twenty-eng`
|
||||
(the largest Twenty SDK app), which migrated flat → modular as it grew. Repo-wide
|
||||
conventions (kebab-case files, named exports, no `any`, `types` over `interface`,
|
||||
short `//` comments) live in the root `CLAUDE.md` and still apply — this file only
|
||||
adds the partners-specific structure.
|
||||
|
||||
> **Status:** migration complete — every domain lives under `src/modules/`. The only
|
||||
> top-level `src/` entries left are `constants/universal-identifiers.ts`, `scripts/`,
|
||||
> `roles/`, `skills/`, `workflows/`, `__tests__/`, and the two root config files. Add new
|
||||
> code to a domain module, never a flat top-level `src/<primitive>/` folder.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
src/
|
||||
application-config.ts # app + application variables — unchanged
|
||||
default-role.ts # unchanged
|
||||
constants/universal-identifiers.ts # ⚠️ MUST NOT MOVE (see Invariants)
|
||||
scripts/ # ⚠️ MUST NOT MOVE — seed/purge/rls/slugify
|
||||
roles/ # app-wide RLS — stays at root (spans every object)
|
||||
skills/ # bundled Claude skills — unchanged
|
||||
workflows/ # bundled workflows — unchanged
|
||||
__tests__/helpers/ # shared test setup (client, fixtures, cleanup)
|
||||
modules/
|
||||
shared/ # cross-cutting: secret-guard (http/), find-or-create, paginate,
|
||||
# utils, front-components, cross-domain nav folders
|
||||
<domain>/ # partner · application · opportunity · … (large domains add a <feature>/ tier)
|
||||
<name>.logic-function.ts # thin SDK entrypoints — sit at the domain/feature root, NOT in a subfolder
|
||||
objects/ fields/ view-fields/ views/ navigation-menu-items/ page-layouts/ # declarations
|
||||
constants/ # value lists + declaration UUID maps (NOT types)
|
||||
services/ # business logic (testable)
|
||||
graphql/{queries,mutations}/ # the only place raw queries/mutations live
|
||||
connector/ # OUTBOUND third-party API per folder (<name>.connector.ts · config · types)
|
||||
mappers/ utils/ types/ front-components/
|
||||
```
|
||||
|
||||
A domain is a self-contained slice: its declarations (`objects`, `fields`, `views`, …)
|
||||
**and** its logic (`logic-functions` → `services` → `graphql`/`connector`) live together.
|
||||
Adding a feature means adding a folder, not scattering files across ten flat piles.
|
||||
|
||||
**Feature tier for large domains:** when a domain holds several distinct features, insert a
|
||||
`<feature>/` level — `modules/<domain>/<feature>/<primitive>/` (the twenty-eng standard,
|
||||
e.g. `modules/code-build/build-task/services/`). It keeps `graphql/` and `services/` from
|
||||
becoming flat piles. Partner uses `partner/{self-service,marketplace,application-intake,
|
||||
directory}/…`; opportunity uses `opportunity/{intake,matching}/…`. A small single-feature
|
||||
domain (application) may keep `<domain>/<primitive>/` directly.
|
||||
|
||||
## The dependency rule (the one that matters — never import upward)
|
||||
|
||||
```
|
||||
logic-functions → services → { graphql, connector, mappers } → shared · utils · types
|
||||
```
|
||||
|
||||
- **Entrypoints (`*.logic-function.ts`, at the domain/feature root)** — SDK entrypoint
|
||||
ONLY. Parse input, call one service, return. No business logic, no raw GraphQL, no
|
||||
external HTTP. >~40 lines means it's doing a service's job — extract it.
|
||||
- **`services/`** — all business logic. Testable, no SDK/transport coupling. Must **not**
|
||||
import a `logic-function`.
|
||||
- **`graphql/`** — the ONLY place raw queries/mutations live (named typed operations,
|
||||
not inline `client.query(...)`).
|
||||
- **`connector/`** — the ONLY place **outbound** third-party APIs are called; one folder
|
||||
per API (`<name>.connector.ts` / `config.ts` / `types.ts`). **Inbound webhooks are NOT connectors** —
|
||||
a webhook the app *receives* is an ordinary `logic-functions/` entrypoint guarded by the
|
||||
shared secret-guard util. (Today the one real connector is Discord; TFT/client-brief/
|
||||
partner-application are inbound.)
|
||||
- Never import **logic** sideways between domains — share via `modules/shared/`. (A relation
|
||||
field naturally referencing its target object's ID constant is a schema reference, not a
|
||||
logic dependency — that cross-module import is allowed.)
|
||||
|
||||
## Naming
|
||||
|
||||
`<name>.<primitive>.ts` — e.g. `submit-application.logic-function.ts`,
|
||||
`resolve-candidacy.service.ts`, `partner.object.ts`. Files are kebab-case (no PascalCase,
|
||||
even for React components: `profile-picture-upload.tsx`).
|
||||
|
||||
Tests co-locate with their subject and split by kind: `<name>.test.ts` = unit (mocked
|
||||
client, fast, no infra); `<name>.integration-test.ts` = real workspace (needs a live
|
||||
server + global setup; excluded by tsconfig). The split is a selectable vitest **project**
|
||||
(`--project unit`), not a file-count mandate — one `vitest.config.ts` with two projects is
|
||||
preferred. Don't rename a mocked unit test to `.integration-test.ts`. Shared test setup
|
||||
lives in `src/__tests__/helpers/`.
|
||||
|
||||
## Invariants (moving code must not break these)
|
||||
|
||||
- The set of `universalIdentifier` UUIDs must stay **byte-identical** across a move —
|
||||
the SDK tracks primitives by identifier, not path, so a pure `git mv` is safe but a
|
||||
changed/dropped UUID re-registers or orphans an object.
|
||||
- **Do not move** `src/constants/universal-identifiers.ts` — the env-toolkit rewrites it
|
||||
by path per bundle.
|
||||
- **Do not move** `src/scripts/*` — `package.json` invokes them by path.
|
||||
- **Keep importers of `src/constants/universal-identifiers.ts` pointing at the root file** —
|
||||
don't relocate its UUID exports into a module.
|
||||
- **Never split a relation across domains with a dangling ID import.** Paired relation
|
||||
fields export/import each other's field-ID constant; keep both sides of a relation in one
|
||||
domain, or hoist the shared ID into `shared/`.
|
||||
- One domain per commit when migrating (`git mv` only, imports fixed in the same commit).
|
||||
|
||||
## Where does new code go?
|
||||
|
||||
External API call → `connector/`. Raw query/mutation → `graphql/`. Any logic →
|
||||
`services/`. New SDK trigger/function → a thin `*.logic-function.ts` at the domain/feature
|
||||
root that calls a service. Shared across domains → `modules/shared/`.
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-partners",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.2",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
@@ -15,7 +15,8 @@
|
||||
"lint": "oxlint -c .oxlintrc.json .",
|
||||
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
|
||||
"test": "vitest run",
|
||||
"test:unit": "vitest run --config vitest.unit.config.ts",
|
||||
"test:unit": "vitest run --project unit",
|
||||
"test:integration": "vitest run --project integration",
|
||||
"test:watch": "vitest",
|
||||
"seed": "tsx src/scripts/seed.ts",
|
||||
"seed:prod": "ENV_FILE=.env.prod tsx src/scripts/seed.ts",
|
||||
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
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';
|
||||
-211
@@ -1,211 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
-226
@@ -1,226 +0,0 @@
|
||||
import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
findCompanyIdByExactName,
|
||||
findPersonIdByPrimaryEmail,
|
||||
} from './find-or-create-company-and-person';
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
// Manual one-way copy of one Opportunity from the TFT workspace into partners.
|
||||
export const IMPORT_OPPORTUNITY_FROM_TFT_LOGIC_FUNCTION_ID =
|
||||
'4c220eaf-a23f-4af2-8d69-38a6c460019f';
|
||||
|
||||
const APPLICATION_SECRET_HEADER = 'x-application-secret';
|
||||
|
||||
// TFT POSTs `null` for empty fields; treat null as "field absent".
|
||||
const dropNulls = (value: unknown): unknown =>
|
||||
value === null
|
||||
? undefined
|
||||
: Array.isArray(value)
|
||||
? value.map(dropNulls)
|
||||
: typeof value === 'object'
|
||||
? Object.fromEntries(
|
||||
Object.entries(value).map(([key, val]) => [key, dropNulls(val)]),
|
||||
)
|
||||
: value;
|
||||
|
||||
// Request contract — the JSON the TFT workflow POSTs.
|
||||
export const importOpportunityFromTftSchema = z.preprocess(dropNulls, z.object({
|
||||
tftOpportunityId: z.string().optional(),
|
||||
name: z.string().trim().min(1),
|
||||
amountMicros: z.number().optional(),
|
||||
currencyCode: z.string().optional(),
|
||||
closeDate: z.string().optional(),
|
||||
stage: z.string().optional(),
|
||||
useCase: z.string().optional(),
|
||||
company: z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
domain: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
pointOfContact: z
|
||||
.object({
|
||||
email: z.string().optional(),
|
||||
firstName: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
}));
|
||||
|
||||
export type ImportOpportunityFromTftInput = z.infer<
|
||||
typeof importOpportunityFromTftSchema
|
||||
>;
|
||||
|
||||
type ImportOpportunityFromTftEvent = {
|
||||
headers?: Record<string, string | undefined>;
|
||||
body?: unknown;
|
||||
};
|
||||
|
||||
export type ImportOpportunityFromTftResult =
|
||||
| { ok: true; created: boolean; id: string }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
// Find by exact name, else create.
|
||||
async function findOrCreateCompanyId(
|
||||
client: CoreApiClient,
|
||||
company: ImportOpportunityFromTftInput['company'],
|
||||
): Promise<string | undefined> {
|
||||
const name = isNonEmptyString(company?.name) ? company.name.trim() : undefined;
|
||||
const domain = isNonEmptyString(company?.domain) ? company.domain.trim() : undefined;
|
||||
if (name === undefined && domain === undefined) return undefined;
|
||||
|
||||
if (name !== undefined) {
|
||||
const existing = await findCompanyIdByExactName(client, name);
|
||||
if (existing !== undefined) return existing;
|
||||
}
|
||||
|
||||
const companyData: CoreSchema.CompanyCreateInput = { name: name ?? domain! };
|
||||
if (domain !== undefined) companyData.domainName = { primaryLinkUrl: domain };
|
||||
|
||||
const result = await client.mutation({
|
||||
createCompany: { __args: { data: companyData }, id: true },
|
||||
});
|
||||
const id = result.createCompany?.id;
|
||||
if (id === undefined) throw new Error('createCompany did not return an id');
|
||||
return id;
|
||||
}
|
||||
|
||||
// Find by primary email, else create — name-only contacts can't be deduped.
|
||||
async function findOrCreatePersonId(
|
||||
client: CoreApiClient,
|
||||
pointOfContact: ImportOpportunityFromTftInput['pointOfContact'],
|
||||
companyId: string | undefined,
|
||||
): Promise<string | undefined> {
|
||||
const email = isNonEmptyString(pointOfContact?.email)
|
||||
? pointOfContact.email.trim()
|
||||
: undefined;
|
||||
const firstName = isNonEmptyString(pointOfContact?.firstName)
|
||||
? pointOfContact.firstName.trim()
|
||||
: '';
|
||||
const lastName = isNonEmptyString(pointOfContact?.lastName)
|
||||
? pointOfContact.lastName.trim()
|
||||
: '';
|
||||
if (email === undefined && firstName === '' && lastName === '') return undefined;
|
||||
|
||||
if (email !== undefined) {
|
||||
const existing = await findPersonIdByPrimaryEmail(client, email);
|
||||
if (existing !== undefined) return existing;
|
||||
}
|
||||
|
||||
const personData: CoreSchema.PersonCreateInput = { name: { firstName, lastName } };
|
||||
if (email !== undefined) personData.emails = { primaryEmail: email };
|
||||
if (companyId !== undefined) personData.companyId = companyId;
|
||||
|
||||
const result = await client.mutation({
|
||||
createPerson: { __args: { data: personData }, id: true },
|
||||
});
|
||||
const id = result.createPerson?.id;
|
||||
if (id === undefined) throw new Error('createPerson did not return an id');
|
||||
return id;
|
||||
}
|
||||
|
||||
export const handler = async (
|
||||
event: ImportOpportunityFromTftEvent | ImportOpportunityFromTftInput,
|
||||
): Promise<ImportOpportunityFromTftResult> => {
|
||||
// HTTP event ({ body, headers }) or a flat input for direct calls.
|
||||
const looksLikeEvent =
|
||||
typeof event === 'object' &&
|
||||
event !== null &&
|
||||
('body' in event || 'headers' in event);
|
||||
|
||||
const headers = looksLikeEvent
|
||||
? (event as ImportOpportunityFromTftEvent).headers ?? {}
|
||||
: {};
|
||||
const rawInput = looksLikeEvent
|
||||
? (event as ImportOpportunityFromTftEvent).body
|
||||
: event;
|
||||
|
||||
// isAuthRequired only accepts user JWTs, not API keys; guard with the shared secret.
|
||||
const expectedSecret = process.env.PARTNER_APPLICATION_SECRET;
|
||||
if (!isNonEmptyString(expectedSecret)) return { ok: false, reason: 'unauthorized' };
|
||||
if (headers[APPLICATION_SECRET_HEADER] !== expectedSecret) {
|
||||
return { ok: false, reason: 'unauthorized' };
|
||||
}
|
||||
|
||||
const parsed = importOpportunityFromTftSchema.safeParse(rawInput);
|
||||
if (!parsed.success) return { ok: false, reason: 'invalid_input' };
|
||||
const input = parsed.data;
|
||||
|
||||
try {
|
||||
const client = new CoreApiClient();
|
||||
const name = input.name.trim();
|
||||
const tftOpportunityId = isNonEmptyString(input.tftOpportunityId)
|
||||
? input.tftOpportunityId.trim()
|
||||
: undefined;
|
||||
|
||||
// Idempotency: stable source id first, name as a fallback for manual calls.
|
||||
const dedupeFilter: CoreSchema.OpportunityFilterInput =
|
||||
tftOpportunityId !== undefined
|
||||
? { tftOpportunityId: { eq: tftOpportunityId } }
|
||||
: { name: { eq: name } };
|
||||
const existing = await client.query({
|
||||
opportunities: {
|
||||
__args: { filter: dedupeFilter, first: 1 },
|
||||
edges: { node: { id: true } },
|
||||
},
|
||||
});
|
||||
const existingId = existing.opportunities?.edges?.[0]?.node?.id;
|
||||
if (existingId !== undefined) return { ok: true, created: false, id: existingId };
|
||||
|
||||
const companyId = await findOrCreateCompanyId(client, input.company);
|
||||
const pointOfContactId = await findOrCreatePersonId(
|
||||
client,
|
||||
input.pointOfContact,
|
||||
companyId,
|
||||
);
|
||||
|
||||
const opportunityData: CoreSchema.OpportunityCreateInput = { name };
|
||||
if (tftOpportunityId !== undefined) opportunityData.tftOpportunityId = tftOpportunityId;
|
||||
if (input.amountMicros !== undefined) {
|
||||
opportunityData.amount = {
|
||||
amountMicros: input.amountMicros,
|
||||
currencyCode: input.currencyCode ?? 'USD',
|
||||
};
|
||||
}
|
||||
if (isNonEmptyString(input.closeDate)) opportunityData.closeDate = input.closeDate;
|
||||
if (isNonEmptyString(input.stage)) {
|
||||
opportunityData.stage = input.stage as CoreSchema.OpportunityStageEnum;
|
||||
}
|
||||
if (isNonEmptyString(input.useCase)) {
|
||||
opportunityData.need = input.useCase.trim();
|
||||
}
|
||||
if (companyId !== undefined) opportunityData.companyId = companyId;
|
||||
if (pointOfContactId !== undefined) opportunityData.pointOfContactId = pointOfContactId;
|
||||
|
||||
const result = await client.mutation({
|
||||
createOpportunity: { __args: { data: opportunityData }, id: true },
|
||||
});
|
||||
const id = result.createOpportunity?.id;
|
||||
if (id === undefined) throw new Error('createOpportunity did not return an id');
|
||||
|
||||
return { ok: true, created: true, id };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: IMPORT_OPPORTUNITY_FROM_TFT_LOGIC_FUNCTION_ID,
|
||||
name: 'import-opportunity-from-tft',
|
||||
description:
|
||||
'Receive one opportunity pushed from the TFT workspace and create it in partners (find-or-create company + contact, idempotent on tftOpportunityId).',
|
||||
timeoutSeconds: 15,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/opportunities',
|
||||
httpMethod: 'POST',
|
||||
isAuthRequired: false,
|
||||
forwardedRequestHeaders: [APPLICATION_SECRET_HEADER],
|
||||
},
|
||||
});
|
||||
-103
@@ -1,103 +0,0 @@
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
mapPartnerForMarketplace,
|
||||
type MarketplaceListPartner,
|
||||
} from './map-partner-for-marketplace';
|
||||
|
||||
export const LIST_AVAILABLE_PARTNERS_LOGIC_FUNCTION_ID =
|
||||
'0f91164f-f492-41e8-9bb0-481be5a3d5b9';
|
||||
|
||||
// CoreApiClient is codegenerated from the synced workspace schema, so the query
|
||||
// selection is strictly typed. Keep the fetch in one place and derive the
|
||||
// response shape from it, so the HTTP contract can never drift from what we
|
||||
// actually ask the API for.
|
||||
const queryAvailablePartners = (client: CoreApiClient) =>
|
||||
client.query({
|
||||
partners: {
|
||||
__args: {
|
||||
filter: {
|
||||
validationStage: { eq: 'VALIDATED' },
|
||||
availability: { eq: 'AVAILABLE' },
|
||||
slug: { neq: '' },
|
||||
},
|
||||
orderBy: [{ name: 'AscNullsLast' }],
|
||||
first: 100,
|
||||
},
|
||||
edges: {
|
||||
node: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
introduction: true,
|
||||
languagesSpoken: true,
|
||||
deploymentExpertise: true,
|
||||
partnerScope: true,
|
||||
region: true,
|
||||
calendarLink: { primaryLinkUrl: true },
|
||||
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 },
|
||||
profilePictureFile: { url: true },
|
||||
skills: true,
|
||||
city: true,
|
||||
country: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
type AvailablePartnerRaw = NonNullable<
|
||||
Awaited<ReturnType<typeof queryAvailablePartners>>['partners']
|
||||
>['edges'][number]['node'];
|
||||
|
||||
type ListAvailablePartnersResult =
|
||||
| { ok: true; count: number; partners: MarketplaceListPartner[] }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
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 = (result.partners?.edges ?? []).map(({ node }) =>
|
||||
mapListPartner(node),
|
||||
);
|
||||
|
||||
return { ok: true, count: partners.length, partners };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: LIST_AVAILABLE_PARTNERS_LOGIC_FUNCTION_ID,
|
||||
name: 'list-available-partners',
|
||||
description: 'Returns all partners with validationStage=VALIDATED and availability=AVAILABLE.',
|
||||
timeoutSeconds: 10,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/partners',
|
||||
httpMethod: 'GET',
|
||||
isAuthRequired: false,
|
||||
},
|
||||
});
|
||||
-89
@@ -1,89 +0,0 @@
|
||||
import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
|
||||
import {
|
||||
type DatabaseEventPayload,
|
||||
defineLogicFunction,
|
||||
type ObjectRecordCreateEvent,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
const ON_APPLICATION_CREATED_FN_ID = '0e055a1c-b8b3-4572-89f3-e76e37bc3f9e';
|
||||
|
||||
// A partner self-applies via the "Apply to brief as partner" workflow: a Create Record action
|
||||
// makes an Application with the opportunity set and createdBy = the clicking member, but no
|
||||
// partner. Resolve the partner from createdBy and complete the candidacy. Admin-created
|
||||
// applications (partner already set, or the creator is not a partner) are left untouched. The
|
||||
// name is set by on-application-set-name, which fires on the partnerId update below.
|
||||
export const handler = async (
|
||||
payload: DatabaseEventPayload<ObjectRecordCreateEvent<CoreSchema.Application>>,
|
||||
): Promise<Record<string, unknown>> => {
|
||||
const after = payload.properties.after;
|
||||
const applicationId = after?.id;
|
||||
if (!applicationId) return {};
|
||||
if (after.partnerId) return {}; // already linked (admin path) — leave it
|
||||
|
||||
const memberId = after.createdBy?.workspaceMemberId;
|
||||
if (!memberId) return {}; // no member actor (system/import) — not a self-apply
|
||||
|
||||
const client = new CoreApiClient();
|
||||
const partnerRes = await client.query({
|
||||
partners: {
|
||||
__args: { filter: { partnerUserId: { eq: memberId } }, first: 1 },
|
||||
edges: { node: { id: true } },
|
||||
},
|
||||
});
|
||||
const partnerId = partnerRes.partners?.edges?.[0]?.node?.id;
|
||||
if (!partnerId) return {}; // creator isn't a partner (e.g. admin) — leave it
|
||||
|
||||
const opportunityId = after.opportunityId;
|
||||
if (opportunityId) {
|
||||
const existingRes = await client.query({
|
||||
applications: {
|
||||
__args: {
|
||||
filter: {
|
||||
opportunityId: { eq: opportunityId },
|
||||
partnerId: { eq: partnerId },
|
||||
},
|
||||
first: 1,
|
||||
},
|
||||
edges: { node: { id: true } },
|
||||
},
|
||||
});
|
||||
const existingId = existingRes.applications?.edges?.find(
|
||||
(edge) => edge.node?.id && edge.node.id !== applicationId,
|
||||
)?.node?.id;
|
||||
if (existingId) {
|
||||
await client.mutation({
|
||||
deleteApplication: {
|
||||
__args: { id: applicationId },
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
return { duplicate: true, keptExisting: existingId };
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
await client.mutation({
|
||||
updateApplication: {
|
||||
__args: {
|
||||
id: applicationId,
|
||||
data: {
|
||||
partnerId,
|
||||
partnerUserId: memberId,
|
||||
state: 'APPLIED',
|
||||
lastActivityAt: now,
|
||||
},
|
||||
},
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
// ponytail: dedupe by (opportunity, partner) above; two near-simultaneous creates could still both pass before either stamps — acceptable.
|
||||
return { applied: true, partnerId };
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: ON_APPLICATION_CREATED_FN_ID,
|
||||
name: 'on-application-created',
|
||||
timeoutSeconds: 15,
|
||||
handler,
|
||||
databaseEventTriggerSettings: { eventName: 'application.created' },
|
||||
});
|
||||
-94
@@ -1,94 +0,0 @@
|
||||
import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
|
||||
import {
|
||||
type DatabaseEventPayload,
|
||||
defineLogicFunction,
|
||||
type ObjectRecordUpdateEvent,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
const ON_OPP_PARTNER_WON_FN_ID = '683f407e-e7a0-435d-a380-e51e536770f8';
|
||||
|
||||
const APPLICATIONS_PAGE_SIZE = 200;
|
||||
|
||||
// Every application on the brief, paginated fully — a single capped page would strand overflow
|
||||
// applications in a stale WON/BACKUP/APPLIED state out of sync with Opportunity.partner.
|
||||
async function collectApplications(client: CoreApiClient, opportunityId: string) {
|
||||
const query = (after?: string) =>
|
||||
client.query({
|
||||
applications: {
|
||||
__args: {
|
||||
filter: { opportunityId: { eq: opportunityId } },
|
||||
first: APPLICATIONS_PAGE_SIZE,
|
||||
...(after ? { after } : {}),
|
||||
},
|
||||
edges: { node: { id: true, partnerId: true, state: true } },
|
||||
pageInfo: { hasNextPage: true, endCursor: true },
|
||||
},
|
||||
});
|
||||
type ApplicationNode = NonNullable<
|
||||
NonNullable<
|
||||
Awaited<ReturnType<typeof query>>['applications']
|
||||
>['edges'][number]['node']
|
||||
>;
|
||||
const applications: ApplicationNode[] = [];
|
||||
let after: string | undefined;
|
||||
for (;;) {
|
||||
const page = await query(after);
|
||||
for (const edge of page.applications?.edges ?? []) {
|
||||
if (edge?.node) applications.push(edge.node);
|
||||
}
|
||||
if (!page.applications?.pageInfo?.hasNextPage) break;
|
||||
after = page.applications.pageInfo.endCursor ?? undefined;
|
||||
}
|
||||
return applications;
|
||||
}
|
||||
|
||||
// WON/BACKUP mirror of Opportunity.partner: on assign, winner -> WON and other active apps ->
|
||||
// BACKUP; on unassign, WON/BACKUP -> APPLIED. DECLINED is never touched. Runs under the app
|
||||
// identity, bypassing partner locks.
|
||||
export const handler = async (
|
||||
payload: DatabaseEventPayload<ObjectRecordUpdateEvent<CoreSchema.Opportunity>>,
|
||||
): Promise<Record<string, unknown>> => {
|
||||
const { after, updatedFields } = payload.properties;
|
||||
if (!updatedFields?.includes('partnerId')) return {};
|
||||
const opportunityId = after?.id;
|
||||
if (!opportunityId) return {};
|
||||
const newPartnerId = after?.partnerId ?? null;
|
||||
|
||||
const client = new CoreApiClient();
|
||||
const applications = await collectApplications(client, opportunityId);
|
||||
|
||||
const setState = async (id: string, state: string) => {
|
||||
await client.mutation({
|
||||
updateApplication: { __args: { id, data: { state } }, id: true },
|
||||
});
|
||||
};
|
||||
|
||||
if (newPartnerId) {
|
||||
// Winner -> WON; every other active (non-DECLINED) application -> BACKUP.
|
||||
for (const node of applications) {
|
||||
if (node.state === 'DECLINED') continue;
|
||||
const target = node.partnerId === newPartnerId ? 'WON' : 'BACKUP';
|
||||
if (node.state !== target) await setState(node.id, target);
|
||||
}
|
||||
const winner = applications.find(
|
||||
(node) => node.partnerId === newPartnerId,
|
||||
);
|
||||
return { won: winner?.id ?? null };
|
||||
}
|
||||
|
||||
// Unassigned: WON and BACKUP applications re-open to APPLIED. DECLINED untouched.
|
||||
for (const node of applications) {
|
||||
if (node.state === 'WON' || node.state === 'BACKUP') {
|
||||
await setState(node.id, 'APPLIED');
|
||||
}
|
||||
}
|
||||
return { won: null, cleared: true };
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: ON_OPP_PARTNER_WON_FN_ID,
|
||||
name: 'on-opportunity-partner-won',
|
||||
timeoutSeconds: 15,
|
||||
handler,
|
||||
databaseEventTriggerSettings: { eventName: 'opportunity.updated' },
|
||||
});
|
||||
-190
@@ -1,190 +0,0 @@
|
||||
import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
|
||||
import {
|
||||
type DatabaseEventPayload,
|
||||
defineLogicFunction,
|
||||
type ObjectRecordCreateEvent,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import { ON_PARTNER_APPLICATION_CREATED_FN_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
|
||||
// Generous bound on the Discord call. The trigger runs out-of-band on the
|
||||
// worker (no user waiting), so this only needs to stay under the function's
|
||||
// 10s budget — not be tight. Guards against a hung/slow webhook.
|
||||
const DISCORD_TIMEOUT_MS = 8000;
|
||||
// Twenty brand blue (#4a38f5) as a Discord embed integer color.
|
||||
const TWENTY_BLUE = 0x4a38f5;
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
type DiscordField = { name: string; value: string; inline?: boolean };
|
||||
|
||||
type PartnerForEmbed = {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
country?: string | null;
|
||||
partnerScope?: string[] | null;
|
||||
skills?: string[] | null;
|
||||
languagesSpoken?: string[] | null;
|
||||
applicant?: {
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
// Pure embed builder, exported for unit testing. Empty fields are omitted.
|
||||
export function buildApplicationEmbed(
|
||||
partner: PartnerForEmbed,
|
||||
frontendUrl: string | undefined,
|
||||
): Record<string, unknown> {
|
||||
// Discord packs up to 3 inline fields per row. We want two paired rows —
|
||||
// Applicant | Company, then Country | Languages — followed by two full-width
|
||||
// rows (Partner scope, Skills). A trailing zero-width inline "spacer" fills
|
||||
// each paired row to 3 columns so the next group starts on its own line.
|
||||
const SPACER: DiscordField = { name: '', value: '', inline: true };
|
||||
const fields: DiscordField[] = [];
|
||||
|
||||
const applicantName = [partner.applicant?.firstName, partner.applicant?.lastName]
|
||||
.filter(isNonEmptyString)
|
||||
.join(' ')
|
||||
.trim();
|
||||
|
||||
const pairedRow1: DiscordField[] = [];
|
||||
if (isNonEmptyString(applicantName)) {
|
||||
pairedRow1.push({ name: 'Applicant', value: applicantName, inline: true });
|
||||
}
|
||||
if (isNonEmptyString(partner.name)) {
|
||||
pairedRow1.push({ name: 'Company', value: partner.name.trim(), inline: true });
|
||||
}
|
||||
|
||||
const pairedRow2: DiscordField[] = [];
|
||||
if (isNonEmptyString(partner.country)) {
|
||||
pairedRow2.push({ name: 'Country', value: partner.country, inline: true });
|
||||
}
|
||||
if (partner.languagesSpoken && partner.languagesSpoken.length > 0) {
|
||||
pairedRow2.push({ name: 'Languages', value: partner.languagesSpoken.join(', '), inline: true });
|
||||
}
|
||||
|
||||
for (const field of pairedRow1) fields.push(field);
|
||||
if (pairedRow1.length > 0) fields.push(SPACER);
|
||||
for (const field of pairedRow2) fields.push(field);
|
||||
if (pairedRow2.length > 0) fields.push(SPACER);
|
||||
|
||||
if (partner.partnerScope && partner.partnerScope.length > 0) {
|
||||
fields.push({ name: 'Partner scope', value: partner.partnerScope.join(', ') });
|
||||
}
|
||||
if (partner.skills && partner.skills.length > 0) {
|
||||
fields.push({ name: 'Skills', value: partner.skills.join(', ') });
|
||||
}
|
||||
|
||||
const embed: Record<string, unknown> = {
|
||||
title: 'New partner application',
|
||||
color: TWENTY_BLUE,
|
||||
timestamp: new Date().toISOString(),
|
||||
fields,
|
||||
};
|
||||
if (isNonEmptyString(frontendUrl)) {
|
||||
embed.url = `${frontendUrl.replace(/\/+$/, '')}/object/partner/${partner.id}`;
|
||||
}
|
||||
return embed;
|
||||
}
|
||||
|
||||
export const handler = async (
|
||||
payload: DatabaseEventPayload<ObjectRecordCreateEvent<CoreSchema.Partner>>,
|
||||
): Promise<Record<string, unknown>> => {
|
||||
// The event 'after' hydrates the actor composite as a NESTED object — the
|
||||
// generated Partner type matches the live partner.created payload shape.
|
||||
const after = payload.properties.after;
|
||||
if (!after?.id) return {};
|
||||
|
||||
// Form-only: the installed app stamps createdBy.source = 'APPLICATION'.
|
||||
// Seed/import authenticate via API key (API); the UI is MANUAL — both skipped.
|
||||
if (after.createdBy?.source !== 'APPLICATION') return {};
|
||||
|
||||
const webhookUrl = process.env.DISCORD_WEBHOOK_URL;
|
||||
if (!isNonEmptyString(webhookUrl)) return {};
|
||||
const frontendUrl = process.env.PARTNER_APP_FRONTEND_URL;
|
||||
|
||||
try {
|
||||
const client = new CoreApiClient();
|
||||
const res = await client.query({
|
||||
partner: {
|
||||
__args: { filter: { id: { eq: after.id } } },
|
||||
id: true,
|
||||
name: true,
|
||||
country: true,
|
||||
partnerScope: true,
|
||||
skills: true,
|
||||
languagesSpoken: true,
|
||||
persons: {
|
||||
edges: {
|
||||
node: {
|
||||
name: { firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const node = res.partner;
|
||||
if (!node) return {};
|
||||
|
||||
const applicantNode = node.persons?.edges?.[0]?.node;
|
||||
const partnerForEmbed: PartnerForEmbed = {
|
||||
id: after.id,
|
||||
name: node.name,
|
||||
country: node.country,
|
||||
partnerScope: node.partnerScope,
|
||||
skills: node.skills,
|
||||
languagesSpoken: node.languagesSpoken,
|
||||
applicant: applicantNode
|
||||
? {
|
||||
firstName: applicantNode.name?.firstName,
|
||||
lastName: applicantNode.name?.lastName,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
const embed = buildApplicationEmbed(partnerForEmbed, frontendUrl);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), DISCORD_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ embeds: [embed] }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
// fetch only rejects on network errors, not on HTTP error statuses, so a
|
||||
// non-2xx (dead webhook, rejected payload, rate limit) must be detected
|
||||
// explicitly — otherwise a failed post is reported as delivered.
|
||||
if (!response.ok) {
|
||||
console.warn(
|
||||
`on-partner-application-created: Discord webhook responded ${response.status}`,
|
||||
);
|
||||
return { notified: false };
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
return { notified: true };
|
||||
} catch {
|
||||
// Best-effort: a Discord failure must never fail the trigger.
|
||||
return { notified: false };
|
||||
}
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: ON_PARTNER_APPLICATION_CREATED_FN_UNIVERSAL_IDENTIFIER,
|
||||
name: 'on-partner-application-created',
|
||||
description:
|
||||
'Posts a Discord notification when the partner application form creates a new Partner.',
|
||||
timeoutSeconds: 10,
|
||||
handler,
|
||||
databaseEventTriggerSettings: {
|
||||
eventName: 'partner.created',
|
||||
},
|
||||
});
|
||||
-95
@@ -1,95 +0,0 @@
|
||||
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' },
|
||||
});
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
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' },
|
||||
});
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
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' },
|
||||
});
|
||||
-214
@@ -1,214 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
-144
@@ -1,144 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
-147
@@ -1,147 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
-149
@@ -1,149 +0,0 @@
|
||||
import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
findOrCreateCompanyByName,
|
||||
findOrCreatePersonByEmail,
|
||||
} from './find-or-create-company-and-person';
|
||||
|
||||
export const SUBMIT_CLIENT_BRIEF_LOGIC_FUNCTION_ID =
|
||||
'a8f3c2e1-9b4d-4a7f-8c6e-1d2f3a4b5c6d';
|
||||
|
||||
const HOSTING_LABEL: Record<'CLOUD' | 'SELF_HOSTING', string> = {
|
||||
CLOUD: 'Cloud',
|
||||
SELF_HOSTING: 'Self-hosting',
|
||||
};
|
||||
|
||||
export const submitClientBriefSchema = z.object({
|
||||
firstName: z.string().trim().min(1),
|
||||
lastName: z.string(),
|
||||
email: z.string().trim().email(),
|
||||
companyName: z.string().trim().min(1),
|
||||
need: z.string().trim().min(1),
|
||||
requirements: z.string().optional(),
|
||||
hostingType: z.enum(['CLOUD', 'SELF_HOSTING']).optional(),
|
||||
country: z.string().optional(),
|
||||
languages: z.array(z.string()).optional(),
|
||||
seatCount: z.string().optional(),
|
||||
timeline: z.string().optional(),
|
||||
budgetRange: z.string().optional(),
|
||||
});
|
||||
|
||||
export type SubmitClientBriefInput = z.infer<typeof submitClientBriefSchema>;
|
||||
|
||||
export type SubmitClientBriefResult =
|
||||
| { ok: true; opportunityId: string }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
export function buildRequirementsText(input: SubmitClientBriefInput): string | null {
|
||||
const base = isNonEmptyString(input.requirements) ? input.requirements.trim() : '';
|
||||
const bullets: string[] = [];
|
||||
if (input.hostingType !== undefined) {
|
||||
bullets.push(`• Hosting: ${HOSTING_LABEL[input.hostingType]}`);
|
||||
}
|
||||
if (isNonEmptyString(input.seatCount)) bullets.push(`• Seats: ${input.seatCount.trim()}`);
|
||||
if (isNonEmptyString(input.country)) bullets.push(`• Country: ${input.country.trim()}`);
|
||||
if (input.languages !== undefined && input.languages.length > 0) {
|
||||
bullets.push(`• Languages: ${input.languages.join(', ')}`);
|
||||
}
|
||||
if (isNonEmptyString(input.timeline)) bullets.push(`• Timeline: ${input.timeline.trim()}`);
|
||||
if (isNonEmptyString(input.budgetRange)) bullets.push(`• Budget: ${input.budgetRange.trim()}`);
|
||||
if (bullets.length === 0) return base.length > 0 ? base : null;
|
||||
const block = `---\nAdditional context:\n${bullets.join('\n')}`;
|
||||
return base ? `${base}\n\n${block}` : block;
|
||||
}
|
||||
|
||||
type SubmitClientBriefEvent = {
|
||||
headers?: Record<string, string | undefined>;
|
||||
body?: unknown;
|
||||
};
|
||||
|
||||
const APPLICATION_SECRET_HEADER = 'x-application-secret';
|
||||
|
||||
export const handler = async (
|
||||
event: SubmitClientBriefEvent | SubmitClientBriefInput,
|
||||
): Promise<SubmitClientBriefResult> => {
|
||||
const looksLikeEvent =
|
||||
typeof event === 'object' &&
|
||||
event !== null &&
|
||||
('body' in event || 'headers' in event);
|
||||
|
||||
const headers = looksLikeEvent
|
||||
? (event as SubmitClientBriefEvent).headers ?? {}
|
||||
: {};
|
||||
const rawInput = looksLikeEvent
|
||||
? (event as SubmitClientBriefEvent).body
|
||||
: event;
|
||||
|
||||
const expectedSecret = process.env.PARTNER_APPLICATION_SECRET;
|
||||
if (!isNonEmptyString(expectedSecret)) {
|
||||
return { ok: false, reason: 'unauthorized' };
|
||||
}
|
||||
const providedSecret = headers[APPLICATION_SECRET_HEADER];
|
||||
if (providedSecret !== expectedSecret) {
|
||||
return { ok: false, reason: 'unauthorized' };
|
||||
}
|
||||
|
||||
const parsed = submitClientBriefSchema.safeParse(rawInput);
|
||||
if (!parsed.success) {
|
||||
return { ok: false, reason: 'invalid_input' };
|
||||
}
|
||||
const input = parsed.data;
|
||||
|
||||
try {
|
||||
const client = new CoreApiClient();
|
||||
const name = `${input.companyName.trim()} — marketplace brief`;
|
||||
const requirements = buildRequirementsText(input);
|
||||
|
||||
const companyId = await findOrCreateCompanyByName(client, input.companyName);
|
||||
const pointOfContactId = await findOrCreatePersonByEmail(client, {
|
||||
email: input.email,
|
||||
firstName: input.firstName,
|
||||
lastName: input.lastName,
|
||||
companyId,
|
||||
});
|
||||
|
||||
const opportunityData: CoreSchema.OpportunityCreateInput = {
|
||||
name,
|
||||
need: input.need,
|
||||
isListed: false,
|
||||
stage: 'NEW',
|
||||
companyId,
|
||||
pointOfContactId,
|
||||
};
|
||||
if (requirements !== null) {
|
||||
opportunityData.requirements = requirements;
|
||||
}
|
||||
|
||||
const result = await client.mutation({
|
||||
createOpportunity: { __args: { data: opportunityData }, id: true },
|
||||
});
|
||||
const opportunityId = result.createOpportunity?.id;
|
||||
if (opportunityId === undefined) {
|
||||
throw new Error('createOpportunity did not return an id');
|
||||
}
|
||||
|
||||
return { ok: true, opportunityId };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: SUBMIT_CLIENT_BRIEF_LOGIC_FUNCTION_ID,
|
||||
name: 'submit-client-brief',
|
||||
description: 'Create an unlisted Opportunity from the public marketplace brief form.',
|
||||
timeoutSeconds: 15,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/client-briefs',
|
||||
httpMethod: 'POST',
|
||||
isAuthRequired: false,
|
||||
forwardedRequestHeaders: [APPLICATION_SECRET_HEADER],
|
||||
},
|
||||
});
|
||||
-337
@@ -1,337 +0,0 @@
|
||||
import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { slugify } from '../scripts/slugify';
|
||||
import { deriveDeploymentExpertise } from './derive-deployment-expertise';
|
||||
import { deriveRegion } from './derive-region';
|
||||
|
||||
export const SUBMIT_PARTNER_APPLICATION_LOGIC_FUNCTION_ID =
|
||||
'7b1e2c5f-3a14-4f7d-8e91-0b5e2a3c4d76';
|
||||
|
||||
export const PARTNER_COUNTRY_VALUES = [
|
||||
'AFGHANISTAN','ALBANIA','ALGERIA','ANDORRA','ANGOLA','ANTIGUA_AND_BARBUDA','ARGENTINA','ARMENIA','AUSTRALIA','AUSTRIA','AZERBAIJAN','BAHAMAS','BAHRAIN','BANGLADESH','BARBADOS','BELARUS','BELGIUM','BELIZE','BENIN','BHUTAN','BOLIVIA','BOSNIA_AND_HERZEGOVINA','BOTSWANA','BRAZIL','BRUNEI','BULGARIA','BURKINA_FASO','BURUNDI','CAMBODIA','CAMEROON','CANADA','CAPE_VERDE','CENTRAL_AFRICAN_REPUBLIC','CHAD','CHILE','CHINA','COLOMBIA','COMOROS','CONGO','DR_CONGO','COSTA_RICA','CROATIA','CUBA','CYPRUS','CZECH_REPUBLIC','DENMARK','DJIBOUTI','DOMINICA','DOMINICAN_REPUBLIC','ECUADOR','EGYPT','EL_SALVADOR','EQUATORIAL_GUINEA','ERITREA','ESTONIA','ESWATINI','ETHIOPIA','FIJI','FINLAND','FRANCE','GABON','GAMBIA','GEORGIA','GERMANY','GHANA','GREECE','GRENADA','GUATEMALA','GUINEA','GUINEA_BISSAU','GUYANA','HAITI','HONDURAS','HUNGARY','ICELAND','INDIA','INDONESIA','IRAN','IRAQ','IRELAND','ISRAEL','ITALY','IVORY_COAST','JAMAICA','JAPAN','JORDAN','KAZAKHSTAN','KENYA','KIRIBATI','KOSOVO','KUWAIT','KYRGYZSTAN','LAOS','LATVIA','LEBANON','LESOTHO','LIBERIA','LIBYA','LIECHTENSTEIN','LITHUANIA','LUXEMBOURG','MADAGASCAR','MALAWI','MALAYSIA','MALDIVES','MALI','MALTA','MARSHALL_ISLANDS','MAURITANIA','MAURITIUS','MEXICO','MICRONESIA','MOLDOVA','MONACO','MONGOLIA','MONTENEGRO','MOROCCO','MOZAMBIQUE','MYANMAR','NAMIBIA','NAURU','NEPAL','NETHERLANDS','NEW_ZEALAND','NICARAGUA','NIGER','NIGERIA','NORTH_KOREA','NORTH_MACEDONIA','NORWAY','OMAN','PAKISTAN','PALAU','PALESTINE','PANAMA','PAPUA_NEW_GUINEA','PARAGUAY','PERU','PHILIPPINES','POLAND','PORTUGAL','QATAR','ROMANIA','RUSSIA','RWANDA','SAINT_KITTS_AND_NEVIS','SAINT_LUCIA','SAINT_VINCENT','SAMOA','SAN_MARINO','SAO_TOME_AND_PRINCIPE','SAUDI_ARABIA','SENEGAL','SERBIA','SEYCHELLES','SIERRA_LEONE','SINGAPORE','SLOVAKIA','SLOVENIA','SOLOMON_ISLANDS','SOMALIA','SOUTH_AFRICA','SOUTH_KOREA','SOUTH_SUDAN','SPAIN','SRI_LANKA','SUDAN','SURINAME','SWEDEN','SWITZERLAND','SYRIA','TAIWAN','TAJIKISTAN','TANZANIA','THAILAND','TIMOR_LESTE','TOGO','TONGA','TRINIDAD_AND_TOBAGO','TUNISIA','TURKEY','TURKMENISTAN','TUVALU','UGANDA','UKRAINE','UNITED_ARAB_EMIRATES','UNITED_KINGDOM','UNITED_STATES','URUGUAY','UZBEKISTAN','VANUATU','VATICAN','VENEZUELA','VIETNAM','YEMEN','ZAMBIA','ZIMBABWE',
|
||||
] as const;
|
||||
|
||||
const PARTNER_LANGUAGE_VALUES = [
|
||||
'ENGLISH','FRENCH','GERMAN','SPANISH','PORTUGUESE','ITALIAN','DUTCH','ARABIC','CHINESE','JAPANESE','RUSSIAN','HINDI',
|
||||
] as const;
|
||||
|
||||
const PARTNER_SCOPE_VALUES = [
|
||||
'ADVISORY','SOLUTIONING','DEVELOPMENT','HOSTING','SUPPORT',
|
||||
] as const;
|
||||
const PARTNER_TYPE_OF_TEAM_VALUES = ['SOLO','AGENCY'] as const;
|
||||
|
||||
// The request contract. zod is the single source of truth: it validates the
|
||||
// incoming body at runtime and the input type is inferred from it, so the two
|
||||
// can never drift. Enum-valued fields are constrained to the same option sets
|
||||
// the Partner object accepts.
|
||||
export const submitPartnerApplicationSchema = z.object({
|
||||
firstName: z.string().trim().min(1),
|
||||
lastName: z.string(),
|
||||
email: z.string().trim().min(1),
|
||||
companyName: z.string().trim().min(1),
|
||||
domainName: z.string().optional(),
|
||||
linkedin: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
country: z.enum(PARTNER_COUNTRY_VALUES).optional(),
|
||||
languages: z.array(z.enum(PARTNER_LANGUAGE_VALUES)).optional(),
|
||||
typeOfTeam: z.enum(PARTNER_TYPE_OF_TEAM_VALUES).optional(),
|
||||
partnerScope: z.array(z.enum(PARTNER_SCOPE_VALUES)).optional(),
|
||||
skills: z.array(z.string()).optional(),
|
||||
applicationNotes: z.string().optional(),
|
||||
hourlyRate: z.number().optional(),
|
||||
projectBudgetMin: z.number().optional(),
|
||||
calendarLink: z.string().optional(),
|
||||
});
|
||||
|
||||
export type SubmitPartnerApplicationInput = z.infer<
|
||||
typeof submitPartnerApplicationSchema
|
||||
>;
|
||||
|
||||
export type SubmitPartnerApplicationResult =
|
||||
| { ok: true; created: boolean; partnerId: string }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function toMicros(usd: number | undefined): { amountMicros: number; currencyCode: 'USD' } | undefined {
|
||||
if (typeof usd !== 'number' || !Number.isFinite(usd) || usd < 0) return undefined;
|
||||
return { amountMicros: Math.round(usd * 1_000_000), currencyCode: 'USD' };
|
||||
}
|
||||
|
||||
function buildApplicationNotes(input: SubmitPartnerApplicationInput): string | null {
|
||||
return isNonEmptyString(input.applicationNotes) ? input.applicationNotes.trim() : null;
|
||||
}
|
||||
|
||||
// Mirrors the subset of Partner{Create,Update}Input this handler writes. The
|
||||
// enum-typed columns are narrowed from the validated string inputs below.
|
||||
type PartnerFieldsForUpsert = {
|
||||
name: string;
|
||||
linkedin?: { primaryLinkUrl: string };
|
||||
website?: { primaryLinkUrl: string };
|
||||
city?: string;
|
||||
country?: CoreSchema.PartnerCountryEnum;
|
||||
languagesSpoken?: CoreSchema.PartnerLanguagesSpokenEnum[];
|
||||
typeOfTeam?: CoreSchema.PartnerTypeOfTeamEnum;
|
||||
partnerScope?: CoreSchema.PartnerPartnerScopeEnum[];
|
||||
skills?: string[];
|
||||
hourlyRate?: { amountMicros: number; currencyCode: 'USD' };
|
||||
projectBudgetMin?: { amountMicros: number; currencyCode: 'USD' };
|
||||
calendarLink?: { primaryLinkUrl: string };
|
||||
applicationNotes?: string | null;
|
||||
};
|
||||
|
||||
function buildPartnerFields(input: SubmitPartnerApplicationInput): PartnerFieldsForUpsert {
|
||||
const fields: PartnerFieldsForUpsert = {
|
||||
name: input.companyName.trim(),
|
||||
};
|
||||
if (isNonEmptyString(input.linkedin)) fields.linkedin = { primaryLinkUrl: input.linkedin.trim() };
|
||||
if (isNonEmptyString(input.domainName)) fields.website = { primaryLinkUrl: input.domainName.trim() };
|
||||
if (isNonEmptyString(input.city)) fields.city = input.city.trim();
|
||||
// validate() has already checked these against the allowed value sets, so
|
||||
// narrowing the validated strings to their enum types here is sound.
|
||||
if (input.country !== undefined) fields.country = input.country as CoreSchema.PartnerCountryEnum;
|
||||
if (input.languages !== undefined && input.languages.length > 0)
|
||||
fields.languagesSpoken = input.languages as CoreSchema.PartnerLanguagesSpokenEnum[];
|
||||
if (input.typeOfTeam !== undefined) fields.typeOfTeam = input.typeOfTeam as CoreSchema.PartnerTypeOfTeamEnum;
|
||||
if (input.partnerScope !== undefined && input.partnerScope.length > 0)
|
||||
fields.partnerScope = input.partnerScope as CoreSchema.PartnerPartnerScopeEnum[];
|
||||
if (input.skills !== undefined && input.skills.length > 0) fields.skills = input.skills.filter(isNonEmptyString);
|
||||
const hourly = toMicros(input.hourlyRate);
|
||||
if (hourly) fields.hourlyRate = hourly;
|
||||
const min = toMicros(input.projectBudgetMin);
|
||||
if (min) fields.projectBudgetMin = min;
|
||||
if (isNonEmptyString(input.calendarLink)) fields.calendarLink = { primaryLinkUrl: input.calendarLink.trim() };
|
||||
const notes = buildApplicationNotes(input);
|
||||
if (notes !== null) fields.applicationNotes = notes;
|
||||
return fields;
|
||||
}
|
||||
|
||||
function normalizeDomainHost(
|
||||
value: string | null | undefined,
|
||||
): string | undefined {
|
||||
if (!isNonEmptyString(value)) return undefined;
|
||||
const host = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, '')
|
||||
.replace(/^www\./, '')
|
||||
.replace(/[/:?#].*$/, '');
|
||||
return host.length > 0 ? host : undefined;
|
||||
}
|
||||
|
||||
// ponytail: matches active rows only — soft-deleted companies still hold the unique index; clear those with `yarn purge:prod`.
|
||||
async function findOrCreateCompanyId(
|
||||
client: CoreApiClient,
|
||||
input: SubmitPartnerApplicationInput,
|
||||
): Promise<string> {
|
||||
const domain = isNonEmptyString(input.domainName)
|
||||
? input.domainName.trim()
|
||||
: undefined;
|
||||
const host = normalizeDomainHost(domain);
|
||||
|
||||
if (host !== undefined) {
|
||||
// Broad ilike catches every stored URL form (bare, any protocol, paths, www).
|
||||
// Paginate to exhaustion so the real match is never paged out; client-side
|
||||
// normalization rejects false positives on each page.
|
||||
let cursor: string | null = null;
|
||||
do {
|
||||
const existing = await client.query({
|
||||
companies: {
|
||||
__args: {
|
||||
filter: { domainName: { primaryLinkUrl: { ilike: `%${host}%` } } },
|
||||
first: 20,
|
||||
...(cursor !== null ? { after: cursor } : {}),
|
||||
},
|
||||
pageInfo: { hasNextPage: true, endCursor: true },
|
||||
edges: { node: { id: true, domainName: { primaryLinkUrl: true } } },
|
||||
},
|
||||
});
|
||||
const match = existing.companies?.edges?.find(
|
||||
(edge) => normalizeDomainHost(edge.node.domainName?.primaryLinkUrl) === host,
|
||||
);
|
||||
if (match !== undefined) {
|
||||
return match.node.id;
|
||||
}
|
||||
const pageInfo = existing.companies?.pageInfo;
|
||||
cursor = pageInfo?.hasNextPage ? (pageInfo.endCursor ?? null) : null;
|
||||
} while (cursor !== null);
|
||||
}
|
||||
|
||||
const companyData: CoreSchema.CompanyCreateInput = {
|
||||
name: input.companyName.trim(),
|
||||
};
|
||||
if (domain !== undefined) {
|
||||
companyData.domainName = { primaryLinkUrl: domain };
|
||||
}
|
||||
const companyResult = await client.mutation({
|
||||
createCompany: { __args: { data: companyData }, id: true },
|
||||
});
|
||||
const companyId = companyResult.createCompany?.id;
|
||||
if (companyId === undefined) {
|
||||
throw new Error('createCompany did not return an id');
|
||||
}
|
||||
return companyId;
|
||||
}
|
||||
|
||||
type SubmitPartnerApplicationEvent = {
|
||||
headers?: Record<string, string | undefined>;
|
||||
body?: unknown;
|
||||
};
|
||||
|
||||
const APPLICATION_SECRET_HEADER = 'x-application-secret';
|
||||
|
||||
export const handler = async (
|
||||
event: SubmitPartnerApplicationEvent | SubmitPartnerApplicationInput,
|
||||
): Promise<SubmitPartnerApplicationResult> => {
|
||||
// Accept either { body, headers } (HTTP) or a flat input object (direct call from tests).
|
||||
const looksLikeEvent =
|
||||
typeof event === 'object' &&
|
||||
event !== null &&
|
||||
('body' in event || 'headers' in event);
|
||||
|
||||
const headers = looksLikeEvent
|
||||
? (event as SubmitPartnerApplicationEvent).headers ?? {}
|
||||
: {};
|
||||
const rawInput = looksLikeEvent
|
||||
? (event as SubmitPartnerApplicationEvent).body
|
||||
: event;
|
||||
|
||||
// Shared-secret guard. The Twenty SDK's isAuthRequired flag only accepts
|
||||
// user-session JWTs, not workspace API keys, so we authenticate at the
|
||||
// handler level via a custom header allowlisted in forwardedRequestHeaders.
|
||||
const expectedSecret = process.env.PARTNER_APPLICATION_SECRET;
|
||||
if (!isNonEmptyString(expectedSecret)) {
|
||||
return { ok: false, reason: 'unauthorized' };
|
||||
}
|
||||
const providedSecret = headers[APPLICATION_SECRET_HEADER];
|
||||
if (providedSecret !== expectedSecret) {
|
||||
return { ok: false, reason: 'unauthorized' };
|
||||
}
|
||||
|
||||
const parsed = submitPartnerApplicationSchema.safeParse(rawInput);
|
||||
if (!parsed.success) {
|
||||
return { ok: false, reason: 'invalid_input' };
|
||||
}
|
||||
const input = parsed.data;
|
||||
|
||||
try {
|
||||
const client = new CoreApiClient();
|
||||
const email = input.email.trim();
|
||||
const partnerFields = buildPartnerFields(input);
|
||||
|
||||
const personLookup = await client.query({
|
||||
people: {
|
||||
__args: {
|
||||
filter: { emails: { primaryEmail: { eq: email } } },
|
||||
first: 1,
|
||||
},
|
||||
edges: {
|
||||
node: {
|
||||
id: true,
|
||||
partner: { id: true, company: { id: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const existingEdge = personLookup.people?.edges?.[0]?.node;
|
||||
|
||||
if (existingEdge && existingEdge.partner) {
|
||||
const partnerId = existingEdge.partner.id;
|
||||
await client.mutation({
|
||||
updatePartner: {
|
||||
__args: { id: partnerId, data: partnerFields },
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
await client.mutation({
|
||||
updatePerson: {
|
||||
__args: {
|
||||
id: existingEdge.id,
|
||||
data: {
|
||||
name: { firstName: input.firstName.trim(), lastName: input.lastName.trim() },
|
||||
},
|
||||
},
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
return { ok: true, created: false, partnerId };
|
||||
}
|
||||
|
||||
const companyId = await findOrCreateCompanyId(client, input);
|
||||
|
||||
const region = deriveRegion(input.country);
|
||||
const partnerResult = await client.mutation({
|
||||
createPartner: {
|
||||
__args: {
|
||||
data: {
|
||||
...partnerFields,
|
||||
slug: slugify(input.companyName),
|
||||
validationStage: 'APPLICATION',
|
||||
reviewed: false,
|
||||
partnerTier: 'NEW',
|
||||
companyId,
|
||||
deploymentExpertise: deriveDeploymentExpertise(input.partnerScope) as CoreSchema.PartnerDeploymentExpertiseEnum[],
|
||||
...(region ? { region: [region] as CoreSchema.PartnerRegionEnum[] } : {}),
|
||||
},
|
||||
},
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
const partnerId = partnerResult.createPartner?.id;
|
||||
if (partnerId === undefined) {
|
||||
throw new Error('createPartner did not return an id');
|
||||
}
|
||||
|
||||
if (existingEdge) {
|
||||
await client.mutation({
|
||||
updatePerson: {
|
||||
__args: {
|
||||
id: existingEdge.id,
|
||||
data: {
|
||||
partnerId,
|
||||
name: { firstName: input.firstName.trim(), lastName: input.lastName.trim() },
|
||||
},
|
||||
},
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await client.mutation({
|
||||
createPerson: {
|
||||
__args: {
|
||||
data: {
|
||||
name: { firstName: input.firstName.trim(), lastName: input.lastName.trim() },
|
||||
emails: { primaryEmail: email },
|
||||
partnerId,
|
||||
companyId,
|
||||
},
|
||||
},
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: true, created: true, partnerId };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: SUBMIT_PARTNER_APPLICATION_LOGIC_FUNCTION_ID,
|
||||
name: 'submit-partner-application',
|
||||
description: 'Receive a partner application from the website and idempotently upsert Partner / Person / Company.',
|
||||
timeoutSeconds: 15,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/partner-applications',
|
||||
httpMethod: 'POST',
|
||||
isAuthRequired: false,
|
||||
forwardedRequestHeaders: [APPLICATION_SECRET_HEADER],
|
||||
},
|
||||
});
|
||||
-90
@@ -1,90 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
+1
-1
@@ -11,7 +11,7 @@ vi.mock('twenty-client-sdk/core', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
import { handler } from '../on-application-created';
|
||||
import { handler } from '../on-application-created.logic-function';
|
||||
|
||||
const MEMBER_ID = 'aaaaaaaa-1111-1111-1111-111111111111';
|
||||
const PARTNER_ID = 'bbbbbbbb-2222-2222-2222-222222222222';
|
||||
+1
-1
@@ -11,7 +11,7 @@ vi.mock('twenty-client-sdk/core', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
import { handler } from '../on-application-set-name';
|
||||
import { handler } from '../on-application-set-name.logic-function';
|
||||
|
||||
const event = (after: Record<string, unknown>, updatedFields: string[]) =>
|
||||
({ properties: { after, updatedFields } }) as never;
|
||||
+1
-1
@@ -9,7 +9,7 @@ import {
|
||||
APPLICATIONS_AS_PARTNER_USER_FIELD_ID,
|
||||
APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
APPLICATION_PARTNER_USER_FIELD_ID,
|
||||
} from 'src/objects/application.object';
|
||||
} from 'src/modules/application/objects/application.object';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: APPLICATIONS_AS_PARTNER_USER_FIELD_ID,
|
||||
+1
-1
@@ -5,7 +5,7 @@ import {
|
||||
APPLICATIONS_ON_PARTNER_FIELD_ID,
|
||||
APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
APPLICATION_PARTNER_FIELD_ID,
|
||||
} from 'src/objects/application.object';
|
||||
} from 'src/modules/application/objects/application.object';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: APPLICATIONS_ON_PARTNER_FIELD_ID,
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { type CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
export function deleteApplication(client: CoreApiClient, id: string) {
|
||||
return client.mutation({
|
||||
deleteApplication: {
|
||||
__args: { id },
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { type CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
|
||||
|
||||
export function updateApplication(
|
||||
client: CoreApiClient,
|
||||
id: string,
|
||||
data: CoreSchema.ApplicationUpdateInput,
|
||||
) {
|
||||
return client.mutation({
|
||||
updateApplication: { __args: { id, data }, id: true },
|
||||
});
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { type CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
export function findApplicationWithRelations(client: CoreApiClient, id: string) {
|
||||
return client.query({
|
||||
application: {
|
||||
__args: { filter: { id: { eq: id } } },
|
||||
id: true,
|
||||
partner: { name: true },
|
||||
opportunity: { name: true },
|
||||
},
|
||||
});
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { type CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
export function findDuplicateApplication(
|
||||
client: CoreApiClient,
|
||||
opportunityId: string,
|
||||
partnerId: string,
|
||||
) {
|
||||
return client.query({
|
||||
applications: {
|
||||
__args: {
|
||||
filter: {
|
||||
opportunityId: { eq: opportunityId },
|
||||
partnerId: { eq: partnerId },
|
||||
},
|
||||
first: 1,
|
||||
},
|
||||
edges: { node: { id: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { type CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
export function findPartnerByMember(client: CoreApiClient, memberId: string) {
|
||||
return client.query({
|
||||
partners: {
|
||||
__args: { filter: { partnerUserId: { eq: memberId } }, first: 1 },
|
||||
edges: { node: { id: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
|
||||
|
||||
import { APPLICATIONS_BY_OPPORTUNITY_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/applications-by-opportunity.view';
|
||||
import { APPLICATIONS_BY_OPPORTUNITY_VIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/application/views/applications-by-opportunity.view';
|
||||
|
||||
export default defineNavigationMenuItem({
|
||||
universalIdentifier: 'e80057bf-9b46-4502-8ccd-b25870ec293c',
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
|
||||
|
||||
import { APPLICATIONS_REVIEW_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/applications-review.view';
|
||||
import { APPLICATIONS_REVIEW_VIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/application/views/applications-review.view';
|
||||
|
||||
export default defineNavigationMenuItem({
|
||||
universalIdentifier: 'fcf7e5e8-9ec2-4f08-898a-39e51a2787d0',
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
|
||||
|
||||
import { FOLLOWUP_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/followup-applications.view';
|
||||
import { FOLLOWUP_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/application/views/followup-applications.view';
|
||||
|
||||
export default defineNavigationMenuItem({
|
||||
universalIdentifier: '636cddf9-b104-432d-a5d3-3bad36b7a54a',
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
|
||||
|
||||
import { MY_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/my-applications.view';
|
||||
import { MY_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/application/views/my-applications.view';
|
||||
|
||||
import { PARTNER_WORKSPACE_FOLDER_UNIVERSAL_IDENTIFIER } from './partner-workspace-folder.navigation-menu-item';
|
||||
import { PARTNER_WORKSPACE_FOLDER_UNIVERSAL_IDENTIFIER } from 'src/modules/shared/navigation-menu-items/partner-workspace-folder.navigation-menu-item';
|
||||
|
||||
export default defineNavigationMenuItem({
|
||||
universalIdentifier: '71c418f0-5761-4797-830a-2cae6d4bdd49',
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
|
||||
import {
|
||||
type DatabaseEventPayload,
|
||||
defineLogicFunction,
|
||||
type ObjectRecordCreateEvent,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import { resolveCandidacy } from 'src/modules/application/services/resolve-candidacy.service';
|
||||
|
||||
const ON_APPLICATION_CREATED_FN_ID = '0e055a1c-b8b3-4572-89f3-e76e37bc3f9e';
|
||||
|
||||
export const handler = async (
|
||||
payload: DatabaseEventPayload<ObjectRecordCreateEvent<CoreSchema.Application>>,
|
||||
): Promise<Record<string, unknown>> =>
|
||||
resolveCandidacy(new CoreApiClient(), payload.properties.after);
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: ON_APPLICATION_CREATED_FN_ID,
|
||||
name: 'on-application-created',
|
||||
timeoutSeconds: 15,
|
||||
handler,
|
||||
databaseEventTriggerSettings: { eventName: 'application.created' },
|
||||
});
|
||||
+6
-15
@@ -5,6 +5,9 @@ import {
|
||||
type ObjectRecordUpdateEvent,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import { findApplicationWithRelations } from 'src/modules/application/graphql/queries/find-application-with-relations';
|
||||
import { updateApplication } from 'src/modules/application/graphql/mutations/update-application';
|
||||
|
||||
const ON_APPLICATION_SET_NAME_FN_ID = '8f123b54-a7c5-42d3-8049-a240930257a7';
|
||||
|
||||
// Re-label an Application "<partner> · <opportunity>" whenever either relation changes.
|
||||
@@ -12,30 +15,18 @@ export const handler = async (
|
||||
payload: DatabaseEventPayload<ObjectRecordUpdateEvent<CoreSchema.Application>>,
|
||||
): Promise<Record<string, unknown>> => {
|
||||
const { after, updatedFields } = payload.properties;
|
||||
const touchedRelation =
|
||||
updatedFields?.includes('partnerId') ||
|
||||
updatedFields?.includes('opportunityId');
|
||||
const touchedRelation = updatedFields?.includes('partnerId') || updatedFields?.includes('opportunityId');
|
||||
if (!touchedRelation) return {};
|
||||
const id = after?.id;
|
||||
if (!id) return {};
|
||||
|
||||
const client = new CoreApiClient();
|
||||
const result = await client.query({
|
||||
application: {
|
||||
__args: { filter: { id: { eq: id } } },
|
||||
id: true,
|
||||
partner: { name: true },
|
||||
opportunity: { name: true },
|
||||
},
|
||||
});
|
||||
const result = await findApplicationWithRelations(client, id);
|
||||
|
||||
const partnerName = result.application?.partner?.name ?? 'Unassigned';
|
||||
const opportunityName = result.application?.opportunity?.name ?? 'No brief';
|
||||
const name = `${partnerName} · ${opportunityName}`;
|
||||
|
||||
await client.mutation({
|
||||
updateApplication: { __args: { id, data: { name } }, id: true },
|
||||
});
|
||||
await updateApplication(client, id, { name });
|
||||
return { labelled: name };
|
||||
};
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { PageLayoutTabLayoutMode, definePageLayout } from 'twenty-sdk/define';
|
||||
|
||||
import { APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/application.object';
|
||||
import { APPLICATION_RECORD_PAGE_FIELDS_VIEW_ID } from 'src/views/application-record-page-fields.view';
|
||||
import { APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/modules/application/objects/application.object';
|
||||
import { APPLICATION_RECORD_PAGE_FIELDS_VIEW_ID } from 'src/modules/application/views/application-record-page-fields.view';
|
||||
|
||||
// Application is a custom (app-owned) object, so we fully control its record page (unlike the
|
||||
// standard Opportunity). The Fields widget points at the FIELDS_WIDGET view so the opportunity +
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import type { CoreApiClient, CoreSchema } from 'twenty-client-sdk/core';
|
||||
import type {
|
||||
DatabaseEventPayload,
|
||||
ObjectRecordCreateEvent,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import { findPartnerByMember } from 'src/modules/application/graphql/queries/find-partner-by-member';
|
||||
import { findDuplicateApplication } from 'src/modules/application/graphql/queries/find-duplicate-application';
|
||||
import { deleteApplication } from 'src/modules/application/graphql/mutations/delete-application';
|
||||
import { updateApplication } from 'src/modules/application/graphql/mutations/update-application';
|
||||
|
||||
type ApplicationCreatedProperties = DatabaseEventPayload<
|
||||
ObjectRecordCreateEvent<CoreSchema.Application>
|
||||
>['properties'];
|
||||
|
||||
// A partner self-applies via the "Apply to brief as partner" workflow: a Create Record action
|
||||
// makes an Application with the opportunity set and createdBy = the clicking member, but no
|
||||
// partner. Resolve the partner from createdBy and complete the candidacy. Admin-created
|
||||
// applications (partner already set, or the creator is not a partner) are left untouched. The
|
||||
// name is set by on-application-set-name, which fires on the partnerId update below.
|
||||
export async function resolveCandidacy(
|
||||
client: CoreApiClient,
|
||||
after: ApplicationCreatedProperties['after'],
|
||||
): Promise<Record<string, unknown>> {
|
||||
const applicationId = after?.id;
|
||||
if (!applicationId) return {};
|
||||
if (after.partnerId) return {}; // already linked (admin path) — leave it
|
||||
|
||||
const memberId = after.createdBy?.workspaceMemberId;
|
||||
if (!memberId) return {}; // no member actor (system/import) — not a self-apply
|
||||
|
||||
const partnerRes = await findPartnerByMember(client, memberId);
|
||||
const partnerId = partnerRes.partners?.edges?.[0]?.node?.id;
|
||||
if (!partnerId) return {}; // creator isn't a partner (e.g. admin) — leave it
|
||||
|
||||
const opportunityId = after.opportunityId;
|
||||
if (opportunityId) {
|
||||
const existingRes = await findDuplicateApplication(client, opportunityId, partnerId);
|
||||
const existingId = existingRes.applications?.edges?.find(
|
||||
(edge) => edge.node?.id && edge.node.id !== applicationId,
|
||||
)?.node?.id;
|
||||
if (existingId) {
|
||||
await deleteApplication(client, applicationId);
|
||||
return { duplicate: true, keptExisting: existingId };
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
await updateApplication(client, applicationId, {
|
||||
partnerId,
|
||||
partnerUserId: memberId,
|
||||
state: 'APPLIED',
|
||||
lastActivityAt: now,
|
||||
});
|
||||
// ponytail: dedupe by (opportunity, partner) above; two near-simultaneous creates could still both pass before either stamps — acceptable.
|
||||
return { applied: true, partnerId };
|
||||
}
|
||||
+1
-1
@@ -7,7 +7,7 @@ import {
|
||||
APPLICATION_PARTNER_FIELD_ID,
|
||||
APPLICATION_PITCH_FIELD_ID,
|
||||
APPLICATION_STATE_FIELD_ID,
|
||||
} from 'src/objects/application.object';
|
||||
} from 'src/modules/application/objects/application.object';
|
||||
|
||||
export const APPLICATION_RECORD_PAGE_FIELDS_VIEW_ID =
|
||||
'e004c2ff-462a-45d4-8a77-071dcf093879';
|
||||
+1
-1
@@ -7,7 +7,7 @@ import {
|
||||
APPLICATION_PARTNER_FIELD_ID,
|
||||
APPLICATION_PITCH_FIELD_ID,
|
||||
APPLICATION_STATE_FIELD_ID,
|
||||
} from 'src/objects/application.object';
|
||||
} from 'src/modules/application/objects/application.object';
|
||||
|
||||
export const APPLICATIONS_BY_OPPORTUNITY_VIEW_UNIVERSAL_IDENTIFIER =
|
||||
'db489249-284d-41b1-8537-71a655389cb7';
|
||||
+1
-1
@@ -8,7 +8,7 @@ import {
|
||||
APPLICATION_PARTNER_FIELD_ID,
|
||||
APPLICATION_PITCH_FIELD_ID,
|
||||
APPLICATION_STATE_FIELD_ID,
|
||||
} from 'src/objects/application.object';
|
||||
} from 'src/modules/application/objects/application.object';
|
||||
|
||||
export const APPLICATIONS_REVIEW_VIEW_UNIVERSAL_IDENTIFIER =
|
||||
'cc652ba3-23e8-4e0e-a616-c9fa50b702a0';
|
||||
+1
-1
@@ -8,7 +8,7 @@ import {
|
||||
APPLICATION_PARTNER_FIELD_ID,
|
||||
APPLICATION_PITCH_FIELD_ID,
|
||||
APPLICATION_STATE_FIELD_ID,
|
||||
} from 'src/objects/application.object';
|
||||
} from 'src/modules/application/objects/application.object';
|
||||
|
||||
export const FOLLOWUP_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER =
|
||||
'c815e9f5-511b-467f-85b0-08ef341ff856';
|
||||
+1
-1
@@ -6,7 +6,7 @@ import {
|
||||
APPLICATION_OPPORTUNITY_FIELD_ID,
|
||||
APPLICATION_PITCH_FIELD_ID,
|
||||
APPLICATION_STATE_FIELD_ID,
|
||||
} from 'src/objects/application.object';
|
||||
} from 'src/modules/application/objects/application.object';
|
||||
|
||||
export const MY_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER =
|
||||
'cba45e02-b3a7-420f-ace5-5e2773076080';
|
||||
+1
-1
@@ -9,7 +9,7 @@ import {
|
||||
APPLICATIONS_ON_OPPORTUNITY_FIELD_ID,
|
||||
APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
APPLICATION_OPPORTUNITY_FIELD_ID,
|
||||
} from 'src/objects/application.object';
|
||||
} from 'src/modules/application/objects/application.object';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: APPLICATIONS_ON_OPPORTUNITY_FIELD_ID,
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import type { CoreApiClient, CoreSchema } from 'twenty-client-sdk/core';
|
||||
|
||||
export function createOpportunity(
|
||||
client: CoreApiClient,
|
||||
data: CoreSchema.OpportunityCreateInput,
|
||||
) {
|
||||
return client.mutation({
|
||||
createOpportunity: { __args: { data }, id: true },
|
||||
});
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import type { CoreApiClient, CoreSchema } from 'twenty-client-sdk/core';
|
||||
|
||||
export function findOpportunityByDedupeKey(
|
||||
client: CoreApiClient,
|
||||
filter: CoreSchema.OpportunityFilterInput,
|
||||
) {
|
||||
return client.query({
|
||||
opportunities: {
|
||||
__args: { filter, first: 1 },
|
||||
edges: { node: { id: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
APPLICATION_SECRET_HEADER,
|
||||
readSecretGuardedEvent,
|
||||
} from 'src/modules/shared/http/read-secret-guarded-event';
|
||||
import { importOpportunityFromTftSchema } from 'src/modules/opportunity/intake/mappers/import-opportunity-from-tft.mapper';
|
||||
import {
|
||||
importOpportunityFromTft,
|
||||
type ImportOpportunityFromTftResult,
|
||||
} from 'src/modules/opportunity/intake/services/import-opportunity-from-tft.service';
|
||||
|
||||
export const IMPORT_OPPORTUNITY_FROM_TFT_LOGIC_FUNCTION_ID =
|
||||
'4c220eaf-a23f-4af2-8d69-38a6c460019f';
|
||||
|
||||
// isAuthRequired only accepts user JWTs, not API keys; guard with the shared secret.
|
||||
export const handler = async (event: unknown): Promise<ImportOpportunityFromTftResult> => {
|
||||
const guard = readSecretGuardedEvent(event, importOpportunityFromTftSchema);
|
||||
if (!guard.ok) return { ok: false, reason: guard.reason };
|
||||
return importOpportunityFromTft(guard.input);
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: IMPORT_OPPORTUNITY_FROM_TFT_LOGIC_FUNCTION_ID,
|
||||
name: 'import-opportunity-from-tft',
|
||||
description:
|
||||
'Receive one opportunity pushed from the TFT workspace and create it in partners (find-or-create company + contact, idempotent on tftOpportunityId).',
|
||||
timeoutSeconds: 15,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/opportunities',
|
||||
httpMethod: 'POST',
|
||||
isAuthRequired: false,
|
||||
forwardedRequestHeaders: [APPLICATION_SECRET_HEADER],
|
||||
},
|
||||
});
|
||||
+2
-4
@@ -13,10 +13,8 @@ vi.mock('twenty-client-sdk/core', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
handler,
|
||||
type ImportOpportunityFromTftInput,
|
||||
} from '../import-opportunity-from-tft.logic-function';
|
||||
import { handler } from './import-opportunity-from-tft.logic-function';
|
||||
import { type ImportOpportunityFromTftInput } from './mappers/import-opportunity-from-tft.mapper';
|
||||
|
||||
const SECRET = 'test-secret-abc123';
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { isNonEmptyString } from 'src/modules/shared/utils/is-non-empty-string.util';
|
||||
|
||||
export const submitClientBriefSchema = z.object({
|
||||
firstName: z.string().trim().min(1),
|
||||
lastName: z.string(),
|
||||
email: z.string().trim().email(),
|
||||
companyName: z.string().trim().min(1),
|
||||
need: z.string().trim().min(1),
|
||||
requirements: z.string().optional(),
|
||||
hostingType: z.enum(['CLOUD', 'SELF_HOSTING']).optional(),
|
||||
country: z.string().optional(),
|
||||
languages: z.array(z.string()).optional(),
|
||||
seatCount: z.string().optional(),
|
||||
timeline: z.string().optional(),
|
||||
budgetRange: z.string().optional(),
|
||||
});
|
||||
|
||||
export type SubmitClientBriefInput = z.infer<typeof submitClientBriefSchema>;
|
||||
|
||||
const HOSTING_LABEL: Record<'CLOUD' | 'SELF_HOSTING', string> = {
|
||||
CLOUD: 'Cloud',
|
||||
SELF_HOSTING: 'Self-hosting',
|
||||
};
|
||||
|
||||
export function buildRequirementsText(input: SubmitClientBriefInput): string | null {
|
||||
const base = isNonEmptyString(input.requirements) ? input.requirements.trim() : '';
|
||||
const bullets: string[] = [];
|
||||
if (input.hostingType !== undefined) {
|
||||
bullets.push(`• Hosting: ${HOSTING_LABEL[input.hostingType]}`);
|
||||
}
|
||||
if (isNonEmptyString(input.seatCount)) bullets.push(`• Seats: ${input.seatCount.trim()}`);
|
||||
if (isNonEmptyString(input.country)) bullets.push(`• Country: ${input.country.trim()}`);
|
||||
if (input.languages !== undefined && input.languages.length > 0) {
|
||||
bullets.push(`• Languages: ${input.languages.join(', ')}`);
|
||||
}
|
||||
if (isNonEmptyString(input.timeline)) bullets.push(`• Timeline: ${input.timeline.trim()}`);
|
||||
if (isNonEmptyString(input.budgetRange)) bullets.push(`• Budget: ${input.budgetRange.trim()}`);
|
||||
if (bullets.length === 0) return base.length > 0 ? base : null;
|
||||
const block = `---\nAdditional context:\n${bullets.join('\n')}`;
|
||||
return base ? `${base}\n\n${block}` : block;
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import { type CoreSchema } from 'twenty-client-sdk/core';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { isNonEmptyString } from 'src/modules/shared/utils/is-non-empty-string.util';
|
||||
|
||||
// TFT POSTs `null` for empty fields; treat null as "field absent".
|
||||
const dropNulls = (value: unknown): unknown =>
|
||||
value === null
|
||||
? undefined
|
||||
: Array.isArray(value)
|
||||
? value.map(dropNulls)
|
||||
: typeof value === 'object'
|
||||
? Object.fromEntries(
|
||||
Object.entries(value).map(([key, val]) => [key, dropNulls(val)]),
|
||||
)
|
||||
: value;
|
||||
|
||||
// Request contract — the JSON the TFT workflow POSTs.
|
||||
export const importOpportunityFromTftSchema = z.preprocess(dropNulls, z.object({
|
||||
tftOpportunityId: z.string().optional(),
|
||||
name: z.string().trim().min(1),
|
||||
amountMicros: z.number().optional(),
|
||||
currencyCode: z.string().optional(),
|
||||
closeDate: z.string().optional(),
|
||||
stage: z.string().optional(),
|
||||
useCase: z.string().optional(),
|
||||
company: z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
domain: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
pointOfContact: z
|
||||
.object({
|
||||
email: z.string().optional(),
|
||||
firstName: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
}));
|
||||
|
||||
export type ImportOpportunityFromTftInput = z.infer<
|
||||
typeof importOpportunityFromTftSchema
|
||||
>;
|
||||
|
||||
export function mapToOpportunityCreateInput(
|
||||
input: ImportOpportunityFromTftInput,
|
||||
refs: { companyId: string | undefined; pointOfContactId: string | undefined },
|
||||
): CoreSchema.OpportunityCreateInput {
|
||||
const name = input.name.trim();
|
||||
const tftOpportunityId = isNonEmptyString(input.tftOpportunityId)
|
||||
? input.tftOpportunityId.trim()
|
||||
: undefined;
|
||||
|
||||
const opportunityData: CoreSchema.OpportunityCreateInput = { name };
|
||||
if (tftOpportunityId !== undefined) opportunityData.tftOpportunityId = tftOpportunityId;
|
||||
if (input.amountMicros !== undefined) {
|
||||
opportunityData.amount = {
|
||||
amountMicros: input.amountMicros,
|
||||
currencyCode: input.currencyCode ?? 'USD',
|
||||
};
|
||||
}
|
||||
if (isNonEmptyString(input.closeDate)) opportunityData.closeDate = input.closeDate;
|
||||
if (isNonEmptyString(input.stage)) {
|
||||
opportunityData.stage = input.stage as CoreSchema.OpportunityStageEnum;
|
||||
}
|
||||
if (isNonEmptyString(input.useCase)) {
|
||||
opportunityData.need = input.useCase.trim();
|
||||
}
|
||||
if (refs.companyId !== undefined) opportunityData.companyId = refs.companyId;
|
||||
if (refs.pointOfContactId !== undefined) {
|
||||
opportunityData.pointOfContactId = refs.pointOfContactId;
|
||||
}
|
||||
return opportunityData;
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
|
||||
|
||||
import {
|
||||
findCompanyIdByExactName,
|
||||
findPersonIdByPrimaryEmail,
|
||||
} from 'src/modules/shared/services/find-or-create-company-and-person.service';
|
||||
import { isNonEmptyString } from 'src/modules/shared/utils/is-non-empty-string.util';
|
||||
import { createOpportunity } from 'src/modules/opportunity/intake/graphql/mutations/create-opportunity';
|
||||
import { findOpportunityByDedupeKey } from 'src/modules/opportunity/intake/graphql/queries/find-opportunity-by-dedupe-key';
|
||||
import {
|
||||
type ImportOpportunityFromTftInput,
|
||||
mapToOpportunityCreateInput,
|
||||
} from 'src/modules/opportunity/intake/mappers/import-opportunity-from-tft.mapper';
|
||||
|
||||
export type ImportOpportunityFromTftResult =
|
||||
| { ok: true; created: boolean; id: string }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
// Find by exact name, else create. Keeps its own create path (TFT sends a domain and may omit
|
||||
// the name), so the shared find-or-create helper — name-only — cannot express it.
|
||||
async function findOrCreateCompanyId(
|
||||
client: CoreApiClient,
|
||||
company: ImportOpportunityFromTftInput['company'],
|
||||
): Promise<string | undefined> {
|
||||
const name = isNonEmptyString(company?.name) ? company.name.trim() : undefined;
|
||||
const domain = isNonEmptyString(company?.domain) ? company.domain.trim() : undefined;
|
||||
if (name === undefined && domain === undefined) return undefined;
|
||||
|
||||
if (name !== undefined) {
|
||||
const existing = await findCompanyIdByExactName(client, name);
|
||||
if (existing !== undefined) return existing;
|
||||
}
|
||||
|
||||
const companyData: CoreSchema.CompanyCreateInput = { name: name ?? domain! };
|
||||
if (domain !== undefined) companyData.domainName = { primaryLinkUrl: domain };
|
||||
|
||||
const result = await client.mutation({
|
||||
createCompany: { __args: { data: companyData }, id: true },
|
||||
});
|
||||
const id = result.createCompany?.id;
|
||||
if (id === undefined) throw new Error('createCompany did not return an id');
|
||||
return id;
|
||||
}
|
||||
|
||||
// Find by primary email, else create — name-only contacts can't be deduped.
|
||||
async function findOrCreatePersonId(
|
||||
client: CoreApiClient,
|
||||
pointOfContact: ImportOpportunityFromTftInput['pointOfContact'],
|
||||
companyId: string | undefined,
|
||||
): Promise<string | undefined> {
|
||||
const email = isNonEmptyString(pointOfContact?.email)
|
||||
? pointOfContact.email.trim()
|
||||
: undefined;
|
||||
const firstName = isNonEmptyString(pointOfContact?.firstName)
|
||||
? pointOfContact.firstName.trim()
|
||||
: '';
|
||||
const lastName = isNonEmptyString(pointOfContact?.lastName)
|
||||
? pointOfContact.lastName.trim()
|
||||
: '';
|
||||
if (email === undefined && firstName === '' && lastName === '') return undefined;
|
||||
|
||||
if (email !== undefined) {
|
||||
const existing = await findPersonIdByPrimaryEmail(client, email);
|
||||
if (existing !== undefined) return existing;
|
||||
}
|
||||
|
||||
const personData: CoreSchema.PersonCreateInput = { name: { firstName, lastName } };
|
||||
if (email !== undefined) personData.emails = { primaryEmail: email };
|
||||
if (companyId !== undefined) personData.companyId = companyId;
|
||||
|
||||
const result = await client.mutation({
|
||||
createPerson: { __args: { data: personData }, id: true },
|
||||
});
|
||||
const id = result.createPerson?.id;
|
||||
if (id === undefined) throw new Error('createPerson did not return an id');
|
||||
return id;
|
||||
}
|
||||
|
||||
// Manual one-way copy of one Opportunity from the TFT workspace into partners; idempotent on
|
||||
// tftOpportunityId (name as a fallback for manual calls).
|
||||
export async function importOpportunityFromTft(
|
||||
input: ImportOpportunityFromTftInput,
|
||||
): Promise<ImportOpportunityFromTftResult> {
|
||||
try {
|
||||
const client = new CoreApiClient();
|
||||
const name = input.name.trim();
|
||||
const tftOpportunityId = isNonEmptyString(input.tftOpportunityId)
|
||||
? input.tftOpportunityId.trim()
|
||||
: undefined;
|
||||
|
||||
const dedupeFilter: CoreSchema.OpportunityFilterInput =
|
||||
tftOpportunityId !== undefined
|
||||
? { tftOpportunityId: { eq: tftOpportunityId } }
|
||||
: { name: { eq: name } };
|
||||
const existing = await findOpportunityByDedupeKey(client, dedupeFilter);
|
||||
const existingId = existing.opportunities?.edges?.[0]?.node?.id;
|
||||
if (existingId !== undefined) return { ok: true, created: false, id: existingId };
|
||||
|
||||
const companyId = await findOrCreateCompanyId(client, input.company);
|
||||
const pointOfContactId = await findOrCreatePersonId(
|
||||
client,
|
||||
input.pointOfContact,
|
||||
companyId,
|
||||
);
|
||||
|
||||
const opportunityData = mapToOpportunityCreateInput(input, {
|
||||
companyId,
|
||||
pointOfContactId,
|
||||
});
|
||||
|
||||
const result = await createOpportunity(client, opportunityData);
|
||||
const id = result.createOpportunity?.id;
|
||||
if (id === undefined) throw new Error('createOpportunity did not return an id');
|
||||
|
||||
return { ok: true, created: true, id };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
|
||||
|
||||
import {
|
||||
findOrCreateCompanyByName,
|
||||
findOrCreatePersonByEmail,
|
||||
} from 'src/modules/shared/services/find-or-create-company-and-person.service';
|
||||
import { createOpportunity } from 'src/modules/opportunity/intake/graphql/mutations/create-opportunity';
|
||||
import {
|
||||
buildRequirementsText,
|
||||
type SubmitClientBriefInput,
|
||||
} from 'src/modules/opportunity/intake/mappers/build-requirements-text.mapper';
|
||||
|
||||
export type SubmitClientBriefResult =
|
||||
| { ok: true; opportunityId: string }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
export async function submitClientBrief(
|
||||
input: SubmitClientBriefInput,
|
||||
): Promise<SubmitClientBriefResult> {
|
||||
try {
|
||||
const client = new CoreApiClient();
|
||||
const name = `${input.companyName.trim()} — marketplace brief`;
|
||||
const requirements = buildRequirementsText(input);
|
||||
|
||||
const companyId = await findOrCreateCompanyByName(client, input.companyName);
|
||||
const pointOfContactId = await findOrCreatePersonByEmail(client, {
|
||||
email: input.email,
|
||||
firstName: input.firstName,
|
||||
lastName: input.lastName,
|
||||
companyId,
|
||||
});
|
||||
|
||||
const opportunityData: CoreSchema.OpportunityCreateInput = {
|
||||
name,
|
||||
need: input.need,
|
||||
isListed: false,
|
||||
stage: 'NEW',
|
||||
companyId,
|
||||
pointOfContactId,
|
||||
};
|
||||
if (requirements !== null) {
|
||||
opportunityData.requirements = requirements;
|
||||
}
|
||||
|
||||
const result = await createOpportunity(client, opportunityData);
|
||||
const opportunityId = result.createOpportunity?.id;
|
||||
if (opportunityId === undefined) {
|
||||
throw new Error('createOpportunity did not return an id');
|
||||
}
|
||||
|
||||
return { ok: true, opportunityId };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { handler, type SubmitClientBriefInput } from '../submit-client-brief.logic-function';
|
||||
import { handler } from './submit-client-brief.logic-function';
|
||||
import { type SubmitClientBriefInput } from './mappers/build-requirements-text.mapper';
|
||||
|
||||
const TEST_SECRET = 'test-secret-abc123';
|
||||
process.env.PARTNER_APPLICATION_SECRET = TEST_SECRET;
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
APPLICATION_SECRET_HEADER,
|
||||
readSecretGuardedEvent,
|
||||
} from 'src/modules/shared/http/read-secret-guarded-event';
|
||||
import { submitClientBriefSchema } from 'src/modules/opportunity/intake/mappers/build-requirements-text.mapper';
|
||||
import {
|
||||
submitClientBrief,
|
||||
type SubmitClientBriefResult,
|
||||
} from 'src/modules/opportunity/intake/services/submit-client-brief.service';
|
||||
|
||||
export const SUBMIT_CLIENT_BRIEF_LOGIC_FUNCTION_ID =
|
||||
'a8f3c2e1-9b4d-4a7f-8c6e-1d2f3a4b5c6d';
|
||||
|
||||
export const handler = async (event: unknown): Promise<SubmitClientBriefResult> => {
|
||||
const guard = readSecretGuardedEvent(event, submitClientBriefSchema);
|
||||
if (!guard.ok) return { ok: false, reason: guard.reason };
|
||||
return submitClientBrief(guard.input);
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: SUBMIT_CLIENT_BRIEF_LOGIC_FUNCTION_ID,
|
||||
name: 'submit-client-brief',
|
||||
description: 'Create an unlisted Opportunity from the public marketplace brief form.',
|
||||
timeoutSeconds: 15,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/client-briefs',
|
||||
httpMethod: 'POST',
|
||||
isAuthRequired: false,
|
||||
forwardedRequestHeaders: [APPLICATION_SECRET_HEADER],
|
||||
},
|
||||
});
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildRequirementsText } from '../submit-client-brief.logic-function';
|
||||
import { buildRequirementsText } from './mappers/build-requirements-text.mapper';
|
||||
|
||||
const base = {
|
||||
firstName: 'Jane',
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import type { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
export function updateApplicationState(client: CoreApiClient, id: string, state: string) {
|
||||
return client.mutation({
|
||||
updateApplication: { __args: { id, data: { state } }, id: true },
|
||||
});
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import type { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
export function updateCompanyPartnerUser(
|
||||
client: CoreApiClient,
|
||||
companyId: string,
|
||||
partnerUserId: string | null,
|
||||
) {
|
||||
return client.mutation({
|
||||
updateCompany: { __args: { id: companyId, data: { partnerUserId } }, id: true },
|
||||
});
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import type { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
export function updateOpportunityPartnerUser(
|
||||
client: CoreApiClient,
|
||||
opportunityId: string,
|
||||
partnerUserId: string | null,
|
||||
) {
|
||||
return client.mutation({
|
||||
updateOpportunity: { __args: { id: opportunityId, data: { partnerUserId } }, id: true },
|
||||
});
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import type { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
export function updatePersonPartnerUser(
|
||||
client: CoreApiClient,
|
||||
id: string,
|
||||
partnerUserId: string | null,
|
||||
) {
|
||||
return client.mutation({
|
||||
updatePerson: { __args: { id, data: { partnerUserId } }, id: true },
|
||||
});
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import type { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
export function findOpportunityStillUsingCompany(
|
||||
client: CoreApiClient,
|
||||
params: {
|
||||
companyId: string;
|
||||
removedPartnerId: string | null | undefined;
|
||||
removedMemberId: string;
|
||||
},
|
||||
) {
|
||||
const { companyId, removedPartnerId, removedMemberId } = params;
|
||||
return client.query({
|
||||
opportunities: {
|
||||
__args: {
|
||||
filter: {
|
||||
companyId: { eq: companyId },
|
||||
...(removedPartnerId
|
||||
? { partnerId: { eq: removedPartnerId } }
|
||||
: { partnerUserId: { eq: removedMemberId } }),
|
||||
},
|
||||
first: 1,
|
||||
},
|
||||
edges: { node: { id: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import type { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
export function getCompanyPartnerUser(client: CoreApiClient, companyId: string) {
|
||||
return client.query({
|
||||
company: {
|
||||
__args: { filter: { id: { eq: companyId } } },
|
||||
id: true,
|
||||
partnerUserId: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import type { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
export function getOpportunityCascadeFields(client: CoreApiClient, opportunityId: string) {
|
||||
return client.query({
|
||||
opportunity: {
|
||||
__args: { filter: { id: { eq: opportunityId } } },
|
||||
id: true,
|
||||
partnerUserId: true,
|
||||
companyId: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import type { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
export function getPartnerPartnerUser(client: CoreApiClient, partnerId: string) {
|
||||
return client.query({
|
||||
partner: { __args: { filter: { id: { eq: partnerId } } }, id: true, partnerUserId: true },
|
||||
});
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import type { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
const APPLICATIONS_PAGE_SIZE = 200;
|
||||
|
||||
export function listApplicationsByOpportunity(
|
||||
client: CoreApiClient,
|
||||
opportunityId: string,
|
||||
after?: string,
|
||||
) {
|
||||
return client.query({
|
||||
applications: {
|
||||
__args: {
|
||||
filter: { opportunityId: { eq: opportunityId } },
|
||||
first: APPLICATIONS_PAGE_SIZE,
|
||||
...(after ? { after } : {}),
|
||||
},
|
||||
edges: { node: { id: true, partnerId: true, state: true } },
|
||||
pageInfo: { hasNextPage: true, endCursor: true },
|
||||
},
|
||||
});
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import type { CoreApiClient, CoreSchema } from 'twenty-client-sdk/core';
|
||||
|
||||
const PEOPLE_PAGE_SIZE = 200;
|
||||
|
||||
export function listPeopleByFilter(
|
||||
client: CoreApiClient,
|
||||
filter: CoreSchema.PersonFilterInput,
|
||||
after?: string,
|
||||
) {
|
||||
return client.query({
|
||||
people: {
|
||||
__args: { filter, first: PEOPLE_PAGE_SIZE, ...(after ? { after } : {}) },
|
||||
edges: { node: { id: true } },
|
||||
pageInfo: { hasNextPage: true, endCursor: true },
|
||||
},
|
||||
});
|
||||
}
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import onOpportunityPartnerAssigned from '../on-opportunity-partner-assigned';
|
||||
import onOpportunityPartnerAssigned from './on-opportunity-partner-assigned.logic-function';
|
||||
|
||||
// defineLogicFunction wraps the handler in a ValidationResult; the fn is on config.handler.
|
||||
// The SDK wraps the handler in a ValidationResult; the fn is on config.handler.
|
||||
const handler = onOpportunityPartnerAssigned.config.handler;
|
||||
|
||||
function requireId(id: string | null | undefined, what: string): string {
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
|
||||
import {
|
||||
type DatabaseEventPayload,
|
||||
defineLogicFunction,
|
||||
type ObjectRecordUpdateEvent,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import { propagatePartnerUser } from 'src/modules/opportunity/matching/services/propagate-partner-user.service';
|
||||
|
||||
const handler = async (
|
||||
payload: DatabaseEventPayload<ObjectRecordUpdateEvent<CoreSchema.Opportunity>>,
|
||||
): Promise<Record<string, unknown>> => {
|
||||
const { after, before, updatedFields } = payload.properties;
|
||||
if (!updatedFields?.includes('partnerId') || !after?.id) return {};
|
||||
return propagatePartnerUser(new CoreApiClient(), { opportunityId: after.id, before, after });
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
// UNCHANGED — kept inline (not in universal-identifiers.ts, which is bundle-local).
|
||||
universalIdentifier: 'd7e4a4e6-9142-4597-adcf-6fb83c0f042d',
|
||||
name: 'on-opportunity-partner-assigned',
|
||||
timeoutSeconds: 15,
|
||||
handler,
|
||||
databaseEventTriggerSettings: { eventName: 'opportunity.updated' },
|
||||
});
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
|
||||
import {
|
||||
type DatabaseEventPayload,
|
||||
defineLogicFunction,
|
||||
type ObjectRecordUpdateEvent,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import { syncApplicationOutcomes } from 'src/modules/opportunity/matching/services/sync-application-outcomes.service';
|
||||
|
||||
export const handler = async (
|
||||
payload: DatabaseEventPayload<ObjectRecordUpdateEvent<CoreSchema.Opportunity>>,
|
||||
): Promise<Record<string, unknown>> => {
|
||||
const { after, updatedFields } = payload.properties;
|
||||
if (!updatedFields?.includes('partnerId') || !after?.id) return {};
|
||||
return syncApplicationOutcomes(new CoreApiClient(), {
|
||||
opportunityId: after.id,
|
||||
newPartnerId: after.partnerId ?? null,
|
||||
});
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: '683f407e-e7a0-435d-a380-e51e536770f8',
|
||||
name: 'on-opportunity-partner-won',
|
||||
timeoutSeconds: 15,
|
||||
handler,
|
||||
databaseEventTriggerSettings: { eventName: 'opportunity.updated' },
|
||||
});
|
||||
+1
-1
@@ -10,7 +10,7 @@ vi.mock('twenty-client-sdk/core', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
import { handler } from '../on-opportunity-partner-won';
|
||||
import { handler } from './on-opportunity-partner-won.logic-function';
|
||||
|
||||
const OPP = 'aaaaaaaa-0000-0000-0000-000000000001';
|
||||
const P_WIN = 'bbbbbbbb-0000-0000-0000-000000000001';
|
||||
+44
-109
@@ -1,40 +1,32 @@
|
||||
import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
|
||||
import {
|
||||
type DatabaseEventPayload,
|
||||
defineLogicFunction,
|
||||
type ObjectRecordUpdateEvent,
|
||||
import type { CoreApiClient, CoreSchema } from 'twenty-client-sdk/core';
|
||||
import type {
|
||||
DatabaseEventPayload,
|
||||
ObjectRecordUpdateEvent,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
// Defined here (not in universal-identifiers.ts) to avoid touching that file's local-only id.
|
||||
const ON_OPP_PARTNER_ASSIGNED_FN_UNIVERSAL_IDENTIFIER = 'd7e4a4e6-9142-4597-adcf-6fb83c0f042d';
|
||||
import { collectAll } from 'src/modules/shared/utils/paginate.util';
|
||||
import { getCompanyPartnerUser } from 'src/modules/opportunity/matching/graphql/queries/get-company-partner-user';
|
||||
import { getOpportunityCascadeFields } from 'src/modules/opportunity/matching/graphql/queries/get-opportunity-cascade-fields';
|
||||
import { getPartnerPartnerUser } from 'src/modules/opportunity/matching/graphql/queries/get-partner-partner-user';
|
||||
import { findOpportunityStillUsingCompany } from 'src/modules/opportunity/matching/graphql/queries/find-opportunity-still-using-company';
|
||||
import { listPeopleByFilter } from 'src/modules/opportunity/matching/graphql/queries/list-people-by-filter';
|
||||
import { updateCompanyPartnerUser } from 'src/modules/opportunity/matching/graphql/mutations/update-company-partner-user';
|
||||
import { updateOpportunityPartnerUser } from 'src/modules/opportunity/matching/graphql/mutations/update-opportunity-partner-user';
|
||||
import { updatePersonPartnerUser } from 'src/modules/opportunity/matching/graphql/mutations/update-person-partner-user';
|
||||
|
||||
const PEOPLE_PAGE_SIZE = 200;
|
||||
type OpportunityUpdateProperties = DatabaseEventPayload<
|
||||
ObjectRecordUpdateEvent<CoreSchema.Opportunity>
|
||||
>['properties'];
|
||||
|
||||
// Every Person id matching the filter, paginated fully — a company can have more than one
|
||||
// page of contacts and a single capped page would leave RLS stamps stale on the rest.
|
||||
async function collectPeopleIds(
|
||||
client: CoreApiClient,
|
||||
filter: CoreSchema.PersonFilterInput,
|
||||
): Promise<string[]> {
|
||||
const ids: string[] = [];
|
||||
let after: string | undefined;
|
||||
|
||||
for (;;) {
|
||||
const page = await client.query({
|
||||
people: {
|
||||
__args: { filter, first: PEOPLE_PAGE_SIZE, ...(after ? { after } : {}) },
|
||||
edges: { node: { id: true } },
|
||||
pageInfo: { hasNextPage: true, endCursor: true },
|
||||
},
|
||||
});
|
||||
for (const edge of page.people?.edges ?? []) {
|
||||
if (edge?.node?.id) ids.push(edge.node.id);
|
||||
}
|
||||
if (!page.people?.pageInfo?.hasNextPage) break;
|
||||
after = page.people.pageInfo.endCursor ?? undefined;
|
||||
}
|
||||
|
||||
return ids;
|
||||
const nodes = await collectAll(async (after) => {
|
||||
const page = await listPeopleByFilter(client, filter, after);
|
||||
return page.people;
|
||||
});
|
||||
return nodes.map((node) => node.id).filter((id): id is string => Boolean(id));
|
||||
}
|
||||
|
||||
// Set (or clear, with null) partnerUser on each Person, attempting all of them even if some
|
||||
@@ -46,11 +38,7 @@ async function setPeoplePartnerUser(
|
||||
partnerUserId: string | null,
|
||||
): Promise<number> {
|
||||
const results = await Promise.allSettled(
|
||||
personIds.map((id) =>
|
||||
client.mutation({
|
||||
updatePerson: { __args: { id, data: { partnerUserId } }, id: true },
|
||||
}),
|
||||
),
|
||||
personIds.map((id) => updatePersonPartnerUser(client, id, partnerUserId)),
|
||||
);
|
||||
return results.filter((result) => result.status === 'rejected').length;
|
||||
}
|
||||
@@ -60,16 +48,15 @@ async function setPeoplePartnerUser(
|
||||
// assign stamps it onto the Opportunity + linked Company + People; unassign clears it,
|
||||
// keeping the Company/People if the same Partner still has another opportunity on that
|
||||
// Company. Runs under the app identity, so its writes bypass partner RLS / field locks.
|
||||
const handler = async (
|
||||
payload: DatabaseEventPayload<ObjectRecordUpdateEvent<CoreSchema.Opportunity>>,
|
||||
): Promise<Record<string, unknown>> => {
|
||||
const { after, before, updatedFields } = payload.properties;
|
||||
|
||||
if (!updatedFields?.includes('partnerId')) return {};
|
||||
const opportunityId = after?.id;
|
||||
if (!opportunityId) return {};
|
||||
|
||||
const client = new CoreApiClient();
|
||||
export async function propagatePartnerUser(
|
||||
client: CoreApiClient,
|
||||
params: {
|
||||
opportunityId: string;
|
||||
before: OpportunityUpdateProperties['before'];
|
||||
after: OpportunityUpdateProperties['after'];
|
||||
},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const { opportunityId, before, after } = params;
|
||||
const partnerId = after?.partnerId;
|
||||
|
||||
// ── Unassign: the partner was removed from the opportunity ───────────────────
|
||||
@@ -83,24 +70,12 @@ const handler = async (
|
||||
const removedPartnerId = before?.partnerId;
|
||||
|
||||
if (!removedMemberId || !companyId) {
|
||||
const oppResult = await client.query({
|
||||
opportunity: {
|
||||
__args: { filter: { id: { eq: opportunityId } } },
|
||||
id: true,
|
||||
partnerUserId: true,
|
||||
companyId: true,
|
||||
},
|
||||
});
|
||||
const oppResult = await getOpportunityCascadeFields(client, opportunityId);
|
||||
removedMemberId = removedMemberId ?? oppResult.opportunity?.partnerUserId ?? null;
|
||||
companyId = companyId ?? oppResult.opportunity?.companyId ?? null;
|
||||
}
|
||||
|
||||
await client.mutation({
|
||||
updateOpportunity: {
|
||||
__args: { id: opportunityId, data: { partnerUserId: null } },
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
await updateOpportunityPartnerUser(client, opportunityId, null);
|
||||
|
||||
if (!removedMemberId || !companyId) {
|
||||
return { cascaded: true, cleared: true };
|
||||
@@ -110,39 +85,19 @@ const handler = async (
|
||||
// it. Decided on partnerId, not the derived partnerUser stamp (which a prior partial
|
||||
// cascade may have left unset); fall back to the stamp only if the old partnerId is
|
||||
// unavailable. The just-cleared opportunity no longer matches either filter.
|
||||
const stillInUse = await client.query({
|
||||
opportunities: {
|
||||
__args: {
|
||||
filter: {
|
||||
companyId: { eq: companyId },
|
||||
...(removedPartnerId
|
||||
? { partnerId: { eq: removedPartnerId } }
|
||||
: { partnerUserId: { eq: removedMemberId } }),
|
||||
},
|
||||
first: 1,
|
||||
},
|
||||
edges: { node: { id: true } },
|
||||
},
|
||||
const stillInUse = await findOpportunityStillUsingCompany(client, {
|
||||
companyId,
|
||||
removedPartnerId,
|
||||
removedMemberId,
|
||||
});
|
||||
if ((stillInUse.opportunities?.edges?.length ?? 0) > 0) {
|
||||
return { cascaded: true, cleared: true, companyKept: true };
|
||||
}
|
||||
|
||||
// Clear the company (only if it belongs to this member) and every person stamped for them.
|
||||
const companyResult = await client.query({
|
||||
company: {
|
||||
__args: { filter: { id: { eq: companyId } } },
|
||||
id: true,
|
||||
partnerUserId: true,
|
||||
},
|
||||
});
|
||||
const companyResult = await getCompanyPartnerUser(client, companyId);
|
||||
if (companyResult.company?.partnerUserId === removedMemberId) {
|
||||
await client.mutation({
|
||||
updateCompany: {
|
||||
__args: { id: companyId, data: { partnerUserId: null } },
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
await updateCompanyPartnerUser(client, companyId, null);
|
||||
}
|
||||
|
||||
const peopleIds = await collectPeopleIds(client, {
|
||||
@@ -159,15 +114,11 @@ const handler = async (
|
||||
}
|
||||
|
||||
// ── Assign / reassign ────────────────────────────────────────────────────────
|
||||
const partnerResult = await client.query({
|
||||
partner: { __args: { filter: { id: { eq: partnerId } } }, id: true, partnerUserId: true },
|
||||
});
|
||||
const partnerResult = await getPartnerPartnerUser(client, partnerId);
|
||||
const partnerUserId = partnerResult.partner?.partnerUserId;
|
||||
if (!partnerUserId) return { cascaded: false, reason: 'partner_has_no_user' };
|
||||
|
||||
await client.mutation({
|
||||
updateOpportunity: { __args: { id: opportunityId, data: { partnerUserId } }, id: true },
|
||||
});
|
||||
await updateOpportunityPartnerUser(client, opportunityId, partnerUserId);
|
||||
|
||||
const companyId = after?.companyId;
|
||||
if (!companyId) return { cascaded: true, partnerUserId };
|
||||
@@ -176,21 +127,13 @@ const handler = async (
|
||||
// partnerUser column on Company/Person models one owner per company, so reassigning it
|
||||
// here would steal the company (and its contacts) from the other partner and expose their
|
||||
// data. Stamp only the opportunity in that case and leave the company/people alone.
|
||||
const companyResult = await client.query({
|
||||
company: {
|
||||
__args: { filter: { id: { eq: companyId } } },
|
||||
id: true,
|
||||
partnerUserId: true,
|
||||
},
|
||||
});
|
||||
const companyResult = await getCompanyPartnerUser(client, companyId);
|
||||
const companyOwner = companyResult.company?.partnerUserId;
|
||||
if (companyOwner && companyOwner !== partnerUserId) {
|
||||
return { cascaded: true, partnerUserId, companyShared: true };
|
||||
}
|
||||
|
||||
await client.mutation({
|
||||
updateCompany: { __args: { id: companyId, data: { partnerUserId } }, id: true },
|
||||
});
|
||||
await updateCompanyPartnerUser(client, companyId, partnerUserId);
|
||||
|
||||
const peopleIds = await collectPeopleIds(client, { companyId: { eq: companyId } });
|
||||
const failed = await setPeoplePartnerUser(client, peopleIds, partnerUserId);
|
||||
@@ -201,12 +144,4 @@ const handler = async (
|
||||
}
|
||||
|
||||
return { cascaded: true, partnerUserId };
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: ON_OPP_PARTNER_ASSIGNED_FN_UNIVERSAL_IDENTIFIER,
|
||||
name: 'on-opportunity-partner-assigned',
|
||||
timeoutSeconds: 15,
|
||||
handler,
|
||||
databaseEventTriggerSettings: { eventName: 'opportunity.updated' },
|
||||
});
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import type { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
import { collectAll } from 'src/modules/shared/utils/paginate.util';
|
||||
import { listApplicationsByOpportunity } from 'src/modules/opportunity/matching/graphql/queries/list-applications-by-opportunity';
|
||||
import { updateApplicationState } from 'src/modules/opportunity/matching/graphql/mutations/update-application-state';
|
||||
|
||||
// WON/BACKUP mirror of Opportunity.partner: on assign, winner -> WON and other active apps ->
|
||||
// BACKUP; on unassign, WON/BACKUP -> APPLIED. DECLINED is never touched. Runs under the app
|
||||
// identity, bypassing partner locks.
|
||||
export async function syncApplicationOutcomes(
|
||||
client: CoreApiClient,
|
||||
params: { opportunityId: string; newPartnerId: string | null },
|
||||
): Promise<Record<string, unknown>> {
|
||||
const { opportunityId, newPartnerId } = params;
|
||||
|
||||
const applications = await collectAll(async (after) => {
|
||||
const page = await listApplicationsByOpportunity(client, opportunityId, after);
|
||||
return page.applications;
|
||||
});
|
||||
|
||||
const setState = (id: string, state: string) => updateApplicationState(client, id, state);
|
||||
|
||||
if (newPartnerId) {
|
||||
// Winner -> WON; every other active (non-DECLINED) application -> BACKUP.
|
||||
for (const node of applications) {
|
||||
if (node.state === 'DECLINED') continue;
|
||||
const target = node.partnerId === newPartnerId ? 'WON' : 'BACKUP';
|
||||
if (node.state !== target) await setState(node.id, target);
|
||||
}
|
||||
const winner = applications.find((node) => node.partnerId === newPartnerId);
|
||||
return { won: winner?.id ?? null };
|
||||
}
|
||||
|
||||
// Unassigned: WON and BACKUP applications re-open to APPLIED. DECLINED untouched.
|
||||
for (const node of applications) {
|
||||
if (node.state === 'WON' || node.state === 'BACKUP') {
|
||||
await setState(node.id, 'APPLIED');
|
||||
}
|
||||
}
|
||||
return { won: null, cleared: true };
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
|
||||
|
||||
import { BRIEFS_TO_MATCH_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/briefs-to-match.view';
|
||||
import { BRIEFS_TO_MATCH_VIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/opportunity/views/briefs-to-match.view';
|
||||
|
||||
export default defineNavigationMenuItem({
|
||||
universalIdentifier: 'c46f6b03-ee43-4799-9615-270ad28d2848',
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
|
||||
|
||||
import { DEALS_BOARD_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/deals-board.view';
|
||||
import { DEALS_BOARD_VIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/opportunity/views/deals-board.view';
|
||||
|
||||
export default defineNavigationMenuItem({
|
||||
universalIdentifier: '9e4dde3a-46f1-431c-b682-2e1fa8a5622c',
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
|
||||
|
||||
import { FOLLOWUP_BRIEFS_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/followup-briefs.view';
|
||||
import { FOLLOWUP_BRIEFS_VIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/opportunity/views/followup-briefs.view';
|
||||
|
||||
export default defineNavigationMenuItem({
|
||||
universalIdentifier: 'd4017fb3-0bfd-4099-94e8-e1ceffa8faca',
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
|
||||
|
||||
import { MY_DEALS_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/my-deals.view';
|
||||
import { MY_DEALS_VIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/opportunity/views/my-deals.view';
|
||||
|
||||
import { PARTNER_WORKSPACE_FOLDER_UNIVERSAL_IDENTIFIER } from './partner-workspace-folder.navigation-menu-item';
|
||||
import { PARTNER_WORKSPACE_FOLDER_UNIVERSAL_IDENTIFIER } from 'src/modules/shared/navigation-menu-items/partner-workspace-folder.navigation-menu-item';
|
||||
|
||||
export default defineNavigationMenuItem({
|
||||
universalIdentifier: '783d4920-6bf6-4dbf-b705-228ffcc9a7d7',
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
|
||||
|
||||
import { OPEN_BRIEFS_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/open-briefs.view';
|
||||
import { OPEN_BRIEFS_VIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/opportunity/views/open-briefs.view';
|
||||
|
||||
import { PARTNER_WORKSPACE_FOLDER_UNIVERSAL_IDENTIFIER } from './partner-workspace-folder.navigation-menu-item';
|
||||
import { PARTNER_WORKSPACE_FOLDER_UNIVERSAL_IDENTIFIER } from 'src/modules/shared/navigation-menu-items/partner-workspace-folder.navigation-menu-item';
|
||||
|
||||
export default defineNavigationMenuItem({
|
||||
universalIdentifier: '1392ad4d-3792-4187-a1c5-ee05815dcfde',
|
||||
+1
-1
@@ -4,7 +4,7 @@ import {
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import { OPPORTUNITY_RECORD_PAGE_APPLICATIONS_VIEW_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { APPLICATIONS_ON_OPPORTUNITY_FIELD_ID } from 'src/objects/application.object';
|
||||
import { APPLICATIONS_ON_OPPORTUNITY_FIELD_ID } from 'src/modules/application/objects/application.object';
|
||||
|
||||
const OPPORTUNITY_RECORD_PAGE_FIELDS =
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.views.opportunityRecordPageFields;
|
||||
+1
-1
@@ -4,7 +4,7 @@ import {
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import { OPPORTUNITY_RECORD_PAGE_IS_LISTED_VIEW_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { OPPORTUNITY_IS_LISTED_FIELD_ID } from 'src/fields/opportunity-is-listed.field';
|
||||
import { OPPORTUNITY_IS_LISTED_FIELD_ID } from 'src/modules/opportunity/fields/opportunity-is-listed.field';
|
||||
|
||||
const OPPORTUNITY_RECORD_PAGE_FIELDS =
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.views.opportunityRecordPageFields;
|
||||
+1
-1
@@ -4,7 +4,7 @@ import {
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import { OPPORTUNITY_RECORD_PAGE_NEED_VIEW_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { OPPORTUNITY_NEED_FIELD_ID } from 'src/fields/opportunity-need.field';
|
||||
import { OPPORTUNITY_NEED_FIELD_ID } from 'src/modules/opportunity/fields/opportunity-need.field';
|
||||
|
||||
const OPPORTUNITY_RECORD_PAGE_FIELDS =
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.views.opportunityRecordPageFields;
|
||||
+1
-1
@@ -4,7 +4,7 @@ import {
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import { OPPORTUNITY_RECORD_PAGE_PARTNER_VIEW_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { PARTNER_ON_OPPORTUNITY_FIELD_ID } from 'src/fields/partner-on-opportunity.field';
|
||||
import { PARTNER_ON_OPPORTUNITY_FIELD_ID } from 'src/modules/opportunity/fields/partner-on-opportunity.field';
|
||||
|
||||
const OPPORTUNITY_RECORD_PAGE_FIELDS =
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.views.opportunityRecordPageFields;
|
||||
+1
-1
@@ -4,7 +4,7 @@ import {
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import { OPPORTUNITY_RECORD_PAGE_REQUIREMENTS_VIEW_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { OPPORTUNITY_REQUIREMENTS_FIELD_ID } from 'src/fields/opportunity-requirements.field';
|
||||
import { OPPORTUNITY_REQUIREMENTS_FIELD_ID } from 'src/modules/opportunity/fields/opportunity-requirements.field';
|
||||
|
||||
const OPPORTUNITY_RECORD_PAGE_FIELDS =
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.views.opportunityRecordPageFields;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user