Skip to content

feat: add contact sales leads endpoints (SITES-42069)#2053

Merged
tarunsinghdev merged 9 commits intomainfrom
feature/SITES-42069-contact-sales-lead
Apr 2, 2026
Merged

feat: add contact sales leads endpoints (SITES-42069)#2053
tarunsinghdev merged 9 commits intomainfrom
feature/SITES-42069-contact-sales-lead

Conversation

@tarunsinghdev
Copy link
Copy Markdown
Contributor

SITES-42069

spacecat-api-service — API Endpoints

New controller: ContactSalesLeadsController

File Description
src/controllers/contact-sales-leads.js Controller with create, getByOrganizationId, checkBySite, and updateStatus methods
src/dto/contact-sales-lead.js DTO serializer (toJSON) for API responses — includes siteId
src/routes/index.js Route registration (merged with upstream changes)
src/index.js Controller instantiation (merged with upstream changes)
test/controllers/contact-sales-leads.test.js Full unit test suite (mocha + chai + sinon + esmock)

Endpoints:

Method Route Auth Description
POST /contact-sales-leads IMS (any authenticated user) Creates a new lead; returns 201 on success, 409 if a duplicate exists for the same org+site (when siteId is sent) or same org+email with no site (when siteId is omitted)
GET /organizations/:organizationId/contact-sales-leads IMS (any authenticated user) Lists all leads for an organization
GET /organizations/:organizationId/sites/:siteId/contact-sales-lead IMS (any authenticated user) Checks if a lead exists for a given org+site; returns { exists: true/false }
PATCH /contact-sales-leads/:contactSalesLeadId IMS (any authenticated user) Updates a lead's status (NEWCONTACTEDCLOSED)

POST /contact-sales-leads behavior:

  • Validates name (required, non-empty) and email (required, valid format)
  • Accepts optional domain (the site baseURL) and optional siteId (validated as UUID)
  • Checks for duplicates in scope of the resolved organization: if siteId is present, 409 when a lead already exists for that org+site; if siteId is omitted, 409 when a lead already exists for that org with the same email and no siteId. If the org cannot be resolved, falls back to a global email check via findByEmail
  • Resolves the internal organizationId from the authenticated user's IMS org
  • Creates the lead with status NEW
  • Returns the serialized lead DTO with 201 Created

GET /organizations/:organizationId/sites/:siteId/contact-sales-lead behavior (new):

  • Validates both organizationId and siteId are valid UUIDs
  • Fetches all leads for the organization, then finds one matching the given siteId
  • Returns { exists: true, lead: <DTO> } if found, { exists: false } otherwise
  • Used by the frontend to determine button state on page load

GET /organizations/:organizationId/contact-sales-leads behavior:

  • Validates organizationId is a valid UUID
  • Checks organization exists
  • Returns array of serialized lead DTOs

PATCH /contact-sales-leads/:contactSalesLeadId behavior:

  • Validates contactSalesLeadId is a valid UUID
  • Validates status is one of: NEW, CONTACTED, CLOSED
  • Returns 404 if lead not found
  • Calls lead.setStatus(status) + lead.save(), returns updated DTO with 200 OK

Test coverage (21 test cases):

Endpoint Tests Description
create 8 Successful creation with org resolution, creation with siteId, creation without siteId, 400 for missing name / missing email / invalid email, 409 for duplicate org+site, 409 for duplicate org+email (no site), 500 for unexpected DB error
getByOrganizationId 3 Successful listing, 400 for invalid org ID, 400 for missing org
checkBySite 5 Returns exists: true when lead matches org+site, returns exists: false for no match, 400 for invalid org ID, 400 for invalid site ID, 500 for DB error
updateStatus 5 Successful status update, 400 for invalid lead ID, 400 for invalid status value, 404 for lead not found, 500 for unexpected DB error

Please ensure your pull request adheres to the following guidelines:

  • make sure to link the related issues in this description. Or if there's no issue created, make sure you
    describe here the problem you're solving.
  • when merging / squashing, make sure the fixed issue references are visible in the commits, for easy compilation of release notes

