From 2f31d678d01b5aa9df59913225ffae1ef9b8f6cc Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Wed, 11 Sep 2024 19:28:52 -0700 Subject: [PATCH] Usage Statistics and Website Demographics Adds two new features to collect and show useful analytics. Usage Statistics: * Begin tracking daily active users who log in and interact with major features of the website each day, such as the chat room, forum and gallery. Demographics page: * For marketing, the home page now shows live statistics about the breakdown of content (explicit vs. non-explicit) on the site, and the /insights page gives a lot more data in detail. * Show the percent split in photo gallery content and how many users opt-in or share explicit content on the site. * Show high-level demographics of the members (by age range, gender, orientation) Misc cleanup: * Rearrange model list in data export to match the auto-create statements. * In data exports, include the forum_memberships, push_notifications and usage_statistics tables. --- pkg/config/config.go | 3 + pkg/controller/chat/chat.go | 7 + pkg/controller/forum/thread.go | 7 + pkg/controller/index/demographics.go | 57 ++++++ pkg/controller/index/index.go | 13 +- pkg/controller/photo/site_gallery.go | 8 + pkg/middleware/authentication.go | 3 +- pkg/models/deletion/delete_user.go | 11 + pkg/models/demographic/demographic.go | 195 ++++++++++++++++++ pkg/models/demographic/queries.go | 284 ++++++++++++++++++++++++++ pkg/models/exporting/models.go | 84 ++++++-- pkg/models/models.go | 3 +- pkg/models/usage_statistic.go | 105 ++++++++++ pkg/models/user.go | 15 ++ pkg/router/router.go | 1 + pkg/session/session.go | 3 +- web/templates/demographics.html | 233 +++++++++++++++++++++ web/templates/index.html | 19 +- 18 files changed, 1020 insertions(+), 31 deletions(-) create mode 100644 pkg/controller/index/demographics.go create mode 100644 pkg/models/demographic/demographic.go create mode 100644 pkg/models/demographic/queries.go create mode 100644 pkg/models/usage_statistic.go create mode 100644 web/templates/demographics.html diff --git a/pkg/config/config.go b/pkg/config/config.go index 68e94b5..b5d5b27 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -83,6 +83,9 @@ const ( // Chat room status refresh interval. ChatStatusRefreshInterval = 30 * time.Second + + // Cache TTL for the demographics page. + DemographicsCacheTTL = time.Hour ) var ( diff --git a/pkg/controller/chat/chat.go b/pkg/controller/chat/chat.go index 8afa506..8347aaf 100644 --- a/pkg/controller/chat/chat.go +++ b/pkg/controller/chat/chat.go @@ -154,6 +154,13 @@ func Landing() http.HandlerFunc { // of time where they can exist in chat but change their name on the site. worker.GetChatStatistics().SetOnlineNow(currentUser.Username) + // Ping their chat login usage statistic. + go func() { + if err := models.LogDailyChatUser(currentUser); err != nil { + log.Error("LogDailyChatUser(%s): error logging this user's chat statistic: %s", currentUser.Username, err) + } + }() + // Redirect them to the chat room. templates.Redirect(w, strings.TrimSuffix(chatURL, "/")+"/?jwt="+ss) return diff --git a/pkg/controller/forum/thread.go b/pkg/controller/forum/thread.go index bb39df7..79f19b0 100644 --- a/pkg/controller/forum/thread.go +++ b/pkg/controller/forum/thread.go @@ -97,6 +97,13 @@ func Thread() http.HandlerFunc { // Is the current user subscribed to notifications on this thread? _, isSubscribed := models.IsSubscribed(currentUser, "threads", thread.ID) + // Ping this user as having used the forums today. + go func() { + if err := models.LogDailyForumUser(currentUser); err != nil { + log.Error("LogDailyForumUser(%s): error logging their usage statistic: %s", currentUser.Username, err) + } + }() + var vars = map[string]interface{}{ "Forum": forum, "Thread": thread, diff --git a/pkg/controller/index/demographics.go b/pkg/controller/index/demographics.go new file mode 100644 index 0000000..34dceb1 --- /dev/null +++ b/pkg/controller/index/demographics.go @@ -0,0 +1,57 @@ +package index + +import ( + "net/http" + + "code.nonshy.com/nonshy/website/pkg/models/demographic" + "code.nonshy.com/nonshy/website/pkg/session" + "code.nonshy.com/nonshy/website/pkg/templates" +) + +// Demographics page (/insights) to show a peek at website demographics. +func Demographics() http.HandlerFunc { + tmpl := templates.Must("demographics.html") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var ( + refresh = r.FormValue("refresh") == "true" + ) + + // Are we refreshing? Check if an admin is logged in. + if refresh { + currentUser, err := session.CurrentUser(r) + if err != nil { + session.FlashError(w, r, "You must be logged in to do that!") + templates.Redirect(w, r.URL.Path) + return + } + + // Do the refresh? + if currentUser.IsAdmin { + _, err := demographic.Refresh() + if err != nil { + session.FlashError(w, r, "Refreshing the insights: %s", err) + } + } + + templates.Redirect(w, r.URL.Path) + return + } + + // Get website statistics to show on home page. + demo, err := demographic.Get() + if err != nil { + session.FlashError(w, r, "Couldn't get website statistics: %s", err) + templates.Redirect(w, "/") + return + } + + vars := map[string]interface{}{ + "Demographic": demo, + } + + if err := tmpl.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} diff --git a/pkg/controller/index/index.go b/pkg/controller/index/index.go index 648eece..142c68e 100644 --- a/pkg/controller/index/index.go +++ b/pkg/controller/index/index.go @@ -5,6 +5,7 @@ import ( "code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/log" + "code.nonshy.com/nonshy/website/pkg/models/demographic" "code.nonshy.com/nonshy/website/pkg/templates" ) @@ -18,7 +19,17 @@ func Create() http.HandlerFunc { return } - if err := tmpl.Execute(w, r, nil); err != nil { + // Get website statistics to show on home page. + demo, err := demographic.Get() + if err != nil { + log.Error("demographic.Get: %s", err) + } + + vars := map[string]interface{}{ + "Demographic": demo, + } + + if err := tmpl.Execute(w, r, vars); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } diff --git a/pkg/controller/photo/site_gallery.go b/pkg/controller/photo/site_gallery.go index d5a7b3f..13171fc 100644 --- a/pkg/controller/photo/site_gallery.go +++ b/pkg/controller/photo/site_gallery.go @@ -4,6 +4,7 @@ import ( "net/http" "code.nonshy.com/nonshy/website/pkg/config" + "code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/session" "code.nonshy.com/nonshy/website/pkg/templates" @@ -121,6 +122,13 @@ func SiteGallery() http.HandlerFunc { likeMap := models.MapLikes(currentUser, "photos", photoIDs) commentMap := models.MapCommentCounts("photos", photoIDs) + // Ping this user as having used the forums today. + go func() { + if err := models.LogDailyGalleryUser(currentUser); err != nil { + log.Error("LogDailyGalleryUser(%s): error logging their usage statistic: %s", currentUser.Username, err) + } + }() + var vars = map[string]interface{}{ "IsSiteGallery": true, "Photos": photos, diff --git a/pkg/middleware/authentication.go b/pkg/middleware/authentication.go index 6c33f5f..6a054c9 100644 --- a/pkg/middleware/authentication.go +++ b/pkg/middleware/authentication.go @@ -48,9 +48,8 @@ func LoginRequired(handler http.Handler) http.Handler { // Ping LastLoginAt for long lived sessions, but not if impersonated. var pingLastLoginAt bool if time.Since(user.LastLoginAt) > config.LastLoginAtCooldown && !session.Impersonated(r) { - user.LastLoginAt = time.Now() pingLastLoginAt = true - if err := user.Save(); err != nil { + if err := user.PingLastLoginAt(); err != nil { log.Error("LoginRequired: couldn't refresh LastLoginAt for user %s: %s", user.Username, err) } } diff --git a/pkg/models/deletion/delete_user.go b/pkg/models/deletion/delete_user.go index 3cb15f6..c41e4cf 100644 --- a/pkg/models/deletion/delete_user.go +++ b/pkg/models/deletion/delete_user.go @@ -59,6 +59,7 @@ func DeleteUser(user *models.User) error { {"IP Addresses", DeleteIPAddresses}, {"Push Notifications", DeletePushNotifications}, {"Forum Memberships", DeleteForumMemberships}, + {"Usage Statistics", DeleteUsageStatistics}, } for _, item := range todo { if err := item.Fn(user.ID); err != nil { @@ -406,3 +407,13 @@ func DeleteForumMemberships(userID uint64) error { ).Delete(&models.ForumMembership{}) return result.Error } + +// DeleteUsageStatistics scrubs data for deleting a user. +func DeleteUsageStatistics(userID uint64) error { + log.Error("DeleteUser: DeleteUsageStatistics(%d)", userID) + result := models.DB.Where( + "user_id = ?", + userID, + ).Delete(&models.UsageStatistic{}) + return result.Error +} diff --git a/pkg/models/demographic/demographic.go b/pkg/models/demographic/demographic.go new file mode 100644 index 0000000..fea99b4 --- /dev/null +++ b/pkg/models/demographic/demographic.go @@ -0,0 +1,195 @@ +// Package demographic handles periodic report pulling for high level website statistics. +// +// It powers the home page and insights page, where a prospective new user can get a peek inside +// the website to see the split between regular vs. explicit content and membership statistics. +// +// These database queries could get slow so the demographics are pulled and cached in this package. +package demographic + +import ( + "encoding/json" + "sort" + "time" + + "code.nonshy.com/nonshy/website/pkg/config" +) + +// Demographic is the top level container struct with all the insights needed for front-end display. +type Demographic struct { + Computed bool + LastUpdated time.Time + Photo Photo + People People +} + +// Photo statistics show the split between explicit and non-explicit content. +type Photo struct { + Total int64 + NonExplicit int64 + Explicit int64 +} + +// People statistics. +type People struct { + Total int64 + ExplicitOptIn int64 + ExplicitPhoto int64 + ByAgeRange map[string]int64 + ByGender map[string]int64 + ByOrientation map[string]int64 +} + +// MemberDemographic of members. +type MemberDemographic struct { + Label string // e.g. age range "18-25" or gender + Count int64 + Percent int +} + +/** + * Dynamic calculation methods on the above types (percentages, etc.) + */ + +func (d Demographic) PrettyPrint() string { + b, err := json.MarshalIndent(d, "", "\t") + if err != nil { + return err.Error() + } + return string(b) +} + +func (p Photo) PercentExplicit() int { + if p.Total == 0 { + return 0 + } + return int((float64(p.Explicit) / float64(p.Total)) * 100) +} + +func (p Photo) PercentNonExplicit() int { + if p.Total == 0 { + return 0 + } + return int((float64(p.NonExplicit) / float64(p.Total)) * 100) +} + +func (p People) PercentExplicit() int { + if p.Total == 0 { + return 0 + } + return int((float64(p.ExplicitOptIn) / float64(p.Total)) * 100) +} + +func (p People) PercentExplicitPhoto() int { + if p.Total == 0 { + return 0 + } + return int((float64(p.ExplicitPhoto) / float64(p.Total)) * 100) +} + +func (p People) IterAgeRanges() []MemberDemographic { + var ( + result = []MemberDemographic{} + values = []string{} + unique = map[string]struct{}{} + ) + + for age := range p.ByAgeRange { + if _, ok := unique[age]; !ok { + values = append(values, age) + } + unique[age] = struct{}{} + } + + sort.Strings(values) + for _, age := range values { + var ( + count = p.ByAgeRange[age] + pct int + ) + if p.Total > 0 { + pct = int((float64(count) / float64(p.Total)) * 100) + } + + result = append(result, MemberDemographic{ + Label: age, + Count: p.ByAgeRange[age], + Percent: pct, + }) + } + + return result +} + +func (p People) IterGenders() []MemberDemographic { + var ( + result = []MemberDemographic{} + values = append(config.Gender, "") + unique = map[string]struct{}{} + ) + + for _, option := range values { + unique[option] = struct{}{} + } + + for gender := range p.ByGender { + if _, ok := unique[gender]; !ok { + values = append(values, gender) + unique[gender] = struct{}{} + } + } + + for _, gender := range values { + var ( + count = p.ByGender[gender] + pct int + ) + if p.Total > 0 { + pct = int((float64(count) / float64(p.Total)) * 100) + } + + result = append(result, MemberDemographic{ + Label: gender, + Count: p.ByGender[gender], + Percent: pct, + }) + } + + return result +} + +func (p People) IterOrientations() []MemberDemographic { + var ( + result = []MemberDemographic{} + values = append(config.Orientation, "") + unique = map[string]struct{}{} + ) + + for _, option := range values { + unique[option] = struct{}{} + } + + for orientation := range p.ByOrientation { + if _, ok := unique[orientation]; !ok { + values = append(values, orientation) + unique[orientation] = struct{}{} + } + } + + for _, gender := range values { + var ( + count = p.ByOrientation[gender] + pct int + ) + if p.Total > 0 { + pct = int((float64(count) / float64(p.Total)) * 100) + } + + result = append(result, MemberDemographic{ + Label: gender, + Count: p.ByOrientation[gender], + Percent: pct, + }) + } + + return result +} diff --git a/pkg/models/demographic/queries.go b/pkg/models/demographic/queries.go new file mode 100644 index 0000000..df0a52d --- /dev/null +++ b/pkg/models/demographic/queries.go @@ -0,0 +1,284 @@ +package demographic + +import ( + "errors" + "sync" + "time" + + "code.nonshy.com/nonshy/website/pkg/config" + "code.nonshy.com/nonshy/website/pkg/log" + "code.nonshy.com/nonshy/website/pkg/models" +) + +// Cached statistics (in case the queries are heavy to hit too often). +var ( + cachedDemographic Demographic + cacheMu sync.Mutex +) + +// Get the current cached demographics result. +func Get() (Demographic, error) { + // Do we have the results cached? + var result = cachedDemographic + if !result.Computed || time.Since(result.LastUpdated) > config.DemographicsCacheTTL { + cacheMu.Lock() + defer cacheMu.Unlock() + + // If we have a race of threads: e.g. one request is pulling the stats and the second is locked. + // Check if we have an updated result from the first thread. + if time.Since(cachedDemographic.LastUpdated) < config.DemographicsCacheTTL { + return cachedDemographic, nil + } + + // Get the latest. + res, err := Generate() + if err != nil { + return result, err + } + + cachedDemographic = res + } + + return cachedDemographic, nil +} + +// Refresh the demographics cache, pulling fresh results from the database every time. +func Refresh() (Demographic, error) { + cacheMu.Lock() + cachedDemographic = Demographic{} + cacheMu.Unlock() + return Get() +} + +// Generate the demographics result. +func Generate() (Demographic, error) { + if !config.Current.Database.IsPostgres { + return cachedDemographic, errors.New("this feature requires a PostgreSQL database") + } + + result := Demographic{ + Computed: true, + LastUpdated: time.Now(), + Photo: PhotoStatistics(), + People: PeopleStatistics(), + } + + return result, nil +} + +// PeopleStatistics pulls various metrics about users of the website. +func PeopleStatistics() People { + var result = People{ + ByAgeRange: map[string]int64{}, + ByGender: map[string]int64{}, + ByOrientation: map[string]int64{}, + } + + type record struct { + MetricType string + MetricValue string + MetricCount int64 + } + var records []record + res := models.DB.Raw(` + -- Users who opt in/out of explicit content + WITH subquery_explicit AS ( + SELECT + SUM(CASE WHEN explicit IS TRUE THEN 1 ELSE 0 END) AS explicit_count, + SUM(CASE WHEN explicit IS NOT TRUE THEN 1 ELSE 0 END) AS non_explicit_count + FROM users + WHERE users.status = 'active' + AND users.certified IS TRUE + ), + + -- Users who share at least one explicit photo on public + subquery_explicit_photo AS ( + SELECT + COUNT(*) AS user_count + FROM users + WHERE users.status = 'active' + AND users.certified IS TRUE + AND EXISTS ( + SELECT 1 + FROM photos + WHERE photos.user_id = users.id + AND photos.explicit IS TRUE + AND photos.visibility = 'public' + ) + ), + + -- User counts by age + subquery_ages AS ( + SELECT + CASE + WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 0 AND 25 THEN '18-25' + WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 26 and 35 THEN '26-35' + WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 36 and 45 THEN '36-45' + WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 46 and 55 THEN '46-55' + WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 56 and 65 THEN '56-65' + WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 66 and 75 THEN '66-75' + WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 76 and 85 THEN '76-85' + ELSE '86+' + END AS age_range, + COUNT(*) AS user_count + FROM + users + GROUP BY + CASE + WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 0 AND 25 THEN '18-25' + WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 26 and 35 THEN '26-35' + WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 36 and 45 THEN '36-45' + WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 46 and 55 THEN '46-55' + WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 56 and 65 THEN '56-65' + WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 66 and 75 THEN '66-75' + WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 76 and 85 THEN '76-85' + ELSE '86+' + END + ), + + -- User counts by gender + subquery_gender AS ( + SELECT + profile_fields.value AS gender, + COUNT(*) AS user_count + FROM users + JOIN profile_fields ON profile_fields.user_id = users.id + WHERE users.status = 'active' + AND users.certified IS TRUE + AND profile_fields.name = 'gender' + GROUP BY profile_fields.value + ), + + -- User counts by orientation + subquery_orientation AS ( + SELECT + profile_fields.value AS orientation, + COUNT(*) AS user_count + FROM users + JOIN profile_fields ON profile_fields.user_id = users.id + WHERE users.status = 'active' + AND users.certified IS TRUE + AND profile_fields.name = 'orientation' + GROUP BY profile_fields.value + ) + + SELECT + 'ExplicitCount' AS metric_type, + 'explicit' AS metric_value, + explicit_count AS metric_count + FROM subquery_explicit + + UNION ALL + + SELECT + 'ExplicitPhotoCount' AS metric_type, + 'count' AS metric_value, + user_count AS metric_count + FROM subquery_explicit_photo + + UNION ALL + + SELECT + 'ExplicitCount' AS metric_type, + 'non_explicit' AS metric_value, + non_explicit_count AS metric_count + FROM subquery_explicit + + UNION ALL + + SELECT + 'AgeCounts' AS metric_type, + age_range AS metric_value, + user_count AS metric_count + FROM subquery_ages + + UNION ALL + + SELECT + 'GenderCount' AS metric_type, + gender AS metric_value, + user_count AS metric_count + FROM subquery_gender + + UNION ALL + + SELECT + 'OrientationCount' AS metric_type, + orientation AS metric_value, + user_count AS metric_count + FROM subquery_orientation + `).Scan(&records) + if res.Error != nil { + log.Error("PeopleStatistics: %s", res.Error) + return result + } + + // Ingest the records. + for _, row := range records { + switch row.MetricType { + case "ExplicitCount": + result.Total += row.MetricCount + if row.MetricValue == "explicit" { + result.ExplicitOptIn = row.MetricCount + } + case "ExplicitPhotoCount": + result.ExplicitPhoto = row.MetricCount + case "AgeCounts": + if _, ok := result.ByAgeRange[row.MetricValue]; !ok { + result.ByAgeRange[row.MetricValue] = 0 + } + result.ByAgeRange[row.MetricValue] += row.MetricCount + case "GenderCount": + if _, ok := result.ByGender[row.MetricValue]; !ok { + result.ByGender[row.MetricValue] = 0 + } + result.ByGender[row.MetricValue] += row.MetricCount + case "OrientationCount": + if _, ok := result.ByOrientation[row.MetricValue]; !ok { + result.ByOrientation[row.MetricValue] = 0 + } + result.ByOrientation[row.MetricValue] += row.MetricCount + } + } + + return result +} + +// PhotoStatistics gets info about photo usage on the website. +// +// Counts of Explicit vs. Non-Explicit photos. +func PhotoStatistics() Photo { + var result Photo + type record struct { + Explicit bool + C int64 + } + var records []record + + res := models.DB.Raw(` + SELECT + photos.explicit, + count(photos.id) AS c + FROM + photos + WHERE + photos.visibility = 'public' + GROUP BY photos.explicit + ORDER BY c DESC + `).Scan(&records) + if res.Error != nil { + log.Error("PhotoStatistics: %s", res.Error) + return result + } + + for _, row := range records { + result.Total += row.C + if row.Explicit { + result.Explicit += row.C + } else { + result.NonExplicit += row.C + } + } + + return result +} diff --git a/pkg/models/exporting/models.go b/pkg/models/exporting/models.go index 15fd80c..53b89a2 100644 --- a/pkg/models/exporting/models.go +++ b/pkg/models/exporting/models.go @@ -19,30 +19,33 @@ func ExportModels(zw *zip.Writer, user *models.User) error { // List of tables to export. Keep the ordering in sync with // the AutoMigrate() calls in ../models.go var todo = []task{ - {"User", ExportUserTable}, + // Note: AdminGroup info is eager-loaded in User export + {"Block", ExportBlockTable}, + {"CertificationPhoto", ExportCertificationPhotoTable}, + {"ChangeLog", ExportChangeLogTable}, + {"Comment", ExportCommentTable}, + {"CommentPhoto", ExportCommentPhotoTable}, + {"Feedback", ExportFeedbackTable}, + {"ForumMembership", ExportForumMembershipTable}, + {"Friend", ExportFriendTable}, + {"Forum", ExportForumTable}, + {"IPAddress", ExportIPAddressTable}, + {"Like", ExportLikeTable}, + {"Message", ExportMessageTable}, + {"Notification", ExportNotificationTable}, {"ProfileField", ExportProfileFieldTable}, {"Photo", ExportPhotoTable}, - {"PrivatePhoto", ExportPrivatePhotoTable}, - {"CertificationPhoto", ExportCertificationPhotoTable}, - {"Message", ExportMessageTable}, - {"Friend", ExportFriendTable}, - {"Block", ExportBlockTable}, - {"Feedback", ExportFeedbackTable}, - {"Forum", ExportForumTable}, - {"Thread", ExportThreadTable}, - {"Comment", ExportCommentTable}, - {"Like", ExportLikeTable}, - {"Notification", ExportNotificationTable}, - {"Subscription", ExportSubscriptionTable}, - {"CommentPhoto", ExportCommentPhotoTable}, // Note: Poll table is eager-loaded in Thread export {"PollVote", ExportPollVoteTable}, - // Note: AdminGroup info is eager-loaded in User export + {"PrivatePhoto", ExportPrivatePhotoTable}, + {"PushNotification", ExportPushNotificationTable}, + {"Subscription", ExportSubscriptionTable}, + {"Thread", ExportThreadTable}, + {"TwoFactor", ExportTwoFactorTable}, + {"UsageStatistic", ExportUsageStatisticTable}, + {"User", ExportUserTable}, {"UserLocation", ExportUserLocationTable}, {"UserNote", ExportUserNoteTable}, - {"ChangeLog", ExportChangeLogTable}, - {"TwoFactor", ExportTwoFactorTable}, - {"IPAddress", ExportIPAddressTable}, } for _, item := range todo { log.Info("Exporting data model: %s", item.Step) @@ -444,3 +447,48 @@ func ExportIPAddressTable(zw *zip.Writer, user *models.User) error { return ZipJson(zw, "ip_addresses.json", items) } + +func ExportForumMembershipTable(zw *zip.Writer, user *models.User) error { + var ( + items = []*models.ForumMembership{} + query = models.DB.Model(&models.ForumMembership{}).Where( + "user_id = ?", + user.ID, + ).Find(&items) + ) + if query.Error != nil { + return query.Error + } + + return ZipJson(zw, "forum_memberships.json", items) +} + +func ExportPushNotificationTable(zw *zip.Writer, user *models.User) error { + var ( + items = []*models.PushNotification{} + query = models.DB.Model(&models.PushNotification{}).Where( + "user_id = ?", + user.ID, + ).Find(&items) + ) + if query.Error != nil { + return query.Error + } + + return ZipJson(zw, "push_notifications.json", items) +} + +func ExportUsageStatisticTable(zw *zip.Writer, user *models.User) error { + var ( + items = []*models.UsageStatistic{} + query = models.DB.Model(&models.UsageStatistic{}).Where( + "user_id = ?", + user.ID, + ).Find(&items) + ) + if query.Error != nil { + return query.Error + } + + return ZipJson(zw, "usage_statistics.json", items) +} diff --git a/pkg/models/models.go b/pkg/models/models.go index 679558a..f2664b6 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -31,9 +31,10 @@ func AutoMigrate() { &Poll{}, // vacuum script cleans up orphaned polls &PrivatePhoto{}, // ✔ &PushNotification{}, // ✔ + &Subscription{}, // ✔ &Thread{}, // ✔ &TwoFactor{}, // ✔ - &Subscription{}, // ✔ + &UsageStatistic{}, // ✔ &User{}, // ✔ &UserLocation{}, // ✔ &UserNote{}, // ✔ diff --git a/pkg/models/usage_statistic.go b/pkg/models/usage_statistic.go new file mode 100644 index 0000000..d75eef8 --- /dev/null +++ b/pkg/models/usage_statistic.go @@ -0,0 +1,105 @@ +package models + +import "time" + +/* +UsageStatistic holds basic analytics points for things like daily/monthly active user counts. + +Generally, there will be one UserStatistic row for each combination of a UserID and Type for +each calendar day of the year. Type names may be like "dau" to log daily logins (Daily Active User), +or "chat" to log daily chat room users. + +If a user logs in multiple times in the same day, their existing UsageStatistic for that day +is reused and the Counter is incremented. So if a user joins chat 3 times on the same day, there +will be a single row for that date for that user, but with a Counter of 3 in that case. + +This makes it easier to query for aggregate reports on daily/monthly active users since each +row/event type combo only appears once per user per day. +*/ +type UsageStatistic struct { + ID uint64 `gorm:"primaryKey"` + UserID uint64 `gorm:"uniqueIndex:idx_usage_statistics"` + Type string `gorm:"uniqueIndex:idx_usage_statistics"` + Date string `gorm:"uniqueIndex:idx_usage_statistics"` // unique days, yyyy-mm-dd format. + Counter uint64 + CreatedAt time.Time `gorm:"index"` // full timestamps + UpdatedAt time.Time `gorm:"index"` +} + +// Options for UsageStatistic Type values. +const ( + UsageStatisticDailyVisit = "dau" // daily active user counter + UsageStatisticChatEntry = "chat" // daily chat room users + UsageStatisticForumUser = "forum" // daily forum users (when they open a thread) + UsageStatisticGalleryUser = "gallery" // daily Site Gallery user (when viewing the site gallery) +) + +// LogDailyActiveUser will ping a UserStatistic for the current user to mark them present for the day. +func LogDailyActiveUser(user *User) error { + var ( + date = time.Now().Format(time.DateOnly) + _, err = IncrementUsageStatistic(user, UsageStatisticDailyVisit, date) + ) + return err +} + +// LogDailyChatUser will ping a UserStatistic for the current user to mark them as having used the chat room today. +func LogDailyChatUser(user *User) error { + var ( + date = time.Now().Format(time.DateOnly) + _, err = IncrementUsageStatistic(user, UsageStatisticChatEntry, date) + ) + return err +} + +// LogDailyForumUser will ping a UserStatistic for the current user to mark them as having used the forums today. +func LogDailyForumUser(user *User) error { + var ( + date = time.Now().Format(time.DateOnly) + _, err = IncrementUsageStatistic(user, UsageStatisticForumUser, date) + ) + return err +} + +// LogDailyGalleryUser will ping a UserStatistic for the current user to mark them as having used the site gallery today. +func LogDailyGalleryUser(user *User) error { + var ( + date = time.Now().Format(time.DateOnly) + _, err = IncrementUsageStatistic(user, UsageStatisticGalleryUser, date) + ) + return err +} + +// GetUsageStatistic looks up a user statistic. +func GetUsageStatistic(user *User, statType, date string) (*UsageStatistic, error) { + var ( + result = &UsageStatistic{} + res = DB.Model(&UsageStatistic{}).Where( + "user_id = ? AND type = ? AND date = ?", + user.ID, statType, date, + ).First(&result) + ) + return result, res.Error +} + +// IncrementUsageStatistic finds or creates a UserStatistic type and increments the counter. +func IncrementUsageStatistic(user *User, statType, date string) (*UsageStatistic, error) { + user.muStatistic.Lock() + defer user.muStatistic.Unlock() + + // Is there an existing row? + stat, err := GetUsageStatistic(user, statType, date) + if err != nil { + stat = &UsageStatistic{ + UserID: user.ID, + Type: statType, + Counter: 0, + Date: date, + } + } + + // Update and save it. + stat.Counter++ + err = DB.Save(stat).Error + return stat, err +} diff --git a/pkg/models/user.go b/pkg/models/user.go index 5fc3027..6d49315 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "strings" + "sync" "time" "code.nonshy.com/nonshy/website/pkg/config" @@ -45,6 +46,9 @@ type User struct { cachePhotoTypes map[PhotoVisibility]struct{} cacheBlockedUserIDs []uint64 cachePhotoIDs []uint64 + + // Feature mutexes. + muStatistic sync.Mutex } type UserVisibility string @@ -226,6 +230,17 @@ func IsValidUsername(username string) error { return nil } +// PingLastLoginAt refreshes the user's "last logged in" time. +func (u *User) PingLastLoginAt() error { + // Also ping their daily active user statistic. + if err := LogDailyActiveUser(u); err != nil { + log.Error("PingLastLoginAt(%s): couldn't log daily active user statistic: %s", u.Username, err) + } + + u.LastLoginAt = time.Now() + return u.Save() +} + // IsBanned returns if the user account is banned. func (u *User) IsBanned() bool { return u.Status == UserStatusBanned diff --git a/pkg/router/router.go b/pkg/router/router.go index 1bad99b..7561b10 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -34,6 +34,7 @@ func New() http.Handler { mux.HandleFunc("GET /sw.js", index.ServiceWorker()) mux.HandleFunc("GET /about", index.StaticTemplate("about.html")()) mux.HandleFunc("GET /features", index.StaticTemplate("features.html")()) + mux.HandleFunc("GET /insights", index.Demographics()) mux.HandleFunc("GET /faq", index.StaticTemplate("faq.html")()) mux.HandleFunc("GET /tos", index.StaticTemplate("tos.html")()) mux.HandleFunc("GET /privacy", index.StaticTemplate("privacy.html")()) diff --git a/pkg/session/session.go b/pkg/session/session.go index 28953ac..c3affa1 100644 --- a/pkg/session/session.go +++ b/pkg/session/session.go @@ -172,8 +172,7 @@ func LoginUser(w http.ResponseWriter, r *http.Request, u *models.User) error { sess.Save(w) // Ping the user's last login time. - u.LastLoginAt = time.Now() - return u.Save() + return u.PingLastLoginAt() } // ImpersonateUser assumes the role of the user impersonated by an admin uid. diff --git a/web/templates/demographics.html b/web/templates/demographics.html new file mode 100644 index 0000000..b9c890a --- /dev/null +++ b/web/templates/demographics.html @@ -0,0 +1,233 @@ +{{define "title"}}A peek inside the {{.Title}} website{{end}} +{{define "content"}} +
+
+
+
+

A peek inside the {{PrettyTitle}} website

+

Some statistics & demographics of our members & content

+
+
+
+
+ +
+
+

+ This page provides some insights into the distribution of content and member demographics + in the {{PrettyTitle}} community. +

+ +

+ If you are a prospective new member and are curious about whether this isn't actually "just another porn site," + hopefully this page will help answer some of those questions for you. +

+ +

+ We have found that {{PrettyTitle}} actually fills a much needed niche in between the two + opposite poles of the "strict naturist website" and the "hyper sexual porn sites" that exist elsewhere + online. Many of our members have come here from other major nudist websites, and we have so far maintained a + nice balance in content: most of what people share here are "normal nudes" that + don't contain any sexual undertones at all! +

+ +

+ On our webcam chat room, though we permit people to be sexual + on camera when they want to be, we typically maintain a balance where most of the webcams are + "blue" (or non-sexual in nature). Members are asked to mark their webcams as 'explicit' (red) when they are + being horny, so you may have informed consent as to whether you want to open their red cam and watch. +

+ +

+ The insights on this page should hopefully back up this story with some hard numbers so you may get a preview + of what you may expect to find on this website should you decide to join us here. +

+ +

+ {{PrettyTitle}} is open to all nudists & exhibitionists and we'd love to have you join our community! +

+ +

+ Last Updated: these website statistics were last collected + on {{.Demographic.LastUpdated.Format "Jan _2, 2006 @ 15:04:05 MST"}} + + + {{if .CurrentUser.IsAdmin}} + + Refresh now + + {{end}} +

+
+ + + +
+ +
+

+ + Photo Gallery +

+ +

+ Our members have shared {{FormatNumberCommas .Demographic.Photo.Total}} photos on + "public" for the whole {{PrettyTitle}} community to see. The majority of these photos tend to be "normal nudes" + and are non-sexual in nature (for example, not featuring so much as an erection or sexually enticing pose). +

+
+ +
+
+ Non-Explicit + ({{FormatNumberCommas .Demographic.Photo.NonExplicit}}) +
+
+ + {{.Demographic.Photo.PercentNonExplicit}}% + +
+
+ {{.Demographic.Photo.PercentNonExplicit}}% +
+
+
+
+ Explicit + ({{FormatNumberCommas .Demographic.Photo.Explicit}}) +
+
+ + {{.Demographic.Photo.PercentExplicit}}% + +
+
+ {{.Demographic.Photo.PercentExplicit}}% +
+
+ +
+

+ Remember: this website is "nudist friendly" by default, and you must opt-in if you want to + see the 'explicit' content on this website. +

+ +

+ So far: {{FormatNumberCommas .Demographic.People.ExplicitOptIn}} of our members + ({{.Demographic.People.PercentExplicit}}%) opt-in to see explicit content, and + {{FormatNumberCommas .Demographic.People.ExplicitPhoto}} + ({{.Demographic.People.PercentExplicitPhoto}}%) of them have actually shared at least one 'explicit' + photo on their public gallery. +

+
+ + + +
+ +
+

+ + People +

+ +

+ We currently have {{FormatNumberCommas .Demographic.People.Total}} members who have + verified their certification photo with the site admin. +

+ +

+ Below are some high-level demographics of who you may expect to find on this website. As a note on + genders: the majority of our members tend to be men, but we do have a handful of women here too. + {{PrettyTitle}} grows exclusively by word of mouth: if you know any women who might like + the vibe of this place, please do recommend that they check it out! +

+
+ +
+
+

By Age Range

+ + {{range .Demographic.People.IterAgeRanges}} +
+
+ {{or .Label "(No answer)"}} + ({{FormatNumberCommas .Count}}) +
+
+ + {{.Percent}}% + +
+
+ {{.Percent}}% +
+
+ {{end}} +
+ +
+

By Gender

+ + {{range .Demographic.People.IterGenders}} +
+
+ {{or .Label "No answer"}} + ({{FormatNumberCommas .Count}}) +
+
+ + {{.Percent}}% + +
+
+ {{.Percent}}% +
+
+ {{end}} +
+
+
+
+

By Orientation

+ + {{range .Demographic.People.IterOrientations}} +
+
+ {{or .Label "(No answer)"}} + ({{FormatNumberCommas .Count}}) +
+
+ + {{.Percent}}% + +
+
+ {{.Percent}}% +
+
+ {{end}} +
+
+ +
+{{end}} diff --git a/web/templates/index.html b/web/templates/index.html index 84d05a0..ca96427 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -41,29 +41,34 @@ content is strictly opt-in and the default is to hide any explicit photos or forums from your view.

+ + {{if .Demographic.Computed}}

A peek inside the site:

{{PrettyTitle}} has been found to fill a much needed niche in between the "strict naturist websites" - and the "hyper sexual porn sites" out there. As of July 31, 2024 here is a brief peek inside the - website to see what the balance of content is like in our community. + and the "hyper sexual porn sites" out there. As of {{.Demographic.LastUpdated.Format "January _2, 2006"}} here is a brief + peek inside the website to see what the balance of content is like in our community.

+ + See more: + - (Coming soon: a 'live statistics' page which will give up-to-date information and pretty graphs & charts; - in the mean time these were manually gathered). + Click here to see detailed insights about the people and content in our community -- updated regularly!

+ {{end}}