Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- **List alias** — Optional human-friendly alias (e.g. `groceries`) for shopping lists, usable in place of the UUID; aliases are unique among active lists, normalized to lowercase, and deleted automatically when the list expires. New endpoints: `GET /api/lists/by-alias/{alias}` to resolve an alias, `PUT /api/lists/{id}/alias` to set, update, or remove it. Small settings UI in the burger menu for managing the alias per list.
- **AGENTS.md** — AI coding agent instructions covering project overview, tech stack, exact commands, coding conventions, development lifecycle (branch-per-change, Conventional Commits, CHANGELOG discipline), PR workflow, and three-tier boundaries (always/ask/never)

### Changed
- **Database schema initialization** — Switched from `SchemaUtils.create()` to `SchemaUtils.createMissingTablesAndColumns()` so new columns (like alias) are automatically added to existing tables on deployment without manual migration

### Fixed
- **Flaky backend tests** — Resolved intermittent `PSQLException: Connection refused` errors in CI by fixing Testcontainers lifecycle mismatch (`@Container` moved to `companion object` for all `@TestInstance(PER_CLASS)` test classes) and disabling parallel test-class execution (`maxParallelForks = 1`) to prevent Exposed's global database registry from being overwritten mid-test by a concurrently running test class

Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ A real-time collaborative shopping list app. Create a list, share the link, and
- **Dark Mode** — Light, dark, and system-preference theme toggle
- **Smart Autocomplete** — Suggests items from your cross-list history as you type
- **Push Notifications** — Opt-in Web Push alerts when list changes happen
- **List Aliases** — Optionally assign a human-friendly alias (e.g. `groceries`) to any list, usable in place of the UUID
- **Prometheus Metrics** — Application metrics (online users, list count, JVM, HTTP requests) exposed for Prometheus scraping

## Tech Stack
Expand Down Expand Up @@ -134,6 +135,8 @@ For production deployment, see [DEPLOYMENT.md](DEPLOYMENT.md).
| `PUT` | `/api/lists/{id}/items/{itemId}` | Update an item |
| `DELETE` | `/api/lists/{id}/items/{itemId}` | Delete an item |
| `POST` | `/api/lists/{id}/clear-completed` | Remove completed items |
| `GET` | `/api/lists/by-alias/{alias}` | Resolve a list by its alias |
| `PUT` | `/api/lists/{id}/alias` | Set, update, or remove a list's alias |
| `WS` | `/ws/{listId}` | Real-time WebSocket connection |
| `GET` | `/api/push/vapid-key` | Get the server's VAPID public key |
| `POST` | `/api/push/subscribe` | Subscribe to push notifications |
Expand All @@ -145,7 +148,7 @@ Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) before

## Security

This app has no authentication — lists are accessible to anyone who knows the UUID. See [SECURITY.md](SECURITY.md) for deployment guidance and how to report vulnerabilities.
This app has no authentication — lists are accessible to anyone who knows the UUID or alias. See [SECURITY.md](SECURITY.md) for deployment guidance and how to report vulnerabilities.

## License

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ object DatabaseFactory {
val database = Database.connect(createHikariDataSource())

transaction(database) {
SchemaUtils.create(ShoppingLists, ListItems, PushSubscriptions)
SchemaUtils.createMissingTablesAndColumns(ShoppingLists, ListItems, PushSubscriptions)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import java.time.Instant
object ShoppingLists : UUIDTable("shopping_lists") {
val createdAt = timestamp("created_at").default(Instant.now())
val lastModified = timestamp("last_modified").default(Instant.now())
val alias = varchar("alias", 64).nullable().uniqueIndex("uq_shopping_lists_alias")
}

object ListItems : UUIDTable("list_items") {
Expand Down
13 changes: 10 additions & 3 deletions backend/src/main/kotlin/com/shoppinglist/models/ShoppingList.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ data class ShoppingList(
val items: List<ListItem> = emptyList(),
val createdAt: String,
val lastModified: String,
val expiresAt: String? = null
val expiresAt: String? = null,
val alias: String? = null
)

@Serializable
Expand All @@ -23,5 +24,11 @@ data class ShoppingListResponse(
val items: List<ListItem>,
val createdAt: String,
val lastModified: String,
val expiresAt: String? = null
)
val expiresAt: String? = null,
val alias: String? = null
)

@Serializable
data class UpdateAliasRequest(
val alias: String? = null
)
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,7 @@ interface ListRepository {
suspend fun deleteList(id: UUID): Boolean
suspend fun listExists(id: UUID): Boolean
suspend fun getListWithExpiration(id: UUID): ShoppingList?
}
suspend fun getListByAlias(alias: String): ShoppingList?
suspend fun updateAlias(id: UUID, alias: String?): Boolean
suspend fun aliasExists(alias: String): Boolean
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ class ListRepositoryImpl : ListRepository {
id = listRow[ShoppingLists.id].toString(),
items = items,
createdAt = listRow[ShoppingLists.createdAt].toString(),
lastModified = listRow[ShoppingLists.lastModified].toString()
lastModified = listRow[ShoppingLists.lastModified].toString(),
alias = listRow[ShoppingLists.alias]
)
}

Expand Down Expand Up @@ -100,7 +101,59 @@ class ListRepositoryImpl : ListRepository {
items = items,
createdAt = listRow[ShoppingLists.createdAt].toString(),
lastModified = lastModified.toString(),
expiresAt = expiresAt.toString()
expiresAt = expiresAt.toString(),
alias = listRow[ShoppingLists.alias]
)
}
}