If the PR is changing the API specification:

  • make sure you add a "Not implemented yet" note the endpoint description, if the implementation is not ready
    yet. Ideally, return a 501 status code with a message explaining the feature is not implemented yet.
  • make sure you add at least one example of the request and response.

If the PR is changing the API implementation or an entity exposed through the API:

  • make sure you update the API specification and the examples to reflect the changes.

If the PR is introducing a new audit type:

  • make sure you update the API specification with the type, schema of the audit result and an example

Related Issues

Thanks for contributing!

Comment thread package.json Outdated
@github-actions
Copy link
Copy Markdown

This PR will trigger a minor release when merged.

Copy link
Copy Markdown
Contributor

@sandsinh sandsinh left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Critical Issues

1. No access control checks

None of the endpoints use AccessControlUtil. Per project conventions, every endpoint returning tenant-specific data must check access control:

  • getByOrganizationId — returns all leads for an org with no access check. Any authenticated IMS user can read any org's leads.
  • checkBySite — same issue, leaks existence of leads across tenants.
  • updateStatus — any authenticated user can update any lead's status.
  • create — resolves org from IMS token but doesn't verify the user has access to that org.

This is a security issue for a table containing PII (name, email).

2. create fetches ALL leads for an org to check duplicates

const leads = await ContactSalesLead.allByOrganizationId(organizationId);
const duplicateForSite = leads.find((lead) => lead.getSiteId() === siteId);

This loads every lead for the org into memory just to find one duplicate. For orgs with many leads, this is an unbounded query. Use a targeted query like findByOrganizationIdAndSiteId() or rely on the DB unique constraint and catch the conflict error.

3. checkBySite has the same full-scan problem

const leads = await ContactSalesLead.allByOrganizationId(organizationId);
const match = leads.find((lead) => lead.getSiteId() === siteId);

Fetches all leads for an org, filters in JS. This endpoint is called on every page load (per PR description: "Used by the frontend to determine button state"). Should be a direct DB lookup.

4. internalServerError(e.message) leaks internal error details

return internalServerError(e.message);

This appears in every catch block. Raw DB error messages (constraint violations, connection errors) will be returned to the client. Use cleanupHeaderValue() or return a generic message per project conventions.


Other Issues

5. No OpenAPI spec

The PR checklist calls out updating the API spec, but no docs/openapi/ changes are included. Per project conventions: "OpenAPI First — Define API contract in docs/openapi/ before implementation."

6. getByOrganizationId returns 400 for missing org — should be 404

if (!organization) {
  return badRequest('Organization not found');
}

A valid UUID that doesn't match an existing org is a "not found" condition, not a bad request. Should be notFound().

7. PR title doesn't follow conventional commits

Current: Feature/sites 42069 contact sales lead
Should be: feat: add contact sales leads endpoints (SITES-42069)

8. resolveOrganizationId silently swallows errors

try {
  const org = await Organization.findByImsOrgId(imsOrgId);
  return org ? org.getId() : null;
} catch {
  return null;
}

If the DB is down, this silently returns null, causing create to fall through to the global findByEmail path instead of failing. At minimum, log the error.

**9. updateStatus is misleading — should be a generic update

The PATCH /contact-sales-leads/:contactSalesLeadId endpoint is named updateStatus but it's the only PATCH route for this resource. If more fields become updatable later (e.g., notes, domain), this name forces either renaming or adding a second PATCH endpoint. Follow the convention used by other controllers — name it update and accept a partial body. The route is already PATCH, which implies partial update semantics.

@tarunsinghdev tarunsinghdev changed the title Feature/sites 42069 contact sales lead feat: add contact sales leads endpoints (SITES-42069) Apr 2, 2026
@tarunsinghdev
Copy link
Copy Markdown
Contributor Author

tarunsinghdev commented Apr 2, 2026

Fixes for Code Review

Addressed all actionable items from @sandsinh's review:

