ai cooking
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Admin users page: ignore legacy GUIDs, sort by saved recipes, and show cooked-click counts (#369) (#386)

* Update admin users page filtering and activity metrics

* parallize feedback

* fix frumpt

---------

Co-authored-by: paul miller <paul.miller>

authored by

Paul Miller
paul miller
and committed by
GitHub
4936dfc2 d081adc3

+117 -20
+77 -14
internal/users/admin_page.go
··· 1 1 package users 2 2 3 3 import ( 4 + "context" 5 + "encoding/json" 4 6 "html/template" 5 7 "log/slog" 6 8 "net/http" 7 9 "sort" 8 10 "strings" 11 + "sync" 12 + "sync/atomic" 13 + "time" 14 + 15 + "careme/internal/cache" 9 16 10 17 utypes "careme/internal/users/types" 11 18 ) 12 19 13 20 type adminUserView struct { 14 - ID string 15 - Emails []string 16 - SavedRecipeCount int 21 + ID string 22 + Emails []string 23 + SavedRecipeCount int 24 + CookedRecipeCount int32 17 25 } 18 26 19 27 var adminUsersPageTmpl = template.Must(template.New("admin-users").Parse(`<!doctype html> ··· 32 40 <th>User ID</th> 33 41 <th>Emails</th> 34 42 <th>Saved Recipe Count</th> 43 + <th>Cooked Click Count</th> 35 44 </tr> 36 45 </thead> 37 46 <tbody> ··· 51 60 </td> 52 61 <td> 53 62 {{.SavedRecipeCount}} 63 + </td> 64 + <td> 65 + {{.CookedRecipeCount}} 54 66 </td> 55 67 </tr> 56 68 {{end}} ··· 72 84 http.Error(w, "unable to load users", http.StatusInternalServerError) 73 85 return 74 86 } 87 + filtered := filterAdminUsers(list) 88 + 75 89 if strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("format")), "emails") { 76 - renderAdminEmailsText(w, list) 90 + renderAdminEmailsText(w, filtered) 77 91 return 78 92 } 79 93 80 - views := make([]adminUserView, 0, len(list)) 81 - for _, user := range list { 94 + views := make([]adminUserView, 0, len(filtered)) 95 + for _, user := range filtered { 82 96 views = append(views, adminUserView{ 83 - ID: user.ID, 84 - Emails: append([]string(nil), user.Email...), 85 - SavedRecipeCount: len(user.LastRecipes), 97 + ID: user.ID, 98 + Emails: append([]string(nil), user.Email...), 99 + SavedRecipeCount: len(user.LastRecipes), 100 + CookedRecipeCount: cookedRecipeCount(r.Context(), storage.cache, user), 86 101 }) 87 102 } 88 103 89 104 sort.Slice(views, func(i, j int) bool { 90 - iEmail := primaryAdminEmail(views[i]) 91 - jEmail := primaryAdminEmail(views[j]) 92 - if iEmail == jEmail { 93 - return views[i].ID < views[j].ID 105 + if views[i].SavedRecipeCount == views[j].SavedRecipeCount { 106 + iEmail := primaryAdminEmail(views[i]) 107 + jEmail := primaryAdminEmail(views[j]) 108 + if iEmail == jEmail { 109 + return views[i].ID < views[j].ID 110 + } 111 + return iEmail < jEmail 94 112 } 95 - return iEmail < jEmail 113 + return views[i].SavedRecipeCount > views[j].SavedRecipeCount 96 114 }) 97 115 98 116 w.Header().Set("Content-Type", "text/html; charset=utf-8") ··· 112 130 return "" 113 131 } 114 132 return strings.ToLower(strings.TrimSpace(v.Emails[0])) 133 + } 134 + 135 + func filterAdminUsers(users []utypes.User) []utypes.User { 136 + filtered := make([]utypes.User, 0, len(users)) 137 + for _, user := range users { 138 + if !strings.HasPrefix(user.ID, "user_") { 139 + continue 140 + } 141 + filtered = append(filtered, user) 142 + } 143 + return filtered 144 + } 145 + 146 + type adminRecipeFeedback struct { 147 + Cooked bool `json:"cooked"` 148 + } 149 + 150 + func cookedRecipeCount(ctx context.Context, c cache.Cache, user utypes.User) int32 { 151 + var count atomic.Int32 152 + var wg sync.WaitGroup 153 + for _, recipe := range user.LastRecipes { 154 + if time.Since(recipe.CreatedAt) > 30*time.Hour*24 { 155 + continue 156 + } 157 + 158 + wg.Go(func() { 159 + feedbackReader, err := c.Get(ctx, "recipe_feedback/"+recipe.Hash) 160 + if err != nil { 161 + return 162 + } 163 + 164 + var feedback adminRecipeFeedback 165 + decodeErr := json.NewDecoder(feedbackReader).Decode(&feedback) 166 + closeErr := feedbackReader.Close() 167 + if decodeErr != nil || closeErr != nil { 168 + return 169 + } 170 + 171 + if feedback.Cooked { 172 + count.Add(1) 173 + } 174 + }) 175 + } 176 + wg.Wait() 177 + return count.Load() 115 178 } 116 179 117 180 func renderAdminEmailsText(w http.ResponseWriter, users []utypes.User) {
+40 -6
internal/users/admin_page_test.go
··· 20 20 now := time.Now() 21 21 22 22 if err := storage.Update(&utypes.User{ 23 + ID: "2b190b35-10c6-4d6f-9858-3dd73c4155a0", 24 + Email: []string{"legacy@example.com"}, 25 + ShoppingDay: time.Monday.String(), 26 + }); err != nil { 27 + t.Fatalf("update legacy user: %v", err) 28 + } 29 + 30 + if err := storage.Update(&utypes.User{ 23 31 ID: "user_1", 24 32 Email: []string{"alice@example.com"}, 25 33 ShoppingDay: time.Monday.String(), 26 34 LastRecipes: []utypes.Recipe{ 27 - {Title: "Tomato Soup", CreatedAt: now}, 28 - {Title: "Veggie Tacos", CreatedAt: now.Add(-1 * time.Hour)}, 35 + {Title: "Tomato Soup", Hash: "hash-1", CreatedAt: now}, 36 + {Title: "Veggie Tacos", Hash: "hash-2", CreatedAt: now.Add(-1 * time.Hour)}, 29 37 }, 30 38 }); err != nil { 31 39 t.Fatalf("update user_1: %v", err) ··· 35 43 ID: "user_2", 36 44 Email: []string{"bob@example.com", "bobby@example.com"}, 37 45 ShoppingDay: time.Tuesday.String(), 46 + LastRecipes: []utypes.Recipe{ 47 + {Title: "Pasta", Hash: "hash-3", CreatedAt: now}, 48 + }, 38 49 }); err != nil { 39 50 t.Fatalf("update user_2: %v", err) 40 51 } 41 52 53 + if err := fc.Put(t.Context(), "recipe_feedback/hash-1", `{"cooked": true}`, cache.Unconditional()); err != nil { 54 + t.Fatalf("put feedback hash-1: %v", err) 55 + } 56 + if err := fc.Put(t.Context(), "recipe_feedback/hash-2", `{"cooked": false}`, cache.Unconditional()); err != nil { 57 + t.Fatalf("put feedback hash-2: %v", err) 58 + } 59 + if err := fc.Put(t.Context(), "recipe_feedback/hash-3", `{"cooked": true}`, cache.Unconditional()); err != nil { 60 + t.Fatalf("put feedback hash-3: %v", err) 61 + } 62 + 42 63 req := httptest.NewRequest(http.MethodGet, "/users", nil) 43 64 rr := httptest.NewRecorder() 44 65 ··· 62 83 t.Fatalf("response body missing %q: %s", want, body) 63 84 } 64 85 } 65 - if !regexp.MustCompile(`<td>\s*2\s*</td>`).MatchString(body) { 66 - t.Fatalf("response body missing saved recipe count 2: %s", body) 86 + if strings.Contains(body, "legacy@example.com") { 87 + t.Fatalf("response body should not include legacy guid account: %s", body) 88 + } 89 + if !regexp.MustCompile(`<td>\s*user_1\s*</td>[\s\S]*?<td>\s*2\s*</td>[\s\S]*?<td>\s*1\s*</td>`).MatchString(body) { 90 + t.Fatalf("response body missing user_1 row with saved/cooked counts: %s", body) 91 + } 92 + if !regexp.MustCompile(`<td>\s*user_2\s*</td>[\s\S]*?<td>\s*1\s*</td>[\s\S]*?<td>\s*1\s*</td>`).MatchString(body) { 93 + t.Fatalf("response body missing user_2 row with saved/cooked counts: %s", body) 67 94 } 68 - if !regexp.MustCompile(`<td>\s*0\s*</td>`).MatchString(body) { 69 - t.Fatalf("response body missing saved recipe count 0: %s", body) 95 + if strings.Index(body, "user_1") > strings.Index(body, "user_2") { 96 + t.Fatalf("expected users sorted by saved recipe count descending: %s", body) 70 97 } 71 98 for _, unwanted := range []string{"Tomato Soup", "Veggie Tacos"} { 72 99 if strings.Contains(body, unwanted) { ··· 103 130 ShoppingDay: time.Wednesday.String(), 104 131 }); err != nil { 105 132 t.Fatalf("update user_1: %v", err) 133 + } 134 + if err := storage.Update(&utypes.User{ 135 + ID: "73f252fc-6116-4d48-9df1-b3cff4963f38", 136 + Email: []string{"legacy@example.com"}, 137 + ShoppingDay: time.Wednesday.String(), 138 + }); err != nil { 139 + t.Fatalf("update legacy user: %v", err) 106 140 } 107 141 if err := storage.Update(&utypes.User{ 108 142 ID: "user_2",