Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
40 changes: 40 additions & 0 deletions backend/internal/controllers/membership.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,46 @@ func NewMembershipController(membershipService services.MembershipServiceInterfa
}
}

// @Summary Join trip by invite code
// @Description Adds the authenticated user to a trip using an invite code
// @Tags memberships
// @Produce json
// @Param code path string true "Invite code"
// @Success 201 {object} models.Membership
// @Failure 400 {object} errs.APIError "Invalid or expired invite code"
// @Failure 401 {object} errs.APIError
// @Failure 500 {object} errs.APIError
// @Router /api/v1/trip-invites/{code}/join [post]
// @ID joinTripByInvite
func (ctrl *MembershipController) JoinTripByInvite(c *fiber.Ctx) error {
userIDValue := c.Locals("userID")
if userIDValue == nil {
return errs.Unauthorized()
}

userIDStr, ok := userIDValue.(string)
if !ok {
return errs.Unauthorized()
}

userID, err := validators.ValidateID(userIDStr)
if err != nil {
return errs.Unauthorized()
}

code := c.Params("code")
if code == "" {
return errs.BadRequest(errors.New("invite code is required"))
}

membership, err := ctrl.membershipService.JoinTripByInviteCode(c.Context(), userID, code)
if err != nil {
return err
}

return c.Status(http.StatusCreated).JSON(membership)
}

// @Summary Add member to trip
// @Description Adds a user as a member of a trip
// @Tags memberships
Expand Down
51 changes: 51 additions & 0 deletions backend/internal/controllers/trips.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,57 @@ func (ctrl *TripController) UpdateTrip(c *fiber.Ctx) error {
return c.Status(http.StatusOK).JSON(trip)
}

// @Summary Create a trip invite
// @Description Creates a shareable invite for the trip. Caller must be a trip member.
// @Tags trips
// @Accept json
// @Produce json
// @Param tripID path string true "Trip ID"
// @Param request body models.CreateTripInviteRequest true "Optional expires_at; default 7 days"
// @Success 201 {object} models.TripInviteAPIResponse
// @Failure 400 {object} errs.APIError
// @Failure 401 {object} errs.APIError
// @Failure 404 {object} errs.APIError
// @Failure 422 {object} errs.APIError
// @Failure 500 {object} errs.APIError
// @Router /api/v1/trips/{tripID}/invites [post]
// @ID createTripInvite
func (ctrl *TripController) CreateTripInvite(c *fiber.Ctx) error {
userIDValue := c.Locals("userID")
if userIDValue == nil {
return errs.Unauthorized()
}

userIDStr, ok := userIDValue.(string)
if !ok {
return errs.Unauthorized()
}

userID, err := validators.ValidateID(userIDStr)
if err != nil {
return errs.Unauthorized()
}

tripID, err := validators.ValidateID(c.Params("tripID"))
if err != nil {
return errs.InvalidUUID()
}

var req models.CreateTripInviteRequest
_ = c.BodyParser(&req) // optional body; empty or {} uses default expiry

if err := validators.Validate(ctrl.validator, req); err != nil {
return err
}

invite, err := ctrl.tripService.CreateTripInvite(c.Context(), tripID, userID, req)
if err != nil {
return err
}

return c.Status(http.StatusCreated).JSON(invite)
}

// @Summary Delete a trip
// @Description Deletes a trip by ID
// @Tags trips
Expand Down
24 changes: 24 additions & 0 deletions backend/internal/migrations/20260205035655_create_trip_invite.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE trip_invites (
id UUID PRIMARY KEY,
trip_id UUID NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code TEXT NOT NULL UNIQUE,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
is_revoked BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);

CREATE INDEX idx_trip_invites_trip_id ON trip_invites(trip_id);
CREATE INDEX idx_trip_invites_code ON trip_invites(code);
Comment on lines +7 to +14
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Index on code is redundant with UNIQUE constraint.

Line 7 declares code TEXT NOT NULL UNIQUE, which automatically creates a unique index. Line 14 creates an additional index idx_trip_invites_code on the same column. This duplicate index wastes storage and slows writes.

Remove redundant index
 CREATE INDEX idx_trip_invites_trip_id ON trip_invites(trip_id);
-CREATE INDEX idx_trip_invites_code ON trip_invites(code);
 CREATE INDEX idx_trip_invites_created_by ON trip_invites(created_by);

Also update the down migration:

 DROP INDEX IF EXISTS idx_trip_invites_created_by;
