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

@ -6,10 +6,6 @@ import retrofit2.http.*
/** /**
* Retrofit API interface matching the mitbringsl backend. * Retrofit API interface matching the mitbringsl backend.
*
* Authentication: every authenticated call must include
* Authorization: Bearer <session-token>
* This is injected by the AuthInterceptor in the OkHttp client.
*/ */
interface MitbringslApi { interface MitbringslApi {
@ -35,9 +31,15 @@ interface MitbringslApi {
@POST("api/lists") @POST("api/lists")
suspend fun createList(@Body body: CreateListRequestDto): Response<ListDto> suspend fun createList(@Body body: CreateListRequestDto): Response<ListDto>
@POST("api/lists/join")
suspend fun joinList(@Body body: JoinListRequestDto): Response<ListDto>
@GET("api/lists/{id}") @GET("api/lists/{id}")
suspend fun getList(@Path("id") listId: String): Response<ListDetailDto> suspend fun getList(@Path("id") listId: String): Response<ListDetailDto>
@POST("api/lists/{id}/invite")
suspend fun getInviteCode(@Path("id") listId: String): Response<InviteCodeResponseDto>
// --- Ops ---------------------------------------------------------------- // --- Ops ----------------------------------------------------------------
@POST("api/lists/{id}/ops") @POST("api/lists/{id}/ops")

View file

@ -48,10 +48,21 @@ data class UserDto(
@Serializable @Serializable
data class CreateListRequestDto(val name: String) data class CreateListRequestDto(val name: String)
@Serializable
data class JoinListRequestDto(
@SerialName("invite_code") val inviteCode: String
)
@Serializable
data class InviteCodeResponseDto(
@SerialName("invite_code") val inviteCode: String
)
@Serializable @Serializable
data class ListDto( data class ListDto(
val id: String, val id: String,
val name: String, val name: String,
@SerialName("invite_code") val inviteCode: String? = null,
@SerialName("updated_at") val updatedAt: String, @SerialName("updated_at") val updatedAt: String,
@SerialName("hlc_ts") val hlcTs: Long, @SerialName("hlc_ts") val hlcTs: Long,
) )
@ -63,6 +74,7 @@ data class ListsResponseDto(val lists: List<ListDto>)
data class ListDetailDto( data class ListDetailDto(
val id: String, val id: String,
val name: String, val name: String,
@SerialName("invite_code") val inviteCode: String? = null,
@SerialName("updated_at") val updatedAt: String, @SerialName("updated_at") val updatedAt: String,
@SerialName("hlc_ts") val hlcTs: Long, @SerialName("hlc_ts") val hlcTs: Long,
val items: List<ItemDto>, val items: List<ItemDto>,

View file

@ -1,5 +1,6 @@
package com.example.mitbringsl.ui.detail package com.example.mitbringsl.ui.detail
import android.content.Intent
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
@ -12,13 +13,15 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@ -41,6 +44,10 @@ fun ListDetailScreen(
val items by viewModel.items.collectAsStateWithLifecycle() val items by viewModel.items.collectAsStateWithLifecycle()
val query by viewModel.query.collectAsStateWithLifecycle() val query by viewModel.query.collectAsStateWithLifecycle()
val suggestions by viewModel.suggestions.collectAsStateWithLifecycle() val suggestions by viewModel.suggestions.collectAsStateWithLifecycle()
val inviteCode by viewModel.inviteCode.collectAsStateWithLifecycle()
val context = LocalContext.current
var showShareDialog by remember { mutableStateOf(false) }
val (openItems, checkedItems) = remember(items) { val (openItems, checkedItems) = remember(items) {
items.partition { !it.checked } items.partition { !it.checked }
@ -60,6 +67,16 @@ fun ListDetailScreen(
) )
} }
}, },
actions = {
if (!inviteCode.isNullOrBlank()) {
IconButton(onClick = { showShareDialog = true }) {
Icon(
imageVector = Icons.Default.Share,
contentDescription = "Liste teilen"
)
}
}
},
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface containerColor = MaterialTheme.colorScheme.surface
) )
@ -229,6 +246,65 @@ fun ListDetailScreen(
} }
} }
} }
if (showShareDialog && !inviteCode.isNullOrBlank()) {
AlertDialog(
onDismissRequest = { showShareDialog = false },
title = { Text("Einkaufsliste teilen") },
text = {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "Gib diesen Einladungs-Code an andere Personen weiter:",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = inviteCode!!,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer,
textAlign = TextAlign.Center,
modifier = Modifier.padding(16.dp)
)
}
}
},
confirmButton = {
Button(
onClick = {
val sendIntent = Intent().apply {
action = Intent.ACTION_SEND
putExtra(
Intent.EXTRA_TEXT,
"Tritt meiner Einkaufsliste bei Mitbringsl bei! Einladungs-Code: ${inviteCode!!}"
)
type = "text/plain"
}
val shareIntent = Intent.createChooser(sendIntent, "Einladungs-Code teilen")
context.startActivity(shareIntent)
showShareDialog = false
},
shape = RoundedCornerShape(12.dp)
) {
Icon(imageVector = Icons.Default.Share, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Code teilen")
}
},
dismissButton = {
TextButton(onClick = { showShareDialog = false }) {
Text("Schließen")
}
},
shape = RoundedCornerShape(24.dp)
)
}
} }
@Composable @Composable