override suspend fun getListByAlias(alias: String): ShoppingList? = newSuspendedTransaction {
val normalizedAlias = alias.trim().lowercase()

val listRow = ShoppingLists.select { ShoppingLists.alias eq normalizedAlias }.singleOrNull()
?: return@newSuspendedTransaction null

val listId = listRow[ShoppingLists.id].value

val items = ListItems
.select { ListItems.listId eq listId }
.orderBy(ListItems.itemOrder)
.map { row ->
ListItem(
id = row[ListItems.id].toString(),
text = row[ListItems.text],
completed = row[ListItems.completed],
createdAt = row[ListItems.createdAt].toString(),
order = row[ListItems.itemOrder]
)
}

val retentionDays = System.getenv("LIST_RETENTION_DAYS")?.toLongOrNull()
?: System.getProperty("LIST_RETENTION_DAYS")?.toLongOrNull()
?: 30L
val lastModified = listRow[ShoppingLists.lastModified]
val expiresAt = lastModified.plus(retentionDays, ChronoUnit.DAYS)

ShoppingList(
id = listRow[ShoppingLists.id].toString(),
items = items,
createdAt = listRow[ShoppingLists.createdAt].toString(),
lastModified = lastModified.toString(),
expiresAt = expiresAt.toString(),
alias = listRow[ShoppingLists.alias]
)
}

override suspend fun updateAlias(id: UUID, alias: String?): Boolean = newSuspendedTransaction {
val normalizedAlias = alias?.trim()?.lowercase()

val updatedRows = ShoppingLists.update({ ShoppingLists.id eq id }) {
it[ShoppingLists.alias] = normalizedAlias
}
updatedRows > 0
}

override suspend fun aliasExists(alias: String): Boolean = newSuspendedTransaction {
val normalizedAlias = alias.trim().lowercase()
ShoppingLists.select { ShoppingLists.alias eq normalizedAlias }.count() > 0
}
}
126 changes: 126 additions & 0 deletions backend/src/main/kotlin/com/shoppinglist/routes/ListRoutes.kt
Original file line number Diff line number Diff line change
Expand Up @@ -427,5 +427,131 @@ fun Route.listRoutes(pushNotificationService: PushNotificationService? = null) {
)
}
}

// GET /api/lists/by-alias/{alias} - Resolve alias to list
get("/by-alias/{alias}") {
try {
val aliasParam = call.parameters["alias"]
if (aliasParam.isNullOrBlank()) {
call.respond(
HttpStatusCode.BadRequest,
ErrorResponse("missing_alias", "Alias is required")
)
return@get
}

val normalizedAlias = aliasParam.trim().lowercase()

val list = listRepository.getListByAlias(normalizedAlias)
if (list == null) {
call.respond(
HttpStatusCode.NotFound,
ErrorResponse("list_not_found", "No list found with this alias")
)
return@get
}

call.respond(HttpStatusCode.OK, list)
} catch (e: Exception) {
call.respond(
HttpStatusCode.InternalServerError,
ErrorResponse("server_error", "Failed to resolve alias")
)
}
}