-DROP INDEX IF EXISTS idx_trip_invites_code;
 DROP INDEX IF EXISTS idx_trip_invites_trip_id;
🤖 Prompt for AI Agents
In `@backend/internal/migrations/20260205035655_create_trip_invite.sql` around
lines 7 - 14, Remove the redundant non-unique index creation for the `code`
column: delete the `CREATE INDEX idx_trip_invites_code ON trip_invites(code);`
statement (the `code TEXT NOT NULL UNIQUE` already creates a unique index) and
update the down migration so it no longer tries to DROP `idx_trip_invites_code`
(or remove any DROP INDEX for that name) to keep up/down symmetric; leave the
`idx_trip_invites_trip_id` index and the `code` UNIQUE constraint intact.

CREATE INDEX idx_trip_invites_created_by ON trip_invites(created_by);
-- +goose StatementEnd

-- +goose Down
-- +goose StatementBegin
DROP INDEX IF EXISTS idx_trip_invites_created_by;
DROP INDEX IF EXISTS idx_trip_invites_code;
DROP INDEX IF EXISTS idx_trip_invites_trip_id;
DROP TABLE IF EXISTS trip_invites;
-- +goose StatementEnd
40 changes: 40 additions & 0 deletions backend/internal/models/trip_invites.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package models

import (
"time"

"github.com/google/uuid"
)

// TripInvite represents a shareable invite for a trip.
type TripInvite struct {
ID uuid.UUID `bun:"id,pk,type:uuid" json:"id"`
TripID uuid.UUID `bun:"trip_id,type:uuid,notnull" json:"trip_id"`
CreatedBy uuid.UUID `bun:"created_by,type:uuid,notnull" json:"created_by"`
Code string `bun:"code,notnull" json:"code"`
ExpiresAt time.Time `bun:"expires_at,nullzero,notnull" json:"expires_at"`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Contradictory bun tags: nullzero with notnull on expires_at.

The nullzero tag instructs bun to convert Go's zero time.Time to SQL NULL, while notnull declares the column cannot be NULL. If ExpiresAt is ever unset (zero value), the insert will fail at the database level. The service currently always sets this field, but this tag combination is error-prone for future maintainers.

Consider removing nullzero since ExpiresAt is required:

Proposed fix
-	ExpiresAt time.Time `bun:"expires_at,nullzero,notnull" json:"expires_at"`
+	ExpiresAt time.Time `bun:"expires_at,notnull" json:"expires_at"`
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
ExpiresAt time.Time `bun:"expires_at,nullzero,notnull" json:"expires_at"`
ExpiresAt time.Time `bun:"expires_at,notnull" json:"expires_at"`
🤖 Prompt for AI Agents
In `@backend/internal/models/trip_invites.go` at line 15, The struct field
ExpiresAt on the trip invite model uses contradictory bun tags `nullzero` and
`notnull`; remove the `nullzero` tag so the bun tag becomes `notnull` (and any
other intended options like `expires_at`) to ensure the column is required.
Locate the ExpiresAt field in the trip invite model (symbol ExpiresAt in
trip_invites.go), delete `nullzero` from its struct tag, run tests and any
schema/migration checks to ensure the DB column remains non-nullable.

IsRevoked bool `bun:"is_revoked,notnull" json:"is_revoked"`
CreatedAt time.Time `bun:"created_at,nullzero" json:"created_at"`
}

func (TripInvite) TableName() string {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we remove this, i don't think it's being used anywhere

Copy link
Contributor Author

@jleung40 jleung40 Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which thing are you referring to? the created at or tablename()

return "trip_invites"
}

// CreateTripInviteRequest is the request body for creating a trip invite.
// If ExpiresAt is nil, a default (e.g. 7 days) is applied in the service.
type CreateTripInviteRequest struct {
ExpiresAt *time.Time `json:"expires_at" validate:"omitempty"`
}

// TripInviteAPIResponse is the API response for a trip invite.
type TripInviteAPIResponse struct {
ID uuid.UUID `json:"id"`
TripID uuid.UUID `json:"trip_id"`
CreatedBy uuid.UUID `json:"created_by"`
Code string `json:"code"`
ExpiresAt time.Time `json:"expires_at"`
IsRevoked bool `json:"is_revoked"`
CreatedAt time.Time `json:"created_at"`
JoinURL *string `json:"join_url,omitempty"`
}
22 changes: 15 additions & 7 deletions backend/internal/repository/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,14 @@ import (
)

