Trip multi-users - BETA

This commit is contained in:
itskovacs 2025-08-19 20:10:16 +02:00
parent cc729722df
commit 9cbbfd9065
13 changed files with 780 additions and 109 deletions

View File

@ -0,0 +1,91 @@
"""Trip multi-users
Revision ID: 26c89b7466f2
Revises: 60a9bb641d8a
Create Date: 2025-08-18 23:19:37.457354
"""
import sqlalchemy as sa
import sqlmodel.sql.sqltypes
from alembic import op
# revision identifiers, used by Alembic.
revision = "26c89b7466f2"
down_revision = "60a9bb641d8a"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"tripmember",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("invited_by", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("invited_at", sa.DateTime(), nullable=False),
sa.Column("joined_at", sa.DateTime(), nullable=True),
sa.Column("trip_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(
["invited_by"],
["user.username"],
name=op.f("fk_tripmember_invited_by_user"),
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["trip_id"], ["trip.id"], name=op.f("fk_tripmember_trip_id_trip"), ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["user"], ["user.username"], name=op.f("fk_tripmember_user_user"), ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_tripmember")),
)
with op.batch_alter_table("tripchecklistitem", schema=None) as batch_op:
batch_op.drop_constraint(batch_op.f("fk_tripchecklistitem_user_user"), type_="foreignkey")
batch_op.drop_column("user")
with op.batch_alter_table("trippackinglistitem", schema=None) as batch_op:
batch_op.drop_constraint(batch_op.f("fk_trippackinglistitem_user_user"), type_="foreignkey")
batch_op.drop_column("user")
with op.batch_alter_table("tripitem", schema=None) as batch_op:
batch_op.drop_constraint(batch_op.f("fk_tripitem_day_id_tripday"), type_="foreignkey")
with op.batch_alter_table("tripday", schema=None) as batch_op:
batch_op.drop_constraint(batch_op.f("fk_tripday_user_user"), type_="foreignkey")
batch_op.drop_column("user")
with op.batch_alter_table("tripitem", schema=None) as batch_op:
batch_op.create_foreign_key(
batch_op.f("fk_tripitem_day_id_tripday"),
"tripday",
["day_id"],
["id"],
ondelete="CASCADE",
)
def downgrade():
with op.batch_alter_table("trippackinglistitem", schema=None) as batch_op:
batch_op.add_column(sa.Column("user", sa.VARCHAR(), nullable=False))
batch_op.create_foreign_key(
batch_op.f("fk_trippackinglistitem_user_user"),
"user",
["user"],
["username"],
ondelete="CASCADE",
)
with op.batch_alter_table("tripday", schema=None) as batch_op:
batch_op.add_column(sa.Column("user", sa.VARCHAR(), nullable=False))
batch_op.create_foreign_key(
batch_op.f("fk_tripday_user_user"), "user", ["user"], ["username"], ondelete="CASCADE"
)
with op.batch_alter_table("tripchecklistitem", schema=None) as batch_op:
batch_op.add_column(sa.Column("user", sa.VARCHAR(), nullable=False))
batch_op.create_foreign_key(
batch_op.f("fk_tripchecklistitem_user_user"), "user", ["user"], ["username"], ondelete="CASCADE"
)
op.drop_table("tripmember")

View File

@ -261,6 +261,7 @@ class Trip(TripBase, table=True):
shares: list["TripShare"] = Relationship(back_populates="trip", cascade_delete=True) shares: list["TripShare"] = Relationship(back_populates="trip", cascade_delete=True)
packing_items: list["TripPackingListItem"] = Relationship(back_populates="trip", cascade_delete=True) packing_items: list["TripPackingListItem"] = Relationship(back_populates="trip", cascade_delete=True)
checklist_items: list["TripChecklistItem"] = Relationship(back_populates="trip", cascade_delete=True) checklist_items: list["TripChecklistItem"] = Relationship(back_populates="trip", cascade_delete=True)
memberships: list["TripMember"] = Relationship(back_populates="trip", cascade_delete=True)
class TripCreate(TripBase): class TripCreate(TripBase):
@ -279,6 +280,7 @@ class TripReadBase(TripBase):
image: str | None image: str | None
image_id: int | None image_id: int | None
days: int days: int
collaborators: list["TripMemberRead"]
@classmethod @classmethod
def serialize(cls, obj: Trip) -> "TripRead": def serialize(cls, obj: Trip) -> "TripRead":
@ -289,6 +291,7 @@ class TripReadBase(TripBase):
image=_prefix_assets_url(obj.image.filename) if obj.image else None, image=_prefix_assets_url(obj.image.filename) if obj.image else None,
image_id=obj.image_id, image_id=obj.image_id,
days=len(obj.days), days=len(obj.days),
collaborators=[TripMemberRead.serialize(m) for m in obj.memberships],
) )
@ -298,6 +301,7 @@ class TripRead(TripBase):
image_id: int | None image_id: int | None
days: list["TripDayRead"] days: list["TripDayRead"]
places: list["PlaceRead"] places: list["PlaceRead"]
collaborators: list["TripMemberRead"]
@classmethod @classmethod
def serialize(cls, obj: Trip) -> "TripRead": def serialize(cls, obj: Trip) -> "TripRead":
@ -309,17 +313,49 @@ class TripRead(TripBase):
image_id=obj.image_id, image_id=obj.image_id,
days=[TripDayRead.serialize(day) for day in obj.days], days=[TripDayRead.serialize(day) for day in obj.days],
places=[PlaceRead.serialize(place) for place in obj.places], places=[PlaceRead.serialize(place) for place in obj.places],
collaborators=[TripMemberRead.serialize(m) for m in obj.memberships],
) )
class TripMember(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
user: str = Field(foreign_key="user.username", ondelete="CASCADE")
invited_by: str | None = Field(default=None, foreign_key="user.username", ondelete="SET NULL")
invited_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
joined_at: datetime | None = None
trip_id: int = Field(foreign_key="trip.id", ondelete="CASCADE")
trip: Trip | None = Relationship(back_populates="memberships")
class TripMemberCreate(BaseModel):
user: str
class TripMemberRead(BaseModel):
user: str
invited_by: str | None = None
invited_at: datetime | None = None
joined_at: datetime | None = None
@classmethod
def serialize(cls, obj: TripMember) -> "TripMemberRead":
return cls(
user=obj.user, invited_by=obj.invited_by, invited_at=obj.invited_at, joined_at=obj.joined_at
)
class TripInvitationRead(TripReadBase):
invited_by: str | None = None
invited_at: datetime
class TripDayBase(SQLModel): class TripDayBase(SQLModel):
label: str label: str
class TripDay(TripDayBase, table=True): class TripDay(TripDayBase, table=True):
id: int | None = Field(default=None, primary_key=True) id: int | None = Field(default=None, primary_key=True)
user: str = Field(foreign_key="user.username", ondelete="CASCADE")
trip_id: int = Field(foreign_key="trip.id", ondelete="CASCADE") trip_id: int = Field(foreign_key="trip.id", ondelete="CASCADE")
trip: Trip | None = Relationship(back_populates="days") trip: Trip | None = Relationship(back_populates="days")
@ -420,7 +456,6 @@ class TripPackingListItemBase(SQLModel):
class TripPackingListItem(TripPackingListItemBase, table=True): class TripPackingListItem(TripPackingListItemBase, table=True):
id: int | None = Field(default=None, primary_key=True) id: int | None = Field(default=None, primary_key=True)
user: str = Field(foreign_key="user.username", ondelete="CASCADE")
trip_id: int = Field(foreign_key="trip.id", ondelete="CASCADE") trip_id: int = Field(foreign_key="trip.id", ondelete="CASCADE")
trip: Trip | None = Relationship(back_populates="packing_items") trip: Trip | None = Relationship(back_populates="packing_items")

View File

@ -8,33 +8,120 @@ from ..deps import SessionDep, get_current_username
from ..models.models import (Image, Place, Trip, TripChecklistItem, from ..models.models import (Image, Place, Trip, TripChecklistItem,
TripChecklistItemCreate, TripChecklistItemRead, TripChecklistItemCreate, TripChecklistItemRead,
TripChecklistItemUpdate, TripCreate, TripDay, TripChecklistItemUpdate, TripCreate, TripDay,
TripDayBase, TripDayRead, TripItem, TripDayBase, TripDayRead, TripInvitationRead,
TripItemCreate, TripItemRead, TripItemUpdate, TripItem, TripItemCreate, TripItemRead,
TripPackingListItem, TripPackingListItemCreate, TripItemUpdate, TripMember, TripMemberCreate,
TripPackingListItemRead, TripPackingListItemUpdate, TripMemberRead, TripPackingListItem,
TripRead, TripReadBase, TripShare, TripPackingListItemCreate,
TripShareURL, TripUpdate) TripPackingListItemRead,
from ..security import verify_exists_and_owns TripPackingListItemUpdate, TripRead, TripReadBase,
TripShare, TripShareURL, TripUpdate, User)
from ..utils.utils import (b64img_decode, generate_urlsafe, remove_image, from ..utils.utils import (b64img_decode, generate_urlsafe, remove_image,
save_image_to_file) save_image_to_file, utc_now)
router = APIRouter(prefix="/api/trips", tags=["trips"]) router = APIRouter(prefix="/api/trips", tags=["trips"])
def _get_trip_or_404(session, trip_id: int) -> Trip:
trip = session.get(Trip, trip_id)
if not trip:
raise HTTPException(status_code=404, detail="Not found")
return trip
def _trip_from_token_or_404(session, token: str) -> TripShare:
share = session.exec(select(TripShare).where(TripShare.token == token)).first()
if not share:
raise HTTPException(status_code=404, detail="Not found")
return share
def _trip_usernames(session, trip_id: int) -> set[str]:
owner = session.exec(select(Trip.user).where(Trip.id == trip_id)).first()
members = session.exec(select(TripMember.user).where(TripMember.trip_id == trip_id)).all()
return {owner} | set(members)
def _can_access_trip(session, trip_id: int, username: str) -> bool:
# TODO: Optimize, if trip does not exist, trip_owner is none and we keep iterating on members despite trip not found
trip_owner = session.exec(select(Trip.user).where(Trip.id == trip_id)).first()
if trip_owner == username:
return True
is_member = session.exec(
select(TripMember.id)
.where(TripMember.trip_id == trip_id, TripMember.user == username, TripMember.joined_at.is_not(None))
.limit(1)
).first()
return bool(is_member)
def _verify_trip_member(session, trip_id: int, username: str) -> None:
if not _can_access_trip(session, trip_id, username):
raise HTTPException(status_code=404, detail="Not found")
@router.get("", response_model=list[TripReadBase]) @router.get("", response_model=list[TripReadBase])
def read_trips( def read_trips(
session: SessionDep, current_user: Annotated[str, Depends(get_current_username)] session: SessionDep, current_user: Annotated[str, Depends(get_current_username)]
) -> list[TripReadBase]: ) -> list[TripReadBase]:
trips = session.exec(select(Trip).filter(Trip.user == current_user)) trips = session.exec(
select(Trip)
.join(TripMember, isouter=True)
.where(
(Trip.user == current_user)
| ((TripMember.user == current_user) & (TripMember.joined_at.is_not(None)))
)
.distinct()
)
return [TripReadBase.serialize(trip) for trip in trips] return [TripReadBase.serialize(trip) for trip in trips]
@router.get("/invitations", response_model=list[TripInvitationRead])
def read_pending_invitations(
session: SessionDep,
current_user: Annotated[str, Depends(get_current_username)],
) -> list[TripInvitationRead]:
pending_inviattions = session.exec(
select(TripMember, Trip)
.join(Trip, Trip.id == TripMember.trip_id)
.where(
TripMember.user == current_user,
TripMember.joined_at.is_(None),
)
).all()
invitations: list[TripInvitationRead] = []
for tm, trip in pending_inviattions:
base = TripReadBase.serialize(trip)
invitations.append(
TripInvitationRead(
**base.model_dump(),
invited_by=tm.invited_by,
invited_at=tm.invited_at,
)
)
return invitations
@router.get("/invitations/pending", response_model=bool)
def has_pending_invitations(
session: SessionDep,
current_user: Annotated[str, Depends(get_current_username)],
) -> bool:
pending = session.exec(
select(TripMember.id).where(TripMember.user == current_user, TripMember.joined_at.is_(None)).limit(1)
).first()
return bool(pending)
@router.get("/{trip_id}", response_model=TripRead) @router.get("/{trip_id}", response_model=TripRead)
def read_trip( def read_trip(
session: SessionDep, trip_id: int, current_user: Annotated[str, Depends(get_current_username)] session: SessionDep, trip_id: int, current_user: Annotated[str, Depends(get_current_username)]
) -> TripRead: ) -> TripRead:
db_trip = session.get(Trip, trip_id) db_trip = _get_trip_or_404(session, trip_id)
verify_exists_and_owns(current_user, db_trip) _verify_trip_member(session, trip_id, current_user)
return TripRead.serialize(db_trip) return TripRead.serialize(db_trip)
@ -42,10 +129,7 @@ def read_trip(
def create_trip( def create_trip(
trip: TripCreate, session: SessionDep, current_user: Annotated[str, Depends(get_current_username)] trip: TripCreate, session: SessionDep, current_user: Annotated[str, Depends(get_current_username)]
) -> TripReadBase: ) -> TripReadBase:
new_trip = Trip( new_trip = Trip(name=trip.name, user=current_user)
name=trip.name,
user=current_user,
)
if trip.image: if trip.image:
image_bytes = b64img_decode(trip.image) image_bytes = b64img_decode(trip.image)
@ -55,17 +139,10 @@ def create_trip(
image = Image(filename=filename, user=current_user) image = Image(filename=filename, user=current_user)
session.add(image) session.add(image)
session.commit() session.flush()
session.refresh(image) session.refresh(image)
new_trip.image_id = image.id new_trip.image_id = image.id
if trip.place_ids:
for place_id in trip.place_ids:
db_place = session.get(Place, place_id)
verify_exists_and_owns(current_user, db_place)
session.add(TripPlaceLink(trip_id=new_trip.id, place_id=db_place.id))
session.commit()
session.add(new_trip) session.add(new_trip)
session.commit() session.commit()
session.refresh(new_trip) session.refresh(new_trip)
@ -79,8 +156,8 @@ def update_trip(
trip: TripUpdate, trip: TripUpdate,
current_user: Annotated[str, Depends(get_current_username)], current_user: Annotated[str, Depends(get_current_username)],
) -> TripRead: ) -> TripRead:
db_trip = session.get(Trip, trip_id) db_trip = _get_trip_or_404(session, trip_id)
verify_exists_and_owns(current_user, db_trip) _verify_trip_member(session, trip_id, current_user)
if db_trip.archived and (trip.archived is not False): if db_trip.archived and (trip.archived is not False):
raise HTTPException(status_code=400, detail="Bad request") raise HTTPException(status_code=400, detail="Bad request")
@ -100,8 +177,8 @@ def update_trip(
image = Image(filename=filename, user=current_user) image = Image(filename=filename, user=current_user)
session.add(image) session.add(image)
session.commit()
session.refresh(image) session.refresh(image)
session.flush()
if db_trip.image_id: if db_trip.image_id:
old_image = session.get(Image, db_trip.image_id) old_image = session.get(Image, db_trip.image_id)
@ -117,11 +194,16 @@ def update_trip(
place_ids = trip_data.pop("place_ids", None) place_ids = trip_data.pop("place_ids", None)
if place_ids is not None: # Could be empty [], so 'in' if place_ids is not None: # Could be empty [], so 'in'
db_trip.places.clear() allowed_users = _trip_usernames(session, trip_id)
new_places = []
for place_id in place_ids: for place_id in place_ids:
db_place = session.get(Place, place_id) db_place = session.get(Place, place_id)
verify_exists_and_owns(current_user, db_place) if not db_place:
db_trip.places.append(db_place) raise HTTPException(status_code=404, detail="Not found")
if db_place.user not in allowed_users:
raise HTTPException(status_code=403, detail="Place not accessible by trip members")
new_places.append(db_place)
db_trip.places = new_places
item_place_ids = { item_place_ids = {
item.place.id for day in db_trip.days for item in day.items if item.place is not None item.place.id for day in db_trip.days for item in day.items if item.place is not None
@ -143,8 +225,8 @@ def update_trip(
def delete_trip( def delete_trip(
session: SessionDep, trip_id: int, current_user: Annotated[str, Depends(get_current_username)] session: SessionDep, trip_id: int, current_user: Annotated[str, Depends(get_current_username)]
): ):
db_trip = session.get(Trip, trip_id) db_trip = _get_trip_or_404(session, trip_id)
verify_exists_and_owns(current_user, db_trip) _verify_trip_member(session, trip_id, current_user)
if db_trip.archived: if db_trip.archived:
raise HTTPException(status_code=400, detail="Bad request") raise HTTPException(status_code=400, detail="Bad request")
@ -171,13 +253,13 @@ def create_tripday(
session: SessionDep, session: SessionDep,
current_user: Annotated[str, Depends(get_current_username)], current_user: Annotated[str, Depends(get_current_username)],
) -> TripDayRead: ) -> TripDayRead:
db_trip = session.get(Trip, trip_id) db_trip = _get_trip_or_404(session, trip_id)
verify_exists_and_owns(current_user, db_trip) _verify_trip_member(session, trip_id, current_user)
if db_trip.archived: if db_trip.archived:
raise HTTPException(status_code=400, detail="Bad request") raise HTTPException(status_code=400, detail="Bad request")
new_day = TripDay(label=td.label, trip_id=trip_id, user=current_user) new_day = TripDay(label=td.label, trip_id=trip_id)
session.add(new_day) session.add(new_day)
session.commit() session.commit()
@ -193,15 +275,14 @@ def update_tripday(
session: SessionDep, session: SessionDep,
current_user: Annotated[str, Depends(get_current_username)], current_user: Annotated[str, Depends(get_current_username)],
) -> TripDayRead: ) -> TripDayRead:
db_trip = session.get(Trip, trip_id) db_trip = _get_trip_or_404(session, trip_id)
verify_exists_and_owns(current_user, db_trip) _verify_trip_member(session, trip_id, current_user)
if db_trip.archived: if db_trip.archived:
raise HTTPException(status_code=400, detail="Bad request") raise HTTPException(status_code=400, detail="Bad request")
db_day = session.get(TripDay, day_id) db_day = session.get(TripDay, day_id)
verify_exists_and_owns(current_user, db_day) if not db_day or (db_day.trip_id != trip_id):
if db_day.trip_id != trip_id:
raise HTTPException(status_code=400, detail="Bad request") raise HTTPException(status_code=400, detail="Bad request")
td_data = td.model_dump(exclude_unset=True) td_data = td.model_dump(exclude_unset=True)
@ -221,15 +302,14 @@ def delete_tripday(
day_id: int, day_id: int,
current_user: Annotated[str, Depends(get_current_username)], current_user: Annotated[str, Depends(get_current_username)],
): ):
db_trip = session.get(Trip, trip_id) db_trip = _get_trip_or_404(session, trip_id)
verify_exists_and_owns(current_user, db_trip) _verify_trip_member(session, trip_id, current_user)
if db_trip.archived: if db_trip.archived:
raise HTTPException(status_code=400, detail="Bad request") raise HTTPException(status_code=400, detail="Bad request")
db_day = session.get(TripDay, day_id) db_day = session.get(TripDay, day_id)
verify_exists_and_owns(current_user, db_day) if not db_day or (db_day.trip_id != trip_id):
if db_day.trip_id != trip_id:
raise HTTPException(status_code=400, detail="Bad request") raise HTTPException(status_code=400, detail="Bad request")
session.delete(db_day) session.delete(db_day)
@ -245,14 +325,14 @@ def create_tripitem(
session: SessionDep, session: SessionDep,
current_user: Annotated[str, Depends(get_current_username)], current_user: Annotated[str, Depends(get_current_username)],
) -> TripItemRead: ) -> TripItemRead:
db_trip = session.get(Trip, trip_id) db_trip = _get_trip_or_404(session, trip_id)
verify_exists_and_owns(current_user, db_trip) _verify_trip_member(session, trip_id, current_user)
if db_trip.archived: if db_trip.archived:
raise HTTPException(status_code=400, detail="Bad request") raise HTTPException(status_code=400, detail="Bad request")
db_day = session.get(TripDay, day_id) db_day = session.get(TripDay, day_id)
if db_day.trip_id != trip_id: if not db_day or (db_day.trip_id != trip_id):
raise HTTPException(status_code=400, detail="Bad request") raise HTTPException(status_code=400, detail="Bad request")
new_item = TripItem( new_item = TripItem(
@ -266,7 +346,7 @@ def create_tripitem(
status=item.status, status=item.status,
) )
if item.place and item.place != "": if item.place is not None:
place_in_trip = any(place.id == item.place for place in db_trip.places) place_in_trip = any(place.id == item.place for place in db_trip.places)
if not place_in_trip: if not place_in_trip:
raise HTTPException(status_code=400, detail="Bad request") raise HTTPException(status_code=400, detail="Bad request")
@ -287,18 +367,18 @@ def update_tripitem(
session: SessionDep, session: SessionDep,
current_user: Annotated[str, Depends(get_current_username)], current_user: Annotated[str, Depends(get_current_username)],
) -> TripItemRead: ) -> TripItemRead:
db_trip = session.get(Trip, trip_id) db_trip = _get_trip_or_404(session, trip_id)
verify_exists_and_owns(current_user, db_trip) _verify_trip_member(session, trip_id, current_user)
if db_trip.archived: if db_trip.archived:
raise HTTPException(status_code=400, detail="Bad request") raise HTTPException(status_code=400, detail="Bad request")
db_day = session.get(TripDay, day_id) db_day = session.get(TripDay, day_id)
if db_day.trip_id != trip_id: if not db_day or (db_day.trip_id != trip_id):
raise HTTPException(status_code=400, detail="Bad request") raise HTTPException(status_code=400, detail="Bad request")
db_item = session.get(TripItem, item_id) db_item = session.get(TripItem, item_id)
if db_item.day_id != day_id: if not db_item or (db_item.day_id != day_id):
raise HTTPException(status_code=400, detail="Bad request") raise HTTPException(status_code=400, detail="Bad request")
item_data = item.model_dump(exclude_unset=True) item_data = item.model_dump(exclude_unset=True)
@ -327,18 +407,18 @@ def delete_tripitem(
item_id: int, item_id: int,
current_user: Annotated[str, Depends(get_current_username)], current_user: Annotated[str, Depends(get_current_username)],
): ):
db_trip = session.get(Trip, trip_id) db_trip = _get_trip_or_404(session, trip_id)
verify_exists_and_owns(current_user, db_trip) _verify_trip_member(session, trip_id, current_user)
if db_trip.archived: if db_trip.archived:
raise HTTPException(status_code=400, detail="Bad request") raise HTTPException(status_code=400, detail="Bad request")
db_day = session.get(TripDay, day_id) db_day = session.get(TripDay, day_id)
if db_day.trip_id != trip_id: if not db_day or (db_day.trip_id != trip_id):
raise HTTPException(status_code=400, detail="Bad request") raise HTTPException(status_code=400, detail="Bad request")
db_item = session.get(TripItem, item_id) db_item = session.get(TripItem, item_id)
if db_item.day_id != day_id: if not db_item or (db_item.day_id != day_id):
raise HTTPException(status_code=400, detail="Bad request") raise HTTPException(status_code=400, detail="Bad request")
session.delete(db_item) session.delete(db_item)
@ -351,11 +431,7 @@ def read_shared_trip(
session: SessionDep, session: SessionDep,
token: str, token: str,
) -> TripRead: ) -> TripRead:
share = session.exec(select(TripShare).where(TripShare.token == token)).first() db_trip = session.get(Trip, _trip_from_token_or_404(session, token).trip_id)
if not share:
raise HTTPException(status_code=404, detail="Not found")
db_trip = session.get(Trip, share.trip_id)
return TripRead.serialize(db_trip) return TripRead.serialize(db_trip)
@ -365,8 +441,7 @@ def get_shared_trip_url(
trip_id: int, trip_id: int,
current_user: Annotated[str, Depends(get_current_username)], current_user: Annotated[str, Depends(get_current_username)],
) -> TripShareURL: ) -> TripShareURL:
db_trip = session.get(Trip, trip_id) _verify_trip_member(session, trip_id, current_user)
verify_exists_and_owns(current_user, db_trip)
share = session.exec(select(TripShare).where(TripShare.trip_id == trip_id)).first() share = session.exec(select(TripShare).where(TripShare.trip_id == trip_id)).first()
if not share: if not share:
@ -381,15 +456,14 @@ def create_shared_trip(
trip_id: int, trip_id: int,
current_user: Annotated[str, Depends(get_current_username)], current_user: Annotated[str, Depends(get_current_username)],
) -> TripShareURL: ) -> TripShareURL:
db_trip = session.get(Trip, trip_id) _verify_trip_member(session, trip_id, current_user)
verify_exists_and_owns(current_user, db_trip)
shared = session.exec(select(TripShare).where(TripShare.trip_id == trip_id)).first() shared = session.exec(select(TripShare).where(TripShare.trip_id == trip_id)).first()
if shared: if shared:
raise HTTPException(status_code=409, detail="The resource already exists") raise HTTPException(status_code=409, detail="The resource already exists")
token = generate_urlsafe() token = generate_urlsafe()
trip_share = TripShare(token=token, trip_id=trip_id, user=current_user) trip_share = TripShare(token=token, trip_id=trip_id)
session.add(trip_share) session.add(trip_share)
session.commit() session.commit()
return {"url": f"/s/t/{token}"} return {"url": f"/s/t/{token}"}
@ -401,8 +475,7 @@ def delete_shared_trip(
trip_id: int, trip_id: int,
current_user: Annotated[str, Depends(get_current_username)], current_user: Annotated[str, Depends(get_current_username)],
): ):
db_trip = session.get(Trip, trip_id) _verify_trip_member(session, trip_id, current_user)
verify_exists_and_owns(current_user, db_trip)
db_share = session.exec(select(TripShare).where(TripShare.trip_id == trip_id)).first() db_share = session.exec(select(TripShare).where(TripShare.trip_id == trip_id)).first()
if not db_share: if not db_share:
@ -419,15 +492,25 @@ def read_packing_list(
trip_id: int, trip_id: int,
current_user: Annotated[str, Depends(get_current_username)], current_user: Annotated[str, Depends(get_current_username)],
) -> list[TripPackingListItemRead]: ) -> list[TripPackingListItemRead]:
p_items = session.exec( _verify_trip_member(session, trip_id, current_user)
select(TripPackingListItem) p_items = session.exec(select(TripPackingListItem).where(TripPackingListItem.trip_id == trip_id))
.where(TripPackingListItem.trip_id == trip_id, TripPackingListItem.user == current_user)
.order_by(TripPackingListItem.id.asc())
).all()
return [TripPackingListItemRead.serialize(i) for i in p_items] return [TripPackingListItemRead.serialize(i) for i in p_items]
@router.get("/shared/{token}/packing", response_model=list[TripPackingListItemRead])
def read_shared_trip_packing_list(
session: SessionDep,
token: str,
) -> list[TripPackingListItemRead]:
p_items = session.exec(
select(TripPackingListItem).where(
TripPackingListItem.trip_id == _trip_from_token_or_404(session, token).trip_id
)
)
return [TripPackingListItemRead.serialize(i) for i in p_items]
@router.post("/{trip_id}/packing", response_model=TripPackingListItemRead) @router.post("/{trip_id}/packing", response_model=TripPackingListItemRead)
def create_packing_item( def create_packing_item(
session: SessionDep, session: SessionDep,
@ -435,11 +518,8 @@ def create_packing_item(
data: TripPackingListItemCreate, data: TripPackingListItemCreate,
current_user: Annotated[str, Depends(get_current_username)], current_user: Annotated[str, Depends(get_current_username)],
) -> TripPackingListItemRead: ) -> TripPackingListItemRead:
item = TripPackingListItem( _verify_trip_member(session, trip_id, current_user)
**data.model_dump(), item = TripPackingListItem(**data.model_dump(), trip_id=trip_id)
trip_id=trip_id,
user=current_user,
)
session.add(item) session.add(item)
session.commit() session.commit()
session.refresh(item) session.refresh(item)
@ -454,11 +534,10 @@ def update_packing_item(
p_id: int, p_id: int,
current_user: Annotated[str, Depends(get_current_username)], current_user: Annotated[str, Depends(get_current_username)],
) -> TripPackingListItemRead: ) -> TripPackingListItemRead:
_verify_trip_member(session, trip_id, current_user)
db_item = session.exec( db_item = session.exec(
select(TripPackingListItem).where( select(TripPackingListItem).where(
TripPackingListItem.id == p_id, TripPackingListItem.id == p_id, TripPackingListItem.trip_id == trip_id
TripPackingListItem.trip_id == trip_id,
TripPackingListItem.user == current_user,
) )
).one_or_none() ).one_or_none()
@ -482,11 +561,10 @@ def delete_packing_item(
p_id: int, p_id: int,
current_user: Annotated[str, Depends(get_current_username)], current_user: Annotated[str, Depends(get_current_username)],
): ):
_verify_trip_member(session, trip_id, current_user)
item = session.exec( item = session.exec(
select(TripPackingListItem).where( select(TripPackingListItem).where(
TripPackingListItem.id == p_id, TripPackingListItem.id == p_id, TripPackingListItem.trip_id == trip_id
TripPackingListItem.trip_id == trip_id,
TripPackingListItem.user == current_user,
) )
).one_or_none() ).one_or_none()
@ -504,11 +582,8 @@ def read_checklist(
trip_id: int, trip_id: int,
current_user: Annotated[str, Depends(get_current_username)], current_user: Annotated[str, Depends(get_current_username)],
) -> list[TripChecklistItemRead]: ) -> list[TripChecklistItemRead]:
items = session.exec( _verify_trip_member(session, trip_id, current_user)
select(TripChecklistItem).where( items = session.exec(select(TripChecklistItem).where(TripChecklistItem.trip_id == trip_id))
TripChecklistItem.trip_id == trip_id, TripChecklistItem.user == current_user
)
)
return [TripChecklistItemRead.serialize(i) for i in items] return [TripChecklistItemRead.serialize(i) for i in items]
@ -532,11 +607,8 @@ def create_checklist_item(
data: TripChecklistItemCreate, data: TripChecklistItemCreate,
current_user: Annotated[str, Depends(get_current_username)], current_user: Annotated[str, Depends(get_current_username)],
) -> TripChecklistItemRead: ) -> TripChecklistItemRead:
item = TripChecklistItem( _verify_trip_member(session, trip_id, current_user)
**data.model_dump(), item = TripChecklistItem(**data.model_dump(), trip_id=trip_id)
trip_id=trip_id,
user=current_user,
)
session.add(item) session.add(item)
session.commit() session.commit()
session.refresh(item) session.refresh(item)
@ -551,12 +623,9 @@ def update_checklist_item(
id: int, id: int,
current_user: Annotated[str, Depends(get_current_username)], current_user: Annotated[str, Depends(get_current_username)],
) -> TripChecklistItemRead: ) -> TripChecklistItemRead:
_verify_trip_member(session, trip_id, current_user)
db_item = session.exec( db_item = session.exec(
select(TripChecklistItem).where( select(TripChecklistItem).where(TripChecklistItem.id == id, TripChecklistItem.trip_id == trip_id)
TripChecklistItem.id == id,
TripChecklistItem.trip_id == trip_id,
TripChecklistItem.user == current_user,
)
).one_or_none() ).one_or_none()
if not db_item: if not db_item:
@ -579,11 +648,11 @@ def delete_checklist_item(
id: int, id: int,
current_user: Annotated[str, Depends(get_current_username)], current_user: Annotated[str, Depends(get_current_username)],
): ):
_verify_trip_member(session, trip_id, current_user)
item = session.exec( item = session.exec(
select(TripChecklistItem).where( select(TripChecklistItem).where(
TripChecklistItem.id == id, TripChecklistItem.id == id,
TripChecklistItem.trip_id == trip_id, TripChecklistItem.trip_id == trip_id,
TripChecklistItem.user == current_user,
) )
).one_or_none() ).one_or_none()
@ -593,3 +662,122 @@ def delete_checklist_item(
session.delete(item) session.delete(item)
session.commit() session.commit()
return {} return {}
@router.get("/{trip_id}/members", response_model=list[TripMemberRead])
def read_trip_members(
session: SessionDep, trip_id: int, current_user: Annotated[str, Depends(get_current_username)]
) -> list[TripMemberRead]:
_verify_trip_member(session, trip_id, current_user)
members: list[TripMemberRead] = []
owner = session.exec(select(Trip.user).where(Trip.id == trip_id)).first()
members.append(TripMemberRead(user=owner, invited_by=None, invited_at=None, joined_at=None))
db_members = session.exec(select(TripMember).where(TripMember.trip_id == trip_id)).all()
members.extend(TripMemberRead.serialize(m) for m in db_members)
return members
@router.post("/{trip_id}/members", response_model=TripMemberRead)
def invite_trip_member(
session: SessionDep,
trip_id: int,
data: TripMemberCreate,
current_user: Annotated[str, Depends(get_current_username)],
) -> TripMemberRead:
db_trip = _get_trip_or_404(session, trip_id)
_verify_trip_member(session, trip_id, current_user)
if db_trip.user == data.user:
raise HTTPException(status_code=409, detail="The resource already exists")
exists = session.exec(
select(TripMember.id)
.where(
TripMember.trip_id == trip_id,
TripMember.user == data.user,
)
.limit(1)
).first()
if exists:
raise HTTPException(status_code=409, detail="The resource already exists")
db_user = session.get(User, data.user)
print(data.user, db_user)
if not db_user:
raise HTTPException(status_code=404, detail="Not found")
new_member = TripMember(trip_id=trip_id, user=data.user, invited_by=current_user)
session.add(new_member)
session.commit()
session.refresh(new_member)
return TripMemberRead.serialize(new_member)
@router.delete("/{trip_id}/members/{username}")
def delete_trip_member(
session: SessionDep,
trip_id: int,
username: str,
current_user: Annotated[str, Depends(get_current_username)],
):
print("yo")
db_trip = _get_trip_or_404(session, trip_id)
_verify_trip_member(session, trip_id, current_user)
if current_user == db_trip.user and current_user == username:
raise HTTPException(status_code=400, detail="Bad request")
if current_user != db_trip.user and current_user != username:
raise HTTPException(status_code=403, detail="Forbidden")
member = session.exec(
select(TripMember).where(
TripMember.user == username,
TripMember.trip_id == trip_id,
)
).one_or_none()
if not member:
raise HTTPException(status_code=404, detail="Not found")
session.delete(member)
session.commit()
return {}
@router.post("/{trip_id}/members/accept")
def accept_invite(
session: SessionDep,
trip_id: int,
current_user: Annotated[str, Depends(get_current_username)],
):
db_member = session.exec(
select(TripMember).where(TripMember.trip_id == trip_id, TripMember.user == current_user)
).one_or_none()
if not db_member:
raise HTTPException(status_code=404, detail="Not found")
if db_member.joined_at:
raise HTTPException(status_code=409, detail="Already a member")
db_member.joined_at = utc_now()
session.add(db_member)
session.commit()
return {}
@router.post("/{trip_id}/members/decline")
def decline_invite(
session: SessionDep,
trip_id: int,
current_user: Annotated[str, Depends(get_current_username)],
):
db_member = session.exec(
select(TripMember).where(TripMember.trip_id == trip_id, TripMember.user == current_user)
).one_or_none()
if not db_member:
raise HTTPException(status_code=404, detail="Not found")
if db_member.joined_at:
raise HTTPException(status_code=409, detail="Already a member")
session.delete(db_member)
session.commit()
return {}

View File

@ -1,5 +1,5 @@
import base64 import base64
from datetime import date from datetime import UTC, date, datetime
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from secrets import token_urlsafe from secrets import token_urlsafe
@ -48,6 +48,10 @@ def remove_image(path: str):
raise Exception("Error deleting image:", exc, path) raise Exception("Error deleting image:", exc, path)
def utc_now():
return datetime.now(UTC)
def parse_str_or_date_to_date(cdate: str | date) -> date: def parse_str_or_date_to_date(cdate: str | date) -> date:
if isinstance(cdate, str): if isinstance(cdate, str):
try: try:

View File

@ -15,6 +15,7 @@
<div class="flex items-center gap-2 print:hidden"> <div class="flex items-center gap-2 print:hidden">
@if (!trip?.archived) { @if (!trip?.archived) {
<div class="hidden md:flex items-center gap-2"> <div class="hidden md:flex items-center gap-2">
<p-button pTooltip="Manage users" text (click)="openMembersDialog()" icon="pi pi-users" />
<p-button pTooltip="Share Trip" text (click)="shareDialogVisible = true" icon="pi pi-share-alt" /> <p-button pTooltip="Share Trip" text (click)="shareDialogVisible = true" icon="pi pi-share-alt" />
<p-button pTooltip="Archive Trip" text (click)="toggleArchiveTrip()" icon="pi pi-box" severity="warn" /> <p-button pTooltip="Archive Trip" text (click)="toggleArchiveTrip()" icon="pi pi-box" severity="warn" />
<div class="border-l border-solid border-gray-700 h-4"></div> <div class="border-l border-solid border-gray-700 h-4"></div>
@ -616,3 +617,59 @@
</div> </div>
</section> </section>
</p-dialog> </p-dialog>
<p-dialog header="Members" [draggable]="false" [dismissableMask]="true" [modal]="true"
[(visible)]="membersDialogVisible" styleClass="w-[95%] md:w-[40%] lg:w-[30%]">
<section class="p-4 max-w-full max-h-[80%] md:max-h-[600px]">
<div class="flex justify-center">
<p-button (click)="addMember()" icon="pi pi-plus" label="Member" text />
</div>
<div class="divide-y divide-gray-100 mt-4 pb-4">
@for (m of tripMembers; track m.user) {
<div class="flex items-center justify-between gap-x-6 py-5">
<div class="flex items-center min-w-0 gap-x-4">
<div class="size-12 flex flex-none rounded-full items-center justify-center" [ngClass]="{
'bg-red-100': !m.invited_at,
'bg-gray-50': m.invited_at && !m.joined_at,
'bg-blue-100': m.invited_at && m.joined_at
}">
<i class="pi pi-user"></i>
</div>
<div class="flex min-w-0 gap-x-4">
<span class="capitalize truncate font-semibold text-gray-900">{{ m.user }}</span>
</div>
</div>
<div class="flex items-center gap-2">
<span
class="text-center bg-gray-100 text-gray-800 text-xs px-2.5 py-0.5 rounded-md group-hover:hidden dark:bg-gray-100/85">-
{{ currency$ | async }}</span>
<div class="hidden shrink-0 sm:flex sm:flex-col sm:items-end">
@if (!m.invited_at) {
<span
class="text-center bg-red-100 text-red-800 text-xs px-2.5 py-0.5 rounded-md group-hover:hidden dark:bg-red-100/85">Owner</span>
}
@if (m.invited_at && !m.joined_at) {
<span
class="text-center bg-gray-100 text-gray-800 text-xs px-2.5 py-0.5 rounded-md group-hover:hidden dark:bg-gray-100/85">Invited</span>
}
@if (m.joined_at) {
<span
class="text-center bg-blue-100 text-blue-800 text-xs px-2.5 py-0.5 rounded-md group-hover:hidden dark:bg-blue-100/85">Member</span>
}
</div>
@if (m.invited_at) {
<p-button text (click)="deleteMember(m.user)" icon="pi pi-trash" severity="danger" />
}
</div>
</div>
}
</div>
</section>
</p-dialog>

View File

@ -16,6 +16,7 @@ import {
TripStatus, TripStatus,
PackingItem, PackingItem,
ChecklistItem, ChecklistItem,
TripMember,
} from "../../types/trip"; } from "../../types/trip";
import { Place } from "../../types/poi"; import { Place } from "../../types/poi";
import { import {
@ -57,6 +58,7 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { CheckboxChangeEvent, CheckboxModule } from "primeng/checkbox"; import { CheckboxChangeEvent, CheckboxModule } from "primeng/checkbox";
import { TripCreatePackingModalComponent } from "../../modals/trip-create-packing-modal/trip-create-packing-modal.component"; import { TripCreatePackingModalComponent } from "../../modals/trip-create-packing-modal/trip-create-packing-modal.component";
import { TripCreateChecklistModalComponent } from "../../modals/trip-create-checklist-modal/trip-create-checklist-modal.component"; import { TripCreateChecklistModalComponent } from "../../modals/trip-create-checklist-modal/trip-create-checklist-modal.component";
import { TripInviteMemberModalComponent } from "../../modals/trip-invite-member-modal/trip-invite-member-modal.component";
@Component({ @Component({
selector: "app-trip", selector: "app-trip",
@ -106,6 +108,8 @@ export class TripComponent implements AfterViewInit {
checklistDialogVisible = false; checklistDialogVisible = false;
checklistItems: ChecklistItem[] = []; checklistItems: ChecklistItem[] = [];
dispchecklist: ChecklistItem[] = []; dispchecklist: ChecklistItem[] = [];
membersDialogVisible = false;
tripMembers: TripMember[] = [];
map?: L.Map; map?: L.Map;
markerClusterGroup?: L.MarkerClusterGroup; markerClusterGroup?: L.MarkerClusterGroup;
@ -134,6 +138,14 @@ export class TripComponent implements AfterViewInit {
this.openChecklist(); this.openChecklist();
}, },
}, },
{
label: "Members",
icon: "pi pi-users",
iconClass: "text-blue-500!",
command: () => {
this.openMembersDialog();
},
},
{ {
label: "Edit", label: "Edit",
icon: "pi pi-pencil", icon: "pi pi-pencil",
@ -1552,4 +1564,84 @@ export class TripComponent implements AfterViewInit {
}, },
}); });
} }
openMembersDialog() {
if (!this.trip) return;
if (!this.tripMembers.length)
this.apiService
.getTripMembers(this.trip.id)
.pipe(take(1))
.subscribe({
next: (items) => {
this.tripMembers = [...items];
},
});
this.membersDialogVisible = true;
}
addMember() {
if (!this.trip) return;
const modal: DynamicDialogRef = this.dialogService.open(
TripInviteMemberModalComponent,
{
header: "Invite member",
modal: true,
appendTo: "body",
closable: true,
dismissableMask: true,
width: "40vw",
breakpoints: {
"1260px": "70vw",
"600px": "90vw",
},
},
);
modal.onClose.pipe(take(1)).subscribe({
next: (user: string | null) => {
if (!user) return;
this.apiService
.inviteTripMember(this.trip!.id, user)
.pipe(take(1))
.subscribe({
next: (member) => {
this.tripMembers = [...this.tripMembers, member];
},
});
},
});
}
deleteMember(username: string) {
const modal = this.dialogService.open(YesNoModalComponent, {
header: "Confirm deletion",
modal: true,
closable: true,
dismissableMask: true,
breakpoints: {
"640px": "90vw",
},
data: `Delete ${username.substring(0, 50)} from Trip ?`,
});
modal.onClose.pipe(take(1)).subscribe({
next: (bool) => {
if (!bool) return;
this.apiService
.deleteTripMember(this.trip!.id, username)
.pipe(take(1))
.subscribe({
next: () => {
const index = this.tripMembers.findIndex(
(p) => p.user == username,
);
if (index > -1) this.tripMembers.splice(index, 1);
},
});
},
});
}
} }

