Add per-user item ownership and isolation

Items are now private to their owner. Add items.user_id (migrated in
place, existing items backfilled to the first admin) and scope every
item, results, and dashboard view to the signed-in user via owner-scoped
queries plus an ownedItem 404 guard. Admins get strict isolation too;
elevation stays limited to settings and user management.

Alert routing follows ownership: deal emails go only to the item owner
and the weekly digest is built per-recipient from their own items. The
scheduler still polls every active item; the Apify/eBay budget stays a
shared pool visible to all.

Add TestItemsArePrivatePerUser and seed owners in db tests.
This commit is contained in:
prosolis
2026-06-20 13:39:19 -07:00
parent 90a74967e0
commit feea126c5e
11 changed files with 327 additions and 72 deletions

View File

@@ -20,9 +20,23 @@ import (
// form: 12 hours, chosen to keep Apify spend low by default.
const defaultPollIntervalMinutes = 720
// ownedItem fetches an item and confirms the current user owns it. It returns
// nil when the item is missing or owned by someone else, so callers treat both
// cases as a 404 and never leak another user's items.
func (a *App) ownedItem(r *http.Request, id int64) (*models.Item, error) {
it, err := a.Store.GetItem(r.Context(), id)
if err != nil {
return nil, err
}
if it == nil || it.UserID != a.userID(r) {
return nil, nil
}
return it, nil
}
func (a *App) GetItems(w http.ResponseWriter, r *http.Request) {
cat := r.URL.Query().Get("category")
all, err := a.Store.ListItems(r.Context())
all, err := a.Store.ListItemsForUser(r.Context(), a.userID(r))
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
@@ -33,7 +47,7 @@ func (a *App) GetItems(w http.ResponseWriter, r *http.Request) {
items = append(items, it)
}
}
cats, _ := a.Store.ListCategories(r.Context())
cats, _ := a.Store.ListCategoriesForUser(r.Context(), a.userID(r))
// Bulk-load recent price history so each row can render a sparkline
// without N+1 queries. 20 points is enough for a meaningful trend line
@@ -54,7 +68,7 @@ func (a *App) GetItems(w http.ResponseWriter, r *http.Request) {
}
func (a *App) GetNewItem(w http.ResponseWriter, r *http.Request) {
cats, _ := a.Store.ListCategories(r.Context())
cats, _ := a.Store.ListCategoriesForUser(r.Context(), a.userID(r))
render(w, r, templates.ItemForm(templates.ItemFormData{
Page: a.page(r, "Add Item", "items"),
IsEdit: false,
@@ -73,12 +87,12 @@ func (a *App) GetNewItem(w http.ResponseWriter, r *http.Request) {
func (a *App) GetEditItem(w http.ResponseWriter, r *http.Request) {
id := intParam(r, "id")
it, err := a.Store.GetItem(r.Context(), id)
it, err := a.ownedItem(r, id)
if err != nil || it == nil {
http.NotFound(w, r)
return
}
cats, _ := a.Store.ListCategories(r.Context())
cats, _ := a.Store.ListCategoriesForUser(r.Context(), a.userID(r))
render(w, r, templates.ItemForm(templates.ItemFormData{
Page: a.page(r, "Edit "+it.Name, "items"),
IsEdit: true,
@@ -341,6 +355,7 @@ func (a *App) PostCreateItem(w http.ResponseWriter, r *http.Request) {
http.Error(w, strings.Join(errs, "; "), http.StatusBadRequest)
return
}
it.UserID = a.userID(r)
id, err := a.Store.CreateItem(r.Context(), &it)
if err != nil {
a.serverError(w, r, err)
@@ -364,14 +379,14 @@ func (a *App) PostCreateItem(w http.ResponseWriter, r *http.Request) {
func (a *App) PostUpdateItem(w http.ResponseWriter, r *http.Request) {
id := intParam(r, "id")
existing, err := a.Store.GetItem(r.Context(), id)
existing, err := a.ownedItem(r, id)
if err != nil || existing == nil {
http.NotFound(w, r)
return
}
updated, errs := parseItemForm(r)
if len(errs) > 0 {
cats, _ := a.Store.ListCategories(r.Context())
cats, _ := a.Store.ListCategoriesForUser(r.Context(), a.userID(r))
updated.ID = id
render(w, r, templates.ItemForm(templates.ItemFormData{
Page: a.page(r, "Edit "+updated.Name, "items"),
@@ -394,7 +409,7 @@ func (a *App) PostUpdateItem(w http.ResponseWriter, r *http.Request) {
func (a *App) PostToggleItem(w http.ResponseWriter, r *http.Request) {
id := intParam(r, "id")
it, err := a.Store.GetItem(r.Context(), id)
it, err := a.ownedItem(r, id)
if err != nil || it == nil {
http.NotFound(w, r)
return
@@ -411,6 +426,11 @@ func (a *App) PostToggleItem(w http.ResponseWriter, r *http.Request) {
func (a *App) PostDeleteItem(w http.ResponseWriter, r *http.Request) {
id := intParam(r, "id")
it, err := a.ownedItem(r, id)
if err != nil || it == nil {
http.NotFound(w, r)
return
}
if err := a.Store.DeleteItem(r.Context(), id); err != nil {
a.serverError(w, r, err)
return
@@ -421,7 +441,7 @@ func (a *App) PostDeleteItem(w http.ResponseWriter, r *http.Request) {
func (a *App) PostRunItem(w http.ResponseWriter, r *http.Request) {
id := intParam(r, "id")
it, err := a.Store.GetItem(r.Context(), id)
it, err := a.ownedItem(r, id)
if err != nil || it == nil {
http.NotFound(w, r)
return
@@ -455,7 +475,7 @@ func (a *App) PostRunItem(w http.ResponseWriter, r *http.Request) {
func (a *App) GetItemError(w http.ResponseWriter, r *http.Request) {
id := intParam(r, "id")
it, err := a.Store.GetItem(r.Context(), id)
it, err := a.ownedItem(r, id)
if err != nil || it == nil {
http.NotFound(w, r)
return