From 9d8ce7c325749aa26ada2d2a01a8f15814c3a299 Mon Sep 17 00:00:00 2001 From: Rashad Karanouh <11599358+rashad@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:59:00 +0200 Subject: [PATCH] =?UTF-8?q?v1.3.2=20=E2=80=94=20Modularize=20partners=20ap?= =?UTF-8?q?p=20into=20vertical-slice=20modules/=20(#23168)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **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///`. 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/`. --- .../internal/twenty-partners/AGENTS.md | 104 ++++++ .../internal/twenty-partners/CLAUDE.md | 1 + .../internal/twenty-partners/package.json | 5 +- .../src/front-components/my-profile/types.ts | 10 - .../get-my-partner-profile.logic-function.ts | 211 ----------- ...ort-opportunity-from-tft.logic-function.ts | 226 ------------ .../list-available-partners.logic-function.ts | 103 ------ .../logic-functions/on-application-created.ts | 89 ----- .../on-opportunity-partner-won.ts | 94 ----- ...tner-application-created.logic-function.ts | 190 ---------- .../on-partner-content-created.ts | 95 ----- .../on-partner-link-created.ts | 69 ---- .../on-partner-service-created.ts | 69 ---- .../save-my-partner-content.logic-function.ts | 214 ----------- .../save-my-partner-links.logic-function.ts | 144 -------- ...save-my-partner-services.logic-function.ts | 147 -------- .../submit-client-brief.logic-function.ts | 149 -------- ...bmit-partner-application.logic-function.ts | 337 ------------------ ...rtner-content-for-review.logic-function.ts | 90 ----- .../__tests__/on-application-created.test.ts | 2 +- .../__tests__/on-application-set-name.test.ts | 2 +- ...-partner-user-on-workspace-member.field.ts | 2 +- .../fields/applications-on-partner.field.ts | 2 +- .../graphql/mutations/delete-application.ts | 10 + .../graphql/mutations/update-application.ts | 11 + .../find-application-with-relations.ts | 12 + .../queries/find-duplicate-application.ts | 20 ++ .../graphql/queries/find-partner-by-member.ts | 10 + ...ons-by-opportunity.navigation-menu-item.ts | 2 +- ...pplications-review.navigation-menu-item.ts | 2 +- ...lowup-applications.navigation-menu-item.ts | 2 +- .../my-applications.navigation-menu-item.ts | 4 +- .../objects/application.object.ts | 0 .../on-application-created.logic-function.ts | 23 ++ ...on-application-set-name.logic-function.ts} | 21 +- .../page-layouts/application.page-layout.ts | 4 +- .../services/resolve-candidacy.service.ts | 57 +++ .../application-record-page-fields.view.ts | 2 +- .../views/applications-by-opportunity.view.ts | 2 +- .../views/applications-review.view.ts | 2 +- .../views/followup-applications.view.ts | 2 +- .../views/my-applications.view.ts | 2 +- .../constants/opportunity-stage-options.ts | 0 .../applications-on-opportunity.field.ts | 2 +- ...-partner-user-on-workspace-member.field.ts | 0 .../fields/opportunities-on-partner.field.ts | 0 .../opportunity-design-doc-status.field.ts | 0 .../opportunity-design-doc-url.field.ts | 0 .../fields/opportunity-hosting-type.field.ts | 0 .../fields/opportunity-intro-sent-at.field.ts | 0 .../fields/opportunity-is-listed.field.ts | 0 .../fields/opportunity-lost-reason.field.ts | 0 .../fields/opportunity-need.field.ts | 0 .../opportunity-number-of-seats.field.ts | 0 .../fields/opportunity-requirements.field.ts | 0 ...pportunity-subscription-frequency.field.ts | 0 .../opportunity-subscription-type.field.ts | 0 .../fields/opportunity-tft-id.field.ts | 0 .../fields/opportunity-use-case.field.ts | 0 .../fields/partner-on-opportunity.field.ts | 0 .../partner-user-on-opportunity.field.ts | 0 .../graphql/mutations/create-opportunity.ts | 10 + .../queries/find-opportunity-by-dedupe-key.ts | 13 + ...ort-opportunity-from-tft.logic-function.ts | 36 ++ .../import-opportunity-from-tft.test.ts | 6 +- .../mappers/build-requirements-text.mapper.ts | 43 +++ .../import-opportunity-from-tft.mapper.ts | 75 ++++ .../import-opportunity-from-tft.service.ts | 119 +++++++ .../services/submit-client-brief.service.ts | 55 +++ .../submit-client-brief.integration-test.ts | 3 +- .../submit-client-brief.logic-function.ts | 34 ++ .../intake}/submit-client-brief.test.ts | 2 +- .../mutations/update-application-state.ts | 7 + .../mutations/update-company-partner-user.ts | 11 + .../update-opportunity-partner-user.ts | 11 + .../mutations/update-person-partner-user.ts | 11 + .../find-opportunity-still-using-company.ts | 26 ++ .../queries/get-company-partner-user.ts | 11 + .../queries/get-opportunity-cascade-fields.ts | 12 + .../queries/get-partner-partner-user.ts | 7 + .../list-applications-by-opportunity.ts | 21 ++ .../graphql/queries/list-people-by-filter.ts | 17 + ...unity-partner-assigned.integration-test.ts | 4 +- ...rtunity-partner-assigned.logic-function.ts | 25 ++ ...-opportunity-partner-won.logic-function.ts | 27 ++ .../on-opportunity-partner-won.test.ts | 2 +- .../propagate-partner-user.service.ts} | 153 +++----- .../sync-application-outcomes.service.ts | 41 +++ .../all-opportunities.navigation-menu-item.ts | 0 .../briefs-to-match.navigation-menu-item.ts | 2 +- .../deals-board.navigation-menu-item.ts | 2 +- .../followup-briefs.navigation-menu-item.ts | 2 +- .../my-deals.navigation-menu-item.ts | 4 +- .../open-briefs.navigation-menu-item.ts | 4 +- .../page-layouts/opportunity.page-layout.ts | 0 ...ity-record-page-applications.view-field.ts | 2 +- ...tunity-record-page-is-listed.view-field.ts | 2 +- ...opportunity-record-page-need.view-field.ts | 2 +- ...ortunity-record-page-partner.view-field.ts | 2 +- ...ity-record-page-requirements.view-field.ts | 2 +- .../views/all-opportunities.view.ts | 4 +- .../views/briefs-to-match.view.ts | 8 +- .../opportunity}/views/deals-board.view.ts | 8 +- .../views/followup-briefs.view.ts | 8 +- .../opportunity}/views/my-deals.view.ts | 4 +- .../opportunity}/views/open-briefs.view.ts | 6 +- .../derive-deployment-expertise.test.ts | 2 +- .../__tests__}/derive-region.test.ts | 4 +- .../on-partner-application-created.test.ts | 6 +- ...it-partner-application.integration-test.ts | 0 .../submit-partner-application.schema.test.ts | 2 +- .../connector/discord/config.ts | 9 + .../connector/discord/discord.connector.ts | 32 ++ .../connector/discord/types.ts | 5 + .../graphql/mutations/create-company.ts | 10 + .../graphql/mutations/create-partner.ts | 13 + .../graphql/mutations/create-person.ts | 13 + .../graphql/mutations/update-partner.ts | 14 + .../graphql/mutations/update-person.ts | 14 + .../queries/find-companies-by-domain.ts | 19 + .../graphql/queries/find-partner-for-embed.ts | 22 ++ .../graphql/queries/find-person-by-email.ts | 18 + .../mappers/application-embed.mapper.ts | 73 ++++ .../mappers/build-partner-fields.mapper.ts | 78 ++++ ...rtner-applications.navigation-menu-item.ts | 2 +- ...tner-application-created.logic-function.ts | 39 ++ .../find-or-create-company.service.ts | 61 ++++ .../notify-partner-application.service.ts | 46 +++ ...submit-partner-application-input.schema.ts | 35 ++ .../submit-partner-application.service.ts | 68 ++++ ...bmit-partner-application.logic-function.ts | 39 ++ .../utils}/derive-deployment-expertise.ts | 0 .../utils}/derive-region.ts | 0 .../views/partner-applications.view.ts | 4 +- .../constants/partner-country-view-groups.ts | 0 .../partner-field-universal-identifiers.ts | 0 .../partner-option-values.constant.ts | 13 + .../partner-content.navigation-menu-item.ts | 0 .../partners-folder.navigation-menu-item.ts | 0 ...rtners-per-country.navigation-menu-item.ts | 2 +- ...partners-per-stage.navigation-menu-item.ts | 2 +- ...partners-validated.navigation-menu-item.ts | 2 +- .../partner-content.page-layout.ts | 2 +- .../page-layouts/partner-link.page-layout.ts | 2 +- .../partner-service.page-layout.ts | 2 +- .../page-layouts/partner.page-layout.ts | 2 +- ...partner-content-record-page-fields.view.ts | 4 +- .../directory}/views/partner-content.view.ts | 2 +- .../partner-link-record-page-fields.view.ts | 4 +- .../views/partner-record-page-fields.view.ts | 12 +- ...partner-service-record-page-fields.view.ts | 4 +- .../views/partners-per-country.view.ts | 4 +- .../views/partners-per-stage.view.ts | 2 +- .../views/partners-validated.view.ts | 4 +- ...-partner-user-on-workspace-member.field.ts | 0 .../partner}/fields/partner-company.field.ts | 0 .../partner-content-customer-company.field.ts | 0 .../partner-content-customer-person.field.ts | 0 .../fields/partner-content-partner.field.ts | 0 ...-partner-user-on-workspace-member.field.ts | 0 .../partner-contents-on-company.field.ts | 0 .../partner-contents-on-partner.field.ts | 0 .../partner-contents-on-person.field.ts | 0 .../fields/partner-link-partner.field.ts | 0 ...-partner-user-on-workspace-member.field.ts | 0 .../fields/partner-links-on-partner.field.ts | 0 .../fields/partner-on-person.field.ts | 0 ...files-as-user-on-workspace-member.field.ts | 0 .../fields/partner-service-partner.field.ts | 0 ...-partner-user-on-workspace-member.field.ts | 0 .../partner-services-on-partner.field.ts | 0 .../fields/partner-user-on-company.field.ts | 0 .../partner-user-on-partner-content.field.ts | 0 .../partner-user-on-partner-link.field.ts | 0 .../partner-user-on-partner-service.field.ts | 0 .../fields/partner-user-on-partner.field.ts | 0 .../fields/partner-user-on-person.field.ts | 0 .../fields/partners-on-company.field.ts | 0 .../partner}/fields/person-discord.field.ts | 0 ...-partner-user-on-workspace-member.field.ts | 0 .../fields/persons-on-partner.field.ts | 0 .../marketplace-api.integration-test.ts | 4 +- .../get-partner-by-slug.logic-function.ts | 22 ++ .../graphql/queries/get-partner-by-slug.ts} | 75 +--- .../queries/list-available-partners.ts | 44 +++ .../list-available-partners.logic-function.ts | 21 ++ ...ap-partner-for-marketplace.mapper.test.ts} | 2 +- .../map-partner-for-marketplace.mapper.ts} | 7 +- .../services/get-partner-by-slug.service.ts | 55 +++ .../list-available-partners.service.ts | 44 +++ .../objects/partner-content.object.ts | 0 .../partner}/objects/partner-link.object.ts | 0 .../objects/partner-service.object.ts | 0 .../partner}/objects/partner.object.ts | 0 ...get-my-partner-profile.integration-test.ts | 0 ...e-partner-from-request.integration-test.ts | 2 +- ...ave-my-partner-content.integration-test.ts | 2 +- .../save-my-partner-links.integration-test.ts | 2 +- ...ave-my-partner-profile.integration-test.ts | 0 ...ve-my-partner-services.integration-test.ts | 2 +- ...ner-content-for-review.integration-test.ts | 2 +- .../constants/my-case-studies.constants.ts | 0 .../constants/my-profile.constants.ts | 0 .../my-case-studies.front-component.tsx | 4 +- .../my-case-studies/case-study-card.tsx | 0 .../my-case-studies/case-study-rows.test.ts | 0 .../my-case-studies/case-study-rows.ts | 0 .../my-profile.front-component.tsx | 6 +- .../my-profile/form-fields.tsx | 0 .../my-profile/markdown-editor.tsx | 0 .../my-profile/markdown-render.test.ts | 0 .../my-profile/markdown-render.tsx | 0 .../my-profile/profile-picture-upload.tsx} | 0 .../front-components/my-profile/types.ts | 8 + .../get-my-partner-profile.logic-function.ts | 25 ++ .../mutations/create-partner-content.ts | 10 + .../graphql/mutations/create-partner-link.ts | 10 + .../mutations/create-partner-service.ts | 10 + .../mutations/delete-partner-content.ts | 7 + .../graphql/mutations/delete-partner-link.ts | 7 + .../mutations/delete-partner-service.ts | 7 + .../mutations/update-partner-content.ts | 11 + .../graphql/mutations/update-partner-link.ts | 11 + .../mutations/update-partner-service.ts | 11 + .../graphql/mutations/update-partner.ts | 11 + .../queries/find-my-partner-profile.ts | 72 ++++ .../graphql/queries/find-partner-by-member.ts | 10 + .../queries/find-partner-content-ids.ts | 10 + .../queries/find-partner-content-owner.ts | 10 + .../queries/find-partner-content-rows.ts | 22 ++ .../graphql/queries/find-partner-link-ids.ts | 10 + .../graphql/queries/find-partner-link-rows.ts | 17 + .../queries/find-partner-service-ids.ts | 10 + .../queries/find-partner-service-rows.ts | 17 + .../get-my-partner-profile.mapper.test.ts} | 2 +- .../mappers/get-my-partner-profile.mapper.ts | 95 +++++ .../save-my-partner-content.mapper.test.ts} | 4 +- .../mappers/save-my-partner-content.mapper.ts | 70 ++++ .../mappers/save-my-partner-links.mapper.ts | 25 ++ .../save-my-partner-profile.mapper.test.ts} | 2 +- .../save-my-partner-profile.mapper.ts} | 50 +-- .../save-my-partner-services.mapper.ts | 21 ++ ...ubmit-partner-content-for-review.mapper.ts | 13 + .../my-case-studies.navigation-menu-item.ts | 4 +- .../my-profile.navigation-menu-item.ts | 4 +- ...-partner-content-created.logic-function.ts | 14 + .../on-partner-link-created.logic-function.ts | 14 + ...-partner-service-created.logic-function.ts | 14 + .../my-case-studies.page-layout.ts | 2 +- .../page-layouts/my-profile.page-layout.ts | 2 +- .../save-my-partner-content.logic-function.ts | 25 ++ .../save-my-partner-links.logic-function.ts | 24 ++ .../save-my-partner-profile.logic-function.ts | 24 ++ ...save-my-partner-services.logic-function.ts | 25 ++ .../get-my-partner-profile.service.ts | 39 ++ .../on-partner-content-created.service.ts | 57 +++ .../on-partner-link-created.service.ts | 39 ++ .../on-partner-service-created.service.ts | 39 ++ .../resolve-partner-from-request.service.ts} | 9 +- .../resolve-partner-from-request.test.ts | 2 +- .../save-my-partner-content.service.ts | 114 ++++++ .../services/save-my-partner-links.service.ts | 87 +++++ .../save-my-partner-profile.service.ts | 42 +++ .../save-my-partner-services.service.ts | 92 +++++ .../stamp-partner-user-on-child.service.ts} | 0 .../stamp-partner-user-on-child.test.ts | 2 +- ...bmit-partner-content-for-review.service.ts | 61 ++++ ...rtner-content-for-review.logic-function.ts | 26 ++ .../utils}/reconcile-children.test.ts | 0 .../self-service/utils}/reconcile-children.ts | 0 .../partner/utils}/content-type.test.ts | 0 .../partner/utils}/content-type.ts | 0 .../partner/utils}/profile-picture.test.ts | 0 .../partner/utils}/profile-picture.ts | 0 .../front-components/call-app-route.ts | 0 .../graphql/mutations/create-company.ts | 7 + .../shared/graphql/mutations/create-person.ts | 23 ++ .../graphql/queries/find-company-by-name.ts | 10 + .../graphql/queries/find-person-by-email.ts | 10 + .../http/read-secret-guarded-event.test.ts | 64 ++++ .../shared/http/read-secret-guarded-event.ts | 21 ++ ...r-workspace-folder.navigation-menu-item.ts | 0 .../pipeline-folder.navigation-menu-item.ts | 0 .../shared/post-install.logic-function.ts} | 0 ...d-or-create-company-and-person.service.ts} | 36 +- .../shared/utils/http-url.util.test.ts} | 2 +- .../shared/utils/http-url.util.ts} | 0 .../shared/utils/is-non-empty-string.util.ts | 3 + .../src/modules/shared/utils/paginate.util.ts | 22 ++ .../shared/utils/strip-markdown.util.test.ts} | 2 +- .../shared/utils/strip-markdown.util.ts} | 0 .../twenty-partners/src/roles/partner.role.ts | 48 +-- .../backfill-partner-user-on-children.ts | 2 +- .../internal/twenty-partners/vitest.config.ts | 35 +- .../twenty-partners/vitest.unit.config.ts | 15 - 295 files changed, 3333 insertions(+), 2690 deletions(-) create mode 100644 packages/twenty-apps/internal/twenty-partners/AGENTS.md create mode 100644 packages/twenty-apps/internal/twenty-partners/CLAUDE.md delete mode 100644 packages/twenty-apps/internal/twenty-partners/src/front-components/my-profile/types.ts delete mode 100644 packages/twenty-apps/internal/twenty-partners/src/logic-functions/get-my-partner-profile.logic-function.ts delete mode 100644 packages/twenty-apps/internal/twenty-partners/src/logic-functions/import-opportunity-from-tft.logic-function.ts delete mode 100644 packages/twenty-apps/internal/twenty-partners/src/logic-functions/list-available-partners.logic-function.ts delete mode 100644 packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-application-created.ts delete mode 100644 packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-opportunity-partner-won.ts delete mode 100644 packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-partner-application-created.logic-function.ts delete mode 100644 packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-partner-content-created.ts delete mode 100644 packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-partner-link-created.ts delete mode 100644 packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-partner-service-created.ts delete mode 100644 packages/twenty-apps/internal/twenty-partners/src/logic-functions/save-my-partner-content.logic-function.ts delete mode 100644 packages/twenty-apps/internal/twenty-partners/src/logic-functions/save-my-partner-links.logic-function.ts delete mode 100644 packages/twenty-apps/internal/twenty-partners/src/logic-functions/save-my-partner-services.logic-function.ts delete mode 100644 packages/twenty-apps/internal/twenty-partners/src/logic-functions/submit-client-brief.logic-function.ts delete mode 100644 packages/twenty-apps/internal/twenty-partners/src/logic-functions/submit-partner-application.logic-function.ts delete mode 100644 packages/twenty-apps/internal/twenty-partners/src/logic-functions/submit-partner-content-for-review.logic-function.ts rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions => modules/application}/__tests__/on-application-created.test.ts (98%) rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions => modules/application}/__tests__/on-application-set-name.test.ts (95%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/application}/fields/applications-as-partner-user-on-workspace-member.field.ts (93%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/application}/fields/applications-on-partner.field.ts (92%) create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/application/graphql/mutations/delete-application.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/application/graphql/mutations/update-application.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/application/graphql/queries/find-application-with-relations.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/application/graphql/queries/find-duplicate-application.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/application/graphql/queries/find-partner-by-member.ts rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/application}/navigation-menu-items/applications-by-opportunity.navigation-menu-item.ts (87%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/application}/navigation-menu-items/applications-review.navigation-menu-item.ts (89%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/application}/navigation-menu-items/followup-applications.navigation-menu-item.ts (88%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/application}/navigation-menu-items/my-applications.navigation-menu-item.ts (61%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/application}/objects/application.object.ts (100%) create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/application/on-application-created.logic-function.ts rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions/on-application-set-name.ts => modules/application/on-application-set-name.logic-function.ts} (72%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/application}/page-layouts/application.page-layout.ts (86%) create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/application/services/resolve-candidacy.service.ts rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/application}/views/application-record-page-fields.view.ts (96%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/application}/views/applications-by-opportunity.view.ts (97%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/application}/views/applications-review.view.ts (98%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/application}/views/followup-applications.view.ts (97%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/application}/views/my-applications.view.ts (96%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/opportunity}/constants/opportunity-stage-options.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/opportunity}/fields/applications-on-opportunity.field.ts (92%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/opportunity}/fields/opportunities-as-partner-user-on-workspace-member.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/opportunity}/fields/opportunities-on-partner.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/opportunity}/fields/opportunity-design-doc-status.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/opportunity}/fields/opportunity-design-doc-url.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/opportunity}/fields/opportunity-hosting-type.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/opportunity}/fields/opportunity-intro-sent-at.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/opportunity}/fields/opportunity-is-listed.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/opportunity}/fields/opportunity-lost-reason.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/opportunity}/fields/opportunity-need.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/opportunity}/fields/opportunity-number-of-seats.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/opportunity}/fields/opportunity-requirements.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/opportunity}/fields/opportunity-subscription-frequency.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/opportunity}/fields/opportunity-subscription-type.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/opportunity}/fields/opportunity-tft-id.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/opportunity}/fields/opportunity-use-case.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/opportunity}/fields/partner-on-opportunity.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/opportunity}/fields/partner-user-on-opportunity.field.ts (100%) create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/graphql/mutations/create-opportunity.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/graphql/queries/find-opportunity-by-dedupe-key.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/import-opportunity-from-tft.logic-function.ts rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions/__tests__ => modules/opportunity/intake}/import-opportunity-from-tft.test.ts (96%) create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/mappers/build-requirements-text.mapper.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/mappers/import-opportunity-from-tft.mapper.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/services/import-opportunity-from-tft.service.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/services/submit-client-brief.service.ts rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions/__tests__ => modules/opportunity/intake}/submit-client-brief.integration-test.ts (96%) create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/submit-client-brief.logic-function.ts rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions/__tests__ => modules/opportunity/intake}/submit-client-brief.test.ts (94%) create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/mutations/update-application-state.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/mutations/update-company-partner-user.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/mutations/update-opportunity-partner-user.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/mutations/update-person-partner-user.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/queries/find-opportunity-still-using-company.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/queries/get-company-partner-user.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/queries/get-opportunity-cascade-fields.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/queries/get-partner-partner-user.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/queries/list-applications-by-opportunity.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/queries/list-people-by-filter.ts rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions/__tests__ => modules/opportunity/matching}/on-opportunity-partner-assigned.integration-test.ts (98%) create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/on-opportunity-partner-assigned.logic-function.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/on-opportunity-partner-won.logic-function.ts rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions/__tests__ => modules/opportunity/matching}/on-opportunity-partner-won.test.ts (97%) rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions/on-opportunity-partner-assigned.ts => modules/opportunity/matching/services/propagate-partner-user.service.ts} (56%) create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/services/sync-application-outcomes.service.ts rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/opportunity}/navigation-menu-items/all-opportunities.navigation-menu-item.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/opportunity}/navigation-menu-items/briefs-to-match.navigation-menu-item.ts (78%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/opportunity}/navigation-menu-items/deals-board.navigation-menu-item.ts (79%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/opportunity}/navigation-menu-items/followup-briefs.navigation-menu-item.ts (78%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/opportunity}/navigation-menu-items/my-deals.navigation-menu-item.ts (62%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/opportunity}/navigation-menu-items/open-briefs.navigation-menu-item.ts (62%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/opportunity}/page-layouts/opportunity.page-layout.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/opportunity}/view-fields/opportunity-record-page-applications.view-field.ts (88%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/opportunity}/view-fields/opportunity-record-page-is-listed.view-field.ts (87%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/opportunity}/view-fields/opportunity-record-page-need.view-field.ts (88%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/opportunity}/view-fields/opportunity-record-page-partner.view-field.ts (87%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/opportunity}/view-fields/opportunity-record-page-requirements.view-field.ts (87%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/opportunity}/views/all-opportunities.view.ts (87%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/opportunity}/views/briefs-to-match.view.ts (83%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/opportunity}/views/deals-board.view.ts (88%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/opportunity}/views/followup-briefs.view.ts (82%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/opportunity}/views/my-deals.view.ts (91%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/opportunity}/views/open-briefs.view.ts (86%) rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions => modules/partner/application-intake/__tests__}/derive-deployment-expertise.test.ts (88%) rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions => modules/partner/application-intake/__tests__}/derive-region.test.ts (85%) rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions => modules/partner/application-intake}/__tests__/on-partner-application-created.test.ts (97%) rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions => modules/partner/application-intake}/__tests__/submit-partner-application.integration-test.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions => modules/partner/application-intake}/__tests__/submit-partner-application.schema.test.ts (95%) create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/connector/discord/config.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/connector/discord/discord.connector.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/connector/discord/types.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/graphql/mutations/create-company.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/graphql/mutations/create-partner.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/graphql/mutations/create-person.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/graphql/mutations/update-partner.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/graphql/mutations/update-person.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/graphql/queries/find-companies-by-domain.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/graphql/queries/find-partner-for-embed.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/graphql/queries/find-person-by-email.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/mappers/application-embed.mapper.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/mappers/build-partner-fields.mapper.ts rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner/application-intake}/navigation-menu-items/partner-applications.navigation-menu-item.ts (88%) create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/on-partner-application-created.logic-function.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/services/find-or-create-company.service.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/services/notify-partner-application.service.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/services/submit-partner-application-input.schema.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/services/submit-partner-application.service.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/submit-partner-application.logic-function.ts rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions => modules/partner/application-intake/utils}/derive-deployment-expertise.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions => modules/partner/application-intake/utils}/derive-region.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner/application-intake}/views/partner-applications.view.ts (93%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner}/constants/partner-country-view-groups.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner}/constants/partner-field-universal-identifiers.ts (100%) create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/constants/partner-option-values.constant.ts rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner/directory}/navigation-menu-items/partner-content.navigation-menu-item.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner/directory}/navigation-menu-items/partners-folder.navigation-menu-item.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner/directory}/navigation-menu-items/partners-per-country.navigation-menu-item.ts (88%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner/directory}/navigation-menu-items/partners-per-stage.navigation-menu-item.ts (88%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner/directory}/navigation-menu-items/partners-validated.navigation-menu-item.ts (88%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner/directory}/page-layouts/partner-content.page-layout.ts (95%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner/directory}/page-layouts/partner-link.page-layout.ts (91%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner/directory}/page-layouts/partner-service.page-layout.ts (95%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner/directory}/page-layouts/partner.page-layout.ts (94%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner/directory}/views/partner-content-record-page-fields.view.ts (93%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner/directory}/views/partner-content.view.ts (97%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner/directory}/views/partner-link-record-page-fields.view.ts (90%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner/directory}/views/partner-record-page-fields.view.ts (94%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner/directory}/views/partner-service-record-page-fields.view.ts (89%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner/directory}/views/partners-per-country.view.ts (92%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner/directory}/views/partners-per-stage.view.ts (97%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner/directory}/views/partners-validated.view.ts (92%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner}/fields/companies-as-partner-user-on-workspace-member.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner}/fields/partner-company.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner}/fields/partner-content-customer-company.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner}/fields/partner-content-customer-person.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner}/fields/partner-content-partner.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner}/fields/partner-contents-as-partner-user-on-workspace-member.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner}/fields/partner-contents-on-company.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner}/fields/partner-contents-on-partner.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner}/fields/partner-contents-on-person.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner}/fields/partner-link-partner.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner}/fields/partner-links-as-partner-user-on-workspace-member.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner}/fields/partner-links-on-partner.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner}/fields/partner-on-person.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner}/fields/partner-profiles-as-user-on-workspace-member.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner}/fields/partner-service-partner.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner}/fields/partner-services-as-partner-user-on-workspace-member.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner}/fields/partner-services-on-partner.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner}/fields/partner-user-on-company.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner}/fields/partner-user-on-partner-content.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner}/fields/partner-user-on-partner-link.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner}/fields/partner-user-on-partner-service.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner}/fields/partner-user-on-partner.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner}/fields/partner-user-on-person.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner}/fields/partners-on-company.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner}/fields/person-discord.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner}/fields/persons-as-partner-user-on-workspace-member.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner}/fields/persons-on-partner.field.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions => modules/partner/marketplace}/__tests__/marketplace-api.integration-test.ts (94%) create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/marketplace/get-partner-by-slug.logic-function.ts rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions/get-partner-by-slug.logic-function.ts => modules/partner/marketplace/graphql/queries/get-partner-by-slug.ts} (51%) create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/marketplace/graphql/queries/list-available-partners.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/marketplace/list-available-partners.logic-function.ts rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions/map-partner-for-marketplace.test.ts => modules/partner/marketplace/mappers/map-partner-for-marketplace.mapper.test.ts} (99%) rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions/map-partner-for-marketplace.ts => modules/partner/marketplace/mappers/map-partner-for-marketplace.mapper.ts} (96%) create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/marketplace/services/get-partner-by-slug.service.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/marketplace/services/list-available-partners.service.ts rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner}/objects/partner-content.object.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner}/objects/partner-link.object.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner}/objects/partner-service.object.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner}/objects/partner.object.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions => modules/partner/self-service}/__tests__/get-my-partner-profile.integration-test.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions => modules/partner/self-service}/__tests__/resolve-partner-from-request.integration-test.ts (98%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner/self-service}/__tests__/save-my-partner-content.integration-test.ts (98%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner/self-service}/__tests__/save-my-partner-links.integration-test.ts (98%) rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions => modules/partner/self-service}/__tests__/save-my-partner-profile.integration-test.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner/self-service}/__tests__/save-my-partner-services.integration-test.ts (98%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner/self-service}/__tests__/submit-partner-content-for-review.integration-test.ts (97%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner/self-service}/constants/my-case-studies.constants.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner/self-service}/constants/my-profile.constants.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner/self-service}/front-components/my-case-studies.front-component.tsx (97%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner/self-service}/front-components/my-case-studies/case-study-card.tsx (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner/self-service}/front-components/my-case-studies/case-study-rows.test.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner/self-service}/front-components/my-case-studies/case-study-rows.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner/self-service}/front-components/my-profile.front-component.tsx (97%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner/self-service}/front-components/my-profile/form-fields.tsx (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner/self-service}/front-components/my-profile/markdown-editor.tsx (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner/self-service}/front-components/my-profile/markdown-render.test.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner/self-service}/front-components/my-profile/markdown-render.tsx (100%) rename packages/twenty-apps/internal/twenty-partners/src/{front-components/my-profile/ProfilePictureUpload.tsx => modules/partner/self-service/front-components/my-profile/profile-picture-upload.tsx} (100%) create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/front-components/my-profile/types.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/get-my-partner-profile.logic-function.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/mutations/create-partner-content.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/mutations/create-partner-link.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/mutations/create-partner-service.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/mutations/delete-partner-content.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/mutations/delete-partner-link.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/mutations/delete-partner-service.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/mutations/update-partner-content.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/mutations/update-partner-link.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/mutations/update-partner-service.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/mutations/update-partner.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/queries/find-my-partner-profile.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/queries/find-partner-by-member.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/queries/find-partner-content-ids.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/queries/find-partner-content-owner.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/queries/find-partner-content-rows.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/queries/find-partner-link-ids.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/queries/find-partner-link-rows.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/queries/find-partner-service-ids.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/queries/find-partner-service-rows.ts rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions/get-my-partner-profile.test.ts => modules/partner/self-service/mappers/get-my-partner-profile.mapper.test.ts} (99%) create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/mappers/get-my-partner-profile.mapper.ts rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions/save-my-partner-content.test.ts => modules/partner/self-service/mappers/save-my-partner-content.mapper.test.ts} (98%) create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/mappers/save-my-partner-content.mapper.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/mappers/save-my-partner-links.mapper.ts rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions/save-my-partner-profile.test.ts => modules/partner/self-service/mappers/save-my-partner-profile.mapper.test.ts} (99%) rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions/save-my-partner-profile.logic-function.ts => modules/partner/self-service/mappers/save-my-partner-profile.mapper.ts} (72%) create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/mappers/save-my-partner-services.mapper.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/mappers/submit-partner-content-for-review.mapper.ts rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner/self-service}/navigation-menu-items/my-case-studies.navigation-menu-item.ts (66%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner/self-service}/navigation-menu-items/my-profile.navigation-menu-item.ts (68%) create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/on-partner-content-created.logic-function.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/on-partner-link-created.logic-function.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/on-partner-service-created.logic-function.ts rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner/self-service}/page-layouts/my-case-studies.page-layout.ts (92%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/partner/self-service}/page-layouts/my-profile.page-layout.ts (92%) create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/save-my-partner-content.logic-function.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/save-my-partner-links.logic-function.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/save-my-partner-profile.logic-function.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/save-my-partner-services.logic-function.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/get-my-partner-profile.service.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/on-partner-content-created.service.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/on-partner-link-created.service.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/on-partner-service-created.service.ts rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions/resolve-partner-from-request.ts => modules/partner/self-service/services/resolve-partner-from-request.service.ts} (94%) rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions => modules/partner/self-service/services}/resolve-partner-from-request.test.ts (98%) create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/save-my-partner-content.service.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/save-my-partner-links.service.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/save-my-partner-profile.service.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/save-my-partner-services.service.ts rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions/stamp-partner-user-on-child.ts => modules/partner/self-service/services/stamp-partner-user-on-child.service.ts} (100%) rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions => modules/partner/self-service/services}/stamp-partner-user-on-child.test.ts (99%) create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/submit-partner-content-for-review.service.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/submit-partner-content-for-review.logic-function.ts rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions => modules/partner/self-service/utils}/reconcile-children.test.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions => modules/partner/self-service/utils}/reconcile-children.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions => modules/partner/utils}/content-type.test.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions => modules/partner/utils}/content-type.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions => modules/partner/utils}/profile-picture.test.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions => modules/partner/utils}/profile-picture.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/shared}/front-components/call-app-route.ts (100%) create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/shared/graphql/mutations/create-company.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/shared/graphql/mutations/create-person.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/shared/graphql/queries/find-company-by-name.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/shared/graphql/queries/find-person-by-email.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/shared/http/read-secret-guarded-event.test.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/shared/http/read-secret-guarded-event.ts rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/shared}/navigation-menu-items/partner-workspace-folder.navigation-menu-item.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{ => modules/shared}/navigation-menu-items/pipeline-folder.navigation-menu-item.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions/post-install.ts => modules/shared/post-install.logic-function.ts} (100%) rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions/find-or-create-company-and-person.ts => modules/shared/services/find-or-create-company-and-person.service.ts} (67%) rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions/http-url.test.ts => modules/shared/utils/http-url.util.test.ts} (95%) rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions/http-url.ts => modules/shared/utils/http-url.util.ts} (100%) create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/shared/utils/is-non-empty-string.util.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/shared/utils/paginate.util.ts rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions/strip-markdown.test.ts => modules/shared/utils/strip-markdown.util.test.ts} (88%) rename packages/twenty-apps/internal/twenty-partners/src/{logic-functions/strip-markdown.ts => modules/shared/utils/strip-markdown.util.ts} (100%) delete mode 100644 packages/twenty-apps/internal/twenty-partners/vitest.unit.config.ts diff --git a/packages/twenty-apps/internal/twenty-partners/AGENTS.md b/packages/twenty-apps/internal/twenty-partners/AGENTS.md new file mode 100644 index 0000000000..65ebad2066 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/AGENTS.md @@ -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//` 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 + / # partner · application · opportunity · … (large domains add a / tier) + .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 (.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 +`/` level — `modules////` (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 `//` 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 (`.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 + +`..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: `.test.ts` = unit (mocked +client, fast, no infra); `.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/`. diff --git a/packages/twenty-apps/internal/twenty-partners/CLAUDE.md b/packages/twenty-apps/internal/twenty-partners/CLAUDE.md new file mode 100644 index 0000000000..43c994c2d3 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/packages/twenty-apps/internal/twenty-partners/package.json b/packages/twenty-apps/internal/twenty-partners/package.json index e1e213a0a2..1a5dfafdf4 100644 --- a/packages/twenty-apps/internal/twenty-partners/package.json +++ b/packages/twenty-apps/internal/twenty-partners/package.json @@ -1,6 +1,6 @@ { "name": "twenty-partners", - "version": "1.3.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", diff --git a/packages/twenty-apps/internal/twenty-partners/src/front-components/my-profile/types.ts b/packages/twenty-apps/internal/twenty-partners/src/front-components/my-profile/types.ts deleted file mode 100644 index 3f85ec2590..0000000000 --- a/packages/twenty-apps/internal/twenty-partners/src/front-components/my-profile/types.ts +++ /dev/null @@ -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'; diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/get-my-partner-profile.logic-function.ts b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/get-my-partner-profile.logic-function.ts deleted file mode 100644 index 3dd4d7b148..0000000000 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/get-my-partner-profile.logic-function.ts +++ /dev/null @@ -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>['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, -): Promise => { - 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, - }, -}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/import-opportunity-from-tft.logic-function.ts b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/import-opportunity-from-tft.logic-function.ts deleted file mode 100644 index 35bb3b829d..0000000000 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/import-opportunity-from-tft.logic-function.ts +++ /dev/null @@ -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; - 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 { - 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 { - 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 => { - // 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], - }, -}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/list-available-partners.logic-function.ts b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/list-available-partners.logic-function.ts deleted file mode 100644 index 27f5961591..0000000000 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/list-available-partners.logic-function.ts +++ /dev/null @@ -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>['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 => { - 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, - }, -}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-application-created.ts b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-application-created.ts deleted file mode 100644 index bb10cb6763..0000000000 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-application-created.ts +++ /dev/null @@ -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>, -): Promise> => { - 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' }, -}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-opportunity-partner-won.ts b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-opportunity-partner-won.ts deleted file mode 100644 index 5da37a7a54..0000000000 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-opportunity-partner-won.ts +++ /dev/null @@ -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>['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>, -): Promise> => { - 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' }, -}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-partner-application-created.logic-function.ts b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-partner-application-created.logic-function.ts deleted file mode 100644 index 1af23bafb3..0000000000 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-partner-application-created.logic-function.ts +++ /dev/null @@ -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 { - // 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 = { - 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>, -): Promise> => { - // 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', - }, -}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-partner-content-created.ts b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-partner-content-created.ts deleted file mode 100644 index 291e58ff3a..0000000000 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-partner-content-created.ts +++ /dev/null @@ -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 => { - 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>, -): Promise> => { - 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' }, -}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-partner-link-created.ts b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-partner-link-created.ts deleted file mode 100644 index 076038a4db..0000000000 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-partner-link-created.ts +++ /dev/null @@ -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 => { - 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>, -): Promise> => { - 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' }, -}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-partner-service-created.ts b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-partner-service-created.ts deleted file mode 100644 index 003ba0408d..0000000000 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-partner-service-created.ts +++ /dev/null @@ -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 => { - 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>, -): Promise> => { - 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' }, -}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/save-my-partner-content.logic-function.ts b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/save-my-partner-content.logic-function.ts deleted file mode 100644 index dd56b95609..0000000000 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/save-my-partner-content.logic-function.ts +++ /dev/null @@ -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; - -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 => { - 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 => { - 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): Promise => { - 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, - }, -}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/save-my-partner-links.logic-function.ts b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/save-my-partner-links.logic-function.ts deleted file mode 100644 index 90ddb97406..0000000000 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/save-my-partner-links.logic-function.ts +++ /dev/null @@ -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; - -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 => { - 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 => { - 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): Promise => { - 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, - }, -}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/save-my-partner-services.logic-function.ts b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/save-my-partner-services.logic-function.ts deleted file mode 100644 index ced928a7fd..0000000000 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/save-my-partner-services.logic-function.ts +++ /dev/null @@ -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; - -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 => { - 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 => { - 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): Promise => { - 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, - }, -}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/submit-client-brief.logic-function.ts b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/submit-client-brief.logic-function.ts deleted file mode 100644 index da0fda42d3..0000000000 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/submit-client-brief.logic-function.ts +++ /dev/null @@ -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; - -export type SubmitClientBriefResult = - | { ok: true; opportunityId: string } - | { ok: false; reason: string }; - -function isNonEmptyString(value: unknown): value is string { - return typeof value === 'string' && value.trim().length > 0; -} - -export function buildRequirementsText(input: SubmitClientBriefInput): string | null { - const base = isNonEmptyString(input.requirements) ? input.requirements.trim() : ''; - const bullets: string[] = []; - if (input.hostingType !== undefined) { - bullets.push(`• Hosting: ${HOSTING_LABEL[input.hostingType]}`); - } - if (isNonEmptyString(input.seatCount)) bullets.push(`• Seats: ${input.seatCount.trim()}`); - if (isNonEmptyString(input.country)) bullets.push(`• Country: ${input.country.trim()}`); - if (input.languages !== undefined && input.languages.length > 0) { - bullets.push(`• Languages: ${input.languages.join(', ')}`); - } - if (isNonEmptyString(input.timeline)) bullets.push(`• Timeline: ${input.timeline.trim()}`); - if (isNonEmptyString(input.budgetRange)) bullets.push(`• Budget: ${input.budgetRange.trim()}`); - if (bullets.length === 0) return base.length > 0 ? base : null; - const block = `---\nAdditional context:\n${bullets.join('\n')}`; - return base ? `${base}\n\n${block}` : block; -} - -type SubmitClientBriefEvent = { - headers?: Record; - body?: unknown; -}; - -const APPLICATION_SECRET_HEADER = 'x-application-secret'; - -export const handler = async ( - event: SubmitClientBriefEvent | SubmitClientBriefInput, -): Promise => { - const looksLikeEvent = - typeof event === 'object' && - event !== null && - ('body' in event || 'headers' in event); - - const headers = looksLikeEvent - ? (event as SubmitClientBriefEvent).headers ?? {} - : {}; - const rawInput = looksLikeEvent - ? (event as SubmitClientBriefEvent).body - : event; - - const expectedSecret = process.env.PARTNER_APPLICATION_SECRET; - if (!isNonEmptyString(expectedSecret)) { - return { ok: false, reason: 'unauthorized' }; - } - const providedSecret = headers[APPLICATION_SECRET_HEADER]; - if (providedSecret !== expectedSecret) { - return { ok: false, reason: 'unauthorized' }; - } - - const parsed = submitClientBriefSchema.safeParse(rawInput); - if (!parsed.success) { - return { ok: false, reason: 'invalid_input' }; - } - const input = parsed.data; - - try { - const client = new CoreApiClient(); - const name = `${input.companyName.trim()} — marketplace brief`; - const requirements = buildRequirementsText(input); - - const companyId = await findOrCreateCompanyByName(client, input.companyName); - const pointOfContactId = await findOrCreatePersonByEmail(client, { - email: input.email, - firstName: input.firstName, - lastName: input.lastName, - companyId, - }); - - const opportunityData: CoreSchema.OpportunityCreateInput = { - name, - need: input.need, - isListed: false, - stage: 'NEW', - companyId, - pointOfContactId, - }; - if (requirements !== null) { - opportunityData.requirements = requirements; - } - - const result = await client.mutation({ - createOpportunity: { __args: { data: opportunityData }, id: true }, - }); - const opportunityId = result.createOpportunity?.id; - if (opportunityId === undefined) { - throw new Error('createOpportunity did not return an id'); - } - - return { ok: true, opportunityId }; - } catch (err) { - return { ok: false, reason: err instanceof Error ? err.message : String(err) }; - } -}; - -export default defineLogicFunction({ - universalIdentifier: SUBMIT_CLIENT_BRIEF_LOGIC_FUNCTION_ID, - name: 'submit-client-brief', - description: 'Create an unlisted Opportunity from the public marketplace brief form.', - timeoutSeconds: 15, - handler, - httpRouteTriggerSettings: { - path: '/client-briefs', - httpMethod: 'POST', - isAuthRequired: false, - forwardedRequestHeaders: [APPLICATION_SECRET_HEADER], - }, -}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/submit-partner-application.logic-function.ts b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/submit-partner-application.logic-function.ts deleted file mode 100644 index f3c7eddc17..0000000000 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/submit-partner-application.logic-function.ts +++ /dev/null @@ -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 { - 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; - body?: unknown; -}; - -const APPLICATION_SECRET_HEADER = 'x-application-secret'; - -export const handler = async ( - event: SubmitPartnerApplicationEvent | SubmitPartnerApplicationInput, -): Promise => { - // 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], - }, -}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/submit-partner-content-for-review.logic-function.ts b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/submit-partner-content-for-review.logic-function.ts deleted file mode 100644 index b2d6c1939c..0000000000 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/submit-partner-content-for-review.logic-function.ts +++ /dev/null @@ -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; - -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, -): Promise => { - 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, - }, -}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-application-created.test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/application/__tests__/on-application-created.test.ts similarity index 98% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-application-created.test.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/application/__tests__/on-application-created.test.ts index f8cd9179a5..1d1d8d732f 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-application-created.test.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/application/__tests__/on-application-created.test.ts @@ -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'; diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-application-set-name.test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/application/__tests__/on-application-set-name.test.ts similarity index 95% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-application-set-name.test.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/application/__tests__/on-application-set-name.test.ts index 3bf5fc7da8..f5b10ef498 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-application-set-name.test.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/application/__tests__/on-application-set-name.test.ts @@ -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, updatedFields: string[]) => ({ properties: { after, updatedFields } }) as never; diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/applications-as-partner-user-on-workspace-member.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/application/fields/applications-as-partner-user-on-workspace-member.field.ts similarity index 93% rename from packages/twenty-apps/internal/twenty-partners/src/fields/applications-as-partner-user-on-workspace-member.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/application/fields/applications-as-partner-user-on-workspace-member.field.ts index ecbfce8761..329866802b 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/fields/applications-as-partner-user-on-workspace-member.field.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/application/fields/applications-as-partner-user-on-workspace-member.field.ts @@ -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, diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/applications-on-partner.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/application/fields/applications-on-partner.field.ts similarity index 92% rename from packages/twenty-apps/internal/twenty-partners/src/fields/applications-on-partner.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/application/fields/applications-on-partner.field.ts index b0fb878c31..7cf1b7cb2a 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/fields/applications-on-partner.field.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/application/fields/applications-on-partner.field.ts @@ -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, diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/application/graphql/mutations/delete-application.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/application/graphql/mutations/delete-application.ts new file mode 100644 index 0000000000..2cd27db96c --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/application/graphql/mutations/delete-application.ts @@ -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, + }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/application/graphql/mutations/update-application.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/application/graphql/mutations/update-application.ts new file mode 100644 index 0000000000..df19f24783 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/application/graphql/mutations/update-application.ts @@ -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 }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/application/graphql/queries/find-application-with-relations.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/application/graphql/queries/find-application-with-relations.ts new file mode 100644 index 0000000000..ef6ff4a531 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/application/graphql/queries/find-application-with-relations.ts @@ -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 }, + }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/application/graphql/queries/find-duplicate-application.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/application/graphql/queries/find-duplicate-application.ts new file mode 100644 index 0000000000..6abe2b1e6e --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/application/graphql/queries/find-duplicate-application.ts @@ -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 } }, + }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/application/graphql/queries/find-partner-by-member.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/application/graphql/queries/find-partner-by-member.ts new file mode 100644 index 0000000000..c2cdc6d6c2 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/application/graphql/queries/find-partner-by-member.ts @@ -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 } }, + }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/applications-by-opportunity.navigation-menu-item.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/application/navigation-menu-items/applications-by-opportunity.navigation-menu-item.ts similarity index 87% rename from packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/applications-by-opportunity.navigation-menu-item.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/application/navigation-menu-items/applications-by-opportunity.navigation-menu-item.ts index 5de3d7bf25..3bcdab075c 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/applications-by-opportunity.navigation-menu-item.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/application/navigation-menu-items/applications-by-opportunity.navigation-menu-item.ts @@ -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', diff --git a/packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/applications-review.navigation-menu-item.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/application/navigation-menu-items/applications-review.navigation-menu-item.ts similarity index 89% rename from packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/applications-review.navigation-menu-item.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/application/navigation-menu-items/applications-review.navigation-menu-item.ts index 04b29ecd23..e14e78193f 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/applications-review.navigation-menu-item.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/application/navigation-menu-items/applications-review.navigation-menu-item.ts @@ -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', diff --git a/packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/followup-applications.navigation-menu-item.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/application/navigation-menu-items/followup-applications.navigation-menu-item.ts similarity index 88% rename from packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/followup-applications.navigation-menu-item.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/application/navigation-menu-items/followup-applications.navigation-menu-item.ts index 6f7f9372b0..2a42d131c6 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/followup-applications.navigation-menu-item.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/application/navigation-menu-items/followup-applications.navigation-menu-item.ts @@ -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', diff --git a/packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/my-applications.navigation-menu-item.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/application/navigation-menu-items/my-applications.navigation-menu-item.ts similarity index 61% rename from packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/my-applications.navigation-menu-item.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/application/navigation-menu-items/my-applications.navigation-menu-item.ts index 11dc58bf3f..00085f67fa 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/my-applications.navigation-menu-item.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/application/navigation-menu-items/my-applications.navigation-menu-item.ts @@ -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', diff --git a/packages/twenty-apps/internal/twenty-partners/src/objects/application.object.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/application/objects/application.object.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/objects/application.object.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/application/objects/application.object.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/application/on-application-created.logic-function.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/application/on-application-created.logic-function.ts new file mode 100644 index 0000000000..3a6d5d98d7 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/application/on-application-created.logic-function.ts @@ -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>, +): Promise> => + 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' }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-application-set-name.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/application/on-application-set-name.logic-function.ts similarity index 72% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-application-set-name.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/application/on-application-set-name.logic-function.ts index ccfe1d613d..bb0bf75682 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-application-set-name.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/application/on-application-set-name.logic-function.ts @@ -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 " · " whenever either relation changes. @@ -12,30 +15,18 @@ export const handler = async ( payload: DatabaseEventPayload>, ): Promise> => { 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 }; }; diff --git a/packages/twenty-apps/internal/twenty-partners/src/page-layouts/application.page-layout.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/application/page-layouts/application.page-layout.ts similarity index 86% rename from packages/twenty-apps/internal/twenty-partners/src/page-layouts/application.page-layout.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/application/page-layouts/application.page-layout.ts index 171f92a919..e859c6befa 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/page-layouts/application.page-layout.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/application/page-layouts/application.page-layout.ts @@ -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 + diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/application/services/resolve-candidacy.service.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/application/services/resolve-candidacy.service.ts new file mode 100644 index 0000000000..65ac1f7ac8 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/application/services/resolve-candidacy.service.ts @@ -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 +>['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> { + 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 }; +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/views/application-record-page-fields.view.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/application/views/application-record-page-fields.view.ts similarity index 96% rename from packages/twenty-apps/internal/twenty-partners/src/views/application-record-page-fields.view.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/application/views/application-record-page-fields.view.ts index 07e3291163..9661519c53 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/views/application-record-page-fields.view.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/application/views/application-record-page-fields.view.ts @@ -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'; diff --git a/packages/twenty-apps/internal/twenty-partners/src/views/applications-by-opportunity.view.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/application/views/applications-by-opportunity.view.ts similarity index 97% rename from packages/twenty-apps/internal/twenty-partners/src/views/applications-by-opportunity.view.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/application/views/applications-by-opportunity.view.ts index f33261cfce..9874123803 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/views/applications-by-opportunity.view.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/application/views/applications-by-opportunity.view.ts @@ -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'; diff --git a/packages/twenty-apps/internal/twenty-partners/src/views/applications-review.view.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/application/views/applications-review.view.ts similarity index 98% rename from packages/twenty-apps/internal/twenty-partners/src/views/applications-review.view.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/application/views/applications-review.view.ts index 792721b9ee..9669a5656b 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/views/applications-review.view.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/application/views/applications-review.view.ts @@ -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'; diff --git a/packages/twenty-apps/internal/twenty-partners/src/views/followup-applications.view.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/application/views/followup-applications.view.ts similarity index 97% rename from packages/twenty-apps/internal/twenty-partners/src/views/followup-applications.view.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/application/views/followup-applications.view.ts index e902cfcc52..c7943c1ff4 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/views/followup-applications.view.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/application/views/followup-applications.view.ts @@ -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'; diff --git a/packages/twenty-apps/internal/twenty-partners/src/views/my-applications.view.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/application/views/my-applications.view.ts similarity index 96% rename from packages/twenty-apps/internal/twenty-partners/src/views/my-applications.view.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/application/views/my-applications.view.ts index bbe4845b6d..a4339dc292 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/views/my-applications.view.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/application/views/my-applications.view.ts @@ -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'; diff --git a/packages/twenty-apps/internal/twenty-partners/src/constants/opportunity-stage-options.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/constants/opportunity-stage-options.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/constants/opportunity-stage-options.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/constants/opportunity-stage-options.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/applications-on-opportunity.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/applications-on-opportunity.field.ts similarity index 92% rename from packages/twenty-apps/internal/twenty-partners/src/fields/applications-on-opportunity.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/applications-on-opportunity.field.ts index 6c0e803dc1..1072040632 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/fields/applications-on-opportunity.field.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/applications-on-opportunity.field.ts @@ -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, diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunities-as-partner-user-on-workspace-member.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/opportunities-as-partner-user-on-workspace-member.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/opportunities-as-partner-user-on-workspace-member.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/opportunities-as-partner-user-on-workspace-member.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunities-on-partner.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/opportunities-on-partner.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/opportunities-on-partner.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/opportunities-on-partner.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-design-doc-status.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/opportunity-design-doc-status.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-design-doc-status.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/opportunity-design-doc-status.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-design-doc-url.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/opportunity-design-doc-url.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-design-doc-url.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/opportunity-design-doc-url.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-hosting-type.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/opportunity-hosting-type.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-hosting-type.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/opportunity-hosting-type.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-intro-sent-at.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/opportunity-intro-sent-at.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-intro-sent-at.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/opportunity-intro-sent-at.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-is-listed.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/opportunity-is-listed.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-is-listed.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/opportunity-is-listed.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-lost-reason.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/opportunity-lost-reason.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-lost-reason.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/opportunity-lost-reason.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-need.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/opportunity-need.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-need.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/opportunity-need.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-number-of-seats.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/opportunity-number-of-seats.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-number-of-seats.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/opportunity-number-of-seats.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-requirements.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/opportunity-requirements.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-requirements.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/opportunity-requirements.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-subscription-frequency.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/opportunity-subscription-frequency.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-subscription-frequency.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/opportunity-subscription-frequency.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-subscription-type.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/opportunity-subscription-type.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-subscription-type.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/opportunity-subscription-type.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-tft-id.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/opportunity-tft-id.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-tft-id.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/opportunity-tft-id.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-use-case.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/opportunity-use-case.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-use-case.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/opportunity-use-case.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-on-opportunity.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/partner-on-opportunity.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/partner-on-opportunity.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/partner-on-opportunity.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-opportunity.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/partner-user-on-opportunity.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-opportunity.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/fields/partner-user-on-opportunity.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/graphql/mutations/create-opportunity.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/graphql/mutations/create-opportunity.ts new file mode 100644 index 0000000000..4482fa66a6 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/graphql/mutations/create-opportunity.ts @@ -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 }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/graphql/queries/find-opportunity-by-dedupe-key.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/graphql/queries/find-opportunity-by-dedupe-key.ts new file mode 100644 index 0000000000..10913ca9d6 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/graphql/queries/find-opportunity-by-dedupe-key.ts @@ -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 } }, + }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/import-opportunity-from-tft.logic-function.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/import-opportunity-from-tft.logic-function.ts new file mode 100644 index 0000000000..1a89975bab --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/import-opportunity-from-tft.logic-function.ts @@ -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 => { + 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], + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/import-opportunity-from-tft.test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/import-opportunity-from-tft.test.ts similarity index 96% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/import-opportunity-from-tft.test.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/import-opportunity-from-tft.test.ts index debd417db6..8d306cefd1 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/import-opportunity-from-tft.test.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/import-opportunity-from-tft.test.ts @@ -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'; diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/mappers/build-requirements-text.mapper.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/mappers/build-requirements-text.mapper.ts new file mode 100644 index 0000000000..84233aba62 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/mappers/build-requirements-text.mapper.ts @@ -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; + +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; +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/mappers/import-opportunity-from-tft.mapper.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/mappers/import-opportunity-from-tft.mapper.ts new file mode 100644 index 0000000000..bddc1e5962 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/mappers/import-opportunity-from-tft.mapper.ts @@ -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; +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/services/import-opportunity-from-tft.service.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/services/import-opportunity-from-tft.service.ts new file mode 100644 index 0000000000..5c216c82d4 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/services/import-opportunity-from-tft.service.ts @@ -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 { + 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 { + 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 { + 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) }; + } +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/services/submit-client-brief.service.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/services/submit-client-brief.service.ts new file mode 100644 index 0000000000..096f81b7b6 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/services/submit-client-brief.service.ts @@ -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 { + 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) }; + } +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/submit-client-brief.integration-test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/submit-client-brief.integration-test.ts similarity index 96% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/submit-client-brief.integration-test.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/submit-client-brief.integration-test.ts index 06c0dd66c3..4c35280160 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/submit-client-brief.integration-test.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/submit-client-brief.integration-test.ts @@ -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; diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/submit-client-brief.logic-function.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/submit-client-brief.logic-function.ts new file mode 100644 index 0000000000..f45395fe3f --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/submit-client-brief.logic-function.ts @@ -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 => { + 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], + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/submit-client-brief.test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/submit-client-brief.test.ts similarity index 94% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/submit-client-brief.test.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/submit-client-brief.test.ts index 619bba97da..b252a916b6 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/submit-client-brief.test.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/intake/submit-client-brief.test.ts @@ -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', diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/mutations/update-application-state.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/mutations/update-application-state.ts new file mode 100644 index 0000000000..63b22ce721 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/mutations/update-application-state.ts @@ -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 }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/mutations/update-company-partner-user.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/mutations/update-company-partner-user.ts new file mode 100644 index 0000000000..52958439b3 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/mutations/update-company-partner-user.ts @@ -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 }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/mutations/update-opportunity-partner-user.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/mutations/update-opportunity-partner-user.ts new file mode 100644 index 0000000000..e355697e85 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/mutations/update-opportunity-partner-user.ts @@ -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 }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/mutations/update-person-partner-user.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/mutations/update-person-partner-user.ts new file mode 100644 index 0000000000..aaeb0bab9c --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/mutations/update-person-partner-user.ts @@ -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 }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/queries/find-opportunity-still-using-company.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/queries/find-opportunity-still-using-company.ts new file mode 100644 index 0000000000..1ebf872208 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/queries/find-opportunity-still-using-company.ts @@ -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 } }, + }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/queries/get-company-partner-user.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/queries/get-company-partner-user.ts new file mode 100644 index 0000000000..fabb6bf507 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/queries/get-company-partner-user.ts @@ -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, + }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/queries/get-opportunity-cascade-fields.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/queries/get-opportunity-cascade-fields.ts new file mode 100644 index 0000000000..1055ae65f3 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/queries/get-opportunity-cascade-fields.ts @@ -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, + }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/queries/get-partner-partner-user.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/queries/get-partner-partner-user.ts new file mode 100644 index 0000000000..c3be3bd142 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/queries/get-partner-partner-user.ts @@ -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 }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/queries/list-applications-by-opportunity.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/queries/list-applications-by-opportunity.ts new file mode 100644 index 0000000000..68400daa75 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/queries/list-applications-by-opportunity.ts @@ -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 }, + }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/queries/list-people-by-filter.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/queries/list-people-by-filter.ts new file mode 100644 index 0000000000..c811bde65c --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/queries/list-people-by-filter.ts @@ -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 }, + }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-opportunity-partner-assigned.integration-test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/on-opportunity-partner-assigned.integration-test.ts similarity index 98% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-opportunity-partner-assigned.integration-test.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/on-opportunity-partner-assigned.integration-test.ts index 0598e7e26c..93b03efbd3 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-opportunity-partner-assigned.integration-test.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/on-opportunity-partner-assigned.integration-test.ts @@ -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 { diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/on-opportunity-partner-assigned.logic-function.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/on-opportunity-partner-assigned.logic-function.ts new file mode 100644 index 0000000000..ece2c68cf8 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/on-opportunity-partner-assigned.logic-function.ts @@ -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>, +): Promise> => { + 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' }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/on-opportunity-partner-won.logic-function.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/on-opportunity-partner-won.logic-function.ts new file mode 100644 index 0000000000..a1884f04d8 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/on-opportunity-partner-won.logic-function.ts @@ -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>, +): Promise> => { + 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' }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-opportunity-partner-won.test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/on-opportunity-partner-won.test.ts similarity index 97% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-opportunity-partner-won.test.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/on-opportunity-partner-won.test.ts index d5a0462df5..b3336518b2 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-opportunity-partner-won.test.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/on-opportunity-partner-won.test.ts @@ -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'; diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-opportunity-partner-assigned.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/services/propagate-partner-user.service.ts similarity index 56% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-opportunity-partner-assigned.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/services/propagate-partner-user.service.ts index e6481ec78f..d4f42a010e 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-opportunity-partner-assigned.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/services/propagate-partner-user.service.ts @@ -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 +>['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 { - 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 { 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>, -): Promise> => { - 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> { + 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' }, -}); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/services/sync-application-outcomes.service.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/services/sync-application-outcomes.service.ts new file mode 100644 index 0000000000..cad07c1c54 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/services/sync-application-outcomes.service.ts @@ -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> { + 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 }; +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/all-opportunities.navigation-menu-item.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/navigation-menu-items/all-opportunities.navigation-menu-item.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/all-opportunities.navigation-menu-item.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/navigation-menu-items/all-opportunities.navigation-menu-item.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/briefs-to-match.navigation-menu-item.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/navigation-menu-items/briefs-to-match.navigation-menu-item.ts similarity index 78% rename from packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/briefs-to-match.navigation-menu-item.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/navigation-menu-items/briefs-to-match.navigation-menu-item.ts index 897f4c4a5f..a760b5ab71 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/briefs-to-match.navigation-menu-item.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/navigation-menu-items/briefs-to-match.navigation-menu-item.ts @@ -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', diff --git a/packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/deals-board.navigation-menu-item.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/navigation-menu-items/deals-board.navigation-menu-item.ts similarity index 79% rename from packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/deals-board.navigation-menu-item.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/navigation-menu-items/deals-board.navigation-menu-item.ts index 35e3894ce9..24e7cad947 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/deals-board.navigation-menu-item.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/navigation-menu-items/deals-board.navigation-menu-item.ts @@ -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', diff --git a/packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/followup-briefs.navigation-menu-item.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/navigation-menu-items/followup-briefs.navigation-menu-item.ts similarity index 78% rename from packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/followup-briefs.navigation-menu-item.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/navigation-menu-items/followup-briefs.navigation-menu-item.ts index 916cf17b93..b4a64afbc3 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/followup-briefs.navigation-menu-item.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/navigation-menu-items/followup-briefs.navigation-menu-item.ts @@ -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', diff --git a/packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/my-deals.navigation-menu-item.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/navigation-menu-items/my-deals.navigation-menu-item.ts similarity index 62% rename from packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/my-deals.navigation-menu-item.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/navigation-menu-items/my-deals.navigation-menu-item.ts index fb400e3662..d8eb8371ea 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/my-deals.navigation-menu-item.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/navigation-menu-items/my-deals.navigation-menu-item.ts @@ -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', diff --git a/packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/open-briefs.navigation-menu-item.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/navigation-menu-items/open-briefs.navigation-menu-item.ts similarity index 62% rename from packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/open-briefs.navigation-menu-item.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/navigation-menu-items/open-briefs.navigation-menu-item.ts index 54e0bd9b87..4348f010f0 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/open-briefs.navigation-menu-item.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/navigation-menu-items/open-briefs.navigation-menu-item.ts @@ -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', diff --git a/packages/twenty-apps/internal/twenty-partners/src/page-layouts/opportunity.page-layout.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/page-layouts/opportunity.page-layout.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/page-layouts/opportunity.page-layout.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/page-layouts/opportunity.page-layout.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/view-fields/opportunity-record-page-applications.view-field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/view-fields/opportunity-record-page-applications.view-field.ts similarity index 88% rename from packages/twenty-apps/internal/twenty-partners/src/view-fields/opportunity-record-page-applications.view-field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/view-fields/opportunity-record-page-applications.view-field.ts index 70d479fffe..6aa9bdc831 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/view-fields/opportunity-record-page-applications.view-field.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/view-fields/opportunity-record-page-applications.view-field.ts @@ -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; diff --git a/packages/twenty-apps/internal/twenty-partners/src/view-fields/opportunity-record-page-is-listed.view-field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/view-fields/opportunity-record-page-is-listed.view-field.ts similarity index 87% rename from packages/twenty-apps/internal/twenty-partners/src/view-fields/opportunity-record-page-is-listed.view-field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/view-fields/opportunity-record-page-is-listed.view-field.ts index 3caea4ee32..908ef60b1f 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/view-fields/opportunity-record-page-is-listed.view-field.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/view-fields/opportunity-record-page-is-listed.view-field.ts @@ -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; diff --git a/packages/twenty-apps/internal/twenty-partners/src/view-fields/opportunity-record-page-need.view-field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/view-fields/opportunity-record-page-need.view-field.ts similarity index 88% rename from packages/twenty-apps/internal/twenty-partners/src/view-fields/opportunity-record-page-need.view-field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/view-fields/opportunity-record-page-need.view-field.ts index 48d06ae21b..6dd0e48f90 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/view-fields/opportunity-record-page-need.view-field.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/view-fields/opportunity-record-page-need.view-field.ts @@ -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; diff --git a/packages/twenty-apps/internal/twenty-partners/src/view-fields/opportunity-record-page-partner.view-field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/view-fields/opportunity-record-page-partner.view-field.ts similarity index 87% rename from packages/twenty-apps/internal/twenty-partners/src/view-fields/opportunity-record-page-partner.view-field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/view-fields/opportunity-record-page-partner.view-field.ts index 69ab044283..9b38b12149 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/view-fields/opportunity-record-page-partner.view-field.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/view-fields/opportunity-record-page-partner.view-field.ts @@ -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; diff --git a/packages/twenty-apps/internal/twenty-partners/src/view-fields/opportunity-record-page-requirements.view-field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/view-fields/opportunity-record-page-requirements.view-field.ts similarity index 87% rename from packages/twenty-apps/internal/twenty-partners/src/view-fields/opportunity-record-page-requirements.view-field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/view-fields/opportunity-record-page-requirements.view-field.ts index af543eb02a..1b26afba92 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/view-fields/opportunity-record-page-requirements.view-field.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/view-fields/opportunity-record-page-requirements.view-field.ts @@ -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; diff --git a/packages/twenty-apps/internal/twenty-partners/src/views/all-opportunities.view.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/views/all-opportunities.view.ts similarity index 87% rename from packages/twenty-apps/internal/twenty-partners/src/views/all-opportunities.view.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/views/all-opportunities.view.ts index a604302a74..84353089d0 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/views/all-opportunities.view.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/views/all-opportunities.view.ts @@ -5,8 +5,8 @@ import { } from 'twenty-sdk/define'; import { ALL_OPPORTUNITIES_VIEW_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; -import { OPPORTUNITY_IS_LISTED_FIELD_ID } from 'src/fields/opportunity-is-listed.field'; -import { PARTNER_ON_OPPORTUNITY_FIELD_ID } from 'src/fields/partner-on-opportunity.field'; +import { OPPORTUNITY_IS_LISTED_FIELD_ID } from 'src/modules/opportunity/fields/opportunity-is-listed.field'; +import { PARTNER_ON_OPPORTUNITY_FIELD_ID } from 'src/modules/opportunity/fields/partner-on-opportunity.field'; // All opportunities, every stage. Kept last in the Matching folder. export default defineView({ diff --git a/packages/twenty-apps/internal/twenty-partners/src/views/briefs-to-match.view.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/views/briefs-to-match.view.ts similarity index 83% rename from packages/twenty-apps/internal/twenty-partners/src/views/briefs-to-match.view.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/views/briefs-to-match.view.ts index 70d4cd7b94..9f71e8184c 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/views/briefs-to-match.view.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/views/briefs-to-match.view.ts @@ -5,10 +5,10 @@ import { defineView, } from 'twenty-sdk/define'; -import { OPPORTUNITY_IS_LISTED_FIELD_ID } from 'src/fields/opportunity-is-listed.field'; -import { OPPORTUNITY_NEED_FIELD_ID } from 'src/fields/opportunity-need.field'; -import { OPPORTUNITY_REQUIREMENTS_FIELD_ID } from 'src/fields/opportunity-requirements.field'; -import { PARTNER_ON_OPPORTUNITY_FIELD_ID } from 'src/fields/partner-on-opportunity.field'; +import { OPPORTUNITY_IS_LISTED_FIELD_ID } from 'src/modules/opportunity/fields/opportunity-is-listed.field'; +import { OPPORTUNITY_NEED_FIELD_ID } from 'src/modules/opportunity/fields/opportunity-need.field'; +import { OPPORTUNITY_REQUIREMENTS_FIELD_ID } from 'src/modules/opportunity/fields/opportunity-requirements.field'; +import { PARTNER_ON_OPPORTUNITY_FIELD_ID } from 'src/modules/opportunity/fields/partner-on-opportunity.field'; export const BRIEFS_TO_MATCH_VIEW_UNIVERSAL_IDENTIFIER = 'cdf316f2-b79b-4d3a-8091-44d90b77cb8c'; diff --git a/packages/twenty-apps/internal/twenty-partners/src/views/deals-board.view.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/views/deals-board.view.ts similarity index 88% rename from packages/twenty-apps/internal/twenty-partners/src/views/deals-board.view.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/views/deals-board.view.ts index 8776bb44f8..5d58d4ee19 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/views/deals-board.view.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/views/deals-board.view.ts @@ -5,10 +5,10 @@ import { defineView, } from 'twenty-sdk/define'; -import { OPPORTUNITY_STAGE_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/opportunity-stage-options'; -import { OPPORTUNITY_IS_LISTED_FIELD_ID } from 'src/fields/opportunity-is-listed.field'; -import { PARTNER_ON_OPPORTUNITY_FIELD_ID } from 'src/fields/partner-on-opportunity.field'; -import { APPLICATIONS_ON_OPPORTUNITY_FIELD_ID } from 'src/objects/application.object'; +import { OPPORTUNITY_STAGE_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/opportunity/constants/opportunity-stage-options'; +import { OPPORTUNITY_IS_LISTED_FIELD_ID } from 'src/modules/opportunity/fields/opportunity-is-listed.field'; +import { PARTNER_ON_OPPORTUNITY_FIELD_ID } from 'src/modules/opportunity/fields/partner-on-opportunity.field'; +import { APPLICATIONS_ON_OPPORTUNITY_FIELD_ID } from 'src/modules/application/objects/application.object'; export const DEALS_BOARD_VIEW_UNIVERSAL_IDENTIFIER = '39c3ffbc-e91a-46b9-a9c6-4d186d5b3dcf'; diff --git a/packages/twenty-apps/internal/twenty-partners/src/views/followup-briefs.view.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/views/followup-briefs.view.ts similarity index 82% rename from packages/twenty-apps/internal/twenty-partners/src/views/followup-briefs.view.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/views/followup-briefs.view.ts index 8cf3f32086..9cad56d298 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/views/followup-briefs.view.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/views/followup-briefs.view.ts @@ -5,10 +5,10 @@ import { defineView, } from 'twenty-sdk/define'; -import { OPPORTUNITY_IS_LISTED_FIELD_ID } from 'src/fields/opportunity-is-listed.field'; -import { OPPORTUNITY_NEED_FIELD_ID } from 'src/fields/opportunity-need.field'; -import { OPPORTUNITY_REQUIREMENTS_FIELD_ID } from 'src/fields/opportunity-requirements.field'; -import { PARTNER_ON_OPPORTUNITY_FIELD_ID } from 'src/fields/partner-on-opportunity.field'; +import { OPPORTUNITY_IS_LISTED_FIELD_ID } from 'src/modules/opportunity/fields/opportunity-is-listed.field'; +import { OPPORTUNITY_NEED_FIELD_ID } from 'src/modules/opportunity/fields/opportunity-need.field'; +import { OPPORTUNITY_REQUIREMENTS_FIELD_ID } from 'src/modules/opportunity/fields/opportunity-requirements.field'; +import { PARTNER_ON_OPPORTUNITY_FIELD_ID } from 'src/modules/opportunity/fields/partner-on-opportunity.field'; export const FOLLOWUP_BRIEFS_VIEW_UNIVERSAL_IDENTIFIER = '208374b1-ab8c-414a-ab6e-cf667e58e82c'; diff --git a/packages/twenty-apps/internal/twenty-partners/src/views/my-deals.view.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/views/my-deals.view.ts similarity index 91% rename from packages/twenty-apps/internal/twenty-partners/src/views/my-deals.view.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/views/my-deals.view.ts index 4f74addef9..0708752514 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/views/my-deals.view.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/views/my-deals.view.ts @@ -5,8 +5,8 @@ import { defineView, } from 'twenty-sdk/define'; -import { OPPORTUNITY_STAGE_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/opportunity-stage-options'; -import { PARTNER_ON_OPPORTUNITY_FIELD_ID } from 'src/fields/partner-on-opportunity.field'; +import { OPPORTUNITY_STAGE_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/opportunity/constants/opportunity-stage-options'; +import { PARTNER_ON_OPPORTUNITY_FIELD_ID } from 'src/modules/opportunity/fields/partner-on-opportunity.field'; export const MY_DEALS_VIEW_UNIVERSAL_IDENTIFIER = '09c9b985-6dce-43ec-a6b3-5e878e1aec74'; diff --git a/packages/twenty-apps/internal/twenty-partners/src/views/open-briefs.view.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/views/open-briefs.view.ts similarity index 86% rename from packages/twenty-apps/internal/twenty-partners/src/views/open-briefs.view.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/views/open-briefs.view.ts index 5774c0cc96..6d9b1ab3d6 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/views/open-briefs.view.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/views/open-briefs.view.ts @@ -5,9 +5,9 @@ import { defineView, } from 'twenty-sdk/define'; -import { OPPORTUNITY_IS_LISTED_FIELD_ID } from 'src/fields/opportunity-is-listed.field'; -import { OPPORTUNITY_NEED_FIELD_ID } from 'src/fields/opportunity-need.field'; -import { OPPORTUNITY_REQUIREMENTS_FIELD_ID } from 'src/fields/opportunity-requirements.field'; +import { OPPORTUNITY_IS_LISTED_FIELD_ID } from 'src/modules/opportunity/fields/opportunity-is-listed.field'; +import { OPPORTUNITY_NEED_FIELD_ID } from 'src/modules/opportunity/fields/opportunity-need.field'; +import { OPPORTUNITY_REQUIREMENTS_FIELD_ID } from 'src/modules/opportunity/fields/opportunity-requirements.field'; export const OPEN_BRIEFS_VIEW_UNIVERSAL_IDENTIFIER = 'e1f9629d-c490-450e-b3c5-c944aca292eb'; diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/derive-deployment-expertise.test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/__tests__/derive-deployment-expertise.test.ts similarity index 88% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/derive-deployment-expertise.test.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/__tests__/derive-deployment-expertise.test.ts index 9fb708a7cc..b326e8e3bd 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/derive-deployment-expertise.test.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/__tests__/derive-deployment-expertise.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { deriveDeploymentExpertise } from './derive-deployment-expertise'; +import { deriveDeploymentExpertise } from '../utils/derive-deployment-expertise'; describe('deriveDeploymentExpertise', () => { it('defaults to CLOUD for everyone', () => { diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/derive-region.test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/__tests__/derive-region.test.ts similarity index 85% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/derive-region.test.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/__tests__/derive-region.test.ts index cd27015549..2923f6362f 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/derive-region.test.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/__tests__/derive-region.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { PARTNER_COUNTRY_VALUES } from './submit-partner-application.logic-function'; -import { COUNTRY_TO_REGION, deriveRegion } from './derive-region'; +import { PARTNER_COUNTRY_VALUES } from 'src/modules/partner/constants/partner-option-values.constant'; +import { COUNTRY_TO_REGION, deriveRegion } from '../utils/derive-region'; describe('deriveRegion', () => { it('maps representative countries to the right region', () => { diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-partner-application-created.test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/__tests__/on-partner-application-created.test.ts similarity index 97% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-partner-application-created.test.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/__tests__/on-partner-application-created.test.ts index 87485cdf89..be76934328 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-partner-application-created.test.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/__tests__/on-partner-application-created.test.ts @@ -9,10 +9,8 @@ vi.mock('twenty-client-sdk/core', () => ({ }), })); -import { - buildApplicationEmbed, - handler, -} from '../on-partner-application-created.logic-function'; +import { buildApplicationEmbed } from '../mappers/application-embed.mapper'; +import { handler } from '../on-partner-application-created.logic-function'; const PARTNER_ID = '11111111-1111-1111-1111-111111111111'; diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/submit-partner-application.integration-test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/__tests__/submit-partner-application.integration-test.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/submit-partner-application.integration-test.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/__tests__/submit-partner-application.integration-test.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/submit-partner-application.schema.test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/__tests__/submit-partner-application.schema.test.ts similarity index 95% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/submit-partner-application.schema.test.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/__tests__/submit-partner-application.schema.test.ts index c0c27f4e3a..07bf2a38b3 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/submit-partner-application.schema.test.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/__tests__/submit-partner-application.schema.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { submitPartnerApplicationSchema } from '../submit-partner-application.logic-function'; +import { submitPartnerApplicationSchema } from '../services/submit-partner-application-input.schema'; const base = { firstName: 'Ada', diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/connector/discord/config.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/connector/discord/config.ts new file mode 100644 index 0000000000..15e2eff7a1 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/connector/discord/config.ts @@ -0,0 +1,9 @@ +export const DISCORD_WEBHOOK_ENV_VAR = 'DISCORD_WEBHOOK_URL'; + +// 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. +export const DISCORD_TIMEOUT_MS = 8000; + +// Twenty brand blue (#4a38f5) as a Discord embed integer color. +export const TWENTY_BLUE = 0x4a38f5; diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/connector/discord/discord.connector.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/connector/discord/discord.connector.ts new file mode 100644 index 0000000000..68721d678c --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/connector/discord/discord.connector.ts @@ -0,0 +1,32 @@ +import { DISCORD_TIMEOUT_MS } from 'src/modules/partner/application-intake/connector/discord/config'; +import { type DiscordWebhookPayload } from 'src/modules/partner/application-intake/connector/discord/types'; + +// Pure outbound transport: POST the payload, honour a hard timeout, and report +// delivery. Holds no decision logic — the caller decides whether to notify. +export async function postWebhook( + url: string, + payload: DiscordWebhookPayload, +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), DISCORD_TIMEOUT_MS); + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + 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 false; + } + return true; + } finally { + clearTimeout(timeout); + } +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/connector/discord/types.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/connector/discord/types.ts new file mode 100644 index 0000000000..a3ac0e7c17 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/connector/discord/types.ts @@ -0,0 +1,5 @@ +export type DiscordField = { name: string; value: string; inline?: boolean }; + +export type DiscordEmbed = Record; + +export type DiscordWebhookPayload = { embeds: DiscordEmbed[] }; diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/graphql/mutations/create-company.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/graphql/mutations/create-company.ts new file mode 100644 index 0000000000..02493952f3 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/graphql/mutations/create-company.ts @@ -0,0 +1,10 @@ +import type { CoreApiClient, CoreSchema } from 'twenty-client-sdk/core'; + +export function createCompany( + client: CoreApiClient, + data: CoreSchema.CompanyCreateInput, +) { + return client.mutation({ + createCompany: { __args: { data }, id: true }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/graphql/mutations/create-partner.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/graphql/mutations/create-partner.ts new file mode 100644 index 0000000000..a1a1410478 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/graphql/mutations/create-partner.ts @@ -0,0 +1,13 @@ +import type { CoreApiClient, CoreSchema } from 'twenty-client-sdk/core'; + +export function createPartner( + client: CoreApiClient, + data: CoreSchema.PartnerCreateInput, +) { + return client.mutation({ + createPartner: { + __args: { data }, + id: true, + }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/graphql/mutations/create-person.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/graphql/mutations/create-person.ts new file mode 100644 index 0000000000..2de2930b87 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/graphql/mutations/create-person.ts @@ -0,0 +1,13 @@ +import type { CoreApiClient, CoreSchema } from 'twenty-client-sdk/core'; + +export function createPerson( + client: CoreApiClient, + data: CoreSchema.PersonCreateInput, +) { + return client.mutation({ + createPerson: { + __args: { data }, + id: true, + }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/graphql/mutations/update-partner.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/graphql/mutations/update-partner.ts new file mode 100644 index 0000000000..3041b0b825 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/graphql/mutations/update-partner.ts @@ -0,0 +1,14 @@ +import type { CoreApiClient, CoreSchema } from 'twenty-client-sdk/core'; + +export function updatePartner( + client: CoreApiClient, + id: string, + data: CoreSchema.PartnerUpdateInput, +) { + return client.mutation({ + updatePartner: { + __args: { id, data }, + id: true, + }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/graphql/mutations/update-person.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/graphql/mutations/update-person.ts new file mode 100644 index 0000000000..01733fbece --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/graphql/mutations/update-person.ts @@ -0,0 +1,14 @@ +import type { CoreApiClient, CoreSchema } from 'twenty-client-sdk/core'; + +export function updatePerson( + client: CoreApiClient, + id: string, + data: CoreSchema.PersonUpdateInput, +) { + return client.mutation({ + updatePerson: { + __args: { id, data }, + id: true, + }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/graphql/queries/find-companies-by-domain.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/graphql/queries/find-companies-by-domain.ts new file mode 100644 index 0000000000..f3450427e3 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/graphql/queries/find-companies-by-domain.ts @@ -0,0 +1,19 @@ +import type { CoreApiClient } from 'twenty-client-sdk/core'; + +export function findCompaniesByDomain( + client: CoreApiClient, + host: string, + cursor: string | null, +) { + return 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 } } }, + }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/graphql/queries/find-partner-for-embed.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/graphql/queries/find-partner-for-embed.ts new file mode 100644 index 0000000000..a1d23b9504 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/graphql/queries/find-partner-for-embed.ts @@ -0,0 +1,22 @@ +import type { CoreApiClient } from 'twenty-client-sdk/core'; + +export function findPartnerForEmbed(client: CoreApiClient, id: string) { + return client.query({ + partner: { + __args: { filter: { id: { eq: id } } }, + id: true, + name: true, + country: true, + partnerScope: true, + skills: true, + languagesSpoken: true, + persons: { + edges: { + node: { + name: { firstName: true, lastName: true }, + }, + }, + }, + }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/graphql/queries/find-person-by-email.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/graphql/queries/find-person-by-email.ts new file mode 100644 index 0000000000..de07e6dab3 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/graphql/queries/find-person-by-email.ts @@ -0,0 +1,18 @@ +import type { CoreApiClient } from 'twenty-client-sdk/core'; + +export function findPersonByEmail(client: CoreApiClient, email: string) { + return client.query({ + people: { + __args: { + filter: { emails: { primaryEmail: { eq: email } } }, + first: 1, + }, + edges: { + node: { + id: true, + partner: { id: true, company: { id: true } }, + }, + }, + }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/mappers/application-embed.mapper.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/mappers/application-embed.mapper.ts new file mode 100644 index 0000000000..09fc2377a9 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/mappers/application-embed.mapper.ts @@ -0,0 +1,73 @@ +import { isNonEmptyString } from 'src/modules/shared/utils/is-non-empty-string.util'; +import { TWENTY_BLUE } from 'src/modules/partner/application-intake/connector/discord/config'; +import { type DiscordField } from 'src/modules/partner/application-intake/connector/discord/types'; + +export 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 { + // 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 = { + 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; +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/mappers/build-partner-fields.mapper.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/mappers/build-partner-fields.mapper.ts new file mode 100644 index 0000000000..c74a3fa184 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/mappers/build-partner-fields.mapper.ts @@ -0,0 +1,78 @@ +import { type CoreSchema } from 'twenty-client-sdk/core'; + +import { isNonEmptyString } from 'src/modules/shared/utils/is-non-empty-string.util'; +import { deriveDeploymentExpertise } from 'src/modules/partner/application-intake/utils/derive-deployment-expertise'; +import { deriveRegion } from 'src/modules/partner/application-intake/utils/derive-region'; +import { type SubmitPartnerApplicationInput } from 'src/modules/partner/application-intake/services/submit-partner-application-input.schema'; +import { slugify } from 'src/scripts/slugify'; + +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. +export 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; +}; + +export 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; +} + +export function buildPartnerCreateData( + partnerFields: PartnerFieldsForUpsert, + input: SubmitPartnerApplicationInput, + companyId: string, +): CoreSchema.PartnerCreateInput { + const region = deriveRegion(input.country); + return { + ...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[] } : {}), + }; +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/partner-applications.navigation-menu-item.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/navigation-menu-items/partner-applications.navigation-menu-item.ts similarity index 88% rename from packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/partner-applications.navigation-menu-item.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/navigation-menu-items/partner-applications.navigation-menu-item.ts index 66b7df6429..0156d5fa2e 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/partner-applications.navigation-menu-item.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/navigation-menu-items/partner-applications.navigation-menu-item.ts @@ -1,7 +1,7 @@ import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define'; import { PARTNER_APPLICATIONS_NAV_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; -import { PARTNER_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/partner-applications.view'; +import { PARTNER_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/partner/application-intake/views/partner-applications.view'; export default defineNavigationMenuItem({ universalIdentifier: PARTNER_APPLICATIONS_NAV_UNIVERSAL_IDENTIFIER, diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/on-partner-application-created.logic-function.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/on-partner-application-created.logic-function.ts new file mode 100644 index 0000000000..e832399fa6 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/on-partner-application-created.logic-function.ts @@ -0,0 +1,39 @@ +import { 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'; +import { DISCORD_WEBHOOK_ENV_VAR } from 'src/modules/partner/application-intake/connector/discord/config'; +import { notifyPartnerApplication } from 'src/modules/partner/application-intake/services/notify-partner-application.service'; +import { isNonEmptyString } from 'src/modules/shared/utils/is-non-empty-string.util'; + +export const handler = async ( + payload: DatabaseEventPayload>, +): Promise> => { + 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_ENV_VAR]; + if (!isNonEmptyString(webhookUrl)) return {}; + + return notifyPartnerApplication(after.id, webhookUrl); +}; + +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', + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/services/find-or-create-company.service.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/services/find-or-create-company.service.ts new file mode 100644 index 0000000000..28cc2dd6eb --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/services/find-or-create-company.service.ts @@ -0,0 +1,61 @@ +import type { CoreApiClient, CoreSchema } from 'twenty-client-sdk/core'; + +import { isNonEmptyString } from 'src/modules/shared/utils/is-non-empty-string.util'; +import { createCompany } from 'src/modules/partner/application-intake/graphql/mutations/create-company'; +import { findCompaniesByDomain } from 'src/modules/partner/application-intake/graphql/queries/find-companies-by-domain'; +import { type SubmitPartnerApplicationInput } from 'src/modules/partner/application-intake/services/submit-partner-application-input.schema'; + +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`. +export async function findOrCreateCompanyId( + client: CoreApiClient, + input: SubmitPartnerApplicationInput, +): Promise { + 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 findCompaniesByDomain(client, host, cursor); + 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 createCompany(client, companyData); + const companyId = companyResult.createCompany?.id; + if (companyId === undefined) { + throw new Error('createCompany did not return an id'); + } + return companyId; +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/services/notify-partner-application.service.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/services/notify-partner-application.service.ts new file mode 100644 index 0000000000..ce753691c9 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/services/notify-partner-application.service.ts @@ -0,0 +1,46 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; + +import { postWebhook } from 'src/modules/partner/application-intake/connector/discord/discord.connector'; +import { findPartnerForEmbed } from 'src/modules/partner/application-intake/graphql/queries/find-partner-for-embed'; +import { + buildApplicationEmbed, + type PartnerForEmbed, +} from 'src/modules/partner/application-intake/mappers/application-embed.mapper'; + +export async function notifyPartnerApplication( + partnerId: string, + webhookUrl: string, +): Promise> { + const frontendUrl = process.env.PARTNER_APP_FRONTEND_URL; + + try { + const client = new CoreApiClient(); + const res = await findPartnerForEmbed(client, partnerId); + + const node = res.partner; + if (!node) return {}; + + const applicantNode = node.persons?.edges?.[0]?.node; + const partnerForEmbed: PartnerForEmbed = { + id: partnerId, + 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 delivered = await postWebhook(webhookUrl, { embeds: [embed] }); + return { notified: delivered }; + } catch { + // Best-effort: a Discord failure must never fail the trigger. + return { notified: false }; + } +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/services/submit-partner-application-input.schema.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/services/submit-partner-application-input.schema.ts new file mode 100644 index 0000000000..ceea3c59b4 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/services/submit-partner-application-input.schema.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; + +import { + PARTNER_COUNTRY_VALUES, + PARTNER_LANGUAGE_VALUES, + PARTNER_SCOPE_VALUES, + PARTNER_TYPE_OF_TEAM_VALUES, +} from 'src/modules/partner/constants/partner-option-values.constant'; + +// 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 +>; diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/services/submit-partner-application.service.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/services/submit-partner-application.service.ts new file mode 100644 index 0000000000..03c827291c --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/services/submit-partner-application.service.ts @@ -0,0 +1,68 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; + +import { createPartner } from 'src/modules/partner/application-intake/graphql/mutations/create-partner'; +import { createPerson } from 'src/modules/partner/application-intake/graphql/mutations/create-person'; +import { updatePartner } from 'src/modules/partner/application-intake/graphql/mutations/update-partner'; +import { updatePerson } from 'src/modules/partner/application-intake/graphql/mutations/update-person'; +import { findPersonByEmail } from 'src/modules/partner/application-intake/graphql/queries/find-person-by-email'; +import { + buildPartnerCreateData, + buildPartnerFields, +} from 'src/modules/partner/application-intake/mappers/build-partner-fields.mapper'; +import { findOrCreateCompanyId } from 'src/modules/partner/application-intake/services/find-or-create-company.service'; +import { type SubmitPartnerApplicationInput } from 'src/modules/partner/application-intake/services/submit-partner-application-input.schema'; + +export type SubmitPartnerApplicationResult = + | { ok: true; created: boolean; partnerId: string } + | { ok: false; reason: string }; + +export async function submitPartnerApplication( + input: SubmitPartnerApplicationInput, +): Promise { + try { + const client = new CoreApiClient(); + const email = input.email.trim(); + const partnerFields = buildPartnerFields(input); + + const personLookup = await findPersonByEmail(client, email); + const existingEdge = personLookup.people?.edges?.[0]?.node; + + if (existingEdge && existingEdge.partner) { + const partnerId = existingEdge.partner.id; + await updatePartner(client, partnerId, partnerFields); + await updatePerson(client, existingEdge.id, { + name: { firstName: input.firstName.trim(), lastName: input.lastName.trim() }, + }); + return { ok: true, created: false, partnerId }; + } + + const companyId = await findOrCreateCompanyId(client, input); + + const partnerResult = await createPartner( + client, + buildPartnerCreateData(partnerFields, input, companyId), + ); + const partnerId = partnerResult.createPartner?.id; + if (partnerId === undefined) { + throw new Error('createPartner did not return an id'); + } + + if (existingEdge) { + await updatePerson(client, existingEdge.id, { + partnerId, + name: { firstName: input.firstName.trim(), lastName: input.lastName.trim() }, + }); + } else { + await createPerson(client, { + name: { firstName: input.firstName.trim(), lastName: input.lastName.trim() }, + emails: { primaryEmail: email }, + partnerId, + companyId, + }); + } + + return { ok: true, created: true, partnerId }; + } catch (err) { + return { ok: false, reason: err instanceof Error ? err.message : String(err) }; + } +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/submit-partner-application.logic-function.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/submit-partner-application.logic-function.ts new file mode 100644 index 0000000000..5b3d0c013c --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/submit-partner-application.logic-function.ts @@ -0,0 +1,39 @@ +import { defineLogicFunction } from 'twenty-sdk/define'; + +import { + APPLICATION_SECRET_HEADER, + readSecretGuardedEvent, +} from 'src/modules/shared/http/read-secret-guarded-event'; +import { submitPartnerApplicationSchema } from 'src/modules/partner/application-intake/services/submit-partner-application-input.schema'; +import { + submitPartnerApplication, + type SubmitPartnerApplicationResult, +} from 'src/modules/partner/application-intake/services/submit-partner-application.service'; + +export type { SubmitPartnerApplicationInput } from 'src/modules/partner/application-intake/services/submit-partner-application-input.schema'; +export type { SubmitPartnerApplicationResult } from 'src/modules/partner/application-intake/services/submit-partner-application.service'; + +export const SUBMIT_PARTNER_APPLICATION_LOGIC_FUNCTION_ID = + '7b1e2c5f-3a14-4f7d-8e91-0b5e2a3c4d76'; + +export const handler = async ( + event: unknown, +): Promise => { + const guard = readSecretGuardedEvent(event, submitPartnerApplicationSchema); + if (!guard.ok) return { ok: false, reason: guard.reason }; + return submitPartnerApplication(guard.input); +}; + +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], + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/derive-deployment-expertise.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/utils/derive-deployment-expertise.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/derive-deployment-expertise.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/utils/derive-deployment-expertise.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/derive-region.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/utils/derive-region.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/derive-region.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/utils/derive-region.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/views/partner-applications.view.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/views/partner-applications.view.ts similarity index 93% rename from packages/twenty-apps/internal/twenty-partners/src/views/partner-applications.view.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/views/partner-applications.view.ts index 4d20a61a53..5f733e5b50 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/views/partner-applications.view.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/application-intake/views/partner-applications.view.ts @@ -10,12 +10,12 @@ import { PARTNER_INTRODUCTION_FIELD_UNIVERSAL_IDENTIFIER, PARTNER_NAME_FIELD_UNIVERSAL_IDENTIFIER, PARTNER_VALIDATION_STAGE_FIELD_UNIVERSAL_IDENTIFIER, -} from 'src/constants/partner-field-universal-identifiers'; +} from 'src/modules/partner/constants/partner-field-universal-identifiers'; import { APPLICATION_UNIVERSAL_IDENTIFIER, PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, } from 'src/constants/universal-identifiers'; -import { PARTNER_USER_ON_PARTNER_FIELD_ID } from 'src/fields/partner-user-on-partner.field'; +import { PARTNER_USER_ON_PARTNER_FIELD_ID } from 'src/modules/partner/fields/partner-user-on-partner.field'; export const PARTNER_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER = '8d8fc77b-77df-4eeb-9e0c-6409efd30a9c'; diff --git a/packages/twenty-apps/internal/twenty-partners/src/constants/partner-country-view-groups.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/constants/partner-country-view-groups.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/constants/partner-country-view-groups.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/constants/partner-country-view-groups.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/constants/partner-field-universal-identifiers.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/constants/partner-field-universal-identifiers.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/constants/partner-field-universal-identifiers.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/constants/partner-field-universal-identifiers.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/constants/partner-option-values.constant.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/constants/partner-option-values.constant.ts new file mode 100644 index 0000000000..25c1948da8 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/constants/partner-option-values.constant.ts @@ -0,0 +1,13 @@ +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; + +export const PARTNER_LANGUAGE_VALUES = [ + 'ENGLISH','FRENCH','GERMAN','SPANISH','PORTUGUESE','ITALIAN','DUTCH','ARABIC','CHINESE','JAPANESE','RUSSIAN','HINDI', +] as const; + +export const PARTNER_SCOPE_VALUES = [ + 'ADVISORY','SOLUTIONING','DEVELOPMENT','HOSTING','SUPPORT', +] as const; + +export const PARTNER_TYPE_OF_TEAM_VALUES = ['SOLO','AGENCY'] as const; diff --git a/packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/partner-content.navigation-menu-item.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/navigation-menu-items/partner-content.navigation-menu-item.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/partner-content.navigation-menu-item.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/navigation-menu-items/partner-content.navigation-menu-item.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/partners-folder.navigation-menu-item.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/navigation-menu-items/partners-folder.navigation-menu-item.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/partners-folder.navigation-menu-item.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/navigation-menu-items/partners-folder.navigation-menu-item.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/partners-per-country.navigation-menu-item.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/navigation-menu-items/partners-per-country.navigation-menu-item.ts similarity index 88% rename from packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/partners-per-country.navigation-menu-item.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/navigation-menu-items/partners-per-country.navigation-menu-item.ts index b9fe7b4fd1..4bf36ae3aa 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/partners-per-country.navigation-menu-item.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/navigation-menu-items/partners-per-country.navigation-menu-item.ts @@ -1,6 +1,6 @@ import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define'; -import { PARTNERS_PER_COUNTRY_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/partners-per-country.view'; +import { PARTNERS_PER_COUNTRY_VIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/partner/directory/views/partners-per-country.view'; export default defineNavigationMenuItem({ universalIdentifier: '284df145-0d35-4733-92ea-89aff788d6a9', diff --git a/packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/partners-per-stage.navigation-menu-item.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/navigation-menu-items/partners-per-stage.navigation-menu-item.ts similarity index 88% rename from packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/partners-per-stage.navigation-menu-item.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/navigation-menu-items/partners-per-stage.navigation-menu-item.ts index 894b1f035b..c7f201cf9a 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/partners-per-stage.navigation-menu-item.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/navigation-menu-items/partners-per-stage.navigation-menu-item.ts @@ -1,6 +1,6 @@ import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define'; -import { PARTNERS_PER_STAGE_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/partners-per-stage.view'; +import { PARTNERS_PER_STAGE_VIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/partner/directory/views/partners-per-stage.view'; export default defineNavigationMenuItem({ universalIdentifier: 'fa1d850b-358c-45a4-a0af-a2fb4c06e129', diff --git a/packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/partners-validated.navigation-menu-item.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/navigation-menu-items/partners-validated.navigation-menu-item.ts similarity index 88% rename from packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/partners-validated.navigation-menu-item.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/navigation-menu-items/partners-validated.navigation-menu-item.ts index c0d03ef317..6dd0cbbfb7 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/partners-validated.navigation-menu-item.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/navigation-menu-items/partners-validated.navigation-menu-item.ts @@ -1,6 +1,6 @@ import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define'; -import { PARTNERS_VALIDATED_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/partners-validated.view'; +import { PARTNERS_VALIDATED_VIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/partner/directory/views/partners-validated.view'; export default defineNavigationMenuItem({ universalIdentifier: 'c9cbe1b6-7c07-4327-bd7a-018d0ed0db33', diff --git a/packages/twenty-apps/internal/twenty-partners/src/page-layouts/partner-content.page-layout.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/page-layouts/partner-content.page-layout.ts similarity index 95% rename from packages/twenty-apps/internal/twenty-partners/src/page-layouts/partner-content.page-layout.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/page-layouts/partner-content.page-layout.ts index 8fa922c24c..942354301e 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/page-layouts/partner-content.page-layout.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/page-layouts/partner-content.page-layout.ts @@ -1,7 +1,7 @@ import { PageLayoutTabLayoutMode, definePageLayout } from 'twenty-sdk/define'; import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; -import { PARTNER_CONTENT_RECORD_PAGE_FIELDS_VIEW_ID } from 'src/views/partner-content-record-page-fields.view'; +import { PARTNER_CONTENT_RECORD_PAGE_FIELDS_VIEW_ID } from 'src/modules/partner/directory/views/partner-content-record-page-fields.view'; export default definePageLayout({ universalIdentifier: '4d1f8ad2-6dcd-4f14-b2bc-3d16bb9dc462', diff --git a/packages/twenty-apps/internal/twenty-partners/src/page-layouts/partner-link.page-layout.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/page-layouts/partner-link.page-layout.ts similarity index 91% rename from packages/twenty-apps/internal/twenty-partners/src/page-layouts/partner-link.page-layout.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/page-layouts/partner-link.page-layout.ts index aeb75fd88d..79c2bdc3ef 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/page-layouts/partner-link.page-layout.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/page-layouts/partner-link.page-layout.ts @@ -1,7 +1,7 @@ import { PageLayoutTabLayoutMode, definePageLayout } from 'twenty-sdk/define'; import { PARTNER_LINK_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; -import { PARTNER_LINK_RECORD_PAGE_FIELDS_VIEW_ID } from 'src/views/partner-link-record-page-fields.view'; +import { PARTNER_LINK_RECORD_PAGE_FIELDS_VIEW_ID } from 'src/modules/partner/directory/views/partner-link-record-page-fields.view'; export default definePageLayout({ universalIdentifier: 'e2b44276-babe-4f29-a94d-8fa220fe6483', diff --git a/packages/twenty-apps/internal/twenty-partners/src/page-layouts/partner-service.page-layout.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/page-layouts/partner-service.page-layout.ts similarity index 95% rename from packages/twenty-apps/internal/twenty-partners/src/page-layouts/partner-service.page-layout.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/page-layouts/partner-service.page-layout.ts index d7800599a0..6542f26ef1 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/page-layouts/partner-service.page-layout.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/page-layouts/partner-service.page-layout.ts @@ -1,7 +1,7 @@ import { PageLayoutTabLayoutMode, definePageLayout } from 'twenty-sdk/define'; import { PARTNER_SERVICE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; -import { PARTNER_SERVICE_RECORD_PAGE_FIELDS_VIEW_ID } from 'src/views/partner-service-record-page-fields.view'; +import { PARTNER_SERVICE_RECORD_PAGE_FIELDS_VIEW_ID } from 'src/modules/partner/directory/views/partner-service-record-page-fields.view'; export default definePageLayout({ universalIdentifier: '7032692b-63bd-432c-acac-a6e368adbff9', diff --git a/packages/twenty-apps/internal/twenty-partners/src/page-layouts/partner.page-layout.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/page-layouts/partner.page-layout.ts similarity index 94% rename from packages/twenty-apps/internal/twenty-partners/src/page-layouts/partner.page-layout.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/page-layouts/partner.page-layout.ts index 8ff936be98..27b94f5dbb 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/page-layouts/partner.page-layout.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/page-layouts/partner.page-layout.ts @@ -1,7 +1,7 @@ import { PageLayoutTabLayoutMode, definePageLayout } from 'twenty-sdk/define'; import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; -import { PARTNER_RECORD_PAGE_FIELDS_VIEW_ID } from 'src/views/partner-record-page-fields.view'; +import { PARTNER_RECORD_PAGE_FIELDS_VIEW_ID } from 'src/modules/partner/directory/views/partner-record-page-fields.view'; // Partner is a custom (app-owned) object, so we fully control its record page. The // Fields widget points at the FIELDS_WIDGET view so the partnerUser relation shows in diff --git a/packages/twenty-apps/internal/twenty-partners/src/views/partner-content-record-page-fields.view.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/views/partner-content-record-page-fields.view.ts similarity index 93% rename from packages/twenty-apps/internal/twenty-partners/src/views/partner-content-record-page-fields.view.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/views/partner-content-record-page-fields.view.ts index 03e5a9b1f5..6b96edbe48 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/views/partner-content-record-page-fields.view.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/views/partner-content-record-page-fields.view.ts @@ -1,7 +1,7 @@ import { ViewType, defineView } from 'twenty-sdk/define'; import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; -import { PARTNER_CONTENT_PARTNER_FIELD_ID } from 'src/fields/partner-content-partner.field'; +import { PARTNER_CONTENT_PARTNER_FIELD_ID } from 'src/modules/partner/fields/partner-content-partner.field'; import { PARTNER_CONTENT_BODY_FIELD_ID, PARTNER_CONTENT_CASE_STUDY_LINK_FIELD_ID, @@ -10,7 +10,7 @@ import { PARTNER_CONTENT_HEADLINE_FIELD_ID, PARTNER_CONTENT_NAME_FIELD_ID, PARTNER_CONTENT_STATUS_FIELD_ID, -} from 'src/objects/partner-content.object'; +} from 'src/modules/partner/objects/partner-content.object'; export const PARTNER_CONTENT_RECORD_PAGE_FIELDS_VIEW_ID = 'a1b2c3d4-5e6f-4789-a012-3456789abcde'; diff --git a/packages/twenty-apps/internal/twenty-partners/src/views/partner-content.view.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/views/partner-content.view.ts similarity index 97% rename from packages/twenty-apps/internal/twenty-partners/src/views/partner-content.view.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/views/partner-content.view.ts index 929be93ca3..2f28302376 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/views/partner-content.view.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/views/partner-content.view.ts @@ -13,7 +13,7 @@ import { PARTNER_CONTENT_NAME_FIELD_ID, PARTNER_CONTENT_STATUS_FIELD_ID, PARTNER_CONTENT_TYPE_FIELD_ID, -} from 'src/objects/partner-content.object'; +} from 'src/modules/partner/objects/partner-content.object'; // Index view for partner content. export default defineView({ diff --git a/packages/twenty-apps/internal/twenty-partners/src/views/partner-link-record-page-fields.view.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/views/partner-link-record-page-fields.view.ts similarity index 90% rename from packages/twenty-apps/internal/twenty-partners/src/views/partner-link-record-page-fields.view.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/views/partner-link-record-page-fields.view.ts index bc778b33a7..b49e0910bd 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/views/partner-link-record-page-fields.view.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/views/partner-link-record-page-fields.view.ts @@ -1,12 +1,12 @@ import { ViewType, defineView } from 'twenty-sdk/define'; import { PARTNER_LINK_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; -import { PARTNER_LINK_PARTNER_FIELD_ID } from 'src/fields/partner-link-partner.field'; +import { PARTNER_LINK_PARTNER_FIELD_ID } from 'src/modules/partner/fields/partner-link-partner.field'; import { PARTNER_LINK_NAME_FIELD_ID, PARTNER_LINK_SORT_ORDER_FIELD_ID, PARTNER_LINK_URL_FIELD_ID, -} from 'src/objects/partner-link.object'; +} from 'src/modules/partner/objects/partner-link.object'; export const PARTNER_LINK_RECORD_PAGE_FIELDS_VIEW_ID = 'e8d1ba61-45fe-4055-9525-6c8bb68e4e0c'; diff --git a/packages/twenty-apps/internal/twenty-partners/src/views/partner-record-page-fields.view.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/views/partner-record-page-fields.view.ts similarity index 94% rename from packages/twenty-apps/internal/twenty-partners/src/views/partner-record-page-fields.view.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/views/partner-record-page-fields.view.ts index e5f5ed92ea..647cea4840 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/views/partner-record-page-fields.view.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/views/partner-record-page-fields.view.ts @@ -22,19 +22,19 @@ import { PARTNER_TYPE_OF_TEAM_FIELD_UNIVERSAL_IDENTIFIER, PARTNER_VALIDATION_STAGE_FIELD_UNIVERSAL_IDENTIFIER, PARTNER_WEBSITE_FIELD_UNIVERSAL_IDENTIFIER, -} from 'src/constants/partner-field-universal-identifiers'; +} from 'src/modules/partner/constants/partner-field-universal-identifiers'; import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; import { PARTNER_CONTENTS_ON_PARTNER_FIELD_ID, -} from 'src/fields/partner-content-partner.field'; -import { PARTNER_COMPANY_FIELD_ID } from 'src/fields/partner-company.field'; +} from 'src/modules/partner/fields/partner-content-partner.field'; +import { PARTNER_COMPANY_FIELD_ID } from 'src/modules/partner/fields/partner-company.field'; import { PARTNER_LINKS_ON_PARTNER_FIELD_ID, -} from 'src/fields/partner-link-partner.field'; +} from 'src/modules/partner/fields/partner-link-partner.field'; import { PARTNER_SERVICES_ON_PARTNER_FIELD_ID, -} from 'src/fields/partner-service-partner.field'; -import { PARTNER_USER_ON_PARTNER_FIELD_ID } from 'src/fields/partner-user-on-partner.field'; +} from 'src/modules/partner/fields/partner-service-partner.field'; +import { PARTNER_USER_ON_PARTNER_FIELD_ID } from 'src/modules/partner/fields/partner-user-on-partner.field'; export const PARTNER_RECORD_PAGE_FIELDS_VIEW_ID = 'a10bf4de-0770-4bee-ae04-1ae97aa18254'; diff --git a/packages/twenty-apps/internal/twenty-partners/src/views/partner-service-record-page-fields.view.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/views/partner-service-record-page-fields.view.ts similarity index 89% rename from packages/twenty-apps/internal/twenty-partners/src/views/partner-service-record-page-fields.view.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/views/partner-service-record-page-fields.view.ts index 74595b5886..c1c2437141 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/views/partner-service-record-page-fields.view.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/views/partner-service-record-page-fields.view.ts @@ -1,12 +1,12 @@ import { ViewType, defineView } from 'twenty-sdk/define'; import { PARTNER_SERVICE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; -import { PARTNER_SERVICE_PARTNER_FIELD_ID } from 'src/fields/partner-service-partner.field'; +import { PARTNER_SERVICE_PARTNER_FIELD_ID } from 'src/modules/partner/fields/partner-service-partner.field'; import { PARTNER_SERVICE_DESCRIPTION_FIELD_ID, PARTNER_SERVICE_SORT_ORDER_FIELD_ID, PARTNER_SERVICE_TITLE_FIELD_ID, -} from 'src/objects/partner-service.object'; +} from 'src/modules/partner/objects/partner-service.object'; export const PARTNER_SERVICE_RECORD_PAGE_FIELDS_VIEW_ID = '0dfa3e6b-5461-4494-8a5a-ce363617e5a0'; diff --git a/packages/twenty-apps/internal/twenty-partners/src/views/partners-per-country.view.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/views/partners-per-country.view.ts similarity index 92% rename from packages/twenty-apps/internal/twenty-partners/src/views/partners-per-country.view.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/views/partners-per-country.view.ts index ae457d1c6b..31e3e79339 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/views/partners-per-country.view.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/views/partners-per-country.view.ts @@ -5,13 +5,13 @@ import { defineView, } from 'twenty-sdk/define'; -import { PARTNER_COUNTRY_VIEW_GROUPS } from 'src/constants/partner-country-view-groups'; +import { PARTNER_COUNTRY_VIEW_GROUPS } from 'src/modules/partner/constants/partner-country-view-groups'; import { PARTNER_COUNTRY_FIELD_UNIVERSAL_IDENTIFIER, PARTNER_NAME_FIELD_UNIVERSAL_IDENTIFIER, PARTNER_REGION_FIELD_UNIVERSAL_IDENTIFIER, PARTNER_VALIDATION_STAGE_FIELD_UNIVERSAL_IDENTIFIER, -} from 'src/constants/partner-field-universal-identifiers'; +} from 'src/modules/partner/constants/partner-field-universal-identifiers'; import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; export const PARTNERS_PER_COUNTRY_VIEW_UNIVERSAL_IDENTIFIER = diff --git a/packages/twenty-apps/internal/twenty-partners/src/views/partners-per-stage.view.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/views/partners-per-stage.view.ts similarity index 97% rename from packages/twenty-apps/internal/twenty-partners/src/views/partners-per-stage.view.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/views/partners-per-stage.view.ts index 896a822d8e..2f92a9892c 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/views/partners-per-stage.view.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/views/partners-per-stage.view.ts @@ -6,7 +6,7 @@ import { PARTNER_SCOPE_FIELD_UNIVERSAL_IDENTIFIER, PARTNER_TIER_FIELD_UNIVERSAL_IDENTIFIER, PARTNER_VALIDATION_STAGE_FIELD_UNIVERSAL_IDENTIFIER, -} from 'src/constants/partner-field-universal-identifiers'; +} from 'src/modules/partner/constants/partner-field-universal-identifiers'; import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; export const PARTNERS_PER_STAGE_VIEW_UNIVERSAL_IDENTIFIER = diff --git a/packages/twenty-apps/internal/twenty-partners/src/views/partners-validated.view.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/views/partners-validated.view.ts similarity index 92% rename from packages/twenty-apps/internal/twenty-partners/src/views/partners-validated.view.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/views/partners-validated.view.ts index 93648a41b1..186c66f3e0 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/views/partners-validated.view.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/directory/views/partners-validated.view.ts @@ -6,9 +6,9 @@ import { PARTNER_REGION_FIELD_UNIVERSAL_IDENTIFIER, PARTNER_TIER_FIELD_UNIVERSAL_IDENTIFIER, PARTNER_VALIDATION_STAGE_FIELD_UNIVERSAL_IDENTIFIER, -} from 'src/constants/partner-field-universal-identifiers'; +} from 'src/modules/partner/constants/partner-field-universal-identifiers'; import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; -import { PARTNER_USER_ON_PARTNER_FIELD_ID } from 'src/fields/partner-user-on-partner.field'; +import { PARTNER_USER_ON_PARTNER_FIELD_ID } from 'src/modules/partner/fields/partner-user-on-partner.field'; // Reuses the retired all-partners view id so the 0.5.x→1.0.0 sync updates the Partner // object's existing view in place instead of deleting it (Twenty blocks deleting an object's only view). diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/companies-as-partner-user-on-workspace-member.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/companies-as-partner-user-on-workspace-member.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/companies-as-partner-user-on-workspace-member.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/companies-as-partner-user-on-workspace-member.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-company.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-company.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/partner-company.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-company.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-content-customer-company.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-content-customer-company.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/partner-content-customer-company.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-content-customer-company.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-content-customer-person.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-content-customer-person.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/partner-content-customer-person.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-content-customer-person.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-content-partner.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-content-partner.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/partner-content-partner.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-content-partner.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-contents-as-partner-user-on-workspace-member.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-contents-as-partner-user-on-workspace-member.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/partner-contents-as-partner-user-on-workspace-member.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-contents-as-partner-user-on-workspace-member.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-contents-on-company.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-contents-on-company.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/partner-contents-on-company.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-contents-on-company.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-contents-on-partner.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-contents-on-partner.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/partner-contents-on-partner.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-contents-on-partner.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-contents-on-person.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-contents-on-person.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/partner-contents-on-person.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-contents-on-person.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-link-partner.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-link-partner.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/partner-link-partner.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-link-partner.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-links-as-partner-user-on-workspace-member.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-links-as-partner-user-on-workspace-member.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/partner-links-as-partner-user-on-workspace-member.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-links-as-partner-user-on-workspace-member.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-links-on-partner.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-links-on-partner.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/partner-links-on-partner.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-links-on-partner.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-on-person.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-on-person.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/partner-on-person.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-on-person.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-profiles-as-user-on-workspace-member.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-profiles-as-user-on-workspace-member.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/partner-profiles-as-user-on-workspace-member.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-profiles-as-user-on-workspace-member.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-service-partner.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-service-partner.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/partner-service-partner.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-service-partner.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-services-as-partner-user-on-workspace-member.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-services-as-partner-user-on-workspace-member.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/partner-services-as-partner-user-on-workspace-member.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-services-as-partner-user-on-workspace-member.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-services-on-partner.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-services-on-partner.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/partner-services-on-partner.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-services-on-partner.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-company.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-user-on-company.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-company.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-user-on-company.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-partner-content.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-user-on-partner-content.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-partner-content.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-user-on-partner-content.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-partner-link.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-user-on-partner-link.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-partner-link.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-user-on-partner-link.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-partner-service.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-user-on-partner-service.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-partner-service.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-user-on-partner-service.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-partner.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-user-on-partner.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-partner.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-user-on-partner.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-person.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-user-on-person.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-person.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partner-user-on-person.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partners-on-company.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partners-on-company.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/partners-on-company.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/partners-on-company.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/person-discord.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/person-discord.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/person-discord.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/person-discord.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/persons-as-partner-user-on-workspace-member.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/persons-as-partner-user-on-workspace-member.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/persons-as-partner-user-on-workspace-member.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/persons-as-partner-user-on-workspace-member.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/persons-on-partner.field.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/persons-on-partner.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/fields/persons-on-partner.field.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/fields/persons-on-partner.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/marketplace-api.integration-test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/marketplace/__tests__/marketplace-api.integration-test.ts similarity index 94% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/marketplace-api.integration-test.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/marketplace/__tests__/marketplace-api.integration-test.ts index c8f428edcf..1c1ab65dcf 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/marketplace-api.integration-test.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/marketplace/__tests__/marketplace-api.integration-test.ts @@ -1,8 +1,8 @@ import { CoreApiClient } from 'twenty-client-sdk/core'; import { beforeAll, describe, expect, it } from 'vitest'; -import { handler as getPartnerBySlug } from '../get-partner-by-slug.logic-function'; -import { handler as listAvailablePartners } from '../list-available-partners.logic-function'; +import { handler as getPartnerBySlug } from 'src/modules/partner/marketplace/get-partner-by-slug.logic-function'; +import { handler as listAvailablePartners } from 'src/modules/partner/marketplace/list-available-partners.logic-function'; const client = new CoreApiClient(); const KNOWN_SLUG = 'nine-dots-ventures'; diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/marketplace/get-partner-by-slug.logic-function.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/marketplace/get-partner-by-slug.logic-function.ts new file mode 100644 index 0000000000..2c270bffec --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/marketplace/get-partner-by-slug.logic-function.ts @@ -0,0 +1,22 @@ +import { defineLogicFunction } from 'twenty-sdk/define'; + +import { getPartnerBySlug } from 'src/modules/partner/marketplace/services/get-partner-by-slug.service'; + +export const GET_PARTNER_BY_SLUG_LOGIC_FUNCTION_ID = + '5e3e7b88-2cf2-4f56-9a4a-46c4c1d6b0bb'; + +export const handler = getPartnerBySlug; + +export default defineLogicFunction({ + universalIdentifier: GET_PARTNER_BY_SLUG_LOGIC_FUNCTION_ID, + name: 'get-partner-by-slug', + description: + 'Returns a single VALIDATED + AVAILABLE partner by slug, or NOT_FOUND.', + timeoutSeconds: 10, + handler, + httpRouteTriggerSettings: { + path: '/partner-by-slug', + httpMethod: 'GET', + isAuthRequired: false, + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/get-partner-by-slug.logic-function.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/marketplace/graphql/queries/get-partner-by-slug.ts similarity index 51% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/get-partner-by-slug.logic-function.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/marketplace/graphql/queries/get-partner-by-slug.ts index cdc139fb49..53b4e13792 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/get-partner-by-slug.logic-function.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/marketplace/graphql/queries/get-partner-by-slug.ts @@ -1,17 +1,8 @@ -import { CoreApiClient } from 'twenty-client-sdk/core'; -import { defineLogicFunction } from 'twenty-sdk/define'; - -import { - mapPartnerForMarketplace, - type MarketplaceProfilePartner, -} from './map-partner-for-marketplace'; - -export const GET_PARTNER_BY_SLUG_LOGIC_FUNCTION_ID = - '5e3e7b88-2cf2-4f56-9a4a-46c4c1d6b0bb'; +import { type CoreApiClient } from 'twenty-client-sdk/core'; // CoreApiClient is codegenerated from the synced workspace schema, so the // selection is strictly typed and the response shape derives from it. -const queryPartnerBySlug = (client: CoreApiClient, slug: string) => +export const queryPartnerBySlug = (client: CoreApiClient, slug: string) => client.query({ partners: { __args: { @@ -82,65 +73,3 @@ const queryPartnerBySlug = (client: CoreApiClient, slug: string) => }, }, }); - -type PartnerRaw = NonNullable< - Awaited>['partners'] ->['edges'][number]['node']; - -type GetPartnerBySlugResult = - | { ok: true; partner: MarketplaceProfilePartner } - | { ok: false; reason: 'NOT_FOUND' | string }; - -const mapProfilePartner = (node: PartnerRaw): MarketplaceProfilePartner => { - const mapped = mapPartnerForMarketplace(node, 'profile'); - - if (!('projectBudgetTypical' in mapped)) { - throw new Error( - 'get-partner-by-slug received list payload from profile mapper', - ); - } - - return mapped; -}; - -export const handler = async (input: { - queryStringParameters?: { slug?: string }; -}): Promise => { - const slug = input?.queryStringParameters?.slug; - if (typeof slug !== 'string' || slug.length === 0) { - return { ok: false, reason: 'Missing slug query parameter' }; - } - - try { - const client = new CoreApiClient(); - const result = await queryPartnerBySlug(client, slug); - const rawNode = result.partners?.edges?.[0]?.node; - - if (!rawNode) { - return { ok: false, reason: 'NOT_FOUND' }; - } - - const partner = mapProfilePartner(rawNode); - - return { ok: true, partner }; - } catch (err) { - return { - ok: false, - reason: err instanceof Error ? err.message : String(err), - }; - } -}; - -export default defineLogicFunction({ - universalIdentifier: GET_PARTNER_BY_SLUG_LOGIC_FUNCTION_ID, - name: 'get-partner-by-slug', - description: - 'Returns a single VALIDATED + AVAILABLE partner by slug, or NOT_FOUND.', - timeoutSeconds: 10, - handler, - httpRouteTriggerSettings: { - path: '/partner-by-slug', - httpMethod: 'GET', - isAuthRequired: false, - }, -}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/marketplace/graphql/queries/list-available-partners.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/marketplace/graphql/queries/list-available-partners.ts new file mode 100644 index 0000000000..2eac63187f --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/marketplace/graphql/queries/list-available-partners.ts @@ -0,0 +1,44 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +// 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. +export 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, + }, + }, + }, + }); diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/marketplace/list-available-partners.logic-function.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/marketplace/list-available-partners.logic-function.ts new file mode 100644 index 0000000000..e24d99b07b --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/marketplace/list-available-partners.logic-function.ts @@ -0,0 +1,21 @@ +import { defineLogicFunction } from 'twenty-sdk/define'; + +import { listAvailablePartners } from 'src/modules/partner/marketplace/services/list-available-partners.service'; + +export const LIST_AVAILABLE_PARTNERS_LOGIC_FUNCTION_ID = + '0f91164f-f492-41e8-9bb0-481be5a3d5b9'; + +export const handler = listAvailablePartners; + +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, + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/map-partner-for-marketplace.test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/marketplace/mappers/map-partner-for-marketplace.mapper.test.ts similarity index 99% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/map-partner-for-marketplace.test.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/marketplace/mappers/map-partner-for-marketplace.mapper.test.ts index 23217054c7..3fb8eef0a6 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/map-partner-for-marketplace.test.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/marketplace/mappers/map-partner-for-marketplace.mapper.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { mapPartnerForMarketplace } from './map-partner-for-marketplace'; +import { mapPartnerForMarketplace } from './map-partner-for-marketplace.mapper'; const makeNode = () => ({ name: 'Nine Dots Ventures', diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/map-partner-for-marketplace.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/marketplace/mappers/map-partner-for-marketplace.mapper.ts similarity index 96% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/map-partner-for-marketplace.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/marketplace/mappers/map-partner-for-marketplace.mapper.ts index 7439fec336..e4b55121e7 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/map-partner-for-marketplace.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/marketplace/mappers/map-partner-for-marketplace.mapper.ts @@ -1,8 +1,9 @@ import { type CoreSchema } from 'twenty-client-sdk/core'; -import { isCaseStudy } from './content-type'; -import { firstFileUrl, resolvePartnerPictureUrl } from './profile-picture'; -import { stripMarkdown } from './strip-markdown'; +import { stripMarkdown } from 'src/modules/shared/utils/strip-markdown.util'; + +import { isCaseStudy } from 'src/modules/partner/utils/content-type'; +import { firstFileUrl, resolvePartnerPictureUrl } from 'src/modules/partner/utils/profile-picture'; export type MapPartnerDetail = 'list' | 'profile'; diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/marketplace/services/get-partner-by-slug.service.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/marketplace/services/get-partner-by-slug.service.ts new file mode 100644 index 0000000000..b574012a44 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/marketplace/services/get-partner-by-slug.service.ts @@ -0,0 +1,55 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; + +import { queryPartnerBySlug } from 'src/modules/partner/marketplace/graphql/queries/get-partner-by-slug'; +import { + mapPartnerForMarketplace, + type MarketplaceProfilePartner, +} from 'src/modules/partner/marketplace/mappers/map-partner-for-marketplace.mapper'; + +type PartnerRaw = NonNullable< + Awaited>['partners'] +>['edges'][number]['node']; + +export type GetPartnerBySlugResult = + | { ok: true; partner: MarketplaceProfilePartner } + | { ok: false; reason: 'NOT_FOUND' | string }; + +const mapProfilePartner = (node: PartnerRaw): MarketplaceProfilePartner => { + const mapped = mapPartnerForMarketplace(node, 'profile'); + + if (!('projectBudgetTypical' in mapped)) { + throw new Error( + 'get-partner-by-slug received list payload from profile mapper', + ); + } + + return mapped; +}; + +export const getPartnerBySlug = async (input: { + queryStringParameters?: { slug?: string }; +}): Promise => { + const slug = input?.queryStringParameters?.slug; + if (typeof slug !== 'string' || slug.length === 0) { + return { ok: false, reason: 'Missing slug query parameter' }; + } + + try { + const client = new CoreApiClient(); + const result = await queryPartnerBySlug(client, slug); + const rawNode = result.partners?.edges?.[0]?.node; + + if (!rawNode) { + return { ok: false, reason: 'NOT_FOUND' }; + } + + const partner = mapProfilePartner(rawNode); + + return { ok: true, partner }; + } catch (err) { + return { + ok: false, + reason: err instanceof Error ? err.message : String(err), + }; + } +}; diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/marketplace/services/list-available-partners.service.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/marketplace/services/list-available-partners.service.ts new file mode 100644 index 0000000000..2cd2691327 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/marketplace/services/list-available-partners.service.ts @@ -0,0 +1,44 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; + +import { queryAvailablePartners } from 'src/modules/partner/marketplace/graphql/queries/list-available-partners'; +import { + mapPartnerForMarketplace, + type MarketplaceListPartner, +} from 'src/modules/partner/marketplace/mappers/map-partner-for-marketplace.mapper'; + +type AvailablePartnerRaw = NonNullable< + Awaited>['partners'] +>['edges'][number]['node']; + +export 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 listAvailablePartners = async (): Promise => { + 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), + }; + } +}; diff --git a/packages/twenty-apps/internal/twenty-partners/src/objects/partner-content.object.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/objects/partner-content.object.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/objects/partner-content.object.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/objects/partner-content.object.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/objects/partner-link.object.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/objects/partner-link.object.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/objects/partner-link.object.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/objects/partner-link.object.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/objects/partner-service.object.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/objects/partner-service.object.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/objects/partner-service.object.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/objects/partner-service.object.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/objects/partner.object.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/objects/partner.object.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/objects/partner.object.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/objects/partner.object.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/get-my-partner-profile.integration-test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/__tests__/get-my-partner-profile.integration-test.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/get-my-partner-profile.integration-test.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/__tests__/get-my-partner-profile.integration-test.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/resolve-partner-from-request.integration-test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/__tests__/resolve-partner-from-request.integration-test.ts similarity index 98% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/resolve-partner-from-request.integration-test.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/__tests__/resolve-partner-from-request.integration-test.ts index 5b65dab36a..76d4d927a8 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/resolve-partner-from-request.integration-test.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/__tests__/resolve-partner-from-request.integration-test.ts @@ -5,7 +5,7 @@ import { decodeJwtClaims, resolvePartnerByUserId, resolvePartnerFromRequest, -} from '../resolve-partner-from-request'; +} from '../services/resolve-partner-from-request.service'; function requireId(id: string | null | undefined, what: string): string { if (!id) throw new Error(`${what} returned no id`); diff --git a/packages/twenty-apps/internal/twenty-partners/src/__tests__/save-my-partner-content.integration-test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/__tests__/save-my-partner-content.integration-test.ts similarity index 98% rename from packages/twenty-apps/internal/twenty-partners/src/__tests__/save-my-partner-content.integration-test.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/__tests__/save-my-partner-content.integration-test.ts index 55f7d7fa76..e2cf017e5e 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/__tests__/save-my-partner-content.integration-test.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/__tests__/save-my-partner-content.integration-test.ts @@ -4,7 +4,7 @@ import { CoreApiClient } from 'twenty-client-sdk/core'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { handler } from 'src/logic-functions/save-my-partner-content.logic-function'; +import { handler } from 'src/modules/partner/self-service/save-my-partner-content.logic-function'; const makeToken = (payload: Record): string => `header.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.sig`; diff --git a/packages/twenty-apps/internal/twenty-partners/src/__tests__/save-my-partner-links.integration-test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/__tests__/save-my-partner-links.integration-test.ts similarity index 98% rename from packages/twenty-apps/internal/twenty-partners/src/__tests__/save-my-partner-links.integration-test.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/__tests__/save-my-partner-links.integration-test.ts index 8cf4fda5e7..bb41f79d5a 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/__tests__/save-my-partner-links.integration-test.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/__tests__/save-my-partner-links.integration-test.ts @@ -5,7 +5,7 @@ import { CoreApiClient } from 'twenty-client-sdk/core'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { handler } from 'src/logic-functions/save-my-partner-links.logic-function'; +import { handler } from 'src/modules/partner/self-service/save-my-partner-links.logic-function'; const makeToken = (payload: Record): string => `header.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.sig`; diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/save-my-partner-profile.integration-test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/__tests__/save-my-partner-profile.integration-test.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/save-my-partner-profile.integration-test.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/__tests__/save-my-partner-profile.integration-test.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/__tests__/save-my-partner-services.integration-test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/__tests__/save-my-partner-services.integration-test.ts similarity index 98% rename from packages/twenty-apps/internal/twenty-partners/src/__tests__/save-my-partner-services.integration-test.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/__tests__/save-my-partner-services.integration-test.ts index feed89150e..92aa45b5a5 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/__tests__/save-my-partner-services.integration-test.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/__tests__/save-my-partner-services.integration-test.ts @@ -5,7 +5,7 @@ import { CoreApiClient } from 'twenty-client-sdk/core'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { handler } from 'src/logic-functions/save-my-partner-services.logic-function'; +import { handler } from 'src/modules/partner/self-service/save-my-partner-services.logic-function'; const makeToken = (payload: Record): string => `header.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.sig`; diff --git a/packages/twenty-apps/internal/twenty-partners/src/__tests__/submit-partner-content-for-review.integration-test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/__tests__/submit-partner-content-for-review.integration-test.ts similarity index 97% rename from packages/twenty-apps/internal/twenty-partners/src/__tests__/submit-partner-content-for-review.integration-test.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/__tests__/submit-partner-content-for-review.integration-test.ts index bbd79e0d66..6c374fb66b 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/__tests__/submit-partner-content-for-review.integration-test.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/__tests__/submit-partner-content-for-review.integration-test.ts @@ -5,7 +5,7 @@ import { CoreApiClient } from 'twenty-client-sdk/core'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { handler } from 'src/logic-functions/submit-partner-content-for-review.logic-function'; +import { handler } from 'src/modules/partner/self-service/submit-partner-content-for-review.logic-function'; const makeToken = (payload: Record): string => `header.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.sig`; diff --git a/packages/twenty-apps/internal/twenty-partners/src/constants/my-case-studies.constants.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/constants/my-case-studies.constants.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/constants/my-case-studies.constants.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/constants/my-case-studies.constants.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/constants/my-profile.constants.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/constants/my-profile.constants.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/constants/my-profile.constants.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/constants/my-profile.constants.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/front-components/my-case-studies.front-component.tsx b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/front-components/my-case-studies.front-component.tsx similarity index 97% rename from packages/twenty-apps/internal/twenty-partners/src/front-components/my-case-studies.front-component.tsx rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/front-components/my-case-studies.front-component.tsx index 6b6bdbea50..4429744ef7 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/front-components/my-case-studies.front-component.tsx +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/front-components/my-case-studies.front-component.tsx @@ -2,9 +2,9 @@ import { type CSSProperties, useCallback, useEffect, useState } from 'react'; import { defineFrontComponent } from 'twenty-sdk/define'; import { enqueueSnackbar, openCommandConfirmationModal } from 'twenty-sdk/front-component'; -import { MY_CASE_STUDIES_FRONT_COMPONENT_ID } from 'src/constants/my-case-studies.constants'; +import { MY_CASE_STUDIES_FRONT_COMPONENT_ID } from 'src/modules/partner/self-service/constants/my-case-studies.constants'; +import { callAppRoute } from 'src/modules/shared/front-components/call-app-route'; -import { callAppRoute } from './call-app-route'; import { CaseStudyCard } from './my-case-studies/case-study-card'; import { buildInitialRows, diff --git a/packages/twenty-apps/internal/twenty-partners/src/front-components/my-case-studies/case-study-card.tsx b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/front-components/my-case-studies/case-study-card.tsx similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/front-components/my-case-studies/case-study-card.tsx rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/front-components/my-case-studies/case-study-card.tsx diff --git a/packages/twenty-apps/internal/twenty-partners/src/front-components/my-case-studies/case-study-rows.test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/front-components/my-case-studies/case-study-rows.test.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/front-components/my-case-studies/case-study-rows.test.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/front-components/my-case-studies/case-study-rows.test.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/front-components/my-case-studies/case-study-rows.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/front-components/my-case-studies/case-study-rows.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/front-components/my-case-studies/case-study-rows.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/front-components/my-case-studies/case-study-rows.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/front-components/my-profile.front-component.tsx b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/front-components/my-profile.front-component.tsx similarity index 97% rename from packages/twenty-apps/internal/twenty-partners/src/front-components/my-profile.front-component.tsx rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/front-components/my-profile.front-component.tsx index 411731188e..11703f8892 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/front-components/my-profile.front-component.tsx +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/front-components/my-profile.front-component.tsx @@ -2,11 +2,11 @@ import { useCallback, useEffect, useState, type ReactNode } from 'react'; import { defineFrontComponent } from 'twenty-sdk/define'; import { enqueueSnackbar } from 'twenty-sdk/front-component'; -import { MY_PROFILE_FRONT_COMPONENT_ID } from 'src/constants/my-profile.constants'; +import { MY_PROFILE_FRONT_COMPONENT_ID } from 'src/modules/partner/self-service/constants/my-profile.constants'; +import { callAppRoute } from 'src/modules/shared/front-components/call-app-route'; -import { callAppRoute } from './call-app-route'; import { MarkdownEditor } from './my-profile/markdown-editor'; -import { ProfilePictureUpload } from './my-profile/ProfilePictureUpload'; +import { ProfilePictureUpload } from './my-profile/profile-picture-upload'; import { ChipMultiSelect, COLORS, diff --git a/packages/twenty-apps/internal/twenty-partners/src/front-components/my-profile/form-fields.tsx b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/front-components/my-profile/form-fields.tsx similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/front-components/my-profile/form-fields.tsx rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/front-components/my-profile/form-fields.tsx diff --git a/packages/twenty-apps/internal/twenty-partners/src/front-components/my-profile/markdown-editor.tsx b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/front-components/my-profile/markdown-editor.tsx similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/front-components/my-profile/markdown-editor.tsx rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/front-components/my-profile/markdown-editor.tsx diff --git a/packages/twenty-apps/internal/twenty-partners/src/front-components/my-profile/markdown-render.test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/front-components/my-profile/markdown-render.test.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/front-components/my-profile/markdown-render.test.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/front-components/my-profile/markdown-render.test.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/front-components/my-profile/markdown-render.tsx b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/front-components/my-profile/markdown-render.tsx similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/front-components/my-profile/markdown-render.tsx rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/front-components/my-profile/markdown-render.tsx diff --git a/packages/twenty-apps/internal/twenty-partners/src/front-components/my-profile/ProfilePictureUpload.tsx b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/front-components/my-profile/profile-picture-upload.tsx similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/front-components/my-profile/ProfilePictureUpload.tsx rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/front-components/my-profile/profile-picture-upload.tsx diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/front-components/my-profile/types.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/front-components/my-profile/types.ts new file mode 100644 index 0000000000..d43b8ebc61 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/front-components/my-profile/types.ts @@ -0,0 +1,8 @@ +export type { MyProfilePayload } from 'src/modules/partner/self-service/mappers/get-my-partner-profile.mapper'; +export type { MyPartnerProfileResult } from 'src/modules/partner/self-service/services/get-my-partner-profile.service'; +export type { SaveResult } from 'src/modules/partner/self-service/services/save-my-partner-profile.service'; +export type { SaveLinksResult } from 'src/modules/partner/self-service/services/save-my-partner-links.service'; +export type { SaveServicesResult } from 'src/modules/partner/self-service/services/save-my-partner-services.service'; +export type { SaveContentResult } from 'src/modules/partner/self-service/services/save-my-partner-content.service'; +export type { SubmitContentForReviewResult } from 'src/modules/partner/self-service/services/submit-partner-content-for-review.service'; +export type { ProfileOptions, SelectOption } from 'src/modules/partner/self-service/constants/my-profile.constants'; diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/get-my-partner-profile.logic-function.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/get-my-partner-profile.logic-function.ts new file mode 100644 index 0000000000..70199b664f --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/get-my-partner-profile.logic-function.ts @@ -0,0 +1,25 @@ +import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define'; + +import { + getMyPartnerProfile, + type MyPartnerProfileResult, +} from 'src/modules/partner/self-service/services/get-my-partner-profile.service'; + +export const GET_MY_PARTNER_PROFILE_ID = 'eacfd95b-de02-4f03-aa38-3cae31bb30a9'; + +export const handler = (event: RoutePayload): Promise => + getMyPartnerProfile(event); + +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, + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/mutations/create-partner-content.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/mutations/create-partner-content.ts new file mode 100644 index 0000000000..b817c42772 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/mutations/create-partner-content.ts @@ -0,0 +1,10 @@ +import { type CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core'; + +export function createPartnerContent( + client: CoreApiClient, + data: CoreSchema.PartnerContentCreateInput, +) { + return client.mutation({ + createPartnerContent: { __args: { data }, id: true }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/mutations/create-partner-link.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/mutations/create-partner-link.ts new file mode 100644 index 0000000000..273eef1d61 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/mutations/create-partner-link.ts @@ -0,0 +1,10 @@ +import { type CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core'; + +export function createPartnerLink( + client: CoreApiClient, + data: CoreSchema.PartnerLinkCreateInput, +) { + return client.mutation({ + createPartnerLink: { __args: { data }, id: true }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/mutations/create-partner-service.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/mutations/create-partner-service.ts new file mode 100644 index 0000000000..fcade425ac --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/mutations/create-partner-service.ts @@ -0,0 +1,10 @@ +import { type CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core'; + +export function createPartnerService( + client: CoreApiClient, + data: CoreSchema.PartnerServiceCreateInput, +) { + return client.mutation({ + createPartnerService: { __args: { data }, id: true }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/mutations/delete-partner-content.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/mutations/delete-partner-content.ts new file mode 100644 index 0000000000..7a0baa27ad --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/mutations/delete-partner-content.ts @@ -0,0 +1,7 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +export function deletePartnerContent(client: CoreApiClient, id: string) { + return client.mutation({ + deletePartnerContent: { __args: { id }, id: true }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/mutations/delete-partner-link.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/mutations/delete-partner-link.ts new file mode 100644 index 0000000000..53a46cab4d --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/mutations/delete-partner-link.ts @@ -0,0 +1,7 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +export function deletePartnerLink(client: CoreApiClient, id: string) { + return client.mutation({ + deletePartnerLink: { __args: { id }, id: true }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/mutations/delete-partner-service.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/mutations/delete-partner-service.ts new file mode 100644 index 0000000000..9efb985aa9 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/mutations/delete-partner-service.ts @@ -0,0 +1,7 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +export function deletePartnerService(client: CoreApiClient, id: string) { + return client.mutation({ + deletePartnerService: { __args: { id }, id: true }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/mutations/update-partner-content.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/mutations/update-partner-content.ts new file mode 100644 index 0000000000..4c78153efc --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/mutations/update-partner-content.ts @@ -0,0 +1,11 @@ +import { type CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core'; + +export function updatePartnerContent( + client: CoreApiClient, + id: string, + data: CoreSchema.PartnerContentUpdateInput, +) { + return client.mutation({ + updatePartnerContent: { __args: { id, data }, id: true }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/mutations/update-partner-link.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/mutations/update-partner-link.ts new file mode 100644 index 0000000000..a9b06ec2c4 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/mutations/update-partner-link.ts @@ -0,0 +1,11 @@ +import { type CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core'; + +export function updatePartnerLink( + client: CoreApiClient, + id: string, + data: CoreSchema.PartnerLinkUpdateInput, +) { + return client.mutation({ + updatePartnerLink: { __args: { id, data }, id: true }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/mutations/update-partner-service.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/mutations/update-partner-service.ts new file mode 100644 index 0000000000..e91cfa546a --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/mutations/update-partner-service.ts @@ -0,0 +1,11 @@ +import { type CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core'; + +export function updatePartnerService( + client: CoreApiClient, + id: string, + data: CoreSchema.PartnerServiceUpdateInput, +) { + return client.mutation({ + updatePartnerService: { __args: { id, data }, id: true }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/mutations/update-partner.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/mutations/update-partner.ts new file mode 100644 index 0000000000..3f5335a8c5 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/mutations/update-partner.ts @@ -0,0 +1,11 @@ +import { type CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core'; + +export function updatePartner( + client: CoreApiClient, + id: string, + data: CoreSchema.PartnerUpdateInput, +) { + return client.mutation({ + updatePartner: { __args: { id, data }, id: true }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/queries/find-my-partner-profile.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/queries/find-my-partner-profile.ts new file mode 100644 index 0000000000..4813ece199 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/queries/find-my-partner-profile.ts @@ -0,0 +1,72 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +// CoreApiClient is codegenerated from the synced workspace schema, so the +// selection is strictly typed and the response shape derives from it. +export function findMyPartnerProfile(client: CoreApiClient, partnerId: string) { + return 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, + }, + }, + }, + }, + }, + }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/queries/find-partner-by-member.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/queries/find-partner-by-member.ts new file mode 100644 index 0000000000..c2cdc6d6c2 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/queries/find-partner-by-member.ts @@ -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 } }, + }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/queries/find-partner-content-ids.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/queries/find-partner-content-ids.ts new file mode 100644 index 0000000000..53bdb5b80d --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/queries/find-partner-content-ids.ts @@ -0,0 +1,10 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +export function findPartnerContentIds(client: CoreApiClient, partnerId: string) { + return client.query({ + partnerContents: { + __args: { filter: { partnerId: { eq: partnerId } } }, + edges: { node: { id: true, contentType: true } }, + }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/queries/find-partner-content-owner.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/queries/find-partner-content-owner.ts new file mode 100644 index 0000000000..cc22cd13a6 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/queries/find-partner-content-owner.ts @@ -0,0 +1,10 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +export function findPartnerContentOwner(client: CoreApiClient, recordId: string) { + return client.query({ + partnerContents: { + __args: { filter: { id: { eq: recordId } }, first: 1 }, + edges: { node: { partnerId: true, status: true } }, + }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/queries/find-partner-content-rows.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/queries/find-partner-content-rows.ts new file mode 100644 index 0000000000..19854efcb1 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/queries/find-partner-content-rows.ts @@ -0,0 +1,22 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +export function findPartnerContentRows(client: CoreApiClient, partnerId: string) { + return 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, + }, + }, + }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/queries/find-partner-link-ids.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/queries/find-partner-link-ids.ts new file mode 100644 index 0000000000..3b0450a6c4 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/queries/find-partner-link-ids.ts @@ -0,0 +1,10 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +export function findPartnerLinkIds(client: CoreApiClient, partnerId: string) { + return client.query({ + partnerLinks: { + __args: { filter: { partnerId: { eq: partnerId } } }, + edges: { node: { id: true } }, + }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/queries/find-partner-link-rows.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/queries/find-partner-link-rows.ts new file mode 100644 index 0000000000..2f6b02633e --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/queries/find-partner-link-rows.ts @@ -0,0 +1,17 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +export function findPartnerLinkRows(client: CoreApiClient, partnerId: string) { + return client.query({ + partnerLinks: { + __args: { filter: { partnerId: { eq: partnerId } } }, + edges: { + node: { + id: true, + name: true, + url: { primaryLinkUrl: true }, + sortOrder: true, + }, + }, + }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/queries/find-partner-service-ids.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/queries/find-partner-service-ids.ts new file mode 100644 index 0000000000..9e50e0a95e --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/queries/find-partner-service-ids.ts @@ -0,0 +1,10 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +export function findPartnerServiceIds(client: CoreApiClient, partnerId: string) { + return client.query({ + partnerServices: { + __args: { filter: { partnerId: { eq: partnerId } } }, + edges: { node: { id: true } }, + }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/queries/find-partner-service-rows.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/queries/find-partner-service-rows.ts new file mode 100644 index 0000000000..6616a8954b --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/graphql/queries/find-partner-service-rows.ts @@ -0,0 +1,17 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +export function findPartnerServiceRows(client: CoreApiClient, partnerId: string) { + return client.query({ + partnerServices: { + __args: { filter: { partnerId: { eq: partnerId } } }, + edges: { + node: { + id: true, + title: true, + description: true, + sortOrder: true, + }, + }, + }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/get-my-partner-profile.test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/mappers/get-my-partner-profile.mapper.test.ts similarity index 99% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/get-my-partner-profile.test.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/mappers/get-my-partner-profile.mapper.test.ts index 3636c717dc..46ce689563 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/get-my-partner-profile.test.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/mappers/get-my-partner-profile.mapper.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { mapMyProfilePayload, type PartnerNode } from './get-my-partner-profile.logic-function'; +import { mapMyProfilePayload, type PartnerNode } from './get-my-partner-profile.mapper'; const makeNode = (overrides: Partial = {}): PartnerNode => ({ diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/mappers/get-my-partner-profile.mapper.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/mappers/get-my-partner-profile.mapper.ts new file mode 100644 index 0000000000..d1d39fe164 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/mappers/get-my-partner-profile.mapper.ts @@ -0,0 +1,95 @@ +import type { findMyPartnerProfile } from 'src/modules/partner/self-service/graphql/queries/find-my-partner-profile'; +import { isCaseStudy } from 'src/modules/partner/utils/content-type'; +import { firstFileUrl } from 'src/modules/partner/utils/profile-picture'; + +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 PartnerNode = NonNullable< + Awaited>['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, + })), +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/save-my-partner-content.test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/mappers/save-my-partner-content.mapper.test.ts similarity index 98% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/save-my-partner-content.test.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/mappers/save-my-partner-content.mapper.test.ts index a9dca370f7..3aa008e488 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/save-my-partner-content.test.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/mappers/save-my-partner-content.mapper.test.ts @@ -4,8 +4,8 @@ import { buildContentCreateData, buildContentUpdateData, saveContentSchema, -} from './save-my-partner-content.logic-function'; -import { canSubmitForReview } from './submit-partner-content-for-review.logic-function'; +} from './save-my-partner-content.mapper'; +import { canSubmitForReview } from './submit-partner-content-for-review.mapper'; describe('saveContentSchema', () => { it('accepts a minimal case study (name only)', () => { diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/mappers/save-my-partner-content.mapper.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/mappers/save-my-partner-content.mapper.ts new file mode 100644 index 0000000000..eb22dc8da1 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/mappers/save-my-partner-content.mapper.ts @@ -0,0 +1,70 @@ +import { type CoreSchema } from 'twenty-client-sdk/core'; +import { z } from 'zod'; + +import { isHttpUrl } from 'src/modules/shared/utils/http-url.util'; + +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; + +export 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; +}; + +// 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' } : {}), + }; +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/mappers/save-my-partner-links.mapper.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/mappers/save-my-partner-links.mapper.ts new file mode 100644 index 0000000000..e1d0f3dab7 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/mappers/save-my-partner-links.mapper.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +import { isHttpUrl } from 'src/modules/shared/utils/http-url.util'; + +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; + +export type LinkRow = { + id: string; + name: string | null; + url: string | null; + sortOrder: number | null; +}; diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/save-my-partner-profile.test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/mappers/save-my-partner-profile.mapper.test.ts similarity index 99% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/save-my-partner-profile.test.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/mappers/save-my-partner-profile.mapper.test.ts index 77d40baeb7..48e223e69b 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/save-my-partner-profile.test.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/mappers/save-my-partner-profile.mapper.test.ts @@ -4,7 +4,7 @@ import { buildPartnerUpdateData, saveProfileSchema, validateProfileOptionValues, -} from './save-my-partner-profile.logic-function'; +} from './save-my-partner-profile.mapper'; describe('saveProfileSchema', () => { it('accepts a valid partial payload', () => { diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/save-my-partner-profile.logic-function.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/mappers/save-my-partner-profile.mapper.ts similarity index 72% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/save-my-partner-profile.logic-function.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/mappers/save-my-partner-profile.mapper.ts index fd3df19f85..f8fe1c73b1 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/save-my-partner-profile.logic-function.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/mappers/save-my-partner-profile.mapper.ts @@ -1,13 +1,8 @@ import { type CoreSchema } from 'twenty-client-sdk/core'; -import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define'; import { z } from 'zod'; -import { PROFILE_OPTIONS } from 'src/constants/my-profile.constants'; - -import { optionalHttpUrl } from './http-url'; -import { buildAppClient, errorResponse, failureResponse, resolvePartnerFromRequest } from './resolve-partner-from-request'; - -export const SAVE_MY_PARTNER_PROFILE_ID = 'de21e2a6-f4b4-4186-90d9-645015e856a1'; +import { PROFILE_OPTIONS } from 'src/modules/partner/self-service/constants/my-profile.constants'; +import { optionalHttpUrl } from 'src/modules/shared/utils/http-url.util'; const optionalUrl = optionalHttpUrl; @@ -43,8 +38,6 @@ export const saveProfileSchema = z export type SaveProfileInput = z.infer; -export type SaveResult = { ok: true } | { ok: false; reason: string }; - const optionValueSet = (options: { value: string }[]): Set => new Set(options.map((option) => option.value)); @@ -118,42 +111,3 @@ export function buildPartnerUpdateData( return data; } - -export const handler = async (event: RoutePayload): Promise => { - const resolved = await resolvePartnerFromRequest(event); - if ('error' in resolved) return errorResponse(resolved.error); - - const parsed = saveProfileSchema.safeParse(event.body); - if (!parsed.success) { - return errorResponse(parsed.error.issues[0]?.message ?? 'invalid_input'); - } - const input = parsed.data; - - const optionError = validateProfileOptionValues(input); - if (optionError) return errorResponse(optionError.error); - - const data = buildPartnerUpdateData(input); - - try { - const client = buildAppClient(); - await client.mutation({ - updatePartner: { __args: { id: resolved.partnerId, data }, id: true }, - }); - return { ok: true }; - } catch (err) { - return failureResponse('save-my-partner-profile', err); - } -}; - -export default defineLogicFunction({ - universalIdentifier: SAVE_MY_PARTNER_PROFILE_ID, - name: 'save-my-partner-profile', - description: "Saves the calling partner's own editable profile fields.", - timeoutSeconds: 15, - handler, - httpRouteTriggerSettings: { - path: '/save-my-partner-profile', - httpMethod: 'POST', - isAuthRequired: true, - }, -}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/mappers/save-my-partner-services.mapper.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/mappers/save-my-partner-services.mapper.ts new file mode 100644 index 0000000000..ceed2cd3c0 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/mappers/save-my-partner-services.mapper.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +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; + +export type ServiceRow = { + id: string; + title: string | null; + description: string | null; + sortOrder: number | null; +}; diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/mappers/submit-partner-content-for-review.mapper.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/mappers/submit-partner-content-for-review.mapper.ts new file mode 100644 index 0000000000..19c7978e61 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/mappers/submit-partner-content-for-review.mapper.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +export const submitContentForReviewSchema = z.object({ + recordId: z.string(), +}); + +export type SubmitContentForReviewInput = z.infer; + +// 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'; +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/my-case-studies.navigation-menu-item.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/navigation-menu-items/my-case-studies.navigation-menu-item.ts similarity index 66% rename from packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/my-case-studies.navigation-menu-item.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/navigation-menu-items/my-case-studies.navigation-menu-item.ts index 6ce509fdea..7f6eed1aff 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/my-case-studies.navigation-menu-item.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/navigation-menu-items/my-case-studies.navigation-menu-item.ts @@ -2,8 +2,8 @@ import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/def import { MY_CASE_STUDIES_NAV_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; -import { MY_CASE_STUDIES_PAGE_LAYOUT_ID } from 'src/constants/my-case-studies.constants'; -import { PARTNER_WORKSPACE_FOLDER_UNIVERSAL_IDENTIFIER } from './partner-workspace-folder.navigation-menu-item'; +import { MY_CASE_STUDIES_PAGE_LAYOUT_ID } from 'src/modules/partner/self-service/constants/my-case-studies.constants'; +import { PARTNER_WORKSPACE_FOLDER_UNIVERSAL_IDENTIFIER } from 'src/modules/shared/navigation-menu-items/partner-workspace-folder.navigation-menu-item'; export default defineNavigationMenuItem({ universalIdentifier: MY_CASE_STUDIES_NAV_UNIVERSAL_IDENTIFIER, diff --git a/packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/my-profile.navigation-menu-item.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/navigation-menu-items/my-profile.navigation-menu-item.ts similarity index 68% rename from packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/my-profile.navigation-menu-item.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/navigation-menu-items/my-profile.navigation-menu-item.ts index b1124c72af..b2dc56b901 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/my-profile.navigation-menu-item.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/navigation-menu-items/my-profile.navigation-menu-item.ts @@ -3,9 +3,9 @@ import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/def import { MY_PROFILE_NAV_ITEM_ID, MY_PROFILE_PAGE_LAYOUT_ID, -} from 'src/constants/my-profile.constants'; +} from 'src/modules/partner/self-service/constants/my-profile.constants'; -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: MY_PROFILE_NAV_ITEM_ID, diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/on-partner-content-created.logic-function.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/on-partner-content-created.logic-function.ts new file mode 100644 index 0000000000..409e9acb1d --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/on-partner-content-created.logic-function.ts @@ -0,0 +1,14 @@ +import { defineLogicFunction } from 'twenty-sdk/define'; + +import { ON_PARTNER_CONTENT_CREATED_FN_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; +import { onPartnerContentCreated } from 'src/modules/partner/self-service/services/on-partner-content-created.service'; + +export const handler = onPartnerContentCreated; + +export default defineLogicFunction({ + universalIdentifier: ON_PARTNER_CONTENT_CREATED_FN_UNIVERSAL_IDENTIFIER, + name: 'on-partner-content-created', + timeoutSeconds: 10, + handler, + databaseEventTriggerSettings: { eventName: 'partnerContent.created' }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/on-partner-link-created.logic-function.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/on-partner-link-created.logic-function.ts new file mode 100644 index 0000000000..d96ac51677 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/on-partner-link-created.logic-function.ts @@ -0,0 +1,14 @@ +import { defineLogicFunction } from 'twenty-sdk/define'; + +import { ON_PARTNER_LINK_CREATED_FN_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; +import { onPartnerLinkCreated } from 'src/modules/partner/self-service/services/on-partner-link-created.service'; + +export const handler = onPartnerLinkCreated; + +export default defineLogicFunction({ + universalIdentifier: ON_PARTNER_LINK_CREATED_FN_UNIVERSAL_IDENTIFIER, + name: 'on-partner-link-created', + timeoutSeconds: 10, + handler, + databaseEventTriggerSettings: { eventName: 'partnerLink.created' }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/on-partner-service-created.logic-function.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/on-partner-service-created.logic-function.ts new file mode 100644 index 0000000000..8bbd893b96 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/on-partner-service-created.logic-function.ts @@ -0,0 +1,14 @@ +import { defineLogicFunction } from 'twenty-sdk/define'; + +import { ON_PARTNER_SERVICE_CREATED_FN_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; +import { onPartnerServiceCreated } from 'src/modules/partner/self-service/services/on-partner-service-created.service'; + +export const handler = onPartnerServiceCreated; + +export default defineLogicFunction({ + universalIdentifier: ON_PARTNER_SERVICE_CREATED_FN_UNIVERSAL_IDENTIFIER, + name: 'on-partner-service-created', + timeoutSeconds: 10, + handler, + databaseEventTriggerSettings: { eventName: 'partnerService.created' }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/page-layouts/my-case-studies.page-layout.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/page-layouts/my-case-studies.page-layout.ts similarity index 92% rename from packages/twenty-apps/internal/twenty-partners/src/page-layouts/my-case-studies.page-layout.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/page-layouts/my-case-studies.page-layout.ts index 0397de1707..09fd4f97af 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/page-layouts/my-case-studies.page-layout.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/page-layouts/my-case-studies.page-layout.ts @@ -5,7 +5,7 @@ import { MY_CASE_STUDIES_PAGE_LAYOUT_ID, MY_CASE_STUDIES_PAGE_TAB_ID, MY_CASE_STUDIES_PAGE_WIDGET_ID, -} from 'src/constants/my-case-studies.constants'; +} from 'src/modules/partner/self-service/constants/my-case-studies.constants'; export default definePageLayout({ universalIdentifier: MY_CASE_STUDIES_PAGE_LAYOUT_ID, diff --git a/packages/twenty-apps/internal/twenty-partners/src/page-layouts/my-profile.page-layout.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/page-layouts/my-profile.page-layout.ts similarity index 92% rename from packages/twenty-apps/internal/twenty-partners/src/page-layouts/my-profile.page-layout.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/page-layouts/my-profile.page-layout.ts index ada54a6ffe..a4cd57236f 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/page-layouts/my-profile.page-layout.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/page-layouts/my-profile.page-layout.ts @@ -5,7 +5,7 @@ import { MY_PROFILE_PAGE_LAYOUT_ID, MY_PROFILE_PAGE_TAB_ID, MY_PROFILE_PAGE_WIDGET_ID, -} from 'src/constants/my-profile.constants'; +} from 'src/modules/partner/self-service/constants/my-profile.constants'; export default definePageLayout({ universalIdentifier: MY_PROFILE_PAGE_LAYOUT_ID, diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/save-my-partner-content.logic-function.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/save-my-partner-content.logic-function.ts new file mode 100644 index 0000000000..edb4bafbea --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/save-my-partner-content.logic-function.ts @@ -0,0 +1,25 @@ +import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define'; + +import { + saveMyPartnerContent, + type SaveContentResult, +} from 'src/modules/partner/self-service/services/save-my-partner-content.service'; + +export const SAVE_MY_PARTNER_CONTENT_ID = 'e574fc61-6d9e-48db-9e98-a9b8160188cc'; + +export const handler = (event: RoutePayload): Promise => + saveMyPartnerContent(event); + +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, + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/save-my-partner-links.logic-function.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/save-my-partner-links.logic-function.ts new file mode 100644 index 0000000000..da982c7ab2 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/save-my-partner-links.logic-function.ts @@ -0,0 +1,24 @@ +import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define'; + +import { + saveMyPartnerLinks, + type SaveLinksResult, +} from 'src/modules/partner/self-service/services/save-my-partner-links.service'; + +export const SAVE_MY_PARTNER_LINKS_ID = 'b56d1158-4e79-4fdb-a7c4-e0f8871b2d42'; + +export const handler = (event: RoutePayload): Promise => + saveMyPartnerLinks(event); + +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, + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/save-my-partner-profile.logic-function.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/save-my-partner-profile.logic-function.ts new file mode 100644 index 0000000000..05ad5d87e6 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/save-my-partner-profile.logic-function.ts @@ -0,0 +1,24 @@ +import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define'; + +import { + saveMyPartnerProfile, + type SaveResult, +} from 'src/modules/partner/self-service/services/save-my-partner-profile.service'; + +export const SAVE_MY_PARTNER_PROFILE_ID = 'de21e2a6-f4b4-4186-90d9-645015e856a1'; + +export const handler = (event: RoutePayload): Promise => + saveMyPartnerProfile(event); + +export default defineLogicFunction({ + universalIdentifier: SAVE_MY_PARTNER_PROFILE_ID, + name: 'save-my-partner-profile', + description: "Saves the calling partner's own editable profile fields.", + timeoutSeconds: 15, + handler, + httpRouteTriggerSettings: { + path: '/save-my-partner-profile', + httpMethod: 'POST', + isAuthRequired: true, + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/save-my-partner-services.logic-function.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/save-my-partner-services.logic-function.ts new file mode 100644 index 0000000000..d602e54235 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/save-my-partner-services.logic-function.ts @@ -0,0 +1,25 @@ +import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define'; + +import { + saveMyPartnerServices, + type SaveServicesResult, +} from 'src/modules/partner/self-service/services/save-my-partner-services.service'; + +export const SAVE_MY_PARTNER_SERVICES_ID = '878a6e36-62f4-4590-807d-ef6204d2d168'; + +export const handler = (event: RoutePayload): Promise => + saveMyPartnerServices(event); + +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, + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/get-my-partner-profile.service.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/get-my-partner-profile.service.ts new file mode 100644 index 0000000000..522838646f --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/get-my-partner-profile.service.ts @@ -0,0 +1,39 @@ +import { type RoutePayload } from 'twenty-sdk/define'; + +import { PROFILE_OPTIONS, type ProfileOptions } from 'src/modules/partner/self-service/constants/my-profile.constants'; +import { findMyPartnerProfile } from 'src/modules/partner/self-service/graphql/queries/find-my-partner-profile'; +import { + mapMyProfilePayload, + type MyProfilePayload, +} from 'src/modules/partner/self-service/mappers/get-my-partner-profile.mapper'; +import { + buildAppClient, + errorResponse, + failureResponse, + resolvePartnerFromRequest, +} from 'src/modules/partner/self-service/services/resolve-partner-from-request.service'; + +export type MyPartnerProfileResult = + | { ok: true; profile: MyProfilePayload; options: ProfileOptions } + | { ok: false; reason: string }; + +export const getMyPartnerProfile = async ( + event: RoutePayload, +): Promise => { + const resolved = await resolvePartnerFromRequest(event); + if ('error' in resolved) return errorResponse(resolved.error); + + try { + const client = buildAppClient(); + const result = await findMyPartnerProfile(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); + } +}; diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/on-partner-content-created.service.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/on-partner-content-created.service.ts new file mode 100644 index 0000000000..452379f8fa --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/on-partner-content-created.service.ts @@ -0,0 +1,57 @@ +import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core'; +import { + type DatabaseEventPayload, + type ObjectRecordCreateEvent, +} from 'twenty-sdk/define'; + +import { updatePartnerContent } from 'src/modules/partner/self-service/graphql/mutations/update-partner-content'; +import { findPartnerByMember } from 'src/modules/partner/self-service/graphql/queries/find-partner-by-member'; +import { stampPartnerUserFromPartner } from 'src/modules/partner/self-service/services/stamp-partner-user-on-child.service'; + +const CASE_STUDY_CONTENT_TYPE: CoreSchema.PartnerContent['contentType'] = [ + 'CASE_STUDY', +]; + +// 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 onPartnerContentCreated = async ( + payload: DatabaseEventPayload>, +): Promise> => { + 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 {}; + + const partnerRes = await findPartnerByMember(client, memberId); + partnerId = partnerRes.partners?.edges?.[0]?.node?.id; + if (!partnerId) return {}; + + await updatePartnerContent(client, childId, { + partnerId, + partnerUserId: memberId, + ...(hasNoContentType(after.contentType) + ? { contentType: CASE_STUDY_CONTENT_TYPE } + : {}), + }); + + return { linked: true, partnerId }; + } + + await stampPartnerUserFromPartner(client, partnerId, 'partnerContent', childId); + + if (hasNoContentType(after.contentType)) { + await updatePartnerContent(client, childId, { contentType: CASE_STUDY_CONTENT_TYPE }); + } + + return { stamped: true }; +}; diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/on-partner-link-created.service.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/on-partner-link-created.service.ts new file mode 100644 index 0000000000..e42dcfcf3b --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/on-partner-link-created.service.ts @@ -0,0 +1,39 @@ +import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core'; +import { + type DatabaseEventPayload, + type ObjectRecordCreateEvent, +} from 'twenty-sdk/define'; + +import { updatePartnerLink } from 'src/modules/partner/self-service/graphql/mutations/update-partner-link'; +import { findPartnerByMember } from 'src/modules/partner/self-service/graphql/queries/find-partner-by-member'; +import { stampPartnerUserFromPartner } from 'src/modules/partner/self-service/services/stamp-partner-user-on-child.service'; + +export const onPartnerLinkCreated = async ( + payload: DatabaseEventPayload>, +): Promise> => { + 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 {}; + + const partnerRes = await findPartnerByMember(client, memberId); + partnerId = partnerRes.partners?.edges?.[0]?.node?.id; + if (!partnerId) return {}; + + await updatePartnerLink(client, childId, { + partnerId, + partnerUserId: memberId, + }); + + return { linked: true, partnerId }; + } + + await stampPartnerUserFromPartner(client, partnerId, 'partnerLink', childId); + return { stamped: true }; +}; diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/on-partner-service-created.service.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/on-partner-service-created.service.ts new file mode 100644 index 0000000000..3e484f9d72 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/on-partner-service-created.service.ts @@ -0,0 +1,39 @@ +import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core'; +import { + type DatabaseEventPayload, + type ObjectRecordCreateEvent, +} from 'twenty-sdk/define'; + +import { updatePartnerService } from 'src/modules/partner/self-service/graphql/mutations/update-partner-service'; +import { findPartnerByMember } from 'src/modules/partner/self-service/graphql/queries/find-partner-by-member'; +import { stampPartnerUserFromPartner } from 'src/modules/partner/self-service/services/stamp-partner-user-on-child.service'; + +export const onPartnerServiceCreated = async ( + payload: DatabaseEventPayload>, +): Promise> => { + 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 {}; + + const partnerRes = await findPartnerByMember(client, memberId); + partnerId = partnerRes.partners?.edges?.[0]?.node?.id; + if (!partnerId) return {}; + + await updatePartnerService(client, childId, { + partnerId, + partnerUserId: memberId, + }); + + return { linked: true, partnerId }; + } + + await stampPartnerUserFromPartner(client, partnerId, 'partnerService', childId); + return { stamped: true }; +}; diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/resolve-partner-from-request.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/resolve-partner-from-request.service.ts similarity index 94% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/resolve-partner-from-request.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/resolve-partner-from-request.service.ts index d94404d834..4ddd0ffd3c 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/resolve-partner-from-request.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/resolve-partner-from-request.service.ts @@ -1,5 +1,7 @@ import { CoreApiClient } from 'twenty-client-sdk/core'; +import { findPartnerByMember } from 'src/modules/partner/self-service/graphql/queries/find-partner-by-member'; + // Self-service routes run with the app-privileged token in production. Under the vitest // harness TWENTY_APP_ACCESS_TOKEN is a crafted identity-only token (decoded for userId but // not a valid API credential), so fall back to the workspace API key for the actual @@ -45,12 +47,7 @@ export const resolvePartnerByUserId = async ( const workspaceMemberId = members.workspaceMembers?.edges?.[0]?.node?.id; if (!workspaceMemberId) return null; - const partners = await client.query({ - partners: { - __args: { filter: { partnerUserId: { eq: workspaceMemberId } }, first: 1 }, - edges: { node: { id: true } }, - }, - }); + const partners = await findPartnerByMember(client, workspaceMemberId); const partnerId = partners.partners?.edges?.[0]?.node?.id; if (!partnerId) return null; diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/resolve-partner-from-request.test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/resolve-partner-from-request.test.ts similarity index 98% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/resolve-partner-from-request.test.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/resolve-partner-from-request.test.ts index 43b2002845..ce10943de8 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/resolve-partner-from-request.test.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/resolve-partner-from-request.test.ts @@ -4,7 +4,7 @@ import { decodeJwtClaims, errorResponse, resolvePartnerFromRequest, -} from './resolve-partner-from-request'; +} from './resolve-partner-from-request.service'; const makeToken = (payload: Record): string => { const body = Buffer.from(JSON.stringify(payload)).toString('base64url'); diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/save-my-partner-content.service.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/save-my-partner-content.service.ts new file mode 100644 index 0000000000..9c7b498474 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/save-my-partner-content.service.ts @@ -0,0 +1,114 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; +import { type RoutePayload } from 'twenty-sdk/define'; + +import { createPartnerContent } from 'src/modules/partner/self-service/graphql/mutations/create-partner-content'; +import { deletePartnerContent } from 'src/modules/partner/self-service/graphql/mutations/delete-partner-content'; +import { updatePartnerContent } from 'src/modules/partner/self-service/graphql/mutations/update-partner-content'; +import { findPartnerContentIds } from 'src/modules/partner/self-service/graphql/queries/find-partner-content-ids'; +import { findPartnerContentRows } from 'src/modules/partner/self-service/graphql/queries/find-partner-content-rows'; +import { + buildContentCreateData, + buildContentUpdateData, + type CaseStudyRow, + saveContentSchema, +} from 'src/modules/partner/self-service/mappers/save-my-partner-content.mapper'; +import { + buildAppClient, + errorResponse, + failureResponse, + resolvePartnerFromRequest, +} from 'src/modules/partner/self-service/services/resolve-partner-from-request.service'; +import { buildReconcilePlan } from 'src/modules/partner/self-service/utils/reconcile-children'; +import { isCaseStudy } from 'src/modules/partner/utils/content-type'; + +export type SaveContentResult = + | { ok: true; caseStudies: CaseStudyRow[] } + | { ok: false; reason: string }; + +const queryExistingContentIds = async ( + client: CoreApiClient, + partnerId: string, +): Promise => { + const result = await findPartnerContentIds(client, partnerId); + return (result.partnerContents?.edges ?? []) + .filter((edge) => isCaseStudy(edge.node.contentType)) + .map((edge) => edge.node.id); +}; + +const queryContentRows = async ( + client: CoreApiClient, + partnerId: string, +): Promise => { + const result = await findPartnerContentRows(client, partnerId); + 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 saveMyPartnerContent = async ( + event: RoutePayload, +): Promise => { + 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 createPartnerContent(client, buildContentCreateData(item)); + 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 updatePartnerContent(client, item.id, buildContentUpdateData(item)); + } + + for (const id of plan.toDelete) { + await deletePartnerContent(client, id); + } + + // 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); + } +}; diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/save-my-partner-links.service.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/save-my-partner-links.service.ts new file mode 100644 index 0000000000..164258619d --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/save-my-partner-links.service.ts @@ -0,0 +1,87 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; +import { type RoutePayload } from 'twenty-sdk/define'; + +import { createPartnerLink } from 'src/modules/partner/self-service/graphql/mutations/create-partner-link'; +import { deletePartnerLink } from 'src/modules/partner/self-service/graphql/mutations/delete-partner-link'; +import { updatePartnerLink } from 'src/modules/partner/self-service/graphql/mutations/update-partner-link'; +import { findPartnerLinkIds } from 'src/modules/partner/self-service/graphql/queries/find-partner-link-ids'; +import { findPartnerLinkRows } from 'src/modules/partner/self-service/graphql/queries/find-partner-link-rows'; +import { + type LinkRow, + saveLinksSchema, +} from 'src/modules/partner/self-service/mappers/save-my-partner-links.mapper'; +import { + buildAppClient, + errorResponse, + failureResponse, + resolvePartnerFromRequest, +} from 'src/modules/partner/self-service/services/resolve-partner-from-request.service'; +import { buildReconcilePlan } from 'src/modules/partner/self-service/utils/reconcile-children'; + +export type SaveLinksResult = { ok: true; links: LinkRow[] } | { ok: false; reason: string }; + +const queryExistingLinkIds = async ( + client: CoreApiClient, + partnerId: string, +): Promise => { + const result = await findPartnerLinkIds(client, partnerId); + return (result.partnerLinks?.edges ?? []).map((edge) => edge.node.id); +}; + +const queryLinkRows = async (client: CoreApiClient, partnerId: string): Promise => { + const result = await findPartnerLinkRows(client, partnerId); + 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 saveMyPartnerLinks = async ( + event: RoutePayload, +): Promise => { + 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 createPartnerLink(client, { + partnerId: resolved.partnerId, + name: link.name, + url: { primaryLinkUrl: link.url }, + sortOrder: link.sortOrder, + }); + } + + for (const link of plan.toUpdate) { + // buildReconcilePlan only puts items with a defined id into toUpdate. + if (link.id === undefined) continue; + await updatePartnerLink(client, link.id, { + name: link.name, + url: { primaryLinkUrl: link.url }, + sortOrder: link.sortOrder, + }); + } + + for (const id of plan.toDelete) { + await deletePartnerLink(client, id); + } + + const links = await queryLinkRows(client, resolved.partnerId); + return { ok: true, links }; + } catch (err) { + return failureResponse('save-my-partner-links', err); + } +}; diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/save-my-partner-profile.service.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/save-my-partner-profile.service.ts new file mode 100644 index 0000000000..ee6d95ebf8 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/save-my-partner-profile.service.ts @@ -0,0 +1,42 @@ +import { type RoutePayload } from 'twenty-sdk/define'; + +import { updatePartner } from 'src/modules/partner/self-service/graphql/mutations/update-partner'; +import { + buildPartnerUpdateData, + saveProfileSchema, + validateProfileOptionValues, +} from 'src/modules/partner/self-service/mappers/save-my-partner-profile.mapper'; +import { + buildAppClient, + errorResponse, + failureResponse, + resolvePartnerFromRequest, +} from 'src/modules/partner/self-service/services/resolve-partner-from-request.service'; + +export type SaveResult = { ok: true } | { ok: false; reason: string }; + +export const saveMyPartnerProfile = async ( + event: RoutePayload, +): Promise => { + const resolved = await resolvePartnerFromRequest(event); + if ('error' in resolved) return errorResponse(resolved.error); + + const parsed = saveProfileSchema.safeParse(event.body); + if (!parsed.success) { + return errorResponse(parsed.error.issues[0]?.message ?? 'invalid_input'); + } + const input = parsed.data; + + const optionError = validateProfileOptionValues(input); + if (optionError) return errorResponse(optionError.error); + + const data = buildPartnerUpdateData(input); + + try { + const client = buildAppClient(); + await updatePartner(client, resolved.partnerId, data); + return { ok: true }; + } catch (err) { + return failureResponse('save-my-partner-profile', err); + } +}; diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/save-my-partner-services.service.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/save-my-partner-services.service.ts new file mode 100644 index 0000000000..d599156799 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/save-my-partner-services.service.ts @@ -0,0 +1,92 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; +import { type RoutePayload } from 'twenty-sdk/define'; + +import { createPartnerService } from 'src/modules/partner/self-service/graphql/mutations/create-partner-service'; +import { deletePartnerService } from 'src/modules/partner/self-service/graphql/mutations/delete-partner-service'; +import { updatePartnerService } from 'src/modules/partner/self-service/graphql/mutations/update-partner-service'; +import { findPartnerServiceIds } from 'src/modules/partner/self-service/graphql/queries/find-partner-service-ids'; +import { findPartnerServiceRows } from 'src/modules/partner/self-service/graphql/queries/find-partner-service-rows'; +import { + saveServicesSchema, + type ServiceRow, +} from 'src/modules/partner/self-service/mappers/save-my-partner-services.mapper'; +import { + buildAppClient, + errorResponse, + failureResponse, + resolvePartnerFromRequest, +} from 'src/modules/partner/self-service/services/resolve-partner-from-request.service'; +import { buildReconcilePlan } from 'src/modules/partner/self-service/utils/reconcile-children'; + +export type SaveServicesResult = + | { ok: true; services: ServiceRow[] } + | { ok: false; reason: string }; + +const queryExistingServiceIds = async ( + client: CoreApiClient, + partnerId: string, +): Promise => { + const result = await findPartnerServiceIds(client, partnerId); + return (result.partnerServices?.edges ?? []).map((edge) => edge.node.id); +}; + +const queryServiceRows = async ( + client: CoreApiClient, + partnerId: string, +): Promise => { + const result = await findPartnerServiceRows(client, partnerId); + 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 saveMyPartnerServices = async ( + event: RoutePayload, +): Promise => { + 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 createPartnerService(client, { + partnerId: resolved.partnerId, + title: service.title, + description: service.description, + sortOrder: service.sortOrder, + }); + } + + for (const service of plan.toUpdate) { + // buildReconcilePlan only puts items with a defined id into toUpdate. + if (service.id === undefined) continue; + await updatePartnerService(client, service.id, { + title: service.title, + description: service.description, + sortOrder: service.sortOrder, + }); + } + + for (const id of plan.toDelete) { + await deletePartnerService(client, id); + } + + const services = await queryServiceRows(client, resolved.partnerId); + return { ok: true, services }; + } catch (err) { + return failureResponse('save-my-partner-services', err); + } +}; diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/stamp-partner-user-on-child.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/stamp-partner-user-on-child.service.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/stamp-partner-user-on-child.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/stamp-partner-user-on-child.service.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/stamp-partner-user-on-child.test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/stamp-partner-user-on-child.test.ts similarity index 99% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/stamp-partner-user-on-child.test.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/stamp-partner-user-on-child.test.ts index 2cb6be215c..19ce546e7c 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/stamp-partner-user-on-child.test.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/stamp-partner-user-on-child.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { type CoreApiClient } from 'twenty-client-sdk/core'; -import { stampPartnerUserFromPartner } from './stamp-partner-user-on-child'; +import { stampPartnerUserFromPartner } from './stamp-partner-user-on-child.service'; describe('stampPartnerUserFromPartner', () => { const query = vi.fn(); diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/submit-partner-content-for-review.service.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/submit-partner-content-for-review.service.ts new file mode 100644 index 0000000000..9146368404 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/submit-partner-content-for-review.service.ts @@ -0,0 +1,61 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; +import { type RoutePayload } from 'twenty-sdk/define'; + +import { updatePartnerContent } from 'src/modules/partner/self-service/graphql/mutations/update-partner-content'; +import { findPartnerContentOwner } from 'src/modules/partner/self-service/graphql/queries/find-partner-content-owner'; +import { + canSubmitForReview, + submitContentForReviewSchema, +} from 'src/modules/partner/self-service/mappers/submit-partner-content-for-review.mapper'; +import { + buildAppClient, + errorResponse, + failureResponse, + resolvePartnerFromRequest, +} from 'src/modules/partner/self-service/services/resolve-partner-from-request.service'; + +export type SubmitContentForReviewResult = + | { ok: true; status: 'UNDER_CUSTOMER_PARTNER_REVIEW' } + | { ok: false; reason: string }; + +const queryContentOwnerAndStatus = async ( + client: CoreApiClient, + recordId: string, +): Promise<{ partnerId: string | null; status: string | null } | null> => { + const result = await findPartnerContentOwner(client, recordId); + const node = result.partnerContents?.edges?.[0]?.node; + if (!node) return null; + return { partnerId: node.partnerId ?? null, status: node.status ?? null }; +}; + +export const submitPartnerContentForReview = async ( + event: RoutePayload, +): Promise => { + 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 updatePartnerContent(client, parsed.data.recordId, { + status: 'UNDER_CUSTOMER_PARTNER_REVIEW', + }); + + return { ok: true, status: 'UNDER_CUSTOMER_PARTNER_REVIEW' }; + } catch (err) { + return failureResponse('submit-partner-content-for-review', err); + } +}; diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/submit-partner-content-for-review.logic-function.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/submit-partner-content-for-review.logic-function.ts new file mode 100644 index 0000000000..81584ebc7f --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/submit-partner-content-for-review.logic-function.ts @@ -0,0 +1,26 @@ +import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define'; + +import { + submitPartnerContentForReview, + type SubmitContentForReviewResult, +} from 'src/modules/partner/self-service/services/submit-partner-content-for-review.service'; + +export const SUBMIT_PARTNER_CONTENT_FOR_REVIEW_ID = '6d722484-bbe9-4ffc-b017-5164e3a5a03c'; + +export const handler = ( + event: RoutePayload, +): Promise => submitPartnerContentForReview(event); + +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, + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/reconcile-children.test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/utils/reconcile-children.test.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/reconcile-children.test.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/utils/reconcile-children.test.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/reconcile-children.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/utils/reconcile-children.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/reconcile-children.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/utils/reconcile-children.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/content-type.test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/utils/content-type.test.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/content-type.test.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/utils/content-type.test.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/content-type.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/utils/content-type.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/content-type.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/utils/content-type.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/profile-picture.test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/utils/profile-picture.test.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/profile-picture.test.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/utils/profile-picture.test.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/profile-picture.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/utils/profile-picture.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/profile-picture.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/partner/utils/profile-picture.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/front-components/call-app-route.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/front-components/call-app-route.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/front-components/call-app-route.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/shared/front-components/call-app-route.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/shared/graphql/mutations/create-company.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/graphql/mutations/create-company.ts new file mode 100644 index 0000000000..8d3943ae69 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/graphql/mutations/create-company.ts @@ -0,0 +1,7 @@ +import type { CoreApiClient } from 'twenty-client-sdk/core'; + +export function createCompany(client: CoreApiClient, name: string) { + return client.mutation({ + createCompany: { __args: { data: { name } }, id: true }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/shared/graphql/mutations/create-person.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/graphql/mutations/create-person.ts new file mode 100644 index 0000000000..3c2c10bf0f --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/graphql/mutations/create-person.ts @@ -0,0 +1,23 @@ +import type { CoreApiClient } from 'twenty-client-sdk/core'; + +export type CreatePersonInput = { + firstName: string; + lastName: string; + email: string; + companyId: string; +}; + +export function createPerson(client: CoreApiClient, input: CreatePersonInput) { + return client.mutation({ + createPerson: { + __args: { + data: { + name: { firstName: input.firstName, lastName: input.lastName }, + emails: { primaryEmail: input.email }, + companyId: input.companyId, + }, + }, + id: true, + }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/shared/graphql/queries/find-company-by-name.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/graphql/queries/find-company-by-name.ts new file mode 100644 index 0000000000..31b9d7d02c --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/graphql/queries/find-company-by-name.ts @@ -0,0 +1,10 @@ +import type { CoreApiClient } from 'twenty-client-sdk/core'; + +export function findCompanyByName(client: CoreApiClient, name: string) { + return client.query({ + companies: { + __args: { filter: { name: { eq: name } }, first: 1 }, + edges: { node: { id: true } }, + }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/shared/graphql/queries/find-person-by-email.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/graphql/queries/find-person-by-email.ts new file mode 100644 index 0000000000..7ff0a9aa66 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/graphql/queries/find-person-by-email.ts @@ -0,0 +1,10 @@ +import type { CoreApiClient } from 'twenty-client-sdk/core'; + +export function findPersonByEmail(client: CoreApiClient, primaryEmail: string) { + return client.query({ + people: { + __args: { filter: { emails: { primaryEmail: { eq: primaryEmail } } }, first: 1 }, + edges: { node: { id: true } }, + }, + }); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/shared/http/read-secret-guarded-event.test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/http/read-secret-guarded-event.test.ts new file mode 100644 index 0000000000..e7b152913c --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/http/read-secret-guarded-event.test.ts @@ -0,0 +1,64 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { readSecretGuardedEvent } from './read-secret-guarded-event'; + +const schema = z.object({ name: z.string().trim().min(1) }); + +describe('readSecretGuardedEvent', () => { + const original = process.env.PARTNER_APPLICATION_SECRET; + + afterEach(() => { + if (original === undefined) delete process.env.PARTNER_APPLICATION_SECRET; + else process.env.PARTNER_APPLICATION_SECRET = original; + }); + + it('returns unauthorized when PARTNER_APPLICATION_SECRET is not set', () => { + delete process.env.PARTNER_APPLICATION_SECRET; + const result = readSecretGuardedEvent( + { headers: { 'x-application-secret': 'anything' }, body: { name: 'Acme' } }, + schema, + ); + expect(result).toEqual({ ok: false, reason: 'unauthorized' }); + }); + + describe('with a secret configured', () => { + beforeEach(() => { + process.env.PARTNER_APPLICATION_SECRET = 'shh'; + }); + + it('returns unauthorized when the header is missing', () => { + const result = readSecretGuardedEvent({ body: { name: 'Acme' } }, schema); + expect(result).toEqual({ ok: false, reason: 'unauthorized' }); + }); + + it('returns unauthorized when the header does not match', () => { + const result = readSecretGuardedEvent( + { headers: { 'x-application-secret': 'wrong' }, body: { name: 'Acme' } }, + schema, + ); + expect(result).toEqual({ ok: false, reason: 'unauthorized' }); + }); + + it('returns invalid_input when the header matches but the body fails the schema', () => { + const result = readSecretGuardedEvent( + { headers: { 'x-application-secret': 'shh' }, body: { name: '' } }, + schema, + ); + expect(result).toEqual({ ok: false, reason: 'invalid_input' }); + }); + + it('returns ok with the parsed input when the header matches and the body is valid', () => { + const result = readSecretGuardedEvent( + { headers: { 'x-application-secret': 'shh' }, body: { name: 'Acme' } }, + schema, + ); + expect(result).toEqual({ ok: true, input: { name: 'Acme' } }); + }); + + it('treats a raw (non-event) payload as the body directly', () => { + const result = readSecretGuardedEvent({ name: 'Acme' }, schema); + expect(result).toEqual({ ok: false, reason: 'unauthorized' }); + }); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/shared/http/read-secret-guarded-event.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/http/read-secret-guarded-event.ts new file mode 100644 index 0000000000..ad7c76f3e1 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/http/read-secret-guarded-event.ts @@ -0,0 +1,21 @@ +import type { z } from 'zod'; + +import { isNonEmptyString } from 'src/modules/shared/utils/is-non-empty-string.util'; + +// The header inbound webhooks carry the shared secret in. Logic-functions must +// forward this exact header (forwardedRequestHeaders) for the guard to see it. +export const APPLICATION_SECRET_HEADER = 'x-application-secret'; +type GuardedEvent = { headers?: Record; body?: unknown }; + +export function readSecretGuardedEvent( + event: unknown, + schema: TSchema, +): { ok: true; input: z.infer } | { ok: false; reason: 'unauthorized' | 'invalid_input' } { + const isEvent = typeof event === 'object' && event !== null && ('body' in event || 'headers' in event); + const headers = isEvent ? (event as GuardedEvent).headers ?? {} : {}; + const raw = isEvent ? (event as GuardedEvent).body : event; + const expected = process.env.PARTNER_APPLICATION_SECRET; + if (!isNonEmptyString(expected) || headers[APPLICATION_SECRET_HEADER] !== expected) return { ok: false, reason: 'unauthorized' }; + const parsed = schema.safeParse(raw); + return parsed.success ? { ok: true, input: parsed.data } : { ok: false, reason: 'invalid_input' }; +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/partner-workspace-folder.navigation-menu-item.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/navigation-menu-items/partner-workspace-folder.navigation-menu-item.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/partner-workspace-folder.navigation-menu-item.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/shared/navigation-menu-items/partner-workspace-folder.navigation-menu-item.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/pipeline-folder.navigation-menu-item.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/navigation-menu-items/pipeline-folder.navigation-menu-item.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/pipeline-folder.navigation-menu-item.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/shared/navigation-menu-items/pipeline-folder.navigation-menu-item.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/post-install.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/post-install.logic-function.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/post-install.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/shared/post-install.logic-function.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/find-or-create-company-and-person.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/services/find-or-create-company-and-person.service.ts similarity index 67% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/find-or-create-company-and-person.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/shared/services/find-or-create-company-and-person.service.ts index ad920c3d75..0f53efb19d 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/find-or-create-company-and-person.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/services/find-or-create-company-and-person.service.ts @@ -1,17 +1,17 @@ import type { CoreApiClient } from 'twenty-client-sdk/core'; +import { createCompany } from 'src/modules/shared/graphql/mutations/create-company'; +import { createPerson } from 'src/modules/shared/graphql/mutations/create-person'; +import { findCompanyByName } from 'src/modules/shared/graphql/queries/find-company-by-name'; +import { findPersonByEmail } from 'src/modules/shared/graphql/queries/find-person-by-email'; + export async function findCompanyIdByExactName( client: CoreApiClient, companyName: string, ): Promise { const name = companyName.trim(); - const lookup = await client.query({ - companies: { - __args: { filter: { name: { eq: name } }, first: 1 }, - edges: { node: { id: true } }, - }, - }); + const lookup = await findCompanyByName(client, name); return lookup.companies?.edges?.[0]?.node?.id; } @@ -24,9 +24,7 @@ export async function findOrCreateCompanyByName( if (existing !== undefined) return existing; const name = companyName.trim(); - const result = await client.mutation({ - createCompany: { __args: { data: { name } }, id: true }, - }); + const result = await createCompany(client, name); const id = result.createCompany?.id; if (id === undefined) throw new Error('createCompany did not return an id'); return id; @@ -38,12 +36,7 @@ export async function findPersonIdByPrimaryEmail( ): Promise { const primaryEmail = email.trim(); - const lookup = await client.query({ - people: { - __args: { filter: { emails: { primaryEmail: { eq: primaryEmail } } }, first: 1 }, - edges: { node: { id: true } }, - }, - }); + const lookup = await findPersonByEmail(client, primaryEmail); return lookup.people?.edges?.[0]?.node?.id; } @@ -66,18 +59,7 @@ export async function findOrCreatePersonByEmail( const existing = await findPersonIdByPrimaryEmail(client, email); if (existing !== undefined) return existing; - const result = await client.mutation({ - createPerson: { - __args: { - data: { - name: { firstName, lastName }, - emails: { primaryEmail: email }, - companyId: input.companyId, - }, - }, - id: true, - }, - }); + const result = await createPerson(client, { email, firstName, lastName, companyId: input.companyId }); const id = result.createPerson?.id; if (id === undefined) throw new Error('createPerson did not return an id'); return id; diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/http-url.test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/utils/http-url.util.test.ts similarity index 95% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/http-url.test.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/shared/utils/http-url.util.test.ts index 4ae1cc71a5..7b2476a8d8 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/http-url.test.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/utils/http-url.util.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { isHttpUrl, optionalHttpUrl } from './http-url'; +import { isHttpUrl, optionalHttpUrl } from './http-url.util'; describe('isHttpUrl', () => { it('accepts http and https', () => { diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/http-url.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/utils/http-url.util.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/http-url.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/shared/utils/http-url.util.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/shared/utils/is-non-empty-string.util.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/utils/is-non-empty-string.util.ts new file mode 100644 index 0000000000..3d8e42d9ea --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/utils/is-non-empty-string.util.ts @@ -0,0 +1,3 @@ +export function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/shared/utils/paginate.util.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/utils/paginate.util.ts new file mode 100644 index 0000000000..52173207c9 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/utils/paginate.util.ts @@ -0,0 +1,22 @@ +type PageResult = { + edges?: ({ node?: TNode | null } | null)[] | null; + pageInfo?: { hasNextPage?: boolean | null; endCursor?: string | null } | null; +} | null | undefined; + +// Drain a cursor-paginated connection fully: accumulate every present node, advancing the +// cursor until hasNextPage is false. A single capped page would strand overflow rows. +export async function collectAll( + fetchPage: (after: string | undefined) => Promise>, +): Promise { + const nodes: TNode[] = []; + let after: string | undefined; + for (;;) { + const page = await fetchPage(after); + for (const edge of page?.edges ?? []) { + if (edge?.node) nodes.push(edge.node); + } + if (!page?.pageInfo?.hasNextPage) break; + after = page?.pageInfo?.endCursor ?? undefined; + } + return nodes; +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/strip-markdown.test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/utils/strip-markdown.util.test.ts similarity index 88% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/strip-markdown.test.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/shared/utils/strip-markdown.util.test.ts index 8224e376ba..5bada54916 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/strip-markdown.test.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/utils/strip-markdown.util.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { stripMarkdown } from './strip-markdown'; +import { stripMarkdown } from './strip-markdown.util'; describe('stripMarkdown', () => { it('removes headings, emphasis, and list markers', () => { diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/strip-markdown.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/utils/strip-markdown.util.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/logic-functions/strip-markdown.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/shared/utils/strip-markdown.util.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/roles/partner.role.ts b/packages/twenty-apps/internal/twenty-partners/src/roles/partner.role.ts index 2cb050c2a7..76d2262ff5 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/roles/partner.role.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/roles/partner.role.ts @@ -20,36 +20,36 @@ import { APPLICATION_PARTNER_USER_FIELD_ID, APPLICATION_STATE_FIELD_ID, APPLICATIONS_ON_OPPORTUNITY_FIELD_ID, -} from 'src/objects/application.object'; +} from 'src/modules/application/objects/application.object'; import { PARTNER_CONTENT_APPROVAL_DATE_FIELD_ID, PARTNER_CONTENT_DOCUMENTS_FIELD_ID, PARTNER_CONTENT_INTERVIEW_FIELD_ID, PARTNER_CONTENT_STATUS_FIELD_ID, PARTNER_CONTENT_TYPE_FIELD_ID, -} from 'src/objects/partner-content.object'; -import { PARTNER_CONTENT_PARTNER_FIELD_ID } from 'src/fields/partner-content-partner.field'; -import { OPPORTUNITY_DESIGN_DOC_STATUS_FIELD_ID } from 'src/fields/opportunity-design-doc-status.field'; -import { OPPORTUNITY_DESIGN_DOC_URL_FIELD_ID } from 'src/fields/opportunity-design-doc-url.field'; -import { OPPORTUNITY_HOSTING_TYPE_FIELD_ID } from 'src/fields/opportunity-hosting-type.field'; -import { OPPORTUNITY_IS_LISTED_FIELD_ID } from 'src/fields/opportunity-is-listed.field'; -import { OPPORTUNITY_LOST_REASON_FIELD_ID } from 'src/fields/opportunity-lost-reason.field'; -import { OPPORTUNITY_NEED_FIELD_ID } from 'src/fields/opportunity-need.field'; -import { OPPORTUNITY_NUMBER_OF_SEATS_FIELD_ID } from 'src/fields/opportunity-number-of-seats.field'; -import { OPPORTUNITY_REQUIREMENTS_FIELD_ID } from 'src/fields/opportunity-requirements.field'; -import { OPPORTUNITY_SUBSCRIPTION_FREQUENCY_FIELD_ID } from 'src/fields/opportunity-subscription-frequency.field'; -import { OPPORTUNITY_SUBSCRIPTION_TYPE_FIELD_ID } from 'src/fields/opportunity-subscription-type.field'; -import { OPPORTUNITY_TFT_ID_FIELD_ID } from 'src/fields/opportunity-tft-id.field'; -import { OPPORTUNITY_USE_CASE_FIELD_ID } from 'src/fields/opportunity-use-case.field'; -import { PARTNER_COMPANY_FIELD_ID } from 'src/fields/partner-company.field'; -import { PARTNER_LINK_PARTNER_FIELD_ID } from 'src/fields/partner-link-partner.field'; -import { PARTNER_ON_OPPORTUNITY_FIELD_ID } from 'src/fields/partner-on-opportunity.field'; -import { PARTNER_SERVICE_PARTNER_FIELD_ID } from 'src/fields/partner-service-partner.field'; -import { PARTNER_USER_ON_OPPORTUNITY_FIELD_ID } from 'src/fields/partner-user-on-opportunity.field'; -import { PARTNER_USER_ON_PARTNER_CONTENT_FIELD_ID } from 'src/fields/partner-user-on-partner-content.field'; -import { PARTNER_USER_ON_PARTNER_LINK_FIELD_ID } from 'src/fields/partner-user-on-partner-link.field'; -import { PARTNER_USER_ON_PARTNER_FIELD_ID } from 'src/fields/partner-user-on-partner.field'; -import { PARTNER_USER_ON_PARTNER_SERVICE_FIELD_ID } from 'src/fields/partner-user-on-partner-service.field'; +} from 'src/modules/partner/objects/partner-content.object'; +import { PARTNER_CONTENT_PARTNER_FIELD_ID } from 'src/modules/partner/fields/partner-content-partner.field'; +import { OPPORTUNITY_DESIGN_DOC_STATUS_FIELD_ID } from 'src/modules/opportunity/fields/opportunity-design-doc-status.field'; +import { OPPORTUNITY_DESIGN_DOC_URL_FIELD_ID } from 'src/modules/opportunity/fields/opportunity-design-doc-url.field'; +import { OPPORTUNITY_HOSTING_TYPE_FIELD_ID } from 'src/modules/opportunity/fields/opportunity-hosting-type.field'; +import { OPPORTUNITY_IS_LISTED_FIELD_ID } from 'src/modules/opportunity/fields/opportunity-is-listed.field'; +import { OPPORTUNITY_LOST_REASON_FIELD_ID } from 'src/modules/opportunity/fields/opportunity-lost-reason.field'; +import { OPPORTUNITY_NEED_FIELD_ID } from 'src/modules/opportunity/fields/opportunity-need.field'; +import { OPPORTUNITY_NUMBER_OF_SEATS_FIELD_ID } from 'src/modules/opportunity/fields/opportunity-number-of-seats.field'; +import { OPPORTUNITY_REQUIREMENTS_FIELD_ID } from 'src/modules/opportunity/fields/opportunity-requirements.field'; +import { OPPORTUNITY_SUBSCRIPTION_FREQUENCY_FIELD_ID } from 'src/modules/opportunity/fields/opportunity-subscription-frequency.field'; +import { OPPORTUNITY_SUBSCRIPTION_TYPE_FIELD_ID } from 'src/modules/opportunity/fields/opportunity-subscription-type.field'; +import { OPPORTUNITY_TFT_ID_FIELD_ID } from 'src/modules/opportunity/fields/opportunity-tft-id.field'; +import { OPPORTUNITY_USE_CASE_FIELD_ID } from 'src/modules/opportunity/fields/opportunity-use-case.field'; +import { PARTNER_COMPANY_FIELD_ID } from 'src/modules/partner/fields/partner-company.field'; +import { PARTNER_LINK_PARTNER_FIELD_ID } from 'src/modules/partner/fields/partner-link-partner.field'; +import { PARTNER_ON_OPPORTUNITY_FIELD_ID } from 'src/modules/opportunity/fields/partner-on-opportunity.field'; +import { PARTNER_SERVICE_PARTNER_FIELD_ID } from 'src/modules/partner/fields/partner-service-partner.field'; +import { PARTNER_USER_ON_OPPORTUNITY_FIELD_ID } from 'src/modules/opportunity/fields/partner-user-on-opportunity.field'; +import { PARTNER_USER_ON_PARTNER_CONTENT_FIELD_ID } from 'src/modules/partner/fields/partner-user-on-partner-content.field'; +import { PARTNER_USER_ON_PARTNER_LINK_FIELD_ID } from 'src/modules/partner/fields/partner-user-on-partner-link.field'; +import { PARTNER_USER_ON_PARTNER_FIELD_ID } from 'src/modules/partner/fields/partner-user-on-partner.field'; +import { PARTNER_USER_ON_PARTNER_SERVICE_FIELD_ID } from 'src/modules/partner/fields/partner-user-on-partner-service.field'; // Shared with configure-partner-rls.ts, which locates the role by this label. export const PARTNER_ROLE_LABEL = 'Partner'; diff --git a/packages/twenty-apps/internal/twenty-partners/src/scripts/backfill-partner-user-on-children.ts b/packages/twenty-apps/internal/twenty-partners/src/scripts/backfill-partner-user-on-children.ts index a01667c6cc..8a7a554262 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/scripts/backfill-partner-user-on-children.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/scripts/backfill-partner-user-on-children.ts @@ -3,7 +3,7 @@ import { type CoreApiClient } from 'twenty-client-sdk/core'; import { stampPartnerUserFromPartner, type PartnerChildObject, -} from 'src/logic-functions/stamp-partner-user-on-child'; +} from 'src/modules/partner/self-service/services/stamp-partner-user-on-child.service'; const PAGE_SIZE = 200; diff --git a/packages/twenty-apps/internal/twenty-partners/vitest.config.ts b/packages/twenty-apps/internal/twenty-partners/vitest.config.ts index 0263e53fd9..aa1c112200 100644 --- a/packages/twenty-apps/internal/twenty-partners/vitest.config.ts +++ b/packages/twenty-apps/internal/twenty-partners/vitest.config.ts @@ -18,15 +18,30 @@ export default defineConfig({ }), ], test: { - testTimeout: 120_000, - hookTimeout: 120_000, - fileParallelism: false, - include: ['src/**/*.integration-test.ts'], - globalSetup: ['src/__tests__/global-setup.ts'], - setupFiles: ['src/__tests__/rate-limit-retry.setup.ts'], - env: { - TWENTY_API_URL, - TWENTY_API_KEY, - }, + projects: [ + { + extends: true, + test: { + name: 'unit', + include: ['src/**/*.test.ts'], + }, + }, + { + extends: true, + test: { + name: 'integration', + include: ['src/**/*.integration-test.ts'], + testTimeout: 120_000, + hookTimeout: 120_000, + fileParallelism: false, + globalSetup: ['src/__tests__/global-setup.ts'], + setupFiles: ['src/__tests__/rate-limit-retry.setup.ts'], + env: { + TWENTY_API_URL, + TWENTY_API_KEY, + }, + }, + }, + ], }, }); diff --git a/packages/twenty-apps/internal/twenty-partners/vitest.unit.config.ts b/packages/twenty-apps/internal/twenty-partners/vitest.unit.config.ts deleted file mode 100644 index c2627a143e..0000000000 --- a/packages/twenty-apps/internal/twenty-partners/vitest.unit.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -import tsconfigPaths from 'vite-tsconfig-paths'; -import { defineConfig } from 'vitest/config'; - -// Pure unit tests — no server required, no globalSetup. -export default defineConfig({ - plugins: [ - tsconfigPaths({ - projects: ['tsconfig.spec.json'], - ignoreConfigErrors: true, - }), - ], - test: { - include: ['src/**/*.test.ts'], - }, -});