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:
Tronax 2026-08-05 20:36:43 +02:00
parent 47b24dfcdf
commit 69591df12a
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
11 changed files with 457 additions and 56 deletions

View file

@ -82,7 +82,9 @@ func (a *API) Handler() http.Handler {
// --- authenticated API endpoints ---
mux.Handle("GET /api/lists", a.RequireAuth(http.HandlerFunc(a.lists.List)))
mux.Handle("POST /api/lists", a.RequireAuth(http.HandlerFunc(a.lists.Create)))
mux.Handle("POST /api/lists/join", a.RequireAuth(http.HandlerFunc(a.lists.Join)))
mux.Handle("GET /api/lists/{id}", a.RequireAuth(http.HandlerFunc(a.lists.Get)))
mux.Handle("POST /api/lists/{id}/invite", a.RequireAuth(http.HandlerFunc(a.lists.Invite)))
mux.Handle("POST /api/lists/{id}/ops", a.RequireAuth(http.HandlerFunc(a.ops.Push)))
mux.Handle("GET /api/lists/{id}/ops", a.RequireAuth(http.HandlerFunc(a.ops.Pull)))
mux.Handle("GET /api/suggestions", a.RequireAuth(http.HandlerFunc(a.suggest.Search)))

View file

@ -27,11 +27,20 @@ type createListRequest struct {
Name string `json:"name"`
}
type joinListRequest struct {
InviteCode string `json:"invite_code"`
}
type inviteCodeResponse struct {
InviteCode string `json:"invite_code"`
}
type listDTO struct {
ID string `json:"id"`
Name string `json:"name"`
UpdatedAt string `json:"updated_at"`
HLCTS int64 `json:"hlc_ts"`
ID string `json:"id"`
Name string `json:"name"`
InviteCode string `json:"invite_code,omitempty"`
UpdatedAt string `json:"updated_at"`
HLCTS int64 `json:"hlc_ts"`
}
type listDetailDTO struct {
@ -45,7 +54,7 @@ type listListResponse struct {
// --- handlers ---------------------------------------------------------------
// List returns all non-deleted lists for the authenticated user.
// List returns all non-deleted lists for the authenticated user (owner or member).
// GET /api/lists
func (h *ListHandler) List(w http.ResponseWriter, r *http.Request) {
userID, ok := userIDFromCtx(r)
@ -64,10 +73,11 @@ func (h *ListHandler) List(w http.ResponseWriter, r *http.Request) {
dtos := make([]listDTO, len(ls))
for i, l := range ls {
dtos[i] = listDTO{
ID: l.ID.String(),
Name: l.Name,
UpdatedAt: l.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
HLCTS: l.HLCTS,
ID: l.ID.String(),
Name: l.Name,
InviteCode: l.InviteCode,
UpdatedAt: l.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
HLCTS: l.HLCTS,
}
}
renderJSON(w, http.StatusOK, listListResponse{Lists: dtos})
@ -99,10 +109,11 @@ func (h *ListHandler) Create(w http.ResponseWriter, r *http.Request) {
return
}
renderJSON(w, http.StatusCreated, listDTO{
ID: l.ID.String(),
Name: l.Name,
UpdatedAt: l.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
HLCTS: l.HLCTS,
ID: l.ID.String(),
Name: l.Name,
InviteCode: l.InviteCode,
UpdatedAt: l.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
HLCTS: l.HLCTS,
})
}
@ -142,15 +153,76 @@ func (h *ListHandler) Get(w http.ResponseWriter, r *http.Request) {
renderJSON(w, http.StatusOK, listDetailDTO{
listDTO: listDTO{
ID: l.ID.String(),
Name: l.Name,
UpdatedAt: l.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
HLCTS: l.HLCTS,
ID: l.ID.String(),
Name: l.Name,
InviteCode: l.InviteCode,
UpdatedAt: l.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
HLCTS: l.HLCTS,
},
Items: its,
})
}
// Invite returns or generates the invite code for a list.
// POST /api/lists/{id}/invite
func (h *ListHandler) Invite(w http.ResponseWriter, r *http.Request) {
userID, ok := userIDFromCtx(r)
if !ok {
renderError(w, http.StatusUnauthorized, "Unauthorized", "Missing user context.")
return
}
listID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
renderError(w, http.StatusBadRequest, "Bad request", "Invalid list ID.")
return
}
code, err := h.lists.GetInviteCode(r.Context(), listID, userID)
if err != nil {
slog.Error("get invite code failed", "error", err, "list_id", listID)
renderError(w, http.StatusBadRequest, "Bad request", "Could not get invite code.")
return
}
renderJSON(w, http.StatusOK, inviteCodeResponse{InviteCode: code})
}
// Join adds the authenticated user to a list using an invite code.
// POST /api/lists/join
func (h *ListHandler) Join(w http.ResponseWriter, r *http.Request) {
userID, ok := userIDFromCtx(r)
if !ok {
renderError(w, http.StatusUnauthorized, "Unauthorized", "Missing user context.")
return
}
var req joinListRequest
if !decodeJSON(w, r, &req) {
return
}
code := strings.TrimSpace(req.InviteCode)
if code == "" {
renderError(w, http.StatusBadRequest, "Bad request", "Invite code must not be empty.")
return
}
l, err := h.lists.JoinByInviteCode(r.Context(), userID, code)
if err != nil {
slog.Error("join list failed", "error", err, "user_id", userID, "code", code)
renderError(w, http.StatusBadRequest, "Bad request", "Ungültiger Einladungs-Code.")
return
}
renderJSON(w, http.StatusOK, listDTO{
ID: l.ID.String(),
Name: l.Name,
InviteCode: l.InviteCode,
UpdatedAt: l.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
HLCTS: l.HLCTS,
})
}
// userIDFromCtx extracts the user UUID from the request context (set by RequireAuth).
func userIDFromCtx(r *http.Request) (uuid.UUID, bool) {
v := r.Context().Value(ctxKeyUserID)

View file

@ -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
}