forked from mirrors/pronouns.cc
feat: add last active time per user
This commit is contained in:
parent
90c7dcf891
commit
cf95e69349
8 changed files with 85 additions and 234 deletions
|
@ -6,8 +6,10 @@ import (
|
||||||
|
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/log"
|
"codeberg.org/u1f320/pronouns.cc/backend/log"
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
"github.com/rs/xid"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (db *DB) initMetrics() (err error) {
|
func (db *DB) initMetrics() (err error) {
|
||||||
|
@ -39,6 +41,20 @@ func (db *DB) initMetrics() (err error) {
|
||||||
return errors.Wrap(err, "registering member count gauge")
|
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{
|
err = prometheus.Register(prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
Name: "pronouns_database_latency",
|
Name: "pronouns_database_latency",
|
||||||
Help: "The latency to the database in nanoseconds",
|
Help: "The latency to the database in nanoseconds",
|
||||||
|
@ -51,6 +67,9 @@ func (db *DB) initMetrics() (err error) {
|
||||||
}
|
}
|
||||||
return float64(time.Since(start))
|
return float64(time.Since(start))
|
||||||
}))
|
}))
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "registering database latency gauge")
|
||||||
|
}
|
||||||
|
|
||||||
db.TotalRequests = promauto.NewCounter(prometheus.CounterOpts{
|
db.TotalRequests = promauto.NewCounter(prometheus.CounterOpts{
|
||||||
Name: "pronouns_api_requests_total",
|
Name: "pronouns_api_requests_total",
|
||||||
|
@ -75,3 +94,32 @@ func (db *DB) TotalMemberCount(ctx context.Context) (numMembers int64, err error
|
||||||
}
|
}
|
||||||
return numMembers, nil
|
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
|
DisplayName *string
|
||||||
Bio *string
|
Bio *string
|
||||||
MemberTitle *string
|
MemberTitle *string
|
||||||
|
LastActive time.Time
|
||||||
|
|
||||||
Avatar *string
|
Avatar *string
|
||||||
Links []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)
|
err = tx.Commit(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "committing transaction")
|
return errors.Wrap(err, "committing transaction")
|
||||||
|
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"github.com/rs/xid"
|
"github.com/rs/xid"
|
||||||
|
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/db"
|
"codeberg.org/u1f320/pronouns.cc/backend/db"
|
||||||
|
"codeberg.org/u1f320/pronouns.cc/backend/log"
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/server"
|
"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)
|
render.NoContent(w, r)
|
||||||
return nil
|
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)
|
err = tx.Commit(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("committing transaction: %v", err)
|
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)
|
err = tx.Commit(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("committing transaction: %v", err)
|
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.
|
|
7
scripts/migrate/016_user_activity.sql
Normal file
7
scripts/migrate/016_user_activity.sql
Normal file
|
@ -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 a new issue