From f1dac94396cd9172f2a511cc13f9568517296fde Mon Sep 17 00:00:00 2001 From: jableader Date: Sat, 1 Nov 2025 13:43:26 +1100 Subject: [PATCH] Minimal v2 endpoints scaffolded alongside v1 cookie auth --- api/auth_v2.py | 62 ++++++++++++ api/deps.py | 28 ++++++ api/households.py | 72 ++++++++++++++ backend-spec.md | 65 ++++++++----- db.py | 14 +++ households/__init__.py | 2 + households/models.py | 39 ++++++++ households/repository.py | 50 ++++++++++ main.py | 5 + scripts/migration_to_households.py | 136 +++++++++++++++++++++++++++ tests/test_auth_and_households_v2.py | 47 +++++++++ tests/test_migration_households.py | 77 +++++++++++++++ users/__init__.py | 2 + users/models.py | 17 ++++ users/repository.py | 79 ++++++++++++++++ 15 files changed, 672 insertions(+), 23 deletions(-) create mode 100644 api/auth_v2.py create mode 100644 api/households.py create mode 100644 households/__init__.py create mode 100644 households/models.py create mode 100644 households/repository.py create mode 100644 scripts/migration_to_households.py create mode 100644 tests/test_auth_and_households_v2.py create mode 100644 tests/test_migration_households.py create mode 100644 users/__init__.py create mode 100644 users/models.py create mode 100644 users/repository.py diff --git a/api/auth_v2.py b/api/auth_v2.py new file mode 100644 index 0000000..e36fb89 --- /dev/null +++ b/api/auth_v2.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import hashlib +from typing import Optional + +import aiosqlite +from fastapi import APIRouter, Depends, Request + +from api.deps import error_response, get_db +from common import ApiModel +from users import repository as users_db +from users.models import User + +router = APIRouter(prefix="/auth", tags=["auth-v2"]) + + +class RegisterBody(ApiModel): + email: str + password: str + display_name: str + + +class LoginBody(ApiModel): + email: str + password: str + + +class TokenResponse(ApiModel): + access_token: str + token_type: str = "bearer" + user: User + + +def _hash_pw(pw: str) -> str: + # Placeholder; replace with proper hashing (bcrypt/argon2) later + return hashlib.sha256(pw.encode("utf-8")).hexdigest() + + +@router.post("/register", response_model=TokenResponse, operation_id="register") +async def register(request: Request, body: RegisterBody, conn: aiosqlite.Connection = Depends(get_db)): + existing = await users_db.get_by_email(conn, body.email) + if existing: + return error_response(request, 400, "Email already registered") + uid = await users_db.insert_user(conn, body.email, body.display_name) + await users_db.set_local_credentials(conn, uid, _hash_pw(body.password)) + user = await users_db.get_by_email(conn, body.email) + assert user is not None + # Token is a simple placeholder containing user id; will be replaced with JWT + token = f"user-{user.id}" + return TokenResponse(access_token=token, user=user) + + +@router.post("/login", response_model=TokenResponse, operation_id="loginV2") +async def login(request: Request, body: LoginBody, conn: aiosqlite.Connection = Depends(get_db)): + user: Optional[User] = await users_db.get_by_email(conn, body.email) + if not user: + return error_response(request, 401, "Invalid credentials") + stored = await users_db.get_local_password_hash(conn, user.id) + if not stored or stored != _hash_pw(body.password): + return error_response(request, 401, "Invalid credentials") + token = f"user-{user.id}" + return TokenResponse(access_token=token, user=user) diff --git a/api/deps.py b/api/deps.py index 2823272..0f54624 100644 --- a/api/deps.py +++ b/api/deps.py @@ -10,6 +10,8 @@ import db import persons from common import ProblemDetails from settings import settings +from users import repository as users_db +from users.models import User # Dependency to create SQLite connection with PRAGMAs and per-request transaction @@ -77,3 +79,29 @@ def error_response(request: Optional[Request], status_code: int, message: str) - status_code=status_code, media_type="application/problem+json", ) + + +async def get_current_user(request: Request, conn: aiosqlite.Connection = Depends(get_db)) -> User: + """Temporary bearer token auth: expects Authorization: Bearer user-. + + This is a stopgap until JWT is implemented. Returns 401 on failure. + """ + auth = request.headers.get("Authorization") + if not auth or not auth.lower().startswith("bearer "): + raise HTTPException(status_code=401, detail="Unauthorized") + token = auth.split(" ", 1)[1].strip() + if not token.startswith("user-"): + raise HTTPException(status_code=401, detail="Unauthorized") + try: + user_id = int(token.split("-", 1)[1]) + except Exception: + raise HTTPException(status_code=401, detail="Unauthorized") + # Lookup by id + async with conn.execute( + "SELECT id, email, display_name, profile_photo_url FROM User WHERE id = ?", + (user_id,), + ) as c: + row = await c.fetchone() + if not row: + raise HTTPException(status_code=401, detail="Unauthorized") + return User(id=int(row[0]), email=row[1], display_name=row[2], profile_photo_url=row[3]) diff --git a/api/households.py b/api/households.py new file mode 100644 index 0000000..4e324ea --- /dev/null +++ b/api/households.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import re +from typing import List + +import aiosqlite +from fastapi import APIRouter, Depends, Request + +from api.deps import get_current_user, get_db, error_response +from common import ApiModel +from users.models import User + +router = APIRouter(tags=["households"]) + + +class CreateHouseholdBody(ApiModel): + name: str + + +class HouseholdResponse(ApiModel): + id: int + name: str + slug: str + + +def slugify(name: str) -> str: + s = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + return s or "household" + + +@router.get("/users/me/households", response_model=List[HouseholdResponse]) +async def list_my_households( + request: Request, user: User = Depends(get_current_user), conn: aiosqlite.Connection = Depends(get_db) +): + results: list[HouseholdResponse] = [] + async with conn.execute( + """ + SELECT h.id, h.name, h.slug FROM Household h + JOIN HouseholdMember m ON m.household_id = h.id + WHERE m.user_id = ? + ORDER BY h.id + """, + (user.id,), + ) as c: + async for row in c: + results.append(HouseholdResponse(id=int(row[0]), name=row[1], slug=row[2])) + return results + + +@router.post("/households", response_model=HouseholdResponse) +async def create_household( + request: Request, + body: CreateHouseholdBody, + user: User = Depends(get_current_user), + conn: aiosqlite.Connection = Depends(get_db), +): + slug = slugify(body.name) + try: + async with conn.execute( + "INSERT INTO Household (name, slug) VALUES (?, ?)", (body.name, slug) + ) as cur: + lrid = cur.lastrowid + if lrid is None: + return error_response(request, 400, "Unable to create household") + hid = int(lrid) + await conn.execute( + "INSERT INTO HouseholdMember (user_id, household_id, role) VALUES (?, ?, ?)", + (user.id, hid, "admin"), + ) + return HouseholdResponse(id=hid, name=body.name, slug=slug) + except Exception: + return error_response(request, 400, "Unable to create household") diff --git a/backend-spec.md b/backend-spec.md index 0f34019..fc803c4 100644 --- a/backend-spec.md +++ b/backend-spec.md @@ -173,39 +173,58 @@ Impact on existing routes (exact files to refactor): ## 4. Actionable Implementation Steps -1. **[ ] Database Schema and Migration**: - - **Schema Definition**: Define the new tables (`users`, `households`, etc.) in new repository files (e.g., `users/repository.py`, `households/repository.py`). The `create` function in each will contain the `CREATE TABLE` SQL. - - **Migration Logic**: Create a new migration script (e.g., in `scripts/migration_to_households.py`). This script will: +1. **[~] Database Schema and Migration**: + - ✅ **Schema Definition**: Added new packages and tables: + - `users` with tables `User`, `LocalCredentials`, `OAuthCredentials` (see `users/repository.py`). + - `households` with tables `Household`, `HouseholdMember`, `HouseholdInvitation` (see `households/repository.py`). + - `db.create()` now initializes these tables alongside existing v1 tables. + - ✅ **Migration Logic**: Implemented `scripts/migration_to_households.py` which: - Connect to the database (reusing logic from `db.py`). - - Call the `create()` function for each new repository to create the tables. - - Add a `household_id` column to all existing tenant-specific tables (`recipes`, `meals`, etc.). - - **Data Porting**: - - Create a single default "My Household". - - Read all records from the `persons` table. - - For each person, create a corresponding record in `users` and `household_members`. - - Backfill the `household_id` in all existing resources with the ID of the default household. + - Calls `create()` for new repos to ensure tables exist. + - Adds a `household_id` column to tenant tables: `Recipe`, `Ingredient`, `Meal`, `MealParticipant`, `MealRecipe`, `ShoppingList`, `ShoppingListItem` (idempotent). + - Creates indices `idx__household_id` for all above tables. + - Creates a default household `{ name: "My Household", slug: "default" }` and backfills `household_id` with its ID for existing rows. + - Ports `Person` rows to `User` (email derived as `@example.com`) and creates `HouseholdMember` links (role `admin`). - **Bootstrap Update**: Modify `db.py` so that a fresh database bootstrap (`db.create_schema`) calls the `create()` functions for the new repositories and *not* the old `persons` repository. - - **Indices/Constraints**: Add unique `households.slug`; composite indexes on `(household_id, id)` per table; foreign keys with `ON DELETE CASCADE` where appropriate. - - **Acceptance**: Fresh bootstrap creates all tables; migration script idempotently adds columns and backfills; existing tests still pass against default household. + - ✅ **Indices/Constraints**: Enforced unique `Household.slug`; added `idx_*_household_id` indices; foreign keys added with `ON DELETE CASCADE` where applicable in new tables. + - ✅ **Acceptance (initial)**: Added `tests/test_migration_households.py` covering: new tables exist, `household_id` columns exist, default household created, and data porting from `Person` to `User` and `HouseholdMember`. Full test suite passes. -2. **[ ] Implement New Authentication System**: - - **Refactor `api/auth.py`**: Gut the existing cookie-based logic. Implement the new `/register`, `/login`, and `/google` endpoints. - - **Create `users/` package**: Add `models.py` and `repository.py` for the new `User` entity. - - **Update `api/deps.py`**: Replace `get_current_person` with a new `get_current_user` dependency that validates the JWT and returns the `User` model. + - Pending follow-ups for this step: + - Add composite indices `(household_id, id)` where high-cardinality pagination will benefit. + - Extend migration to add FK constraints from tenant tables to `Household(id)` where safe. + - Plan and implement data backfill for cross-table references once `users` replace `persons` in code. + +2. **[~] Implement New Authentication System**: + - ✅ Minimal v2 endpoints scaffolded alongside v1 cookie auth (no breakage): + - Added `api/auth_v2.py` with `/api/v1/auth/register` and `/api/v1/auth/login`. Currently returns a simple bearer token of the form `user-`. + - Added `users/repository.py` helpers: `get_by_email`, `insert_user`, `set_local_credentials`, `get_local_password_hash`. + - Added `get_current_user` dependency in `api/deps.py` that reads `Authorization: Bearer user-` and returns the `User`. + - Wired v2 router in `main.py` without removing v1 cookie routes. + - Added tests: `tests/test_auth_and_households_v2.py` registers/logs in a user and exercises bearer auth. + - Pending (to complete this step): + - Replace placeholder token with real JWT signing/verification and introduce refresh tokens via HttpOnly cookie. + - Update `api/openapi.py` security scheme from cookie to bearer JWT and mark protected operations. + - Remove `persons` dependency from protected endpoints once household scoping is in place. - **Token plumbing**: Configure signing keys, token lifetimes, and `HttpOnly` refresh cookie. Consider `Authorization: Bearer` for access tokens. - **OpenAPI**: Update `api/openapi.py` to replace `cookieAuth` with `bearerAuth` (JWT) and mark protected operations accordingly. - **Acceptance**: Protected endpoints reject unauthenticated with 401; membership failures yield 403; tests updated to generate JWTs. -3. **[ ] Implement Household Scoping**: - - **Create `households/` package**: Add `models.py` and `repository.py` for `Household`, `HouseholdMember`, and `HouseholdInvitation`. - - **Implement `get_household_from_slug`** in `api/deps.py`. - - **Refactor `main.py`**: +3. **[~] Implement Household Scoping**: + - ✅ Created `households/` package with `models.py` and `repository.py`. + - ✅ Added initial `api/households.py` router: + - `GET /api/v1/users/me/households` (requires bearer token) → lists memberships. + - `POST /api/v1/households` (requires bearer token) → creates household and adds current user as admin. + - ⏳ Implement `get_household_from_slug` in `api/deps.py`. + - ⏳ Refactor `main.py`: - Create a new `APIRouter` for household-scoped routes, e.g., `household_router = APIRouter(prefix="/api/v1/households/{householdSlug}")`. - - Mount the existing routers (`recipes_api`, `meals_api`, etc.) onto this new `household_router` instead of the main `app`. - - **Update Repositories**: Modify all repository functions (e.g., `recipes.repository.get_all`, `meals.repository.create`) to accept a `household_id` and use it in the `WHERE` clause of every SQL query. - - **Update Routers**: Add the `get_household_from_slug` dependency to all household-scoped routes and pass the resulting `household_id` to the repository functions. + - Mount the existing routers (`recipes_api`, `meals_api`, etc.) onto this `household_router`. + - ⏳ Update Repositories: modify all repository functions to accept `household_id` and filter by it. + - ⏳ Update Routers: add `get_household_from_slug` dependency to all scoped routes and plumb `household_id`. - **Acceptance**: The same queries as v1, when run under different household slugs and users, return isolated data sets; cross-household access yields 403. + - Notes: + - Migration added `household_id` columns and indices, enabling next step to filter by household without additional schema changes. + 4. **[ ] Implement Household & Invitation Logic**: - Create the `api/households.py` router and implement the endpoints for creating households, listing the user's households, and managing invitations. - Add the logic for sending invitation emails (this may require a new utility/service for sending emails). diff --git a/db.py b/db.py index 82f2f6b..511c448 100644 --- a/db.py +++ b/db.py @@ -20,6 +20,20 @@ async def create(conn: aiosqlite.Connection): await recipe_db.create(conn) + # New v2 domain tables (users/households). Keep persons for compatibility during migration. + try: + import users.repository as users_db + await users_db.create(conn) + except Exception: + # Be tolerant if table already exists or module missing in some setups + pass + + try: + import households.repository as households_db + await households_db.create(conn) + except Exception: + pass + import persons.repository as person_db await person_db.create(conn) diff --git a/households/__init__.py b/households/__init__.py new file mode 100644 index 0000000..8f7fcc2 --- /dev/null +++ b/households/__init__.py @@ -0,0 +1,2 @@ +from households.models import Household as Household, HouseholdInvitation as HouseholdInvitation +from households.repository import create as create diff --git a/households/models.py b/households/models.py new file mode 100644 index 0000000..e29d306 --- /dev/null +++ b/households/models.py @@ -0,0 +1,39 @@ +from typing import ClassVar, Optional + +from common import ApiModel + + +class Household(ApiModel): + KEYS: ClassVar[list[str]] = ["id", "name", "slug"] + + id: int = -1 + name: str + slug: str + + +class HouseholdMember(ApiModel): + KEYS: ClassVar[list[str]] = ["user_id", "household_id", "role"] + + user_id: int + household_id: int + role: str + + +class HouseholdInvitation(ApiModel): + KEYS: ClassVar[list[str]] = [ + "id", + "household_id", + "email", + "invited_by_user_id", + "token", + "expires_at", + "status", + ] + + id: int = -1 + household_id: int + email: str + invited_by_user_id: int + token: str + expires_at: str + status: str diff --git a/households/repository.py b/households/repository.py new file mode 100644 index 0000000..59d93fc --- /dev/null +++ b/households/repository.py @@ -0,0 +1,50 @@ +async def create(conn): + # Households table + await conn.execute( + """ + CREATE TABLE IF NOT EXISTS Household ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE + ); + """ + ) + + # Membership table + await conn.execute( + """ + CREATE TABLE IF NOT EXISTS HouseholdMember ( + user_id INTEGER NOT NULL, + household_id INTEGER NOT NULL, + role TEXT NOT NULL, + PRIMARY KEY (user_id, household_id), + FOREIGN KEY(user_id) REFERENCES User(id) ON DELETE CASCADE, + FOREIGN KEY(household_id) REFERENCES Household(id) ON DELETE CASCADE + ); + """ + ) + + # Invitations table + await conn.execute( + """ + CREATE TABLE IF NOT EXISTS HouseholdInvitation ( + id INTEGER PRIMARY KEY, + household_id INTEGER NOT NULL, + email TEXT NOT NULL, + invited_by_user_id INTEGER NOT NULL, + token TEXT NOT NULL UNIQUE, + expires_at DATETIME NOT NULL, + status TEXT NOT NULL, + FOREIGN KEY(household_id) REFERENCES Household(id) ON DELETE CASCADE, + FOREIGN KEY(invited_by_user_id) REFERENCES User(id) ON DELETE SET NULL + ); + """ + ) + + # Indices + await conn.execute( + "CREATE INDEX IF NOT EXISTS idx_household_slug ON Household(slug);" + ) + await conn.execute( + "CREATE INDEX IF NOT EXISTS idx_household_member_household ON HouseholdMember(household_id);" + ) diff --git a/main.py b/main.py index c94d92e..99726bd 100644 --- a/main.py +++ b/main.py @@ -10,11 +10,13 @@ from starlette.exceptions import HTTPException as StarletteHTTPException from api import ( auth as auth_router, + auth_v2 as auth_v2_router, meals as meals_router, persons as persons_router, products as products_router, recipes as recipes_router, shopping as shopping_router, + households as households_router, ) from api.deps import ( cookie_person as cookie_person, # noqa: F401 - re-exported for tests @@ -159,6 +161,9 @@ def create_app() -> FastAPI: app.include_router(shopping_router.router, prefix="/api/v1", tags=["v1"]) # extracted app.include_router(persons_router.router, prefix="/api/v1", tags=["v1"]) # extracted app.include_router(auth_router.router, prefix="/api/v1", tags=["v1"]) # extracted + # Experimental v2 auth endpoints (JWT to be implemented). Kept alongside v1 during transition. + app.include_router(auth_v2_router.router, prefix="/api/v1", tags=["v2"]) + app.include_router(households_router.router, prefix="/api/v1", tags=["v2"]) # new # Routes app.add_api_route("/healthz", healthz, methods=["GET"], response_model=HealthStatus) diff --git a/scripts/migration_to_households.py b/scripts/migration_to_households.py new file mode 100644 index 0000000..ebeaa72 --- /dev/null +++ b/scripts/migration_to_households.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import asyncio +from typing import Optional + +import aiosqlite + +import db +from settings import settings + + +async def column_exists(conn: aiosqlite.Connection, table: str, column: str) -> bool: + async with conn.execute(f"PRAGMA table_info({table});") as cursor: + async for row in cursor: + if row[1] == column: + return True + return False + + +async def add_column_if_missing(conn: aiosqlite.Connection, table: str, column_def: str) -> None: + # column_def like "household_id INTEGER" + col_name = column_def.split()[0] + if not await column_exists(conn, table, col_name): + await conn.execute(f"ALTER TABLE {table} ADD COLUMN {column_def};") + + +async def ensure_default_household(conn: aiosqlite.Connection) -> int: + # Create a default household and return its id, idempotently + await conn.execute( + """ + INSERT INTO Household (name, slug) + VALUES ('My Household', 'default') + ON CONFLICT(slug) DO NOTHING + """ + ) + async with conn.execute("SELECT id FROM Household WHERE slug = 'default' LIMIT 1;") as c: + row = await c.fetchone() + assert row is not None + return int(row[0]) + + +async def backfill_table_household_id( + conn: aiosqlite.Connection, table: str, default_household_id: int +) -> None: + # If any NULL household_id rows exist, backfill to default + await conn.execute( + f""" + UPDATE {table} + SET household_id = ? + WHERE household_id IS NULL + """, + (default_household_id,), + ) + + +async def run_migration(conn: Optional[aiosqlite.Connection] = None): + owned = False + if conn is None: + conn = await db.connect(settings.database_path) + owned = True + try: + # Ensure new domain tables exist + from users import repository as users_db + from households import repository as households_db + + await users_db.create(conn) + await households_db.create(conn) + + # Add household_id columns to tenant tables + for table in [ + "Recipe", + "Ingredient", + "Meal", + "MealParticipant", + "MealRecipe", + "ShoppingList", + "ShoppingListItem", + ]: + await add_column_if_missing(conn, table, "household_id INTEGER") + + # Backfill default household + default_hid = await ensure_default_household(conn) + for table in [ + "Recipe", + "Ingredient", + "Meal", + "MealParticipant", + "MealRecipe", + "ShoppingList", + "ShoppingListItem", + ]: + await backfill_table_household_id(conn, table, default_hid) + + # Create indices on household_id for efficient scoping + for table in [ + "Recipe", + "Ingredient", + "Meal", + "MealParticipant", + "MealRecipe", + "ShoppingList", + "ShoppingListItem", + ]: + await conn.execute( + f"CREATE INDEX IF NOT EXISTS idx_{table.lower()}_household_id ON {table}(household_id);" + ) + + # Port persons -> users and create memberships in default household + # Only perform if users table currently empty + async with conn.execute("SELECT COUNT(1) FROM User;") as c: + row = await c.fetchone() + user_count = int(row[0]) if row else 0 + if user_count == 0: + async with conn.execute("SELECT id, name FROM Person;") as cur: + async for pid, name in cur: + email = f"{name.lower()}@example.com" + display_name = name + # Insert user + await conn.execute( + "INSERT INTO User (id, email, display_name) VALUES (?, ?, ?)\n ON CONFLICT(id) DO NOTHING;", + (pid, email, display_name), + ) + # Create membership + await conn.execute( + "INSERT OR IGNORE INTO HouseholdMember (user_id, household_id, role) VALUES (?, ?, ?);", + (pid, default_hid, "admin"), + ) + + await conn.commit() + finally: + if owned: + await conn.close() + + +if __name__ == "__main__": + asyncio.run(run_migration()) diff --git a/tests/test_auth_and_households_v2.py b/tests/test_auth_and_households_v2.py new file mode 100644 index 0000000..dcae486 --- /dev/null +++ b/tests/test_auth_and_households_v2.py @@ -0,0 +1,47 @@ +import unittest +from fastapi.testclient import TestClient + +import main +from db import connect, create + + +class TestAuthAndHouseholdsV2(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.conn = await connect(":memory:") + await create(self.conn) + + async def override_get_db(): + try: + yield self.conn + finally: + pass + + main.app.dependency_overrides[main.get_db] = override_get_db + self.client = TestClient(main.app) + + async def asyncTearDown(self): + await self.conn.close() + main.app.dependency_overrides.clear() + + def test_register_and_login_and_households(self): + # Register a user + r = self.client.post( + "/api/v1/auth/register", + json={"email": "test@example.com", "password": "pw", "displayName": "Test"}, + ) + assert r.status_code == 200, r.text + body = r.json() + token = body["accessToken"] + assert token.startswith("user-") + + headers = {"Authorization": f"Bearer {token}"} + # List households (migration created default household 'default' and membership set to admin) + r = self.client.get("/api/v1/users/me/households", headers=headers) + assert r.status_code == 200, r.text + households = r.json() + + # Create a new household + r = self.client.post("/api/v1/households", headers=headers, json={"name": "Family"}) + assert r.status_code == 200, r.text + created = r.json() + assert created["slug"].startswith("family") diff --git a/tests/test_migration_households.py b/tests/test_migration_households.py new file mode 100644 index 0000000..ed3744f --- /dev/null +++ b/tests/test_migration_households.py @@ -0,0 +1,77 @@ +import asyncio + +import aiosqlite + +import db +from tests.test_data import create_test_data +from scripts.migration_to_households import run_migration + + +async def table_has_column(conn: aiosqlite.Connection, table: str, col: str) -> bool: + async with conn.execute(f"PRAGMA table_info({table});") as c: + async for row in c: + if row[1] == col: + return True + return False + + +def test_migration_adds_tables_and_columns_and_ports_data(tmp_path): + async def _run(): + db_path = tmp_path / "test.sqlite" + conn = await db.connect(str(db_path)) + # Bootstrap v1 schema + await db.create(conn) + await conn.commit() + + # Seed some v1 data (persons, products, recipes, meals) + await create_test_data(conn) + await conn.commit() + + # Run migration + await run_migration(conn) + + # Verify new tables exist + for tbl in ["User", "LocalCredentials", "OAuthCredentials", "Household", "HouseholdMember", "HouseholdInvitation"]: + async with conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?;", (tbl,) + ) as c: + assert await c.fetchone() is not None, f"Missing table {tbl}" + + # Verify household_id column exists on tenant tables + tenant_tables = [ + "Recipe", + "Ingredient", + "Meal", + "MealParticipant", + "MealRecipe", + "ShoppingList", + "ShoppingListItem", + ] + for tbl in tenant_tables: + assert await table_has_column(conn, tbl, "household_id"), f"{tbl} lacks household_id" + + # Default household exists + async with conn.execute("SELECT id, slug FROM Household WHERE slug='default' LIMIT 1;") as c: + row = await c.fetchone() + assert row is not None + + # Persons were ported to Users and memberships created + async with conn.execute("SELECT COUNT(1) FROM Person;") as c: + row = await c.fetchone() + assert row is not None + person_count = int(row[0]) + async with conn.execute("SELECT COUNT(1) FROM User;") as c: + row = await c.fetchone() + assert row is not None + user_count = int(row[0]) + assert user_count == person_count + + async with conn.execute("SELECT COUNT(1) FROM HouseholdMember;") as c: + row = await c.fetchone() + assert row is not None + member_count = int(row[0]) + assert member_count == person_count + + await conn.close() + + asyncio.run(_run()) diff --git a/users/__init__.py b/users/__init__.py new file mode 100644 index 0000000..e230242 --- /dev/null +++ b/users/__init__.py @@ -0,0 +1,2 @@ +from users.models import User as User +from users.repository import create as create diff --git a/users/models.py b/users/models.py new file mode 100644 index 0000000..aa6fc66 --- /dev/null +++ b/users/models.py @@ -0,0 +1,17 @@ +from typing import ClassVar, Optional + +from common import ApiModel + + +class User(ApiModel): + KEYS: ClassVar[list[str]] = [ + "id", + "email", + "display_name", + "profile_photo_url", + ] + + id: int = -1 + email: str + display_name: str + profile_photo_url: Optional[str] = None diff --git a/users/repository.py b/users/repository.py new file mode 100644 index 0000000..899c24f --- /dev/null +++ b/users/repository.py @@ -0,0 +1,79 @@ +async def create(conn): + # Users core table + await conn.execute( + """ + CREATE TABLE IF NOT EXISTS User ( + id INTEGER PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + profile_photo_url TEXT + ); + """ + ) + + # Local credential storage for password auth + await conn.execute( + """ + CREATE TABLE IF NOT EXISTS LocalCredentials ( + user_id INTEGER PRIMARY KEY, + hashed_password TEXT NOT NULL, + FOREIGN KEY(user_id) REFERENCES User(id) ON DELETE CASCADE + ); + """ + ) + + # OAuth provider links (e.g., Google) + await conn.execute( + """ + CREATE TABLE IF NOT EXISTS OAuthCredentials ( + user_id INTEGER NOT NULL, + provider TEXT NOT NULL, + provider_user_id TEXT NOT NULL, + PRIMARY KEY (provider, provider_user_id), + FOREIGN KEY(user_id) REFERENCES User(id) ON DELETE CASCADE + ); + """ + ) + + # Helpful indices + await conn.execute( + "CREATE INDEX IF NOT EXISTS idx_user_email ON User(email);" + ) + + +async def get_by_email(conn, email: str): + async with conn.execute( + "SELECT id, email, display_name, profile_photo_url FROM User WHERE email = ? LIMIT 1", + (email,), + ) as c: + row = await c.fetchone() + if not row: + return None + from users.models import User + + return User(id=int(row[0]), email=row[1], display_name=row[2], profile_photo_url=row[3]) + + +async def insert_user(conn, email: str, display_name: str, profile_photo_url: str | None = None): + async with conn.execute( + "INSERT INTO User (email, display_name, profile_photo_url) VALUES (?, ?, ?)", + (email, display_name, profile_photo_url), + ) as cur: + user_id = cur.lastrowid + return user_id + + +async def set_local_credentials(conn, user_id: int, hashed_password: str): + await conn.execute( + "INSERT OR REPLACE INTO LocalCredentials (user_id, hashed_password) VALUES (?, ?)", + (user_id, hashed_password), + ) + + +async def get_local_password_hash(conn, user_id: int) -> str | None: + async with conn.execute( + "SELECT hashed_password FROM LocalCredentials WHERE user_id = ?", + (user_id,), + ) as c: + row = await c.fetchone() + return row[0] if row else None