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:
Copilot
2026-04-05 16:12:47 +03:00
committed by GitHub
parent 487686dd26
commit c35d427c07
40 changed files with 2476 additions and 11 deletions
+6
View File
@@ -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
View File
@@ -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",
+2
View File
@@ -62,6 +62,8 @@ const (
ParamActionValue = "action_value"
ParamTerminal = "terminal"
ParamPosition = "position"
ParamWeeklyReport = "weekly_report"
ParamMonthlyReport = "monthly_report"
All = "all"
)
+1
View File
@@ -45,4 +45,5 @@ const (
AsyncTaskEndpoint = "asynctask"
RulesEndpoint = "rules"
RuleStatsEndpoint = "rulestats"
NotificationsEndpoint = "notifications"
)
+2
View File
@@ -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)
+17
View File
@@ -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
}
+28
View File
@@ -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)
}
}
+47
View File
@@ -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)
}
})
}
}
+6
View File
@@ -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"`
+97
View File
@@ -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
}
+5
View File
@@ -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)
}
+1
View File
@@ -9,4 +9,5 @@ const (
TableNameAPIKeys = "apikeys"
TableNameAuditLogs = "audit_logs"
TableNameDifficultyRules = "difficulty_rules"
TableNameUserSettings = "user_settings"
)
+10
View File
@@ -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"`
}
+4
View File
@@ -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)
+191
View File
@@ -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)
);
+44
View File
@@ -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
View File
@@ -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"
+167
View File
@@ -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()
+143
View File
@@ -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
</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 `
)
+57
View File
@@ -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
}
+18
View File
@@ -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",
+1 -1
View File
@@ -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 {
+473
View File
@@ -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
}
+49
View File
@@ -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)
}
}
+33
View File
@@ -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 {
+109
View File
@@ -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)
}
}
+6
View File
@@ -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,
}
}
+17
View File
@@ -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,
+653
View File
@@ -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)
}
})
}
+7
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+4
View File
@@ -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>