View File

@ -4,7 +4,10 @@
<img src="favicon.png" (click)="gotoMap()" class="cursor-pointer w-24" /> <img src="favicon.png" (click)="gotoMap()" class="cursor-pointer w-24" />
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<p-button icon="pi pi-map" (click)="gotoMap()" text /> <div class="relative">
<p-button icon="pi pi-bell" (click)="toggleInvitations()" text />
@if(hasPendingInvitations) {<div class="absolute top-1 right-2 size-2 bg-red-400 rounded-full"></div>}
</div>
<p-button icon="pi pi-plus" (click)="addTrip()" text /> <p-button icon="pi pi-plus" (click)="addTrip()" text />
</div> </div>
</div> </div>
@ -19,7 +22,13 @@
<div class="absolute inset-0 bg-black/5 flex flex-col justify-end p-4 text-white"> <div class="absolute inset-0 bg-black/5 flex flex-col justify-end p-4 text-white">
<h3 class="text-lg font-semibold line-clamp-2">{{ trip.name }}</h3> <h3 class="text-lg font-semibold line-clamp-2">{{ trip.name }}</h3>
<p class="text-sm ">{{ trip.days || 0 }} {{ trip.days > 1 ? 'days' : 'day'}}</p>
<div class="flex items-center gap-2 text-sm">
<p>{{ trip.days || 0 }} {{ trip.days > 1 ? 'days' : 'day'}}</p>
@if (trip.collaborators.length) {- <p>{{ trip.collaborators.length + 1 }} user{{ trip.collaborators.length > 0
? 's'
: '' }}</p>}
</div>
<i <i
class="pi pi-arrow-right text-xl absolute right-4 bottom-4 h-4 opacity-0 -translate-x-4 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-300"></i> class="pi pi-arrow-right text-xl absolute right-4 bottom-4 h-4 opacity-0 -translate-x-4 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-300"></i>
@ -49,3 +58,39 @@
} }
</div> </div>
</div> </div>
<p-dialog header="Invitations" [draggable]="false" [dismissableMask]="true" [modal]="true"
[(visible)]="invitationsDialogVisible" styleClass="w-[95%] md:w-[40%] lg:w-[30%]">
<section class="p-4 max-w-full max-h-[80%] md:max-h-[600px]">
<div class="divide-y divide-gray-100">
@for (inv of invitations; track inv.id) {
<div class="flex items-center justify-between gap-x-6 py-5">
<div class="flex items-center min-w-0 gap-x-4">
<img [src]="inv.image" class="w-20 rounded-full object-cover">
<div class="flex flex-col min-w-0 gap-1">
<div class="capitalize truncate text-md font-semibold text-gray-900">{{ inv.name }}</div>
<div class="text-xs md:text-sm text-gray-500">On <i>{{ inv.invited_at | date: 'short'}}</i>, <i>{{
inv.invited_by
}}</i> invited you to collaborate </div>
<div class="flex justify-center md:hidden gap-4">
<p-button text (click)="acceptInvitation(inv.id)" icon="pi pi-check" severity="success" />
<p-button text (click)="declineInvitation(inv.id)" icon="pi pi-times" severity="danger" />
</div>
</div>
</div>
<div class="hidden md:flex gap-4">
<p-button text (click)="acceptInvitation(inv.id)" icon="pi pi-check" severity="success" />
<p-button text (click)="declineInvitation(inv.id)" icon="pi pi-times" severity="danger" />
</div>
</div>
} @empty {
<div class="text-center">
No invitation
</div>
}
</div>
</section>
</p-dialog>

