diff --git a/backend/db/metrics.go b/backend/db/metrics.go index b8ca51d..c558cee 100644 --- a/backend/db/metrics.go +++ b/backend/db/metrics.go @@ -6,8 +6,10 @@ import ( "codeberg.org/u1f320/pronouns.cc/backend/log" "emperror.dev/errors" + "github.com/jackc/pgx/v5/pgconn" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/rs/xid" ) func (db *DB) initMetrics() (err error) { @@ -39,6 +41,20 @@ func (db *DB) initMetrics() (err error) { return errors.Wrap(err, "registering member count gauge") } + err = prometheus.Register(prometheus.NewGaugeFunc(prometheus.GaugeOpts{ + Name: "pronouns_users_active", + Help: "The number of users active in the past 30 days", + }, func() float64 { + count, err := db.ActiveUsers(context.Background()) + if err != nil { + log.Errorf("getting active user count for metrics: %v", err) + } + return float64(count) + })) + if err != nil { + return errors.Wrap(err, "registering active user count gauge") + } + err = prometheus.Register(prometheus.NewGaugeFunc(prometheus.GaugeOpts{ Name: "pronouns_database_latency", Help: "The latency to the database in nanoseconds", @@ -51,6 +67,9 @@ func (db *DB) initMetrics() (err error) { } return float64(time.Since(start)) })) + if err != nil { + return errors.Wrap(err, "registering database latency gauge") + } db.TotalRequests = promauto.NewCounter(prometheus.CounterOpts{ Name: "pronouns_api_requests_total", @@ -75,3 +94,32 @@ func (db *DB) TotalMemberCount(ctx context.Context) (numMembers int64, err error } return numMembers, nil } + +const activeTime = 30 * 24 * time.Hour + +func (db *DB) ActiveUsers(ctx context.Context) (numUsers int64, err error) { + t := time.Now().Add(-activeTime) + err = db.QueryRow(ctx, "SELECT COUNT(*) FROM users WHERE deleted_at IS NULL AND last_active > $1", t).Scan(&numUsers) + if err != nil { + return 0, errors.Wrap(err, "querying active user count") + } + return numUsers, nil +} + +type connOrTx interface { + Exec(ctx context.Context, sql string, arguments ...any) (commandTag pgconn.CommandTag, err error) +} + +// UpdateActiveTime is called on create and update endpoints (PATCH /users/@me, POST/PATCH/DELETE /members) +func (db *DB) UpdateActiveTime(ctx context.Context, tx connOrTx, userID xid.ID) (err error) { + sql, args, err := sq.Update("users").Set("last_active", time.Now().UTC()).Where("id = ?", userID).ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = tx.Exec(ctx, sql, args...) + if err != nil { + return errors.Wrap(err, "executing query") + } + return nil +} diff --git a/backend/db/user.go b/backend/db/user.go index 6d8e24f..f7bdfaa 100644 --- a/backend/db/user.go +++ b/backend/db/user.go @@ -24,6 +24,7 @@ type User struct { DisplayName *string Bio *string MemberTitle *string + LastActive time.Time Avatar *string Links []string diff --git a/backend/routes/member/create_member.go b/backend/routes/member/create_member.go index 63dd571..13fd878 100644 --- a/backend/routes/member/create_member.go +++ b/backend/routes/member/create_member.go @@ -176,6 +176,13 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error } } + // update last active time + err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID) + if err != nil { + log.Errorf("updating last active time for user %v: %v", claims.UserID, err) + return err + } + err = tx.Commit(ctx) if err != nil { return errors.Wrap(err, "committing transaction") diff --git a/backend/routes/member/delete_member.go b/backend/routes/member/delete_member.go index f02cbd3..a3d121e 100644 --- a/backend/routes/member/delete_member.go +++ b/backend/routes/member/delete_member.go @@ -9,6 +9,7 @@ import ( "github.com/rs/xid" "codeberg.org/u1f320/pronouns.cc/backend/db" + "codeberg.org/u1f320/pronouns.cc/backend/log" "codeberg.org/u1f320/pronouns.cc/backend/server" ) @@ -51,6 +52,13 @@ func (s *Server) deleteMember(w http.ResponseWriter, r *http.Request) error { } } + // update last active time + err = s.DB.UpdateActiveTime(ctx, s.DB, claims.UserID) + if err != nil { + log.Errorf("updating last active time for user %v: %v", claims.UserID, err) + return err + } + render.NoContent(w, r) return nil } diff --git a/backend/routes/member/patch_member.go b/backend/routes/member/patch_member.go index 4621401..8361d7f 100644 --- a/backend/routes/member/patch_member.go +++ b/backend/routes/member/patch_member.go @@ -270,6 +270,13 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error { } } + // update last active time + err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID) + if err != nil { + log.Errorf("updating last active time for user %v: %v", claims.UserID, err) + return err + } + err = tx.Commit(ctx) if err != nil { log.Errorf("committing transaction: %v", err) diff --git a/backend/routes/user/patch_user.go b/backend/routes/user/patch_user.go index f07336c..ab07a58 100644 --- a/backend/routes/user/patch_user.go +++ b/backend/routes/user/patch_user.go @@ -252,6 +252,13 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { } } + // update last active time + err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID) + if err != nil { + log.Errorf("updating last active time for user %v: %v", claims.UserID, err) + return err + } + err = tx.Commit(ctx) if err != nil { log.Errorf("committing transaction: %v", err) diff --git a/openapi.yml b/openapi.yml deleted file mode 100644 index 6c72508..0000000 --- a/openapi.yml +++ /dev/null @@ -1,234 +0,0 @@ -openapi: 3.1.0 -info: - title: pronouns.cc API - version: 1.0.0 -servers: - - url: https://pronouns.cc/api/v1 -paths: - /users/{userRef}: - parameters: - - name: userRef - in: path - required: true - schema: - anyOf: - - $ref: "#/components/schemas/xid" - - $ref: "#/components/schemas/name" - get: - summary: /users/{userRef} - description: Get a user's information. - tags: [Users] - operationId: GetUser - responses: - "200": - description: OK - content: - application/json: - schema: - $ref: "#/components/schemas/User" - "404": - description: No user with that name or ID found. - content: - application/json: - schema: - $ref: "#/components/schemas/APIError" -components: - schemas: - XID: - title: ID - type: string - readOnly: true - minLength: 20 - maxLength: 20 - pattern: "^[0-9a-v]{20}$" - example: "ce6v1aje6i88cb6k5heg" - description: A unique, unchanging identifier for a user or a member. - Name: - title: Name - type: string - readOnly: false - minLength: 2 - maxLength: 40 - pattern: "^[\\w-.]{2,40}$" - example: "testingUser" - description: A user-defined identifier for a user or a member. - - WordStatus: - type: integer - oneOf: - - title: Favourite - const: 1 - description: Name/pronouns is user's/member's favourite - - title: Okay - const: 2 - description: Name/pronouns is okay to use - - title: Jokingly - const: 3 - description: Name/pronouns should only be used jokingly - - title: Friends only - const: 4 - description: Name/pronouns can only be used by friends - - title: Avoid - const: 5 - description: Name/pronouns should be avoided - example: 2 - description: Status for name/pronouns. - - Names: - type: array - items: - type: object - properties: - name: - type: string - required: true - minLength: 1 - maxLength: 50 - summary: A single name entry. - example: "Testington" - status: - $ref: "#/components/schemas/WordStatus" - description: Array of user's/member's name preferences. - - Pronouns: - type: array - items: - type: object - properties: - pronouns: - type: string - required: true - minLength: 1 - maxLength: 50 - summary: A single pronouns entry. - example: "it/it/its/its/itself" - display_text: - type: string - required: false - nullable: true - minLength: 1 - maxLenght: 50 - summary: A pronoun's display text. If not present, the first two forms (separated by /) in `pronouns` is used. - example: "it/its" - status: - $ref: "#/components/schemas/WordStatus" - description: Array of user's/member's pronoun preferences. - - Field: - type: object - properties: - name: - type: string - nullable: false - required: true - minLength: 1 - maxLength: 100 - example: "Name" - description: The field's name. - favourite: - type: array - items: - type: string - description: The field's favourite entries. - okay: - type: array - items: - type: string - description: The field's okay entries. - jokingly: - type: array - items: - type: string - description: The field's joking entries. - friends_only: - type: array - items: - type: string - description: The field's friends only entries. - avoid: - type: array - items: - type: string - description: The field's avoid entries. - - User: - type: object - properties: - id: - $ref: "#/components/schemas/XID" - name: - $ref: "#/components/schemas/Name" - display_name: - type: string - nullable: true - readOnly: false - minLength: 1 - maxLength: 100 - example: "Testington, Head Tester" - description: An optional nickname. - bio: - type: string - nullable: true - readOnly: false - minLength: 1 - maxLength: 1000 - example: "Hi! I'm a user!" - description: An optional bio/description. - avatar_urls: - type: array - nullable: true - items: - type: string - readOnly: true - example: ["https://pronouns.cc/avatars/members/ce6v1aje6i88cb6k5heg.webp", "https://pronouns.cc/avatars/members/ce6v1aje6i88cb6k5heg.jpg"] - description: | - An optional array of avatar URLs. - The first entry is the canonical avatar URL (the one that should be used if possible), - if the array has more entries, those are alternative formats. - links: - type: array - nullable: true - items: - type: string - minLength: 1 - maxLength: 256 - readOnly: false - example: ["https://pronouns.cc", "https://codeberg.org/u1f320"] - description: An optional array of links associated with the user. - names: - $ref: "#/components/schemas/Names" - pronouns: - $ref: "#/components/schemas/Pronouns" - fields: - type: array - nullable: true - items: - $ref: "#/components/schemas/Field" - - APIError: - type: object - properties: - code: - type: integer - optional: false - nullable: false - readOnly: true - description: A machine-readable error code. - message: - type: string - optional: false - nullable: false - readOnly: true - description: A human-readable error string. - details: - type: string - optional: true - nullable: false - readOnly: true - description: Human-readable details, if applicable. - ratelimit_reset: - type: integer - optional: true - nullable: false - readOnly: true - description: Unix timestamp after which you can make requests again, if this is a rate limit error. \ No newline at end of file diff --git a/scripts/migrate/016_user_activity.sql b/scripts/migrate/016_user_activity.sql new file mode 100644 index 0000000..e306e93 --- /dev/null +++ b/scripts/migrate/016_user_activity.sql @@ -0,0 +1,7 @@ +-- +migrate Up + +-- 2023-05-02: Add a last_active column to users, updated whenever the user modifies their profile or members. +-- This is not directly exposed in the API. +-- Potential future use cases: showing total number of active users, pruning completely empty users if they don't log in? + +alter table users add column last_active timestamptz not null default now();