Skip to content

Commit 895d651

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 bc796e8 commit 895d651

6 files changed

Lines changed: 428 additions & 25 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'
@@ -646,13 +647,59 @@ const OrganizationProfileSettings: React.FC<
646647
return
647648
}
648649

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

657704
// Refresh the router to get the updated organization data from the server
658705
router.refresh()

clients/packages/client/src/v1.ts

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -698,6 +698,28 @@ export interface paths {
698698
patch?: never
699699
trace?: never
700700
}
701+
'/v1/organizations/{id}/submit-review': {
702+
parameters: {
703+
query?: never
704+
header?: never
705+
path?: never
706+
cookie?: never
707+
}
708+
get?: never
709+
put?: never
710+
/**
711+
* Submit Organization for Review
712+
* @description Submit an organization's saved details for review.
713+
*
714+
* **Scopes**: `organizations:write`
715+
*/
716+
post: operations['organizations:submit_review']
717+
delete?: never
718+
options?: never
719+
head?: never
720+
patch?: never
721+
trace?: never
722+
}
701723
'/v1/organizations/{id}/payment-status': {
702724
parameters: {
703725
query?: never
@@ -2770,7 +2792,7 @@ export interface paths {
27702792
}
27712793
/**
27722794
* Get Member by External ID
2773-
* @description Get a member by external ID.
2795+
* @description Get a member by external ID. One of customer_id or external_customer_id must be specified.
27742796
*
27752797
* **Scopes**: `members:read` `members:write`
27762798
*/
@@ -2779,7 +2801,7 @@ export interface paths {
27792801
post?: never
27802802
/**
27812803
* Delete Member by External ID
2782-
* @description Delete a member by external ID.
2804+
* @description Delete a member by external ID. One of customer_id or external_customer_id must be specified.
27832805
*
27842806
* **Scopes**: `members:write`
27852807
*/
@@ -2788,7 +2810,7 @@ export interface paths {
27882810
head?: never
27892811
/**
27902812
* Update Member by External ID
2791-
* @description Update a member by external ID.
2813+
* @description Update a member by external ID. One of customer_id or external_customer_id must be specified.
27922814
*
27932815
* **Scopes**: `members:write`
27942816
*/
@@ -30884,6 +30906,46 @@ export interface operations {
3088430906
}
3088530907
}
3088630908
}
30909+
'organizations:submit_review': {
30910+
parameters: {
30911+
query?: never
30912+
header?: never
30913+
path: {
30914+
id: string
30915+
}
30916+
cookie?: never
30917+
}
30918+
requestBody?: never
30919+
responses: {
30920+
/** @description Organization submitted for review. */
30921+
200: {
30922+
headers: {
30923+
[name: string]: unknown
30924+
}
30925+
content: {
30926+
'application/json': components['schemas']['Organization']
30927+
}
30928+
}
30929+
/** @description Organization not found. */
30930+
404: {
30931+
headers: {
30932+
[name: string]: unknown
30933+
}
30934+
content: {
30935+
'application/json': components['schemas']['ResourceNotFound']
30936+
}
30937+
}
30938+
/** @description Validation Error */
30939+
422: {
30940+
headers: {
30941+
[name: string]: unknown
30942+
}
30943+
content: {
30944+
'application/json': components['schemas']['HTTPValidationError']
30945+
}
30946+
}
30947+
}
30948+
}
3088730949
'organizations:get_payment_status': {
3088830950
parameters: {
3088930951
query?: never
@@ -37200,7 +37262,12 @@ export interface operations {
3720037262
}
3720137263
'members:get_member_by_external_id': {
3720237264
parameters: {
37203-
query?: never
37265+
query?: {
37266+
/** @description The customer ID. */
37267+
customer_id?: string | null
37268+
/** @description The customer external ID. */
37269+
external_customer_id?: string | null
37270+
}
3720437271
header?: never
3720537272
path: {
3720637273
/** @description The member external ID. */
@@ -37241,7 +37308,12 @@ export interface operations {
3724137308
}
3724237309
'members:delete_member_by_external_id': {
3724337310
parameters: {
37244-
query?: never
37311+
query?: {
37312+
/** @description The customer ID. */
37313+
customer_id?: string | null
37314+
/** @description The customer external ID. */
37315+
external_customer_id?: string | null
37316+
}
3724537317
header?: never
3724637318
path: {
3724737319
/** @description The member external ID. */
@@ -37280,7 +37352,12 @@ export interface operations {
3728037352
}
3728137353
'members:update_member_by_external_id': {
3728237354
parameters: {
37283-
query?: never
37355+
query?: {
37356+
/** @description The customer ID. */
37357+
customer_id?: string | null
37358+
/** @description The customer external ID. */
37359+
external_customer_id?: string | null
37360+
}
3728437361
header?: never
3728537362
path: {
3728637363
/** @description The member external ID. */

server/polar/organization/endpoints.py

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

246246

247+
@router.post(
248+
"/{id}/submit-review",
249+
response_model=OrganizationSchema,
250+
summary="Submit Organization for Review",
251+
responses={
252+
200: {"description": "Organization submitted for review."},
253+
404: OrganizationNotFound,
254+
},
255+
tags=[APITag.private],
256+
)
257+
async def submit_review(
258+
id: OrganizationID,
259+
auth_subject: auth.OrganizationsWrite,
260+
session: AsyncSession = Depends(get_db_session),
261+
) -> Organization:
262+
"""Submit an organization's saved details for review."""
263+
organization = await organization_service.get(session, auth_subject, id)
264+
265+
if organization is None:
266+
raise ResourceNotFound()
267+
268+
return await organization_service.submit_for_review(session, organization)
269+
270+
247271
@router.delete(
248272
"/{id}",
249273
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
@@ -14,7 +15,12 @@
1415
from polar.config import Environment, settings
1516
from polar.customer.repository import CustomerRepository
1617
from polar.enums import InvoiceNumbering
17-
from polar.exceptions import NotPermitted, PolarError, PolarRequestValidationError
18+
from polar.exceptions import (
19+
NotPermitted,
20+
PolarError,
21+
PolarRequestValidationError,
22+
ValidationError,
23+
)
1824
from polar.integrations.loops.service import loops as loops_service
1925
from polar.integrations.plain.service import plain as plain_service
2026
from polar.integrations.polar.service import polar_self as polar_self_service
@@ -354,23 +360,96 @@ async def update(
354360
},
355361
)
356362

357-
# Only store details once to avoid API overrides later w/o review
358-
# We do allow initial details being set upon creation that will still require review,
359-
# so upon creation we set details but not details_submitted_at
360-
# so details_submitted_at effectively doubles as a "submit for review"
361-
# timestamp, for now. We'll revisit this soon enough. @pieterbeulque
362-
if not organization.details_submitted_at and update_schema.details:
363+
if update_schema.details:
363364
organization.details = cast(
364365
OrganizationDetails, update_schema.details.model_dump()
365366
)
367+
368+
organization = await repository.update(organization, update_dict=update_dict)
369+
370+
await self._after_update(session, organization)
371+
return organization
372+
373+
def _validate_review_submission(
374+
self, organization: Organization
375+
) -> builtins.list[ValidationError]:
376+
errors: builtins.list[ValidationError] = []
377+
378+
if not organization.name or not organization.name.strip():
379+
errors.append(
380+
{
381+
"loc": ("body", "name"),
382+
"msg": "Organization name is required.",
383+
"type": "value_error",
384+
"input": organization.name,
385+
}
386+
)
387+
388+
if not organization.website:
389+
errors.append(
390+
{
391+
"loc": ("body", "website"),
392+
"msg": "Website is required.",
393+
"type": "value_error",
394+
"input": organization.website,
395+
}
396+
)
397+
398+
if not organization.email:
399+
errors.append(
400+
{
401+
"loc": ("body", "email"),
402+
"msg": "Support email is required.",
403+
"type": "value_error",
404+
"input": organization.email,
405+
}
406+
)
407+
408+
if not any(
409+
social.get("url", "").strip() for social in (organization.socials or [])
410+
):
411+
errors.append(
412+
{
413+
"loc": ("body", "socials"),
414+
"msg": "At least one social media link is required.",
415+
"type": "value_error",
416+
"input": organization.socials,
417+
}
418+
)
419+
420+
product_description = (organization.details or {}).get("product_description")
421+
if (
422+
not isinstance(product_description, str)
423+
or len(product_description.strip()) < 30
424+
):
425+
errors.append(
426+
{
427+
"loc": ("body", "details", "product_description"),
428+
"msg": "Please provide at least 30 characters.",
429+
"type": "value_error",
430+
"input": product_description,
431+
}
432+
)
433+
434+
return errors
435+
436+
async def submit_for_review(
437+
self, session: AsyncSession, organization: Organization
438+
) -> Organization:
439+
errors = self._validate_review_submission(organization)
440+
441+
if errors:
442+
raise PolarRequestValidationError(errors)
443+
444+
if organization.details_submitted_at is None:
366445
organization.details_submitted_at = datetime.now(UTC)
367446
enqueue_job(
368447
"organization_review.run_agent",
369448
organization_id=organization.id,
370449
context=ReviewContext.SUBMISSION,
371450
)
372451

373-
organization = await repository.update(organization, update_dict=update_dict)
452+
session.add(organization)
374453

375454
await self._after_update(session, organization)
376455
return organization
@@ -968,8 +1047,8 @@ async def get_ai_review(
9681047
) -> OrganizationReview | None:
9691048
"""Get the existing AI review for an organization, if any.
9701049
971-
The actual AI review is now triggered asynchronously via a background
972-
task when organization details are first submitted (see update()).
1050+
The actual AI review is triggered asynchronously via a background
1051+
task when organization details are submitted for review.
9731052
"""
9741053
repository = OrganizationReviewRepository.from_session(session)
9751054
return await repository.get_by_organization(organization.id)

0 commit comments

Comments
 (0)