View File

@ -2,11 +2,13 @@ import { Component, OnInit } from "@angular/core";
import { ApiService } from "../../services/api.service"; import { ApiService } from "../../services/api.service";
import { ButtonModule } from "primeng/button"; import { ButtonModule } from "primeng/button";
import { SkeletonModule } from "primeng/skeleton"; import { SkeletonModule } from "primeng/skeleton";
import { TripBase } from "../../types/trip"; import { TripBase, TripInvitation } from "../../types/trip";
import { DialogService, DynamicDialogRef } from "primeng/dynamicdialog"; import { DialogService, DynamicDialogRef } from "primeng/dynamicdialog";
import { TripCreateModalComponent } from "../../modals/trip-create-modal/trip-create-modal.component"; import { TripCreateModalComponent } from "../../modals/trip-create-modal/trip-create-modal.component";
import { Router } from "@angular/router"; import { Router } from "@angular/router";
import { forkJoin, take } from "rxjs"; import { forkJoin, take } from "rxjs";
import { DatePipe } from "@angular/common";
import { DialogModule } from "primeng/dialog";
interface TripBaseWithDates extends TripBase { interface TripBaseWithDates extends TripBase {
from?: Date; from?: Date;
@ -16,12 +18,15 @@ interface TripBaseWithDates extends TripBase {
@Component({ @Component({
selector: "app-trips", selector: "app-trips",
standalone: true, standalone: true,
imports: [SkeletonModule, ButtonModule], imports: [SkeletonModule, ButtonModule, DialogModule, DatePipe],
templateUrl: "./trips.component.html", templateUrl: "./trips.component.html",
styleUrls: ["./trips.component.scss"], styleUrls: ["./trips.component.scss"],
}) })
export class TripsComponent implements OnInit { export class TripsComponent implements OnInit {
trips: TripBase[] = []; trips: TripBase[] = [];
hasPendingInvitations = false;
invitations: TripInvitation[] = [];
invitationsDialogVisible = false;
constructor( constructor(
private apiService: ApiService, private apiService: ApiService,
@ -39,6 +44,12 @@ export class TripsComponent implements OnInit {
this.sortTrips(); this.sortTrips();
}, },
}); });
this.apiService
.getHasTripsInvitations()
.pipe(take(1))
.subscribe({
next: (bool) => (this.hasPendingInvitations = bool),
});
} }
gotoMap() { gotoMap() {
@ -137,4 +148,50 @@ export class TripsComponent implements OnInit {
} }
return labels; return labels;
} }
toggleInvitations() {
if (!this.invitations.length)
this.apiService
.getTripsInvitations()
.pipe(take(1))
.subscribe({
next: (items) => {
this.invitations = [...items];
},
});
this.invitationsDialogVisible = true;
}
removeInvitationAndHide(trip_id: number) {
this.invitations = this.invitations.filter((inv) => inv.id != trip_id);
this.hasPendingInvitations = !!this.invitations.length;
this.invitationsDialogVisible = false;
}
acceptInvitation(trip_id: number) {
this.apiService
.acceptTripMemberInvite(trip_id)
.pipe(take(1))
.subscribe({
next: () => {
const index = this.invitations.findIndex((inv) => inv.id == trip_id);
if (index > -1) {
this.trips = [...this.trips, this.invitations[index]];
this.sortTrips();
}
this.removeInvitationAndHide(trip_id);
},
});
}
declineInvitation(trip_id: number) {
this.apiService
.declineTripMemberInvite(trip_id)
.pipe(take(1))
.subscribe({
next: () => {
this.removeInvitationAndHide(trip_id);
},
});
}
} }

