diff --git a/backend/innercontext/api/products.py b/backend/innercontext/api/products.py index a3bef80..801d21f 100644 --- a/backend/innercontext/api/products.py +++ b/backend/innercontext/api/products.py @@ -188,7 +188,7 @@ def get_or_404(session: Session, model, record_id) -> object: # --------------------------------------------------------------------------- -@router.get("/", response_model=list[Product]) +@router.get("", response_model=list[Product]) def list_products( category: Optional[ProductCategory] = None, brand: Optional[str] = None, @@ -224,7 +224,7 @@ def list_products( return products -@router.post("/", response_model=Product, status_code=201) +@router.post("", response_model=Product, status_code=201) def create_product(data: ProductCreate, session: Session = Depends(get_session)): product = Product( id=uuid4(), @@ -236,9 +236,13 @@ def create_product(data: ProductCreate, session: Session = Depends(get_session)) return product -@router.get("/{product_id}", response_model=Product) +@router.get("/{product_id}") def get_product(product_id: UUID, session: Session = Depends(get_session)): - return get_or_404(session, Product, product_id) + product = get_or_404(session, Product, product_id) + inventory = session.exec(select(ProductInventory).where(ProductInventory.product_id == product_id)).all() + data = product.model_dump(mode="json") + data["inventory"] = [item.model_dump(mode="json") for item in inventory] + return data @router.patch("/{product_id}", response_model=Product) diff --git a/backend/innercontext/api/routines.py b/backend/innercontext/api/routines.py index ac2c81b..51bc70a 100644 --- a/backend/innercontext/api/routines.py +++ b/backend/innercontext/api/routines.py @@ -76,7 +76,7 @@ def get_or_404(session: Session, model, record_id) -> object: # --------------------------------------------------------------------------- -@router.get("/", response_model=list[Routine]) +@router.get("", response_model=list[Routine]) def list_routines( from_date: Optional[date] = None, to_date: Optional[date] = None, @@ -93,7 +93,7 @@ def list_routines( return session.exec(stmt).all() -@router.post("/", response_model=Routine, status_code=201) +@router.post("", response_model=Routine, status_code=201) def create_routine(data: RoutineCreate, session: Session = Depends(get_session)): routine = Routine(id=uuid4(), **data.model_dump()) session.add(routine) @@ -108,9 +108,13 @@ def list_grooming_schedule(session: Session = Depends(get_session)): return session.exec(select(GroomingSchedule)).all() -@router.get("/{routine_id}", response_model=Routine) +@router.get("/{routine_id}") def get_routine(routine_id: UUID, session: Session = Depends(get_session)): - return get_or_404(session, Routine, routine_id) + routine = get_or_404(session, Routine, routine_id) + steps = session.exec(select(RoutineStep).where(RoutineStep.routine_id == routine_id)).all() + data = routine.model_dump(mode="json") + data["steps"] = [step.model_dump(mode="json") for step in steps] + return data @router.patch("/{routine_id}", response_model=Routine) diff --git a/backend/innercontext/api/skincare.py b/backend/innercontext/api/skincare.py index 25de98f..35b5bda 100644 --- a/backend/innercontext/api/skincare.py +++ b/backend/innercontext/api/skincare.py @@ -78,7 +78,7 @@ def get_or_404(session: Session, model, record_id) -> object: # --------------------------------------------------------------------------- -@router.get("/", response_model=list[SkinConditionSnapshot]) +@router.get("", response_model=list[SkinConditionSnapshot]) def list_snapshots( from_date: Optional[date] = None, to_date: Optional[date] = None, @@ -95,7 +95,7 @@ def list_snapshots( return session.exec(stmt).all() -@router.post("/", response_model=SkinConditionSnapshot, status_code=201) +@router.post("", response_model=SkinConditionSnapshot, status_code=201) def create_snapshot( data: SnapshotCreate, session: Session = Depends(get_session) ): diff --git a/backend/innercontext/models/product.py b/backend/innercontext/models/product.py index 618ad82..6905bfa 100644 --- a/backend/innercontext/models/product.py +++ b/backend/innercontext/models/product.py @@ -2,7 +2,7 @@ from datetime import date, datetime from typing import ClassVar, Optional from uuid import UUID, uuid4 -from pydantic import model_validator +from pydantic import field_validator, model_validator from sqlalchemy import JSON, Column, DateTime from sqlmodel import Field, Relationship, SQLModel @@ -188,6 +188,13 @@ class Product(SQLModel, table=True): inventory: list["ProductInventory"] = Relationship(back_populates="product") + @field_validator("product_effect_profile", mode="before") + @classmethod + def coerce_effect_profile(cls, v: object) -> object: + if isinstance(v, dict): + return ProductEffectProfile(**v) + return v + @model_validator(mode="after") def validate_business_rules(self) -> "Product": if ( diff --git a/backend/main.py b/backend/main.py index 1a21b95..0bd80c4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -17,7 +17,7 @@ async def lifespan(app: FastAPI): yield -app = FastAPI(title="innercontext API", lifespan=lifespan) +app = FastAPI(title="innercontext API", lifespan=lifespan, redirect_slashes=False) app.add_middleware( CORSMiddleware, @@ -30,7 +30,7 @@ app.include_router(products.router, prefix="/products", tags=["products"]) app.include_router(inventory.router, prefix="/inventory", tags=["inventory"]) app.include_router(health.router, prefix="/health", tags=["health"]) app.include_router(routines.router, prefix="/routines", tags=["routines"]) -app.include_router(skincare.router, prefix="/skin-snapshots", tags=["skincare"]) +app.include_router(skincare.router, prefix="/skincare", tags=["skincare"]) @app.get("/health-check") diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index b16a8cb..c2a5721 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -61,7 +61,7 @@ def product_data(): @pytest.fixture() def created_product(client, product_data): - r = client.post("/products/", json=product_data) + r = client.post("/products", json=product_data) assert r.status_code == 201 return r.json() @@ -81,7 +81,7 @@ def created_medication(client, medication_data): @pytest.fixture() def created_routine(client): r = client.post( - "/routines/", json={"routine_date": "2026-02-26", "part_of_day": "am"} + "/routines", json={"routine_date": "2026-02-26", "part_of_day": "am"} ) assert r.status_code == 201 return r.json() diff --git a/backend/tests/test_products.py b/backend/tests/test_products.py index 03c046b..098df1b 100644 --- a/backend/tests/test_products.py +++ b/backend/tests/test_products.py @@ -2,7 +2,7 @@ import uuid def test_create_minimal(client, product_data): - r = client.post("/products/", json=product_data) + r = client.post("/products", json=product_data) assert r.status_code == 201 data = r.json() assert "id" in data @@ -15,7 +15,7 @@ def test_create_with_actives(client, product_data): product_data["actives"] = [ {"name": "Niacinamide", "percent": 10.0, "functions": ["niacinamide"]} ] - r = client.post("/products/", json=product_data) + r = client.post("/products", json=product_data) assert r.status_code == 201 data = r.json() assert len(data["actives"]) == 1 @@ -25,24 +25,24 @@ def test_create_with_actives(client, product_data): def test_create_invalid_enum(client, product_data): product_data["category"] = "not_a_category" - r = client.post("/products/", json=product_data) + r = client.post("/products", json=product_data) assert r.status_code == 422 def test_create_missing_required(client, product_data): del product_data["name"] - r = client.post("/products/", json=product_data) + r = client.post("/products", json=product_data) assert r.status_code == 422 def test_list_empty(client): - r = client.get("/products/") + r = client.get("/products") assert r.status_code == 200 assert r.json() == [] def test_list_returns_created(client, created_product): - r = client.get("/products/") + r = client.get("/products") assert r.status_code == 200 ids = [p["id"] for p in r.json()] assert created_product["id"] in ids @@ -56,12 +56,12 @@ def test_list_filter_category(client, client_and_data=None): "recommended_time": "both", "leave_on": True, } - r1 = client.post("/products/", json={**base, "name": "Moist", "category": "moisturizer"}) - r2 = client.post("/products/", json={**base, "name": "Ser", "category": "serum"}) + r1 = client.post("/products", json={**base, "name": "Moist", "category": "moisturizer"}) + r2 = client.post("/products", json={**base, "name": "Ser", "category": "serum"}) assert r1.status_code == 201 assert r2.status_code == 201 - r = client.get("/products/?category=moisturizer") + r = client.get("/products?category=moisturizer") assert r.status_code == 200 data = r.json() assert all(p["category"] == "moisturizer" for p in data) @@ -77,10 +77,10 @@ def test_list_filter_brand(client): "leave_on": True, "category": "serum", } - client.post("/products/", json={**base, "name": "A1", "brand": "BrandA"}) - client.post("/products/", json={**base, "name": "B1", "brand": "BrandB"}) + client.post("/products", json={**base, "name": "A1", "brand": "BrandA"}) + client.post("/products", json={**base, "name": "B1", "brand": "BrandB"}) - r = client.get("/products/?brand=BrandA") + r = client.get("/products?brand=BrandA") assert r.status_code == 200 data = r.json() assert all(p["brand"] == "BrandA" for p in data) @@ -95,14 +95,14 @@ def test_list_filter_is_medication(client): "leave_on": True, "category": "serum", } - client.post("/products/", json={**base, "name": "Normal", "is_medication": False}) + client.post("/products", json={**base, "name": "Normal", "is_medication": False}) # is_medication=True requires usage_notes (model validator) client.post( - "/products/", + "/products", json={**base, "name": "Med", "is_medication": True, "usage_notes": "Apply pea-sized amount"}, ) - r = client.get("/products/?is_medication=true") + r = client.get("/products?is_medication=true") assert r.status_code == 200 data = r.json() assert all(p["is_medication"] is True for p in data) @@ -118,10 +118,10 @@ def test_list_filter_targets(client): "leave_on": True, "category": "serum", } - client.post("/products/", json={**base, "name": "Acne", "targets": ["acne"]}) - client.post("/products/", json={**base, "name": "Aging", "targets": ["aging"]}) + client.post("/products", json={**base, "name": "Acne", "targets": ["acne"]}) + client.post("/products", json={**base, "name": "Aging", "targets": ["aging"]}) - r = client.get("/products/?targets=acne") + r = client.get("/products?targets=acne") assert r.status_code == 200 data = r.json() assert len(data) == 1 diff --git a/backend/tests/test_routines.py b/backend/tests/test_routines.py index 898c656..0df2565 100644 --- a/backend/tests/test_routines.py +++ b/backend/tests/test_routines.py @@ -8,7 +8,7 @@ import uuid def test_create_routine_minimal(client): r = client.post( - "/routines/", json={"routine_date": "2026-02-26", "part_of_day": "am"} + "/routines", json={"routine_date": "2026-02-26", "part_of_day": "am"} ) assert r.status_code == 201 data = r.json() @@ -19,21 +19,21 @@ def test_create_routine_minimal(client): def test_create_routine_invalid_part_of_day(client): r = client.post( - "/routines/", json={"routine_date": "2026-02-26", "part_of_day": "noon"} + "/routines", json={"routine_date": "2026-02-26", "part_of_day": "noon"} ) assert r.status_code == 422 def test_list_routines_empty(client): - r = client.get("/routines/") + r = client.get("/routines") assert r.status_code == 200 assert r.json() == [] def test_list_filter_date_range(client): - client.post("/routines/", json={"routine_date": "2026-01-01", "part_of_day": "am"}) - client.post("/routines/", json={"routine_date": "2026-06-01", "part_of_day": "am"}) - r = client.get("/routines/?from_date=2026-05-01&to_date=2026-12-31") + client.post("/routines", json={"routine_date": "2026-01-01", "part_of_day": "am"}) + client.post("/routines", json={"routine_date": "2026-06-01", "part_of_day": "am"}) + r = client.get("/routines?from_date=2026-05-01&to_date=2026-12-31") assert r.status_code == 200 data = r.json() assert len(data) == 1 @@ -41,9 +41,9 @@ def test_list_filter_date_range(client): def test_list_filter_part_of_day(client): - client.post("/routines/", json={"routine_date": "2026-02-01", "part_of_day": "am"}) - client.post("/routines/", json={"routine_date": "2026-02-02", "part_of_day": "pm"}) - r = client.get("/routines/?part_of_day=pm") + client.post("/routines", json={"routine_date": "2026-02-01", "part_of_day": "am"}) + client.post("/routines", json={"routine_date": "2026-02-02", "part_of_day": "pm"}) + r = client.get("/routines?part_of_day=pm") assert r.status_code == 200 data = r.json() assert len(data) == 1 diff --git a/backend/tests/test_skincare.py b/backend/tests/test_skincare.py index d68b22d..2d922b9 100644 --- a/backend/tests/test_skincare.py +++ b/backend/tests/test_skincare.py @@ -2,7 +2,7 @@ import uuid def test_create_snapshot_minimal(client): - r = client.post("/skin-snapshots/", json={"snapshot_date": "2026-02-26"}) + r = client.post("/skincare", json={"snapshot_date": "2026-02-26"}) assert r.status_code == 201 data = r.json() assert "id" in data @@ -12,7 +12,7 @@ def test_create_snapshot_minimal(client): def test_create_snapshot_full(client): r = client.post( - "/skin-snapshots/", + "/skincare", json={ "snapshot_date": "2026-02-20", "overall_state": "good", @@ -39,22 +39,22 @@ def test_create_snapshot_full(client): def test_create_snapshot_invalid_state(client): r = client.post( - "/skin-snapshots/", + "/skincare", json={"snapshot_date": "2026-02-26", "overall_state": "stellar"}, ) assert r.status_code == 422 def test_list_snapshots_empty(client): - r = client.get("/skin-snapshots/") + r = client.get("/skincare") assert r.status_code == 200 assert r.json() == [] def test_list_filter_date_range(client): - client.post("/skin-snapshots/", json={"snapshot_date": "2026-01-01"}) - client.post("/skin-snapshots/", json={"snapshot_date": "2026-06-01"}) - r = client.get("/skin-snapshots/?from_date=2026-05-01&to_date=2026-12-31") + client.post("/skincare", json={"snapshot_date": "2026-01-01"}) + client.post("/skincare", json={"snapshot_date": "2026-06-01"}) + r = client.get("/skincare?from_date=2026-05-01&to_date=2026-12-31") assert r.status_code == 200 data = r.json() assert len(data) == 1 @@ -63,14 +63,14 @@ def test_list_filter_date_range(client): def test_list_filter_overall_state(client): client.post( - "/skin-snapshots/", + "/skincare", json={"snapshot_date": "2026-02-10", "overall_state": "good"}, ) client.post( - "/skin-snapshots/", + "/skincare", json={"snapshot_date": "2026-02-11", "overall_state": "poor"}, ) - r = client.get("/skin-snapshots/?overall_state=poor") + r = client.get("/skincare?overall_state=poor") assert r.status_code == 200 data = r.json() assert len(data) == 1 @@ -78,31 +78,31 @@ def test_list_filter_overall_state(client): def test_get_snapshot(client): - r = client.post("/skin-snapshots/", json={"snapshot_date": "2026-02-26"}) + r = client.post("/skincare", json={"snapshot_date": "2026-02-26"}) sid = r.json()["id"] - r2 = client.get(f"/skin-snapshots/{sid}") + r2 = client.get(f"/skincare/{sid}") assert r2.status_code == 200 assert r2.json()["id"] == sid def test_get_snapshot_not_found(client): - r = client.get(f"/skin-snapshots/{uuid.uuid4()}") + r = client.get(f"/skincare/{uuid.uuid4()}") assert r.status_code == 404 def test_update_snapshot_state(client): - r = client.post("/skin-snapshots/", json={"snapshot_date": "2026-02-26"}) + r = client.post("/skincare", json={"snapshot_date": "2026-02-26"}) sid = r.json()["id"] - r2 = client.patch(f"/skin-snapshots/{sid}", json={"overall_state": "excellent"}) + r2 = client.patch(f"/skincare/{sid}", json={"overall_state": "excellent"}) assert r2.status_code == 200 assert r2.json()["overall_state"] == "excellent" def test_update_snapshot_concerns(client): - r = client.post("/skin-snapshots/", json={"snapshot_date": "2026-02-26"}) + r = client.post("/skincare", json={"snapshot_date": "2026-02-26"}) sid = r.json()["id"] r2 = client.patch( - f"/skin-snapshots/{sid}", + f"/skincare/{sid}", json={"active_concerns": ["dehydration", "redness"]}, ) assert r2.status_code == 200 @@ -113,20 +113,20 @@ def test_update_snapshot_concerns(client): def test_update_snapshot_not_found(client): r = client.patch( - f"/skin-snapshots/{uuid.uuid4()}", json={"overall_state": "good"} + f"/skincare/{uuid.uuid4()}", json={"overall_state": "good"} ) assert r.status_code == 404 def test_delete_snapshot(client): - r = client.post("/skin-snapshots/", json={"snapshot_date": "2026-02-26"}) + r = client.post("/skincare", json={"snapshot_date": "2026-02-26"}) sid = r.json()["id"] - r2 = client.delete(f"/skin-snapshots/{sid}") + r2 = client.delete(f"/skincare/{sid}") assert r2.status_code == 204 - r3 = client.get(f"/skin-snapshots/{sid}") + r3 = client.get(f"/skincare/{sid}") assert r3.status_code == 404 def test_delete_snapshot_not_found(client): - r = client.delete(f"/skin-snapshots/{uuid.uuid4()}") + r = client.delete(f"/skincare/{uuid.uuid4()}") assert r.status_code == 404 diff --git a/frontend/src/routes/products/new/+page.server.ts b/frontend/src/routes/products/new/+page.server.ts index 6727025..96a127d 100644 --- a/frontend/src/routes/products/new/+page.server.ts +++ b/frontend/src/routes/products/new/+page.server.ts @@ -26,8 +26,9 @@ export const actions: Actions = { .map((t) => t.trim()) .filter(Boolean) ?? []; + let product; try { - const product = await createProduct({ + product = await createProduct({ name, brand, category, @@ -36,9 +37,9 @@ export const actions: Actions = { leave_on, targets }); - redirect(303, `/products/${product.id}`); } catch (e) { return fail(500, { error: (e as Error).message }); } + redirect(303, `/products/${product.id}`); } }; diff --git a/frontend/src/routes/routines/new/+page.server.ts b/frontend/src/routes/routines/new/+page.server.ts index b3c033d..b0222f9 100644 --- a/frontend/src/routes/routines/new/+page.server.ts +++ b/frontend/src/routes/routines/new/+page.server.ts @@ -17,11 +17,12 @@ export const actions: Actions = { return fail(400, { error: 'Date and AM/PM are required' }); } + let routine; try { - const routine = await createRoutine({ routine_date, part_of_day, notes: notes || undefined }); - redirect(303, `/routines/${routine.id}`); + routine = await createRoutine({ routine_date, part_of_day, notes: notes || undefined }); } catch (e) { return fail(500, { error: (e as Error).message }); } + redirect(303, `/routines/${routine.id}`); } };