feat(ops): ECR-007 行为分析与 ECR-008 内容运营后台
落地埋点 ingest/数据看板、首页宫格 CMS 与测评上下架;含账号引导、问答流式与免责声明去重,以及 review P1 审计同事务修复。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -115,12 +115,18 @@ func (r *AdminRepo) InsertAudit(ctx context.Context, adminID uuid.UUID, action,
|
||||
|
||||
// UserListItem is a compact user row for admin tables.
|
||||
type UserListItem struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
Nickname *string `json:"nickname,omitempty"`
|
||||
AskPaidQuotaLeft int `json:"ask_paid_quota_left"`
|
||||
ProfileCount int `json:"profile_count"`
|
||||
MembershipActive bool `json:"membership_active"`
|
||||
MembershipPlan *string `json:"membership_plan,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ListUsers returns users newest first; q matches id when UUID.
|
||||
// ListUsers returns users newest first; q matches id / phone / nickname.
|
||||
func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int) ([]UserListItem, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
@@ -129,10 +135,26 @@ func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int)
|
||||
offset = 0
|
||||
}
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, status, created_at FROM users
|
||||
WHERE deleted_at IS NULL
|
||||
AND ($1 = '' OR id::text = $1)
|
||||
ORDER BY created_at DESC
|
||||
SELECT u.id, u.status, u.phone, u.nickname, u.ask_paid_quota_left, u.created_at,
|
||||
(SELECT count(*) FROM profiles p WHERE p.user_id=u.id AND p.deleted_at IS NULL) AS profile_count,
|
||||
EXISTS(
|
||||
SELECT 1 FROM memberships m
|
||||
WHERE m.user_id=u.id AND m.deleted_at IS NULL AND m.status='active' AND m.expires_at > now()
|
||||
) AS membership_active,
|
||||
(
|
||||
SELECT m.plan FROM memberships m
|
||||
WHERE m.user_id=u.id AND m.deleted_at IS NULL
|
||||
LIMIT 1
|
||||
) AS membership_plan
|
||||
FROM users u
|
||||
WHERE u.deleted_at IS NULL
|
||||
AND (
|
||||
$1 = ''
|
||||
OR u.id::text = $1
|
||||
OR COALESCE(u.phone,'') ILIKE '%' || $1 || '%'
|
||||
OR COALESCE(u.nickname,'') ILIKE '%' || $1 || '%'
|
||||
)
|
||||
ORDER BY u.created_at DESC
|
||||
LIMIT $2 OFFSET $3`, q, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -141,7 +163,10 @@ func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int)
|
||||
var out []UserListItem
|
||||
for rows.Next() {
|
||||
var u UserListItem
|
||||
if err := rows.Scan(&u.ID, &u.Status, &u.CreatedAt); err != nil {
|
||||
if err := rows.Scan(
|
||||
&u.ID, &u.Status, &u.Phone, &u.Nickname, &u.AskPaidQuotaLeft, &u.CreatedAt,
|
||||
&u.ProfileCount, &u.MembershipActive, &u.MembershipPlan,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, u)
|
||||
@@ -149,6 +174,126 @@ func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int)
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DashboardStats is ops overview counters.
|
||||
type DashboardStats struct {
|
||||
UsersTotal int `json:"users_total"`
|
||||
MembershipActive int `json:"membership_active"`
|
||||
OrdersToday int `json:"orders_today"`
|
||||
PaidCentsToday int `json:"paid_cents_today"`
|
||||
AskRepliesToday int `json:"ask_replies_today"`
|
||||
ProfilesTotal int `json:"profiles_total"`
|
||||
ReportsTotal int `json:"reports_total"`
|
||||
Series []DashboardDay `json:"series"`
|
||||
ReportsByType []ReportTypeCnt `json:"reports_by_type"`
|
||||
}
|
||||
|
||||
// DashboardDay is one day of trend metrics.
|
||||
type DashboardDay struct {
|
||||
Day string `json:"day"`
|
||||
NewUsers int `json:"new_users"`
|
||||
Orders int `json:"orders"`
|
||||
PaidCents int `json:"paid_cents"`
|
||||
AskReplies int `json:"ask_replies"`
|
||||
}
|
||||
|
||||
// ReportTypeCnt counts reports by type.
|
||||
type ReportTypeCnt struct {
|
||||
Type string `json:"type"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// GetDashboardStats aggregates key ops metrics.
|
||||
func (r *AdminRepo) GetDashboardStats(ctx context.Context) (*DashboardStats, error) {
|
||||
s := &DashboardStats{}
|
||||
if err := r.Pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM users WHERE deleted_at IS NULL`).Scan(&s.UsersTotal); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.Pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM memberships
|
||||
WHERE deleted_at IS NULL AND status='active' AND expires_at > now()`).Scan(&s.MembershipActive); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.Pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM orders
|
||||
WHERE deleted_at IS NULL AND created_at >= date_trunc('day', now())`).Scan(&s.OrdersToday); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.Pool.QueryRow(ctx, `
|
||||
SELECT coalesce(sum(amount_cents),0) FROM orders
|
||||
WHERE deleted_at IS NULL AND status='paid' AND created_at >= date_trunc('day', now())`).Scan(&s.PaidCentsToday); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.Pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM ask_messages m
|
||||
JOIN ask_threads t ON t.id=m.thread_id AND t.deleted_at IS NULL
|
||||
WHERE m.deleted_at IS NULL AND m.role='assistant'
|
||||
AND m.created_at >= date_trunc('day', now())`).Scan(&s.AskRepliesToday); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.Pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM profiles WHERE deleted_at IS NULL`).Scan(&s.ProfilesTotal); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.Pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM growth_reports WHERE deleted_at IS NULL`).Scan(&s.ReportsTotal); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
WITH days AS (
|
||||
SELECT generate_series(
|
||||
date_trunc('day', now()) - interval '6 day',
|
||||
date_trunc('day', now()),
|
||||
interval '1 day'
|
||||
)::date AS d
|
||||
)
|
||||
SELECT to_char(d.d, 'YYYY-MM-DD') AS day,
|
||||
(SELECT count(*) FROM users u
|
||||
WHERE u.deleted_at IS NULL AND u.created_at::date = d.d) AS new_users,
|
||||
(SELECT count(*) FROM orders o
|
||||
WHERE o.deleted_at IS NULL AND o.created_at::date = d.d) AS orders,
|
||||
(SELECT coalesce(sum(o.amount_cents),0) FROM orders o
|
||||
WHERE o.deleted_at IS NULL AND o.status='paid' AND o.created_at::date = d.d) AS paid_cents,
|
||||
(SELECT count(*) FROM ask_messages m
|
||||
JOIN ask_threads t ON t.id=m.thread_id AND t.deleted_at IS NULL
|
||||
WHERE m.deleted_at IS NULL AND m.role='assistant' AND m.created_at::date = d.d) AS ask_replies
|
||||
FROM days d
|
||||
ORDER BY d.d ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var p DashboardDay
|
||||
if err := rows.Scan(&p.Day, &p.NewUsers, &p.Orders, &p.PaidCents, &p.AskReplies); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.Series = append(s.Series, p)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
trows, err := r.Pool.Query(ctx, `
|
||||
SELECT type, count(*) FROM growth_reports
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY type
|
||||
ORDER BY count(*) DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer trows.Close()
|
||||
for trows.Next() {
|
||||
var c ReportTypeCnt
|
||||
if err := trows.Scan(&c.Type, &c.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.ReportsByType = append(s.ReportsByType, c)
|
||||
}
|
||||
return s, trows.Err()
|
||||
}
|
||||
|
||||
// UserExists reports whether user id is present.
|
||||
func (r *AdminRepo) UserExists(ctx context.Context, id uuid.UUID) (bool, error) {
|
||||
var n int
|
||||
@@ -165,12 +310,14 @@ type ProfileBrief struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Relation string `json:"relation"`
|
||||
DisplayName string `json:"display_name"`
|
||||
BirthDate string `json:"birth_date,omitempty"`
|
||||
}
|
||||
|
||||
// ListProfilesForUser returns profile briefs.
|
||||
func (r *AdminRepo) ListProfilesForUser(ctx context.Context, userID uuid.UUID) ([]ProfileBrief, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, relation, display_name FROM profiles
|
||||
SELECT id, relation, display_name, to_char(birth_date, 'YYYY-MM-DD')
|
||||
FROM profiles
|
||||
WHERE user_id=$1 AND deleted_at IS NULL
|
||||
ORDER BY created_at ASC`, userID)
|
||||
if err != nil {
|
||||
@@ -180,7 +327,7 @@ func (r *AdminRepo) ListProfilesForUser(ctx context.Context, userID uuid.UUID) (
|
||||
var out []ProfileBrief
|
||||
for rows.Next() {
|
||||
var p ProfileBrief
|
||||
if err := rows.Scan(&p.ID, &p.Relation, &p.DisplayName); err != nil {
|
||||
if err := rows.Scan(&p.ID, &p.Relation, &p.DisplayName, &p.BirthDate); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
@@ -188,6 +335,83 @@ func (r *AdminRepo) ListProfilesForUser(ctx context.Context, userID uuid.UUID) (
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ReportBrief for admin user detail.
|
||||
type ReportBrief struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Type string `json:"type"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ListReportsForUser returns recent growth reports.
|
||||
func (r *AdminRepo) ListReportsForUser(ctx context.Context, userID uuid.UUID, limit int) ([]ReportBrief, error) {
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, type, created_at FROM growth_reports
|
||||
WHERE user_id=$1 AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2`, userID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ReportBrief
|
||||
for rows.Next() {
|
||||
var rep ReportBrief
|
||||
if err := rows.Scan(&rep.ID, &rep.Type, &rep.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, rep)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetUserAccount loads phone/nickname/paid ask quota for one user.
|
||||
func (r *AdminRepo) GetUserAccount(ctx context.Context, userID uuid.UUID) (phone, nickname *string, paidLeft int, status string, createdAt time.Time, err error) {
|
||||
err = r.Pool.QueryRow(ctx, `
|
||||
SELECT phone, nickname, ask_paid_quota_left, status, created_at
|
||||
FROM users WHERE id=$1 AND deleted_at IS NULL`, userID,
|
||||
).Scan(&phone, &nickname, &paidLeft, &status, &createdAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil, 0, "", time.Time{}, err
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// GrantAskQuotaWithAudit adds paid ask quota and writes audit.
|
||||
func (r *AdminRepo) GrantAskQuotaWithAudit(ctx context.Context, adminID, userID uuid.UUID, delta int, meta json.RawMessage) (int, error) {
|
||||
if delta <= 0 {
|
||||
return 0, errors.New("delta must be positive")
|
||||
}
|
||||
tx, err := r.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
var left int
|
||||
err = tx.QueryRow(ctx, `
|
||||
UPDATE users SET ask_paid_quota_left = ask_paid_quota_left + $2, updated_at=now()
|
||||
WHERE id=$1 AND deleted_at IS NULL
|
||||
RETURNING ask_paid_quota_left`, userID, delta,
|
||||
).Scan(&left)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if meta == nil {
|
||||
meta = json.RawMessage(`{}`)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta)
|
||||
VALUES ($1,'ask_quota.grant','user',$2,$3)`, adminID, userID.String(), meta); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return left, nil
|
||||
}
|
||||
|
||||
// OrderListItem for admin order tables.
|
||||
type OrderListItem struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// AnalyticsRepo persists behavior events and sessions.
|
||||
type AnalyticsRepo struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// AnalyticsEventRow is one ingest event after validation.
|
||||
type AnalyticsEventRow struct {
|
||||
SessionID string
|
||||
UserID uuid.UUID
|
||||
Name string
|
||||
PagePath string
|
||||
Props json.RawMessage
|
||||
ClientTS time.Time
|
||||
}
|
||||
|
||||
// UpsertSession creates or refreshes a session row.
|
||||
func (r *AnalyticsRepo) UpsertSession(
|
||||
ctx context.Context,
|
||||
sessionID, deviceKey string,
|
||||
userID uuid.UUID,
|
||||
startedAt time.Time,
|
||||
) error {
|
||||
_, err := r.Pool.Exec(ctx, `
|
||||
INSERT INTO analytics_sessions(session_id, device_key, user_id, started_at)
|
||||
VALUES ($1,$2,$3,$4)
|
||||
ON CONFLICT (session_id) DO UPDATE SET
|
||||
device_key = EXCLUDED.device_key,
|
||||
user_id = COALESCE(EXCLUDED.user_id, analytics_sessions.user_id)`,
|
||||
sessionID, deviceKey, userID, startedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// EndSession updates session end fields.
|
||||
func (r *AnalyticsRepo) EndSession(
|
||||
ctx context.Context,
|
||||
sessionID string,
|
||||
endedAt time.Time,
|
||||
exitPage string,
|
||||
durationMs int,
|
||||
) error {
|
||||
_, err := r.Pool.Exec(ctx, `
|
||||
UPDATE analytics_sessions
|
||||
SET ended_at=$2, exit_page=$3, duration_ms=$4
|
||||
WHERE session_id=$1`,
|
||||
sessionID, endedAt, emptyToNil(exitPage), durationMs,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// InsertEvents bulk-inserts event rows.
|
||||
func (r *AnalyticsRepo) InsertEvents(ctx context.Context, rows []AnalyticsEventRow) error {
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, row := range rows {
|
||||
uid := interface{}(nil)
|
||||
if row.UserID != uuid.Nil {
|
||||
uid = row.UserID
|
||||
}
|
||||
page := emptyToNil(row.PagePath)
|
||||
props := row.Props
|
||||
if len(props) == 0 {
|
||||
props = []byte("{}")
|
||||
}
|
||||
if _, err := r.Pool.Exec(ctx, `
|
||||
INSERT INTO analytics_events(session_id, user_id, name, page_path, props, client_ts)
|
||||
VALUES ($1,$2,$3,$4,$5,$6)`,
|
||||
row.SessionID, uid, row.Name, page, props, row.ClientTS,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OverviewAgg is admin overview metrics.
|
||||
type OverviewAgg struct {
|
||||
DAU int `json:"dau"`
|
||||
NewUsers int `json:"new_users"`
|
||||
Sessions int `json:"sessions"`
|
||||
AvgSessionMs float64 `json:"avg_session_ms"`
|
||||
Series []DayPoint `json:"series"`
|
||||
}
|
||||
|
||||
// DayPoint is one day in a trend series.
|
||||
type DayPoint struct {
|
||||
Day string `json:"day"`
|
||||
DAU int `json:"dau"`
|
||||
Sessions int `json:"sessions"`
|
||||
}
|
||||
|
||||
// PageAgg is per-page metrics.
|
||||
type PageAgg struct {
|
||||
PagePath string `json:"page_path"`
|
||||
PV int `json:"pv"`
|
||||
UV int `json:"uv"`
|
||||
AvgDwellMs float64 `json:"avg_dwell_ms"`
|
||||
ExitCount int `json:"exit_count"`
|
||||
}
|
||||
|
||||
// ExitAgg is exit page ranking.
|
||||
type ExitAgg struct {
|
||||
ExitPage string `json:"exit_page"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// ClickAgg is click ranking.
|
||||
type ClickAgg struct {
|
||||
ElementID string `json:"element_id"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// FunnelStep is one named funnel count.
|
||||
type FunnelStep struct {
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
func (r *AnalyticsRepo) Overview(ctx context.Context, from, to time.Time) (*OverviewAgg, error) {
|
||||
out := &OverviewAgg{}
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT count(DISTINCT user_id)::int
|
||||
FROM analytics_events
|
||||
WHERE received_at >= $1 AND received_at < $2 AND user_id IS NOT NULL`,
|
||||
from, to,
|
||||
).Scan(&out.DAU)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = r.Pool.QueryRow(ctx, `
|
||||
SELECT count(*)::int FROM users
|
||||
WHERE created_at >= $1 AND created_at < $2 AND deleted_at IS NULL`,
|
||||
from, to,
|
||||
).Scan(&out.NewUsers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = r.Pool.QueryRow(ctx, `
|
||||
SELECT count(*)::int,
|
||||
COALESCE(avg(duration_ms) FILTER (WHERE duration_ms IS NOT NULL), 0)::float8
|
||||
FROM analytics_sessions
|
||||
WHERE started_at >= $1 AND started_at < $2`,
|
||||
from, to,
|
||||
).Scan(&out.Sessions, &out.AvgSessionMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT to_char(d, 'YYYY-MM-DD') AS day,
|
||||
COALESCE((
|
||||
SELECT count(DISTINCT e.user_id)::int FROM analytics_events e
|
||||
WHERE e.received_at >= d AND e.received_at < d + interval '1 day'
|
||||
AND e.user_id IS NOT NULL
|
||||
), 0) AS dau,
|
||||
COALESCE((
|
||||
SELECT count(*)::int FROM analytics_sessions s
|
||||
WHERE s.started_at >= d AND s.started_at < d + interval '1 day'
|
||||
), 0) AS sessions
|
||||
FROM generate_series($1::timestamptz, $2::timestamptz - interval '1 day', interval '1 day') AS d
|
||||
ORDER BY d`,
|
||||
from, to,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var p DayPoint
|
||||
if err := rows.Scan(&p.Day, &p.DAU, &p.Sessions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.Series = append(out.Series, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *AnalyticsRepo) Pages(ctx context.Context, from, to time.Time) ([]PageAgg, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
WITH views AS (
|
||||
SELECT coalesce(nullif(page_path,''), props->>'page_path') AS path,
|
||||
count(*)::int AS pv,
|
||||
count(DISTINCT user_id)::int AS uv
|
||||
FROM analytics_events
|
||||
WHERE name='page_view' AND received_at >= $1 AND received_at < $2
|
||||
GROUP BY 1
|
||||
),
|
||||
dwells AS (
|
||||
SELECT coalesce(nullif(page_path,''), props->>'page_path') AS path,
|
||||
avg(NULLIF((props->>'dwell_ms')::float8, 'NaN'))::float8 AS avg_dwell
|
||||
FROM analytics_events
|
||||
WHERE name='page_leave' AND received_at >= $1 AND received_at < $2
|
||||
GROUP BY 1
|
||||
),
|
||||
exits AS (
|
||||
SELECT coalesce(nullif(exit_page,''), '') AS path, count(*)::int AS n
|
||||
FROM analytics_sessions
|
||||
WHERE ended_at >= $1 AND ended_at < $2 AND exit_page IS NOT NULL AND exit_page <> ''
|
||||
GROUP BY 1
|
||||
)
|
||||
SELECT coalesce(v.path, d.path, e.path) AS page_path,
|
||||
coalesce(v.pv, 0), coalesce(v.uv, 0),
|
||||
coalesce(d.avg_dwell, 0), coalesce(e.n, 0)
|
||||
FROM views v
|
||||
FULL OUTER JOIN dwells d ON v.path = d.path
|
||||
FULL OUTER JOIN exits e ON coalesce(v.path, d.path) = e.path
|
||||
WHERE coalesce(v.path, d.path, e.path) IS NOT NULL
|
||||
AND coalesce(v.path, d.path, e.path) <> ''
|
||||
ORDER BY coalesce(v.pv, 0) DESC
|
||||
LIMIT 50`,
|
||||
from, to,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []PageAgg
|
||||
for rows.Next() {
|
||||
var p PageAgg
|
||||
if err := rows.Scan(&p.PagePath, &p.PV, &p.UV, &p.AvgDwellMs, &p.ExitCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *AnalyticsRepo) Exits(ctx context.Context, from, to time.Time) ([]ExitAgg, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT exit_page, count(*)::int
|
||||
FROM analytics_sessions
|
||||
WHERE ended_at >= $1 AND ended_at < $2
|
||||
AND exit_page IS NOT NULL AND exit_page <> ''
|
||||
GROUP BY exit_page
|
||||
ORDER BY count(*) DESC
|
||||
LIMIT 30`,
|
||||
from, to,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ExitAgg
|
||||
for rows.Next() {
|
||||
var e ExitAgg
|
||||
if err := rows.Scan(&e.ExitPage, &e.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *AnalyticsRepo) Clicks(ctx context.Context, from, to time.Time) ([]ClickAgg, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT coalesce(props->>'element_id', '') AS eid, count(*)::int
|
||||
FROM analytics_events
|
||||
WHERE name='ui_click' AND received_at >= $1 AND received_at < $2
|
||||
AND coalesce(props->>'element_id','') <> ''
|
||||
GROUP BY 1
|
||||
ORDER BY count(*) DESC
|
||||
LIMIT 30`,
|
||||
from, to,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ClickAgg
|
||||
for rows.Next() {
|
||||
var c ClickAgg
|
||||
if err := rows.Scan(&c.ElementID, &c.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *AnalyticsRepo) Funnel(ctx context.Context, from, to time.Time, names []string) ([]FunnelStep, error) {
|
||||
out := make([]FunnelStep, 0, len(names))
|
||||
for _, name := range names {
|
||||
var n int
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT count(*)::int FROM analytics_events
|
||||
WHERE name=$1 AND received_at >= $2 AND received_at < $3`,
|
||||
name, from, to,
|
||||
).Scan(&n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, FunnelStep{Name: name, Count: n})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func emptyToNil(s string) interface{} {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -102,3 +102,62 @@ func (r *AskRepo) ConsumeMembershipQuota(ctx context.Context, userID uuid.UUID)
|
||||
}
|
||||
return true, left, nil
|
||||
}
|
||||
|
||||
// GetAskPaidQuota returns purchased ask pack remaining.
|
||||
func (r *AskRepo) GetAskPaidQuota(ctx context.Context, userID uuid.UUID) (int, error) {
|
||||
var n int
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT ask_paid_quota_left FROM users WHERE id=$1 AND deleted_at IS NULL`, userID,
|
||||
).Scan(&n)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, nil
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// AddAskPaidQuota increments purchased ask pack remaining.
|
||||
func (r *AskRepo) AddAskPaidQuota(ctx context.Context, userID uuid.UUID, delta int) (int, error) {
|
||||
if delta <= 0 {
|
||||
return 0, errors.New("delta must be positive")
|
||||
}
|
||||
var n int
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
UPDATE users SET ask_paid_quota_left = ask_paid_quota_left + $2, updated_at=now()
|
||||
WHERE id=$1 AND deleted_at IS NULL
|
||||
RETURNING ask_paid_quota_left`, userID, delta,
|
||||
).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// ConsumeAskPaidQuota decrements purchased ask pack remaining.
|
||||
func (r *AskRepo) ConsumeAskPaidQuota(ctx context.Context, userID uuid.UUID) (ok bool, left int, err error) {
|
||||
err = r.Pool.QueryRow(ctx, `
|
||||
UPDATE users SET ask_paid_quota_left = ask_paid_quota_left - 1, updated_at=now()
|
||||
WHERE id=$1 AND deleted_at IS NULL AND ask_paid_quota_left > 0
|
||||
RETURNING ask_paid_quota_left`, userID,
|
||||
).Scan(&left)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return false, 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
return true, left, nil
|
||||
}
|
||||
|
||||
// SoftDeleteThread marks a thread and its messages deleted for the owner.
|
||||
func (r *AskRepo) SoftDeleteThread(ctx context.Context, userID, threadID uuid.UUID) error {
|
||||
ct, err := r.Pool.Exec(ctx, `
|
||||
UPDATE ask_threads SET deleted_at=now(), updated_at=now()
|
||||
WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL`, threadID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ct.RowsAffected() == 0 {
|
||||
return errors.New("thread not found")
|
||||
}
|
||||
_, err = r.Pool.Exec(ctx, `
|
||||
UPDATE ask_messages SET deleted_at=now()
|
||||
WHERE thread_id=$1 AND deleted_at IS NULL`, threadID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// AuthRepo persists account credentials and sessions.
|
||||
type AuthRepo struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// AccountRow is a registered user snapshot.
|
||||
type AccountRow struct {
|
||||
ID uuid.UUID
|
||||
Phone string
|
||||
PasswordHash string
|
||||
Nickname string
|
||||
Status string
|
||||
}
|
||||
|
||||
// GetByPhone loads a registered user by phone.
|
||||
func (r *AuthRepo) GetByPhone(ctx context.Context, phone string) (*AccountRow, error) {
|
||||
row := &AccountRow{}
|
||||
var nick *string
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, phone, password_hash, COALESCE(nickname,''), status
|
||||
FROM users
|
||||
WHERE phone=$1 AND deleted_at IS NULL`, phone,
|
||||
).Scan(&row.ID, &row.Phone, &row.PasswordHash, &nick, &row.Status)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if nick != nil {
|
||||
row.Nickname = *nick
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// GetAccount loads account fields for a user id.
|
||||
func (r *AuthRepo) GetAccount(ctx context.Context, userID uuid.UUID) (*AccountRow, error) {
|
||||
row := &AccountRow{}
|
||||
var phone, hash, nick *string
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, phone, password_hash, nickname, status
|
||||
FROM users WHERE id=$1 AND deleted_at IS NULL`, userID,
|
||||
).Scan(&row.ID, &phone, &hash, &nick, &row.Status)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if phone != nil {
|
||||
row.Phone = *phone
|
||||
}
|
||||
if hash != nil {
|
||||
row.PasswordHash = *hash
|
||||
}
|
||||
if nick != nil {
|
||||
row.Nickname = *nick
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// RegisterOnUser upgrades an anonymous user with phone credentials.
|
||||
func (r *AuthRepo) RegisterOnUser(ctx context.Context, userID uuid.UUID, phone, hash, nickname string) error {
|
||||
tag, err := r.Pool.Exec(ctx, `
|
||||
UPDATE users
|
||||
SET phone=$2, password_hash=$3, nickname=NULLIF($4,''), updated_at=now()
|
||||
WHERE id=$1 AND deleted_at IS NULL AND phone IS NULL`,
|
||||
userID, phone, hash, nickname,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return errString("register conflict")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateUserWithPhone inserts a new registered user.
|
||||
func (r *AuthRepo) CreateUserWithPhone(ctx context.Context, phone, hash, nickname string) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
INSERT INTO users(phone, password_hash, nickname)
|
||||
VALUES ($1,$2,NULLIF($3,''))
|
||||
RETURNING id`,
|
||||
phone, hash, nickname,
|
||||
).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// TouchPassword updates stored password hash (open-login record).
|
||||
func (r *AuthRepo) TouchPassword(ctx context.Context, userID uuid.UUID, hash string) error {
|
||||
_, err := r.Pool.Exec(ctx, `
|
||||
UPDATE users SET password_hash=$2, updated_at=now()
|
||||
WHERE id=$1 AND deleted_at IS NULL`, userID, hash)
|
||||
return err
|
||||
}
|
||||
|
||||
// BindDevice sets device_identities.user_id to account.
|
||||
func (r *AuthRepo) BindDevice(ctx context.Context, deviceKey string, userID uuid.UUID) error {
|
||||
_, err := r.Pool.Exec(ctx, `
|
||||
INSERT INTO device_identities(device_key, user_id)
|
||||
VALUES ($1,$2)
|
||||
ON CONFLICT (device_key) DO UPDATE SET user_id=$2, updated_at=now(), deleted_at=NULL`,
|
||||
deviceKey, userID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// CreateSession inserts a session token.
|
||||
func (r *AuthRepo) CreateSession(ctx context.Context, userID uuid.UUID, token string, expires time.Time) error {
|
||||
_, err := r.Pool.Exec(ctx, `
|
||||
INSERT INTO user_sessions(user_id, token, expires_at) VALUES ($1,$2,$3)`,
|
||||
userID, token, expires,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// UserIDByToken resolves a live session.
|
||||
func (r *AuthRepo) UserIDByToken(ctx context.Context, token string) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT user_id FROM user_sessions
|
||||
WHERE token=$1 AND revoked_at IS NULL AND expires_at > now()`, token,
|
||||
).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// RevokeSession marks token revoked.
|
||||
func (r *AuthRepo) RevokeSession(ctx context.Context, token string) error {
|
||||
_, err := r.Pool.Exec(ctx, `
|
||||
UPDATE user_sessions SET revoked_at=now() WHERE token=$1 AND revoked_at IS NULL`, token)
|
||||
return err
|
||||
}
|
||||
|
||||
// IsRegistered reports whether user has phone.
|
||||
func (r *AuthRepo) IsRegistered(ctx context.Context, userID uuid.UUID) (bool, error) {
|
||||
var ok bool
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM users WHERE id=$1 AND phone IS NOT NULL AND deleted_at IS NULL
|
||||
)`, userID).Scan(&ok)
|
||||
return ok, err
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// HomeTool is one homepage grid entry.
|
||||
type HomeTool struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
RowIndex int `json:"row_index"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
Path string `json:"path"`
|
||||
Icon string `json:"icon"`
|
||||
Label string `json:"label"`
|
||||
Badge *string `json:"badge,omitempty"`
|
||||
BadgeTone *string `json:"badge_tone,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
// HomeToolsRepo persists homepage grid tools.
|
||||
type HomeToolsRepo struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// ListAll returns all tools ordered by row then sort.
|
||||
func (r *HomeToolsRepo) ListAll(ctx context.Context) ([]HomeTool, error) {
|
||||
return r.query(ctx, false)
|
||||
}
|
||||
|
||||
// ListEnabled returns enabled tools for C-end.
|
||||
func (r *HomeToolsRepo) ListEnabled(ctx context.Context) ([]HomeTool, error) {
|
||||
return r.query(ctx, true)
|
||||
}
|
||||
|
||||
func (r *HomeToolsRepo) query(ctx context.Context, onlyEnabled bool) ([]HomeTool, error) {
|
||||
q := `
|
||||
SELECT id, row_index, sort_order, path, icon, label, badge, badge_tone, enabled, updated_at
|
||||
FROM home_tools`
|
||||
if onlyEnabled {
|
||||
q += ` WHERE enabled = true`
|
||||
}
|
||||
q += ` ORDER BY row_index, sort_order, label`
|
||||
rows, err := r.Pool.Query(ctx, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []HomeTool
|
||||
for rows.Next() {
|
||||
var t HomeTool
|
||||
if err := rows.Scan(
|
||||
&t.ID, &t.RowIndex, &t.SortOrder, &t.Path, &t.Icon, &t.Label,
|
||||
&t.Badge, &t.BadgeTone, &t.Enabled, &t.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ReplaceAll deletes all rows and inserts items in one transaction.
|
||||
func (r *HomeToolsRepo) ReplaceAll(ctx context.Context, items []HomeTool) error {
|
||||
return r.ReplaceAllWithAudit(ctx, items, uuid.Nil, nil)
|
||||
}
|
||||
|
||||
// ReplaceAllWithAudit replaces tools and optionally writes admin_audit_logs in one tx.
|
||||
// If adminID is uuid.Nil, skips audit insert.
|
||||
func (r *HomeToolsRepo) ReplaceAllWithAudit(
|
||||
ctx context.Context,
|
||||
items []HomeTool,
|
||||
adminID uuid.UUID,
|
||||
meta json.RawMessage,
|
||||
) error {
|
||||
tx, err := r.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM home_tools`); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, it := range items {
|
||||
id := it.ID
|
||||
if id == uuid.Nil {
|
||||
id = uuid.New()
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO home_tools(id, row_index, sort_order, path, icon, label, badge, badge_tone, enabled, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,now())`,
|
||||
id, it.RowIndex, it.SortOrder, it.Path, it.Icon, it.Label, it.Badge, it.BadgeTone, it.Enabled,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if adminID != uuid.Nil {
|
||||
if meta == nil {
|
||||
meta = json.RawMessage(`{}`)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta)
|
||||
VALUES ($1,'home_tools.replace','home_tools','all',$2)`, adminID, meta); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
@@ -18,16 +18,98 @@ type ReportRepo struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// Create inserts a growth report.
|
||||
// Create inserts a growth report (optional peer for pair reports).
|
||||
func (r *ReportRepo) Create(ctx context.Context, userID, profileID uuid.UUID, typ string, summary, detail json.RawMessage) (*model.GrowthReport, error) {
|
||||
return r.CreateWithPeer(ctx, userID, profileID, nil, typ, summary, detail)
|
||||
}
|
||||
|
||||
// CreateWithPeer inserts a report with optional peer_profile_id.
|
||||
func (r *ReportRepo) CreateWithPeer(ctx context.Context, userID, profileID uuid.UUID, peer *uuid.UUID, typ string, summary, detail json.RawMessage) (*model.GrowthReport, error) {
|
||||
rep := &model.GrowthReport{}
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
INSERT INTO growth_reports(user_id, profile_id, type, summary, detail)
|
||||
VALUES ($1,$2,$3,$4,$5)
|
||||
INSERT INTO growth_reports(user_id, profile_id, peer_profile_id, type, summary, detail)
|
||||
VALUES ($1,$2,$3,$4,$5,$6)
|
||||
RETURNING id, user_id, profile_id, type, summary, detail, created_at`,
|
||||
userID, profileID, typ, summary, detail,
|
||||
userID, profileID, peer, typ, summary, detail,
|
||||
).Scan(&rep.ID, &rep.UserID, &rep.ProfileID, &rep.Type, &rep.Summary, &rep.Detail, &rep.CreatedAt)
|
||||
return rep, err
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rep.PeerProfileID = peer
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// SoftDeleteMatching soft-deletes prior reports for overwrite semantics.
|
||||
func (r *ReportRepo) SoftDeleteMatching(ctx context.Context, userID, profileID uuid.UUID, peer *uuid.UUID, typ string) error {
|
||||
if peer == nil {
|
||||
_, err := r.Pool.Exec(ctx, `
|
||||
UPDATE growth_reports SET deleted_at=now(), updated_at=now()
|
||||
WHERE user_id=$1 AND profile_id=$2 AND type=$3
|
||||
AND peer_profile_id IS NULL AND deleted_at IS NULL`,
|
||||
userID, profileID, typ)
|
||||
return err
|
||||
}
|
||||
_, err := r.Pool.Exec(ctx, `
|
||||
UPDATE growth_reports SET deleted_at=now(), updated_at=now()
|
||||
WHERE user_id=$1 AND type=$2 AND deleted_at IS NULL
|
||||
AND (
|
||||
(profile_id=$3 AND peer_profile_id=$4) OR
|
||||
(profile_id=$4 AND peer_profile_id=$3)
|
||||
)`,
|
||||
userID, typ, profileID, *peer)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpsertWithPeer soft-deletes matching then inserts.
|
||||
func (r *ReportRepo) UpsertWithPeer(ctx context.Context, userID, profileID uuid.UUID, peer *uuid.UUID, typ string, summary, detail json.RawMessage) (*model.GrowthReport, error) {
|
||||
if err := r.SoftDeleteMatching(ctx, userID, profileID, peer, typ); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.CreateWithPeer(ctx, userID, profileID, peer, typ, summary, detail)
|
||||
}
|
||||
|
||||
// GetLatest returns newest non-deleted report for profile+type(+peer).
|
||||
func (r *ReportRepo) GetLatest(ctx context.Context, userID, profileID uuid.UUID, typ string, peer *uuid.UUID) (*model.GrowthReport, error) {
|
||||
rep := &model.GrowthReport{}
|
||||
var peerOut *uuid.UUID
|
||||
var err error
|
||||
if peer == nil {
|
||||
err = r.Pool.QueryRow(ctx, `
|
||||
SELECT id, user_id, profile_id, peer_profile_id, type, summary, detail, created_at
|
||||
FROM growth_reports
|
||||
WHERE user_id=$1 AND profile_id=$2 AND type=$3
|
||||
AND peer_profile_id IS NULL AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
userID, profileID, typ,
|
||||
).Scan(&rep.ID, &rep.UserID, &rep.ProfileID, &peerOut, &rep.Type, &rep.Summary, &rep.Detail, &rep.CreatedAt)
|
||||
} else {
|
||||
err = r.Pool.QueryRow(ctx, `
|
||||
SELECT id, user_id, profile_id, peer_profile_id, type, summary, detail, created_at
|
||||
FROM growth_reports
|
||||
WHERE user_id=$1 AND type=$2 AND deleted_at IS NULL
|
||||
AND (
|
||||
(profile_id=$3 AND peer_profile_id=$4) OR
|
||||
(profile_id=$4 AND peer_profile_id=$3)
|
||||
)
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
userID, typ, profileID, *peer,
|
||||
).Scan(&rep.ID, &rep.UserID, &rep.ProfileID, &peerOut, &rep.Type, &rep.Summary, &rep.Detail, &rep.CreatedAt)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rep.PeerProfileID = peerOut
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// SoftDeleteForProfile marks all reports involving a profile as deleted.
|
||||
func (r *ReportRepo) SoftDeleteForProfile(ctx context.Context, userID, profileID uuid.UUID) error {
|
||||
_, err := r.Pool.Exec(ctx, `
|
||||
UPDATE growth_reports SET deleted_at=now(), updated_at=now()
|
||||
WHERE user_id=$1 AND deleted_at IS NULL
|
||||
AND (profile_id=$2 OR peer_profile_id=$2)`,
|
||||
userID, profileID)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetForUser loads a report owned by user.
|
||||
@@ -188,10 +270,52 @@ func (r *ReportRepo) PayMock(ctx context.Context, userID, orderID uuid.UUID) err
|
||||
userID, p, days); err != nil {
|
||||
return err
|
||||
}
|
||||
case "ask_pack":
|
||||
p := "pack10"
|
||||
if plan != nil && *plan != "" {
|
||||
p = *plan
|
||||
}
|
||||
delta := AskPackQuota(p)
|
||||
if delta <= 0 {
|
||||
return errString("invalid ask_pack plan")
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE users SET ask_paid_quota_left = ask_paid_quota_left + $2, updated_at=now()
|
||||
WHERE id=$1 AND deleted_at IS NULL`, userID, delta); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// AskPackQuota returns how many ask replies a pack plan grants.
|
||||
func AskPackQuota(plan string) int {
|
||||
switch plan {
|
||||
case "pack10":
|
||||
return 10
|
||||
case "pack30":
|
||||
return 30
|
||||
case "pack100":
|
||||
return 100
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// AskPackAmountCents is mock price for an ask pack plan.
|
||||
func AskPackAmountCents(plan string) int {
|
||||
switch plan {
|
||||
case "pack10":
|
||||
return 990
|
||||
case "pack30":
|
||||
return 1980
|
||||
case "pack100":
|
||||
return 4990
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
var errMissingReport = errString("report_id required for deep_access")
|
||||
|
||||
type errString string
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
@@ -62,13 +63,13 @@ func (r *ScaleRepo) ListPublished(ctx context.Context) ([]ScaleListItem, error)
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetBySlug loads scale with questions.
|
||||
// GetBySlug loads a published scale with questions.
|
||||
func (r *ScaleRepo) GetBySlug(ctx context.Context, slug string) (*ScaleDetail, error) {
|
||||
d := &ScaleDetail{Slug: slug}
|
||||
var scaleID uuid.UUID
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, title, description FROM scales
|
||||
WHERE slug=$1 AND deleted_at IS NULL`, slug,
|
||||
WHERE slug=$1 AND status='published' AND deleted_at IS NULL`, slug,
|
||||
).Scan(&scaleID, &d.Title, &d.Description)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -90,6 +91,75 @@ func (r *ScaleRepo) GetBySlug(ctx context.Context, slug string) (*ScaleDetail, e
|
||||
return d, rows.Err()
|
||||
}
|
||||
|
||||
// ScaleAdminItem is a scale row for ops.
|
||||
type ScaleAdminItem struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// ListAllAdmin returns all non-deleted scales.
|
||||
func (r *ScaleRepo) ListAllAdmin(ctx context.Context) ([]ScaleAdminItem, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, slug, title, description, status FROM scales
|
||||
WHERE deleted_at IS NULL ORDER BY created_at`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ScaleAdminItem
|
||||
for rows.Next() {
|
||||
var it ScaleAdminItem
|
||||
if err := rows.Scan(&it.ID, &it.Slug, &it.Title, &it.Description, &it.Status); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, it)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UpdateStatus sets published|draft.
|
||||
func (r *ScaleRepo) UpdateStatus(ctx context.Context, id uuid.UUID, status string) error {
|
||||
return r.UpdateStatusWithAudit(ctx, id, status, uuid.Nil, nil)
|
||||
}
|
||||
|
||||
// UpdateStatusWithAudit updates status and optionally writes audit in one tx.
|
||||
func (r *ScaleRepo) UpdateStatusWithAudit(
|
||||
ctx context.Context,
|
||||
id uuid.UUID,
|
||||
status string,
|
||||
adminID uuid.UUID,
|
||||
meta json.RawMessage,
|
||||
) error {
|
||||
tx, err := r.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE scales SET status=$2, updated_at=now()
|
||||
WHERE id=$1 AND deleted_at IS NULL`, id, status)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return pgx.ErrNoRows
|
||||
}
|
||||
if adminID != uuid.Nil {
|
||||
if meta == nil {
|
||||
meta = json.RawMessage(`{}`)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta)
|
||||
VALUES ($1,'scale.status','scale',$2,$3)`, adminID, id.String(), meta); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// SaveResult stores scoring output.
|
||||
func (r *ScaleRepo) SaveResult(ctx context.Context, userID, scaleID, profileID uuid.UUID, answers, result json.RawMessage) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
@@ -101,10 +171,11 @@ func (r *ScaleRepo) SaveResult(ctx context.Context, userID, scaleID, profileID u
|
||||
return id, err
|
||||
}
|
||||
|
||||
// ScaleIDBySlug resolves id.
|
||||
// ScaleIDBySlug resolves id for a published scale.
|
||||
func (r *ScaleRepo) ScaleIDBySlug(ctx context.Context, slug string) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id FROM scales WHERE slug=$1 AND deleted_at IS NULL`, slug).Scan(&id)
|
||||
SELECT id FROM scales
|
||||
WHERE slug=$1 AND status='published' AND deleted_at IS NULL`, slug).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user