Feature: Shared lists & Invite Code invitation mechanism
- Backend:
- Migration 000002: add invite_code column to lists & list_members auto-population
- ListStore: GetLists/GetList check owner_id & list_members; add GetInviteCode & JoinByInviteCode
- HTTP API: add POST /api/lists/{id}/invite & POST /api/lists/join
- Android App:
- DTOs & MitbringslApi: add JoinListRequestDto, InviteCodeResponseDto & endpoints
- ListsScreen & ViewModel: add 'Liste beitreten' action button & dialog for entering invite code
- ListDetailScreen & ViewModel: add 'Liste teilen' action icon in top bar with System Share Sheet
- ADB reinstall & launch verified ✅
This commit is contained in:
parent
47b24dfcdf
commit
69591df12a
11 changed files with 457 additions and 56 deletions
|
|
@ -3,6 +3,7 @@ package store
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
|
@ -11,13 +12,14 @@ import (
|
|||
|
||||
// List is the projection of a list row.
|
||||
type List struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OwnerID uuid.UUID `json:"owner_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
HLCTS int64 `json:"hlc_ts"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OwnerID uuid.UUID `json:"owner_id"`
|
||||
InviteCode string `json:"invite_code,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
HLCTS int64 `json:"hlc_ts"`
|
||||
}
|
||||
|
||||
// ListStore provides read/write access to the lists projection.
|
||||
|
|
@ -28,32 +30,56 @@ type ListStore struct {
|
|||
// NewListStore creates a ListStore backed by the given pool.
|
||||
func NewListStore(pool *pgxpool.Pool) *ListStore { return &ListStore{pool: pool} }
|
||||
|
||||
// CreateList inserts a new list owned by ownerID.
|
||||
// In the MVP, list creation is a direct INSERT (no op required).
|
||||
// The caller is responsible for emitting a list_create op if desired for full
|
||||
// op-log coverage; for Phase C the REST endpoint does a direct insert.
|
||||
// CreateList inserts a new list owned by ownerID and automatically adds an entry into list_members.
|
||||
func (s *ListStore) CreateList(ctx context.Context, ownerID uuid.UUID, name string) (*List, error) {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("liststore: begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
inviteCode := strings.ToUpper(uuid.New().String()[:8])
|
||||
|
||||
var l List
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO lists (name, owner_id)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id, name, owner_id, created_at, updated_at, hlc_ts`,
|
||||
name, ownerID,
|
||||
).Scan(&l.ID, &l.Name, &l.OwnerID, &l.CreatedAt, &l.UpdatedAt, &l.HLCTS)
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO lists (name, owner_id, invite_code)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, name, owner_id, COALESCE(invite_code, ''), created_at, updated_at, hlc_ts`,
|
||||
name, ownerID, inviteCode,
|
||||
).Scan(&l.ID, &l.Name, &l.OwnerID, &l.InviteCode, &l.CreatedAt, &l.UpdatedAt, &l.HLCTS)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("liststore: create list: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO list_members (list_id, user_id, role)
|
||||
VALUES ($1, $2, 'owner')
|
||||
ON CONFLICT DO NOTHING`,
|
||||
l.ID, ownerID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("liststore: insert owner member: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, fmt.Errorf("liststore: commit tx: %w", err)
|
||||
}
|
||||
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
// GetLists returns all non-deleted lists owned by ownerID, newest first.
|
||||
func (s *ListStore) GetLists(ctx context.Context, ownerID uuid.UUID) ([]List, error) {
|
||||
// GetLists returns all non-deleted lists accessible by userID (as owner or member), newest first.
|
||||
func (s *ListStore) GetLists(ctx context.Context, userID uuid.UUID) ([]List, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, name, owner_id, created_at, updated_at, hlc_ts
|
||||
FROM lists
|
||||
WHERE owner_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC`,
|
||||
ownerID,
|
||||
SELECT l.id, l.name, l.owner_id, COALESCE(l.invite_code, ''), l.created_at, l.updated_at, l.hlc_ts
|
||||
FROM lists l
|
||||
WHERE l.deleted_at IS NULL AND (
|
||||
l.owner_id = $1 OR EXISTS (
|
||||
SELECT 1 FROM list_members lm WHERE lm.list_id = l.id AND lm.user_id = $1
|
||||
)
|
||||
)
|
||||
ORDER BY l.created_at DESC`,
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("liststore: get lists: %w", err)
|
||||
|
|
@ -63,7 +89,7 @@ func (s *ListStore) GetLists(ctx context.Context, ownerID uuid.UUID) ([]List, er
|
|||
var lists []List
|
||||
for rows.Next() {
|
||||
var l List
|
||||
if err := rows.Scan(&l.ID, &l.Name, &l.OwnerID, &l.CreatedAt, &l.UpdatedAt, &l.HLCTS); err != nil {
|
||||
if err := rows.Scan(&l.ID, &l.Name, &l.OwnerID, &l.InviteCode, &l.CreatedAt, &l.UpdatedAt, &l.HLCTS); err != nil {
|
||||
return nil, fmt.Errorf("liststore: scan list row: %w", err)
|
||||
}
|
||||
lists = append(lists, l)
|
||||
|
|
@ -71,18 +97,67 @@ func (s *ListStore) GetLists(ctx context.Context, ownerID uuid.UUID) ([]List, er
|
|||
return lists, rows.Err()
|
||||
}
|
||||
|
||||
// GetList returns a single non-deleted list, checking that userID is the owner.
|
||||
// Returns pgx.ErrNoRows if not found or not owned by userID.
|
||||
// GetList returns a single non-deleted list if userID is owner or member.
|
||||
func (s *ListStore) GetList(ctx context.Context, listID, userID uuid.UUID) (*List, error) {
|
||||
var l List
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, name, owner_id, created_at, updated_at, hlc_ts
|
||||
FROM lists
|
||||
WHERE id = $1 AND owner_id = $2 AND deleted_at IS NULL`,
|
||||
SELECT l.id, l.name, l.owner_id, COALESCE(l.invite_code, ''), l.created_at, l.updated_at, l.hlc_ts
|
||||
FROM lists l
|
||||
WHERE l.id = $1 AND l.deleted_at IS NULL AND (
|
||||
l.owner_id = $2 OR EXISTS (
|
||||
SELECT 1 FROM list_members lm WHERE lm.list_id = l.id AND lm.user_id = $2
|
||||
)
|
||||
)`,
|
||||
listID, userID,
|
||||
).Scan(&l.ID, &l.Name, &l.OwnerID, &l.CreatedAt, &l.UpdatedAt, &l.HLCTS)
|
||||
).Scan(&l.ID, &l.Name, &l.OwnerID, &l.InviteCode, &l.CreatedAt, &l.UpdatedAt, &l.HLCTS)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("liststore: get list: %w", err)
|
||||
}
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
// GetInviteCode returns the invite code for a list if userID has access.
|
||||
func (s *ListStore) GetInviteCode(ctx context.Context, listID, userID uuid.UUID) (string, error) {
|
||||
l, err := s.GetList(ctx, listID, userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if l.InviteCode != "" {
|
||||
return l.InviteCode, nil
|
||||
}
|
||||
|
||||
// Generate if missing
|
||||
code := strings.ToUpper(uuid.New().String()[:8])
|
||||
_, err = s.pool.Exec(ctx, `UPDATE lists SET invite_code = $1 WHERE id = $2`, code, listID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("liststore: generate invite code: %w", err)
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// JoinByInviteCode adds userID as a member to the list identified by inviteCode.
|
||||
func (s *ListStore) JoinByInviteCode(ctx context.Context, userID uuid.UUID, inviteCode string) (*List, error) {
|
||||
cleanCode := strings.ToUpper(strings.TrimSpace(inviteCode))
|
||||
var l List
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, name, owner_id, COALESCE(invite_code, ''), created_at, updated_at, hlc_ts
|
||||
FROM lists
|
||||
WHERE invite_code = $1 AND deleted_at IS NULL`,
|
||||
cleanCode,
|
||||
).Scan(&l.ID, &l.Name, &l.OwnerID, &l.InviteCode, &l.CreatedAt, &l.UpdatedAt, &l.HLCTS)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("liststore: list not found for invite code: %w", err)
|
||||
}
|
||||
|
||||
_, err = s.pool.Exec(ctx, `
|
||||
INSERT INTO list_members (list_id, user_id, role)
|
||||
VALUES ($1, $2, 'member')
|
||||
ON CONFLICT DO NOTHING`,
|
||||
l.ID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("liststore: add member: %w", err)
|
||||
}
|
||||
|
||||
return &l, nil
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue