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
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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>,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)))
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
1
backend/migrations/000002_add_invite_code.down.sql
Normal file
1
backend/migrations/000002_add_invite_code.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE lists DROP COLUMN IF EXISTS invite_code;
|
||||||
13
backend/migrations/000002_add_invite_code.up.sql
Normal file
13
backend/migrations/000002_add_invite_code.up.sql
Normal 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;
|
||||||
Loading…
Add table
Add a link
Reference in a new issue