// PUT /api/lists/{id}/alias - Set/update/remove alias
put("/{id}/alias") {
try {
val idParam = call.parameters["id"]
if (idParam == null) {
call.respond(
HttpStatusCode.BadRequest,
ErrorResponse("missing_id", "List ID is required")
)
return@put
}

val listId = try {
UUID.fromString(idParam)
} catch (e: IllegalArgumentException) {
call.respond(
HttpStatusCode.BadRequest,
ErrorResponse("invalid_id", "Invalid UUID format for list ID")
)
return@put
}

if (!listRepository.listExists(listId)) {
call.respond(
HttpStatusCode.NotFound,
ErrorResponse("list_not_found", "Shopping list not found")
)
return@put
}

val request = try {
call.receive<UpdateAliasRequest>()
} catch (e: Exception) {
call.respond(
HttpStatusCode.BadRequest,
ErrorResponse("invalid_request", "Invalid request body")
)
return@put
}

val normalizedAlias = request.alias?.trim()?.lowercase()

if (normalizedAlias != null) {
val trimmedInput = request.alias!!.trim()
val aliasRegex = Regex("^[a-z0-9]([a-z0-9-]*[a-z0-9])?\$")
if (!aliasRegex.matches(trimmedInput) || trimmedInput.length > 64) {
call.respond(
HttpStatusCode.BadRequest,
ErrorResponse("invalid_alias", "Alias must be 1-64 characters, lowercase alphanumeric and hyphens only, cannot start or end with a hyphen")
)
return@put
}

val uuidRegex = Regex("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\$")
if (uuidRegex.matches(normalizedAlias)) {
call.respond(
HttpStatusCode.BadRequest,
ErrorResponse("invalid_alias", "Alias cannot be in UUID format")
)
return@put
}

if (listRepository.aliasExists(normalizedAlias)) {
val currentList = listRepository.getListWithExpiration(listId)
if (currentList?.alias != normalizedAlias) {
call.respond(
HttpStatusCode.Conflict,
ErrorResponse("alias_taken", "This alias is already in use by another list")
)
return@put
}
}
}

listRepository.updateAlias(listId, normalizedAlias)

val updatedList = listRepository.getListWithExpiration(listId)
if (updatedList == null) {
call.respond(
HttpStatusCode.NotFound,
ErrorResponse("list_not_found", "Shopping list not found")
)
return@put
}

call.respond(HttpStatusCode.OK, updatedList)
} catch (e: Exception) {
call.respond(
HttpStatusCode.InternalServerError,
ErrorResponse("server_error", "Failed to update alias")
)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.test.assertFalse

@Testcontainers
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
Expand Down Expand Up @@ -137,4 +138,54 @@ class ListRepositoryTest {
assertTrue(deleted)
assertNull(retrievedList)
}

@Test
fun `getListByAlias should return list with matching alias`() = runBlocking {
val listId = UUID.randomUUID()
listRepository.createList(listId)
listRepository.updateAlias(listId, "test-alias")

val retrievedList = listRepository.getListByAlias("test-alias")

assertNotNull(retrievedList)
assertEquals(listId.toString(), retrievedList.id)
assertEquals("test-alias", retrievedList.alias)
}

@Test
fun `getListByAlias should return null for non-existing alias`() = runBlocking {
val retrievedList = listRepository.getListByAlias("nonexistent")

assertNull(retrievedList)
}

@Test
fun `updateAlias should set and clear alias`() = runBlocking {
val listId = UUID.randomUUID()
listRepository.createList(listId)

val setResult = listRepository.updateAlias(listId, "my-alias")
assertTrue(setResult)

val listWithAlias = listRepository.getListById(listId)
assertNotNull(listWithAlias)
assertEquals("my-alias", listWithAlias.alias)

val clearResult = listRepository.updateAlias(listId, null)
assertTrue(clearResult)

val listWithoutAlias = listRepository.getListById(listId)
assertNotNull(listWithoutAlias)
assertNull(listWithoutAlias.alias)
}

@Test
fun `aliasExists should return true for existing alias`() = runBlocking {
val listId = UUID.randomUUID()
listRepository.createList(listId)
listRepository.updateAlias(listId, "existing-alias")

assertTrue(listRepository.aliasExists("existing-alias"))
assertFalse(listRepository.aliasExists("nonexistent-alias"))
}
}
Loading
Loading