Critical Issues — Fixed

  1. Access control checks added ✅
  • Imported AccessControlUtil and forbidden from shared packages
  • accessControlUtil initialized once per controller instance (matching EntitlementsController pattern)
  • create: checks hasAccess(organization) after resolving org from IMS token
  • getByOrganizationId: checks hasAccess(organization) before returning leads
  • checkBySite: checks hasAccess(organization) before returning existence check
  • update: checks hasAccess(organization) for leads with an org, requires hasAdminAccess() for leads without one
  1. Targeted DB query for duplicate check in create ✅
    Replaced full table scan allByOrganizationId() + .find() with ContactSalesLead.findByAll({ organizationId, siteId
    }) — this produces a direct WHERE organization_id = ? AND site_id = ? query with LIMIT 1 at the DB level via the
    BaseCollection's PostgREST query builder.

  2. Targeted DB query for checkBySite ✅
    Same fix — replaced allByOrganizationId() + .find() with ContactSalesLead.findByAll({ organizationId, siteId }).
    This is critical since the endpoint is called on every page load.

  3. Internal error details no longer leaked ✅
    All catch blocks now return generic messages (e.g., 'Failed to create contact sales lead') instead of e.message.
    The raw error is still logged server-side via context.log.error().

Other Issues — Fixed

  1. 404 for missing org (was 400) ✅
    getByOrganizationId and checkBySite now return notFound('Organization not found') instead of badRequest(...).

  2. resolveOrganization now logs errors ✅
    Renamed from resolveOrganizationId to resolveOrganization (returns the org object for access control). The catch
    block now calls context.log.error(...) before returning null.

  3. updateStatus → update (generic partial update) ✅

  • Renamed method from updateStatus to update
  • Now accepts both status and notes fields (partial update semantics)
  • Validates that at least one updatable field is provided
  • Route updated in src/routes/index.js

Additional Changes

  • Removed findByEmail fallback — Aligned with spacecat-shared changes where the standalone email index
    (idx_contact_sales_leads_email) was dropped. Leads are always queried within an org context now. The create
    endpoint now requires organization resolution and returns 400 if the org can't be resolved from the auth context.
  • Routes registered in required-capabilities.js as INTERNAL_ROUTES (IMS-authenticated, not for S2S consumers)
  • Route test updated (test/routes/index.test.js) with mock controller and route expectations
  • Test coverage: 40 test cases (up from 21), 100% line/branch/statement coverage

Comment thread src/routes/index.js Outdated
'DELETE /sites/:siteId/ims-org-access/:accessId': imsOrgAccessController.revokeGrant,

// Contact Sales Leads
'POST /contact-sales-leads': contactSalesLeadsController.create,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can also make this per site ?
POST /organizations/:organizationId/sites/:siteId/contact-sales-lead

@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 2, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@tarunsinghdev tarunsinghdev merged commit 915702e into main Apr 2, 2026
20 checks passed
@tarunsinghdev tarunsinghdev deleted the feature/SITES-42069-contact-sales-lead branch April 2, 2026 13:34
solaris007 pushed a commit that referenced this pull request Apr 2, 2026
# [1.407.0](v1.406.0...v1.407.0) (2026-04-02)

### Features