type Repository struct {
User UserRepository
Health HealthRepository
Image ImageRepository
Comment CommentRepository
Membership MembershipRepository
Trip TripRepository
db *bun.DB
User UserRepository
Health HealthRepository
Image ImageRepository
Comment CommentRepository
Membership MembershipRepository
Trip TripRepository
TripInvite TripInviteRepository
db *bun.DB
}

func NewRepository(db *bun.DB) *Repository {
Expand All @@ -27,6 +28,7 @@ func NewRepository(db *bun.DB) *Repository {
Comment: &commentRepository{db: db},
Trip: &tripRepository{db: db},
Membership: &membershipRepository{db: db},
TripInvite: newTripInviteRepository(db),
db: db,
}
}
Expand Down Expand Up @@ -58,6 +60,12 @@ type TripRepository interface {
Delete(ctx context.Context, id uuid.UUID) error
}

type TripInviteRepository interface {
Create(ctx context.Context, invite *models.TripInvite) (*models.TripInvite, error)
FindByID(ctx context.Context, id uuid.UUID) (*models.TripInvite, error)
FindByCode(ctx context.Context, code string) (*models.TripInvite, error)
}

type MembershipRepository interface {
Create(ctx context.Context, membership *models.Membership) (*models.Membership, error)
Find(ctx context.Context, userID, tripID uuid.UUID) (*models.MembershipDatabaseResponse, error)
Expand Down
66 changes: 66 additions & 0 deletions backend/internal/repository/trip_invite.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package repository

import (
"context"
"database/sql"
"errors"
"toggo/internal/errs"
"toggo/internal/models"

"github.com/google/uuid"
"github.com/uptrace/bun"
)

var _ TripInviteRepository = (*tripInviteRepository)(nil)

type tripInviteRepository struct {
db *bun.DB
}

func newTripInviteRepository(db *bun.DB) TripInviteRepository {
return &tripInviteRepository{db: db}
}

// Create inserts a new trip invite.
func (r *tripInviteRepository) Create(ctx context.Context, invite *models.TripInvite) (*models.TripInvite, error) {
_, err := r.db.NewInsert().
Model(invite).
Returning("*").
Exec(ctx)
if err != nil {
return nil, err
}
return invite, nil
}

// FindByID returns a trip invite by id.
func (r *tripInviteRepository) FindByID(ctx context.Context, id uuid.UUID) (*models.TripInvite, error) {
invite := &models.TripInvite{}
err := r.db.NewSelect().
Model(invite).
Where("id = ?", id).
Scan(ctx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, errs.ErrNotFound
}
return nil, err
}
return invite, nil
}

// FindByCode returns a trip invite by its code.
func (r *tripInviteRepository) FindByCode(ctx context.Context, code string) (*models.TripInvite, error) {
invite := &models.TripInvite{}
err := r.db.NewSelect().
Model(invite).
Where("code = ?", code).
Scan(ctx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, errs.ErrNotFound
}
return nil, err
}
return invite, nil
}
3 changes: 3 additions & 0 deletions backend/internal/server/routers/membership.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ func MembershipRoutes(apiGroup fiber.Router, routeParams types.RouteParams) fibe
membershipGroup := apiGroup.Group("/memberships")
membershipGroup.Post("", membershipController.AddMember)

// /api/v1/trip-invites/:code/join
apiGroup.Post("/trip-invites/:code/join", membershipController.JoinTripByInvite)

// /api/v1/trips/:tripID/memberships
tripMembershipGroup := apiGroup.Group("/trips/:tripID/memberships")
tripMembershipGroup.Use(middlewares.TripMemberRequired(routeParams.ServiceParams.Repository))
Expand Down
1 change: 1 addition & 0 deletions backend/internal/server/routers/trips.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ func TripRoutes(apiGroup fiber.Router, routeParams types.RouteParams) fiber.Rout
tripIDGroup.Get("", tripController.GetTrip)
tripIDGroup.Patch("", tripController.UpdateTrip)
tripIDGroup.Delete("", tripController.DeleteTrip)
tripIDGroup.Post("/invites", tripController.CreateTripInvite)

