diff --git a/android/app/src/main/java/com/example/mitbringsl/data/remote/api/MitbringslApi.kt b/android/app/src/main/java/com/example/mitbringsl/data/remote/api/MitbringslApi.kt index 64ae203..c6587eb 100644 --- a/android/app/src/main/java/com/example/mitbringsl/data/remote/api/MitbringslApi.kt +++ b/android/app/src/main/java/com/example/mitbringsl/data/remote/api/MitbringslApi.kt @@ -6,10 +6,6 @@ import retrofit2.http.* /** * Retrofit API interface matching the mitbringsl backend. - * - * Authentication: every authenticated call must include - * Authorization: Bearer - * This is injected by the AuthInterceptor in the OkHttp client. */ interface MitbringslApi { @@ -35,9 +31,15 @@ interface MitbringslApi { @POST("api/lists") suspend fun createList(@Body body: CreateListRequestDto): Response + @POST("api/lists/join") + suspend fun joinList(@Body body: JoinListRequestDto): Response + @GET("api/lists/{id}") suspend fun getList(@Path("id") listId: String): Response + @POST("api/lists/{id}/invite") + suspend fun getInviteCode(@Path("id") listId: String): Response + // --- Ops ---------------------------------------------------------------- @POST("api/lists/{id}/ops") diff --git a/android/app/src/main/java/com/example/mitbringsl/data/remote/dto/Dtos.kt b/android/app/src/main/java/com/example/mitbringsl/data/remote/dto/Dtos.kt index e649631..fbb3317 100644 --- a/android/app/src/main/java/com/example/mitbringsl/data/remote/dto/Dtos.kt +++ b/android/app/src/main/java/com/example/mitbringsl/data/remote/dto/Dtos.kt @@ -48,10 +48,21 @@ data class UserDto( @Serializable 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 data class ListDto( val id: String, val name: String, + @SerialName("invite_code") val inviteCode: String? = null, @SerialName("updated_at") val updatedAt: String, @SerialName("hlc_ts") val hlcTs: Long, ) @@ -63,6 +74,7 @@ data class ListsResponseDto(val lists: List) data class ListDetailDto( val id: String, val name: String, + @SerialName("invite_code") val inviteCode: String? = null, @SerialName("updated_at") val updatedAt: String, @SerialName("hlc_ts") val hlcTs: Long, val items: List, diff --git a/android/app/src/main/java/com/example/mitbringsl/ui/detail/ListDetailScreen.kt b/android/app/src/main/java/com/example/mitbringsl/ui/detail/ListDetailScreen.kt index 02ec6bf..e883e93 100644 --- a/android/app/src/main/java/com/example/mitbringsl/ui/detail/ListDetailScreen.kt +++ b/android/app/src/main/java/com/example/mitbringsl/ui/detail/ListDetailScreen.kt @@ -1,5 +1,6 @@ package com.example.mitbringsl.ui.detail +import android.content.Intent import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateFloatAsState 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.filled.Add import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Share import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -41,6 +44,10 @@ fun ListDetailScreen( val items by viewModel.items.collectAsStateWithLifecycle() val query by viewModel.query.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) { 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( 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 diff --git a/android/app/src/main/java/com/example/mitbringsl/ui/detail/ListDetailViewModel.kt b/android/app/src/main/java/com/example/mitbringsl/ui/detail/ListDetailViewModel.kt index 084d6be..d8746fa 100644 --- a/android/app/src/main/java/com/example/mitbringsl/ui/detail/ListDetailViewModel.kt +++ b/android/app/src/main/java/com/example/mitbringsl/ui/detail/ListDetailViewModel.kt @@ -30,9 +30,26 @@ class ListDetailViewModel @Inject constructor( initialValue = emptyList() ) + private val _inviteCode = MutableStateFlow(null) + val inviteCode: StateFlow = _inviteCode.asStateFlow() + fun setListId(id: String) { if (_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 + } } } diff --git a/android/app/src/main/java/com/example/mitbringsl/ui/lists/ListsScreen.kt b/android/app/src/main/java/com/example/mitbringsl/ui/lists/ListsScreen.kt index e6fe613..01430e5 100644 --- a/android/app/src/main/java/com/example/mitbringsl/ui/lists/ListsScreen.kt +++ b/android/app/src/main/java/com/example/mitbringsl/ui/lists/ListsScreen.kt @@ -13,7 +13,7 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.CloudDone import androidx.compose.material.icons.filled.CloudOff 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.WifiOff import androidx.compose.material3.* @@ -40,8 +40,10 @@ fun ListsScreen( val isOnline by viewModel.isOnline.collectAsStateWithLifecycle() val isLoggedIn by viewModel.sessionManager.isLoggedIn.collectAsStateWithLifecycle() val userEmail = viewModel.sessionManager.getUserEmail() + val joinError by viewModel.joinError.collectAsStateWithLifecycle() var showAddDialog by remember { mutableStateOf(false) } + var showJoinDialog by remember { mutableStateOf(false) } Scaffold( topBar = { @@ -53,7 +55,13 @@ fun ListsScreen( ) }, actions = { - // Sync / Account button + IconButton(onClick = { showJoinDialog = true }) { + Icon( + imageVector = Icons.Default.GroupAdd, + contentDescription = "Liste beitreten" + ) + } + AssistChip( onClick = onOpenSync, label = { @@ -155,11 +163,20 @@ fun ListsScreen( ) Spacer(modifier = Modifier.height(8.dp)) 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, color = MaterialTheme.colorScheme.onSurfaceVariant, 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 { 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 @@ -287,3 +319,59 @@ fun CreateListDialog( 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) + ) +} diff --git a/android/app/src/main/java/com/example/mitbringsl/ui/lists/ListsViewModel.kt b/android/app/src/main/java/com/example/mitbringsl/ui/lists/ListsViewModel.kt index 6ba8fd8..1fe6bef 100644 --- a/android/app/src/main/java/com/example/mitbringsl/ui/lists/ListsViewModel.kt +++ b/android/app/src/main/java/com/example/mitbringsl/ui/lists/ListsViewModel.kt @@ -3,12 +3,17 @@ package com.example.mitbringsl.ui.lists import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope 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.remote.api.MitbringslApi +import com.example.mitbringsl.data.remote.dto.JoinListRequestDto import com.example.mitbringsl.data.repository.ShoppingRepository import com.example.mitbringsl.util.NetworkMonitor import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @@ -16,6 +21,8 @@ import javax.inject.Inject @HiltViewModel class ListsViewModel @Inject constructor( private val shoppingRepository: ShoppingRepository, + private val listDao: ListDao, + private val api: MitbringslApi, val sessionManager: SessionManager, networkMonitor: NetworkMonitor, ) : ViewModel() { @@ -34,6 +41,9 @@ class ListsViewModel @Inject constructor( initialValue = emptyList() ) + private val _joinError = MutableStateFlow(null) + val joinError: StateFlow = _joinError.asStateFlow() + fun createList(name: String) { if (name.isBlank()) return 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) { viewModelScope.launch { shoppingRepository.deleteList(listId) diff --git a/backend/internal/httpapi/api.go b/backend/internal/httpapi/api.go index 4a7057a..7e1415d 100644 --- a/backend/internal/httpapi/api.go +++ b/backend/internal/httpapi/api.go @@ -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))) diff --git a/backend/internal/httpapi/lists.go b/backend/internal/httpapi/lists.go index 532310b..a0aa104 100644 --- a/backend/internal/httpapi/lists.go +++ b/backend/internal/httpapi/lists.go @@ -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) diff --git a/backend/internal/store/liststore.go b/backend/internal/store/liststore.go index d8f3a7a..122d49d 100644 --- a/backend/internal/store/liststore.go +++ b/backend/internal/store/liststore.go @@ -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 +} diff --git a/backend/migrations/000002_add_invite_code.down.sql b/backend/migrations/000002_add_invite_code.down.sql new file mode 100644 index 0000000..7ebbe08 --- /dev/null +++ b/backend/migrations/000002_add_invite_code.down.sql @@ -0,0 +1 @@ +ALTER TABLE lists DROP COLUMN IF EXISTS invite_code; diff --git a/backend/migrations/000002_add_invite_code.up.sql b/backend/migrations/000002_add_invite_code.up.sql new file mode 100644 index 0000000..146b112 --- /dev/null +++ b/backend/migrations/000002_add_invite_code.up.sql @@ -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;