Skip to content

Commit c0ba40c

Browse files
committed
onboarding: Add separate submit for review endpoint
We store the details and call the specific endpoint when the user is submitting their org for review. This is hidden from the external API.
1 parent 8246ebe commit c0ba40c

6 files changed

Lines changed: 407 additions & 18 deletions

File tree

clients/apps/web/src/components/Settings/OrganizationProfileSettings.tsx

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useAuth } from '@/hooks'
22
import { useOrganizationKYC } from '@/hooks/queries/org'
33
import { useUpdateOrganization } from '@/hooks/queries'
4+
import { api } from '@/utils/client'
45
import { useAutoSave } from '@/hooks/useAutoSave'
56
import { useURLValidation } from '@/hooks/useURLValidation'
67
import { setValidationErrors } from '@/utils/api/errors'
@@ -640,13 +641,59 @@ const OrganizationProfileSettings: React.FC<
640641
return
641642
}
642643

643-
reset({
644-
...data,
645-
default_presentment_currency:
646-
data.default_presentment_currency as schemas['PresentmentCurrency'],
647-
country: data.country as schemas['CountryAlpha2Input'] | undefined,
648-
socials: [...(data.socials || []), ...emptySocials],
649-
})
644+
if (inKYCMode) {
645+
const submitReviewResult = await api.POST(
646+
'/v1/organizations/{id}/submit-review',
647+
{
648+
params: { path: { id: organization.id } },
649+
},
650+
)
651+
const { data: submittedOrganization, error: submitError } =
652+
submitReviewResult
653+
654+
if (submitError) {
655+
const errorMessage = Array.isArray(submitError.detail)
656+
? submitError.detail[0]?.msg ||
657+
'An error occurred while submitting the organization for review'
658+
: typeof submitError.detail === 'string'
659+
? submitError.detail
660+
: 'An error occurred while submitting the organization for review'
661+
662+
if (isValidationError(submitError.detail)) {
663+
setValidationErrors(submitError.detail, setError)
664+
} else {
665+
setError('root', { message: errorMessage })
666+
}
667+
668+
toast({
669+
title: 'Review Submission Failed',
670+
description: errorMessage,
671+
})
672+
673+
return
674+
}
675+
676+
reset({
677+
...submittedOrganization,
678+
default_presentment_currency:
679+
submittedOrganization.default_presentment_currency as schemas['PresentmentCurrency'],
680+
country: submittedOrganization.country as
681+
| schemas['CountryAlpha2Input']
682+
| undefined,
683+
socials: [...(submittedOrganization.socials || []), ...emptySocials],
684+
details: cleanedBody.details,
685+
})
686+
}
687+
688+
if (!inKYCMode) {
689+
reset({
690+
...data,
691+
default_presentment_currency:
692+
data.default_presentment_currency as schemas['PresentmentCurrency'],
693+
country: data.country as schemas['CountryAlpha2Input'] | undefined,
694+
socials: [...(data.socials || []), ...emptySocials],
695+
})
696+
}
650697

651698
// Refresh the router to get the updated organization data from the server
652699
router.refresh()