View file

@ -30,9 +30,26 @@ class ListDetailViewModel @Inject constructor(
initialValue = emptyList() initialValue = emptyList()
) )
private val _inviteCode = MutableStateFlow<String?>(null)
val inviteCode: StateFlow<String?> = _inviteCode.asStateFlow()
fun setListId(id: String) { fun setListId(id: String) {
if (_listId.value != id) { if (_listId.value != id) {
_listId.value = id _listId.value = id
fetchInviteCode(id)
}
}
private fun fetchInviteCode(id: String) {
viewModelScope.launch {
try {
val resp = api.getInviteCode(id)
if (resp.isSuccessful && resp.body() != null) {
_inviteCode.value = resp.body()!!.inviteCode
}
} catch (_: Exception) {
// Offline or local list
}
} }
} }

View file

@ -13,7 +13,7 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CloudDone import androidx.compose.material.icons.filled.CloudDone
import androidx.compose.material.icons.filled.CloudOff import androidx.compose.material.icons.filled.CloudOff
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.GroupAdd
import androidx.compose.material.icons.filled.ShoppingCart import androidx.compose.material.icons.filled.ShoppingCart
import androidx.compose.material.icons.filled.WifiOff import androidx.compose.material.icons.filled.WifiOff
import androidx.compose.material3.* import androidx.compose.material3.*
@ -40,8 +40,10 @@ fun ListsScreen(
val isOnline by viewModel.isOnline.collectAsStateWithLifecycle() val isOnline by viewModel.isOnline.collectAsStateWithLifecycle()
val isLoggedIn by viewModel.sessionManager.isLoggedIn.collectAsStateWithLifecycle() val isLoggedIn by viewModel.sessionManager.isLoggedIn.collectAsStateWithLifecycle()
val userEmail = viewModel.sessionManager.getUserEmail() val userEmail = viewModel.sessionManager.getUserEmail()
val joinError by viewModel.joinError.collectAsStateWithLifecycle()
var showAddDialog by remember { mutableStateOf(false) } var showAddDialog by remember { mutableStateOf(false) }
var showJoinDialog by remember { mutableStateOf(false) }
Scaffold( Scaffold(
topBar = { topBar = {
@ -53,7 +55,13 @@ fun ListsScreen(
) )
}, },
actions = { actions = {
// Sync / Account button IconButton(onClick = { showJoinDialog = true }) {
Icon(
imageVector = Icons.Default.GroupAdd,
contentDescription = "Liste beitreten"
)
}
AssistChip( AssistChip(
onClick = onOpenSync, onClick = onOpenSync,
label = { label = {
@ -155,11 +163,20 @@ fun ListsScreen(
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = "Erstelle direkt deine erste Liste — ohne Anmeldung nutzbar!", text = "Erstelle deine erste Liste oder tritt einer bestehenden Liste per Einladungs-Code bei.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
Spacer(modifier = Modifier.height(16.dp))
OutlinedButton(
onClick = { showJoinDialog = true },
shape = RoundedCornerShape(12.dp)
) {
Icon(imageVector = Icons.Default.GroupAdd, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Liste per Code beitreten")
}
} }
} else { } else {
LazyColumn( LazyColumn(
@ -189,6 +206,21 @@ fun ListsScreen(
} }
) )
} }
if (showJoinDialog) {
JoinListDialog(
errorMessage = joinError,
onDismiss = {
viewModel.clearJoinError()
showJoinDialog = false
},
onConfirm = { code ->
viewModel.joinList(code, onSuccess = {
showJoinDialog = false
})
}
)
}
} }
@Composable @Composable
@ -287,3 +319,59 @@ fun CreateListDialog(
shape = RoundedCornerShape(24.dp) shape = RoundedCornerShape(24.dp)
) )
} }
@Composable
fun JoinListDialog(
errorMessage: String?,
onDismiss: () -> Unit,
onConfirm: (String) -> Unit
) {
var code by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Liste beitreten") },
text = {
Column {
Text(
text = "Gib den 8-stelligen Einladungs-Code ein, um einer geteilten Liste beizutreten:",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = code,
onValueChange = { code = it.uppercase() },
label = { Text("Einladungs-Code") },
placeholder = { Text("z.B. X7K9P2A1") },
singleLine = true,
shape = RoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth()
)
if (errorMessage != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = errorMessage,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
},
confirmButton = {
Button(
onClick = { onConfirm(code) },
enabled = code.isNotBlank(),
shape = RoundedCornerShape(12.dp)
) {
Text("Beitreten")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Abbrechen")
}
},
shape = RoundedCornerShape(24.dp)
)
}

View file

@ -3,12 +3,17 @@ package com.example.mitbringsl.ui.lists
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.example.mitbringsl.data.auth.SessionManager import com.example.mitbringsl.data.auth.SessionManager
import com.example.mitbringsl.data.local.dao.ListDao
import com.example.mitbringsl.data.local.entity.ListEntity import com.example.mitbringsl.data.local.entity.ListEntity
import com.example.mitbringsl.data.remote.api.MitbringslApi
import com.example.mitbringsl.data.remote.dto.JoinListRequestDto
import com.example.mitbringsl.data.repository.ShoppingRepository import com.example.mitbringsl.data.repository.ShoppingRepository
import com.example.mitbringsl.util.NetworkMonitor import com.example.mitbringsl.util.NetworkMonitor
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@ -16,6 +21,8 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class ListsViewModel @Inject constructor( class ListsViewModel @Inject constructor(
private val shoppingRepository: ShoppingRepository, private val shoppingRepository: ShoppingRepository,
private val listDao: ListDao,
private val api: MitbringslApi,
val sessionManager: SessionManager, val sessionManager: SessionManager,
networkMonitor: NetworkMonitor, networkMonitor: NetworkMonitor,
) : ViewModel() { ) : ViewModel() {
@ -34,6 +41,9 @@ class ListsViewModel @Inject constructor(
initialValue = emptyList() initialValue = emptyList()
) )
private val _joinError = MutableStateFlow<String?>(null)
val joinError: StateFlow<String?> = _joinError.asStateFlow()
fun createList(name: String) { fun createList(name: String) {
if (name.isBlank()) return if (name.isBlank()) return
viewModelScope.launch { viewModelScope.launch {
@ -41,6 +51,39 @@ class ListsViewModel @Inject constructor(
} }
} }
fun joinList(inviteCode: String, onSuccess: () -> Unit) {
if (inviteCode.isBlank()) return
viewModelScope.launch {
_joinError.value = null
try {
val resp = api.joinList(JoinListRequestDto(inviteCode.trim()))
if (resp.isSuccessful && resp.body() != null) {
val dto = resp.body()!!
val ownerId = sessionManager.getUserId()
listDao.upsert(
ListEntity(
id = dto.id,
name = dto.name,
ownerId = ownerId,
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis(),
hlcTs = dto.hlcTs
)
)
onSuccess()
} else {
_joinError.value = "Ungültiger Einladungs-Code oder Serverfehler."
}
} catch (e: Exception) {
_joinError.value = e.localizedMessage ?: "Netzwerkfehler beim Beitreten."
}
}
}
fun clearJoinError() {
_joinError.value = null
}
fun deleteList(listId: String) { fun deleteList(listId: String) {
viewModelScope.launch { viewModelScope.launch {
shoppingRepository.deleteList(listId) shoppingRepository.deleteList(listId)

View file

@ -82,7 +82,9 @@ func (a *API) Handler() http.Handler {
// --- authenticated API endpoints --- // --- authenticated API endpoints ---
mux.Handle("GET /api/lists", a.RequireAuth(http.HandlerFunc(a.lists.List))) 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", 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("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("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/lists/{id}/ops", a.RequireAuth(http.HandlerFunc(a.ops.Pull)))
mux.Handle("GET /api/suggestions", a.RequireAuth(http.HandlerFunc(a.suggest.Search))) 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"` Name string `json:"name"`
} }
type joinListRequest struct {
InviteCode string `json:"invite_code"`
}
type inviteCodeResponse struct {
InviteCode string `json:"invite_code"`
}
type listDTO struct { type listDTO struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
UpdatedAt string `json:"updated_at"` InviteCode string `json:"invite_code,omitempty"`
HLCTS int64 `json:"hlc_ts"` UpdatedAt string `json:"updated_at"`
HLCTS int64 `json:"hlc_ts"`
} }
type listDetailDTO struct { type listDetailDTO struct {
@ -45,7 +54,7 @@ type listListResponse struct {
// --- handlers --------------------------------------------------------------- // --- 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 // GET /api/lists
func (h *ListHandler) List(w http.ResponseWriter, r *http.Request) { func (h *ListHandler) List(w http.ResponseWriter, r *http.Request) {
userID, ok := userIDFromCtx(r) userID, ok := userIDFromCtx(r)
@ -64,10 +73,11 @@ func (h *ListHandler) List(w http.ResponseWriter, r *http.Request) {
dtos := make([]listDTO, len(ls)) dtos := make([]listDTO, len(ls))
for i, l := range ls { for i, l := range ls {
dtos[i] = listDTO{ dtos[i] = listDTO{
ID: l.ID.String(), ID: l.ID.String(),
Name: l.Name, Name: l.Name,
UpdatedAt: l.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), InviteCode: l.InviteCode,
HLCTS: l.HLCTS, UpdatedAt: l.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
HLCTS: l.HLCTS,
} }
} }
renderJSON(w, http.StatusOK, listListResponse{Lists: dtos}) renderJSON(w, http.StatusOK, listListResponse{Lists: dtos})
@ -99,10 +109,11 @@ func (h *ListHandler) Create(w http.ResponseWriter, r *http.Request) {
return return
} }
renderJSON(w, http.StatusCreated, listDTO{ renderJSON(w, http.StatusCreated, listDTO{
ID: l.ID.String(), ID: l.ID.String(),
Name: l.Name, Name: l.Name,
UpdatedAt: l.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), InviteCode: l.InviteCode,
HLCTS: l.HLCTS, 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{ renderJSON(w, http.StatusOK, listDetailDTO{
listDTO: listDTO{ listDTO: listDTO{
ID: l.ID.String(), ID: l.ID.String(),
Name: l.Name, Name: l.Name,
UpdatedAt: l.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), InviteCode: l.InviteCode,
HLCTS: l.HLCTS, UpdatedAt: l.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
HLCTS: l.HLCTS,
}, },
Items: its, 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). // userIDFromCtx extracts the user UUID from the request context (set by RequireAuth).
func userIDFromCtx(r *http.Request) (uuid.UUID, bool) { func userIDFromCtx(r *http.Request) (uuid.UUID, bool) {
v := r.Context().Value(ctxKeyUserID) v := r.Context().Value(ctxKeyUserID)

View file

@ -3,6 +3,7 @@ package store
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@ -11,13 +12,14 @@ import (
// List is the projection of a list row. // List is the projection of a list row.
type List struct { type List struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
Name string `json:"name"` Name string `json:"name"`
OwnerID uuid.UUID `json:"owner_id"` OwnerID uuid.UUID `json:"owner_id"`
CreatedAt time.Time `json:"created_at"` InviteCode string `json:"invite_code,omitempty"`
UpdatedAt time.Time `json:"updated_at"` CreatedAt time.Time `json:"created_at"`
DeletedAt *time.Time `json:"deleted_at,omitempty"` UpdatedAt time.Time `json:"updated_at"`
HLCTS int64 `json:"hlc_ts"` DeletedAt *time.Time `json:"deleted_at,omitempty"`
HLCTS int64 `json:"hlc_ts"`
} }
// ListStore provides read/write access to the lists projection. // 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. // NewListStore creates a ListStore backed by the given pool.
func NewListStore(pool *pgxpool.Pool) *ListStore { return &ListStore{pool: pool} } func NewListStore(pool *pgxpool.Pool) *ListStore { return &ListStore{pool: pool} }
// CreateList inserts a new list owned by ownerID. // CreateList inserts a new list owned by ownerID and automatically adds an entry into list_members.
// 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.
func (s *ListStore) CreateList(ctx context.Context, ownerID uuid.UUID, name string) (*List, error) { 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 var l List
err := s.pool.QueryRow(ctx, ` err = tx.QueryRow(ctx, `
INSERT INTO lists (name, owner_id) INSERT INTO lists (name, owner_id, invite_code)
VALUES ($1, $2) VALUES ($1, $2, $3)
RETURNING id, name, owner_id, created_at, updated_at, hlc_ts`, RETURNING id, name, owner_id, COALESCE(invite_code, ''), created_at, updated_at, hlc_ts`,
name, ownerID, name, ownerID, inviteCode,
).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 { if err != nil {
return nil, fmt.Errorf("liststore: create list: %w", err) 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 return &l, nil
} }
// GetLists returns all non-deleted lists owned by ownerID, newest first. // GetLists returns all non-deleted lists accessible by userID (as owner or member), newest first.
func (s *ListStore) GetLists(ctx context.Context, ownerID uuid.UUID) ([]List, error) { func (s *ListStore) GetLists(ctx context.Context, userID uuid.UUID) ([]List, error) {
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT id, name, owner_id, created_at, updated_at, hlc_ts SELECT l.id, l.name, l.owner_id, COALESCE(l.invite_code, ''), l.created_at, l.updated_at, l.hlc_ts
FROM lists FROM lists l
WHERE owner_id = $1 AND deleted_at IS NULL WHERE l.deleted_at IS NULL AND (
ORDER BY created_at DESC`, l.owner_id = $1 OR EXISTS (
ownerID, 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 { if err != nil {
return nil, fmt.Errorf("liststore: get lists: %w", err) 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 var lists []List
for rows.Next() { for rows.Next() {
var l List 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) return nil, fmt.Errorf("liststore: scan list row: %w", err)
} }
lists = append(lists, l) lists = append(lists, l)
@ -71,18 +97,67 @@ func (s *ListStore) GetLists(ctx context.Context, ownerID uuid.UUID) ([]List, er
return lists, rows.Err() return lists, rows.Err()
} }
// GetList returns a single non-deleted list, checking that userID is the owner. // GetList returns a single non-deleted list if userID is owner or member.
// Returns pgx.ErrNoRows if not found or not owned by userID.
func (s *ListStore) GetList(ctx context.Context, listID, userID uuid.UUID) (*List, error) { func (s *ListStore) GetList(ctx context.Context, listID, userID uuid.UUID) (*List, error) {
var l List var l List
err := s.pool.QueryRow(ctx, ` err := s.pool.QueryRow(ctx, `
SELECT id, name, owner_id, created_at, updated_at, hlc_ts SELECT l.id, l.name, l.owner_id, COALESCE(l.invite_code, ''), l.created_at, l.updated_at, l.hlc_ts
FROM lists FROM lists l
WHERE id = $1 AND owner_id = $2 AND deleted_at IS NULL`, 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, 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 { if err != nil {
return nil, fmt.Errorf("liststore: get list: %w", err) return nil, fmt.Errorf("liststore: get list: %w", err)
} }
return &l, nil 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
}

View file

@ -0,0 +1 @@
ALTER TABLE lists DROP COLUMN IF EXISTS invite_code;

View file

@ -0,0 +1,13 @@
-- Add invite_code to lists table for sharing
ALTER TABLE lists ADD COLUMN IF NOT EXISTS invite_code TEXT UNIQUE;
-- Generate random 8-character invite code for lists that don't have one
UPDATE lists
SET invite_code = UPPER(SUBSTRING(MD5(RANDOM()::TEXT) FROM 1 FOR 8))
WHERE invite_code IS NULL;
-- Automatically insert owner into list_members on list creation if missing
INSERT INTO list_members (list_id, user_id, role)
SELECT id, owner_id, 'owner'
FROM lists
ON CONFLICT (list_id, user_id) DO NOTHING;