View File

@ -0,0 +1,9 @@
<div pFocusTrap class="grid items-center gap-4">
<p-floatlabel variant="in">
<input class="col-span-2" id="name" [formControl]="memberForm" (keypress.enter)="closeDialog()" pInputText fluid />
<label for="name">Name</label>
</p-floatlabel>
<div class="mt-4 text-right">
<p-button (click)="closeDialog()" [disabled]="!memberForm.value">Invite</p-button>
</div>

View File

@ -0,0 +1,32 @@
import { Component } from "@angular/core";
import { FormControl, ReactiveFormsModule } from "@angular/forms";
import { ButtonModule } from "primeng/button";
import { DynamicDialogRef } from "primeng/dynamicdialog";
import { FloatLabelModule } from "primeng/floatlabel";
import { InputTextModule } from "primeng/inputtext";
import { FocusTrapModule } from "primeng/focustrap";
@Component({
selector: "app-trip-invite-member-modal",
imports: [
FloatLabelModule,
InputTextModule,
ButtonModule,
ReactiveFormsModule,
FocusTrapModule,
],
standalone: true,
templateUrl: "./trip-invite-member-modal.component.html",
styleUrl: "./trip-invite-member-modal.component.scss",
})
export class TripInviteMemberModalComponent {
memberForm = new FormControl("");
constructor(private ref: DynamicDialogRef) {}
closeDialog() {
if (!this.memberForm.value) return;
// Normalize data for API POST
this.ref.close(this.memberForm.value);
}
}

