mirror of
https://github.com/PrivateCaptcha/PrivateCaptcha.git
synced 2026-05-12 07:50:47 -05:00
Add weekly/monthly usage reports with notification scheduling (#408)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: ribtoks <505555+ribtoks@users.noreply.github.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: CodeRabbit <noreply@coderabbit.ai> Co-authored-by: Taras Kushnir <tk.dev@mailbox.org>
This commit is contained in:
@@ -415,6 +415,12 @@ func run(ctx context.Context, cfg common.ConfigStore, stderr io.Writer, listener
|
||||
PastInterval: 30 * 24 * time.Hour,
|
||||
BusinessDB: businessDB,
|
||||
})
|
||||
jobs.AddLocked(6*time.Hour, &maintenance.ScheduleReportsJob{
|
||||
Store: businessDB,
|
||||
TimeSeries: timeSeriesDB,
|
||||
PlanService: planService,
|
||||
UsersLimit: 50,
|
||||
})
|
||||
jobs.AddLocked(10*time.Minute, asyncTasksJob)
|
||||
|
||||
jobs.RunAll()
|
||||
|
||||
+22
-1
@@ -52,7 +52,7 @@ func homepage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func serveExecute(templateBody string, r *http.Request, w http.ResponseWriter) error {
|
||||
tpl, err := template.New("HtmlBody").Parse(templateBody)
|
||||
tpl, err := template.New("HtmlBody").Funcs(email.Functions()).Parse(templateBody)
|
||||
if err != nil {
|
||||
log.Printf("Failed to parse template: %v", err)
|
||||
return err
|
||||
@@ -65,6 +65,7 @@ func serveExecute(templateBody string, r *http.Request, w http.ResponseWriter) e
|
||||
email.OrgInvitationContext
|
||||
email.APIKeyExpirationContext
|
||||
email.TwoFactorEmailContext
|
||||
email.UsageReportContext
|
||||
// heap of everything else
|
||||
PortalURL string
|
||||
CurrentYear int
|
||||
@@ -93,6 +94,26 @@ func serveExecute(templateBody string, r *http.Request, w http.ResponseWriter) e
|
||||
OS: agent.OS().String(),
|
||||
Location: "EE",
|
||||
},
|
||||
UsageReportContext: email.UsageReportContext{
|
||||
Period: "weekly",
|
||||
PeriodDate: time.Now().Format("02 Jan 2006"),
|
||||
TotalRequests: 1245000,
|
||||
TotalVerifies: 8320,
|
||||
PrevRequests: 11200,
|
||||
PrevVerifies: 9100,
|
||||
RequestsChange: 11.2,
|
||||
VerifiesChange: -8.6,
|
||||
VerificationRateChange: -17.8,
|
||||
DashboardPath: "settings?tab=usage",
|
||||
VerificationRate: 66.8,
|
||||
TopProperties: []*email.PropertyStat{
|
||||
{Name: "Main Site with extremely long name", Domain: "*.example.com", Count: 5200, Percent: 41.8, Change: 8.3},
|
||||
{Name: "Blog", Domain: "blog.example.com", Count: 3100, Percent: 24.9, Change: 6.9, Alternate: true},
|
||||
{Name: "Shop", Domain: "shop.example.com", Count: 2050, Percent: 16.5, Change: -10.9},
|
||||
{Name: "Forum", Domain: "suddomain.app.forum.example.com", Count: 1300, Percent: 10.4, Change: 62.5, Alternate: true},
|
||||
{Name: "Docs", Domain: "docs.example.com", Count: 800, Percent: 6.4, Change: 100.0},
|
||||
},
|
||||
},
|
||||
UserName: "John Doe",
|
||||
CDNURL: "https://cdn.privatecaptcha.com",
|
||||
PortalURL: "https://portal.privatecaptcha.com",
|
||||
|
||||
@@ -62,6 +62,8 @@ const (
|
||||
ParamActionValue = "action_value"
|
||||
ParamTerminal = "terminal"
|
||||
ParamPosition = "position"
|
||||
ParamWeeklyReport = "weekly_report"
|
||||
ParamMonthlyReport = "monthly_report"
|
||||
All = "all"
|
||||
)
|
||||
|
||||
|
||||
@@ -45,4 +45,5 @@ const (
|
||||
AsyncTaskEndpoint = "asynctask"
|
||||
RulesEndpoint = "rules"
|
||||
RuleStatsEndpoint = "rulestats"
|
||||
NotificationsEndpoint = "notifications"
|
||||
)
|
||||
|
||||
@@ -47,6 +47,8 @@ type TimeSeriesStore interface {
|
||||
WriteVerifyLogBatch(ctx context.Context, records []*VerifyRecord) error
|
||||
RetrievePropertyStatsSince(ctx context.Context, r *BackfillRequest, from time.Time) ([]*TimeCount, error)
|
||||
RetrieveAccountStats(ctx context.Context, userID int32, from time.Time) ([]*OrgTimeCount, error)
|
||||
RetrieveWeeklyReportStats(ctx context.Context, userID int32, from, mid, to time.Time) (*UserReportStats, error)
|
||||
RetrieveMonthlyReportStats(ctx context.Context, userID int32, from, mid, to time.Time) (*UserReportStats, error)
|
||||
RetrievePropertyStatsByPeriod(ctx context.Context, orgID, propertyID int32, period TimePeriod) ([]*TimePeriodStat, error)
|
||||
RetrievePropertyRuleStatsByPeriod(ctx context.Context, userID, orgID, propertyID int32, period TimePeriod) ([]*TimeCount, error)
|
||||
RetrieveRecentTopProperties(ctx context.Context, limit int) (map[int32]uint, error)
|
||||
|
||||
@@ -42,3 +42,20 @@ type OrgTimeCount struct {
|
||||
Timestamp time.Time
|
||||
Count uint32
|
||||
}
|
||||
|
||||
type UserReportPropertyStat struct {
|
||||
PropertyID int32
|
||||
OrgID int32
|
||||
CurrentRequests uint64
|
||||
PrevRequests uint64
|
||||
CurrentVerifies uint64
|
||||
PrevVerifies uint64
|
||||
}
|
||||
|
||||
type UserReportStats struct {
|
||||
Properties []*UserReportPropertyStat
|
||||
TotalCurrentRequests uint64
|
||||
TotalPrevRequests uint64
|
||||
TotalCurrentVerifies uint64
|
||||
TotalPrevVerifies uint64
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ package common
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
@@ -368,3 +370,29 @@ func (e RetriableError) Unwrap() error {
|
||||
func SafeString(s string, maxLen int) string {
|
||||
return s[:min(len(s), maxLen)]
|
||||
}
|
||||
|
||||
// formatSuffix formats the number to one decimal place and appends the suffix.
|
||||
func formatSuffix(val float64, suffix string) string {
|
||||
str := fmt.Sprintf("%.1f", val)
|
||||
str = strings.TrimSuffix(str, ".0")
|
||||
return str + suffix
|
||||
}
|
||||
|
||||
// FormatMagnitude converts a number into a string with K, M, B, or T suffixes.
|
||||
func FormatMagnitude(value float64) string {
|
||||
absVal := math.Abs(value)
|
||||
|
||||
switch {
|
||||
case absVal >= 1_000_000_000_000:
|
||||
return formatSuffix(value/1_000_000_000_000, "T")
|
||||
case absVal >= 1_000_000_000:
|
||||
return formatSuffix(value/1_000_000_000, "B")
|
||||
case absVal >= 1_000_000:
|
||||
return formatSuffix(value/1_000_000, "M")
|
||||
case absVal >= 1_000:
|
||||
return formatSuffix(value/1_000, "K")
|
||||
default:
|
||||
// For numbers less than 1000, return the exact number without decimals
|
||||
return fmt.Sprintf("%.0f", value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,3 +392,50 @@ func TestChunkedCleanup(t *testing.T) {
|
||||
t.Errorf("Expected deleter to be called at least twice, got %d calls", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatMagnitude(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input float64
|
||||
expected string
|
||||
}{
|
||||
// Base cases (under 1,000)
|
||||
{"Zero", 0, "0"},
|
||||
{"Positive under thousand", 42, "42"},
|
||||
{"Upper boundary under thousand", 999, "999"},
|
||||
|
||||
// Thousands (K)
|
||||
{"Exactly one thousand", 1_000, "1K"},
|
||||
{"Thousands with decimal", 1_500, "1.5K"},
|
||||
{"Thousands trimming decimal", 2_000, "2K"},
|
||||
{"Upper boundary thousands", 999_999, "1000K"}, // 999.999K rounds up to 1000.0K -> 1000K due to %.1f
|
||||
|
||||
// Millions (M)
|
||||
{"Exactly one million", 1_000_000, "1M"},
|
||||
{"Millions with decimal", 2_540_000, "2.5M"},
|
||||
{"Millions trimming decimal", 5_000_000, "5M"},
|
||||
|
||||
// Billions (B)
|
||||
{"Exactly one billion", 1_000_000_000, "1B"},
|
||||
{"Billions with decimal", 1_900_000_000, "1.9B"},
|
||||
|
||||
// Trillions (T)
|
||||
{"Exactly one trillion", 1_000_000_000_000, "1T"},
|
||||
{"Trillions with decimal", 4_200_000_000_000, "4.2T"},
|
||||
|
||||
// Negative numbers
|
||||
{"Negative under thousand", -42, "-42"},
|
||||
{"Negative thousands", -1_500, "-1.5K"},
|
||||
{"Negative millions", -3_500_000, "-3.5M"},
|
||||
{"Negative billions", -1_900_000_000, "-1.9B"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := FormatMagnitude(tc.input)
|
||||
if got != tc.expected {
|
||||
t.Errorf("FormatMagnitude(%f) = %q; want %q", tc.input, got, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -615,6 +615,12 @@ type AuditLogAccess struct {
|
||||
EntityName string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
type AuditLogUserSettings struct {
|
||||
WeeklyReport bool `json:"weekly_report"`
|
||||
MonthlyReport bool `json:"monthly_report"`
|
||||
NotificationsEmail *string `json:"notifications_email,omitempty"`
|
||||
}
|
||||
|
||||
type AuditLogDifficultyRule struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
PropertyID int32 `json:"property_id,omitempty"`
|
||||
|
||||
@@ -3631,3 +3631,100 @@ func (impl *BusinessStoreImpl) MoveDifficultyRuleWithRebalancing(ctx context.Con
|
||||
|
||||
return updatedRule, auditEvent, nil
|
||||
}
|
||||
|
||||
func (impl *BusinessStoreImpl) RetrieveUserSettings(ctx context.Context, userID int32) (*dbgen.UserSettings, error) {
|
||||
reader := &StoreOneReader[int32, dbgen.UserSettings]{
|
||||
CacheKey: UserSettingsCacheKey(userID),
|
||||
Cache: impl.cache,
|
||||
}
|
||||
|
||||
if impl.querier != nil {
|
||||
reader.QueryKeyFunc = QueryKeyInt
|
||||
reader.QueryFunc = impl.querier.GetUserSettings
|
||||
}
|
||||
|
||||
return reader.Read(ctx)
|
||||
}
|
||||
|
||||
func (impl *BusinessStoreImpl) UpsertUserSettings(ctx context.Context, params *dbgen.UpsertUserSettingsParams) (*dbgen.UserSettings, *common.AuditLogEvent, error) {
|
||||
if impl.querier == nil {
|
||||
return nil, nil, ErrMaintenance
|
||||
}
|
||||
|
||||
settings, err := impl.querier.UpsertUserSettings(ctx, params)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to upsert user settings", "userID", params.UserID, common.ErrAttr(err))
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
_ = impl.cache.Set(ctx, UserSettingsCacheKey(params.UserID), settings)
|
||||
|
||||
slog.DebugContext(ctx, "Upserted user settings", "userID", params.UserID)
|
||||
|
||||
auditEvent := &common.AuditLogEvent{
|
||||
UserID: params.UserID,
|
||||
Action: common.AuditLogActionUpdate,
|
||||
EntityID: int64(settings.ID),
|
||||
TableName: TableNameUserSettings,
|
||||
NewValue: settings,
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
|
||||
return settings, auditEvent, nil
|
||||
}
|
||||
|
||||
func (impl *BusinessStoreImpl) RetrieveUsersWithPendingWeeklyReport(ctx context.Context, limit, lastSeenUserID int32, referencePrefix, referenceSuffix string) ([]*dbgen.GetUsersWithPendingWeeklyReportRow, error) {
|
||||
if limit <= 0 || lastSeenUserID < 0 || len(referencePrefix) == 0 || len(referenceSuffix) == 0 {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
|
||||
if impl.querier == nil {
|
||||
return nil, ErrMaintenance
|
||||
}
|
||||
|
||||
users, err := impl.querier.GetUsersWithPendingWeeklyReport(ctx, &dbgen.GetUsersWithPendingWeeklyReportParams{
|
||||
Limit: limit,
|
||||
UserID: lastSeenUserID,
|
||||
ReferencePrefix: referencePrefix,
|
||||
ReferenceSuffix: referenceSuffix,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return []*dbgen.GetUsersWithPendingWeeklyReportRow{}, nil
|
||||
}
|
||||
slog.ErrorContext(ctx, "Failed to retrieve users with pending weekly report", "limit", limit, "lastSeenUserID", lastSeenUserID, "prefix", referencePrefix, "suffix", referenceSuffix, common.ErrAttr(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
slog.DebugContext(ctx, "Fetched users with pending weekly report", "count", len(users), "limit", limit, "lastSeenUserID", lastSeenUserID, "prefix", referencePrefix, "suffix", referenceSuffix)
|
||||
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (impl *BusinessStoreImpl) RetrieveUsersWithPendingMonthlyReport(ctx context.Context, limit, lastSeenUserID int32, referencePrefix, referenceSuffix string) ([]*dbgen.GetUsersWithPendingMonthlyReportRow, error) {
|
||||
if limit <= 0 || lastSeenUserID < 0 || len(referencePrefix) == 0 || len(referenceSuffix) == 0 {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
|
||||
if impl.querier == nil {
|
||||
return nil, ErrMaintenance
|
||||
}
|
||||
|
||||
users, err := impl.querier.GetUsersWithPendingMonthlyReport(ctx, &dbgen.GetUsersWithPendingMonthlyReportParams{
|
||||
Limit: limit,
|
||||
UserID: lastSeenUserID,
|
||||
ReferencePrefix: referencePrefix,
|
||||
ReferenceSuffix: referenceSuffix,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return []*dbgen.GetUsersWithPendingMonthlyReportRow{}, nil
|
||||
}
|
||||
slog.ErrorContext(ctx, "Failed to retrieve users with pending monthly report", "limit", limit, "lastSeenUserID", lastSeenUserID, "prefix", referencePrefix, "suffix", referenceSuffix, common.ErrAttr(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
slog.DebugContext(ctx, "Fetched users with pending monthly report", "count", len(users), "limit", limit, "lastSeenUserID", lastSeenUserID, "prefix", referencePrefix, "suffix", referenceSuffix)
|
||||
|
||||
return users, nil
|
||||
}
|
||||
|
||||
@@ -225,6 +225,7 @@ const (
|
||||
rawOrgRulesCacheKeyPrefix
|
||||
difficultyRuleCacheKeyPrefix
|
||||
propertyRuleStatsCacheKeyPrefix
|
||||
userSettingsCacheKeyPrefix
|
||||
// Add new fields _above_
|
||||
CACHE_KEY_PREFIXES_COUNT
|
||||
)
|
||||
@@ -270,6 +271,7 @@ func init() {
|
||||
cachePrefixToStrings[rawOrgRulesCacheKeyPrefix] = "rawOrgRules/"
|
||||
cachePrefixToStrings[difficultyRuleCacheKeyPrefix] = "diffRule/"
|
||||
cachePrefixToStrings[propertyRuleStatsCacheKeyPrefix] = "propertyRuleStats/"
|
||||
cachePrefixToStrings[userSettingsCacheKeyPrefix] = "userSettings/"
|
||||
|
||||
for i, v := range cachePrefixToStrings {
|
||||
if len(v) == 0 {
|
||||
@@ -409,3 +411,6 @@ func DifficultyRuleCacheKey(ruleID int32) CacheKey {
|
||||
func propertyRuleStatsCacheKey(propertyID int32, key string) CacheKey {
|
||||
return CacheKey{Prefix: propertyRuleStatsCacheKeyPrefix, IntValue: propertyID, StrValue: key}
|
||||
}
|
||||
func UserSettingsCacheKey(userID int32) CacheKey {
|
||||
return Int32CacheKey(userSettingsCacheKeyPrefix, userID)
|
||||
}
|
||||
|
||||
@@ -9,4 +9,5 @@ const (
|
||||
TableNameAPIKeys = "apikeys"
|
||||
TableNameAuditLogs = "audit_logs"
|
||||
TableNameDifficultyRules = "difficulty_rules"
|
||||
TableNameUserSettings = "user_settings"
|
||||
)
|
||||
|
||||
@@ -604,3 +604,13 @@ type UserNotification struct {
|
||||
ReplyToEmail pgtype.Text `db:"reply_to_email" json:"reply_to_email"`
|
||||
EmailTo pgtype.Text `db:"email_to" json:"email_to"`
|
||||
}
|
||||
|
||||
type UserSettings struct {
|
||||
ID int32 `db:"id" json:"id"`
|
||||
UserID int32 `db:"user_id" json:"user_id"`
|
||||
WeeklyReport bool `db:"weekly_report" json:"weekly_report"`
|
||||
MonthlyReport bool `db:"monthly_report" json:"monthly_report"`
|
||||
NotificationsEmail pgtype.Text `db:"notifications_email" json:"notifications_email"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -78,6 +78,9 @@ type Querier interface {
|
||||
GetUserByID(ctx context.Context, id int32) (*User, error)
|
||||
GetUserOrganizations(ctx context.Context, userID pgtype.Int4) ([]*GetUserOrganizationsRow, error)
|
||||
GetUserPropertiesCount(ctx context.Context, orgOwnerID pgtype.Int4) (int64, error)
|
||||
GetUserSettings(ctx context.Context, userID int32) (*UserSettings, error)
|
||||
GetUsersWithPendingMonthlyReport(ctx context.Context, arg *GetUsersWithPendingMonthlyReportParams) ([]*GetUsersWithPendingMonthlyReportRow, error)
|
||||
GetUsersWithPendingWeeklyReport(ctx context.Context, arg *GetUsersWithPendingWeeklyReportParams) ([]*GetUsersWithPendingWeeklyReportRow, error)
|
||||
GetUsersWithoutSubscription(ctx context.Context, dollar_1 []int32) ([]*User, error)
|
||||
InsertLock(ctx context.Context, arg *InsertLockParams) (*Lock, error)
|
||||
InviteEmailToOrg(ctx context.Context, arg *InviteEmailToOrgParams) (*OrganizationUser, error)
|
||||
@@ -111,6 +114,7 @@ type Querier interface {
|
||||
UpdateProperty(ctx context.Context, arg *UpdatePropertyParams) (*UpdatePropertyRow, error)
|
||||
UpdateUserData(ctx context.Context, arg *UpdateUserDataParams) (*User, error)
|
||||
UpdateUserSubscription(ctx context.Context, arg *UpdateUserSubscriptionParams) (*User, error)
|
||||
UpsertUserSettings(ctx context.Context, arg *UpsertUserSettingsParams) (*UserSettings, error)
|
||||
}
|
||||
|
||||
var _ Querier = (*Queries)(nil)
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: user_settings.sql
|
||||
|
||||
package generated
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const getUserSettings = `-- name: GetUserSettings :one
|
||||
SELECT id, user_id, weekly_report, monthly_report, notifications_email, created_at, updated_at FROM backend.user_settings WHERE user_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserSettings(ctx context.Context, userID int32) (*UserSettings, error) {
|
||||
row := q.db.QueryRow(ctx, getUserSettings, userID)
|
||||
var i UserSettings
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.WeeklyReport,
|
||||
&i.MonthlyReport,
|
||||
&i.NotificationsEmail,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return &i, err
|
||||
}
|
||||
|
||||
const getUsersWithPendingMonthlyReport = `-- name: GetUsersWithPendingMonthlyReport :many
|
||||
SELECT us.user_id, us.notifications_email, u.email, COALESCE(s.status, '') as subscription_status
|
||||
FROM backend.user_settings us
|
||||
JOIN backend.users u ON us.user_id = u.id
|
||||
LEFT JOIN backend.subscriptions s ON u.subscription_id = s.id
|
||||
WHERE us.monthly_report = TRUE AND u.deleted_at IS NULL AND u.subscription_id IS NOT NULL
|
||||
AND us.user_id > $2
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM backend.user_notifications un
|
||||
WHERE un.user_id = us.user_id
|
||||
AND un.reference_id = $3::TEXT || us.user_id::TEXT || $4::TEXT
|
||||
AND un.processed_at IS NULL
|
||||
)
|
||||
ORDER BY us.user_id
|
||||
LIMIT $1
|
||||
`
|
||||
|
||||
type GetUsersWithPendingMonthlyReportParams struct {
|
||||
Limit int32 `db:"limit" json:"limit"`
|
||||
UserID int32 `db:"user_id" json:"user_id"`
|
||||
ReferencePrefix string `db:"reference_prefix" json:"reference_prefix"`
|
||||
ReferenceSuffix string `db:"reference_suffix" json:"reference_suffix"`
|
||||
}
|
||||
|
||||
type GetUsersWithPendingMonthlyReportRow struct {
|
||||
UserID int32 `db:"user_id" json:"user_id"`
|
||||
NotificationsEmail pgtype.Text `db:"notifications_email" json:"notifications_email"`
|
||||
Email string `db:"email" json:"email"`
|
||||
SubscriptionStatus string `db:"subscription_status" json:"subscription_status"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetUsersWithPendingMonthlyReport(ctx context.Context, arg *GetUsersWithPendingMonthlyReportParams) ([]*GetUsersWithPendingMonthlyReportRow, error) {
|
||||
rows, err := q.db.Query(ctx, getUsersWithPendingMonthlyReport,
|
||||
arg.Limit,
|
||||
arg.UserID,
|
||||
arg.ReferencePrefix,
|
||||
arg.ReferenceSuffix,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []*GetUsersWithPendingMonthlyReportRow
|
||||
for rows.Next() {
|
||||
var i GetUsersWithPendingMonthlyReportRow
|
||||
if err := rows.Scan(
|
||||
&i.UserID,
|
||||
&i.NotificationsEmail,
|
||||
&i.Email,
|
||||
&i.SubscriptionStatus,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, &i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getUsersWithPendingWeeklyReport = `-- name: GetUsersWithPendingWeeklyReport :many
|
||||
SELECT us.user_id, us.notifications_email, u.email, COALESCE(s.status, '') as subscription_status
|
||||
FROM backend.user_settings us
|
||||
JOIN backend.users u ON us.user_id = u.id
|
||||
LEFT JOIN backend.subscriptions s ON u.subscription_id = s.id
|
||||
WHERE us.weekly_report = TRUE AND u.deleted_at IS NULL AND u.subscription_id IS NOT NULL
|
||||
AND us.user_id > $2
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM backend.user_notifications un
|
||||
WHERE un.user_id = us.user_id
|
||||
AND un.reference_id = $3::TEXT || us.user_id::TEXT || $4::TEXT
|
||||
AND un.processed_at IS NULL
|
||||
)
|
||||
ORDER BY us.user_id
|
||||
LIMIT $1
|
||||
`
|
||||
|
||||
type GetUsersWithPendingWeeklyReportParams struct {
|
||||
Limit int32 `db:"limit" json:"limit"`
|
||||
UserID int32 `db:"user_id" json:"user_id"`
|
||||
ReferencePrefix string `db:"reference_prefix" json:"reference_prefix"`
|
||||
ReferenceSuffix string `db:"reference_suffix" json:"reference_suffix"`
|
||||
}
|
||||
|
||||
type GetUsersWithPendingWeeklyReportRow struct {
|
||||
UserID int32 `db:"user_id" json:"user_id"`
|
||||
NotificationsEmail pgtype.Text `db:"notifications_email" json:"notifications_email"`
|
||||
Email string `db:"email" json:"email"`
|
||||
SubscriptionStatus string `db:"subscription_status" json:"subscription_status"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetUsersWithPendingWeeklyReport(ctx context.Context, arg *GetUsersWithPendingWeeklyReportParams) ([]*GetUsersWithPendingWeeklyReportRow, error) {
|
||||
rows, err := q.db.Query(ctx, getUsersWithPendingWeeklyReport,
|
||||
arg.Limit,
|
||||
arg.UserID,
|
||||
arg.ReferencePrefix,
|
||||
arg.ReferenceSuffix,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []*GetUsersWithPendingWeeklyReportRow
|
||||
for rows.Next() {
|
||||
var i GetUsersWithPendingWeeklyReportRow
|
||||
if err := rows.Scan(
|
||||
&i.UserID,
|
||||
&i.NotificationsEmail,
|
||||
&i.Email,
|
||||
&i.SubscriptionStatus,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, &i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const upsertUserSettings = `-- name: UpsertUserSettings :one
|
||||
INSERT INTO backend.user_settings (user_id, weekly_report, monthly_report, notifications_email)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
weekly_report = EXCLUDED.weekly_report,
|
||||
monthly_report = EXCLUDED.monthly_report,
|
||||
notifications_email = EXCLUDED.notifications_email,
|
||||
updated_at = NOW()
|
||||
RETURNING id, user_id, weekly_report, monthly_report, notifications_email, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpsertUserSettingsParams struct {
|
||||
UserID int32 `db:"user_id" json:"user_id"`
|
||||
WeeklyReport bool `db:"weekly_report" json:"weekly_report"`
|
||||
MonthlyReport bool `db:"monthly_report" json:"monthly_report"`
|
||||
NotificationsEmail pgtype.Text `db:"notifications_email" json:"notifications_email"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertUserSettings(ctx context.Context, arg *UpsertUserSettingsParams) (*UserSettings, error) {
|
||||
row := q.db.QueryRow(ctx, upsertUserSettings,
|
||||
arg.UserID,
|
||||
arg.WeeklyReport,
|
||||
arg.MonthlyReport,
|
||||
arg.NotificationsEmail,
|
||||
)
|
||||
var i UserSettings
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.WeeklyReport,
|
||||
&i.MonthlyReport,
|
||||
&i.NotificationsEmail,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return &i, err
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS backend.user_settings;
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE IF NOT EXISTS backend.user_settings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INT NOT NULL REFERENCES backend.users(id) ON DELETE CASCADE,
|
||||
weekly_report BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
monthly_report BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
notifications_email TEXT DEFAULT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(user_id)
|
||||
);
|
||||
@@ -0,0 +1,44 @@
|
||||
-- name: GetUserSettings :one
|
||||
SELECT * FROM backend.user_settings WHERE user_id = $1;
|
||||
|
||||
-- name: UpsertUserSettings :one
|
||||
INSERT INTO backend.user_settings (user_id, weekly_report, monthly_report, notifications_email)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
weekly_report = EXCLUDED.weekly_report,
|
||||
monthly_report = EXCLUDED.monthly_report,
|
||||
notifications_email = EXCLUDED.notifications_email,
|
||||
updated_at = NOW()
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetUsersWithPendingWeeklyReport :many
|
||||
SELECT us.user_id, us.notifications_email, u.email, COALESCE(s.status, '') as subscription_status
|
||||
FROM backend.user_settings us
|
||||
JOIN backend.users u ON us.user_id = u.id
|
||||
LEFT JOIN backend.subscriptions s ON u.subscription_id = s.id
|
||||
WHERE us.weekly_report = TRUE AND u.deleted_at IS NULL AND u.subscription_id IS NOT NULL
|
||||
AND us.user_id > $2
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM backend.user_notifications un
|
||||
WHERE un.user_id = us.user_id
|
||||
AND un.reference_id = sqlc.arg(reference_prefix)::TEXT || us.user_id::TEXT || sqlc.arg(reference_suffix)::TEXT
|
||||
AND un.processed_at IS NULL
|
||||
)
|
||||
ORDER BY us.user_id
|
||||
LIMIT $1;
|
||||
|
||||
-- name: GetUsersWithPendingMonthlyReport :many
|
||||
SELECT us.user_id, us.notifications_email, u.email, COALESCE(s.status, '') as subscription_status
|
||||
FROM backend.user_settings us
|
||||
JOIN backend.users u ON us.user_id = u.id
|
||||
LEFT JOIN backend.subscriptions s ON u.subscription_id = s.id
|
||||
WHERE us.monthly_report = TRUE AND u.deleted_at IS NULL AND u.subscription_id IS NOT NULL
|
||||
AND us.user_id > $2
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM backend.user_notifications un
|
||||
WHERE un.user_id = us.user_id
|
||||
AND un.reference_id = sqlc.arg(reference_prefix)::TEXT || us.user_id::TEXT || sqlc.arg(reference_suffix)::TEXT
|
||||
AND un.processed_at IS NULL
|
||||
)
|
||||
ORDER BY us.user_id
|
||||
LIMIT $1;
|
||||
+2
-1
@@ -72,6 +72,7 @@ sql:
|
||||
backend_rule_condition_property_country_code: RuleConditionPropertyCountryCode
|
||||
backend_rule_condition_property_domain: RuleConditionPropertyDomain
|
||||
backend_rule_condition_property_http_header_name: RuleConditionPropertyHTTPHeaderName
|
||||
backend_rule_condition_property_always: RuleConditionPropertyAlways
|
||||
backend_rule_condition_operator: RuleConditionOperator
|
||||
backend_rule_condition_operator_equals: RuleConditionOperatorEquals
|
||||
backend_rule_condition_operator_contains: RuleConditionOperatorContains
|
||||
@@ -79,12 +80,12 @@ sql:
|
||||
backend_rule_condition_operator_empty: RuleConditionOperatorEmpty
|
||||
backend_rule_condition_operator_in: RuleConditionOperatorIn
|
||||
backend_rule_condition_operator_bot: RuleConditionOperatorBot
|
||||
backend_rule_condition_property_always: RuleConditionPropertyAlways
|
||||
backend_rule_action_property: RuleActionProperty
|
||||
backend_rule_action_property_difficulty_level_percent: RuleActionPropertyDifficultyLevelPercent
|
||||
backend_rule_action_property_http_request: RuleActionPropertyHTTPRequest
|
||||
backend_rule_action_property_difficulty_growth: RuleActionPropertyDifficultyGrowth
|
||||
backend_rule_action_property_break: RuleActionPropertyBreak
|
||||
backend_user_setting: UserSettings
|
||||
overrides:
|
||||
- db_type: "pg_catalog.interval"
|
||||
go_type: "time.Duration"
|
||||
|
||||
@@ -313,6 +313,95 @@ ORDER BY org_id, ts`
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (ts *TimeSeriesDB) retrieveReportStats(ctx context.Context, userID int32, from, mid, to time.Time, accessTable, verifyTable string) (*common.UserReportStats, error) {
|
||||
fromStr := from.Format(time.DateTime)
|
||||
midStr := mid.Format(time.DateTime)
|
||||
toStr := to.Format(time.DateTime)
|
||||
userIDStr := strconv.Itoa(int(userID))
|
||||
|
||||
query := `SELECT property_id, org_id,
|
||||
sumIf(req_count, timestamp >= {mid_ts:DateTime}) as current_requests,
|
||||
sumIf(req_count, timestamp < {mid_ts:DateTime}) as prev_requests,
|
||||
sumIf(ver_count, timestamp >= {mid_ts:DateTime}) as current_verifies,
|
||||
sumIf(ver_count, timestamp < {mid_ts:DateTime}) as prev_verifies
|
||||
FROM (
|
||||
SELECT property_id, org_id, sum(count) as req_count, 0 as ver_count, toStartOfDay(timestamp) as timestamp
|
||||
FROM %s FINAL
|
||||
WHERE user_id = {user_id:UInt32} AND timestamp >= {from_ts:DateTime} AND timestamp < {to_ts:DateTime}
|
||||
GROUP BY property_id, org_id, timestamp
|
||||
UNION ALL
|
||||
SELECT property_id, org_id, 0 as req_count, sum(success_count + failure_count) as ver_count, toStartOfDay(timestamp) as timestamp
|
||||
FROM %s FINAL
|
||||
WHERE user_id = {user_id:UInt32} AND timestamp >= {from_ts:DateTime} AND timestamp < {to_ts:DateTime}
|
||||
GROUP BY property_id, org_id, timestamp
|
||||
)
|
||||
GROUP BY property_id, org_id
|
||||
ORDER BY current_requests DESC`
|
||||
|
||||
rows, err := ts.Clickhouse.Query(fmt.Sprintf(query, accessTable, verifyTable),
|
||||
clickhouse.Named("user_id", userIDStr),
|
||||
clickhouse.Named("from_ts", fromStr),
|
||||
clickhouse.Named("mid_ts", midStr),
|
||||
clickhouse.Named("to_ts", toStr))
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to execute report stats query", "userID", userID, "accessTable", accessTable, common.ErrAttr(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer rows.Close()
|
||||
|
||||
stats := &common.UserReportStats{}
|
||||
|
||||
for rows.Next() {
|
||||
ps := &common.UserReportPropertyStat{}
|
||||
if err := rows.Scan(&ps.PropertyID, &ps.OrgID, &ps.CurrentRequests, &ps.PrevRequests, &ps.CurrentVerifies, &ps.PrevVerifies); err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to read row from report stats query", common.ErrAttr(err))
|
||||
return nil, err
|
||||
}
|
||||
stats.Properties = append(stats.Properties, ps)
|
||||
stats.TotalCurrentRequests += ps.CurrentRequests
|
||||
stats.TotalPrevRequests += ps.PrevRequests
|
||||
stats.TotalCurrentVerifies += ps.CurrentVerifies
|
||||
stats.TotalPrevVerifies += ps.PrevVerifies
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (ts *TimeSeriesDB) RetrieveWeeklyReportStats(ctx context.Context, userID int32, from, mid, to time.Time) (*common.UserReportStats, error) {
|
||||
if !ts.IsAvailable() {
|
||||
return nil, ErrMaintenance
|
||||
}
|
||||
|
||||
stats, err := ts.retrieveReportStats(ctx, userID, from, mid, to, AccessLogTableName1d, VerifyLogTable1d)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "Fetched weekly report stats", "userID", userID, "properties", len(stats.Properties),
|
||||
"currentReq", stats.TotalCurrentRequests, "prevReq", stats.TotalPrevRequests,
|
||||
"currentVer", stats.TotalCurrentVerifies, "prevVer", stats.TotalPrevVerifies)
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (ts *TimeSeriesDB) RetrieveMonthlyReportStats(ctx context.Context, userID int32, from, mid, to time.Time) (*common.UserReportStats, error) {
|
||||
if !ts.IsAvailable() {
|
||||
return nil, ErrMaintenance
|
||||
}
|
||||
|
||||
stats, err := ts.retrieveReportStats(ctx, userID, from, mid, to, AccessLogTableName1d, VerifyLogTable1d)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "Fetched monthly report stats", "userID", userID, "properties", len(stats.Properties),
|
||||
"currentReq", stats.TotalCurrentRequests, "prevReq", stats.TotalPrevRequests,
|
||||
"currentVer", stats.TotalCurrentVerifies, "prevVer", stats.TotalPrevVerifies)
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (ts *TimeSeriesDB) RetrievePropertyStatsByPeriod(ctx context.Context, orgID, propertyID int32, period common.TimePeriod) ([]*common.TimePeriodStat, error) {
|
||||
if !ts.IsAvailable() {
|
||||
return nil, ErrMaintenance
|
||||
@@ -729,6 +818,84 @@ func (m *MemoryTimeSeries) RetrieveAccountStats(ctx context.Context, userID int3
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (m *MemoryTimeSeries) memoryReportStats(userID int32, from, mid, to time.Time) *common.UserReportStats {
|
||||
type propKey struct {
|
||||
PropertyID int32
|
||||
OrgID int32
|
||||
}
|
||||
|
||||
type propCounts struct {
|
||||
CurrentRequests uint64
|
||||
PrevRequests uint64
|
||||
CurrentVerifies uint64
|
||||
PrevVerifies uint64
|
||||
}
|
||||
|
||||
counts := make(map[propKey]*propCounts)
|
||||
|
||||
for _, log := range m.accessLogs {
|
||||
if log.UserID == userID && !log.Timestamp.Before(from) && log.Timestamp.Before(to) {
|
||||
key := propKey{PropertyID: log.PropertyID, OrgID: log.OrgID}
|
||||
if counts[key] == nil {
|
||||
counts[key] = &propCounts{}
|
||||
}
|
||||
if !log.Timestamp.Before(mid) {
|
||||
counts[key].CurrentRequests++
|
||||
} else {
|
||||
counts[key].PrevRequests++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, log := range m.verifyLogs {
|
||||
if log.UserID == userID && !log.Timestamp.Before(from) && log.Timestamp.Before(to) {
|
||||
key := propKey{PropertyID: log.PropertyID, OrgID: log.OrgID}
|
||||
if counts[key] == nil {
|
||||
counts[key] = &propCounts{}
|
||||
}
|
||||
if !log.Timestamp.Before(mid) {
|
||||
counts[key].CurrentVerifies++
|
||||
} else {
|
||||
counts[key].PrevVerifies++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stats := &common.UserReportStats{}
|
||||
for key, c := range counts {
|
||||
stats.Properties = append(stats.Properties, &common.UserReportPropertyStat{
|
||||
PropertyID: key.PropertyID,
|
||||
OrgID: key.OrgID,
|
||||
CurrentRequests: c.CurrentRequests,
|
||||
PrevRequests: c.PrevRequests,
|
||||
CurrentVerifies: c.CurrentVerifies,
|
||||
PrevVerifies: c.PrevVerifies,
|
||||
})
|
||||
stats.TotalCurrentRequests += c.CurrentRequests
|
||||
stats.TotalPrevRequests += c.PrevRequests
|
||||
stats.TotalCurrentVerifies += c.CurrentVerifies
|
||||
stats.TotalPrevVerifies += c.PrevVerifies
|
||||
}
|
||||
|
||||
sort.Slice(stats.Properties, func(i, j int) bool {
|
||||
return stats.Properties[i].CurrentRequests > stats.Properties[j].CurrentRequests
|
||||
})
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
func (m *MemoryTimeSeries) RetrieveWeeklyReportStats(ctx context.Context, userID int32, from, mid, to time.Time) (*common.UserReportStats, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.memoryReportStats(userID, from, mid, to), nil
|
||||
}
|
||||
|
||||
func (m *MemoryTimeSeries) RetrieveMonthlyReportStats(ctx context.Context, userID int32, from, mid, to time.Time) (*common.UserReportStats, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.memoryReportStats(userID, from, mid, to), nil
|
||||
}
|
||||
|
||||
func (m *MemoryTimeSeries) RetrievePropertyStatsByPeriod(ctx context.Context, orgID, propertyID int32, period common.TimePeriod) ([]*common.TimePeriodStat, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package email
|
||||
|
||||
import "github.com/PrivateCaptcha/PrivateCaptcha/pkg/common"
|
||||
|
||||
type PropertyStat struct {
|
||||
Name string
|
||||
Domain string
|
||||
Count uint64
|
||||
Percent float64
|
||||
Change float64
|
||||
Alternate bool
|
||||
}
|
||||
|
||||
type UsageReportContext struct {
|
||||
Period string
|
||||
PeriodDate string
|
||||
TotalRequests uint64
|
||||
TotalVerifies uint64
|
||||
PrevRequests uint64
|
||||
PrevVerifies uint64
|
||||
RequestsChange float64
|
||||
VerifiesChange float64
|
||||
VerificationRateChange float64
|
||||
DashboardPath string
|
||||
VerificationRate float64
|
||||
TopProperties []*PropertyStat
|
||||
}
|
||||
|
||||
var (
|
||||
UsageReportTemplate = common.NewEmailTemplate("usage-report", usageReportHTMLTemplate, usageReportTextTemplate)
|
||||
)
|
||||
|
||||
const (
|
||||
usageReportHTMLTemplate = `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<link rel="preload" as="image" href="{{.CDNURL}}/portal/img/pc-logo-dark.png" />
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body
|
||||
style='background-color:#ffffff;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif'
|
||||
>
|
||||
<table
|
||||
align="center"
|
||||
width="100%"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="max-width:37.5em;margin:0 auto;padding:20px 0 48px"
|
||||
>
|
||||
<tbody>
|
||||
<tr style="width:100%">
|
||||
<td>
|
||||
<img alt="Private Captcha" height="40" src="{{.CDNURL}}/portal/img/pc-logo-dark.png" style="display:block;outline:none;border:none;text-decoration:none" />
|
||||
<p style="font-size:16px;line-height:32px;margin:24px 0 16px">
|
||||
Hello,
|
||||
</p>
|
||||
<p style="font-size:16px;line-height:26px;margin:16px 0">
|
||||
Here is your {{.Period}}{{if .PeriodDate}} ({{.PeriodDate}}){{end}} Private Captcha usage report:
|
||||
</p>
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="margin:16px 0 24px;border-collapse:collapse">
|
||||
<tr>
|
||||
<td style="padding:10px 20px;border:1px solid #dddddd;font-size:14px;color:#000000;text-align:left">Total Requests</td>
|
||||
<td style="padding:10px 20px;border:1px solid #dddddd;font-size:14px;text-align:right"><span style='font-family:Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace'>{{.TotalRequests | humanize}}</span></td>
|
||||
<td style="padding:10px 20px;border:1px solid #dddddd;font-size:13px;text-align:right;{{if gt .RequestsChange 0.0}}color:#22883e{{else if lt .RequestsChange 0.0}}color:#c53030{{else}}color:#888888{{end}}"><span style='font-family:Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace'>{{if gt .RequestsChange 0.0}}+{{end}}{{printf "%.1f" .RequestsChange}}%</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 20px;border:1px solid #dddddd;font-size:14px;color:#000000;text-align:left">Total Verifications</td>
|
||||
<td style="padding:10px 20px;border:1px solid #dddddd;font-size:14px;text-align:right"><span style='font-family:Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace'>{{.TotalVerifies | humanize}}</span></td>
|
||||
<td style="padding:10px 20px;border:1px solid #dddddd;font-size:13px;text-align:right;{{if gt .VerifiesChange 0.0}}color:#22883e{{else if lt .VerifiesChange 0.0}}color:#c53030{{else}}color:#888888{{end}}"><span style='font-family:Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace'>{{if gt .VerifiesChange 0.0}}+{{end}}{{printf "%.1f" .VerifiesChange}}%</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 20px;border:1px solid #dddddd;font-size:14px;color:#000000;text-align:left">Verification Rate</td>
|
||||
<td style="padding:10px 20px;border:1px solid #dddddd;font-size:14px;text-align:right"><span style='font-family:Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace'>{{printf "%.1f" .VerificationRate}}%</span></td>
|
||||
<td style="padding:10px 20px;border:1px solid #dddddd;font-size:13px;text-align:right;{{if gt .VerificationRateChange 0.0}}color:#22883e{{else if lt .VerificationRateChange 0.0}}color:#c53030{{else}}color:#888888{{end}}"><span style='font-family:Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace'>{{if gt .VerificationRateChange 0.0}}+{{end}}{{printf "%.1f" .VerificationRateChange}}%</span></td>
|
||||
</tr>
|
||||
</table>
|
||||
{{- if .TopProperties}}
|
||||
<p style="font-size:16px;line-height:26px;margin:16px 0">Top {{len .TopProperties}} properties by requests:</p>
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="margin:16px 0 24px;border-collapse:collapse;width:100%">
|
||||
<tr>
|
||||
<td style="padding:12px 16px;border:1px solid #dddddd;font-size:14px;color:#000000;font-weight:bold;text-align:left;">Property</td>
|
||||
<td style="padding:12px 16px;border:1px solid #dddddd;font-size:14px;color:#000000;font-weight:bold;text-align:left">Domain</td>
|
||||
<td style="padding:12px 16px;border:1px solid #dddddd;font-size:14px;color:#000000;font-weight:bold;text-align:right">Requests</td>
|
||||
<td style="padding:12px 16px;border:1px solid #dddddd;font-size:14px;color:#000000;font-weight:bold;text-align:right">%</td>
|
||||
<td style="padding:12px 16px;border:1px solid #dddddd;font-size:14px;color:#000000;font-weight:bold;text-align:right">Change</td>
|
||||
</tr>
|
||||
{{- range .TopProperties}}
|
||||
<tr{{if .Alternate}} style="background-color:#f9f9f9"{{end}}>
|
||||
<td style="padding:12px 16px;border:1px solid #dddddd;font-size:14px;text-align:left" title="{{.Name}}">{{truncate .Name 24}}</td>
|
||||
<td style="padding:12px 16px;border:1px solid #dddddd;font-size:14px;text-align:left" title="{{.Domain}}">{{truncate .Domain 24}}</td>
|
||||
<td style="padding:12px 16px;border:1px solid #dddddd;font-size:14px;text-align:right"><span style='font-family:Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace'>{{.Count | humanize}}</span></td>
|
||||
<td style="padding:12px 16px;border:1px solid #dddddd;font-size:14px;text-align:right"><span style='font-family:Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace'>{{printf "%.1f" .Percent}}%</span></td>
|
||||
<td style="padding:12px 16px;border:1px solid #dddddd;font-size:14px;text-align:right;{{if gt .Change 0.0}}color:#22883e{{else if lt .Change 0.0}}color:#c53030{{else}}color:#888888{{end}}"><span style='font-family:Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace'>{{if gt .Change 0.0}}+{{end}}{{printf "%.1f" .Change}}%</span></td>
|
||||
</tr>
|
||||
{{- end}}
|
||||
</table>
|
||||
{{- end}}
|
||||
<p style="font-size:16px;line-height:26px;margin:16px 0">View detailed statistics in your <a href="{{.PortalURL}}/{{.DashboardPath}}">dashboard</a>.</p>
|
||||
<p style="font-size:16px;line-height:26px;margin:16px 0">
|
||||
Warmly,<br />The Private Captcha team
|
||||
</p>
|
||||
<hr style="width:100%;border:none;border-top:1px solid #eaeaea;border-color:#cccccc;margin:20px 0" />
|
||||
<p style="font-size:12px;line-height:24px;margin:16px 0;color:#9ca299">
|
||||
You can manage your report preferences in the <a href="{{.PortalURL}}/settings?tab=notifications" style="text-decoration:underline;color:#9ca299;">portal</a>.
|
||||
</p>
|
||||
<p style="font-size:14px;line-height:24px;margin:16px 0;color:#9ca299;margin-bottom:10px">
|
||||
<a href="https://privatecaptcha.com" style="text-decoration:underline;color:#9ca299;">PrivateCaptcha</a> © {{.CurrentYear}} Intmaker OÜ
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>`
|
||||
usageReportTextTemplate = `Hello,
|
||||
|
||||
Here is your {{.Period}} Private Captcha usage report:
|
||||
|
||||
Total Requests: {{.TotalRequests}} ({{if gt .RequestsChange 0.0}}+{{end}}{{printf "%.1f" .RequestsChange}}%)
|
||||
Total Verifications: {{.TotalVerifies}} ({{if gt .VerifiesChange 0.0}}+{{end}}{{printf "%.1f" .VerifiesChange}}%)
|
||||
Verification Rate: {{printf "%.1f" .VerificationRate}}% ({{if gt .VerificationRateChange 0.0}}+{{end}}{{printf "%.1f" .VerificationRateChange}}%)
|
||||
{{- if .TopProperties}}
|
||||
|
||||
Top {{len .TopProperties}} properties by requests:
|
||||
{{- range .TopProperties}}
|
||||
- {{.Name}} ({{.Domain}}): {{.Count}} requests ({{printf "%.1f" .Percent}}%, {{if gt .Change 0.0}}+{{end}}{{printf "%.1f" .Change}}%)
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
View detailed statistics in your dashboard ({{.PortalURL}}/{{.DashboardPath}}).
|
||||
|
||||
Warmly,
|
||||
The Private Captcha team
|
||||
|
||||
--
|
||||
|
||||
You can manage your report preferences in notification settings ({{.PortalURL}}/settings?tab=notifications).
|
||||
|
||||
PrivateCaptcha (c) {{.CurrentYear}} Intmaker OÜ`
|
||||
)
|
||||
@@ -1,6 +1,9 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
|
||||
"github.com/PrivateCaptcha/PrivateCaptcha/pkg/common"
|
||||
)
|
||||
|
||||
@@ -11,9 +14,63 @@ var (
|
||||
WelcomeEmailTemplate,
|
||||
TwoFactorEmailTemplate,
|
||||
OrgInvitationTemplate,
|
||||
UsageReportTemplate,
|
||||
}
|
||||
emailFuncs = template.FuncMap{
|
||||
"truncate": func(s string, n int) string {
|
||||
if n <= 3 {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return "..."
|
||||
}
|
||||
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
|
||||
return s[:n-3] + "..."
|
||||
},
|
||||
"humanize": func(input any) string {
|
||||
var v float64
|
||||
|
||||
switch t := input.(type) {
|
||||
case int:
|
||||
v = float64(t)
|
||||
case int8:
|
||||
v = float64(t)
|
||||
case uint8:
|
||||
v = float64(t)
|
||||
case int16:
|
||||
v = float64(t)
|
||||
case uint16:
|
||||
v = float64(t)
|
||||
case int32:
|
||||
v = float64(t)
|
||||
case uint32:
|
||||
v = float64(t)
|
||||
case int64:
|
||||
v = float64(t)
|
||||
case uint64:
|
||||
v = float64(t)
|
||||
case float32:
|
||||
v = float64(t)
|
||||
case float64:
|
||||
v = t
|
||||
default:
|
||||
// If it's not a number, return a string representation or empty
|
||||
return fmt.Sprintf("%v", input)
|
||||
}
|
||||
|
||||
return common.FormatMagnitude(v)
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func Templates() []*common.EmailTemplate {
|
||||
return templates
|
||||
}
|
||||
|
||||
func Functions() template.FuncMap {
|
||||
return emailFuncs
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ func TestEmailTemplates(t *testing.T) {
|
||||
OrgInvitationContext
|
||||
APIKeyExpirationContext
|
||||
TwoFactorEmailContext
|
||||
UsageReportContext
|
||||
// heap of everything else
|
||||
PortalURL string
|
||||
CurrentYear int
|
||||
@@ -42,6 +43,23 @@ func TestEmailTemplates(t *testing.T) {
|
||||
OS: "Ubuntu",
|
||||
Location: "EE",
|
||||
},
|
||||
UsageReportContext: UsageReportContext{
|
||||
Period: "weekly",
|
||||
PeriodDate: time.Now().Format("02 Jan 2006"),
|
||||
TotalRequests: 1234,
|
||||
TotalVerifies: 567,
|
||||
PrevRequests: 1100,
|
||||
PrevVerifies: 500,
|
||||
RequestsChange: 12.2,
|
||||
VerifiesChange: 13.4,
|
||||
VerificationRateChange: 1.0,
|
||||
DashboardPath: "settings?tab=usage",
|
||||
VerificationRate: 45.9,
|
||||
TopProperties: []*PropertyStat{
|
||||
{Name: "Main Site", Domain: "example.com", Count: 800, Percent: 64.8, Change: 14.3},
|
||||
{Name: "Blog", Domain: "blog.example.com", Count: 434, Percent: 35.2, Change: 8.5, Alternate: true},
|
||||
},
|
||||
},
|
||||
UserName: "John Doe",
|
||||
CDNURL: "https://cdn.privatecaptcha.com",
|
||||
PortalURL: "https://portal.privatecaptcha.com",
|
||||
|
||||
@@ -150,7 +150,7 @@ func (j *UserEmailNotificationsJob) retrieveTemplate(ctx context.Context,
|
||||
nt := &preparedNotificationTemplate{name: name}
|
||||
|
||||
if len(contentHTML) > 0 {
|
||||
if tplHTML, err := htmltpl.New("NotificationHTML").Parse(contentHTML); err != nil {
|
||||
if tplHTML, err := htmltpl.New("NotificationHTML").Funcs(email.Functions()).Parse(contentHTML); err != nil {
|
||||
hlog.ErrorContext(ctx, "Failed to parse HTML template", "name", name, common.ErrAttr(err))
|
||||
return nil, err
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
package maintenance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/PrivateCaptcha/PrivateCaptcha/pkg/billing"
|
||||
"github.com/PrivateCaptcha/PrivateCaptcha/pkg/common"
|
||||
"github.com/PrivateCaptcha/PrivateCaptcha/pkg/db"
|
||||
dbgen "github.com/PrivateCaptcha/PrivateCaptcha/pkg/db/generated"
|
||||
"github.com/PrivateCaptcha/PrivateCaptcha/pkg/email"
|
||||
"github.com/jpillora/backoff"
|
||||
)
|
||||
|
||||
const (
|
||||
maxPaginationIterations = 100
|
||||
topPropertiesLimit = 5
|
||||
floatEpsilon = 1e-4
|
||||
|
||||
WeeklyReferencePrefix = "report/weekly/"
|
||||
MonthlyReferencePrefix = "report/monthly/"
|
||||
)
|
||||
|
||||
type ScheduleReportsJob struct {
|
||||
Store db.Implementor
|
||||
TimeSeries common.TimeSeriesStore
|
||||
PlanService billing.PlanService
|
||||
UsersLimit int32
|
||||
}
|
||||
|
||||
type ScheduleReportsParams struct {
|
||||
UsersLimit int32 `json:"users_limit,omitempty"`
|
||||
UserID int32 `json:"user_id,omitempty"`
|
||||
UserEmail string `json:"user_email,omitempty"`
|
||||
Weekly bool `json:"weekly,omitempty"`
|
||||
Monthly bool `json:"monthly,omitempty"`
|
||||
}
|
||||
|
||||
var _ common.PeriodicJob = (*ScheduleReportsJob)(nil)
|
||||
|
||||
func (j *ScheduleReportsJob) Name() string {
|
||||
return "schedule_reports_job"
|
||||
}
|
||||
|
||||
func (j *ScheduleReportsJob) Interval() time.Duration {
|
||||
return 1 * time.Hour
|
||||
}
|
||||
|
||||
func (j *ScheduleReportsJob) Timeout() time.Duration {
|
||||
return 5 * time.Minute
|
||||
}
|
||||
|
||||
func (j *ScheduleReportsJob) Jitter() time.Duration {
|
||||
return 10 * time.Minute
|
||||
}
|
||||
|
||||
func (j *ScheduleReportsJob) Trigger() <-chan struct{} {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j *ScheduleReportsJob) NewParams() any {
|
||||
limit := j.UsersLimit
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
return &ScheduleReportsParams{
|
||||
UsersLimit: limit,
|
||||
Weekly: true,
|
||||
Monthly: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (j *ScheduleReportsJob) RunOnce(ctx context.Context, params any) error {
|
||||
return j.RunOnceAt(ctx, params, time.Now().UTC())
|
||||
}
|
||||
|
||||
func (j *ScheduleReportsJob) RunOnceAt(ctx context.Context, params any, tnow time.Time) error {
|
||||
p, ok := params.(*ScheduleReportsParams)
|
||||
if !ok || (p == nil) {
|
||||
slog.ErrorContext(ctx, "Job parameter has incorrect type", "params", params, "job", j.Name())
|
||||
p = j.NewParams().(*ScheduleReportsParams)
|
||||
}
|
||||
|
||||
if p.UserID > 0 {
|
||||
slog.DebugContext(ctx, "Processing reports for a single user", "userID", p.UserID, "weekly", p.Weekly, "monthly", p.Monthly)
|
||||
|
||||
var errs []error
|
||||
if p.Weekly {
|
||||
if err := j.scheduleWeeklyReportForUser(ctx, p.UserID, &p.UserEmail, tnow); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
if p.Monthly {
|
||||
if err := j.scheduleMonthlyReportForUser(ctx, p.UserID, &p.UserEmail, tnow); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
slog.DebugContext(ctx, "Processing reports for users", "limit", p.UsersLimit, "weekly", p.Weekly, "monthly", p.Monthly,
|
||||
"weekday", tnow.Weekday(), "dayOfTheMonth", tnow.Day())
|
||||
|
||||
if p.Weekly && (tnow.Weekday() == time.Monday) {
|
||||
if err := j.scheduleWeeklyReports(ctx, tnow, p.UsersLimit); err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to schedule weekly reports", common.ErrAttr(err))
|
||||
}
|
||||
}
|
||||
|
||||
if p.Monthly && (tnow.Day() == 1) {
|
||||
if err := j.scheduleMonthlyReports(ctx, tnow, p.UsersLimit); err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to schedule monthly reports", common.ErrAttr(err))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func weeklyReportReference(userID int32, year int, week int) string {
|
||||
return fmt.Sprintf("%s%d%s", WeeklyReferencePrefix, userID, weeklyReferenceSuffix(year, week))
|
||||
}
|
||||
|
||||
func weeklyReferenceSuffix(year int, week int) string {
|
||||
return fmt.Sprintf("/%d/%d", year, week)
|
||||
}
|
||||
|
||||
func monthlyReportReference(userID int32, year int, month time.Month) string {
|
||||
return fmt.Sprintf("%s%d%s", MonthlyReferencePrefix, userID, monthlyReferenceSuffix(year, month))
|
||||
}
|
||||
|
||||
func monthlyReferenceSuffix(year int, month time.Month) string {
|
||||
return fmt.Sprintf("/%d/%d", year, month)
|
||||
}
|
||||
|
||||
func truncateDay(t time.Time) time.Time {
|
||||
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
||||
}
|
||||
|
||||
func (j *ScheduleReportsJob) scheduleWeeklyReports(ctx context.Context, tnow time.Time, usersLimit int32) error {
|
||||
year, week := tnow.ISOWeek()
|
||||
fetchLimit := usersLimit + 1
|
||||
refSuffix := weeklyReferenceSuffix(year, week)
|
||||
|
||||
b := &backoff.Backoff{
|
||||
Min: 50 * time.Millisecond,
|
||||
Max: 1 * time.Second,
|
||||
Factor: 2,
|
||||
Jitter: true,
|
||||
}
|
||||
|
||||
var lastSeenUserID int32
|
||||
for iteration := 0; iteration < maxPaginationIterations; iteration++ {
|
||||
users, err := j.Store.Impl().RetrieveUsersWithPendingWeeklyReport(ctx, fetchLimit, lastSeenUserID, WeeklyReferencePrefix, refSuffix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hasMore := int32(len(users)) > usersLimit
|
||||
if hasMore {
|
||||
users = users[:usersLimit]
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "Scheduling weekly reports chunk", "count", len(users), "lastSeenUserID", lastSeenUserID)
|
||||
|
||||
for _, user := range users {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
slog.WarnContext(ctx, "Job context cancelled while scheduling weekly reports", common.ErrAttr(ctx.Err()))
|
||||
return ctx.Err()
|
||||
case <-time.After(b.Duration()):
|
||||
}
|
||||
|
||||
if !j.PlanService.IsSubscriptionActive(user.SubscriptionStatus) {
|
||||
slog.DebugContext(ctx, "Skipping weekly report for user with inactive subscription", "userID", user.UserID, "status", user.SubscriptionStatus)
|
||||
continue
|
||||
}
|
||||
|
||||
var emailTo *string
|
||||
if user.NotificationsEmail.Valid {
|
||||
emailTo = &user.NotificationsEmail.String
|
||||
}
|
||||
|
||||
// single user report failure shouldn't abort this
|
||||
_ = j.scheduleWeeklyReportForUser(ctx, user.UserID, emailTo, tnow)
|
||||
|
||||
lastSeenUserID = user.UserID
|
||||
}
|
||||
|
||||
if !hasMore {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j *ScheduleReportsJob) scheduleWeeklyReportForUser(ctx context.Context, userID int32, emailTo *string, tnow time.Time) error {
|
||||
today := truncateDay(tnow)
|
||||
from := today.AddDate(0, 0, -14)
|
||||
mid := today.AddDate(0, 0, -7)
|
||||
|
||||
reportCtx, err := BuildWeeklyReport(ctx, j.Store, j.TimeSeries, userID, from, mid, today)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to build weekly report", "userID", userID, common.ErrAttr(err))
|
||||
return err
|
||||
}
|
||||
|
||||
year, week := tnow.ISOWeek()
|
||||
|
||||
notif := &common.ScheduledNotification{
|
||||
ReferenceID: weeklyReportReference(userID, year, week),
|
||||
UserID: userID,
|
||||
Subject: "[Private Captcha] Your weekly usage report",
|
||||
Data: reportCtx,
|
||||
DateTime: tnow,
|
||||
TemplateHash: email.UsageReportTemplate.Hash(),
|
||||
Persistent: false,
|
||||
Condition: common.NotificationWithSubscription,
|
||||
}
|
||||
|
||||
if (emailTo != nil) && (len(*emailTo) > 0) {
|
||||
notif.EmailTo = emailTo
|
||||
}
|
||||
|
||||
_, err = j.Store.Impl().CreateUserNotification(ctx, notif)
|
||||
if err != nil {
|
||||
if !errors.Is(err, db.ErrAlreadyExists) {
|
||||
slog.WarnContext(ctx, "Failed to create weekly report notification", "userID", userID, common.ErrAttr(err))
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j *ScheduleReportsJob) scheduleMonthlyReports(ctx context.Context, tnow time.Time, usersLimit int32) error {
|
||||
fetchLimit := usersLimit + 1
|
||||
refSuffix := monthlyReferenceSuffix(tnow.Year(), tnow.Month())
|
||||
|
||||
b := &backoff.Backoff{
|
||||
Min: 50 * time.Millisecond,
|
||||
Max: 1 * time.Second,
|
||||
Factor: 2,
|
||||
Jitter: true,
|
||||
}
|
||||
|
||||
var lastSeenUserID int32
|
||||
for iteration := 0; iteration < maxPaginationIterations; iteration++ {
|
||||
users, err := j.Store.Impl().RetrieveUsersWithPendingMonthlyReport(ctx, fetchLimit, lastSeenUserID, MonthlyReferencePrefix, refSuffix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hasMore := int32(len(users)) > usersLimit
|
||||
if hasMore {
|
||||
users = users[:usersLimit]
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "Scheduling monthly reports chunk", "count", len(users), "lastSeenUserID", lastSeenUserID)
|
||||
|
||||
for _, user := range users {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
slog.WarnContext(ctx, "Job context cancelled while scheduling monthly reports", common.ErrAttr(ctx.Err()))
|
||||
return ctx.Err()
|
||||
case <-time.After(b.Duration()):
|
||||
}
|
||||
|
||||
if !j.PlanService.IsSubscriptionActive(user.SubscriptionStatus) {
|
||||
slog.DebugContext(ctx, "Skipping monthly report for user with inactive subscription", "userID", user.UserID, "status", user.SubscriptionStatus)
|
||||
continue
|
||||
}
|
||||
|
||||
var emailTo *string
|
||||
if user.NotificationsEmail.Valid {
|
||||
emailTo = &user.NotificationsEmail.String
|
||||
}
|
||||
|
||||
// single user report failure shouldn't abort this
|
||||
_ = j.scheduleMonthlyReportForUser(ctx, user.UserID, emailTo, tnow)
|
||||
|
||||
lastSeenUserID = user.UserID
|
||||
}
|
||||
|
||||
if !hasMore {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j *ScheduleReportsJob) scheduleMonthlyReportForUser(ctx context.Context, userID int32, emailTo *string, tnow time.Time) error {
|
||||
today := truncateDay(tnow)
|
||||
from := today.AddDate(0, -2, 0)
|
||||
mid := today.AddDate(0, -1, 0)
|
||||
|
||||
reportCtx, err := BuildMonthlyReport(ctx, j.Store, j.TimeSeries, userID, from, mid, today)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to build monthly report", "userID", userID, common.ErrAttr(err))
|
||||
return err
|
||||
}
|
||||
|
||||
notif := &common.ScheduledNotification{
|
||||
ReferenceID: monthlyReportReference(userID, tnow.Year(), tnow.Month()),
|
||||
UserID: userID,
|
||||
Subject: "[Private Captcha] Your monthly usage report",
|
||||
Data: reportCtx,
|
||||
DateTime: tnow,
|
||||
TemplateHash: email.UsageReportTemplate.Hash(),
|
||||
Persistent: false,
|
||||
Condition: common.NotificationWithSubscription,
|
||||
}
|
||||
|
||||
if (emailTo != nil) && (len(*emailTo) > 0) {
|
||||
notif.EmailTo = emailTo
|
||||
}
|
||||
|
||||
if _, err := j.Store.Impl().CreateUserNotification(ctx, notif); err != nil {
|
||||
if !errors.Is(err, db.ErrAlreadyExists) {
|
||||
slog.WarnContext(ctx, "Failed to create monthly report notification", "userID", userID, common.ErrAttr(err))
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildWeeklyReport builds a complete weekly usage report for a user.
|
||||
func BuildWeeklyReport(ctx context.Context, store db.Implementor, ts common.TimeSeriesStore, userID int32, from, mid, to time.Time) (*email.UsageReportContext, error) {
|
||||
report := &email.UsageReportContext{
|
||||
Period: "weekly",
|
||||
PeriodDate: to.Format("02 Jan 2006"),
|
||||
DashboardPath: common.SettingsEndpoint + "?tab=" + common.UsageEndpoint,
|
||||
}
|
||||
|
||||
stats, err := ts.RetrieveWeeklyReportStats(ctx, userID, from, mid, to)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to retrieve weekly report stats", "userID", userID, common.ErrAttr(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fillTotals(report, stats)
|
||||
fillChanges(report, stats)
|
||||
fillTopProperties(ctx, store, report, stats)
|
||||
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// BuildMonthlyReport builds a complete monthly usage report for a user.
|
||||
func BuildMonthlyReport(ctx context.Context, store db.Implementor, ts common.TimeSeriesStore, userID int32, from, mid, to time.Time) (*email.UsageReportContext, error) {
|
||||
report := &email.UsageReportContext{
|
||||
Period: "monthly",
|
||||
PeriodDate: to.Format("Jan 2006"),
|
||||
DashboardPath: common.SettingsEndpoint + "?tab=" + common.UsageEndpoint,
|
||||
}
|
||||
|
||||
stats, err := ts.RetrieveMonthlyReportStats(ctx, userID, from, mid, to)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to retrieve monthly report stats", "userID", userID, common.ErrAttr(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fillTotals(report, stats)
|
||||
fillChanges(report, stats)
|
||||
fillTopProperties(ctx, store, report, stats)
|
||||
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func fillTotals(report *email.UsageReportContext, stats *common.UserReportStats) {
|
||||
report.TotalRequests = stats.TotalCurrentRequests
|
||||
report.PrevRequests = stats.TotalPrevRequests
|
||||
report.TotalVerifies = stats.TotalCurrentVerifies
|
||||
report.PrevVerifies = stats.TotalPrevVerifies
|
||||
report.VerificationRate = verificationRate(report.TotalRequests, report.TotalVerifies)
|
||||
}
|
||||
|
||||
func percentChange(current, previous uint64) float64 {
|
||||
if previous == 0 {
|
||||
if current == 0 {
|
||||
return 0
|
||||
}
|
||||
return 100
|
||||
}
|
||||
return (float64(current) - float64(previous)) / float64(previous) * 100
|
||||
}
|
||||
|
||||
func percentChangeFloat(current, previous float64) float64 {
|
||||
if math.Abs(previous) < floatEpsilon {
|
||||
if math.Abs(current) < floatEpsilon {
|
||||
return 0
|
||||
}
|
||||
return 100
|
||||
}
|
||||
return (current - previous) / previous * 100
|
||||
}
|
||||
|
||||
func verificationRate(totalRequests, totalVerifies uint64) float64 {
|
||||
if totalRequests == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(totalVerifies) / float64(totalRequests) * 100
|
||||
}
|
||||
|
||||
func fillChanges(report *email.UsageReportContext, stats *common.UserReportStats) {
|
||||
report.RequestsChange = percentChange(report.TotalRequests, report.PrevRequests)
|
||||
report.VerifiesChange = percentChange(report.TotalVerifies, report.PrevVerifies)
|
||||
report.VerificationRateChange = percentChangeFloat(
|
||||
report.VerificationRate,
|
||||
verificationRate(report.PrevRequests, report.PrevVerifies),
|
||||
)
|
||||
}
|
||||
|
||||
func fillTopProperties(ctx context.Context, store db.Implementor, report *email.UsageReportContext, stats *common.UserReportStats) {
|
||||
if len(stats.Properties) == 0 || report.TotalRequests == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
props := stats.Properties
|
||||
if len(props) > topPropertiesLimit {
|
||||
props = props[:topPropertiesLimit]
|
||||
}
|
||||
|
||||
batch := make(map[int32]uint, len(props))
|
||||
for _, ps := range props {
|
||||
batch[ps.PropertyID] = 0
|
||||
}
|
||||
|
||||
properties, err := store.Impl().RetrievePropertiesByID(ctx, batch)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "Failed to batch-retrieve properties for report", common.ErrAttr(err))
|
||||
return
|
||||
}
|
||||
|
||||
propMap := make(map[int32]*dbgen.Property, len(properties))
|
||||
for _, p := range properties {
|
||||
propMap[p.ID] = p
|
||||
}
|
||||
|
||||
topProperties := make([]*email.PropertyStat, 0, len(props))
|
||||
for _, ps := range props {
|
||||
prop, ok := propMap[ps.PropertyID]
|
||||
if !ok {
|
||||
slog.DebugContext(ctx, "Skipping unknown property in report", "propID", ps.PropertyID)
|
||||
continue
|
||||
}
|
||||
|
||||
percent := float64(ps.CurrentRequests) / float64(report.TotalRequests) * 100
|
||||
change := percentChange(ps.CurrentRequests, ps.PrevRequests)
|
||||
|
||||
pStat := &email.PropertyStat{
|
||||
Name: prop.Name,
|
||||
Domain: prop.Domain,
|
||||
Count: ps.CurrentRequests,
|
||||
Percent: percent,
|
||||
Change: change,
|
||||
Alternate: len(topProperties)%2 == 1,
|
||||
}
|
||||
if prop.AllowSubdomains {
|
||||
pStat.Domain = "*." + prop.Domain
|
||||
}
|
||||
|
||||
topProperties = append(topProperties, pStat)
|
||||
}
|
||||
report.TopProperties = topProperties
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package maintenance
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPercentChange(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
current uint64
|
||||
previous uint64
|
||||
expected float64
|
||||
}{
|
||||
{"zero to zero", 0, 0, 0},
|
||||
{"zero to positive", 0, 100, -100},
|
||||
{"positive to zero", 100, 0, 100},
|
||||
{"increase", 150, 100, 50},
|
||||
{"decrease", 50, 100, -50},
|
||||
{"equal", 100, 100, 0},
|
||||
{"double", 200, 100, 100},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := percentChange(tt.current, tt.previous)
|
||||
if got != tt.expected {
|
||||
t.Errorf("percentChange(%d, %d) = %f, want %f", tt.current, tt.previous, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferenceSuffix(t *testing.T) {
|
||||
if got := weeklyReferenceSuffix(2025, 11); got != "/2025/11" {
|
||||
t.Errorf("weeklyReferenceSuffix(2025, 11) = %q, want %q", got, "/2025/11")
|
||||
}
|
||||
if got := monthlyReferenceSuffix(2025, time.March); got != "/2025/3" {
|
||||
t.Errorf("monthlyReferenceSuffix(2025, March) = %q, want %q", got, "/2025/3")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateDay(t *testing.T) {
|
||||
input := time.Date(2025, 3, 17, 14, 30, 45, 123, time.UTC)
|
||||
expected := time.Date(2025, 3, 17, 0, 0, 0, 0, time.UTC)
|
||||
if got := truncateDay(input); !got.Equal(expected) {
|
||||
t.Errorf("truncateDay(%v) = %v, want %v", input, got, expected)
|
||||
}
|
||||
}
|
||||
@@ -203,6 +203,34 @@ func (ul *UserAuditLog) initFromProperty(oldValue, newValue *db.AuditLogProperty
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ul *UserAuditLog) initFromUserSettings(oldValue, newValue *db.AuditLogUserSettings) error {
|
||||
ul.Resource = "Notification Settings"
|
||||
|
||||
if newValue != nil {
|
||||
var parts []string
|
||||
if newValue.WeeklyReport {
|
||||
parts = append(parts, "Weekly")
|
||||
}
|
||||
if newValue.MonthlyReport {
|
||||
parts = append(parts, "Monthly")
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
ul.Property = "Reports"
|
||||
ul.Value = strings.Join(parts, ", ")
|
||||
}
|
||||
if newValue.NotificationsEmail != nil && len(*newValue.NotificationsEmail) > 0 {
|
||||
if ul.Property == "" {
|
||||
ul.Property = "Email"
|
||||
} else {
|
||||
ul.Property += ", Email"
|
||||
}
|
||||
ul.Value += " " + common.MaskEmail(*newValue.NotificationsEmail, '*')
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ul *UserAuditLog) initFromAPIKey(oldValue, newValue *db.AuditLogAPIKey) error {
|
||||
ul.Resource = "API key"
|
||||
|
||||
@@ -328,6 +356,11 @@ func (s *Server) newUserAuditLog(ctx context.Context, log *dbgen.AuditLog) (*Use
|
||||
if oldOrgUser, newOrgUser, err = db.ParseAuditLogPayloads[db.AuditLogOrgUser](ctx, log); err == nil {
|
||||
err = ul.initFromOrgUser(oldOrgUser, newOrgUser)
|
||||
}
|
||||
case db.TableNameUserSettings:
|
||||
var oldSettings, newSettings *db.AuditLogUserSettings
|
||||
if oldSettings, newSettings, err = db.ParseAuditLogPayloads[db.AuditLogUserSettings](ctx, log); err == nil {
|
||||
err = ul.initFromUserSettings(oldSettings, newSettings)
|
||||
}
|
||||
default:
|
||||
// Allow extensions to handle custom audit log types
|
||||
if s.AuditLogParser != nil {
|
||||
|
||||
@@ -1330,3 +1330,112 @@ func TestCreateAuditLogsContextWithAuditLogs(t *testing.T) {
|
||||
t.Error("Expected AuditLogs to have entries")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserAuditLogInitFromUserSettings(t *testing.T) {
|
||||
email := "test@example.com"
|
||||
tests := []struct {
|
||||
name string
|
||||
oldValue *db.AuditLogUserSettings
|
||||
newValue *db.AuditLogUserSettings
|
||||
wantErr bool
|
||||
wantResource string
|
||||
wantProperty string
|
||||
}{
|
||||
{
|
||||
name: "nil values",
|
||||
oldValue: nil,
|
||||
newValue: nil,
|
||||
wantErr: false,
|
||||
wantResource: "Notification Settings",
|
||||
},
|
||||
{
|
||||
name: "weekly report enabled",
|
||||
newValue: &db.AuditLogUserSettings{
|
||||
WeeklyReport: true,
|
||||
},
|
||||
wantErr: false,
|
||||
wantResource: "Notification Settings",
|
||||
wantProperty: "Reports",
|
||||
},
|
||||
{
|
||||
name: "both reports enabled",
|
||||
newValue: &db.AuditLogUserSettings{
|
||||
WeeklyReport: true,
|
||||
MonthlyReport: true,
|
||||
},
|
||||
wantErr: false,
|
||||
wantResource: "Notification Settings",
|
||||
wantProperty: "Reports",
|
||||
},
|
||||
{
|
||||
name: "email set",
|
||||
newValue: &db.AuditLogUserSettings{
|
||||
NotificationsEmail: &email,
|
||||
},
|
||||
wantErr: false,
|
||||
wantResource: "Notification Settings",
|
||||
wantProperty: "Email",
|
||||
},
|
||||
{
|
||||
name: "reports and email set",
|
||||
newValue: &db.AuditLogUserSettings{
|
||||
WeeklyReport: true,
|
||||
NotificationsEmail: &email,
|
||||
},
|
||||
wantErr: false,
|
||||
wantResource: "Notification Settings",
|
||||
wantProperty: "Reports, Email",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ul := &UserAuditLog{}
|
||||
err := ul.initFromUserSettings(tt.oldValue, tt.newValue)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("initFromUserSettings() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if ul.Resource != tt.wantResource {
|
||||
t.Errorf("initFromUserSettings() Resource = %v, want %v", ul.Resource, tt.wantResource)
|
||||
}
|
||||
if tt.wantProperty != "" && ul.Property != tt.wantProperty {
|
||||
t.Errorf("initFromUserSettings() Property = %v, want %v", ul.Property, tt.wantProperty)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewUserAuditLogUserSettings(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
planService := billing.NewPlanService(nil)
|
||||
|
||||
server := &Server{
|
||||
PlanService: planService,
|
||||
Stage: "production",
|
||||
}
|
||||
|
||||
email := "user@example.com"
|
||||
log := &dbgen.AuditLog{
|
||||
ID: 100,
|
||||
UserID: db.Int(1),
|
||||
Action: dbgen.AuditLogActionUpdate,
|
||||
EntityTable: db.TableNameUserSettings,
|
||||
CreatedAt: db.Timestampz(time.Now()),
|
||||
Source: dbgen.AuditLogSourcePortal,
|
||||
NewValue: mustMarshalJSON(&db.AuditLogUserSettings{WeeklyReport: true, MonthlyReport: false, NotificationsEmail: &email}),
|
||||
}
|
||||
|
||||
ul, err := server.newUserAuditLog(ctx, log)
|
||||
if err != nil {
|
||||
t.Fatalf("newUserAuditLog() error = %v", err)
|
||||
}
|
||||
if ul == nil {
|
||||
t.Fatal("newUserAuditLog() returned nil")
|
||||
}
|
||||
if ul.Resource != "Notification Settings" {
|
||||
t.Errorf("Resource = %v, want 'Notification Settings'", ul.Resource)
|
||||
}
|
||||
if ul.TableName != db.TableNameUserSettings {
|
||||
t.Errorf("TableName = %v, want %v", ul.TableName, db.TableNameUserSettings)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,9 @@ type RenderConstants struct {
|
||||
RulesEndpoint string
|
||||
RuleStatsEndpoint string
|
||||
Terminal string
|
||||
NotificationsEndpoint string
|
||||
WeeklyReport string
|
||||
MonthlyReport string
|
||||
}
|
||||
|
||||
func NewRenderConstants() *RenderConstants {
|
||||
@@ -201,6 +204,9 @@ func NewRenderConstants() *RenderConstants {
|
||||
RulesEndpoint: common.RulesEndpoint,
|
||||
RuleStatsEndpoint: common.RuleStatsEndpoint,
|
||||
Terminal: common.ParamTerminal,
|
||||
NotificationsEndpoint: common.NotificationsEndpoint,
|
||||
WeeklyReport: common.ParamWeeklyReport,
|
||||
MonthlyReport: common.ParamMonthlyReport,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -387,6 +387,23 @@ func TestRenderHTML(t *testing.T) {
|
||||
selector: "",
|
||||
matches: []string{},
|
||||
},
|
||||
{
|
||||
path: []string{common.SettingsEndpoint, common.TabEndpoint, common.NotificationsEndpoint},
|
||||
template: settingsNotificationsTemplatePrefix + "page.html",
|
||||
model: &settingsNotificationsRenderContext{
|
||||
SettingsCommonRenderContext: SettingsCommonRenderContext{
|
||||
CsrfRenderContext: stubToken(),
|
||||
Email: "foo@bar.com",
|
||||
ActiveTabID: common.NotificationsEndpoint,
|
||||
Tabs: CreateTabViewModels(common.NotificationsEndpoint, server.SettingsTabs),
|
||||
},
|
||||
WeeklyReport: true,
|
||||
MonthlyReport: false,
|
||||
ReportEmail: "reports@example.com",
|
||||
},
|
||||
selector: "",
|
||||
matches: []string{},
|
||||
},
|
||||
{
|
||||
path: []string{common.AuditLogsEndpoint},
|
||||
template: auditLogsTemplate,
|
||||
|
||||
@@ -0,0 +1,653 @@
|
||||
package portal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/PrivateCaptcha/PrivateCaptcha/pkg/common"
|
||||
"github.com/PrivateCaptcha/PrivateCaptcha/pkg/db"
|
||||
dbgen "github.com/PrivateCaptcha/PrivateCaptcha/pkg/db/generated"
|
||||
db_tests "github.com/PrivateCaptcha/PrivateCaptcha/pkg/db/tests"
|
||||
"github.com/PrivateCaptcha/PrivateCaptcha/pkg/maintenance"
|
||||
)
|
||||
|
||||
func TestScheduleWeeklyReport(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
t.Parallel()
|
||||
|
||||
ctx := common.TraceContext(t.Context(), t.Name())
|
||||
|
||||
user, _, err := db_tests.CreateNewAccountForTest(ctx, store, t.Name(), testPlan)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create new account: %v", err)
|
||||
}
|
||||
|
||||
_, _, err = store.Impl().UpsertUserSettings(ctx, &dbgen.UpsertUserSettingsParams{
|
||||
UserID: user.ID,
|
||||
WeeklyReport: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to upsert user settings: %v", err)
|
||||
}
|
||||
|
||||
job := &maintenance.ScheduleReportsJob{
|
||||
Store: store,
|
||||
TimeSeries: timeSeries,
|
||||
PlanService: server.PlanService,
|
||||
UsersLimit: 50,
|
||||
}
|
||||
|
||||
// Monday so weekly reports trigger
|
||||
tnow := time.Date(2025, 3, 17, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
params := &maintenance.ScheduleReportsParams{
|
||||
UsersLimit: 50,
|
||||
UserID: user.ID,
|
||||
Weekly: true,
|
||||
Monthly: false,
|
||||
}
|
||||
|
||||
if err := job.RunOnceAt(ctx, params, tnow); err != nil {
|
||||
t.Fatalf("RunOnceAt failed: %v", err)
|
||||
}
|
||||
|
||||
notifications, err := store.Impl().RetrievePendingUserNotifications(ctx, tnow.Add(-1*time.Minute), 100, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to retrieve pending notifications: %v", err)
|
||||
}
|
||||
|
||||
year, week := tnow.ISOWeek()
|
||||
expectedRef := fmt.Sprintf("%s%d/%d/%d", maintenance.WeeklyReferencePrefix, user.ID, year, week)
|
||||
|
||||
var found bool
|
||||
for _, n := range notifications {
|
||||
if n.UserNotification.UserID.Int32 == user.ID && n.UserNotification.ReferenceID == expectedRef {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("weekly report notification not found for user %d with reference %q", user.ID, expectedRef)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleMonthlyReport(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
t.Parallel()
|
||||
|
||||
ctx := common.TraceContext(t.Context(), t.Name())
|
||||
|
||||
user, _, err := db_tests.CreateNewAccountForTest(ctx, store, t.Name(), testPlan)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create new account: %v", err)
|
||||
}
|
||||
|
||||
_, _, err = store.Impl().UpsertUserSettings(ctx, &dbgen.UpsertUserSettingsParams{
|
||||
UserID: user.ID,
|
||||
MonthlyReport: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to upsert user settings: %v", err)
|
||||
}
|
||||
|
||||
job := &maintenance.ScheduleReportsJob{
|
||||
Store: store,
|
||||
TimeSeries: timeSeries,
|
||||
PlanService: server.PlanService,
|
||||
UsersLimit: 50,
|
||||
}
|
||||
|
||||
// 1st of month so monthly reports trigger
|
||||
tnow := time.Date(2025, 4, 1, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
params := &maintenance.ScheduleReportsParams{
|
||||
UsersLimit: 50,
|
||||
UserID: user.ID,
|
||||
Weekly: false,
|
||||
Monthly: true,
|
||||
}
|
||||
|
||||
if err := job.RunOnceAt(ctx, params, tnow); err != nil {
|
||||
t.Fatalf("RunOnceAt failed: %v", err)
|
||||
}
|
||||
|
||||
notifications, err := store.Impl().RetrievePendingUserNotifications(ctx, tnow.Add(-1*time.Minute), 100, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to retrieve pending notifications: %v", err)
|
||||
}
|
||||
|
||||
expectedRef := fmt.Sprintf("%s%d/%d/%d", maintenance.MonthlyReferencePrefix, user.ID, tnow.Year(), int(tnow.Month()))
|
||||
|
||||
var found bool
|
||||
for _, n := range notifications {
|
||||
if n.UserNotification.UserID.Int32 == user.ID && n.UserNotification.ReferenceID == expectedRef {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("monthly report notification not found for user %d with reference %q", user.ID, expectedRef)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeeklyReportDedup(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
t.Parallel()
|
||||
|
||||
ctx := common.TraceContext(t.Context(), t.Name())
|
||||
|
||||
user, _, err := db_tests.CreateNewAccountForTest(ctx, store, t.Name(), testPlan)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create new account: %v", err)
|
||||
}
|
||||
|
||||
_, _, err = store.Impl().UpsertUserSettings(ctx, &dbgen.UpsertUserSettingsParams{
|
||||
UserID: user.ID,
|
||||
WeeklyReport: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to upsert user settings: %v", err)
|
||||
}
|
||||
|
||||
job := &maintenance.ScheduleReportsJob{
|
||||
Store: store,
|
||||
TimeSeries: timeSeries,
|
||||
PlanService: server.PlanService,
|
||||
UsersLimit: 50,
|
||||
}
|
||||
|
||||
// Monday so weekly reports trigger
|
||||
tnow := time.Date(2025, 3, 17, 10, 0, 0, 0, time.UTC)
|
||||
year, week := tnow.ISOWeek()
|
||||
refSuffix := fmt.Sprintf("/%d/%d", year, week)
|
||||
|
||||
// before running the job, user should be in the pending list
|
||||
pendingBefore, err := store.Impl().RetrieveUsersWithPendingWeeklyReport(ctx, 100, 0, maintenance.WeeklyReferencePrefix, refSuffix)
|
||||
if err != nil {
|
||||
t.Fatalf("RetrieveUsersWithPendingWeeklyReport failed: %v", err)
|
||||
}
|
||||
var foundBefore bool
|
||||
for _, u := range pendingBefore {
|
||||
if u.UserID == user.ID {
|
||||
foundBefore = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundBefore {
|
||||
t.Fatalf("expected user %d in pending weekly report list before job run", user.ID)
|
||||
}
|
||||
|
||||
params := &maintenance.ScheduleReportsParams{
|
||||
UsersLimit: 50,
|
||||
UserID: user.ID,
|
||||
Weekly: true,
|
||||
Monthly: false,
|
||||
}
|
||||
|
||||
if err := job.RunOnceAt(ctx, params, tnow); err != nil {
|
||||
t.Fatalf("RunOnceAt failed: %v", err)
|
||||
}
|
||||
|
||||
// after running the job, user should be absent from the pending list
|
||||
pendingAfter, err := store.Impl().RetrieveUsersWithPendingWeeklyReport(ctx, 100, 0, maintenance.WeeklyReferencePrefix, refSuffix)
|
||||
if err != nil {
|
||||
t.Fatalf("RetrieveUsersWithPendingWeeklyReport failed: %v", err)
|
||||
}
|
||||
for _, u := range pendingAfter {
|
||||
if u.UserID == user.ID {
|
||||
t.Errorf("user %d should not be in pending weekly report list after job run", user.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonthlyReportDedup(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
t.Parallel()
|
||||
|
||||
ctx := common.TraceContext(t.Context(), t.Name())
|
||||
|
||||
user, _, err := db_tests.CreateNewAccountForTest(ctx, store, t.Name(), testPlan)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create new account: %v", err)
|
||||
}
|
||||
|
||||
_, _, err = store.Impl().UpsertUserSettings(ctx, &dbgen.UpsertUserSettingsParams{
|
||||
UserID: user.ID,
|
||||
MonthlyReport: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to upsert user settings: %v", err)
|
||||
}
|
||||
|
||||
job := &maintenance.ScheduleReportsJob{
|
||||
Store: store,
|
||||
TimeSeries: timeSeries,
|
||||
PlanService: server.PlanService,
|
||||
UsersLimit: 50,
|
||||
}
|
||||
|
||||
// 1st of month so monthly reports trigger
|
||||
tnow := time.Date(2025, 4, 1, 10, 0, 0, 0, time.UTC)
|
||||
refSuffix := fmt.Sprintf("/%d/%d", tnow.Year(), int(tnow.Month()))
|
||||
|
||||
// before running the job, user should be in the pending list
|
||||
pendingBefore, err := store.Impl().RetrieveUsersWithPendingMonthlyReport(ctx, 100, 0, maintenance.MonthlyReferencePrefix, refSuffix)
|
||||
if err != nil {
|
||||
t.Fatalf("RetrieveUsersWithPendingMonthlyReport failed: %v", err)
|
||||
}
|
||||
var foundBefore bool
|
||||
for _, u := range pendingBefore {
|
||||
if u.UserID == user.ID {
|
||||
foundBefore = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundBefore {
|
||||
t.Fatalf("expected user %d in pending monthly report list before job run", user.ID)
|
||||
}
|
||||
|
||||
params := &maintenance.ScheduleReportsParams{
|
||||
UsersLimit: 50,
|
||||
UserID: user.ID,
|
||||
Weekly: false,
|
||||
Monthly: true,
|
||||
}
|
||||
|
||||
if err := job.RunOnceAt(ctx, params, tnow); err != nil {
|
||||
t.Fatalf("RunOnceAt failed: %v", err)
|
||||
}
|
||||
|
||||
// after running the job, user should be absent from the pending list
|
||||
pendingAfter, err := store.Impl().RetrieveUsersWithPendingMonthlyReport(ctx, 100, 0, maintenance.MonthlyReferencePrefix, refSuffix)
|
||||
if err != nil {
|
||||
t.Fatalf("RetrieveUsersWithPendingMonthlyReport failed: %v", err)
|
||||
}
|
||||
for _, u := range pendingAfter {
|
||||
if u.UserID == user.ID {
|
||||
t.Errorf("user %d should not be in pending monthly report list after job run", user.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func seedTimeSeries(t *testing.T, ts *db.MemoryTimeSeries, userID int32, propID, orgID int32, timestamp time.Time, count int) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
records := make([]*common.AccessRecord, count)
|
||||
for i := range records {
|
||||
records[i] = &common.AccessRecord{
|
||||
UserID: userID,
|
||||
PropertyID: propID,
|
||||
OrgID: orgID,
|
||||
Timestamp: timestamp.Add(time.Duration(i) * time.Minute),
|
||||
}
|
||||
}
|
||||
if err := ts.WriteAccessLogBatch(ctx, records); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedVerifyLogs(t *testing.T, ts *db.MemoryTimeSeries, userID int32, propID, orgID int32, timestamp time.Time, count int) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
records := make([]*common.VerifyRecord, count)
|
||||
for i := range records {
|
||||
records[i] = &common.VerifyRecord{
|
||||
UserID: userID,
|
||||
PropertyID: propID,
|
||||
OrgID: orgID,
|
||||
Timestamp: timestamp.Add(time.Duration(i) * time.Minute),
|
||||
Status: 1,
|
||||
}
|
||||
}
|
||||
if err := ts.WriteVerifyLogBatch(ctx, records); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWeeklyReport(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
t.Parallel()
|
||||
|
||||
ctx := common.TraceContext(t.Context(), t.Name())
|
||||
|
||||
user, org, err := db_tests.CreateNewAccountForTest(ctx, store, t.Name(), testPlan)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create new account: %v", err)
|
||||
}
|
||||
|
||||
prop1, err := db_tests.CreatePropertyForOrg(ctx, store, org)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create property 1: %v", err)
|
||||
}
|
||||
prop2Params := db_tests.CreateNewPropertyParams(user.ID, "blog.reports-test.org")
|
||||
prop2, _, err := store.Impl().CreateNewProperty(ctx, prop2Params, org)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create property 2: %v", err)
|
||||
}
|
||||
|
||||
now := time.Date(2025, 3, 17, 0, 0, 0, 0, time.UTC)
|
||||
mid := now.AddDate(0, 0, -7)
|
||||
from := now.AddDate(0, 0, -14)
|
||||
|
||||
t.Run("WithData", func(t *testing.T) {
|
||||
ts := db.NewMemoryTimeSeries()
|
||||
|
||||
seedTimeSeries(t, ts, user.ID, prop1.ID, org.ID, mid, 100)
|
||||
seedVerifyLogs(t, ts, user.ID, prop1.ID, org.ID, mid, 50)
|
||||
seedTimeSeries(t, ts, user.ID, prop1.ID, org.ID, from, 80)
|
||||
seedVerifyLogs(t, ts, user.ID, prop1.ID, org.ID, from, 40)
|
||||
|
||||
result, err := maintenance.BuildWeeklyReport(ctx, store, ts, user.ID, from, mid, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if result.Period != "weekly" {
|
||||
t.Errorf("expected period 'weekly', got %q", result.Period)
|
||||
}
|
||||
if result.TotalRequests != 100 {
|
||||
t.Errorf("expected TotalRequests=100, got %d", result.TotalRequests)
|
||||
}
|
||||
if result.PrevRequests != 80 {
|
||||
t.Errorf("expected PrevRequests=80, got %d", result.PrevRequests)
|
||||
}
|
||||
if result.TotalVerifies != 50 {
|
||||
t.Errorf("expected TotalVerifies=50, got %d", result.TotalVerifies)
|
||||
}
|
||||
if result.PrevVerifies != 40 {
|
||||
t.Errorf("expected PrevVerifies=40, got %d", result.PrevVerifies)
|
||||
}
|
||||
if result.RequestsChange <= 0 {
|
||||
t.Errorf("expected positive RequestsChange, got %f", result.RequestsChange)
|
||||
}
|
||||
if result.VerifiesChange <= 0 {
|
||||
t.Errorf("expected positive VerifiesChange, got %f", result.VerifiesChange)
|
||||
}
|
||||
if result.VerificationRate == 0 {
|
||||
t.Error("expected non-zero VerificationRate")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NoData", func(t *testing.T) {
|
||||
ts := db.NewMemoryTimeSeries()
|
||||
|
||||
result, err := maintenance.BuildWeeklyReport(ctx, store, ts, user.ID, from, mid, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if result.TotalRequests != 0 {
|
||||
t.Errorf("expected TotalRequests=0, got %d", result.TotalRequests)
|
||||
}
|
||||
if result.TotalVerifies != 0 {
|
||||
t.Errorf("expected TotalVerifies=0, got %d", result.TotalVerifies)
|
||||
}
|
||||
if result.PrevRequests != 0 {
|
||||
t.Errorf("expected PrevRequests=0, got %d", result.PrevRequests)
|
||||
}
|
||||
if result.VerificationRate != 0 {
|
||||
t.Errorf("expected VerificationRate=0, got %f", result.VerificationRate)
|
||||
}
|
||||
if len(result.TopProperties) != 0 {
|
||||
t.Errorf("expected no TopProperties, got %d", len(result.TopProperties))
|
||||
}
|
||||
if result.RequestsChange != 0 {
|
||||
t.Errorf("expected RequestsChange=0, got %f", result.RequestsChange)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NoPreviousPeriod", func(t *testing.T) {
|
||||
ts := db.NewMemoryTimeSeries()
|
||||
|
||||
seedTimeSeries(t, ts, user.ID, prop1.ID, org.ID, mid, 50)
|
||||
seedVerifyLogs(t, ts, user.ID, prop1.ID, org.ID, mid, 30)
|
||||
|
||||
result, err := maintenance.BuildWeeklyReport(ctx, store, ts, user.ID, from, mid, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if result.TotalRequests != 50 {
|
||||
t.Errorf("expected TotalRequests=50, got %d", result.TotalRequests)
|
||||
}
|
||||
if result.PrevRequests != 0 {
|
||||
t.Errorf("expected PrevRequests=0, got %d", result.PrevRequests)
|
||||
}
|
||||
if result.RequestsChange != 100 {
|
||||
t.Errorf("expected RequestsChange=100, got %f", result.RequestsChange)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DecreaseShowsNegativeChange", func(t *testing.T) {
|
||||
ts := db.NewMemoryTimeSeries()
|
||||
|
||||
seedTimeSeries(t, ts, user.ID, prop1.ID, org.ID, mid, 30)
|
||||
seedVerifyLogs(t, ts, user.ID, prop1.ID, org.ID, mid, 20)
|
||||
seedTimeSeries(t, ts, user.ID, prop1.ID, org.ID, from, 60)
|
||||
seedVerifyLogs(t, ts, user.ID, prop1.ID, org.ID, from, 40)
|
||||
|
||||
result, err := maintenance.BuildWeeklyReport(ctx, store, ts, user.ID, from, mid, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if result.RequestsChange >= 0 {
|
||||
t.Errorf("expected negative RequestsChange, got %f", result.RequestsChange)
|
||||
}
|
||||
if result.VerifiesChange >= 0 {
|
||||
t.Errorf("expected negative VerifiesChange, got %f", result.VerifiesChange)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NoChangeShowsZero", func(t *testing.T) {
|
||||
ts := db.NewMemoryTimeSeries()
|
||||
|
||||
seedTimeSeries(t, ts, user.ID, prop1.ID, org.ID, mid, 50)
|
||||
seedTimeSeries(t, ts, user.ID, prop1.ID, org.ID, from, 50)
|
||||
|
||||
result, err := maintenance.BuildWeeklyReport(ctx, store, ts, user.ID, from, mid, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if result.RequestsChange != 0 {
|
||||
t.Errorf("expected RequestsChange=0, got %f", result.RequestsChange)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("TopProperties", func(t *testing.T) {
|
||||
ts := db.NewMemoryTimeSeries()
|
||||
|
||||
seedTimeSeries(t, ts, user.ID, prop1.ID, org.ID, mid, 100)
|
||||
seedTimeSeries(t, ts, user.ID, prop2.ID, org.ID, mid, 50)
|
||||
|
||||
result, err := maintenance.BuildWeeklyReport(ctx, store, ts, user.ID, from, mid, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(result.TopProperties) != 2 {
|
||||
t.Fatalf("expected 2 TopProperties, got %d", len(result.TopProperties))
|
||||
}
|
||||
|
||||
if result.TopProperties[0].Count != 100 {
|
||||
t.Errorf("expected first property count=100, got %d", result.TopProperties[0].Count)
|
||||
}
|
||||
if result.TopProperties[0].Alternate {
|
||||
t.Error("expected first property row to be unstriped")
|
||||
}
|
||||
if !result.TopProperties[1].Alternate {
|
||||
t.Error("expected second property row to be striped")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PropertyChangeDirection", func(t *testing.T) {
|
||||
ts := db.NewMemoryTimeSeries()
|
||||
|
||||
seedTimeSeries(t, ts, user.ID, prop1.ID, org.ID, mid, 100)
|
||||
seedTimeSeries(t, ts, user.ID, prop1.ID, org.ID, from, 50)
|
||||
seedTimeSeries(t, ts, user.ID, prop2.ID, org.ID, mid, 30)
|
||||
seedTimeSeries(t, ts, user.ID, prop2.ID, org.ID, from, 60)
|
||||
|
||||
result, err := maintenance.BuildWeeklyReport(ctx, store, ts, user.ID, from, mid, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(result.TopProperties) != 2 {
|
||||
t.Fatalf("expected 2 TopProperties, got %d", len(result.TopProperties))
|
||||
}
|
||||
|
||||
// prop1 has 100 current (up from 50)
|
||||
if result.TopProperties[0].Change <= 0 {
|
||||
t.Errorf("expected positive Change for increasing property, got %f", result.TopProperties[0].Change)
|
||||
}
|
||||
// prop2 has 30 current (down from 60)
|
||||
if result.TopProperties[1].Change >= 0 {
|
||||
t.Errorf("expected negative Change for decreasing property, got %f", result.TopProperties[1].Change)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("VerificationRate", func(t *testing.T) {
|
||||
ts := db.NewMemoryTimeSeries()
|
||||
|
||||
seedTimeSeries(t, ts, user.ID, prop1.ID, org.ID, mid, 100)
|
||||
seedVerifyLogs(t, ts, user.ID, prop1.ID, org.ID, mid, 50)
|
||||
|
||||
result, err := maintenance.BuildWeeklyReport(ctx, store, ts, user.ID, from, mid, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expectedRate := 50.0
|
||||
if result.VerificationRate != expectedRate {
|
||||
t.Errorf("expected VerificationRate=%f, got %f", expectedRate, result.VerificationRate)
|
||||
}
|
||||
if result.VerificationRateChange != 100 {
|
||||
t.Errorf("expected VerificationRateChange=100, got %f", result.VerificationRateChange)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("VerificationRateChange", func(t *testing.T) {
|
||||
ts := db.NewMemoryTimeSeries()
|
||||
|
||||
seedTimeSeries(t, ts, user.ID, prop1.ID, org.ID, mid, 100)
|
||||
seedVerifyLogs(t, ts, user.ID, prop1.ID, org.ID, mid, 40)
|
||||
seedTimeSeries(t, ts, user.ID, prop1.ID, org.ID, from, 100)
|
||||
seedVerifyLogs(t, ts, user.ID, prop1.ID, org.ID, from, 50)
|
||||
|
||||
result, err := maintenance.BuildWeeklyReport(ctx, store, ts, user.ID, from, mid, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if result.VerificationRate != 40 {
|
||||
t.Errorf("expected VerificationRate=40, got %f", result.VerificationRate)
|
||||
}
|
||||
if result.VerificationRateChange >= 0 {
|
||||
t.Errorf("expected negative VerificationRateChange, got %f", result.VerificationRateChange)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildMonthlyReport(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
t.Parallel()
|
||||
|
||||
ctx := common.TraceContext(t.Context(), t.Name())
|
||||
|
||||
user, org, err := db_tests.CreateNewAccountForTest(ctx, store, t.Name(), testPlan)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create new account: %v", err)
|
||||
}
|
||||
|
||||
prop1, err := db_tests.CreatePropertyForOrg(ctx, store, org)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create property: %v", err)
|
||||
}
|
||||
|
||||
now := time.Date(2025, 4, 1, 0, 0, 0, 0, time.UTC)
|
||||
mid := now.AddDate(0, -1, 0)
|
||||
from := now.AddDate(0, -2, 0)
|
||||
|
||||
t.Run("WithData", func(t *testing.T) {
|
||||
ts := db.NewMemoryTimeSeries()
|
||||
|
||||
seedTimeSeries(t, ts, user.ID, prop1.ID, org.ID, mid, 200)
|
||||
seedVerifyLogs(t, ts, user.ID, prop1.ID, org.ID, mid, 100)
|
||||
seedTimeSeries(t, ts, user.ID, prop1.ID, org.ID, from, 150)
|
||||
seedVerifyLogs(t, ts, user.ID, prop1.ID, org.ID, from, 80)
|
||||
|
||||
result, err := maintenance.BuildMonthlyReport(ctx, store, ts, user.ID, from, mid, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if result.Period != "monthly" {
|
||||
t.Errorf("expected period 'monthly', got %q", result.Period)
|
||||
}
|
||||
if result.TotalRequests != 200 {
|
||||
t.Errorf("expected TotalRequests=200, got %d", result.TotalRequests)
|
||||
}
|
||||
if result.PrevRequests != 150 {
|
||||
t.Errorf("expected PrevRequests=150, got %d", result.PrevRequests)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NoData", func(t *testing.T) {
|
||||
ts := db.NewMemoryTimeSeries()
|
||||
|
||||
result, err := maintenance.BuildMonthlyReport(ctx, store, ts, user.ID, from, mid, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if result.TotalRequests != 0 {
|
||||
t.Errorf("expected TotalRequests=0, got %d", result.TotalRequests)
|
||||
}
|
||||
if result.Period != "monthly" {
|
||||
t.Errorf("expected period 'monthly', got %q", result.Period)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NoPreviousPeriod", func(t *testing.T) {
|
||||
ts := db.NewMemoryTimeSeries()
|
||||
|
||||
seedTimeSeries(t, ts, user.ID, prop1.ID, org.ID, mid, 70)
|
||||
|
||||
result, err := maintenance.BuildMonthlyReport(ctx, store, ts, user.ID, from, mid, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if result.TotalRequests != 70 {
|
||||
t.Errorf("expected TotalRequests=70, got %d", result.TotalRequests)
|
||||
}
|
||||
if result.PrevRequests != 0 {
|
||||
t.Errorf("expected PrevRequests=0, got %d", result.PrevRequests)
|
||||
}
|
||||
if result.RequestsChange != 100 {
|
||||
t.Errorf("expected RequestsChange=100, got %f", result.RequestsChange)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -195,6 +195,12 @@ func (s *Server) createSettingsTabs() []*SettingsTab {
|
||||
TemplatePrefix: settingsUsageTemplatePrefix,
|
||||
ModelHandler: s.getUsageSettings,
|
||||
},
|
||||
{
|
||||
ID: common.NotificationsEndpoint,
|
||||
Name: "Notifications",
|
||||
TemplatePrefix: settingsNotificationsTemplatePrefix,
|
||||
ModelHandler: s.getNotificationsSettings,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,6 +366,7 @@ func (s *Server) setupWithPrefix(rg *common.RouteGenerator, security alice.Const
|
||||
rg.Handle(rg.Post(common.SettingsEndpoint, common.TabEndpoint, common.GeneralEndpoint, common.EmailEndpoint), privateWrite, s.Handler(s.editEmail))
|
||||
rg.Handle(rg.Put(common.SettingsEndpoint, common.TabEndpoint, common.GeneralEndpoint), privateWrite, s.Handler(s.putGeneralSettings))
|
||||
rg.Handle(rg.Post(common.SettingsEndpoint, common.TabEndpoint, common.APIKeysEndpoint, common.NewEndpoint), privateWrite, s.Handler(s.postAPIKeySettings))
|
||||
rg.Handle(rg.Put(common.SettingsEndpoint, common.TabEndpoint, common.NotificationsEndpoint), privateWrite, s.Handler(s.putNotificationsSettings))
|
||||
|
||||
rg.Handle(rg.Get(common.AuditLogsEndpoint), privateRead, s.Handler(s.getAuditLogs))
|
||||
|
||||
|
||||
+117
-6
@@ -18,18 +18,21 @@ import (
|
||||
"github.com/PrivateCaptcha/PrivateCaptcha/pkg/email"
|
||||
"github.com/PrivateCaptcha/PrivateCaptcha/pkg/session"
|
||||
"github.com/badoux/checkmail"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const (
|
||||
// Content-specific template names
|
||||
settingsGeneralTemplatePrefix = "settings-general/"
|
||||
settingsAPIKeysTemplatePrefix = "settings-apikeys/"
|
||||
settingsUsageTemplatePrefix = "settings-usage/"
|
||||
settingsGeneralTemplatePrefix = "settings-general/"
|
||||
settingsAPIKeysTemplatePrefix = "settings-apikeys/"
|
||||
settingsUsageTemplatePrefix = "settings-usage/"
|
||||
settingsNotificationsTemplatePrefix = "settings-notifications/"
|
||||
|
||||
// Other templates
|
||||
settingsGeneralFormTemplate = "settings-general/form.html"
|
||||
settingsAPIKeysContentTemplate = "settings-apikeys/content.html"
|
||||
apiKeyRowTemplate = "settings-apikeys/key.html"
|
||||
settingsGeneralFormTemplate = "settings-general/form.html"
|
||||
settingsAPIKeysContentTemplate = "settings-apikeys/content.html"
|
||||
apiKeyRowTemplate = "settings-apikeys/key.html"
|
||||
settingsNotificationsFormTemplate = "settings-notifications/form.html"
|
||||
|
||||
// notifications
|
||||
apiKeyExpirationNotificationDays = 14
|
||||
@@ -119,6 +122,15 @@ type settingsAPIKeysRenderContext struct {
|
||||
CreateOpen bool
|
||||
}
|
||||
|
||||
type settingsNotificationsRenderContext struct {
|
||||
SettingsCommonRenderContext
|
||||
ReportEmail string
|
||||
UserEmail string
|
||||
EmailError string
|
||||
WeeklyReport bool
|
||||
MonthlyReport bool
|
||||
}
|
||||
|
||||
func apiKeyToUserAPIKey(key *dbgen.APIKey, tnow time.Time, hasher common.IdentifierHasher) *userAPIKey {
|
||||
// in terms of "leaky bucket" logic
|
||||
capacity := float64(key.RequestsBurst)
|
||||
@@ -1018,3 +1030,102 @@ func (s *Server) getUsageSettings(w http.ResponseWriter, r *http.Request) (*View
|
||||
|
||||
return &ViewModel{Model: renderCtx}, nil
|
||||
}
|
||||
|
||||
func (s *Server) createNotificationsSettingsModel(ctx context.Context, user *dbgen.User) *settingsNotificationsRenderContext {
|
||||
renderCtx := &settingsNotificationsRenderContext{
|
||||
SettingsCommonRenderContext: s.CreateSettingsCommonRenderContext(common.NotificationsEndpoint, user),
|
||||
UserEmail: user.Email,
|
||||
}
|
||||
|
||||
settings, err := s.Store.Impl().RetrieveUserSettings(ctx, user.ID)
|
||||
if err != nil {
|
||||
if !errors.Is(err, db.ErrRecordNotFound) && !errors.Is(err, db.ErrNegativeCacheHit) {
|
||||
slog.ErrorContext(ctx, "Failed to retrieve user settings", "userID", user.ID, common.ErrAttr(err))
|
||||
renderCtx.ErrorMessage = "Could not load notification settings. Please try again."
|
||||
}
|
||||
return renderCtx
|
||||
}
|
||||
|
||||
renderCtx.WeeklyReport = settings.WeeklyReport
|
||||
renderCtx.MonthlyReport = settings.MonthlyReport
|
||||
|
||||
if settings.NotificationsEmail.Valid {
|
||||
renderCtx.ReportEmail = settings.NotificationsEmail.String
|
||||
}
|
||||
|
||||
return renderCtx
|
||||
}
|
||||
|
||||
func (s *Server) getNotificationsSettings(w http.ResponseWriter, r *http.Request) (*ViewModel, error) {
|
||||
ctx := r.Context()
|
||||
|
||||
user, err := s.SessionUser(ctx, s.Session(w, r))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
renderCtx := s.createNotificationsSettingsModel(ctx, user)
|
||||
|
||||
return &ViewModel{Model: renderCtx}, nil
|
||||
}
|
||||
|
||||
func (s *Server) putNotificationsSettings(w http.ResponseWriter, r *http.Request) (*ViewModel, error) {
|
||||
ctx := r.Context()
|
||||
user, err := s.SessionUser(ctx, s.Session(w, r))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to parse form data", common.ErrAttr(err))
|
||||
return nil, ErrInvalidRequestArg
|
||||
}
|
||||
|
||||
_, weeklyReport := r.Form[common.ParamWeeklyReport]
|
||||
_, monthlyReport := r.Form[common.ParamMonthlyReport]
|
||||
reportEmail := strings.TrimSpace(r.FormValue(common.ParamEmail))
|
||||
|
||||
renderCtx := &settingsNotificationsRenderContext{
|
||||
SettingsCommonRenderContext: s.CreateSettingsCommonRenderContext(common.NotificationsEndpoint, user),
|
||||
WeeklyReport: weeklyReport,
|
||||
MonthlyReport: monthlyReport,
|
||||
ReportEmail: reportEmail,
|
||||
UserEmail: user.Email,
|
||||
}
|
||||
|
||||
if len(reportEmail) > 0 {
|
||||
if err := s.EmailVerifier.VerifyEmail(ctx, reportEmail); err != nil {
|
||||
slog.WarnContext(ctx, "Email verification failed for notification settings", "userID", user.ID, common.ErrAttr(err))
|
||||
renderCtx.EmailError = "Invalid email address."
|
||||
return &ViewModel{
|
||||
Model: renderCtx,
|
||||
View: settingsNotificationsFormTemplate,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
params := &dbgen.UpsertUserSettingsParams{
|
||||
UserID: user.ID,
|
||||
WeeklyReport: weeklyReport,
|
||||
MonthlyReport: monthlyReport,
|
||||
NotificationsEmail: pgtype.Text{String: reportEmail, Valid: len(reportEmail) > 0},
|
||||
}
|
||||
|
||||
_, auditEvent, err := s.Store.Impl().UpsertUserSettings(ctx, params)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to save notification settings", common.ErrAttr(err))
|
||||
renderCtx.ErrorMessage = "Failed to save notification settings."
|
||||
return &ViewModel{
|
||||
Model: renderCtx,
|
||||
View: settingsNotificationsFormTemplate,
|
||||
}, nil
|
||||
}
|
||||
|
||||
renderCtx.SuccessMessage = "Notification settings saved."
|
||||
|
||||
return &ViewModel{
|
||||
Model: renderCtx,
|
||||
View: settingsNotificationsFormTemplate,
|
||||
AuditEvent: auditEvent,
|
||||
}, nil
|
||||
}
|
||||
|
||||
+7
-2
@@ -22,6 +22,11 @@
|
||||
@apply font-sans;
|
||||
}
|
||||
|
||||
.pc-form-secondary-text {
|
||||
@apply text-sm;
|
||||
@apply text-gray-500;
|
||||
}
|
||||
|
||||
.pc-form-text-error {
|
||||
@apply text-red-500;
|
||||
}
|
||||
@@ -129,7 +134,7 @@
|
||||
@apply block;
|
||||
@apply px-4;
|
||||
@apply py-4;
|
||||
@apply placeholder-gray-600;
|
||||
@apply placeholder-gray-500;
|
||||
@apply bg-white;
|
||||
@apply border;
|
||||
@apply rounded-xl;
|
||||
@@ -140,7 +145,7 @@
|
||||
@apply block;
|
||||
@apply px-4;
|
||||
@apply py-2.5;
|
||||
@apply placeholder-gray-600;
|
||||
@apply placeholder-gray-500;
|
||||
@apply border;
|
||||
@apply rounded-md;
|
||||
@apply focus:outline-none;
|
||||
|
||||
@@ -75,6 +75,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
{{- else if eq .TableName "user_settings" -}}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
|
||||
</svg>
|
||||
{{- else if eq .TableName "difficulty_rules" -}}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<main class="px-4 py-16 sm:px-6 lg:flex-auto lg:px-0 lg:py-20">
|
||||
<div class="mx-auto max-w-4xl lg:mx-0">
|
||||
<div class="grid grid-cols-1 gap-x-8 gap-y-10 md:grid-cols-3">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold leading-7 text-gray-900">Notifications</h2>
|
||||
<p class="mt-1 text-sm leading-6 text-gray-600">Receive account- and organization-level usage reports over the email.</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
id="notifications-form"
|
||||
hx-put='{{ partsURL .Const.SettingsEndpoint .Const.TabEndpoint .Const.NotificationsEndpoint }}'
|
||||
hx-target="this"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#notifications-form-spinner"
|
||||
hx-disabled-elt="input, button"
|
||||
class="md:col-span-2"
|
||||
>
|
||||
{{template "form.html" .}}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -0,0 +1,85 @@
|
||||
{{- if .Params.ErrorMessage -}}
|
||||
<div class="col-span-full">
|
||||
<div class="pb-5">{{ template "error-message.html" .Params.ErrorMessage }}</div>
|
||||
</div>
|
||||
{{- else if .Params.WarningMessage -}}
|
||||
<div class="col-span-full">
|
||||
<div class="pb-5">{{ template "warning-message.html" .Params.WarningMessage }}</div>
|
||||
</div>
|
||||
{{- else if .Params.SuccessMessage -}}
|
||||
<div class="col-span-full">
|
||||
<div class="pb-5">{{ template "success-message.html" .Params.SuccessMessage }}</div>
|
||||
</div>
|
||||
{{- else if .Params.InfoMessage -}}
|
||||
<div class="col-span-full">
|
||||
<div class="pb-5">{{ template "info-message.html" .Params.InfoMessage }}</div>
|
||||
</div>
|
||||
{{- end -}}
|
||||
|
||||
<div class="grid sm:max-w-lg grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
|
||||
<div class="sm:col-span-full">
|
||||
<label for="{{ .Const.Email}}" class="pc-internal-form-label">Email</label>
|
||||
<div class="mt-2 relative">
|
||||
{{- if .Params.EmailError -}}
|
||||
{{template "info-icon-red.html" .}}
|
||||
{{- end -}}
|
||||
<input type="email" name="{{ .Const.Email }}" maxlength="255" {{ if .Params.ReportEmail }}value="{{ .Params.ReportEmail }}"{{end}} placeholder="{{.Params.UserEmail}}" class="w-full pc-internal-form-input-base {{ if .Params.EmailError }}pc-form-input-error{{ else }}pc-form-input-normal{{ end }}" />
|
||||
</div>
|
||||
{{- if .Params.EmailError -}}
|
||||
<p class="pc-form-error-text">{{ .Params.EmailError }}</p>
|
||||
{{- else -}}
|
||||
<p class="pc-form-secondary-text">If empty, reports are sent to your account email.</p>
|
||||
{{- end -}}
|
||||
</div>
|
||||
|
||||
<div class="col-span-full">
|
||||
<fieldset>
|
||||
<legend class="text-sm/6 font-semibold text-gray-900">Report types</legend>
|
||||
<div class="mt-6 space-y-6">
|
||||
<div class="flex gap-3">
|
||||
<div class="flex h-6 shrink-0 items-center">
|
||||
<div class="group grid size-4 grid-cols-1">
|
||||
<input id="{{ .Const.WeeklyReport }}" aria-describedby="{{ .Const.WeeklyReport }}-description" name="{{ .Const.WeeklyReport }}" type="checkbox" {{ if $.Params.WeeklyReport }}checked{{ end }} class="col-start-1 row-start-1 pc-internal-form-checkbox">
|
||||
<svg class="pointer-events-none col-start-1 row-start-1 size-3.5 self-center justify-self-center stroke-white group-has-[:disabled]:stroke-gray-950/25" viewBox="0 0 14 14" fill="none">
|
||||
<path class="opacity-0 group-has-[:checked]:opacity-100" d="M3 8L6 11L11 3.5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path class="opacity-0 group-has-[:indeterminate]:opacity-100" d="M3 7H11" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm/6">
|
||||
<label for="{{ .Const.WeeklyReport }}" class="font-medium text-gray-900">Weekly report</label>
|
||||
<span id="{{ .Const.WeeklyReport }}-description" class="text-gray-500"><span class="sr-only">Weekly report </span>sent every Monday</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<div class="flex h-6 shrink-0 items-center">
|
||||
<div class="group grid size-4 grid-cols-1">
|
||||
<input id="{{ .Const.MonthlyReport }}" aria-describedby="{{ .Const.MonthlyReport }}-description" name="{{ .Const.MonthlyReport }}" type="checkbox" {{ if $.Params.MonthlyReport }}checked{{ end }} class="col-start-1 row-start-1 pc-internal-form-checkbox">
|
||||
<svg class="pointer-events-none col-start-1 row-start-1 size-3.5 self-center justify-self-center stroke-white group-has-[:disabled]:stroke-gray-950/25" viewBox="0 0 14 14" fill="none">
|
||||
<path class="opacity-0 group-has-[:checked]:opacity-100" d="M3 8L6 11L11 3.5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path class="opacity-0 group-has-[:indeterminate]:opacity-100" d="M3 7H11" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm/6">
|
||||
<label for="{{ .Const.MonthlyReport }}" class="font-medium text-gray-900">Monthly report</label>
|
||||
<span id="{{ .Const.MonthlyReport }}-description" class="text-gray-500"><span class="sr-only">Monthly report </span>sent every first day of the month</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start md:col-span-2">
|
||||
<button
|
||||
type="submit"
|
||||
class="pc-internal-form-button pc-internal-form-button-primary"
|
||||
>
|
||||
<svg id="notifications-form-spinner" class="htmx-indicator animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 396 B |
@@ -0,0 +1,5 @@
|
||||
{{template "settings.html" .}}
|
||||
|
||||
{{define "settings-page"}}
|
||||
{{template "tab.html" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,4 @@
|
||||
{{ template "settings-nav.html" .}}
|
||||
<div id="settings-content-area" class="lg:flex-auto">
|
||||
{{ template "content.html" . }}
|
||||
</div>
|
||||
Reference in New Issue
Block a user