feat: add last active time per user
This commit is contained in:
parent
90c7dcf891
commit
cf95e69349
|
@ -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
|
||||
}
|
||||
|
|
|
@ -24,6 +24,7 @@ type User struct {
|
|||
DisplayName *string
|
||||
Bio *string
|
||||
MemberTitle *string
|
||||
LastActive time.Time
|
||||
|
||||
Avatar *string
|
||||
Links []string
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
234
openapi.yml
234
openapi.yml
|
@ -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.
|
|
@ -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();
|
Loading…
Reference in New Issue