View File

@ -1,5 +1,5 @@
import { inject, Injectable } from "@angular/core"; import { inject, Injectable } from "@angular/core";
import { HttpClient } from "@angular/common/http"; import { HttpClient, HttpHeaders } from "@angular/common/http";
import { Category, Place } from "../types/poi"; import { Category, Place } from "../types/poi";
import { BehaviorSubject, map, Observable, shareReplay, tap } from "rxjs"; import { BehaviorSubject, map, Observable, shareReplay, tap } from "rxjs";
import { Info } from "../types/info"; import { Info } from "../types/info";
@ -11,7 +11,9 @@ import {
Trip, Trip,
TripBase, TripBase,
TripDay, TripDay,
TripInvitation,
TripItem, TripItem,
TripMember,
} from "../types/trip"; } from "../types/trip";
const NO_AUTH_HEADER = { const NO_AUTH_HEADER = {
@ -312,6 +314,51 @@ export class ApiService {
); );
} }
getHasTripsInvitations(): Observable<boolean> {
return this.httpClient.get<boolean>(
`${this.apiBaseUrl}/trips/invitations/pending`,
);
}
getTripsInvitations(): Observable<TripInvitation[]> {
return this.httpClient.get<TripInvitation[]>(
`${this.apiBaseUrl}/trips/invitations`,
);
}
getTripMembers(trip_id: number): Observable<TripMember[]> {
return this.httpClient.get<TripMember[]>(
`${this.apiBaseUrl}/trips/${trip_id}/members`,
);
}
deleteTripMember(trip_id: number, username: string): Observable<null> {
return this.httpClient.delete<null>(
`${this.apiBaseUrl}/trips/${trip_id}/members/${username}`,
);
}
inviteTripMember(trip_id: number, user: string): Observable<TripMember> {
return this.httpClient.post<TripMember>(
`${this.apiBaseUrl}/trips/${trip_id}/members`,
{ user },
);
}
acceptTripMemberInvite(trip_id: number): Observable<null> {
return this.httpClient.post<null>(
`${this.apiBaseUrl}/trips/${trip_id}/members/accept`,
{},
);
}
declineTripMemberInvite(trip_id: number): Observable<null> {
return this.httpClient.post<null>(
`${this.apiBaseUrl}/trips/${trip_id}/members/decline`,
{},
);
}
checkVersion(): Observable<string> { checkVersion(): Observable<string> {
return this.httpClient.get<string>( return this.httpClient.get<string>(
`${this.apiBaseUrl}/settings/checkversion`, `${this.apiBaseUrl}/settings/checkversion`,

View File

@ -7,6 +7,7 @@ export interface TripBase {
archived?: boolean; archived?: boolean;
user: string; user: string;
days: number; days: number;
collaborators: TripMember[];
} }
export interface Trip { export interface Trip {
@ -16,6 +17,7 @@ export interface Trip {
archived?: boolean; archived?: boolean;
user: string; user: string;
days: TripDay[]; days: TripDay[];
collaborators: TripMember[];
// POST / PUT // POST / PUT
places: Place[]; places: Place[];
@ -62,6 +64,18 @@ export interface FlattenedTripItem {
status?: TripStatus; status?: TripStatus;
} }
export interface TripMember {
user: string;
invited_by: string;
invited_at: string;
joined_at?: string;
}
export interface TripInvitation extends TripBase {
invited_by: string;
invited_at: string;
}
export interface SharedTripURL { export interface SharedTripURL {
url: string; url: string;
} }