clients/packages/client/src/v1.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -705,6 +705,28 @@ export interface paths {
705705
patch?: never
706706
trace?: never
707707
}
708+
'/v1/organizations/{id}/submit-review': {
709+
parameters: {
710+
query?: never
711+
header?: never
712+
path?: never
713+
cookie?: never
714+
}
715+
get?: never
716+
put?: never
717+
/**
718+
* Submit Organization for Review
719+
* @description Submit an organization's saved details for review.
720+
*
721+
* **Scopes**: `organizations:write`
722+
*/
723+
post: operations['organizations:submit_review']
724+
delete?: never
725+
options?: never
726+
head?: never
727+
patch?: never
728+
trace?: never
729+
}
708730
'/v1/organizations/{id}/account': {
709731
parameters: {
710732
query?: never
@@ -30666,6 +30688,46 @@ export interface operations {
3066630688
}
3066730689
}
3066830690
}
30691+
'organizations:submit_review': {
30692+
parameters: {
30693+
query?: never
30694+
header?: never
30695+
path: {
30696+
id: string
30697+
}
30698+
cookie?: never
30699+
}
30700+
requestBody?: never
30701+
responses: {
30702+
/** @description Organization submitted for review. */
30703+
200: {
30704+
headers: {
30705+
[name: string]: unknown
30706+
}
30707+
content: {
30708+
'application/json': components['schemas']['Organization']
30709+
}
30710+
}
30711+
/** @description Organization not found. */
30712+
404: {
30713+
headers: {
30714+
[name: string]: unknown
30715+
}
30716+
content: {
30717+
'application/json': components['schemas']['ResourceNotFound']
30718+
}
30719+
}
30720+
/** @description Validation Error */
30721+
422: {
30722+
headers: {
30723+
[name: string]: unknown
30724+
}
30725+
content: {
30726+
'application/json': components['schemas']['HTTPValidationError']
30727+
}
30728+
}
30729+
}
30730+
}
3066930731
'organizations:get_account': {
3067030732
parameters: {
3067130733
query?: never

server/polar/organization/endpoints.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,30 @@ async def update(
176176
return await organization_service.update(session, organization, organization_update)
177177

178178

179+
@router.post(
180+
"/{id}/submit-review",
181+
response_model=OrganizationSchema,
182+
summary="Submit Organization for Review",
183+
responses={
184+
200: {"description": "Organization submitted for review."},
185+
404: OrganizationNotFound,
186+
},
187+
tags=[APITag.private],
188+
)
189+
async def submit_review(
190+
id: OrganizationID,
191+
auth_subject: auth.OrganizationsWrite,
192+
session: AsyncSession = Depends(get_db_session),
193+
) -> Organization:
194+
"""Submit an organization's saved details for review."""
195+
organization = await organization_service.get(session, auth_subject, id)
196+
197+
if organization is None:
198+
raise ResourceNotFound()
199+
200+
return await organization_service.submit_for_review(session, organization)
201+
202+
179203
@router.delete(
180204
"/{id}",
181205
response_model=OrganizationDeletionResponse,

server/polar/organization/service.py

Lines changed: 89 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import builtins
12
import uuid
23
from collections.abc import Sequence
34
from datetime import UTC, datetime
@@ -15,7 +16,12 @@
1516
from polar.config import Environment, settings
1617
from polar.customer.repository import CustomerRepository
1718
from polar.enums import InvoiceNumbering
18-
from polar.exceptions import NotPermitted, PolarError, PolarRequestValidationError
19+
from polar.exceptions import (
20+
NotPermitted,
21+
PolarError,
22+
PolarRequestValidationError,
23+
ValidationError,
24+
)
1925
from polar.integrations.loops.service import loops as loops_service
2026
from polar.integrations.plain.service import plain as plain_service
2127
from polar.kit.anonymization import anonymize_email_for_deletion, anonymize_for_deletion
@@ -332,23 +338,96 @@ async def update(
332338
},
333339
)
334340

335-
# Only store details once to avoid API overrides later w/o review
336-
# We do allow initial details being set upon creation that will still require review,
337-
# so upon creation we set details but not details_submitted_at
338-
# so details_submitted_at effectively doubles as a "submit for review"
339-
# timestamp, for now. We'll revisit this soon enough. @pieterbeulque
340-
if not organization.details_submitted_at and update_schema.details:
341+
if update_schema.details:
341342
organization.details = cast(
342343
OrganizationDetails, update_schema.details.model_dump()
343344
)
345+
346+
organization = await repository.update(organization, update_dict=update_dict)
347+
348+
await self._after_update(session, organization)
349+
return organization
350+
351+
def _validate_review_submission(
352+
self, organization: Organization
353+
) -> builtins.list[ValidationError]:
354+
errors: builtins.list[ValidationError] = []
355+
356+
if not organization.name or not organization.name.strip():
357+
errors.append(
358+
{
359+
"loc": ("body", "name"),
360+
"msg": "Organization name is required.",
361+
"type": "value_error",
362+
"input": organization.name,
363+
}
364+
)
365+
366+
if not organization.website:
367+
errors.append(
368+
{
369+
"loc": ("body", "website"),
370+
"msg": "Website is required.",
371+
"type": "value_error",
372+
"input": organization.website,
373+
}
374+
)
375+
376+
if not organization.email:
377+
errors.append(
378+
{
379+
"loc": ("body", "email"),
380+
"msg": "Support email is required.",
381+
"type": "value_error",
382+
"input": organization.email,
383+
}
384+
)
385+
386+
if not any(
387+
social.get("url", "").strip() for social in (organization.socials or [])
388+
):
389+
errors.append(
390+
{
391+
"loc": ("body", "socials"),
392+
"msg": "At least one social media link is required.",
393+
"type": "value_error",
394+
"input": organization.socials,
395+
}
396+
)
397+
398+
product_description = (organization.details or {}).get("product_description")
399+
if (
400+
not isinstance(product_description, str)
401+
or len(product_description.strip()) < 30
402+
):
403+
errors.append(
404+
{
405+
"loc": ("body", "details", "product_description"),
406+
"msg": "Please provide at least 30 characters.",
407+
"type": "value_error",
408+
"input": product_description,
409+
}
410+
)
411+
412+
return errors
413+
414+
async def submit_for_review(
415+
self, session: AsyncSession, organization: Organization
416+
) -> Organization:
417+
errors = self._validate_review_submission(organization)
418+
419+
if errors:
420+
raise PolarRequestValidationError(errors)
421+
422+
if organization.details_submitted_at is None:
344423
organization.details_submitted_at = datetime.now(UTC)
345424
enqueue_job(
346425
"organization_review.run_agent",
347426
organization_id=organization.id,
348427
context=ReviewContext.SUBMISSION,
349428
)
350429

351-
organization = await repository.update(organization, update_dict=update_dict)
430+
session.add(organization)
352431

353432
await self._after_update(session, organization)
354433
return organization
@@ -949,8 +1028,8 @@ async def get_ai_review(
9491028
) -> OrganizationReview | None:
9501029
"""Get the existing AI review for an organization, if any.
9511030
952-
The actual AI review is now triggered asynchronously via a background
953-
task when organization details are first submitted (see update()).
1031+
The actual AI review is triggered asynchronously via a background
1032+
task when organization details are submitted for review.
9541033
"""
9551034
repository = OrganizationReviewRepository.from_session(session)
9561035
return await repository.get_by_organization(organization.id)

server/tests/organization/test_endpoints.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,77 @@ async def test_disable_seat_based_pricing_when_enabled(
261261

262262
assert response.status_code == 422
263263

264+
@pytest.mark.auth
265+
async def test_submit_for_review_requires_relevant_fields(
266+
self,
267+
client: AsyncClient,
268+
organization: Organization,
269+
user_organization: UserOrganization,
270+
) -> None:
271+
update_response = await client.patch(
272+
f"/v1/organizations/{organization.id}",
273+
json={
274+
"details": {
275+
"product_description": "Too short",
276+
"selling_categories": ["Software / SaaS"],
277+
"pricing_models": ["Subscription"],
278+
"switching": False,
279+
}
280+
},
281+
)
282+
assert update_response.status_code == 200
283+
284+
response = await client.post(
285+
f"/v1/organizations/{organization.id}/submit-review"
286+
)
287+
288+
assert response.status_code == 422
289+
error_locations = {tuple(error["loc"]) for error in response.json()["detail"]}
290+
assert ("body", "website") in error_locations
291+
assert ("body", "email") in error_locations
292+
assert ("body", "socials") in error_locations
293+
assert ("body", "details", "product_description") in error_locations
294+
295+
@pytest.mark.auth
296+
async def test_submit_for_review_valid(
297+
self,
298+
client: AsyncClient,
299+
mocker: MockerFixture,
300+
organization: Organization,
301+
user_organization: UserOrganization,
302+
) -> None:
303+
enqueue_job_mock = mocker.patch("polar.organization.service.enqueue_job")
304+
305+
update_response = await client.patch(
306+
f"/v1/organizations/{organization.id}",
307+
json={
308+
"website": "https://example.com",
309+
"email": "support@example.com",
310+
"socials": [{"platform": "x", "url": "https://x.com/polar"}],
311+
"details": {
312+
"product_description": "Subscription SaaS for software teams and agencies.",
313+
"selling_categories": ["Software / SaaS"],
314+
"pricing_models": ["Subscription"],
315+
"switching": False,
316+
},
317+
},
318+
)
319+
assert update_response.status_code == 200
320+
321+
response = await client.post(
322+
f"/v1/organizations/{organization.id}/submit-review"
323+
)
324+
325+
assert response.status_code == 200
326+
assert response.json()["details_submitted_at"] is not None
327+
enqueue_job_mock.assert_called_once()
328+
329+
@pytest.mark.auth
330+
async def test_submit_for_review_not_existing(self, client: AsyncClient) -> None:
331+
response = await client.post(f"/v1/organizations/{uuid.uuid4()}/submit-review")
332+
333+
assert response.status_code == 404
334+
264335

265336
@pytest.mark.asyncio
266337
class TestInviteOrganization:

0 commit comments

Comments
 (0)