feat(backend): move product pricing to async persisted jobs

This commit is contained in:
Piotr Oleszczyk 2026-03-04 22:46:16 +01:00
parent c869f88db2
commit 0e439b4ca7
18 changed files with 468 additions and 67 deletions

View file

@ -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")

View file

@ -7,7 +7,8 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from google.genai import types as genai_types from google.genai import types as genai_types
from pydantic import BaseModel as PydanticBase from pydantic import BaseModel as PydanticBase
from pydantic import ValidationError 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 db import get_session
from innercontext.api.utils import get_or_404 from innercontext.api.utils import get_or_404
@ -18,6 +19,7 @@ from innercontext.llm import (
get_extraction_config, get_extraction_config,
) )
from innercontext.services.fx import convert_to_pln from innercontext.services.fx import convert_to_pln
from innercontext.services.pricing_jobs import enqueue_pricing_recalc
from innercontext.models import ( from innercontext.models import (
Product, Product,
ProductBase, ProductBase,
@ -43,6 +45,10 @@ from innercontext.models.product import (
router = APIRouter() router = APIRouter()
PricingSource = Literal["category", "fallback", "insufficient_data"]
PricingOutput = tuple[PriceTier | None, float | None, PricingSource | None]
PricingOutputs = dict[UUID, PricingOutput]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Request / response schemas # Request / response schemas
@ -150,6 +156,19 @@ class ProductParseResponse(SQLModel):
needle_length_mm: Optional[float] = None 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): class AIActiveIngredient(ActiveIngredient):
# Gemini API rejects int-enum values in response_schema; override with plain int. # Gemini API rejects int-enum values in response_schema; override with plain int.
strength_level: Optional[int] = None # type: ignore[assignment] 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( def _compute_pricing_outputs(
products: list[Product], products: list[Product],
) -> dict[ ) -> PricingOutputs:
UUID,
tuple[
PriceTier | None,
float | None,
Literal["category", "fallback", "insufficient_data"] | None,
],
]:
price_per_use_by_id: dict[UUID, float] = {} price_per_use_by_id: dict[UUID, float] = {}
grouped: dict[ProductCategory, list[tuple[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 price_per_use_by_id[product.id] = ppu
grouped.setdefault(product.category, []).append((product.id, ppu)) grouped.setdefault(product.category, []).append((product.id, ppu))
outputs: dict[ outputs: PricingOutputs = {
UUID,
tuple[
PriceTier | None,
float | None,
Literal["category", "fallback", "insufficient_data"] | None,
],
] = {
p.id: ( p.id: (
None, None,
price_per_use_by_id.get(p.id), price_per_use_by_id.get(p.id),
@ -385,21 +390,6 @@ def _compute_pricing_outputs(
return 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 # Product routes
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -424,7 +414,7 @@ def list_products(
if is_tool is not None: if is_tool is not None:
stmt = stmt.where(Product.is_tool == is_tool) 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) # Filter by targets (JSON column — done in Python)
if targets: if targets:
@ -454,12 +444,8 @@ def list_products(
inv_by_product.setdefault(inv.product_id, []).append(inv) inv_by_product.setdefault(inv.product_id, []).append(inv)
results = [] results = []
pricing_pool = list(session.exec(select(Product)).all()) if products else []
pricing_outputs = _compute_pricing_outputs(pricing_pool)
for p in products: for p in products:
r = ProductWithInventory.model_validate(p, from_attributes=True) 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, []) r.inventory = inv_by_product.get(p.id, [])
results.append(r) results.append(r)
return results return results
@ -476,6 +462,7 @@ def create_product(data: ProductCreate, session: Session = Depends(get_session))
**payload, **payload,
) )
session.add(product) session.add(product)
enqueue_pricing_recalc(session)
session.commit() session.commit()
session.refresh(product) session.refresh(product)
return product return product
@ -631,17 +618,104 @@ def parse_product_text(data: ProductParseRequest) -> ProductParseResponse:
raise HTTPException(status_code=422, detail=e.errors()) 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) @router.get("/{product_id}", response_model=ProductWithInventory)
def get_product(product_id: UUID, session: Session = Depends(get_session)): def get_product(product_id: UUID, session: Session = Depends(get_session)):
product = get_or_404(session, Product, product_id) 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( inventory = session.exec(
select(ProductInventory).where(ProductInventory.product_id == product_id) select(ProductInventory).where(ProductInventory.product_id == product_id)
).all() ).all()
result = ProductWithInventory.model_validate(product, from_attributes=True) result = ProductWithInventory.model_validate(product, from_attributes=True)
_with_pricing(result, pricing_outputs.get(product.id, (None, None, None)))
result.inventory = list(inventory) result.inventory = list(inventory)
return result return result
@ -658,18 +732,17 @@ def update_product(
for key, value in patch_data.items(): for key, value in patch_data.items():
setattr(product, key, value) setattr(product, key, value)
session.add(product) session.add(product)
enqueue_pricing_recalc(session)
session.commit() session.commit()
session.refresh(product) session.refresh(product)
pricing_pool = list(session.exec(select(Product)).all()) return ProductPublic.model_validate(product, from_attributes=True)
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)))
@router.delete("/{product_id}", status_code=204) @router.delete("/{product_id}", status_code=204)
def delete_product(product_id: UUID, session: Session = Depends(get_session)): def delete_product(product_id: UUID, session: Session = Depends(get_session)):
product = get_or_404(session, Product, product_id) product = get_or_404(session, Product, product_id)
session.delete(product) session.delete(product)
enqueue_pricing_recalc(session)
session.commit() session.commit()

View file

@ -32,6 +32,7 @@ from .product import (
ProductPublic, ProductPublic,
ProductWithInventory, ProductWithInventory,
) )
from .pricing import PricingRecalcJob
from .routine import GroomingSchedule, Routine, RoutineStep from .routine import GroomingSchedule, Routine, RoutineStep
from .skincare import ( from .skincare import (
SkinConditionSnapshot, SkinConditionSnapshot,
@ -77,6 +78,7 @@ __all__ = [
"ProductInventory", "ProductInventory",
"ProductPublic", "ProductPublic",
"ProductWithInventory", "ProductWithInventory",
"PricingRecalcJob",
# routine # routine
"GroomingSchedule", "GroomingSchedule",
"Routine", "Routine",

View file

@ -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,
),
)

View file

@ -174,6 +174,11 @@ class Product(ProductBase, table=True):
default=None, sa_column=Column(JSON, nullable=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) created_at: datetime = Field(default_factory=utc_now, nullable=False)
updated_at: datetime = Field( updated_at: datetime = Field(
default_factory=utc_now, default_factory=utc_now,

View file

@ -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

View file

@ -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()

View file

@ -7,8 +7,9 @@ load_dotenv() # load .env before db.py reads DATABASE_URL
from fastapi import FastAPI # noqa: E402 from fastapi import FastAPI # noqa: E402
from fastapi.middleware.cors import CORSMiddleware # 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 from innercontext.api import ( # noqa: E402
ai_logs, ai_logs,
health, health,
@ -17,11 +18,18 @@ from innercontext.api import ( # noqa: E402
routines, routines,
skincare, skincare,
) )
from innercontext.services.pricing_jobs import enqueue_pricing_recalc # noqa: E402
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]: async def lifespan(app: FastAPI) -> AsyncIterator[None]:
create_db_and_tables() 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 yield

View file

@ -1,8 +1,10 @@
import uuid import uuid
from innercontext.api import products as products_api 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.models.enums import DayTime, ProductCategory
from innercontext.services.pricing_jobs import process_one_pending_pricing_job
from sqlmodel import select
def _product( def _product(
@ -45,7 +47,7 @@ def test_compute_pricing_outputs_groups_by_category(monkeypatch):
assert cleanser_tiers[-1] == "luxury" 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) monkeypatch.setattr(products_api, "convert_to_pln", lambda amount, currency: amount)
base = { base = {
@ -67,13 +69,15 @@ def test_price_tier_is_null_when_not_enough_products(client, monkeypatch):
) )
assert response.status_code == 201 assert response.status_code == 201
assert process_one_pending_pricing_job(session)
products = client.get("/products").json() products = client.get("/products").json()
assert len(products) == 7 assert len(products) == 7
assert all(p["price_tier"] is None for p in products) 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) 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) monkeypatch.setattr(products_api, "convert_to_pln", lambda amount, currency: amount)
base = { base = {
@ -91,13 +95,15 @@ def test_price_tier_is_computed_on_list(client, monkeypatch):
) )
assert response.status_code == 201 assert response.status_code == 201
assert process_one_pending_pricing_job(session)
products = client.get("/products").json() products = client.get("/products").json()
assert len(products) == 8 assert len(products) == 8
assert any(p["price_tier"] == "budget" for p in products) assert any(p["price_tier"] == "budget" for p in products)
assert any(p["price_tier"] == "luxury" 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) monkeypatch.setattr(products_api, "convert_to_pln", lambda amount, currency: amount)
serum_base = { serum_base = {
@ -130,6 +136,8 @@ def test_price_tier_uses_fallback_for_medium_categories(client, monkeypatch):
) )
assert response.status_code == 201 assert response.status_code == 201
assert process_one_pending_pricing_job(session)
products = client.get("/products?category=toner").json() products = client.get("/products?category=toner").json()
assert len(products) == 5 assert len(products) == 5
assert all(p["price_tier"] is not None for p in products) 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( 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) 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 response.status_code == 201
assert process_one_pending_pricing_job(session)
oils = client.get("/products?category=oil").json() oils = client.get("/products?category=oil").json()
assert len(oils) == 3 assert len(oils) == 3
assert all(p["price_tier"] is None for p in oils) assert all(p["price_tier"] is None for p in oils)
assert all(p["price_tier_source"] == "insufficient_data" 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"}

View file

@ -9,7 +9,7 @@
# #
# The innercontext user needs passwordless sudo for systemctl only: # The innercontext user needs passwordless sudo for systemctl only:
# /etc/sudoers.d/innercontext-deploy: # /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 set -euo pipefail
SERVER="${DEPLOY_SERVER:-innercontext}" # ssh host alias or user@host SERVER="${DEPLOY_SERVER:-innercontext}" # ssh host alias or user@host
@ -45,8 +45,8 @@ deploy_backend() {
echo "==> [backend] Syncing dependencies..." echo "==> [backend] Syncing dependencies..."
ssh "$SERVER" "cd $REMOTE/backend && uv sync --frozen --no-dev --no-editable" ssh "$SERVER" "cd $REMOTE/backend && uv sync --frozen --no-dev --no-editable"
echo "==> [backend] Restarting service (alembic runs on start)..." echo "==> [backend] Restarting services (alembic runs on API start)..."
ssh "$SERVER" "sudo systemctl restart innercontext && echo OK" ssh "$SERVER" "sudo systemctl restart innercontext && sudo systemctl restart innercontext-pricing-worker && echo OK"
} }
# ── Dispatch ─────────────────────────────────────────────────────────────── # ── Dispatch ───────────────────────────────────────────────────────────────

View file

@ -222,17 +222,20 @@ chown innercontext:innercontext /opt/innercontext/frontend/.env.production
cat > /etc/sudoers.d/innercontext-deploy << 'EOF' cat > /etc/sudoers.d/innercontext-deploy << 'EOF'
innercontext ALL=(root) NOPASSWD: \ innercontext ALL=(root) NOPASSWD: \
/usr/bin/systemctl restart innercontext, \ /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 EOF
chmod 440 /etc/sudoers.d/innercontext-deploy chmod 440 /etc/sudoers.d/innercontext-deploy
``` ```
### Install systemd service ### Install systemd services
```bash ```bash
cp /opt/innercontext/systemd/innercontext-node.service /etc/systemd/system/ 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 daemon-reload
systemctl enable innercontext-node systemctl enable innercontext-node
systemctl enable --now innercontext-pricing-worker
# Do NOT start yet — build/ is empty until the first deploy.sh run # 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 4. Upload `backend/` source to the server
5. Run `uv sync --frozen` on the server 5. Run `uv sync --frozen` on the server
6. Restart `innercontext` (runs alembic migrations on start) 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 # 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 `/` ### 502 Bad Gateway on `/`
```bash ```bash

View file

@ -9,6 +9,7 @@ import type {
MedicationUsage, MedicationUsage,
PartOfDay, PartOfDay,
Product, Product,
ProductSummary,
ProductContext, ProductContext,
ProductEffectProfile, ProductEffectProfile,
ProductInventory, ProductInventory,
@ -70,6 +71,20 @@ export function getProducts(
return api.get(`/products${qs ? `?${qs}` : ""}`); return api.get(`/products${qs ? `?${qs}` : ""}`);
} }
export function getProductSummaries(
params: ProductListParams = {},
): Promise<ProductSummary[]> {
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<Product> => export const getProduct = (id: string): Promise<Product> =>
api.get(`/products/${id}`); api.get(`/products/${id}`);
export const createProduct = ( export const createProduct = (

View file

@ -183,6 +183,19 @@ export interface Product {
inventory: ProductInventory[]; 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 ─────────────────────────────────────────────────────────── // ─── Routine types ───────────────────────────────────────────────────────────
export interface RoutineStep { export interface RoutineStep {

View file

@ -1,7 +1,7 @@
import { getProducts } from '$lib/api'; import { getProductSummaries } from '$lib/api';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => { export const load: PageServerLoad = async () => {
const products = await getProducts(); const products = await getProductSummaries();
return { products }; return { products };
}; };

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from './$types'; import type { PageData } from './$types';
import type { Product } from '$lib/types'; import type { ProductSummary } from '$lib/types';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { SvelteMap } from 'svelte/reactivity'; import { SvelteMap } from 'svelte/reactivity';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
@ -31,8 +31,8 @@
'spf', 'mask', 'exfoliant', 'hair_treatment', 'tool', 'spot_treatment', 'oil' 'spf', 'mask', 'exfoliant', 'hair_treatment', 'tool', 'spot_treatment', 'oil'
]; ];
function isOwned(p: Product): boolean { function isOwned(p: ProductSummary): boolean {
return p.inventory?.some(inv => !inv.finished_at) ?? false; return p.is_owned;
} }
function setSort(nextKey: SortKey): void { function setSort(nextKey: SortKey): void {
@ -90,7 +90,7 @@
return sortDirection === 'asc' ? cmp : -cmp; return sortDirection === 'asc' ? cmp : -cmp;
}); });
const map = new SvelteMap<string, Product[]>(); const map = new SvelteMap<string, ProductSummary[]>();
for (const p of items) { for (const p of items) {
if (!map.has(p.category)) map.set(p.category, []); if (!map.has(p.category)) map.set(p.category, []);
map.get(p.category)!.push(p); map.get(p.category)!.push(p);
@ -121,8 +121,8 @@
return value; return value;
} }
function getPricePerUse(product: Product): number | undefined { function getPricePerUse(product: ProductSummary): number | undefined {
return (product as Product & { price_per_use_pln?: number }).price_per_use_pln; return product.price_per_use_pln;
} }
function formatCategory(value: string): string { function formatCategory(value: string): string {

View file

@ -1,10 +1,10 @@
import { addRoutineStep, deleteRoutine, deleteRoutineStep, getProducts, getRoutine } from '$lib/api'; import { addRoutineStep, deleteRoutine, deleteRoutineStep, getProductSummaries, getRoutine } from '$lib/api';
import { error, fail, redirect } from '@sveltejs/kit'; import { error, fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => { export const load: PageServerLoad = async ({ params }) => {
try { try {
const [routine, products] = await Promise.all([getRoutine(params.id), getProducts()]); const [routine, products] = await Promise.all([getRoutine(params.id), getProductSummaries()]);
return { routine, products }; return { routine, products };
} catch { } catch {
error(404, 'Routine not found'); error(404, 'Routine not found');

View file

@ -1,9 +1,9 @@
import { addRoutineStep, createRoutine, getProducts, suggestBatch, suggestRoutine } from '$lib/api'; import { addRoutineStep, createRoutine, getProductSummaries, suggestBatch, suggestRoutine } from '$lib/api';
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async () => { export const load: PageServerLoad = async () => {
const products = await getProducts(); const products = await getProductSummaries();
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
return { products, today }; return { products, today };
}; };

View file

@ -0,0 +1,16 @@
[Unit]
Description=innercontext async pricing worker
After=network.target
[Service]
Type=simple
User=innercontext
Group=innercontext
WorkingDirectory=/opt/innercontext/backend
EnvironmentFile=/opt/innercontext/backend/.env
ExecStart=/opt/innercontext/backend/.venv/bin/python -m innercontext.workers.pricing
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target