From b483926789b60120fddf7d213743aff7de634dd8 Mon Sep 17 00:00:00 2001 From: Villiam Strandh Date: Wed, 28 Oct 2026 09:53:30 +0100 Subject: [PATCH 1/7] feat(catalog): add GET /v1/catalogs/{id} Returns one catalog version with its products and the IDs of the versions published before and after it, so the dashboard can show a version page and step between versions. --- clients/packages/client/src/v1.ts | 139 +++++++++++++++++++++++++ server/polar/catalog/endpoints.py | 28 ++++- server/polar/catalog/repository.py | 40 +++++++ server/polar/catalog/schemas.py | 23 +++- server/polar/catalog/service.py | 57 ++++++++-- server/tests/catalog/test_endpoints.py | 94 ++++++++++++++++- 6 files changed, 370 insertions(+), 11 deletions(-) diff --git a/clients/packages/client/src/v1.ts b/clients/packages/client/src/v1.ts index 5d5ae13b390..2653aff0f6d 100644 --- a/clients/packages/client/src/v1.ts +++ b/clients/packages/client/src/v1.ts @@ -3787,6 +3787,28 @@ export interface paths { patch?: never trace?: never } + '/v1/catalogs/{id}': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** + * Get Catalog + * @description Get a catalog version, the products in it, and its neighboring versions. + * + * **Scopes**: `products:read` `products:write` + */ + get: operations['catalogs:get'] + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/v1/custom-fields/': { parameters: { query?: never @@ -13812,11 +13834,88 @@ export interface components { /** Detail */ detail: string } + /** CatalogProduct */ + CatalogProduct: { + /** + * External Id + * @description Key of the product in the catalog. + */ + external_id: string + /** + * Product Id + * Format: uuid4 + * @description The product version in this catalog. + */ + product_id: string + } /** * CatalogStatus * @enum {string} */ CatalogStatus: 'draft' | 'live' | 'archived' + /** + * CatalogVersion + * @description A catalog version with its products and neighboring versions. + */ + CatalogVersion: { + /** + * Created At + * Format: date-time + * @description Creation timestamp of the object. + * @example 2026-01-01T00:00:00.000000Z + */ + created_at: string + /** + * Modified At + * @description Last modification timestamp of the object. + */ + modified_at: string | null + /** + * Id + * Format: uuid4 + * @description The ID of the object. + */ + id: string + /** + * Organization Id + * Format: uuid4 + * @description The organization ID. + * @example 1dbfc517-0bbf-4301-9ba8-555ca42b9737 + */ + organization_id: string + /** + * Name + * @description Optional name given when it was published. + */ + name: string | null + /** + * Version Hash + * @description Hash identifying this version of the catalog. + */ + version_hash: string | null + /** @description `live` for the catalog new checkouts use, `archived` for previous versions, `draft` for unpublished changes. */ + status: components['schemas']['CatalogStatus'] + /** + * Published At + * @description When the catalog was published. Null for drafts. + */ + published_at: string | null + /** + * Previous Id + * @description ID of the version published before this one. + */ + previous_id: string | null + /** + * Next Id + * @description ID of the version published after this one. + */ + next_id: string | null + /** + * Products + * @description The products in this version, ordered by key. + */ + products: components['schemas']['CatalogProduct'][] + } /** ChannelNamePreviewRequest */ ChannelNamePreviewRequest: { /** Organization Id */ @@ -53939,6 +54038,46 @@ export interface operations { } } } + 'catalogs:get': { + parameters: { + query?: never + header?: never + path: { + id: string + } + cookie?: never + } + requestBody?: never + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['CatalogVersion'] + } + } + /** @description Catalog not found. */ + 404: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['ResourceNotFound'] + } + } + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['HTTPValidationError'] + } + } + } + } 'custom-fields:list': { parameters: { query?: { diff --git a/server/polar/catalog/endpoints.py b/server/polar/catalog/endpoints.py index 52e33b89237..2387a05d253 100644 --- a/server/polar/catalog/endpoints.py +++ b/server/polar/catalog/endpoints.py @@ -1,6 +1,7 @@ from fastapi import Depends, Query -from pydantic import AwareDatetime +from pydantic import UUID4, AwareDatetime +from polar.exceptions import ResourceNotFound from polar.kit.pagination import ListResource, PaginationParamsQuery from polar.kit.schemas import MultipleQueryFilter from polar.models.catalog import CatalogStatus @@ -11,6 +12,7 @@ from . import auth from .schemas import Catalog as CatalogSchema +from .schemas import CatalogVersion from .service import catalog as catalog_service router = APIRouter(prefix="/catalogs", tags=["catalogs", APITag.private]) @@ -61,3 +63,27 @@ async def list( count, pagination, ) + + +CatalogNotFound = { + "description": "Catalog not found.", + "model": ResourceNotFound.schema(), +} + + +@router.get( + "/{id}", + summary="Get Catalog", + response_model=CatalogVersion, + responses={404: CatalogNotFound}, +) +async def get( + id: UUID4, + auth_subject: auth.CatalogRead, + session: AsyncReadSession = Depends(get_db_read_session), +) -> CatalogVersion: + """Get a catalog version, the products in it, and its neighboring versions.""" + catalog = await catalog_service.get(session, auth_subject, id) + if catalog is None: + raise ResourceNotFound() + return await catalog_service.get_version(session, catalog) diff --git a/server/polar/catalog/repository.py b/server/polar/catalog/repository.py index ca921eb72bc..3d12de912b5 100644 --- a/server/polar/catalog/repository.py +++ b/server/polar/catalog/repository.py @@ -46,6 +46,34 @@ async def get_version_hashes(self, ids: Sequence[UUID]) -> dict[UUID, str | None result = await self.session.execute(statement) return {id: version_hash for id, version_hash in result.tuples()} + async def get_previous(self, catalog: Catalog) -> Catalog | None: + if catalog.published_at is None: + return None + statement = ( + self._get_published_statement(catalog.organization_id) + .where(Catalog.published_at < catalog.published_at) + .order_by(Catalog.published_at.desc()) + .limit(1) + ) + return await self.get_one_or_none(statement) + + async def get_next(self, catalog: Catalog) -> Catalog | None: + if catalog.published_at is None: + return None + statement = ( + self._get_published_statement(catalog.organization_id) + .where(Catalog.published_at > catalog.published_at) + .order_by(Catalog.published_at.asc()) + .limit(1) + ) + return await self.get_one_or_none(statement) + + def _get_published_statement(self, organization_id: UUID) -> Select[tuple[Catalog]]: + return self.get_base_statement().where( + Catalog.organization_id == organization_id, + Catalog.published_at.is_not(None), + ) + def get_statement_by_org_ids( self, org_ids: set[AccessibleOrganizationID] ) -> Select[tuple[Catalog]]: @@ -74,3 +102,15 @@ async def get_all_by_catalog(self, catalog_id: UUID) -> Sequence[CatalogProduct] .options(joinedload(CatalogProduct.product)) ) return await self.get_all(statement) + + async def get_product_ids_by_catalog(self, catalog_id: UUID) -> dict[str, UUID]: + statement = ( + select(CatalogProduct.external_id, CatalogProduct.product_id) + .where( + CatalogProduct.catalog_id == catalog_id, + CatalogProduct.deleted_at.is_(None), + ) + .order_by(CatalogProduct.external_id) + ) + result = await self.session.execute(statement) + return dict(result.tuples().all()) diff --git a/server/polar/catalog/schemas.py b/server/polar/catalog/schemas.py index 5253dcc02e0..5f6d6c641d7 100644 --- a/server/polar/catalog/schemas.py +++ b/server/polar/catalog/schemas.py @@ -1,8 +1,8 @@ from datetime import datetime -from pydantic import Field +from pydantic import UUID4, Field -from polar.kit.schemas import IDSchema, TimestampedSchema +from polar.kit.schemas import IDSchema, Schema, TimestampedSchema from polar.models.catalog import CatalogStatus from polar.organization.schemas import OrganizationID @@ -26,3 +26,22 @@ class Catalog(IDSchema, TimestampedSchema): published_at: datetime | None = Field( description="When the catalog was published. Null for drafts." ) + + +class CatalogProduct(Schema): + external_id: str = Field(description="Key of the product in the catalog.") + product_id: UUID4 = Field(description="The product version in this catalog.") + + +class CatalogVersion(Catalog): + """A catalog version with its products and neighboring versions.""" + + previous_id: UUID4 | None = Field( + description="ID of the version published before this one." + ) + next_id: UUID4 | None = Field( + description="ID of the version published after this one." + ) + products: list[CatalogProduct] = Field( + description="The products in this version, ordered by key." + ) diff --git a/server/polar/catalog/service.py b/server/polar/catalog/service.py index 36fdcd48fcd..5b7753a71eb 100644 --- a/server/polar/catalog/service.py +++ b/server/polar/catalog/service.py @@ -6,6 +6,7 @@ from polar.auth.models import Agent, AuthSubject, User from polar.auth.permission import OrganizationPermission from polar.authz.service import get_accessible_org_ids +from polar.authz.types import AccessibleOrganizationID from polar.kit.pagination import PaginationParams from polar.kit.utils import utc_now from polar.models import ( @@ -22,6 +23,8 @@ from polar.product.repository import ProductRepository from .repository import CatalogProductRepository, CatalogRepository +from .schemas import Catalog as CatalogSchema +from .schemas import CatalogVersion def compute_version_hash(parent_hash: str | None, entries: Mapping[str, str]) -> str: @@ -45,15 +48,9 @@ async def list( published_before: datetime | None = None, pagination: PaginationParams, ) -> tuple[Sequence[Catalog], int]: - org_ids = await get_accessible_org_ids( - session, auth_subject, permission=OrganizationPermission.products_read - ) + org_ids = await self._get_readable_org_ids(session, auth_subject) if organization_id is not None: org_ids = {oid for oid in org_ids if oid in set(organization_id)} - enabled_org_ids = await OrganizationRepository.from_session( - session - ).get_ids_with_feature(set(org_ids), "config_as_code_enabled") - org_ids = {oid for oid in org_ids if oid in enabled_org_ids} repository = CatalogRepository.from_session(session) statement = repository.get_statement_by_org_ids(org_ids) @@ -69,6 +66,52 @@ async def list( page=pagination.page, ) + async def get( + self, + session: AsyncReadSession, + auth_subject: AuthSubject[User | Organization | Agent], + id: uuid.UUID, + ) -> Catalog | None: + org_ids = await self._get_readable_org_ids(session, auth_subject) + repository = CatalogRepository.from_session(session) + statement = repository.get_statement_by_org_ids(org_ids).where(Catalog.id == id) + return await repository.get_one_or_none(statement) + + async def get_version( + self, session: AsyncReadSession, catalog: Catalog + ) -> CatalogVersion: + repository = CatalogRepository.from_session(session) + previous = await repository.get_previous(catalog) + next = await repository.get_next(catalog) + product_ids = await CatalogProductRepository.from_session( + session + ).get_product_ids_by_catalog(catalog.id) + + return CatalogVersion.model_validate( + { + **CatalogSchema.model_validate(catalog).model_dump(), + "previous_id": previous.id if previous is not None else None, + "next_id": next.id if next is not None else None, + "products": [ + {"external_id": external_id, "product_id": product_id} + for external_id, product_id in product_ids.items() + ], + } + ) + + async def _get_readable_org_ids( + self, + session: AsyncReadSession, + auth_subject: AuthSubject[User | Organization | Agent], + ) -> set[AccessibleOrganizationID]: + org_ids = await get_accessible_org_ids( + session, auth_subject, permission=OrganizationPermission.products_read + ) + enabled_org_ids = await OrganizationRepository.from_session( + session + ).get_ids_with_feature(set(org_ids), "config_as_code_enabled") + return {oid for oid in org_ids if oid in enabled_org_ids} + async def get_live_entries( self, session: AsyncReadSession, diff --git a/server/tests/catalog/test_endpoints.py b/server/tests/catalog/test_endpoints.py index b382ceec0f9..bd994823e67 100644 --- a/server/tests/catalog/test_endpoints.py +++ b/server/tests/catalog/test_endpoints.py @@ -4,7 +4,13 @@ from httpx import AsyncClient from polar.kit.utils import utc_now -from polar.models import Catalog, Organization, UserOrganization +from polar.models import ( + Catalog, + CatalogProduct, + Organization, + Product, + UserOrganization, +) from polar.models.catalog import CatalogStatus from tests.fixtures.auth import AuthSubjectFixture from tests.fixtures.database import SaveFixture @@ -133,3 +139,89 @@ async def test_published_filter_requires_utc_offset( ) assert response.status_code == 422 + + +@pytest.mark.anyio +class TestGet: + async def test_anonymous( + self, client: AsyncClient, catalogs: tuple[Catalog, Catalog, Catalog] + ) -> None: + _, live, _ = catalogs + + response = await client.get(f"/v1/catalogs/{live.id}") + + assert response.status_code == 401 + + @pytest.mark.auth + @pytest.mark.usefixtures("config_as_code_enabled") + async def test_not_member( + self, client: AsyncClient, catalogs: tuple[Catalog, Catalog, Catalog] + ) -> None: + _, live, _ = catalogs + + response = await client.get(f"/v1/catalogs/{live.id}") + + assert response.status_code == 404 + + @pytest.mark.auth + @pytest.mark.usefixtures("user_organization", "catalogs") + async def test_config_as_code_disabled( + self, client: AsyncClient, catalogs: tuple[Catalog, Catalog, Catalog] + ) -> None: + _, live, _ = catalogs + + response = await client.get(f"/v1/catalogs/{live.id}") + + assert response.status_code == 404 + + @pytest.mark.auth + @pytest.mark.usefixtures("config_as_code_enabled", "user_organization") + async def test_valid( + self, + client: AsyncClient, + save_fixture: SaveFixture, + catalogs: tuple[Catalog, Catalog, Catalog], + product: Product, + product_second: Product, + product_one_time: Product, + ) -> None: + draft, live, archived = catalogs + for catalog, catalog_product, external_id in ( + (archived, product, "pro"), + (archived, product_one_time, "lifetime"), + (live, product_second, "pro"), + (live, product_one_time, "lifetime"), + (draft, product_second, "pro"), + ): + await save_fixture( + CatalogProduct( + catalog=catalog, product=catalog_product, external_id=external_id + ) + ) + + response = await client.get(f"/v1/catalogs/{live.id}") + + assert response.status_code == 200 + json = response.json() + assert json["id"] == str(live.id) + assert json["previous_id"] == str(archived.id) + assert json["next_id"] is None + assert json["products"] == [ + {"external_id": "lifetime", "product_id": str(product_one_time.id)}, + {"external_id": "pro", "product_id": str(product_second.id)}, + ] + + response = await client.get(f"/v1/catalogs/{archived.id}") + + json = response.json() + assert json["previous_id"] is None + assert json["next_id"] == str(live.id) + + response = await client.get(f"/v1/catalogs/{draft.id}") + + json = response.json() + assert json["previous_id"] is None + assert json["next_id"] is None + assert json["products"] == [ + {"external_id": "pro", "product_id": str(product_second.id)} + ] From 370fece4a549e05502cb3c64bc24323bb1bbcd47 Mon Sep 17 00:00:00 2001 From: Villiam Strandh Date: Wed, 28 Oct 2026 10:16:12 +0100 Subject: [PATCH 2/7] fix(catalog): order tied catalog versions like the list endpoint --- server/polar/catalog/repository.py | 24 +++++++++++++---- server/tests/catalog/test_endpoints.py | 36 ++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/server/polar/catalog/repository.py b/server/polar/catalog/repository.py index 3d12de912b5..773578b3c8f 100644 --- a/server/polar/catalog/repository.py +++ b/server/polar/catalog/repository.py @@ -1,7 +1,7 @@ from collections.abc import Sequence from uuid import UUID -from sqlalchemy import Select, select +from sqlalchemy import Select, select, tuple_ from sqlalchemy.orm import joinedload from polar.authz.types import AccessibleOrganizationID @@ -51,8 +51,15 @@ async def get_previous(self, catalog: Catalog) -> Catalog | None: return None statement = ( self._get_published_statement(catalog.organization_id) - .where(Catalog.published_at < catalog.published_at) - .order_by(Catalog.published_at.desc()) + .where( + tuple_(Catalog.published_at, Catalog.created_at, Catalog.id) + < (catalog.published_at, catalog.created_at, catalog.id) + ) + .order_by( + Catalog.published_at.desc(), + Catalog.created_at.desc(), + Catalog.id.desc(), + ) .limit(1) ) return await self.get_one_or_none(statement) @@ -62,8 +69,15 @@ async def get_next(self, catalog: Catalog) -> Catalog | None: return None statement = ( self._get_published_statement(catalog.organization_id) - .where(Catalog.published_at > catalog.published_at) - .order_by(Catalog.published_at.asc()) + .where( + tuple_(Catalog.published_at, Catalog.created_at, Catalog.id) + > (catalog.published_at, catalog.created_at, catalog.id) + ) + .order_by( + Catalog.published_at.asc(), + Catalog.created_at.asc(), + Catalog.id.asc(), + ) .limit(1) ) return await self.get_one_or_none(statement) diff --git a/server/tests/catalog/test_endpoints.py b/server/tests/catalog/test_endpoints.py index bd994823e67..9acea118768 100644 --- a/server/tests/catalog/test_endpoints.py +++ b/server/tests/catalog/test_endpoints.py @@ -225,3 +225,39 @@ async def test_valid( assert json["products"] == [ {"external_id": "pro", "product_id": str(product_second.id)} ] + + @pytest.mark.auth + @pytest.mark.usefixtures("config_as_code_enabled", "user_organization") + async def test_tied_published_at( + self, + client: AsyncClient, + save_fixture: SaveFixture, + organization: Organization, + ) -> None: + now = utc_now() + first = Catalog( + organization=organization, + status=CatalogStatus.archived, + published_at=now, + created_at=now - timedelta(seconds=1), + ) + second = Catalog( + organization=organization, + status=CatalogStatus.live, + published_at=now, + created_at=now, + ) + await save_fixture(first) + await save_fixture(second) + + response = await client.get(f"/v1/catalogs/{first.id}") + + json = response.json() + assert json["previous_id"] is None + assert json["next_id"] == str(second.id) + + response = await client.get(f"/v1/catalogs/{second.id}") + + json = response.json() + assert json["previous_id"] == str(first.id) + assert json["next_id"] is None From 1713a388229dd23d12acfe7b36d55408a5b1356a Mon Sep 17 00:00:00 2001 From: Villiam Strandh Date: Wed, 28 Oct 2026 10:16:13 +0100 Subject: [PATCH 3/7] test(catalog): cover GET /v1/catalogs/{id} for organization and agent subjects --- server/tests/catalog/test_endpoints.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/tests/catalog/test_endpoints.py b/server/tests/catalog/test_endpoints.py index 9acea118768..713526119ea 100644 --- a/server/tests/catalog/test_endpoints.py +++ b/server/tests/catalog/test_endpoints.py @@ -174,7 +174,11 @@ async def test_config_as_code_disabled( assert response.status_code == 404 - @pytest.mark.auth + @pytest.mark.auth( + AuthSubjectFixture(subject="user"), + AuthSubjectFixture(subject="organization"), + AuthSubjectFixture(subject="agent"), + ) @pytest.mark.usefixtures("config_as_code_enabled", "user_organization") async def test_valid( self, From 9e851cc1bd84807ca2b8a1d10aca36a2b41bd819 Mon Sep 17 00:00:00 2001 From: Villiam Strandh Date: Wed, 28 Oct 2026 10:25:51 +0100 Subject: [PATCH 4/7] refactor(catalog): drop previous_id and next_id from GET /v1/catalogs/{id} Neighbors depend on the list ordering and change whenever a new version is published. The dashboard can find them from the list endpoint. --- clients/packages/client/src/v1.ts | 14 ++------ server/polar/catalog/endpoints.py | 2 +- server/polar/catalog/repository.py | 44 +----------------------- server/polar/catalog/schemas.py | 8 +---- server/polar/catalog/service.py | 5 --- server/tests/catalog/test_endpoints.py | 46 -------------------------- 6 files changed, 5 insertions(+), 114 deletions(-) diff --git a/clients/packages/client/src/v1.ts b/clients/packages/client/src/v1.ts index 2653aff0f6d..84fe40d3353 100644 --- a/clients/packages/client/src/v1.ts +++ b/clients/packages/client/src/v1.ts @@ -3796,7 +3796,7 @@ export interface paths { } /** * Get Catalog - * @description Get a catalog version, the products in it, and its neighboring versions. + * @description Get a catalog version and the products in it. * * **Scopes**: `products:read` `products:write` */ @@ -13855,7 +13855,7 @@ export interface components { CatalogStatus: 'draft' | 'live' | 'archived' /** * CatalogVersion - * @description A catalog version with its products and neighboring versions. + * @description A catalog version with its products. */ CatalogVersion: { /** @@ -13900,16 +13900,6 @@ export interface components { * @description When the catalog was published. Null for drafts. */ published_at: string | null - /** - * Previous Id - * @description ID of the version published before this one. - */ - previous_id: string | null - /** - * Next Id - * @description ID of the version published after this one. - */ - next_id: string | null /** * Products * @description The products in this version, ordered by key. diff --git a/server/polar/catalog/endpoints.py b/server/polar/catalog/endpoints.py index 2387a05d253..bcd31185111 100644 --- a/server/polar/catalog/endpoints.py +++ b/server/polar/catalog/endpoints.py @@ -82,7 +82,7 @@ async def get( auth_subject: auth.CatalogRead, session: AsyncReadSession = Depends(get_db_read_session), ) -> CatalogVersion: - """Get a catalog version, the products in it, and its neighboring versions.""" + """Get a catalog version and the products in it.""" catalog = await catalog_service.get(session, auth_subject, id) if catalog is None: raise ResourceNotFound() diff --git a/server/polar/catalog/repository.py b/server/polar/catalog/repository.py index 773578b3c8f..33602fe3047 100644 --- a/server/polar/catalog/repository.py +++ b/server/polar/catalog/repository.py @@ -1,7 +1,7 @@ from collections.abc import Sequence from uuid import UUID -from sqlalchemy import Select, select, tuple_ +from sqlalchemy import Select, select from sqlalchemy.orm import joinedload from polar.authz.types import AccessibleOrganizationID @@ -46,48 +46,6 @@ async def get_version_hashes(self, ids: Sequence[UUID]) -> dict[UUID, str | None result = await self.session.execute(statement) return {id: version_hash for id, version_hash in result.tuples()} - async def get_previous(self, catalog: Catalog) -> Catalog | None: - if catalog.published_at is None: - return None - statement = ( - self._get_published_statement(catalog.organization_id) - .where( - tuple_(Catalog.published_at, Catalog.created_at, Catalog.id) - < (catalog.published_at, catalog.created_at, catalog.id) - ) - .order_by( - Catalog.published_at.desc(), - Catalog.created_at.desc(), - Catalog.id.desc(), - ) - .limit(1) - ) - return await self.get_one_or_none(statement) - - async def get_next(self, catalog: Catalog) -> Catalog | None: - if catalog.published_at is None: - return None - statement = ( - self._get_published_statement(catalog.organization_id) - .where( - tuple_(Catalog.published_at, Catalog.created_at, Catalog.id) - > (catalog.published_at, catalog.created_at, catalog.id) - ) - .order_by( - Catalog.published_at.asc(), - Catalog.created_at.asc(), - Catalog.id.asc(), - ) - .limit(1) - ) - return await self.get_one_or_none(statement) - - def _get_published_statement(self, organization_id: UUID) -> Select[tuple[Catalog]]: - return self.get_base_statement().where( - Catalog.organization_id == organization_id, - Catalog.published_at.is_not(None), - ) - def get_statement_by_org_ids( self, org_ids: set[AccessibleOrganizationID] ) -> Select[tuple[Catalog]]: diff --git a/server/polar/catalog/schemas.py b/server/polar/catalog/schemas.py index 5f6d6c641d7..0446f42a7da 100644 --- a/server/polar/catalog/schemas.py +++ b/server/polar/catalog/schemas.py @@ -34,14 +34,8 @@ class CatalogProduct(Schema): class CatalogVersion(Catalog): - """A catalog version with its products and neighboring versions.""" + """A catalog version with its products.""" - previous_id: UUID4 | None = Field( - description="ID of the version published before this one." - ) - next_id: UUID4 | None = Field( - description="ID of the version published after this one." - ) products: list[CatalogProduct] = Field( description="The products in this version, ordered by key." ) diff --git a/server/polar/catalog/service.py b/server/polar/catalog/service.py index 5b7753a71eb..be60dc996ab 100644 --- a/server/polar/catalog/service.py +++ b/server/polar/catalog/service.py @@ -80,9 +80,6 @@ async def get( async def get_version( self, session: AsyncReadSession, catalog: Catalog ) -> CatalogVersion: - repository = CatalogRepository.from_session(session) - previous = await repository.get_previous(catalog) - next = await repository.get_next(catalog) product_ids = await CatalogProductRepository.from_session( session ).get_product_ids_by_catalog(catalog.id) @@ -90,8 +87,6 @@ async def get_version( return CatalogVersion.model_validate( { **CatalogSchema.model_validate(catalog).model_dump(), - "previous_id": previous.id if previous is not None else None, - "next_id": next.id if next is not None else None, "products": [ {"external_id": external_id, "product_id": product_id} for external_id, product_id in product_ids.items() diff --git a/server/tests/catalog/test_endpoints.py b/server/tests/catalog/test_endpoints.py index 713526119ea..15eb3d73426 100644 --- a/server/tests/catalog/test_endpoints.py +++ b/server/tests/catalog/test_endpoints.py @@ -208,60 +208,14 @@ async def test_valid( assert response.status_code == 200 json = response.json() assert json["id"] == str(live.id) - assert json["previous_id"] == str(archived.id) - assert json["next_id"] is None assert json["products"] == [ {"external_id": "lifetime", "product_id": str(product_one_time.id)}, {"external_id": "pro", "product_id": str(product_second.id)}, ] - response = await client.get(f"/v1/catalogs/{archived.id}") - - json = response.json() - assert json["previous_id"] is None - assert json["next_id"] == str(live.id) - response = await client.get(f"/v1/catalogs/{draft.id}") json = response.json() - assert json["previous_id"] is None - assert json["next_id"] is None assert json["products"] == [ {"external_id": "pro", "product_id": str(product_second.id)} ] - - @pytest.mark.auth - @pytest.mark.usefixtures("config_as_code_enabled", "user_organization") - async def test_tied_published_at( - self, - client: AsyncClient, - save_fixture: SaveFixture, - organization: Organization, - ) -> None: - now = utc_now() - first = Catalog( - organization=organization, - status=CatalogStatus.archived, - published_at=now, - created_at=now - timedelta(seconds=1), - ) - second = Catalog( - organization=organization, - status=CatalogStatus.live, - published_at=now, - created_at=now, - ) - await save_fixture(first) - await save_fixture(second) - - response = await client.get(f"/v1/catalogs/{first.id}") - - json = response.json() - assert json["previous_id"] is None - assert json["next_id"] == str(second.id) - - response = await client.get(f"/v1/catalogs/{second.id}") - - json = response.json() - assert json["previous_id"] == str(first.id) - assert json["next_id"] is None From 1c94e901c629b0a8c7b575338d8ae7caa909121d Mon Sep 17 00:00:00 2001 From: Villiam Strandh Date: Wed, 28 Oct 2026 12:15:31 +0100 Subject: [PATCH 5/7] refactor --- clients/packages/client/src/v1.ts | 10 +++++++ server/polar/catalog/endpoints.py | 5 ++-- server/polar/catalog/repository.py | 12 -------- server/polar/catalog/schemas.py | 3 ++ server/polar/catalog/service.py | 28 ++++++------------- server/polar/models/catalog.py | 15 +++++++++- server/tests/catalog/test_endpoints.py | 2 ++ .../tests/declarative_config/test_service.py | 2 ++ 8 files changed, 42 insertions(+), 35 deletions(-) diff --git a/clients/packages/client/src/v1.ts b/clients/packages/client/src/v1.ts index 84fe40d3353..5c871f49a92 100644 --- a/clients/packages/client/src/v1.ts +++ b/clients/packages/client/src/v1.ts @@ -13804,6 +13804,11 @@ export interface components { * @description Name given when it was published. Defaults to `v{n}`, n being its position in the organization's catalog history. */ name: string | null + /** + * Parent Id + * @description The catalog version this one was published on top of. + */ + parent_id: string | null /** @description `live` for the catalog new checkouts use, `archived` for previous versions, `draft` for unpublished changes. */ status: components['schemas']['CatalogStatus'] /** @@ -13888,6 +13893,11 @@ export interface components { * @description Optional name given when it was published. */ name: string | null + /** + * Parent Id + * @description The catalog version this one was published on top of. + */ + parent_id: string | null /** * Version Hash * @description Hash identifying this version of the catalog. diff --git a/server/polar/catalog/endpoints.py b/server/polar/catalog/endpoints.py index bcd31185111..026822cf946 100644 --- a/server/polar/catalog/endpoints.py +++ b/server/polar/catalog/endpoints.py @@ -4,6 +4,7 @@ from polar.exceptions import ResourceNotFound from polar.kit.pagination import ListResource, PaginationParamsQuery from polar.kit.schemas import MultipleQueryFilter +from polar.models import Catalog from polar.models.catalog import CatalogStatus from polar.openapi import APITag from polar.organization.schemas import OrganizationID @@ -81,9 +82,9 @@ async def get( id: UUID4, auth_subject: auth.CatalogRead, session: AsyncReadSession = Depends(get_db_read_session), -) -> CatalogVersion: +) -> Catalog: """Get a catalog version and the products in it.""" catalog = await catalog_service.get(session, auth_subject, id) if catalog is None: raise ResourceNotFound() - return await catalog_service.get_version(session, catalog) + return catalog diff --git a/server/polar/catalog/repository.py b/server/polar/catalog/repository.py index 33602fe3047..ca921eb72bc 100644 --- a/server/polar/catalog/repository.py +++ b/server/polar/catalog/repository.py @@ -74,15 +74,3 @@ async def get_all_by_catalog(self, catalog_id: UUID) -> Sequence[CatalogProduct] .options(joinedload(CatalogProduct.product)) ) return await self.get_all(statement) - - async def get_product_ids_by_catalog(self, catalog_id: UUID) -> dict[str, UUID]: - statement = ( - select(CatalogProduct.external_id, CatalogProduct.product_id) - .where( - CatalogProduct.catalog_id == catalog_id, - CatalogProduct.deleted_at.is_(None), - ) - .order_by(CatalogProduct.external_id) - ) - result = await self.session.execute(statement) - return dict(result.tuples().all()) diff --git a/server/polar/catalog/schemas.py b/server/polar/catalog/schemas.py index 0446f42a7da..c47d8f7dc4d 100644 --- a/server/polar/catalog/schemas.py +++ b/server/polar/catalog/schemas.py @@ -17,6 +17,9 @@ class Catalog(IDSchema, TimestampedSchema): "position in the organization's catalog history." ) ) + parent_id: UUID4 | None = Field( + description="The catalog version this one was published on top of." + ) status: CatalogStatus = Field( description=( "`live` for the catalog new checkouts use, `archived` for previous " diff --git a/server/polar/catalog/service.py b/server/polar/catalog/service.py index be60dc996ab..58c06fefa5e 100644 --- a/server/polar/catalog/service.py +++ b/server/polar/catalog/service.py @@ -3,6 +3,8 @@ from collections.abc import Mapping, Sequence from datetime import datetime +from sqlalchemy.orm import selectinload + from polar.auth.models import Agent, AuthSubject, User from polar.auth.permission import OrganizationPermission from polar.authz.service import get_accessible_org_ids @@ -23,8 +25,6 @@ from polar.product.repository import ProductRepository from .repository import CatalogProductRepository, CatalogRepository -from .schemas import Catalog as CatalogSchema -from .schemas import CatalogVersion def compute_version_hash(parent_hash: str | None, entries: Mapping[str, str]) -> str: @@ -74,25 +74,12 @@ async def get( ) -> Catalog | None: org_ids = await self._get_readable_org_ids(session, auth_subject) repository = CatalogRepository.from_session(session) - statement = repository.get_statement_by_org_ids(org_ids).where(Catalog.id == id) - return await repository.get_one_or_none(statement) - - async def get_version( - self, session: AsyncReadSession, catalog: Catalog - ) -> CatalogVersion: - product_ids = await CatalogProductRepository.from_session( - session - ).get_product_ids_by_catalog(catalog.id) - - return CatalogVersion.model_validate( - { - **CatalogSchema.model_validate(catalog).model_dump(), - "products": [ - {"external_id": external_id, "product_id": product_id} - for external_id, product_id in product_ids.items() - ], - } + statement = ( + repository.get_statement_by_org_ids(org_ids) + .where(Catalog.id == id) + .options(selectinload(Catalog.products)) ) + return await repository.get_one_or_none(statement) async def _get_readable_org_ids( self, @@ -244,6 +231,7 @@ async def publish( name=name if name is not None else await self.get_default_name(session, organization), + parent=live, version_hash=version_hash, status=CatalogStatus.live, published_at=utc_now(), diff --git a/server/polar/models/catalog.py b/server/polar/models/catalog.py index 35aeea09034..fef3188bb44 100644 --- a/server/polar/models/catalog.py +++ b/server/polar/models/catalog.py @@ -18,7 +18,7 @@ from polar.kit.extensions.sqlalchemy.types import StringEnum if TYPE_CHECKING: - from polar.models import Organization + from polar.models import CatalogProduct, Organization class CatalogStatus(StrEnum): @@ -67,3 +67,16 @@ def organization(cls) -> Mapped["Organization"]: @declared_attr def parent(cls) -> Mapped["Catalog | None"]: return relationship("Catalog", remote_side="Catalog.id", lazy="raise") + + @declared_attr + def products(cls) -> Mapped[list["CatalogProduct"]]: + return relationship( + "CatalogProduct", + lazy="raise", + primaryjoin=( + "and_(CatalogProduct.catalog_id == Catalog.id, " + "CatalogProduct.deleted_at.is_(None))" + ), + order_by="CatalogProduct.external_id", + viewonly=True, + ) diff --git a/server/tests/catalog/test_endpoints.py b/server/tests/catalog/test_endpoints.py index 15eb3d73426..4a64f17d793 100644 --- a/server/tests/catalog/test_endpoints.py +++ b/server/tests/catalog/test_endpoints.py @@ -30,6 +30,7 @@ async def catalogs( live = Catalog( organization=organization, name="Spring pricing", + parent=archived, version_hash="b" * 64, status=CatalogStatus.live, published_at=now, @@ -208,6 +209,7 @@ async def test_valid( assert response.status_code == 200 json = response.json() assert json["id"] == str(live.id) + assert json["parent_id"] == str(archived.id) assert json["products"] == [ {"external_id": "lifetime", "product_id": str(product_one_time.id)}, {"external_id": "pro", "product_id": str(product_second.id)}, diff --git a/server/tests/declarative_config/test_service.py b/server/tests/declarative_config/test_service.py index ccd8f6d7668..d4f946b29d4 100644 --- a/server/tests/declarative_config/test_service.py +++ b/server/tests/declarative_config/test_service.py @@ -2852,6 +2852,8 @@ async def test_lifecycle( live = await catalogs_repo.get_live(organization.id) assert live is not None assert live.id != first.id + assert first.parent_id is None + assert live.parent_id == first.id assert live.version_hash not in (None, first.version_hash) assert new_product.catalog_id == live.id assert updated.catalog == ConfigCatalogChange( From f9824ae7dd27bd98f5162bc95a7b6433ab9360fc Mon Sep 17 00:00:00 2001 From: Villiam Strandh Date: Wed, 28 Oct 2026 16:00:36 +0100 Subject: [PATCH 6/7] chore(client): regenerate v1.ts --- clients/packages/client/src/v1.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/clients/packages/client/src/v1.ts b/clients/packages/client/src/v1.ts index 5c871f49a92..3eb1e48f5d6 100644 --- a/clients/packages/client/src/v1.ts +++ b/clients/packages/client/src/v1.ts @@ -13890,7 +13890,7 @@ export interface components { organization_id: string /** * Name - * @description Optional name given when it was published. + * @description Name given when it was published. Defaults to `v{n}`, n being its position in the organization's catalog history. */ name: string | null /** @@ -13898,11 +13898,6 @@ export interface components { * @description The catalog version this one was published on top of. */ parent_id: string | null - /** - * Version Hash - * @description Hash identifying this version of the catalog. - */ - version_hash: string | null /** @description `live` for the catalog new checkouts use, `archived` for previous versions, `draft` for unpublished changes. */ status: components['schemas']['CatalogStatus'] /** From 82a2d3c58e3db69ed9d5d1f66e21bf6949672ff8 Mon Sep 17 00:00:00 2001 From: Villiam Strandh Date: Thu, 29 Oct 2026 09:45:51 +0100 Subject: [PATCH 7/7] rename products to products_members --- server/polar/catalog/schemas.py | 3 ++- server/polar/catalog/service.py | 2 +- server/polar/models/catalog.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/server/polar/catalog/schemas.py b/server/polar/catalog/schemas.py index c4d73d2ccc7..74aa31a372e 100644 --- a/server/polar/catalog/schemas.py +++ b/server/polar/catalog/schemas.py @@ -40,5 +40,6 @@ class CatalogVersion(Catalog): """A catalog version with its products.""" products: list[CatalogProduct] = Field( - description="The products in this version, ordered by key." + validation_alias="product_members", + description="The products in this version, ordered by key.", ) diff --git a/server/polar/catalog/service.py b/server/polar/catalog/service.py index 18ede45ab1a..608c4e157f9 100644 --- a/server/polar/catalog/service.py +++ b/server/polar/catalog/service.py @@ -77,7 +77,7 @@ async def get( statement = ( repository.get_statement_by_org_ids(org_ids) .where(Catalog.id == id) - .options(selectinload(Catalog.products)) + .options(selectinload(Catalog.product_members)) ) return await repository.get_one_or_none(statement) diff --git a/server/polar/models/catalog.py b/server/polar/models/catalog.py index fef3188bb44..3212db3472f 100644 --- a/server/polar/models/catalog.py +++ b/server/polar/models/catalog.py @@ -69,7 +69,7 @@ def parent(cls) -> Mapped["Catalog | None"]: return relationship("Catalog", remote_side="Catalog.id", lazy="raise") @declared_attr - def products(cls) -> Mapped[list["CatalogProduct"]]: + def product_members(cls) -> Mapped[list["CatalogProduct"]]: return relationship( "CatalogProduct", lazy="raise",