-
Notifications
You must be signed in to change notification settings - Fork 0
Added invitation CRUD #128
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial Index on Line 7 declares 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 |
||
| 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 | ||
| 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"` | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Contradictory bun tags: The Consider removing 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
Suggested change
🤖 Prompt for AI Agents |
||||||
| IsRevoked bool `bun:"is_revoked,notnull" json:"is_revoked"` | ||||||
| CreatedAt time.Time `bun:"created_at,nullzero" json:"created_at"` | ||||||
| } | ||||||
|
|
||||||
| func (TripInvite) TableName() string { | ||||||
|
||||||
| 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"` | ||||||
| } | ||||||
| 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 | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Optional helper extractionfunc 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 |
||
|
|
||
| // 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 { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.