Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ just dev --neon # same, against Neon; `--neon` works on run/migrate*/test
just migrate # apply migrations
just migrate-create "msg" # new Alembic migration after model changes
just migrate-down # roll back last migration
just link-user <email> <hotel_id> # link a user to a hotel (`--neon` works too)
just lint # ruff checks
just format # ruff format
```
Expand Down
59 changes: 59 additions & 0 deletions backend/foresight/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Operator commands, run through `just` (see the justfile).

For jobs that can't wait for the app to offer them. Like `worker.run_job`, a
command opens its own session and commits, once, when it succeeds; everything it
does goes through `services/`. A `DomainError` is printed and exits 1, with
nothing committed.
"""

from __future__ import annotations

import argparse
import asyncio
import sys

from foresight.database.session import async_session_factory, engine
from foresight.services.domain import user as user_service
from foresight.services.errors import DomainError


async def link_user(email: str, hotel_id: int) -> None:
async with async_session_factory() as session:
user = await user_service.get_user_by_email(session, email)
linked = await user_service.link_user_to_hotel(
session, user_id=user.id, hotel_id=hotel_id
)
await session.commit()
if linked:
print(f"Linked {user.email} to hotel {hotel_id}")
else:
print(f"{user.email} is already linked to hotel {hotel_id}")


async def _run(args: argparse.Namespace) -> None:
try:
if args.command == "link-user":
await link_user(args.email, args.hotel_id)
finally:
await engine.dispose()


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="foresight.cli")
commands = parser.add_subparsers(dest="command", required=True)

link = commands.add_parser("link-user", help="Link a user to a hotel by email")
link.add_argument("email", help="The user's email, as Google reports it")
link.add_argument("hotel_id", type=int, help="The hotel's id")

args = parser.parse_args(argv)
try:
asyncio.run(_run(args))
except DomainError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
return 0


if __name__ == "__main__":
sys.exit(main())
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""users and user hotels

The API has no notion of who is calling it. Adds users, identified by their
Google account id (unique; email can change and is not), and a link table so a
hotel can have several users and a user can belong to several hotels. A user
with no link is allowed: they have signed up but work for no hotel yet. Links
are removed with either side.

Both tables are new, so existing data is untouched. A downgrade drops both
tables and every user and link in them.

Revision ID: e6235f613e08
Revises: 46b9cb210348
Create Date: 2026-10-25 19:17:46.437463

"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa

# revision identifiers, used by Alembic.
revision: str = "e6235f613e08"
down_revision: Union[str, None] = "46b9cb210348"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("google_id", sa.String(length=255), nullable=False),
sa.Column("email", sa.String(length=320), nullable=False),
sa.Column("name", sa.String(length=200), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"last_signed_in_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_users")),
sa.UniqueConstraint("google_id", name=op.f("uq_users_google_id")),
)
op.create_table(
"user_hotels",
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("hotel_id", sa.Integer(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(
["hotel_id"],
["hotels.id"],
name=op.f("fk_user_hotels_hotel_id_hotels"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["user_id"],
["users.id"],
name=op.f("fk_user_hotels_user_id_users"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("user_id", "hotel_id", name=op.f("pk_user_hotels")),
)
op.create_index(
op.f("ix_user_hotels_hotel_id"),
"user_hotels",
["hotel_id"],
unique=False,
)


def downgrade() -> None:
op.drop_index(op.f("ix_user_hotels_hotel_id"), table_name="user_hotels")
op.drop_table("user_hotels")
op.drop_table("users")
3 changes: 3 additions & 0 deletions backend/foresight/database/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from foresight.database.models.signals.event import Event, EventObservation
from foresight.database.models.signals.flight import FlightArrival
from foresight.database.models.signals.weather import Weather
from foresight.database.models.user import User, UserHotel

__all__ = [
"Base",
Expand All @@ -24,6 +25,8 @@
"Job",
"PricingModel",
"RoomType",
"User",
"UserHotel",
"Weather",
"RecommendationSnapshot",
"Widget",
Expand Down
54 changes: 54 additions & 0 deletions backend/foresight/database/models/user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""A person who signs in with Google, and the hotels they work for."""

from datetime import datetime

from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column

from foresight.database.base import Base


class User(Base):
"""One Google account. Created the first time someone signs in, so a user
may have no hotel yet.
"""

__tablename__ = "users"

id: Mapped[int] = mapped_column(primary_key=True)
# Google's account id (the `sub` claim). This is the identity: an email can
# change, the id can't.
google_id: Mapped[str] = mapped_column(String(255), unique=True)
# Not unique: an address can pass to another person before its previous
# owner signs in again and the row is updated.
email: Mapped[str] = mapped_column(String(320))
name: Mapped[str | None] = mapped_column(String(200))

created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
# A row only exists because they just signed in, so it starts at creation.
last_signed_in_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)


class UserHotel(Base):
"""A user works for a hotel. A hotel has several users and a user can belong
to several hotels. Deleting either side removes the link.
"""

__tablename__ = "user_hotels"

# The composite key rejects a second link for the same pair, and its leading
# column serves as the foreign key index for user_id.
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
)
hotel_id: Mapped[int] = mapped_column(
ForeignKey("hotels.id", ondelete="CASCADE"), primary_key=True, index=True
)

created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
68 changes: 68 additions & 0 deletions backend/foresight/repository/user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""Data access for `users` and `user_hotels`.

`create_user` returns None when the Google account already has a user.
`link_hotel` is idempotent: it reports whether it added a link or one was
already there.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

from sqlalchemy import func, select
from sqlalchemy.dialects.postgresql import insert

from foresight.database.models.user import User, UserHotel

if TYPE_CHECKING:
from collections.abc import Sequence

from sqlalchemy.ext.asyncio import AsyncSession


async def create_user(
session: AsyncSession, *, google_id: str, email: str, name: str | None
) -> User | None:
"""Insert a user; None if this Google account already has one."""
return await session.scalar(
insert(User)
.values(google_id=google_id, email=email, name=name)
.on_conflict_do_nothing(constraint="uq_users_google_id")
.returning(User)
)


async def get_user(session: AsyncSession, user_id: int) -> User | None:
return await session.get(User, user_id)


async def get_user_by_google_id(session: AsyncSession, google_id: str) -> User | None:
return await session.scalar(select(User).where(User.google_id == google_id))


async def list_users_by_email(session: AsyncSession, email: str) -> Sequence[User]:
"""Users with this email, ignoring case. Email isn't unique, so there can be
several."""
stmt = select(User).where(func.lower(User.email) == email.lower()).order_by(User.id)
return (await session.scalars(stmt)).all()


async def link_hotel(session: AsyncSession, *, user_id: int, hotel_id: int) -> bool:
"""Link a user to a hotel. True if the link is new, False if it already
existed. The caller checks both exist."""
linked = await session.scalar(
insert(UserHotel)
.values(user_id=user_id, hotel_id=hotel_id)
.on_conflict_do_nothing(constraint="pk_user_hotels")
.returning(UserHotel.user_id)
)
return linked is not None


async def list_hotel_ids(session: AsyncSession, user_id: int) -> Sequence[int]:
stmt = (
select(UserHotel.hotel_id)
.where(UserHotel.user_id == user_id)
.order_by(UserHotel.hotel_id)
)
return (await session.scalars(stmt)).all()
79 changes: 79 additions & 0 deletions backend/foresight/services/domain/user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""Users, and which hotels they work for."""

from __future__ import annotations

from typing import TYPE_CHECKING

from foresight.repository import user as user_repo
from foresight.services.domain.hotel import get_hotel
from foresight.services.errors import ConflictError, NotFoundError

if TYPE_CHECKING:
from collections.abc import Sequence

from sqlalchemy.ext.asyncio import AsyncSession

from foresight.database.models.user import User


async def create_user(
session: AsyncSession, *, google_id: str, email: str, name: str | None
) -> User:
"""Raises ConflictError if this Google account already has a user."""
user = await user_repo.create_user(
session, google_id=google_id, email=email, name=name
)
if user is None:
raise ConflictError(f"A user already exists for Google account {google_id!r}")
return user


async def get_user(session: AsyncSession, user_id: int) -> User:
"""Raises NotFoundError for an unknown user."""
user = await user_repo.get_user(session, user_id)
if user is None:
raise NotFoundError("User", user_id)
return user


async def get_user_by_google_id(session: AsyncSession, google_id: str) -> User:
"""Raises NotFoundError if no user has this Google account id."""
user = await user_repo.get_user_by_google_id(session, google_id)
if user is None:
raise NotFoundError("User", google_id)
return user


async def get_user_by_email(session: AsyncSession, email: str) -> User:
"""The user with this email, ignoring case.

Raises:
NotFoundError: No user has this email.
ConflictError: Several do. Emails change hands, so this never picks one:
it would give a hotel's access to the wrong person.
"""
users = await user_repo.list_users_by_email(session, email)
if not users:
raise NotFoundError("User", email)
if len(users) > 1:
raise ConflictError(f"More than one user has the email {email!r}")
return users[0]


async def link_user_to_hotel(
session: AsyncSession, *, user_id: int, hotel_id: int
) -> bool:
"""Link a user to a hotel. True if the link is new, False if it already
existed; linking twice is not an error.

Raises NotFoundError for an unknown user or hotel.
"""
await get_user(session, user_id)
await get_hotel(session, hotel_id)
return await user_repo.link_hotel(session, user_id=user_id, hotel_id=hotel_id)


async def list_hotel_ids(session: AsyncSession, user_id: int) -> Sequence[int]:
"""The hotels a user is linked to. Raises NotFoundError for an unknown user."""
await get_user(session, user_id)
return await user_repo.list_hotel_ids(session, user_id)
Loading
Loading