return tripGroup
}
73 changes: 73 additions & 0 deletions backend/internal/services/membership.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (

type MembershipServiceInterface interface {
AddMember(ctx context.Context, req models.CreateMembershipRequest) (*models.Membership, error)
JoinTripByInviteCode(ctx context.Context, userID uuid.UUID, code string) (*models.Membership, error)
GetMembership(ctx context.Context, tripID, userID uuid.UUID) (*models.MembershipAPIResponse, error)
GetTripMembers(ctx context.Context, tripID uuid.UUID, limit int, cursorToken string) (*models.MembershipCursorPageResult, error)
GetUserTrips(ctx context.Context, userID uuid.UUID) ([]*models.Membership, error)
Expand Down Expand Up @@ -84,6 +85,78 @@ func (s *MembershipService) AddMember(ctx context.Context, req models.CreateMemb
return s.Membership.Create(ctx, membership)
}

// JoinTripByInviteCode adds the authenticated user to a trip using an invite code.
// - If the code is invalid -> error
// - If the invite is expired or revoked -> error
// - If the user is already a member -> returns existing membership (no error)
func (s *MembershipService) JoinTripByInviteCode(ctx context.Context, userID uuid.UUID, code string) (*models.Membership, error) {
invite, err := s.TripInvite.FindByCode(ctx, code)
if err != nil {
if errors.Is(err, errs.ErrNotFound) {
return nil, errs.BadRequest(errors.New("invalid invite code"))
}
return nil, err
}

now := time.Now().UTC()
if invite.IsRevoked || invite.ExpiresAt.Before(now) {
return nil, errs.BadRequest(errors.New("invite link has expired"))
}

// If already a member, return existing membership.
existingMembership, err := s.Membership.Find(ctx, userID, invite.TripID)
if err == nil {
return &models.Membership{
UserID: existingMembership.UserID,
TripID: existingMembership.TripID,
IsAdmin: existingMembership.IsAdmin,
BudgetMin: existingMembership.BudgetMin,
BudgetMax: existingMembership.BudgetMax,
Availability: existingMembership.Availability,
CreatedAt: existingMembership.CreatedAt,
UpdatedAt: existingMembership.UpdatedAt,
}, nil
}
if !errors.Is(err, errs.ErrNotFound) {
return nil, err
}
Comment on lines +106 to +122
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider extracting membership conversion to a helper.

The code that converts MembershipDatabaseResponse to Membership (lines 109-118, 143-152) is duplicated here and also appears in AddMember (lines 62-71). A helper method would reduce repetition.

Optional helper extraction
func toMembership(m *models.MembershipDatabaseResponse) *models.Membership {
	return &models.Membership{
		UserID:       m.UserID,
		TripID:       m.TripID,
		IsAdmin:      m.IsAdmin,
		BudgetMin:    m.BudgetMin,
		BudgetMax:    m.BudgetMax,
		Availability: m.Availability,
		CreatedAt:    m.CreatedAt,
		UpdatedAt:    m.UpdatedAt,
	}
}

Also applies to: 135-155

🤖 Prompt for AI Agents
In `@backend/internal/services/membership.go` around lines 106 - 122, Extract the
duplicated conversion logic into a single helper, e.g., create a function
membershipFromDB(m *models.MembershipDatabaseResponse) *models.Membership that
maps UserID, TripID, IsAdmin, BudgetMin, BudgetMax, Availability, CreatedAt and
UpdatedAt; then replace the inline conversions in methods that call
s.Membership.Find and AddMember (the blocks that build a models.Membership from
existingMembership/newMembership) to call membershipFromDB(existingMembership)
(or membershipFromDB(newMembership)) instead.


// Not a member yet; create a basic membership.
membership := &models.Membership{
UserID: userID,
TripID: invite.TripID,
IsAdmin: false,
BudgetMin: 0,
BudgetMax: 0,
CreatedAt: now,
UpdatedAt: now,
}

created, err := s.Membership.Create(ctx, membership)
if err != nil {
// If there was a race and the membership already exists, treat as success.
if errors.Is(err, errs.ErrDuplicate) {
existingMembership, findErr := s.Membership.Find(ctx, userID, invite.TripID)
if findErr != nil {
return nil, findErr
}
return &models.Membership{
UserID: existingMembership.UserID,
TripID: existingMembership.TripID,
IsAdmin: existingMembership.IsAdmin,
BudgetMin: existingMembership.BudgetMin,
BudgetMax: existingMembership.BudgetMax,
Availability: existingMembership.Availability,
CreatedAt: existingMembership.CreatedAt,
UpdatedAt: existingMembership.UpdatedAt,
}, nil
}
return nil, err
}

return created, nil
}

func (s *MembershipService) GetMembership(ctx context.Context, tripID, userID uuid.UUID) (*models.MembershipAPIResponse, error) {
membership, err := s.Membership.Find(ctx, userID, tripID)
if err != nil {
Expand Down
Loading
Loading