* add contact sales leads endpoints (SITES-42069) ([#2053](#2053)) ([915702e](915702e))
@solaris007
Copy link
Copy Markdown
Member

🎉 This PR is included in version 1.407.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

ravverma pushed a commit that referenced this pull request Apr 6, 2026
[SITES-42069](https://jira.corp.adobe.com/browse/SITES-42069)

### `spacecat-api-service` — API Endpoints

**New controller: `ContactSalesLeadsController`**

| File | Description |
|------|-------------|
| `src/controllers/contact-sales-leads.js` | Controller with `create`,
`getByOrganizationId`, `checkBySite`, and `updateStatus` methods |
| `src/dto/contact-sales-lead.js` | DTO serializer (`toJSON`) for API
responses — includes `siteId` |
| `src/routes/index.js` | Route registration (merged with upstream
changes) |
| `src/index.js` | Controller instantiation (merged with upstream
changes) |
| `test/controllers/contact-sales-leads.test.js` | Full unit test suite
(mocha + chai + sinon + esmock) |


**Endpoints:**

| Method | Route | Auth | Description |
|--------|-------|------|-------------|
| `POST` | `/contact-sales-leads` | IMS (any authenticated user) |
Creates a new lead; returns `201` on success, `409` if a duplicate
exists for the same org+site (when `siteId` is sent) or same org+email
with no site (when `siteId` is omitted) |
| `GET` | `/organizations/:organizationId/contact-sales-leads` | IMS
(any authenticated user) | Lists all leads for an organization |
| `GET` |
`/organizations/:organizationId/sites/:siteId/contact-sales-lead` | IMS
(any authenticated user) | Checks if a lead exists for a given org+site;
returns `{ exists: true/false }` |
| `PATCH` | `/contact-sales-leads/:contactSalesLeadId` | IMS (any
authenticated user) | Updates a lead's status (`NEW` → `CONTACTED` →
`CLOSED`) |

**`POST /contact-sales-leads` behavior:**

- Validates `name` (required, non-empty) and `email` (required, valid
format)
- Accepts optional `domain` (the site baseURL) and optional `siteId`
(validated as UUID)
- Checks for duplicates in scope of the resolved organization: if
`siteId` is present, `409` when a lead already exists for that org+site;
if `siteId` is omitted, `409` when a lead already exists for that org
with the same email and no `siteId`. If the org cannot be resolved,
falls back to a global email check via `findByEmail`
- Resolves the internal `organizationId` from the authenticated user's
IMS org
- Creates the lead with status `NEW`
- Returns the serialized lead DTO with `201 Created`

**`GET /organizations/:organizationId/sites/:siteId/contact-sales-lead`
behavior (new):**

- Validates both `organizationId` and `siteId` are valid UUIDs
- Fetches all leads for the organization, then finds one matching the
given `siteId`
- Returns `{ exists: true, lead: <DTO> }` if found, `{ exists: false }`
otherwise
- Used by the frontend to determine button state on page load

**`GET /organizations/:organizationId/contact-sales-leads` behavior:**

- Validates `organizationId` is a valid UUID
- Checks organization exists
- Returns array of serialized lead DTOs

**`PATCH /contact-sales-leads/:contactSalesLeadId` behavior:**

- Validates `contactSalesLeadId` is a valid UUID
- Validates `status` is one of: `NEW`, `CONTACTED`, `CLOSED`
- Returns `404` if lead not found
- Calls `lead.setStatus(status)` + `lead.save()`, returns updated DTO
with `200 OK`

**Test coverage (21 test cases):**

| Endpoint | Tests | Description |
|----------|-------|-------------|
| `create` | 8 | Successful creation with org resolution, creation with
siteId, creation without siteId, 400 for missing name / missing email /
invalid email, 409 for duplicate org+site, 409 for duplicate org+email
(no site), 500 for unexpected DB error |
| `getByOrganizationId` | 3 | Successful listing, 400 for invalid org
ID, 400 for missing org |
| `checkBySite` | 5 | Returns `exists: true` when lead matches org+site,
returns `exists: false` for no match, 400 for invalid org ID, 400 for
invalid site ID, 500 for DB error |
| `updateStatus` | 5 | Successful status update, 400 for invalid lead
ID, 400 for invalid status value, 404 for lead not found, 500 for
unexpected DB error |

---


Please ensure your pull request adheres to the following guidelines:
- [x] make sure to link the related issues in this description. Or if
there's no issue created, make sure you
  describe here the problem you're solving.
- [ ] when merging / squashing, make sure the fixed issue references are
visible in the commits, for easy compilation of release notes

If the PR is changing the API specification:
- [ ] make sure you add a "Not implemented yet" note the endpoint
description, if the implementation is not ready
yet. Ideally, return a 501 status code with a message explaining the
feature is not implemented yet.
- [ ] make sure you add at least one example of the request and
response.

If the PR is changing the API implementation or an entity exposed
through the API:
- [ ] make sure you update the API specification and the examples to
reflect the changes.

If the PR is introducing a new audit type:
- [ ] make sure you update the API specification with the type, schema
of the audit result and an example

## Related Issues


Thanks for contributing!
ravverma pushed a commit that referenced this pull request Apr 6, 2026
# [1.407.0](v1.406.0...v1.407.0) (2026-04-02)

### Features

* add contact sales leads endpoints (SITES-42069) ([#2053](#2053)) ([915702e](915702e))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants