From 0e439b4ca7bff6533d26c239df3dca0ceba05832 Mon Sep 17 00:00:00 2001 From: Piotr Oleszczyk Date: Wed, 4 Mar 2026 22:46:16 +0100 Subject: [PATCH] feat(backend): move product pricing to async persisted jobs --- ..._async_pricing_jobs_and_snapshot_fields.py | 85 +++++++++ backend/innercontext/api/products.py | 161 +++++++++++++----- backend/innercontext/models/__init__.py | 2 + backend/innercontext/models/pricing.py | 33 ++++ backend/innercontext/models/product.py | 5 + backend/innercontext/services/pricing_jobs.py | 93 ++++++++++ backend/innercontext/workers/pricing.py | 18 ++ backend/main.py | 10 +- backend/tests/test_products_pricing.py | 38 ++++- deploy.sh | 6 +- docs/DEPLOYMENT.md | 16 +- frontend/src/lib/api.ts | 15 ++ frontend/src/lib/types.ts | 13 ++ frontend/src/routes/products/+page.server.ts | 4 +- frontend/src/routes/products/+page.svelte | 12 +- .../src/routes/routines/[id]/+page.server.ts | 4 +- .../routes/routines/suggest/+page.server.ts | 4 +- systemd/innercontext-pricing-worker.service | 16 ++ 18 files changed, 468 insertions(+), 67 deletions(-) create mode 100644 backend/alembic/versions/f1a2b3c4d5e6_add_async_pricing_jobs_and_snapshot_fields.py create mode 100644 backend/innercontext/models/pricing.py create mode 100644 backend/innercontext/services/pricing_jobs.py create mode 100644 backend/innercontext/workers/pricing.py create mode 100644 systemd/innercontext-pricing-worker.service diff --git a/backend/alembic/versions/f1a2b3c4d5e6_add_async_pricing_jobs_and_snapshot_fields.py b/backend/alembic/versions/f1a2b3c4d5e6_add_async_pricing_jobs_and_snapshot_fields.py new file mode 100644 index 0000000..942fae0 --- /dev/null +++ b/backend/alembic/versions/f1a2b3c4d5e6_add_async_pricing_jobs_and_snapshot_fields.py @@ -0,0 +1,85 @@ +"""add_async_pricing_jobs_and_snapshot_fields + +Revision ID: f1a2b3c4d5e6 +Revises: 7c91e4b2af38 +Create Date: 2026-03-04 00:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + +from alembic import op + +revision: str = "f1a2b3c4d5e6" +down_revision: Union[str, None] = "7c91e4b2af38" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "products", + sa.Column( + "price_tier", + sa.Enum("BUDGET", "MID", "PREMIUM", "LUXURY", name="pricetier"), + nullable=True, + ), + ) + op.add_column("products", sa.Column("price_per_use_pln", sa.Float(), nullable=True)) + op.add_column( + "products", sa.Column("price_tier_source", sa.String(length=32), nullable=True) + ) + op.add_column( + "products", sa.Column("pricing_computed_at", sa.DateTime(), nullable=True) + ) + op.create_index( + op.f("ix_products_price_tier"), "products", ["price_tier"], unique=False + ) + + op.create_table( + "pricing_recalc_jobs", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("scope", sqlmodel.sql.sqltypes.AutoString(length=32), nullable=False), + sa.Column( + "status", sqlmodel.sql.sqltypes.AutoString(length=16), nullable=False + ), + sa.Column("attempts", sa.Integer(), nullable=False), + sa.Column("error", sqlmodel.sql.sqltypes.AutoString(length=512), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("started_at", sa.DateTime(), nullable=True), + sa.Column("finished_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_pricing_recalc_jobs_scope"), + "pricing_recalc_jobs", + ["scope"], + unique=False, + ) + op.create_index( + op.f("ix_pricing_recalc_jobs_status"), + "pricing_recalc_jobs", + ["status"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index( + op.f("ix_pricing_recalc_jobs_status"), table_name="pricing_recalc_jobs" + ) + op.drop_index( + op.f("ix_pricing_recalc_jobs_scope"), table_name="pricing_recalc_jobs" + ) + op.drop_table("pricing_recalc_jobs") + + op.drop_index(op.f("ix_products_price_tier"), table_name="products") + op.drop_column("products", "pricing_computed_at") + op.drop_column("products", "price_tier_source") + op.drop_column("products", "price_per_use_pln") + op.drop_column("products", "price_tier") + op.execute("DROP TYPE IF EXISTS pricetier") diff --git a/backend/innercontext/api/products.py b/backend/innercontext/api/products.py index 36eef02..c6dde0a 100644 --- a/backend/innercontext/api/products.py +++ b/backend/innercontext/api/products.py @@ -7,7 +7,8 @@ from fastapi import APIRouter, Depends, HTTPException, Query from google.genai import types as genai_types from pydantic import BaseModel as PydanticBase from pydantic import ValidationError -from sqlmodel import Session, SQLModel, col, select +from sqlalchemy import inspect, select as sa_select +from sqlmodel import Field, Session, SQLModel, col, select from db import get_session from innercontext.api.utils import get_or_404 @@ -18,6 +19,7 @@ from innercontext.llm import ( get_extraction_config, ) from innercontext.services.fx import convert_to_pln +from innercontext.services.pricing_jobs import enqueue_pricing_recalc from innercontext.models import ( Product, ProductBase, @@ -43,6 +45,10 @@ from innercontext.models.product import ( router = APIRouter() +PricingSource = Literal["category", "fallback", "insufficient_data"] +PricingOutput = tuple[PriceTier | None, float | None, PricingSource | None] +PricingOutputs = dict[UUID, PricingOutput] + # --------------------------------------------------------------------------- # Request / response schemas @@ -150,6 +156,19 @@ class ProductParseResponse(SQLModel): needle_length_mm: Optional[float] = None +class ProductListItem(SQLModel): + id: UUID + name: str + brand: str + category: ProductCategory + recommended_time: DayTime + targets: list[SkinConcern] = Field(default_factory=list) + is_owned: bool + price_tier: PriceTier | None = None + price_per_use_pln: float | None = None + price_tier_source: PricingSource | None = None + + class AIActiveIngredient(ActiveIngredient): # Gemini API rejects int-enum values in response_schema; override with plain int. strength_level: Optional[int] = None # type: ignore[assignment] @@ -317,14 +336,7 @@ def _thresholds(values: list[float]) -> tuple[float, float, float]: def _compute_pricing_outputs( products: list[Product], -) -> dict[ - UUID, - tuple[ - PriceTier | None, - float | None, - Literal["category", "fallback", "insufficient_data"] | None, - ], -]: +) -> PricingOutputs: price_per_use_by_id: dict[UUID, float] = {} grouped: dict[ProductCategory, list[tuple[UUID, float]]] = {} @@ -335,14 +347,7 @@ def _compute_pricing_outputs( price_per_use_by_id[product.id] = ppu grouped.setdefault(product.category, []).append((product.id, ppu)) - outputs: dict[ - UUID, - tuple[ - PriceTier | None, - float | None, - Literal["category", "fallback", "insufficient_data"] | None, - ], - ] = { + outputs: PricingOutputs = { p.id: ( None, price_per_use_by_id.get(p.id), @@ -385,21 +390,6 @@ def _compute_pricing_outputs( return outputs -def _with_pricing( - view: ProductPublic, - pricing: tuple[ - PriceTier | None, - float | None, - Literal["category", "fallback", "insufficient_data"] | None, - ], -) -> ProductPublic: - price_tier, price_per_use_pln, price_tier_source = pricing - view.price_tier = price_tier - view.price_per_use_pln = price_per_use_pln - view.price_tier_source = price_tier_source - return view - - # --------------------------------------------------------------------------- # Product routes # --------------------------------------------------------------------------- @@ -424,7 +414,7 @@ def list_products( if is_tool is not None: stmt = stmt.where(Product.is_tool == is_tool) - products = session.exec(stmt).all() + products = list(session.exec(stmt).all()) # Filter by targets (JSON column — done in Python) if targets: @@ -454,12 +444,8 @@ def list_products( inv_by_product.setdefault(inv.product_id, []).append(inv) results = [] - pricing_pool = list(session.exec(select(Product)).all()) if products else [] - pricing_outputs = _compute_pricing_outputs(pricing_pool) - for p in products: r = ProductWithInventory.model_validate(p, from_attributes=True) - _with_pricing(r, pricing_outputs.get(p.id, (None, None, None))) r.inventory = inv_by_product.get(p.id, []) results.append(r) return results @@ -476,6 +462,7 @@ def create_product(data: ProductCreate, session: Session = Depends(get_session)) **payload, ) session.add(product) + enqueue_pricing_recalc(session) session.commit() session.refresh(product) return product @@ -631,17 +618,104 @@ def parse_product_text(data: ProductParseRequest) -> ProductParseResponse: raise HTTPException(status_code=422, detail=e.errors()) +@router.get("/summary", response_model=list[ProductListItem]) +def list_products_summary( + category: Optional[ProductCategory] = None, + brand: Optional[str] = None, + targets: Optional[list[SkinConcern]] = Query(default=None), + is_medication: Optional[bool] = None, + is_tool: Optional[bool] = None, + session: Session = Depends(get_session), +): + product_table = inspect(Product).local_table + stmt = sa_select( + product_table.c.id, + product_table.c.name, + product_table.c.brand, + product_table.c.category, + product_table.c.recommended_time, + product_table.c.targets, + product_table.c.price_tier, + product_table.c.price_per_use_pln, + product_table.c.price_tier_source, + ) + if category is not None: + stmt = stmt.where(product_table.c.category == category) + if brand is not None: + stmt = stmt.where(product_table.c.brand == brand) + if is_medication is not None: + stmt = stmt.where(product_table.c.is_medication == is_medication) + if is_tool is not None: + stmt = stmt.where(product_table.c.is_tool == is_tool) + + rows = list(session.execute(stmt).all()) + + if targets: + target_values = {t.value for t in targets} + rows = [ + row + for row in rows + if any( + (t.value if hasattr(t, "value") else t) in target_values + for t in (row[5] or []) + ) + ] + + product_ids = [row[0] for row in rows] + inventory_rows = ( + session.exec( + select(ProductInventory).where( + col(ProductInventory.product_id).in_(product_ids) + ) + ).all() + if product_ids + else [] + ) + owned_ids = { + inv.product_id + for inv in inventory_rows + if inv.product_id is not None and inv.finished_at is None + } + + results: list[ProductListItem] = [] + for row in rows: + ( + product_id, + name, + brand_value, + category_value, + recommended_time, + row_targets, + price_tier, + price_per_use_pln, + price_tier_source, + ) = row + results.append( + ProductListItem( + id=product_id, + name=name, + brand=brand_value, + category=category_value, + recommended_time=recommended_time, + targets=row_targets or [], + is_owned=product_id in owned_ids, + price_tier=price_tier, + price_per_use_pln=price_per_use_pln, + price_tier_source=price_tier_source, + ) + ) + + return results + + @router.get("/{product_id}", response_model=ProductWithInventory) def get_product(product_id: UUID, session: Session = Depends(get_session)): product = get_or_404(session, Product, product_id) - pricing_pool = list(session.exec(select(Product)).all()) - pricing_outputs = _compute_pricing_outputs(pricing_pool) inventory = session.exec( select(ProductInventory).where(ProductInventory.product_id == product_id) ).all() result = ProductWithInventory.model_validate(product, from_attributes=True) - _with_pricing(result, pricing_outputs.get(product.id, (None, None, None))) result.inventory = list(inventory) return result @@ -658,18 +732,17 @@ def update_product( for key, value in patch_data.items(): setattr(product, key, value) session.add(product) + enqueue_pricing_recalc(session) session.commit() session.refresh(product) - pricing_pool = list(session.exec(select(Product)).all()) - pricing_outputs = _compute_pricing_outputs(pricing_pool) - result = ProductPublic.model_validate(product, from_attributes=True) - return _with_pricing(result, pricing_outputs.get(product.id, (None, None, None))) + return ProductPublic.model_validate(product, from_attributes=True) @router.delete("/{product_id}", status_code=204) def delete_product(product_id: UUID, session: Session = Depends(get_session)): product = get_or_404(session, Product, product_id) session.delete(product) + enqueue_pricing_recalc(session) session.commit() diff --git a/backend/innercontext/models/__init__.py b/backend/innercontext/models/__init__.py index 73939cc..141045e 100644 --- a/backend/innercontext/models/__init__.py +++ b/backend/innercontext/models/__init__.py @@ -32,6 +32,7 @@ from .product import ( ProductPublic, ProductWithInventory, ) +from .pricing import PricingRecalcJob from .routine import GroomingSchedule, Routine, RoutineStep from .skincare import ( SkinConditionSnapshot, @@ -77,6 +78,7 @@ __all__ = [ "ProductInventory", "ProductPublic", "ProductWithInventory", + "PricingRecalcJob", # routine "GroomingSchedule", "Routine", diff --git a/backend/innercontext/models/pricing.py b/backend/innercontext/models/pricing.py new file mode 100644 index 0000000..14e7022 --- /dev/null +++ b/backend/innercontext/models/pricing.py @@ -0,0 +1,33 @@ +from datetime import datetime +from typing import ClassVar +from uuid import UUID, uuid4 + +from sqlalchemy import Column, DateTime +from sqlmodel import Field, SQLModel + +from .base import utc_now +from .domain import Domain + + +class PricingRecalcJob(SQLModel, table=True): + __tablename__ = "pricing_recalc_jobs" + __domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.SKINCARE}) + + id: UUID = Field(default_factory=uuid4, primary_key=True) + scope: str = Field(default="global", max_length=32, index=True) + status: str = Field(default="pending", max_length=16, index=True) + attempts: int = Field(default=0, ge=0) + error: str | None = Field(default=None, max_length=512) + + created_at: datetime = Field(default_factory=utc_now, nullable=False) + started_at: datetime | None = Field(default=None) + finished_at: datetime | None = Field(default=None) + updated_at: datetime = Field( + default_factory=utc_now, + sa_column=Column( + DateTime(timezone=True), + default=utc_now, + onupdate=utc_now, + nullable=False, + ), + ) diff --git a/backend/innercontext/models/product.py b/backend/innercontext/models/product.py index afd74c7..ac74054 100644 --- a/backend/innercontext/models/product.py +++ b/backend/innercontext/models/product.py @@ -174,6 +174,11 @@ class Product(ProductBase, table=True): default=None, sa_column=Column(JSON, nullable=True) ) + price_tier: PriceTier | None = Field(default=None, index=True) + price_per_use_pln: float | None = Field(default=None) + price_tier_source: str | None = Field(default=None, max_length=32) + pricing_computed_at: datetime | None = Field(default=None) + created_at: datetime = Field(default_factory=utc_now, nullable=False) updated_at: datetime = Field( default_factory=utc_now, diff --git a/backend/innercontext/services/pricing_jobs.py b/backend/innercontext/services/pricing_jobs.py new file mode 100644 index 0000000..9e9c9dd --- /dev/null +++ b/backend/innercontext/services/pricing_jobs.py @@ -0,0 +1,93 @@ +from datetime import datetime + +from sqlmodel import Session, col, select + +from innercontext.models import PricingRecalcJob, Product +from innercontext.models.base import utc_now + + +def enqueue_pricing_recalc( + session: Session, *, scope: str = "global" +) -> PricingRecalcJob: + existing = session.exec( + select(PricingRecalcJob) + .where(PricingRecalcJob.scope == scope) + .where(col(PricingRecalcJob.status).in_(["pending", "running"])) + .order_by(col(PricingRecalcJob.created_at).asc()) + ).first() + if existing is not None: + return existing + + job = PricingRecalcJob(scope=scope, status="pending") + session.add(job) + return job + + +def claim_next_pending_pricing_job(session: Session) -> PricingRecalcJob | None: + stmt = ( + select(PricingRecalcJob) + .where(PricingRecalcJob.status == "pending") + .order_by(col(PricingRecalcJob.created_at).asc()) + ) + bind = session.get_bind() + if bind is not None and bind.dialect.name == "postgresql": + stmt = stmt.with_for_update(skip_locked=True) + + job = session.exec(stmt).first() + if job is None: + return None + + job.status = "running" + job.attempts += 1 + job.started_at = utc_now() + job.finished_at = None + job.error = None + session.add(job) + session.commit() + session.refresh(job) + return job + + +def _apply_pricing_snapshot(session: Session, computed_at: datetime) -> int: + from innercontext.api.products import _compute_pricing_outputs + + products = list(session.exec(select(Product)).all()) + pricing_outputs = _compute_pricing_outputs(products) + + for product in products: + tier, price_per_use_pln, tier_source = pricing_outputs.get( + product.id, (None, None, None) + ) + product.price_tier = tier + product.price_per_use_pln = price_per_use_pln + product.price_tier_source = tier_source + product.pricing_computed_at = computed_at + + return len(products) + + +def process_pricing_job(session: Session, job: PricingRecalcJob) -> int: + try: + updated_count = _apply_pricing_snapshot(session, computed_at=utc_now()) + job.status = "succeeded" + job.finished_at = utc_now() + job.error = None + session.add(job) + session.commit() + return updated_count + except Exception as exc: + session.rollback() + job.status = "failed" + job.finished_at = utc_now() + job.error = str(exc)[:512] + session.add(job) + session.commit() + raise + + +def process_one_pending_pricing_job(session: Session) -> bool: + job = claim_next_pending_pricing_job(session) + if job is None: + return False + process_pricing_job(session, job) + return True diff --git a/backend/innercontext/workers/pricing.py b/backend/innercontext/workers/pricing.py new file mode 100644 index 0000000..f7e29f2 --- /dev/null +++ b/backend/innercontext/workers/pricing.py @@ -0,0 +1,18 @@ +import time + +from sqlmodel import Session + +from db import engine +from innercontext.services.pricing_jobs import process_one_pending_pricing_job + + +def run_forever(poll_interval_seconds: float = 2.0) -> None: + while True: + with Session(engine) as session: + processed = process_one_pending_pricing_job(session) + if not processed: + time.sleep(poll_interval_seconds) + + +if __name__ == "__main__": + run_forever() diff --git a/backend/main.py b/backend/main.py index d2a529d..57e48d4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -7,8 +7,9 @@ load_dotenv() # load .env before db.py reads DATABASE_URL from fastapi import FastAPI # noqa: E402 from fastapi.middleware.cors import CORSMiddleware # noqa: E402 +from sqlmodel import Session # noqa: E402 -from db import create_db_and_tables # noqa: E402 +from db import create_db_and_tables, engine # noqa: E402 from innercontext.api import ( # noqa: E402 ai_logs, health, @@ -17,11 +18,18 @@ from innercontext.api import ( # noqa: E402 routines, skincare, ) +from innercontext.services.pricing_jobs import enqueue_pricing_recalc # noqa: E402 @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: create_db_and_tables() + try: + with Session(engine) as session: + enqueue_pricing_recalc(session) + session.commit() + except Exception as exc: # pragma: no cover + print(f"[startup] failed to enqueue pricing recalculation job: {exc}") yield diff --git a/backend/tests/test_products_pricing.py b/backend/tests/test_products_pricing.py index 1abdeca..c8284fc 100644 --- a/backend/tests/test_products_pricing.py +++ b/backend/tests/test_products_pricing.py @@ -1,8 +1,10 @@ import uuid from innercontext.api import products as products_api -from innercontext.models import Product +from innercontext.models import PricingRecalcJob, Product from innercontext.models.enums import DayTime, ProductCategory +from innercontext.services.pricing_jobs import process_one_pending_pricing_job +from sqlmodel import select def _product( @@ -45,7 +47,7 @@ def test_compute_pricing_outputs_groups_by_category(monkeypatch): assert cleanser_tiers[-1] == "luxury" -def test_price_tier_is_null_when_not_enough_products(client, monkeypatch): +def test_price_tier_is_null_when_not_enough_products(client, session, monkeypatch): monkeypatch.setattr(products_api, "convert_to_pln", lambda amount, currency: amount) base = { @@ -67,13 +69,15 @@ def test_price_tier_is_null_when_not_enough_products(client, monkeypatch): ) assert response.status_code == 201 + assert process_one_pending_pricing_job(session) + products = client.get("/products").json() assert len(products) == 7 assert all(p["price_tier"] is None for p in products) assert all(p["price_per_use_pln"] is not None for p in products) -def test_price_tier_is_computed_on_list(client, monkeypatch): +def test_price_tier_is_computed_by_worker(client, session, monkeypatch): monkeypatch.setattr(products_api, "convert_to_pln", lambda amount, currency: amount) base = { @@ -91,13 +95,15 @@ def test_price_tier_is_computed_on_list(client, monkeypatch): ) assert response.status_code == 201 + assert process_one_pending_pricing_job(session) + products = client.get("/products").json() assert len(products) == 8 assert any(p["price_tier"] == "budget" for p in products) assert any(p["price_tier"] == "luxury" for p in products) -def test_price_tier_uses_fallback_for_medium_categories(client, monkeypatch): +def test_price_tier_uses_fallback_for_medium_categories(client, session, monkeypatch): monkeypatch.setattr(products_api, "convert_to_pln", lambda amount, currency: amount) serum_base = { @@ -130,6 +136,8 @@ def test_price_tier_uses_fallback_for_medium_categories(client, monkeypatch): ) assert response.status_code == 201 + assert process_one_pending_pricing_job(session) + products = client.get("/products?category=toner").json() assert len(products) == 5 assert all(p["price_tier"] is not None for p in products) @@ -137,7 +145,7 @@ def test_price_tier_uses_fallback_for_medium_categories(client, monkeypatch): def test_price_tier_stays_null_for_tiny_categories_even_with_fallback_pool( - client, monkeypatch + client, session, monkeypatch ): monkeypatch.setattr(products_api, "convert_to_pln", lambda amount, currency: amount) @@ -171,7 +179,27 @@ def test_price_tier_stays_null_for_tiny_categories_even_with_fallback_pool( ) assert response.status_code == 201 + assert process_one_pending_pricing_job(session) + oils = client.get("/products?category=oil").json() assert len(oils) == 3 assert all(p["price_tier"] is None for p in oils) assert all(p["price_tier_source"] == "insufficient_data" for p in oils) + + +def test_product_write_enqueues_pricing_job(client, session): + response = client.post( + "/products", + json={ + "name": "Serum X", + "brand": "B", + "category": "serum", + "recommended_time": "both", + "leave_on": True, + }, + ) + assert response.status_code == 201 + + jobs = session.exec(select(PricingRecalcJob)).all() + assert len(jobs) == 1 + assert jobs[0].status in {"pending", "running", "succeeded"} diff --git a/deploy.sh b/deploy.sh index d60cc43..46119fb 100755 --- a/deploy.sh +++ b/deploy.sh @@ -9,7 +9,7 @@ # # The innercontext user needs passwordless sudo for systemctl only: # /etc/sudoers.d/innercontext-deploy: -# innercontext ALL=(root) NOPASSWD: /usr/bin/systemctl restart innercontext, /usr/bin/systemctl restart innercontext-node, /usr/bin/systemctl is-active innercontext, /usr/bin/systemctl is-active innercontext-node +# innercontext ALL=(root) NOPASSWD: /usr/bin/systemctl restart innercontext, /usr/bin/systemctl restart innercontext-node, /usr/bin/systemctl restart innercontext-pricing-worker, /usr/bin/systemctl is-active innercontext, /usr/bin/systemctl is-active innercontext-node, /usr/bin/systemctl is-active innercontext-pricing-worker set -euo pipefail SERVER="${DEPLOY_SERVER:-innercontext}" # ssh host alias or user@host @@ -45,8 +45,8 @@ deploy_backend() { echo "==> [backend] Syncing dependencies..." ssh "$SERVER" "cd $REMOTE/backend && uv sync --frozen --no-dev --no-editable" - echo "==> [backend] Restarting service (alembic runs on start)..." - ssh "$SERVER" "sudo systemctl restart innercontext && echo OK" + echo "==> [backend] Restarting services (alembic runs on API start)..." + ssh "$SERVER" "sudo systemctl restart innercontext && sudo systemctl restart innercontext-pricing-worker && echo OK" } # ── Dispatch ─────────────────────────────────────────────────────────────── diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 5f88f0d..9f14878 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -222,17 +222,20 @@ chown innercontext:innercontext /opt/innercontext/frontend/.env.production cat > /etc/sudoers.d/innercontext-deploy << 'EOF' innercontext ALL=(root) NOPASSWD: \ /usr/bin/systemctl restart innercontext, \ - /usr/bin/systemctl restart innercontext-node + /usr/bin/systemctl restart innercontext-node, \ + /usr/bin/systemctl restart innercontext-pricing-worker EOF chmod 440 /etc/sudoers.d/innercontext-deploy ``` -### Install systemd service +### Install systemd services ```bash cp /opt/innercontext/systemd/innercontext-node.service /etc/systemd/system/ +cp /opt/innercontext/systemd/innercontext-pricing-worker.service /etc/systemd/system/ systemctl daemon-reload systemctl enable innercontext-node +systemctl enable --now innercontext-pricing-worker # Do NOT start yet — build/ is empty until the first deploy.sh run ``` @@ -310,6 +313,7 @@ This will: 4. Upload `backend/` source to the server 5. Run `uv sync --frozen` on the server 6. Restart `innercontext` (runs alembic migrations on start) +7. Restart `innercontext-pricing-worker` --- @@ -347,6 +351,14 @@ journalctl -u innercontext -n 50 # Check .env DATABASE_URL is correct and PG LXC accepts connections ``` +### Product prices stay empty / stale + +```bash +systemctl status innercontext-pricing-worker +journalctl -u innercontext-pricing-worker -n 50 +# Ensure worker is running and can connect to PostgreSQL +``` + ### 502 Bad Gateway on `/` ```bash diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 43f5db4..1651605 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -9,6 +9,7 @@ import type { MedicationUsage, PartOfDay, Product, + ProductSummary, ProductContext, ProductEffectProfile, ProductInventory, @@ -70,6 +71,20 @@ export function getProducts( return api.get(`/products${qs ? `?${qs}` : ""}`); } +export function getProductSummaries( + params: ProductListParams = {}, +): Promise { + const q = new URLSearchParams(); + if (params.category) q.set("category", params.category); + if (params.brand) q.set("brand", params.brand); + if (params.targets) params.targets.forEach((t) => q.append("targets", t)); + if (params.is_medication != null) + q.set("is_medication", String(params.is_medication)); + if (params.is_tool != null) q.set("is_tool", String(params.is_tool)); + const qs = q.toString(); + return api.get(`/products/summary${qs ? `?${qs}` : ""}`); +} + export const getProduct = (id: string): Promise => api.get(`/products/${id}`); export const createProduct = ( diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index a414675..9cbdd82 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -183,6 +183,19 @@ export interface Product { inventory: ProductInventory[]; } +export interface ProductSummary { + id: string; + name: string; + brand: string; + category: ProductCategory; + recommended_time: DayTime; + targets: SkinConcern[]; + is_owned: boolean; + price_tier?: PriceTier; + price_per_use_pln?: number; + price_tier_source?: PriceTierSource; +} + // ─── Routine types ─────────────────────────────────────────────────────────── export interface RoutineStep { diff --git a/frontend/src/routes/products/+page.server.ts b/frontend/src/routes/products/+page.server.ts index 5248dcf..db61dec 100644 --- a/frontend/src/routes/products/+page.server.ts +++ b/frontend/src/routes/products/+page.server.ts @@ -1,7 +1,7 @@ -import { getProducts } from '$lib/api'; +import { getProductSummaries } from '$lib/api'; import type { PageServerLoad } from './$types'; export const load: PageServerLoad = async () => { - const products = await getProducts(); + const products = await getProductSummaries(); return { products }; }; diff --git a/frontend/src/routes/products/+page.svelte b/frontend/src/routes/products/+page.svelte index 2069bff..20562bf 100644 --- a/frontend/src/routes/products/+page.svelte +++ b/frontend/src/routes/products/+page.svelte @@ -1,6 +1,6 @@