diff --git a/adminanalytics.go b/adminanalytics.go new file mode 100644 index 0000000..170dfe9 --- /dev/null +++ b/adminanalytics.go @@ -0,0 +1,234 @@ +package database + +import ( + "context" + stdjson "encoding/json" + "time" + + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" +) + +type AdminAnalyticsTable struct { + *pgxpool.Pool +} + +func newAdminAnalyticsTable(db *pgxpool.Pool) *AdminAnalyticsTable { + return &AdminAnalyticsTable{db} +} + +func (a AdminAnalyticsTable) Schema() string { + return ` +CREATE MATERIALIZED VIEW IF NOT EXISTS mv_admin_usage AS +SELECT + COUNT(*) FILTER (WHERE open_time > NOW() - INTERVAL '1 day') AS tickets_created_today, + COUNT(DISTINCT guild_id) FILTER (WHERE open_time > NOW() - INTERVAL '1 day') AS active_guilds_daily, + COUNT(DISTINCT guild_id) FILTER (WHERE open_time > NOW() - INTERVAL '7 days') AS active_guilds_weekly, + COUNT(DISTINCT guild_id) FILTER (WHERE open_time > NOW() - INTERVAL '30 days') AS active_guilds_monthly, + COUNT(DISTINCT guild_id) AS total_guilds +FROM tickets; + +CREATE MATERIALIZED VIEW IF NOT EXISTS mv_admin_tickets_per_day AS +SELECT date_trunc('day', open_time AT TIME ZONE 'UTC')::date AS date, COUNT(*)::int AS count +FROM tickets +WHERE open_time > NOW() - INTERVAL '90 days' +GROUP BY 1 +ORDER BY 1; + +CREATE MATERIALIZED VIEW IF NOT EXISTS mv_admin_feature_adoption AS +SELECT 'Panels' AS feature, COUNT(DISTINCT guild_id)::int AS guild_count FROM panels +UNION ALL SELECT 'Multipanels', COUNT(DISTINCT guild_id)::int FROM multi_panels +UNION ALL SELECT 'Forms', COUNT(DISTINCT guild_id)::int FROM forms +UNION ALL SELECT 'Teams', COUNT(DISTINCT guild_id)::int FROM support_team +UNION ALL SELECT 'Labels', COUNT(DISTINCT guild_id)::int FROM ticket_labels +UNION ALL SELECT 'Feedback', COUNT(DISTINCT guild_id)::int FROM service_ratings +UNION ALL SELECT 'Integrations', COUNT(DISTINCT guild_id)::int FROM custom_integration_guilds +UNION ALL SELECT 'Close Reasons', COUNT(DISTINCT guild_id)::int FROM close_reason WHERE close_reason IS NOT NULL; + +CREATE MATERIALIZED VIEW IF NOT EXISTS mv_admin_retention AS +WITH active AS ( + SELECT DISTINCT guild_id FROM tickets WHERE open_time > NOW() - INTERVAL '30 days' +), previously_active AS ( + SELECT DISTINCT guild_id FROM tickets + WHERE open_time > NOW() - INTERVAL '60 days' AND open_time <= NOW() - INTERVAL '30 days' +), churned AS ( + SELECT pa.guild_id, (SELECT MAX(open_time) FROM tickets t WHERE t.guild_id = pa.guild_id) AS last_ticket_time + FROM previously_active pa + WHERE NOT EXISTS (SELECT 1 FROM active a WHERE a.guild_id = pa.guild_id) +) +SELECT + (SELECT COUNT(*) FROM active) AS active_guilds_30d, + (SELECT COUNT(*) FROM churned) AS churned_guilds_30d, + COALESCE(json_agg(json_build_object('guild_id', c.guild_id, 'last_ticket_time', c.last_ticket_time) ORDER BY c.last_ticket_time DESC) FILTER (WHERE c.guild_id IS NOT NULL), '[]'::json) AS recently_churned +FROM (SELECT * FROM churned ORDER BY last_ticket_time DESC LIMIT 50) c; + +CREATE MATERIALIZED VIEW IF NOT EXISTS mv_admin_config_patterns AS +SELECT setting, value, count FROM ( + SELECT 'Feedback Enabled' AS setting, 'Yes' AS value, COUNT(DISTINCT guild_id)::int AS count FROM service_ratings + UNION ALL + SELECT 'Panels per Guild', panel_bucket, COUNT(*)::int FROM ( + SELECT CASE + WHEN cnt = 1 THEN '1' + WHEN cnt <= 3 THEN '2-3' + WHEN cnt <= 5 THEN '4-5' + WHEN cnt <= 10 THEN '6-10' + ELSE '11+' + END AS panel_bucket + FROM (SELECT guild_id, COUNT(*)::int AS cnt FROM panels GROUP BY guild_id) sub + ) bucketed GROUP BY panel_bucket +) combined; + +CREATE UNIQUE INDEX IF NOT EXISTS mv_admin_tickets_per_day_date ON mv_admin_tickets_per_day(date); +CREATE UNIQUE INDEX IF NOT EXISTS mv_admin_feature_adoption_feature ON mv_admin_feature_adoption(feature); +CREATE UNIQUE INDEX IF NOT EXISTS mv_admin_config_patterns_idx ON mv_admin_config_patterns(setting, value); +` +} + +func (a *AdminAnalyticsTable) RefreshViews(ctx context.Context) error { + views := []string{ + "REFRESH MATERIALIZED VIEW CONCURRENTLY mv_admin_tickets_per_day", + "REFRESH MATERIALIZED VIEW CONCURRENTLY mv_admin_feature_adoption", + "REFRESH MATERIALIZED VIEW CONCURRENTLY mv_admin_config_patterns", + "REFRESH MATERIALIZED VIEW mv_admin_usage", + "REFRESH MATERIALIZED VIEW mv_admin_retention", + } + + for _, q := range views { + if _, err := a.Exec(ctx, q); err != nil { + return err + } + } + + return nil +} + +type GlobalUsageMetrics struct { + TicketsCreatedToday int `json:"tickets_created_today"` + ActiveGuildsDaily int `json:"active_guilds_daily"` + ActiveGuildsWeekly int `json:"active_guilds_weekly"` + ActiveGuildsMonthly int `json:"active_guilds_monthly"` + TotalGuilds int `json:"total_guilds"` +} + +func (a *AdminAnalyticsTable) GetGlobalUsageMetrics(ctx context.Context) (metrics GlobalUsageMetrics, e error) { + query := `SELECT tickets_created_today, active_guilds_daily, active_guilds_weekly, active_guilds_monthly, total_guilds FROM mv_admin_usage;` + err := a.QueryRow(ctx, query).Scan( + &metrics.TicketsCreatedToday, + &metrics.ActiveGuildsDaily, + &metrics.ActiveGuildsWeekly, + &metrics.ActiveGuildsMonthly, + &metrics.TotalGuilds, + ) + if err != nil && err != pgx.ErrNoRows { + e = err + } + return +} + +type GlobalTicketsPerDay struct { + Date time.Time `json:"date"` + Count int `json:"count"` +} + +func (a *AdminAnalyticsTable) GetGlobalTicketsPerDay(ctx context.Context) ([]GlobalTicketsPerDay, error) { + query := `SELECT date, count FROM mv_admin_tickets_per_day ORDER BY date;` + rows, err := a.Query(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []GlobalTicketsPerDay + for rows.Next() { + var r GlobalTicketsPerDay + if err := rows.Scan(&r.Date, &r.Count); err != nil { + return nil, err + } + results = append(results, r) + } + return results, nil +} + +type FeatureAdoption struct { + Feature string `json:"feature"` + GuildCount int `json:"guild_count"` +} + +func (a *AdminAnalyticsTable) GetFeatureAdoption(ctx context.Context) ([]FeatureAdoption, error) { + query := `SELECT feature, guild_count FROM mv_admin_feature_adoption ORDER BY guild_count DESC;` + rows, err := a.Query(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []FeatureAdoption + for rows.Next() { + var r FeatureAdoption + if err := rows.Scan(&r.Feature, &r.GuildCount); err != nil { + return nil, err + } + results = append(results, r) + } + return results, nil +} + +type ChurnedGuild struct { + GuildId uint64 `json:"guild_id"` + LastTicketTime time.Time `json:"last_ticket_time"` +} + +type RetentionMetrics struct { + ActiveGuilds30d int `json:"active_guilds_30d"` + ChurnedGuilds30d int `json:"churned_guilds_30d"` + RecentlyChurned []ChurnedGuild `json:"recently_churned"` +} + +func (a *AdminAnalyticsTable) GetRetentionMetrics(ctx context.Context) (metrics RetentionMetrics, e error) { + var churnedJson []byte + query := `SELECT active_guilds_30d, churned_guilds_30d, recently_churned::text FROM mv_admin_retention;` + err := a.QueryRow(ctx, query).Scan(&metrics.ActiveGuilds30d, &metrics.ChurnedGuilds30d, &churnedJson) + if err != nil { + if err == pgx.ErrNoRows { + metrics.RecentlyChurned = make([]ChurnedGuild, 0) + return metrics, nil + } + return metrics, err + } + + if len(churnedJson) > 0 { + if err := stdjson.Unmarshal(churnedJson, &metrics.RecentlyChurned); err != nil { + return metrics, err + } + } + + if metrics.RecentlyChurned == nil { + metrics.RecentlyChurned = make([]ChurnedGuild, 0) + } + return +} + +type ConfigPatternEntry struct { + Setting string `json:"setting"` + Value string `json:"value"` + Count int `json:"count"` +} + +func (a *AdminAnalyticsTable) GetConfigPatterns(ctx context.Context) ([]ConfigPatternEntry, error) { + query := `SELECT setting, value, count FROM mv_admin_config_patterns ORDER BY setting, count DESC;` + rows, err := a.Query(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []ConfigPatternEntry + for rows.Next() { + var r ConfigPatternEntry + if err := rows.Scan(&r.Setting, &r.Value, &r.Count); err != nil { + return nil, err + } + results = append(results, r) + } + return results, nil +} diff --git a/affiliatecodes.go b/affiliatecodes.go new file mode 100644 index 0000000..dd9469d --- /dev/null +++ b/affiliatecodes.go @@ -0,0 +1,175 @@ +package database + +import ( + "context" + _ "embed" + "errors" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" +) + +type AffiliateCodes struct { + *pgxpool.Pool +} + +type AffiliateCode struct { + Id uuid.UUID `json:"id"` + UserId uint64 `json:"user_id"` + Code string `json:"code"` + PolarDiscountId *string `json:"polar_discount_id"` + Status string `json:"status"` + DiscountBasisPoints int `json:"discount_basis_points"` + CreditPercentage *int `json:"credit_percentage"` + CreatedAt time.Time `json:"created_at"` + ApprovedAt *time.Time `json:"approved_at"` + ApprovedBy *uint64 `json:"approved_by"` + RevokedAt *time.Time `json:"revoked_at"` +} + +var ( + //go:embed sql/affiliate_codes/schema.sql + affiliateCodesSchema string + + //go:embed sql/affiliate_codes/create.sql + affiliateCodesCreate string + + //go:embed sql/affiliate_codes/get_by_id.sql + affiliateCodesGetById string + + //go:embed sql/affiliate_codes/get_by_code.sql + affiliateCodesGetByCode string + + //go:embed sql/affiliate_codes/get_by_user_id.sql + affiliateCodesGetByUserId string + + //go:embed sql/affiliate_codes/get_by_polar_discount_id.sql + affiliateCodesGetByPolarDiscountId string + + //go:embed sql/affiliate_codes/list_all.sql + affiliateCodesListAll string + + //go:embed sql/affiliate_codes/count_all.sql + affiliateCodesCountAll string + + //go:embed sql/affiliate_codes/update_status.sql + affiliateCodesUpdateStatus string + + //go:embed sql/affiliate_codes/set_polar_discount_id.sql + affiliateCodesSetPolarDiscountId string + + //go:embed sql/affiliate_codes/update_rates.sql + affiliateCodesUpdateRates string + + //go:embed sql/affiliate_codes/update_code.sql + affiliateCodesUpdateCode string +) + +func newAffiliateCodes(db *pgxpool.Pool) *AffiliateCodes { + return &AffiliateCodes{db} +} + +func (AffiliateCodes) Schema() string { + return affiliateCodesSchema +} + +func (t *AffiliateCodes) Create(ctx context.Context, userId uint64, code, status string, discountBasisPoints int, creditPercentage *int) (AffiliateCode, error) { + var id uuid.UUID + var createdAt time.Time + if err := t.QueryRow(ctx, affiliateCodesCreate, userId, code, status, discountBasisPoints, creditPercentage).Scan(&id, &createdAt); err != nil { + return AffiliateCode{}, err + } + + return AffiliateCode{ + Id: id, + UserId: userId, + Code: code, + Status: status, + DiscountBasisPoints: discountBasisPoints, + CreditPercentage: creditPercentage, + CreatedAt: createdAt, + }, nil +} + +func (t *AffiliateCodes) scanRow(row pgx.Row) (*AffiliateCode, error) { + var ac AffiliateCode + if err := row.Scan( + &ac.Id, &ac.UserId, &ac.Code, &ac.PolarDiscountId, &ac.Status, + &ac.DiscountBasisPoints, &ac.CreditPercentage, + &ac.CreatedAt, &ac.ApprovedAt, &ac.ApprovedBy, &ac.RevokedAt, + ); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &ac, nil +} + +func (t *AffiliateCodes) GetById(ctx context.Context, id uuid.UUID) (*AffiliateCode, error) { + return t.scanRow(t.QueryRow(ctx, affiliateCodesGetById, id)) +} + +func (t *AffiliateCodes) GetByCode(ctx context.Context, code string) (*AffiliateCode, error) { + return t.scanRow(t.QueryRow(ctx, affiliateCodesGetByCode, code)) +} + +func (t *AffiliateCodes) GetByUserId(ctx context.Context, userId uint64) (*AffiliateCode, error) { + return t.scanRow(t.QueryRow(ctx, affiliateCodesGetByUserId, userId)) +} + +func (t *AffiliateCodes) GetByPolarDiscountId(ctx context.Context, polarDiscountId string) (*AffiliateCode, error) { + return t.scanRow(t.QueryRow(ctx, affiliateCodesGetByPolarDiscountId, polarDiscountId)) +} + +func (t *AffiliateCodes) ListAll(ctx context.Context, status *string, limit, offset int) ([]AffiliateCode, error) { + rows, err := t.Query(ctx, affiliateCodesListAll, status, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var codes []AffiliateCode + for rows.Next() { + var ac AffiliateCode + if err := rows.Scan( + &ac.Id, &ac.UserId, &ac.Code, &ac.PolarDiscountId, &ac.Status, + &ac.DiscountBasisPoints, &ac.CreditPercentage, + &ac.CreatedAt, &ac.ApprovedAt, &ac.ApprovedBy, &ac.RevokedAt, + ); err != nil { + return nil, err + } + codes = append(codes, ac) + } + return codes, nil +} + +func (t *AffiliateCodes) CountAll(ctx context.Context, status *string) (int, error) { + var count int + if err := t.QueryRow(ctx, affiliateCodesCountAll, status).Scan(&count); err != nil { + return 0, err + } + return count, nil +} + +func (t *AffiliateCodes) UpdateStatus(ctx context.Context, id uuid.UUID, status string, approvedBy *uint64) error { + _, err := t.Exec(ctx, affiliateCodesUpdateStatus, id, status, approvedBy) + return err +} + +func (t *AffiliateCodes) SetPolarDiscountId(ctx context.Context, id uuid.UUID, polarDiscountId string) error { + _, err := t.Exec(ctx, affiliateCodesSetPolarDiscountId, id, polarDiscountId) + return err +} + +func (t *AffiliateCodes) UpdateRates(ctx context.Context, id uuid.UUID, discountBasisPoints int, creditPercentage *int) error { + _, err := t.Exec(ctx, affiliateCodesUpdateRates, id, discountBasisPoints, creditPercentage) + return err +} + +func (t *AffiliateCodes) UpdateCode(ctx context.Context, id uuid.UUID, code string) error { + _, err := t.Exec(ctx, affiliateCodesUpdateCode, id, code) + return err +} diff --git a/affiliatereferrals.go b/affiliatereferrals.go new file mode 100644 index 0000000..c3e8548 --- /dev/null +++ b/affiliatereferrals.go @@ -0,0 +1,282 @@ +package database + +import ( + "context" + _ "embed" + "errors" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" +) + +type AffiliateReferrals struct { + *pgxpool.Pool +} + +type AffiliateReferral struct { + Id uuid.UUID `json:"id"` + AffiliateCodeId uuid.UUID `json:"affiliate_code_id"` + AffiliateUserId uint64 `json:"affiliate_user_id"` + ReferredUserId uint64 `json:"referred_user_id"` + PolarSubscriptionId string `json:"polar_subscription_id"` + ReferredTier string `json:"referred_tier"` + ReferredSkuId uuid.UUID `json:"referred_sku_id"` + PurchasedDays int `json:"purchased_days"` + CreditDays int `json:"credit_days"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + RedeemableAt time.Time `json:"redeemable_at"` + RedeemedAt *time.Time `json:"redeemed_at"` + EntitlementId *uuid.UUID `json:"entitlement_id"` + VoidedAt *time.Time `json:"voided_at"` + VoidedReason *string `json:"voided_reason"` +} + +var ( + //go:embed sql/affiliate_referrals/schema.sql + affiliateReferralsSchema string + + //go:embed sql/affiliate_referrals/create.sql + affiliateReferralsCreate string + + //go:embed sql/affiliate_referrals/get_by_id.sql + affiliateReferralsGetById string + + //go:embed sql/affiliate_referrals/get_by_subscription_id.sql + affiliateReferralsGetBySubscriptionId string + + //go:embed sql/affiliate_referrals/list_by_affiliate_user_id.sql + affiliateReferralsListByAffiliateUserId string + + //go:embed sql/affiliate_referrals/count_by_affiliate_user_id.sql + affiliateReferralsCountByAffiliateUserId string + + //go:embed sql/affiliate_referrals/list_redeemable.sql + affiliateReferralsListRedeemable string + + //go:embed sql/affiliate_referrals/update_status.sql + affiliateReferralsUpdateStatus string + + //go:embed sql/affiliate_referrals/set_entitlement_id.sql + affiliateReferralsSetEntitlementId string + + //go:embed sql/affiliate_referrals/void_by_subscription_id.sql + affiliateReferralsVoidBySubscriptionId string + + //go:embed sql/affiliate_referrals/flag_by_subscription_id.sql + affiliateReferralsFlagBySubscriptionId string + + //go:embed sql/affiliate_referrals/sum_redeemable_days.sql + affiliateReferralsSumRedeemableDays string + + //go:embed sql/affiliate_referrals/count_pending.sql + affiliateReferralsCountPending string + + //go:embed sql/affiliate_referrals/sum_redeemed_days.sql + affiliateReferralsSumRedeemedDays string + + //go:embed sql/affiliate_referrals/list_flagged.sql + affiliateReferralsListFlagged string + + //go:embed sql/affiliate_referrals/count_flagged.sql + affiliateReferralsCountFlagged string +) + +func newAffiliateReferrals(db *pgxpool.Pool) *AffiliateReferrals { + return &AffiliateReferrals{db} +} + +func (AffiliateReferrals) Schema() string { + return affiliateReferralsSchema +} + +func (t *AffiliateReferrals) scanRow(row pgx.Row) (*AffiliateReferral, error) { + var r AffiliateReferral + if err := row.Scan( + &r.Id, &r.AffiliateCodeId, &r.AffiliateUserId, &r.ReferredUserId, + &r.PolarSubscriptionId, &r.ReferredTier, &r.ReferredSkuId, + &r.PurchasedDays, &r.CreditDays, &r.Status, + &r.CreatedAt, &r.RedeemableAt, &r.RedeemedAt, + &r.EntitlementId, &r.VoidedAt, &r.VoidedReason, + ); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &r, nil +} + +func (t *AffiliateReferrals) scanRows(rows pgx.Rows) ([]AffiliateReferral, error) { + defer rows.Close() + + var referrals []AffiliateReferral + for rows.Next() { + var r AffiliateReferral + if err := rows.Scan( + &r.Id, &r.AffiliateCodeId, &r.AffiliateUserId, &r.ReferredUserId, + &r.PolarSubscriptionId, &r.ReferredTier, &r.ReferredSkuId, + &r.PurchasedDays, &r.CreditDays, &r.Status, + &r.CreatedAt, &r.RedeemableAt, &r.RedeemedAt, + &r.EntitlementId, &r.VoidedAt, &r.VoidedReason, + ); err != nil { + return nil, err + } + referrals = append(referrals, r) + } + return referrals, nil +} + +func (t *AffiliateReferrals) Create( + ctx context.Context, + affiliateCodeId uuid.UUID, + affiliateUserId, referredUserId uint64, + polarSubscriptionId, referredTier string, + referredSkuId uuid.UUID, + purchasedDays, creditDays int, + status string, + redeemableAt time.Time, +) (AffiliateReferral, error) { + var id uuid.UUID + var createdAt time.Time + if err := t.QueryRow(ctx, affiliateReferralsCreate, + affiliateCodeId, affiliateUserId, referredUserId, + polarSubscriptionId, referredTier, referredSkuId, + purchasedDays, creditDays, status, redeemableAt, + ).Scan(&id, &createdAt); err != nil { + return AffiliateReferral{}, err + } + + return AffiliateReferral{ + Id: id, + AffiliateCodeId: affiliateCodeId, + AffiliateUserId: affiliateUserId, + ReferredUserId: referredUserId, + PolarSubscriptionId: polarSubscriptionId, + ReferredTier: referredTier, + ReferredSkuId: referredSkuId, + PurchasedDays: purchasedDays, + CreditDays: creditDays, + Status: status, + CreatedAt: createdAt, + RedeemableAt: redeemableAt, + }, nil +} + +func (t *AffiliateReferrals) GetById(ctx context.Context, id uuid.UUID) (*AffiliateReferral, error) { + return t.scanRow(t.QueryRow(ctx, affiliateReferralsGetById, id)) +} + +func (t *AffiliateReferrals) GetBySubscriptionId(ctx context.Context, polarSubscriptionId string) (*AffiliateReferral, error) { + return t.scanRow(t.QueryRow(ctx, affiliateReferralsGetBySubscriptionId, polarSubscriptionId)) +} + +func (t *AffiliateReferrals) ListByAffiliateUserId(ctx context.Context, affiliateUserId uint64, limit, offset int) ([]AffiliateReferral, error) { + rows, err := t.Query(ctx, affiliateReferralsListByAffiliateUserId, affiliateUserId, limit, offset) + if err != nil { + return nil, err + } + return t.scanRows(rows) +} + +func (t *AffiliateReferrals) CountByAffiliateUserId(ctx context.Context, affiliateUserId uint64) (int, error) { + var count int + if err := t.QueryRow(ctx, affiliateReferralsCountByAffiliateUserId, affiliateUserId).Scan(&count); err != nil { + return 0, err + } + return count, nil +} + +func (t *AffiliateReferrals) ListRedeemable(ctx context.Context, tx pgx.Tx, affiliateUserId uint64) ([]AffiliateReferral, error) { + rows, err := tx.Query(ctx, affiliateReferralsListRedeemable, affiliateUserId) + if err != nil { + return nil, err + } + defer rows.Close() + + var referrals []AffiliateReferral + for rows.Next() { + var r AffiliateReferral + if err := rows.Scan( + &r.Id, &r.AffiliateCodeId, &r.AffiliateUserId, &r.ReferredUserId, + &r.PolarSubscriptionId, &r.ReferredTier, &r.ReferredSkuId, + &r.PurchasedDays, &r.CreditDays, &r.Status, + &r.CreatedAt, &r.RedeemableAt, &r.RedeemedAt, + &r.EntitlementId, &r.VoidedAt, &r.VoidedReason, + ); err != nil { + return nil, err + } + referrals = append(referrals, r) + } + return referrals, nil +} + +func (t *AffiliateReferrals) UpdateStatus(ctx context.Context, tx pgx.Tx, id uuid.UUID, status string) error { + _, err := tx.Exec(ctx, affiliateReferralsUpdateStatus, id, status) + return err +} + +func (t *AffiliateReferrals) SetEntitlementId(ctx context.Context, tx pgx.Tx, id uuid.UUID, entitlementId uuid.UUID) error { + _, err := tx.Exec(ctx, affiliateReferralsSetEntitlementId, id, entitlementId) + return err +} + +func (t *AffiliateReferrals) VoidBySubscriptionId(ctx context.Context, polarSubscriptionId, reason string) error { + _, err := t.Exec(ctx, affiliateReferralsVoidBySubscriptionId, polarSubscriptionId, reason) + return err +} + +func (t *AffiliateReferrals) FlagBySubscriptionId(ctx context.Context, polarSubscriptionId, reason string) error { + _, err := t.Exec(ctx, affiliateReferralsFlagBySubscriptionId, polarSubscriptionId, reason) + return err +} + +func (t *AffiliateReferrals) SumRedeemedByUser(ctx context.Context, affiliateUserId uint64) (int, error) { + var sum int + if err := t.QueryRow(ctx, affiliateReferralsSumRedeemedDays, affiliateUserId).Scan(&sum); err != nil { + return 0, err + } + return sum, nil +} + +func (t *AffiliateReferrals) SumRedeemedByUserTx(ctx context.Context, tx pgx.Tx, affiliateUserId uint64) (int, error) { + var sum int + if err := tx.QueryRow(ctx, affiliateReferralsSumRedeemedDays, affiliateUserId).Scan(&sum); err != nil { + return 0, err + } + return sum, nil +} + +func (t *AffiliateReferrals) ListFlagged(ctx context.Context, limit, offset int) ([]AffiliateReferral, error) { + rows, err := t.Query(ctx, affiliateReferralsListFlagged, limit, offset) + if err != nil { + return nil, err + } + return t.scanRows(rows) +} + +func (t *AffiliateReferrals) CountFlagged(ctx context.Context) (int, error) { + var count int + if err := t.QueryRow(ctx, affiliateReferralsCountFlagged).Scan(&count); err != nil { + return 0, err + } + return count, nil +} + +func (t *AffiliateReferrals) SumRedeemableByUser(ctx context.Context, affiliateUserId uint64) (int, error) { + var sum int + if err := t.QueryRow(ctx, affiliateReferralsSumRedeemableDays, affiliateUserId).Scan(&sum); err != nil { + return 0, err + } + return sum, nil +} + +func (t *AffiliateReferrals) CountPending(ctx context.Context, affiliateUserId uint64) (int, error) { + var count int + if err := t.QueryRow(ctx, affiliateReferralsCountPending, affiliateUserId).Scan(&count); err != nil { + return 0, err + } + return count, nil +} diff --git a/analyticstypes.go b/analyticstypes.go new file mode 100644 index 0000000..002f61b --- /dev/null +++ b/analyticstypes.go @@ -0,0 +1,80 @@ +package database + +import "time" + +type TripleWindow struct { + AllTime *time.Duration + Monthly *time.Duration + Weekly *time.Duration +} + +// MetricWindows extends TripleWindow with a selected-range aggregate. The +// selected window honours the exact days parameter from the query string, +// replacing the bucketed approach that previously picked the nearest fixed +// window and silently mislabelled it. +type MetricWindows struct { + Selected *time.Duration // equals AllTime when days == 0 + AllTime *time.Duration + Monthly *time.Duration + Weekly *time.Duration +} + +// TripleWindow drops the selected window, for callers that only need the +// three fixed buckets (the worker's /stats server command). +func (mw MetricWindows) TripleWindow() TripleWindow { + return TripleWindow{ + AllTime: mw.AllTime, + Monthly: mw.Monthly, + Weekly: mw.Weekly, + } +} + +type CountOnDate struct { + Date time.Time `json:"date"` + Count uint64 `json:"count"` +} + +type PanelTicketCount struct { + PanelId *int `json:"panel_id"` + PanelTitle string `json:"panel_title"` + Count int `json:"count"` +} + +type LabelTicketCount struct { + LabelId int `json:"label_id"` + Name string `json:"name"` + Colour int32 `json:"colour"` + Count int `json:"count"` +} + +type ThreadChannelSplit struct { + ThreadCount int `json:"thread_count"` + ChannelCount int `json:"channel_count"` +} + +type FeedbackResponseRate struct { + ClosedTickets int `json:"closed_tickets"` + RatedTickets int `json:"rated_tickets"` + Rate float64 `json:"rate"` +} + +type AutoCloseStats struct { + AutoClosed int `json:"auto_closed"` + ManualClosed int `json:"manual_closed"` +} + +type PeakHourEntry struct { + DayOfWeek int `json:"day_of_week"` + HourOfDay int `json:"hour_of_day"` + Count int `json:"count"` +} + +type SourceBreakdown struct { + Source TicketSource `json:"source"` + Count int `json:"count"` +} + +type ResponseTimeByHour struct { + HourOfDay int `json:"hour_of_day"` + AvgResponseTime *time.Duration `json:"avg_response_time"` +} diff --git a/archivechannel.go b/archivechannel.go deleted file mode 100644 index c4f2132..0000000 --- a/archivechannel.go +++ /dev/null @@ -1,75 +0,0 @@ -package database - -import ( - "context" - - "github.com/jackc/pgx/v4" - "github.com/jackc/pgx/v4/pgxpool" -) - -type ArchiveChannel struct { - *pgxpool.Pool -} - -func newArchiveChannel(db *pgxpool.Pool) *ArchiveChannel { - return &ArchiveChannel{ - db, - } -} - -func (c ArchiveChannel) Schema() string { - return ` -CREATE TABLE IF NOT EXISTS archive_channel( - "guild_id" int8 NOT NULL UNIQUE, - "channel_id" int8, - PRIMARY KEY("guild_id") -);` -} - -func (c *ArchiveChannel) Get(ctx context.Context, guildId uint64) (archiveChannel *uint64, e error) { - query := `SELECT "channel_id" from archive_channel WHERE "guild_id" = $1;` - - if err := c.QueryRow(ctx, query, guildId).Scan(&archiveChannel); err != nil && err != pgx.ErrNoRows { - e = err - } - - return -} - -func (c *ArchiveChannel) GetByPanel(ctx context.Context, guildId uint64, panelId int) (archiveChannel *uint64, e error) { - query := ` - SELECT - COALESCE(p.transcript_channel_id, ac.channel_id) - FROM - panels p - JOIN archive_channel ac ON ac.guild_id = p.guild_id - WHERE p.panel_id = $1 AND p.guild_id = $2; -` - - if err := c.QueryRow(ctx, query, panelId, guildId).Scan(&archiveChannel); err != nil && err != pgx.ErrNoRows { - e = err - } - - return -} - -func (c *ArchiveChannel) Set(ctx context.Context, guildId uint64, archiveChannel *uint64) (err error) { - query := ` -INSERT INTO archive_channel("guild_id", "channel_id") -VALUES($1, $2) -ON CONFLICT("guild_id") DO UPDATE SET "channel_id" = $2; -` - - _, err = c.Exec(ctx, query, guildId, archiveChannel) - return -} - -func (c *ArchiveChannel) DeleteByGuild(ctx context.Context, guildId uint64) (err error) { - _, err = c.Exec(ctx, `DELETE FROM archive_channel WHERE "guild_id" = $1;`, guildId) - return -} - -func (c *ArchiveChannel) DeleteByChannel(ctx context.Context, channelId uint64) (err error) { - _, err = c.Exec(ctx, `DELETE FROM archive_channel WHERE "channel_id" = $1;`, channelId) - return -} diff --git a/auditlog.go b/auditlog.go index 4c1949e..176defd 100644 --- a/auditlog.go +++ b/auditlog.go @@ -56,6 +56,10 @@ const ( AuditActionTicketSendTag AuditActionType = 91 AuditActionTicketClose AuditActionType = 92 AuditActionTicketCloseReasonUpdate AuditActionType = 93 + AuditActionTicketCloseRequest AuditActionType = 94 + AuditActionTicketClaim AuditActionType = 95 + AuditActionTicketUnclaim AuditActionType = 96 + AuditActionTicketTransfer AuditActionType = 97 AuditActionGuildIntegrationActivate AuditActionType = 100 AuditActionGuildIntegrationUpdate AuditActionType = 101 @@ -75,15 +79,89 @@ const ( AuditActionUserIntegrationUpdate AuditActionType = 201 AuditActionUserIntegrationDelete AuditActionType = 202 AuditActionUserIntegrationSetPublic AuditActionType = 203 + AuditActionUserIntegrationApprove AuditActionType = 204 + AuditActionUserIntegrationReject AuditActionType = 205 + AuditActionUserIntegrationUnapprove AuditActionType = 206 AuditActionWhitelabelCreate AuditActionType = 210 AuditActionWhitelabelDelete AuditActionType = 211 AuditActionWhitelabelCreateInteractions AuditActionType = 212 AuditActionWhitelabelStatusSet AuditActionType = 213 AuditActionWhitelabelStatusDelete AuditActionType = 214 + AuditActionWhitelabelResync AuditActionType = 215 + + AuditActionBotStaffAdd AuditActionType = 300 + AuditActionBotStaffRemove AuditActionType = 301 + AuditActionBotStaffTierUpdate AuditActionType = 302 + AuditActionBotStaffGlobalViewUpdate AuditActionType = 303 + + AuditActionGlobalBlacklistAdd AuditActionType = 310 + AuditActionGlobalBlacklistRemove AuditActionType = 311 + + AuditActionServerBlacklistAdd AuditActionType = 320 + AuditActionServerBlacklistRemove AuditActionType = 321 + + AuditActionPremiumKeyGenerate AuditActionType = 330 + + AuditActionPolarCheckoutCreate AuditActionType = 340 + AuditActionPolarSubscriptionCancel AuditActionType = 341 + AuditActionPolarSubscriptionChange AuditActionType = 342 + + AuditActionPolarProductCreate AuditActionType = 350 + AuditActionPolarProductUpdate AuditActionType = 351 + AuditActionPolarProductDelete AuditActionType = 352 + + AuditActionSkuCreate AuditActionType = 360 + AuditActionSkuUpdate AuditActionType = 361 + AuditActionSkuDelete AuditActionType = 362 + + AuditActionKBArticleCreate AuditActionType = 370 + AuditActionKBArticleUpdate AuditActionType = 371 + AuditActionKBArticleDelete AuditActionType = 372 + AuditActionKBCategoryCreate AuditActionType = 380 + AuditActionKBCategoryUpdate AuditActionType = 381 + AuditActionKBCategoryDelete AuditActionType = 382 + + AuditActionKBSettingsUpdate AuditActionType = 390 + + AuditActionGallerySubmit AuditActionType = 400 + AuditActionGalleryApprove AuditActionType = 401 + AuditActionGalleryReject AuditActionType = 402 + AuditActionGalleryRemove AuditActionType = 403 + AuditActionGalleryImport AuditActionType = 404 + + AuditActionOnboardingComplete AuditActionType = 410 + AuditActionOnboardingSkip AuditActionType = 411 + + AuditActionAffiliateApply AuditActionType = 420 + AuditActionAffiliateApprove AuditActionType = 421 + AuditActionAffiliateRevoke AuditActionType = 422 + AuditActionAffiliateCreate AuditActionType = 423 + AuditActionAffiliateRedeem AuditActionType = 424 + AuditActionAffiliateVoid AuditActionType = 425 + AuditActionAffiliateUpdateRate AuditActionType = 426 + AuditActionAffiliateUpdateCode AuditActionType = 427 + + AuditActionNotificationPreferencesUpdate AuditActionType = 430 + AuditActionUserEmailUpdate AuditActionType = 431 + AuditActionUserEmailDelete AuditActionType = 432 + AuditActionNotificationMarkRead AuditActionType = 433 + AuditActionNotificationMarkAllRead AuditActionType = 434 + + AuditActionEmailVerify AuditActionType = 435 + AuditActionEmailResendVerification AuditActionType = 436 + + AuditActionFeatureFlagToggle AuditActionType = 440 + AuditActionFeatureFlagCreate AuditActionType = 441 + AuditActionFeatureFlagRulesUpdate AuditActionType = 442 +) + +type AuditCategory int16 - AuditActionBotStaffAdd AuditActionType = 300 - AuditActionBotStaffRemove AuditActionType = 301 +const ( + AuditCategoryGuild AuditCategory = 1 + AuditCategoryUser AuditCategory = 2 + AuditCategoryStaff AuditCategory = 3 ) type AuditResourceType int16 @@ -109,10 +187,29 @@ const ( AuditResourceBotStaff AuditResourceType = 18 AuditResourceTicketLabel AuditResourceType = 19 AuditResourceTicketLabelAssignment AuditResourceType = 20 + AuditResourceGlobalBlacklist AuditResourceType = 21 + AuditResourceServerBlacklist AuditResourceType = 22 + AuditResourcePremiumKey AuditResourceType = 23 + AuditResourcePolarSubscription AuditResourceType = 24 + AuditResourcePolarProduct AuditResourceType = 25 + AuditResourceSku AuditResourceType = 26 + AuditResourceKBArticle AuditResourceType = 27 + AuditResourceKBCategory AuditResourceType = 28 + AuditResourceKBSettings AuditResourceType = 29 + AuditResourceGalleryListing AuditResourceType = 30 + AuditResourceOnboarding AuditResourceType = 31 + AuditResourceAffiliate AuditResourceType = 32 + AuditResourceAffiliateReferral AuditResourceType = 33 + AuditResourceNotificationPrefs AuditResourceType = 34 + AuditResourceUserEmail AuditResourceType = 35 + AuditResourceNotification AuditResourceType = 36 + AuditResourceUserEmailVerification AuditResourceType = 37 + AuditResourceFeatureFlag AuditResourceType = 38 ) type AuditLogEntry struct { Id int64 + Category AuditCategory GuildId *uint64 UserId uint64 ActionType AuditActionType @@ -125,6 +222,7 @@ type AuditLogEntry struct { } type AuditLogQueryOptions struct { + Category *AuditCategory GuildId *uint64 UserId *uint64 ActionType *int16 @@ -149,6 +247,7 @@ func (t AuditLogTable) Schema() string { return ` CREATE TABLE IF NOT EXISTS audit_logs ( "id" BIGSERIAL PRIMARY KEY, + "category" INT2 NOT NULL, "guild_id" INT8 DEFAULT NULL, "user_id" INT8 NOT NULL, "action_type" INT2 NOT NULL, @@ -159,6 +258,8 @@ CREATE TABLE IF NOT EXISTS audit_logs ( "metadata" JSONB DEFAULT NULL, "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW() ); +ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS "category" INT2; +CREATE INDEX IF NOT EXISTS audit_logs_category_created_at_idx ON audit_logs("category", "created_at" DESC); CREATE INDEX IF NOT EXISTS audit_logs_guild_id_created_at_idx ON audit_logs("guild_id", "created_at" DESC); CREATE INDEX IF NOT EXISTS audit_logs_user_id_idx ON audit_logs("user_id"); CREATE INDEX IF NOT EXISTS audit_logs_action_type_idx ON audit_logs("action_type"); @@ -169,10 +270,11 @@ CREATE INDEX IF NOT EXISTS audit_logs_created_at_idx ON audit_logs("created_at" func (t *AuditLogTable) Insert(ctx context.Context, entry AuditLogEntry) error { query := ` -INSERT INTO audit_logs ("guild_id", "user_id", "action_type", "resource_type", "resource_id", "old_data", "new_data", "metadata") -VALUES ($1, $2, $3, $4, $5, $6, $7, $8);` +INSERT INTO audit_logs ("category", "guild_id", "user_id", "action_type", "resource_type", "resource_id", "old_data", "new_data", "metadata") +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9);` _, err := t.Exec(ctx, query, + entry.Category, entry.GuildId, entry.UserId, entry.ActionType, @@ -186,7 +288,7 @@ VALUES ($1, $2, $3, $4, $5, $6, $7, $8);` } func (t *AuditLogTable) Query(ctx context.Context, opts AuditLogQueryOptions) ([]AuditLogEntry, error) { - query, args := buildAuditLogQuery("SELECT \"id\", \"guild_id\", \"user_id\", \"action_type\", \"resource_type\", \"resource_id\", \"old_data\", \"new_data\", \"metadata\", \"created_at\" FROM audit_logs", opts) + query, args := buildAuditLogQuery("SELECT \"id\", COALESCE(\"category\", 0), \"guild_id\", \"user_id\", \"action_type\", \"resource_type\", \"resource_id\", \"old_data\", \"new_data\", \"metadata\", \"created_at\" FROM audit_logs", opts) query += " ORDER BY \"created_at\" DESC" if opts.Limit > 0 { @@ -210,6 +312,7 @@ func (t *AuditLogTable) Query(ctx context.Context, opts AuditLogQueryOptions) ([ var entry AuditLogEntry if err := rows.Scan( &entry.Id, + &entry.Category, &entry.GuildId, &entry.UserId, &entry.ActionType, @@ -245,6 +348,11 @@ func buildAuditLogQuery(base string, opts AuditLogQueryOptions) (string, []inter var conditions []string var args []interface{} + if opts.Category != nil { + args = append(args, *opts.Category) + conditions = append(conditions, fmt.Sprintf("\"category\" = $%d", len(args))) + } + if opts.GuildId != nil { args = append(args, *opts.GuildId) conditions = append(conditions, fmt.Sprintf("\"guild_id\" = $%d", len(args))) diff --git a/autoclose.go b/autoclose.go deleted file mode 100644 index c247fd7..0000000 --- a/autoclose.go +++ /dev/null @@ -1,86 +0,0 @@ -package database - -import ( - "context" - "github.com/jackc/pgx/v4" - "github.com/jackc/pgx/v4/pgxpool" - "time" -) - -type AutoCloseTable struct { - *pgxpool.Pool -} - -type AutoCloseSettings struct { - Enabled bool `json:"enabled"` - SinceOpenWithNoResponse *time.Duration `json:"since_open_with_no_response"` - SinceLastMessage *time.Duration `json:"since_last_message"` - OnUserLeave *bool `json:"on_user_leave"` -} - -func newAutoCloseTable(db *pgxpool.Pool) *AutoCloseTable { - return &AutoCloseTable{ - db, - } -} - -func (a AutoCloseTable) Schema() string { - return ` -CREATE TABLE IF NOT EXISTS auto_close( - "guild_id" int8 NOT NULL, - "enabled" bool NOT NULL, - "since_open_with_no_response" interval, - "since_last_message" interval, - "on_user_leave" bool, - PRIMARY KEY("guild_id") -); -` -} - -func (a *AutoCloseTable) Get(ctx context.Context, guildId uint64) (settings AutoCloseSettings, e error) { - query := `SELECT "enabled", "since_open_with_no_response", "since_last_message", "on_user_leave" FROM auto_close WHERE "guild_id" = $1;` - if err := a.QueryRow(ctx, query, guildId).Scan(&settings.Enabled, &settings.SinceOpenWithNoResponse, &settings.SinceLastMessage, &settings.OnUserLeave); err != nil && err != pgx.ErrNoRows { // defaults to nil if no rows - e = err - } - - return -} - -func (a *AutoCloseTable) Set(ctx context.Context, guildId uint64, settings AutoCloseSettings) (err error) { - query := ` -INSERT INTO - auto_close("guild_id", "enabled", "since_open_with_no_response", "since_last_message", "on_user_leave") -VALUES - ($1, $2, $3, $4, $5) -ON CONFLICT("guild_id") DO - UPDATE SET - "enabled" = $2, - "since_open_with_no_response" = $3, - "since_last_message" = $4, - "on_user_leave" = $5 -;` - - _, err = a.Exec(ctx, query, guildId, settings.Enabled, settings.SinceOpenWithNoResponse, settings.SinceLastMessage, settings.OnUserLeave) - return -} - -func (a *AutoCloseTable) Reset(ctx context.Context, guildId uint64) (err error) { - query := ` -UPDATE auto_close -SET since_open_with_no_response = NULL, since_last_message = NULL -WHERE "guild_id" = $1; -` - - _, err = a.Exec(ctx, query, guildId) - return -} - -func (a *AutoCloseTable) Delete(ctx context.Context, guildId uint64) (err error) { - query := ` -DELETE FROM auto_close -WHERE "guild_id" = $1; -` - - _, err = a.Exec(ctx, query, guildId) - return -} diff --git a/botstaff.go b/botstaff.go index e3e3a2b..a02aebe 100644 --- a/botstaff.go +++ b/botstaff.go @@ -2,9 +2,24 @@ package database import ( "context" + + "github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4/pgxpool" ) +type BotStaffTier string + +const ( + BotStaffTierAdmin BotStaffTier = "admin" + BotStaffTierHelper BotStaffTier = "helper" +) + +type BotStaffEntry struct { + UserId uint64 `json:"id,string"` + Tier BotStaffTier `json:"tier"` + GlobalView bool `json:"global_view"` +} + type BotStaff struct { *pgxpool.Pool } @@ -19,8 +34,11 @@ func (s BotStaff) Schema() string { return ` CREATE TABLE IF NOT EXISTS bot_staff( "user_id" int8 NOT NULL UNIQUE, + "tier" TEXT NOT NULL DEFAULT 'helper', PRIMARY KEY("user_id") -);` +); + +ALTER TABLE bot_staff ADD COLUMN IF NOT EXISTS "global_view" bool NOT NULL DEFAULT false;` } func (s *BotStaff) IsStaff(ctx context.Context, userId uint64) (isStaff bool, err error) { @@ -28,7 +46,7 @@ func (s *BotStaff) IsStaff(ctx context.Context, userId uint64) (isStaff bool, er SELECT EXISTS ( SELECT 1 FROM bot_staff - where "user_id" = $1 + WHERE "user_id" = $1 ); ` @@ -36,8 +54,47 @@ SELECT EXISTS ( return } -func (s *BotStaff) GetAll(ctx context.Context) ([]uint64, error) { - query := `SELECT "user_id" FROM bot_staff;` +func (s *BotStaff) GetTier(ctx context.Context, userId uint64) (BotStaffTier, error) { + query := `SELECT "tier" FROM bot_staff WHERE "user_id" = $1` + + var tier BotStaffTier + err := s.QueryRow(ctx, query, userId).Scan(&tier) + if err == pgx.ErrNoRows { + return "", nil + } + + return tier, err +} + +// Tier is in the query so no caller can report true for a helper row. +func (s *BotStaff) HasGlobalView(ctx context.Context, userId uint64) (bool, error) { + query := `SELECT "global_view" FROM bot_staff WHERE "user_id" = $1 AND "tier" = 'admin'` + + var globalView bool + err := s.QueryRow(ctx, query, userId).Scan(&globalView) + if err == pgx.ErrNoRows { + return false, nil + } + + return globalView, err +} + +func (s *BotStaff) SetGlobalView(ctx context.Context, userId uint64, enabled bool) (bool, error) { + query := ` +UPDATE bot_staff +SET "global_view" = $2 +WHERE "user_id" = $1 AND "tier" = 'admin';` + + tag, err := s.Exec(ctx, query, userId, enabled) + if err != nil { + return false, err + } + + return tag.RowsAffected() > 0, nil +} + +func (s *BotStaff) GetAll(ctx context.Context) ([]BotStaffEntry, error) { + query := `SELECT "user_id", "tier", "global_view" FROM bot_staff ORDER BY "tier", "user_id";` rows, err := s.Query(ctx, query) if err != nil { @@ -46,27 +103,40 @@ func (s *BotStaff) GetAll(ctx context.Context) ([]uint64, error) { defer rows.Close() - var userIds []uint64 + var entries []BotStaffEntry for rows.Next() { - var userId uint64 - if err = rows.Scan(&userId); err != nil { + var entry BotStaffEntry + if err = rows.Scan(&entry.UserId, &entry.Tier, &entry.GlobalView); err != nil { return nil, err } - userIds = append(userIds, userId) + entries = append(entries, entry) } - return userIds, nil + return entries, nil } -func (s *BotStaff) Add(ctx context.Context, userId uint64) (err error) { +func (s *BotStaff) Add(ctx context.Context, userId uint64, tier BotStaffTier) (err error) { query := ` -INSERT INTO bot_staff("user_id") -VALUES($1) -ON CONFLICT("user_id") DO NOTHING; +INSERT INTO bot_staff("user_id", "tier") +VALUES($1, $2) +ON CONFLICT("user_id") DO UPDATE +SET "tier" = $2, + "global_view" = (bot_staff."global_view" AND $2::text = 'admin'); ` - _, err = s.Exec(ctx, query, userId) + _, err = s.Exec(ctx, query, userId, tier) + return +} + +func (s *BotStaff) UpdateTier(ctx context.Context, userId uint64, tier BotStaffTier) (err error) { + query := ` +UPDATE bot_staff +SET "tier" = $2, + "global_view" = ("global_view" AND $2::text = 'admin') +WHERE "user_id" = $1;` + + _, err = s.Exec(ctx, query, userId, tier) return } diff --git a/categoryupdatequeue.go b/categoryupdatequeue.go index 32a2042..dc99f41 100644 --- a/categoryupdatequeue.go +++ b/categoryupdatequeue.go @@ -52,6 +52,7 @@ func (q *CategoryUpdateQueue) GetReadyForUpdate(ctx context.Context, delayInterv if err != nil { return nil, err } + defer rows.Close() var items []CategoryUpdateQueueItem for rows.Next() { diff --git a/claimsettings.go b/claimsettings.go index 3df92fe..a3d3daa 100644 --- a/claimsettings.go +++ b/claimsettings.go @@ -29,14 +29,10 @@ const ( ) type ClaimSettings struct { - SupportCanView bool `json:"support_can_view"` - SupportCanType bool `json:"support_can_type"` - SwitchPanelClaimBehavior SwitchPanelClaimBehavior `json:"switch_panel_claim_behavior"` + SwitchPanelClaimBehavior SwitchPanelClaimBehavior `json:"switch_panel_claim_behavior"` } var defaultClaimSettings = ClaimSettings{ - SupportCanView: true, - SupportCanType: false, SwitchPanelClaimBehavior: SwitchPanelAutoUnclaim, } @@ -45,17 +41,13 @@ type ClaimSettingsTable struct { } func newClaimSettingsTable(db *pgxpool.Pool) *ClaimSettingsTable { - return &ClaimSettingsTable{ - db, - } + return &ClaimSettingsTable{db} } func (c ClaimSettingsTable) Schema() string { return ` CREATE TABLE IF NOT EXISTS claim_settings( "guild_id" int8 NOT NULL, - "support_can_view" bool NOT NULL, - "support_can_type" bool NOT NULL, "switch_panel_claim_behavior" int2 NOT NULL DEFAULT 0, PRIMARY KEY("guild_id") ); @@ -63,37 +55,22 @@ CREATE TABLE IF NOT EXISTS claim_settings( } func (c *ClaimSettingsTable) Get(ctx context.Context, guildId uint64) (settings ClaimSettings, e error) { - query := `SELECT "support_can_view", "support_can_type", "switch_panel_claim_behavior" FROM claim_settings WHERE "guild_id" = $1;` - if err := c.QueryRow(ctx, query, guildId).Scan(&settings.SupportCanView, &settings.SupportCanType, &settings.SwitchPanelClaimBehavior); err != nil { + query := `SELECT "switch_panel_claim_behavior" FROM claim_settings WHERE "guild_id" = $1;` + if err := c.QueryRow(ctx, query, guildId).Scan(&settings.SwitchPanelClaimBehavior); err != nil { if err == pgx.ErrNoRows { settings = defaultClaimSettings } else { e = err } } - return } func (c *ClaimSettingsTable) Set(ctx context.Context, guildId uint64, settings ClaimSettings) (err error) { query := ` -INSERT INTO claim_settings("guild_id", "support_can_view", "support_can_type", "switch_panel_claim_behavior") VALUES($1, $2, $3, $4) - ON CONFLICT("guild_id") DO UPDATE SET - "support_can_view" = $2, - "support_can_type" = $3, - "switch_panel_claim_behavior" = $4;` - - _, err = c.Exec(ctx, query, guildId, settings.SupportCanView, settings.SupportCanType, settings.SwitchPanelClaimBehavior) - return -} - -func (c *ClaimSettingsTable) SetSwitchPanelClaimBehavior(ctx context.Context, guildId uint64, behavior SwitchPanelClaimBehavior) (err error) { - query := ` -INSERT INTO claim_settings("guild_id", "support_can_view", "support_can_type", "switch_panel_claim_behavior") -VALUES($1, $2, $3, $4) -ON CONFLICT("guild_id") DO UPDATE SET "switch_panel_claim_behavior" = $4;` +INSERT INTO claim_settings("guild_id", "switch_panel_claim_behavior") VALUES($1, $2) +ON CONFLICT("guild_id") DO UPDATE SET "switch_panel_claim_behavior" = $2;` - defaults := defaultClaimSettings - _, err = c.Exec(ctx, query, guildId, defaults.SupportCanView, defaults.SupportCanType, behavior) + _, err = c.Exec(ctx, query, guildId, settings.SwitchPanelClaimBehavior) return } diff --git a/closeconfirmation.go b/closeconfirmation.go deleted file mode 100644 index 5c7a2e6..0000000 --- a/closeconfirmation.go +++ /dev/null @@ -1,43 +0,0 @@ -package database - -import ( - "context" - "github.com/jackc/pgx/v4" - "github.com/jackc/pgx/v4/pgxpool" -) - -type CloseConfirmation struct { - *pgxpool.Pool -} - -func newCloseConfirmation(db *pgxpool.Pool) *CloseConfirmation { - return &CloseConfirmation{ - db, - } -} - -func (c CloseConfirmation) Schema() string { - return ` -CREATE TABLE IF NOT EXISTS close_confirmation( - "guild_id" int8 NOT NULL UNIQUE, - "confirm" bool NOT NULL, - PRIMARY KEY("guild_id") -);` -} - -func (c *CloseConfirmation) Get(ctx context.Context, guildId uint64) (confirm bool, e error) { - if err := c.QueryRow(ctx, `SELECT "confirm" from close_confirmation WHERE "guild_id" = $1;`, guildId).Scan(&confirm); err != nil { - if err == pgx.ErrNoRows { - confirm = true - } else { - e = err - } - } - - return -} - -func (c *CloseConfirmation) Set(ctx context.Context, guildId uint64, confirm bool) (err error) { - _, err = c.Exec(ctx, `INSERT INTO close_confirmation("guild_id", "confirm") VALUES($1, $2) ON CONFLICT("guild_id") DO UPDATE SET "confirm" = $2;`, guildId, confirm) - return -} diff --git a/closereason.go b/closereason.go index 0731dc4..f29cada 100644 --- a/closereason.go +++ b/closereason.go @@ -2,6 +2,7 @@ package database import ( "context" + "fmt" "github.com/jackc/pgtype" "github.com/jackc/pgx/v4" @@ -118,3 +119,147 @@ func (c *CloseMetadataTable) Delete(ctx context.Context, guildId uint64, ticketI _, err = c.Exec(ctx, query, guildId, ticketId) return } + +func (c *CloseMetadataTable) GetTopCloseReasons(ctx context.Context, guildId uint64, panelId *int, limit int) ([]string, error) { + query := ` +SELECT cr.close_reason +FROM close_reason cr +INNER JOIN tickets t ON cr.guild_id = t.guild_id AND cr.ticket_id = t.id +WHERE cr.guild_id = $1 + AND ($2::int IS NULL OR t.panel_id = $2) + AND cr.close_reason IS NOT NULL + AND cr.close_reason != '' + AND cr.close_reason != 'Automatically closed due to inactivity' +GROUP BY cr.close_reason +ORDER BY COUNT(*) DESC +LIMIT $3;` + + rows, err := c.Query(ctx, query, guildId, panelId, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var reasons []string + for rows.Next() { + var reason string + if err := rows.Scan(&reason); err != nil { + return nil, err + } + reasons = append(reasons, reason) + } + + return reasons, nil +} + +type CloseReasonCount struct { + Reason string `json:"reason"` + Count int `json:"count"` +} + +func (c *CloseMetadataTable) GetTopCloseReasonsWithCount(ctx context.Context, guildId uint64, panelId *int, limit, days int, caseInsensitive bool, filter *PanelFilter) ([]CloseReasonCount, error) { + reasonExpr := "cr.close_reason" + if caseInsensitive { + reasonExpr = "LOWER(cr.close_reason)" + } + + query := ` +SELECT ` + reasonExpr + ` AS close_reason, COUNT(*)::int AS count +FROM close_reason cr +INNER JOIN tickets t ON cr.guild_id = t.guild_id AND cr.ticket_id = t.id +WHERE cr.guild_id = $1 + AND ($2::int IS NULL OR t.panel_id = $2) + AND cr.close_reason IS NOT NULL + AND cr.close_reason != '' + AND cr.close_reason != 'Automatically closed due to inactivity' + AND ($4 = 0 OR t.open_time > NOW() - make_interval(days => $4))` + + PanelPredicate("t", 5, 6) + ` +GROUP BY ` + reasonExpr + ` +ORDER BY COUNT(*) DESC +LIMIT $3;` + + arr, unassigned, err := filter.Args() + if err != nil { + return nil, fmt.Errorf("top close reasons: %w", err) + } + + rows, err := c.Query(ctx, query, guildId, panelId, limit, days, arr, unassigned) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []CloseReasonCount + for rows.Next() { + var r CloseReasonCount + if err := rows.Scan(&r.Reason, &r.Count); err != nil { + return nil, err + } + results = append(results, r) + } + + return results, nil +} + +// GetAutoCloseVsManualClose is the worker-compatible wrapper. Delegates to +// GetAutoCloseVsManualCloseFiltered with no panel filter. +func (c *CloseMetadataTable) GetAutoCloseVsManualClose(ctx context.Context, guildId uint64, nDays int) (AutoCloseStats, error) { + return c.GetAutoCloseVsManualCloseFiltered(ctx, guildId, nDays, nil) +} + +func (c *CloseMetadataTable) GetAutoCloseVsManualCloseFiltered(ctx context.Context, guildId uint64, nDays int, filter *PanelFilter) (AutoCloseStats, error) { + query := ` +SELECT + COUNT(*) FILTER (WHERE cr.closed_by IS NULL), + COUNT(*) FILTER (WHERE cr.closed_by IS NOT NULL) +FROM close_reason cr +INNER JOIN tickets t ON cr.guild_id = t.guild_id AND cr.ticket_id = t.id +WHERE cr.guild_id = $1 AND t.close_time > CURRENT_DATE - ($2 - 1) * INTERVAL '1 day'` + + PanelPredicate("t", 3, 4) + + arr, unassigned, err := filter.Args() + if err != nil { + return AutoCloseStats{}, fmt.Errorf("auto close stats: %w", err) + } + + var stats AutoCloseStats + if err := c.QueryRow(ctx, query, guildId, nDays, arr, unassigned).Scan(&stats.AutoClosed, &stats.ManualClosed); err != nil { + return AutoCloseStats{}, err + } + + return stats, nil +} + +func (c *CloseMetadataTable) GetTopCloseReasonsContaining(ctx context.Context, guildId uint64, panelId *int, contains string, limit int) ([]string, error) { + query := ` +SELECT cr.close_reason +FROM close_reason cr +INNER JOIN tickets t ON cr.guild_id = t.guild_id AND cr.ticket_id = t.id +WHERE cr.guild_id = $1 + AND ($2::int IS NULL OR t.panel_id = $2) + AND cr.close_reason IS NOT NULL + AND cr.close_reason != '' + AND cr.close_reason != 'Automatically closed due to inactivity' + AND LOWER(cr.close_reason) LIKE '%' || LOWER($3) || '%' +GROUP BY cr.close_reason +ORDER BY CASE WHEN LOWER(cr.close_reason) LIKE LOWER($3) || '%' THEN 0 ELSE 1 END, + COUNT(*) DESC +LIMIT $4;` + + rows, err := c.Query(ctx, query, guildId, panelId, contains, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var reasons []string + for rows.Next() { + var reason string + if err := rows.Scan(&reason); err != nil { + return nil, err + } + reasons = append(reasons, reason) + } + + return reasons, nil +} diff --git a/cmd/viewrefresher/envvars.md b/cmd/viewrefresher/envvars.md deleted file mode 100644 index fd6b8a7..0000000 --- a/cmd/viewrefresher/envvars.md +++ /dev/null @@ -1,2 +0,0 @@ -- DATABASE_URI -- DAEMON \ No newline at end of file diff --git a/cmd/viewrefresher/main.go b/cmd/viewrefresher/main.go deleted file mode 100644 index 1520fd9..0000000 --- a/cmd/viewrefresher/main.go +++ /dev/null @@ -1,46 +0,0 @@ -package main - -import ( - "os" - "time" - - "github.com/TicketsBot-cloud/database" - "github.com/jackc/pgx/v4/pgxpool" - "github.com/sirupsen/logrus" -) - -func main() { - logrus.Info("Connecting to database...") - pool := must(pgxpool.Connect(ctx, os.Getenv("DATABASE_URI"))) - db := database.NewDatabase(pool) - logrus.Info("Connected!") - - if os.Getenv("DAEMON") == "true" { - for { - doRefresh(db) - time.Sleep(6 * time.Hour) - } - } else { - doRefresh(db) - } -} - -func doRefresh(db *database.Database) { - logrus.Info("Starting refresh...") - - for _, view := range db.Views() { - if err := view.Refresh(); err != nil { - logrus.Errorf("Error refreshing view: %s", err.Error()) - } - } - - logrus.Info("Refresh complete") -} - -func must[T any](v T, err error) T { - if err != nil { - panic(err) - } - - return v -} diff --git a/custom_integration.go b/custom_integration.go index c08e29e..982e199 100644 --- a/custom_integration.go +++ b/custom_integration.go @@ -2,6 +2,8 @@ package database import ( "context" + "time" + "github.com/jackc/pgtype" "github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4/pgxpool" @@ -12,17 +14,20 @@ type CustomIntegrationTable struct { } type CustomIntegration struct { - Id int `json:"id"` - OwnerId uint64 `json:"owner_id"` - HttpMethod string `json:"http_method"` - WebhookUrl string `json:"webhook_url"` - ValidationUrl *string `json:"validation_url"` - Name string `json:"name"` - Description string `json:"description"` - ImageUrl *string `json:"image_url"` - PrivacyPolicyUrl *string `json:"privacy_policy_url"` - Public bool `json:"public"` - Approved bool `json:"approved"` + Id int `json:"id"` + OwnerId uint64 `json:"owner_id"` + HttpMethod string `json:"http_method"` + WebhookUrl string `json:"webhook_url"` + ValidationUrl *string `json:"validation_url"` + Name string `json:"name"` + Description string `json:"description"` + ImageUrl *string `json:"image_url"` + PrivacyPolicyUrl *string `json:"privacy_policy_url"` + Public bool `json:"public"` + Approved bool `json:"approved"` + RejectionReason *string `json:"rejection_reason"` + ReviewedBy *uint64 `json:"reviewed_by"` + ReviewedAt *time.Time `json:"reviewed_at"` } type CustomIntegrationWithGuildCount struct { @@ -58,11 +63,15 @@ CREATE TABLE IF NOT EXISTS custom_integrations( PRIMARY KEY("id") ); CREATE INDEX IF NOT EXISTS custom_integrations_owner_id ON custom_integrations("owner_id"); + +ALTER TABLE custom_integrations ADD COLUMN IF NOT EXISTS "rejection_reason" TEXT DEFAULT NULL; +ALTER TABLE custom_integrations ADD COLUMN IF NOT EXISTS "reviewed_by" int8 DEFAULT NULL; +ALTER TABLE custom_integrations ADD COLUMN IF NOT EXISTS "reviewed_at" TIMESTAMP DEFAULT NULL; ` } func (i *CustomIntegrationTable) Get(ctx context.Context, id int) (CustomIntegration, bool, error) { - query := `SELECT "id", "owner_id", "webhook_url", "validation_url", "http_method", "name", "description", "image_url", "privacy_policy_url", "public", "approved" FROM custom_integrations WHERE "id" = $1;` + query := `SELECT "id", "owner_id", "webhook_url", "validation_url", "http_method", "name", "description", "image_url", "privacy_policy_url", "public", "approved", "rejection_reason", "reviewed_by", "reviewed_at" FROM custom_integrations WHERE "id" = $1;` var integration CustomIntegration err := i.QueryRow(ctx, query, id).Scan( @@ -77,6 +86,9 @@ func (i *CustomIntegrationTable) Get(ctx context.Context, id int) (CustomIntegra &integration.PrivacyPolicyUrl, &integration.Public, &integration.Approved, + &integration.RejectionReason, + &integration.ReviewedBy, + &integration.ReviewedAt, ) if err != nil { @@ -92,7 +104,7 @@ func (i *CustomIntegrationTable) Get(ctx context.Context, id int) (CustomIntegra func (i *CustomIntegrationTable) GetAll(ctx context.Context, ids []int) ([]CustomIntegration, error) { query := ` -SELECT "id", "owner_id", "webhook_url", "validation_url", "http_method", "name", "description", "image_url", "privacy_policy_url", "public", "approved" +SELECT "id", "owner_id", "webhook_url", "validation_url", "http_method", "name", "description", "image_url", "privacy_policy_url", "public", "approved", "rejection_reason", "reviewed_by", "reviewed_at" FROM custom_integrations WHERE "id" = ANY($1);` @@ -121,6 +133,9 @@ WHERE "id" = ANY($1);` &integration.PrivacyPolicyUrl, &integration.Public, &integration.Approved, + &integration.RejectionReason, + &integration.ReviewedBy, + &integration.ReviewedAt, ) if err != nil { @@ -139,6 +154,29 @@ func (i *CustomIntegrationTable) GetOwnedCount(ctx context.Context, userId uint6 return } +// ListPublicOwnerIds returns distinct owner IDs of all integrations flagged public +// (pending review or approved). Used to keep public-integration authors cached. +func (i *CustomIntegrationTable) ListPublicOwnerIds(ctx context.Context) ([]uint64, error) { + query := `SELECT DISTINCT "owner_id" FROM custom_integrations WHERE "public" = TRUE;` + + rows, err := i.Query(ctx, query) + if err != nil { + return nil, err + } + + var ownerIds []uint64 + for rows.Next() { + var ownerId uint64 + if err := rows.Scan(&ownerId); err != nil { + return nil, err + } + + ownerIds = append(ownerIds, ownerId) + } + + return ownerIds, nil +} + func (i *CustomIntegrationTable) GetAllOwned(ctx context.Context, ownerId uint64) ([]CustomIntegrationWithGuildCount, error) { query := ` SELECT @@ -153,9 +191,16 @@ SELECT integrations.privacy_policy_url, integrations.public, integrations.approved, + integrations.rejection_reason, + integrations.reviewed_by, + integrations.reviewed_at, COALESCE(counts.count, 0) AS guild_count FROM custom_integrations AS integrations -LEFT OUTER JOIN custom_integration_guild_counts counts ON integrations.id = counts.integration_id +LEFT JOIN ( + SELECT integration_id, COUNT(*)::int AS count + FROM custom_integration_guilds + GROUP BY integration_id +) counts ON counts.integration_id = integrations.id WHERE "owner_id" = $1;` rows, err := i.Query(ctx, query, ownerId) @@ -178,6 +223,139 @@ WHERE "owner_id" = $1;` &integration.PrivacyPolicyUrl, &integration.Public, &integration.Approved, + &integration.RejectionReason, + &integration.ReviewedBy, + &integration.ReviewedAt, + &integration.GuildCount, + ) + + if err != nil { + return nil, err + } + + integrations = append(integrations, integration) + } + + return integrations, nil +} + +func (i *CustomIntegrationTable) GetPendingReview(ctx context.Context, limit, offset int) ([]CustomIntegrationWithGuildCount, error) { + query := ` +SELECT + integrations.id, + integrations.owner_id, + integrations.webhook_url, + integrations.validation_url, + integrations.http_method, + integrations.name, + integrations.description, + integrations.image_url, + integrations.privacy_policy_url, + integrations.public, + integrations.approved, + integrations.rejection_reason, + integrations.reviewed_by, + integrations.reviewed_at, + COALESCE(counts.count, 0) AS guild_count +FROM custom_integrations AS integrations +LEFT JOIN ( + SELECT integration_id, COUNT(*)::int AS count + FROM custom_integration_guilds + GROUP BY integration_id +) counts ON counts.integration_id = integrations.id +WHERE integrations.public = TRUE AND integrations.approved = FALSE AND integrations.rejection_reason IS NULL +ORDER BY integrations.id DESC +LIMIT $1 OFFSET $2;` + + return i.queryListing(ctx, query, limit, offset) +} + +func (i *CustomIntegrationTable) GetApproved(ctx context.Context, limit, offset int) ([]CustomIntegrationWithGuildCount, error) { + query := ` +SELECT + integrations.id, + integrations.owner_id, + integrations.webhook_url, + integrations.validation_url, + integrations.http_method, + integrations.name, + integrations.description, + integrations.image_url, + integrations.privacy_policy_url, + integrations.public, + integrations.approved, + integrations.rejection_reason, + integrations.reviewed_by, + integrations.reviewed_at, + COALESCE(counts.count, 0) AS guild_count +FROM custom_integrations AS integrations +LEFT JOIN ( + SELECT integration_id, COUNT(*)::int AS count + FROM custom_integration_guilds + GROUP BY integration_id +) counts ON counts.integration_id = integrations.id +WHERE integrations.approved = TRUE +ORDER BY integrations.id DESC +LIMIT $1 OFFSET $2;` + + return i.queryListing(ctx, query, limit, offset) +} + +func (i *CustomIntegrationTable) GetRejected(ctx context.Context, limit, offset int) ([]CustomIntegrationWithGuildCount, error) { + query := ` +SELECT + integrations.id, + integrations.owner_id, + integrations.webhook_url, + integrations.validation_url, + integrations.http_method, + integrations.name, + integrations.description, + integrations.image_url, + integrations.privacy_policy_url, + integrations.public, + integrations.approved, + integrations.rejection_reason, + integrations.reviewed_by, + integrations.reviewed_at, + COALESCE(counts.count, 0) AS guild_count +FROM custom_integrations AS integrations +LEFT JOIN ( + SELECT integration_id, COUNT(*)::int AS count + FROM custom_integration_guilds + GROUP BY integration_id +) counts ON counts.integration_id = integrations.id +WHERE integrations.rejection_reason IS NOT NULL +ORDER BY integrations.reviewed_at DESC NULLS LAST +LIMIT $1 OFFSET $2;` + + return i.queryListing(ctx, query, limit, offset) +} + +func (i *CustomIntegrationTable) queryListing(ctx context.Context, query string, limit, offset int) ([]CustomIntegrationWithGuildCount, error) { + rows, err := i.Query(ctx, query, limit, offset) + if err != nil { + return nil, err + } + + integrations := make([]CustomIntegrationWithGuildCount, 0) + for rows.Next() { + var integration CustomIntegrationWithGuildCount + err := rows.Scan( + &integration.Id, + &integration.OwnerId, + &integration.WebhookUrl, + &integration.ValidationUrl, + &integration.HttpMethod, + &integration.Name, + &integration.Description, + &integration.ImageUrl, + &integration.PrivacyPolicyUrl, + &integration.Public, + &integration.Approved, + &integration.RejectionReason, + &integration.ReviewedBy, + &integration.ReviewedAt, &integration.GuildCount, ) @@ -191,6 +369,27 @@ WHERE "owner_id" = $1;` return integrations, nil } +func (i *CustomIntegrationTable) CountByStatus(ctx context.Context, status string) (int, error) { + var query string + switch status { + case "pending": + query = `SELECT COUNT(*) FROM custom_integrations WHERE "public" = TRUE AND "approved" = FALSE AND "rejection_reason" IS NULL;` + case "approved": + query = `SELECT COUNT(*) FROM custom_integrations WHERE "approved" = TRUE;` + case "rejected": + query = `SELECT COUNT(*) FROM custom_integrations WHERE "rejection_reason" IS NOT NULL;` + default: + return 0, pgx.ErrNoRows + } + + var count int + if err := i.QueryRow(ctx, query).Scan(&count); err != nil { + return 0, err + } + + return count, nil +} + func (i *CustomIntegrationGuildsTable) GetAvailableIntegrationsWithActive(ctx context.Context, guildId, userId uint64, limit, offset int) ([]CustomIntegrationWithActive, error) { query := ` WITH active AS ( @@ -210,12 +409,19 @@ SELECT integrations.privacy_policy_url, integrations.public, integrations.approved, + integrations.rejection_reason, + integrations.reviewed_by, + integrations.reviewed_at, COALESCE(counts.count, 0) AS guild_count, CASE WHEN active.integration_id IS NOT NULL THEN TRUE ELSE FALSE END AS added FROM custom_integrations as integrations LEFT OUTER JOIN active ON active.integration_id = integrations.id -LEFT OUTER JOIN custom_integration_guild_counts counts ON integrations.id = counts.integration_id -WHERE active.integration_id IS NOT NULL OR +LEFT JOIN ( + SELECT integration_id, COUNT(*)::int AS count + FROM custom_integration_guilds + GROUP BY integration_id +) counts ON counts.integration_id = integrations.id +WHERE active.integration_id IS NOT NULL OR ((integrations.public = 't' AND integrations.approved = 't') OR integrations.owner_id = $2) ORDER BY active.integration_id NULLS LAST, guild_count DESC LIMIT $3 OFFSET $4; @@ -241,6 +447,9 @@ LIMIT $3 OFFSET $4; &integration.PrivacyPolicyUrl, &integration.Public, &integration.Approved, + &integration.RejectionReason, + &integration.ReviewedBy, + &integration.ReviewedAt, &integration.GuildCount, &integration.Active, ) @@ -255,6 +464,25 @@ LIMIT $3 OFFSET $4; return integrations, nil } +// Mirrors the WHERE clause of GetAvailableIntegrationsWithActive so page counts match the listing. +func (i *CustomIntegrationGuildsTable) CountAvailableIntegrations(ctx context.Context, guildId, userId uint64) (count int, err error) { + query := ` +WITH active AS ( + SELECT integration_id + FROM custom_integration_guilds + WHERE guild_id=$1 +) +SELECT COUNT(*) +FROM custom_integrations as integrations +LEFT OUTER JOIN active ON active.integration_id = integrations.id +WHERE active.integration_id IS NOT NULL OR + ((integrations.public = 't' AND integrations.approved = 't') OR integrations.owner_id = $2); +` + + err = i.QueryRow(ctx, query, guildId, userId).Scan(&count) + return +} + func (i *CustomIntegrationGuildsTable) CanActivate(ctx context.Context, integrationId int, userId uint64) (canActivate bool, err error) { query := ` SELECT EXISTS( @@ -296,11 +524,56 @@ RETURNING "id"; } func (i *CustomIntegrationTable) SetPublic(ctx context.Context, integrationId int) (err error) { - query := `UPDATE custom_integrations SET "public" = TRUE WHERE "id" = $1;` + query := ` +UPDATE custom_integrations +SET "public" = TRUE, + "rejection_reason" = NULL, + "reviewed_by" = NULL, + "reviewed_at" = NULL +WHERE "id" = $1;` _, err = i.Exec(ctx, query, integrationId) return } +func (i *CustomIntegrationTable) Approve(ctx context.Context, integrationId int, reviewerId uint64) error { + query := ` +UPDATE custom_integrations +SET "approved" = TRUE, + "public" = TRUE, + "rejection_reason" = NULL, + "reviewed_by" = $2, + "reviewed_at" = NOW() +WHERE "id" = $1;` + _, err := i.Exec(ctx, query, integrationId, reviewerId) + return err +} + +func (i *CustomIntegrationTable) Reject(ctx context.Context, integrationId int, reviewerId uint64, reason string) error { + query := ` +UPDATE custom_integrations +SET "approved" = FALSE, + "public" = FALSE, + "rejection_reason" = $3, + "reviewed_by" = $2, + "reviewed_at" = NOW() +WHERE "id" = $1;` + _, err := i.Exec(ctx, query, integrationId, reviewerId, reason) + return err +} + +func (i *CustomIntegrationTable) Unapprove(ctx context.Context, integrationId int, reviewerId uint64) error { + query := ` +UPDATE custom_integrations +SET "approved" = FALSE, + "public" = FALSE, + "rejection_reason" = NULL, + "reviewed_by" = $2, + "reviewed_at" = NOW() +WHERE "id" = $1;` + _, err := i.Exec(ctx, query, integrationId, reviewerId) + return err +} + func (i *CustomIntegrationTable) Update(ctx context.Context, integration CustomIntegration) (err error) { query := ` UPDATE custom_integrations @@ -313,7 +586,10 @@ SET "image_url" = $7, "privacy_policy_url" = $8, "public" = $9, - "approved" = $10 + "approved" = $10, + "rejection_reason" = $11, + "reviewed_by" = $12, + "reviewed_at" = $13 WHERE "id" = $1; ` @@ -330,6 +606,9 @@ WHERE "id" = $1; integration.PrivacyPolicyUrl, integration.Public, integration.Approved, + integration.RejectionReason, + integration.ReviewedBy, + integration.ReviewedAt, ) return diff --git a/custom_integration_guild_counts.go b/custom_integration_guild_counts.go deleted file mode 100644 index 4b28f0a..0000000 --- a/custom_integration_guild_counts.go +++ /dev/null @@ -1,63 +0,0 @@ -package database - -import ( - "context" - "fmt" - "github.com/jackc/pgx/v4/pgxpool" -) - -type CustomIntegrationGuildCountsView struct { - *pgxpool.Pool -} - -func newCustomIntegrationGuildCountsView(db *pgxpool.Pool) *CustomIntegrationGuildCountsView { - return &CustomIntegrationGuildCountsView{ - db, - } -} - -func (v CustomIntegrationGuildCountsView) Schema() string { - s := v.schema("custom_integration_guild_counts") - for _, indexSchema := range v.indexes("custom_integration_guild_counts") { - s += "\n" - s += indexSchema - } - - return s -} - -func (v CustomIntegrationGuildCountsView) schema(tableName string) string { - return fmt.Sprintf(` -CREATE MATERIALIZED VIEW IF NOT EXISTS %[1]s -AS - SELECT integration_id, COUNT(*) AS COUNT - FROM custom_integration_guilds - GROUP BY integration_id -WITH DATA; -`, tableName) -} - -func (v CustomIntegrationGuildCountsView) indexes(tableName string) []string { - return []string{ - fmt.Sprintf("CREATE UNIQUE INDEX IF NOT EXISTS %[1]s_integration_id_key ON %[1]s(integration_id);", tableName), - } -} - -func (v *CustomIntegrationGuildCountsView) Refresh(ctx context.Context) error { - statements := slice(v.schema("custom_integration_guild_counts_new")) - statements = append(statements, v.indexes("custom_integration_guild_counts_new")...) - statements = append(statements, - "DROP MATERIALIZED VIEW IF EXISTS custom_integration_guild_counts;", - "ALTER MATERIALIZED VIEW custom_integration_guild_counts_new RENAME TO custom_integration_guild_counts;", - "ALTER INDEX custom_integration_guild_counts_new_integration_id_key RENAME TO custom_integration_guild_counts_integration_id_key;", - ) - - tx, err := transact(ctx, v.Pool, statements...) - if err != nil { - return err - } - - defer tx.Rollback(ctx) - - return tx.Commit(ctx) -} diff --git a/dashboardonboarding.go b/dashboardonboarding.go new file mode 100644 index 0000000..0a378b9 --- /dev/null +++ b/dashboardonboarding.go @@ -0,0 +1,102 @@ +package database + +import ( + "context" + "time" + + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" +) + +type DashboardOnboarding struct { + GuildId uint64 `json:"guild_id,string"` + OnboardingCompleted bool `json:"onboarding_completed"` + OnboardingCompletedAt *time.Time `json:"onboarding_completed_at"` + CurrentStep int16 `json:"current_step"` + Skipped bool `json:"skipped"` +} + +type DashboardOnboardingTable struct { + *pgxpool.Pool +} + +func newDashboardOnboardingTable(pool *pgxpool.Pool) *DashboardOnboardingTable { + return &DashboardOnboardingTable{ + pool, + } +} + +func (t DashboardOnboardingTable) Schema() string { + return ` +CREATE TABLE IF NOT EXISTS dashboard_onboarding( + "guild_id" INT8 NOT NULL, + "onboarding_completed" BOOL NOT NULL DEFAULT false, + "onboarding_completed_at" TIMESTAMPTZ DEFAULT NULL, + "current_step" INT2 NOT NULL DEFAULT 0, + "skipped" BOOL NOT NULL DEFAULT false, + PRIMARY KEY("guild_id") +); +` +} + +func (t *DashboardOnboardingTable) Get(ctx context.Context, guildId uint64) (DashboardOnboarding, error) { + query := ` +SELECT "onboarding_completed", "onboarding_completed_at", "current_step", "skipped" +FROM dashboard_onboarding +WHERE "guild_id" = $1; +` + + var onboarding DashboardOnboarding + onboarding.GuildId = guildId + + err := t.QueryRow(ctx, query, guildId).Scan( + &onboarding.OnboardingCompleted, + &onboarding.OnboardingCompletedAt, + &onboarding.CurrentStep, + &onboarding.Skipped, + ) + + if err == pgx.ErrNoRows { + return DashboardOnboarding{GuildId: guildId}, nil + } else if err != nil { + return DashboardOnboarding{}, err + } + + return onboarding, nil +} + +func (t *DashboardOnboardingTable) SetStep(ctx context.Context, guildId uint64, step int16) error { + query := ` +INSERT INTO dashboard_onboarding("guild_id", "current_step") +VALUES($1, $2) +ON CONFLICT("guild_id") +DO UPDATE SET "current_step" = $2; +` + + _, err := t.Exec(ctx, query, guildId, step) + return err +} + +func (t *DashboardOnboardingTable) Complete(ctx context.Context, guildId uint64) error { + query := ` +INSERT INTO dashboard_onboarding("guild_id", "onboarding_completed", "onboarding_completed_at", "current_step") +VALUES($1, true, NOW(), 5) +ON CONFLICT("guild_id") +DO UPDATE SET "onboarding_completed" = true, "onboarding_completed_at" = NOW(), "current_step" = 5; +` + + _, err := t.Exec(ctx, query, guildId) + return err +} + +func (t *DashboardOnboardingTable) Skip(ctx context.Context, guildId uint64) error { + query := ` +INSERT INTO dashboard_onboarding("guild_id", "skipped") +VALUES($1, true) +ON CONFLICT("guild_id") +DO UPDATE SET "skipped" = true; +` + + _, err := t.Exec(ctx, query, guildId) + return err +} diff --git a/database.go b/database.go index 0f98600..87f16bb 100644 --- a/database.go +++ b/database.go @@ -12,66 +12,77 @@ const defaultTransactionTimeout = time.Second * 3 type Database struct { pool *pgxpool.Pool + AffiliateCodes *AffiliateCodes + AffiliateReferrals *AffiliateReferrals ActiveLanguage *ActiveLanguage - ArchiveChannel *ArchiveChannel AuditLog *AuditLogTable ArchiveMessages *ArchiveMessages - AutoClose *AutoCloseTable AutoCloseExclude *AutoCloseExclude Blacklist *Blacklist BotStaff *BotStaff CategoryUpdateQueue *CategoryUpdateQueue ChannelCategory *ChannelCategory ClaimSettings *ClaimSettingsTable - CloseConfirmation *CloseConfirmation CloseReason *CloseMetadataTable CloseRequest *CloseRequestTable CustomIntegrations *CustomIntegrationTable - CustomIntegrationGuildCounts *CustomIntegrationGuildCountsView CustomIntegrationGuilds *CustomIntegrationGuildsTable CustomIntegrationHeaders *CustomIntegrationHeadersTable CustomIntegrationPlaceholders *CustomIntegrationPlaceholdersTable CustomIntegrationSecretValues *CustomIntegrationSecretValuesTable CustomIntegrationSecrets *CustomIntegrationSecretsTable CustomColours *CustomColours + DashboardOnboarding *DashboardOnboardingTable DashboardUsers *DashboardUsersTable ArchiveDmMessages *ArchiveDmMessages DiscordEntitlements *DiscordEntitlements DiscordStoreSkus *DiscordStoreSkus + EmailVerificationCodes *EmailVerificationCodes EmbedFields *EmbedFieldsTable Embeds *EmbedsTable Entitlements *Entitlements ExitSurveyResponses *ExitSurveyResponses Experiment *ExperimentTable - FeedbackEnabled *FeedbackEnabled + ExperimentExposures *ExperimentExposuresTable FirstResponseTime *FirstResponseTime FormInput *FormInputTable FormInputOption *FormInputOptionTable Forms *FormsTable FormInputApiConfig *FormInputApiConfigTable FormInputApiHeaders *FormInputApiHeaderTable + GalleryListings *GalleryListingsTable + GalleryListingTags *GalleryListingTagsTable GdprLogs *GDPRLogsTable GlobalBlacklist *GlobalBlacklist GuildLeaveTime *GuildLeaveTime GuildMetadata *GuildMetadataTable + KBArticles *KBArticlesTable + KBArticleFeedback *KBArticleFeedbackTable + KBCategories *KBCategoriesTable + KBSettings *KBSettingsTable LegacyPremiumEntitlementGuilds *LegacyPremiumEntitlementGuilds LegacyPremiumEntitlements *LegacyPremiumEntitlements MultiPanels *MultiPanelTable MultiPanelTargets *MultiPanelTargets MultiServerSkus *MultiServerSkus - NamingScheme *TicketNamingScheme + Notifications *NotificationsTable + NotificationPreferences *NotificationPreferencesTable OnCall *OnCall Panel *PanelTable PanelAccessControlRules *PanelAccessControlRules + PanelKBCategories *PanelKBCategoriesTable PanelRoleMentions *PanelRoleMentions PanelSupportHours *PanelSupportHoursTable PanelSupportHoursSettings *PanelSupportHoursSettingsTable PanelTeams *PanelTeamsTable PanelTicketPermissions *PanelTicketPermissionsTable + PanelAutoClose *PanelAutoCloseTable PanelUserMention *PanelUserMention PanelHereMention *PanelHereMention Participants *ParticipantTable PatreonEntitlements *PatreonEntitlements + PolarEntitlements *PolarEntitlements + PolarProducts *PolarProducts Permissions *Permissions PremiumGuilds *PremiumGuilds PremiumKeys *PremiumKeys @@ -80,6 +91,7 @@ type Database struct { ServerBlacklist *ServerBlacklist ServiceRatings *ServiceRatings Settings *SettingsTable + Skus *Skus StaffOverride *StaffOverride SubscriptionSkus *SubscriptionSkus SupportTeam *SupportTeamTable @@ -89,19 +101,18 @@ type Database struct { Tag *TagsTable TicketClaims *TicketClaims TicketLastMessage *TicketLastMessageTable - TicketLimit *TicketLimit TicketMembers *TicketMembers - TicketPermissions *TicketPermissionsTable Tickets *TicketTable UsedKeys *UsedKeys - UsersCanClose *UsersCanClose + UserEmails *UserEmails UserGuilds *UserGuildsTable VoteCredits *VoteCredits Votes *Votes Webhooks *WebhookTable - WelcomeMessages *WelcomeMessages - TicketLabels *TicketLabelsTable - TicketLabelAssignments *TicketLabelAssignmentsTable + TicketLabels *TicketLabelsTable + TicketLabelAssignments *TicketLabelAssignmentsTable + TicketMessageCounts *TicketMessageCounts + AdminAnalytics *AdminAnalyticsTable Whitelabel *WhitelabelBotTable WhitelabelErrors *WhitelabelErrors WhitelabelGuilds *WhitelabelGuilds @@ -112,66 +123,77 @@ type Database struct { func NewDatabase(pool *pgxpool.Pool) *Database { db := &Database{ pool: pool, + AffiliateCodes: newAffiliateCodes(pool), + AffiliateReferrals: newAffiliateReferrals(pool), ActiveLanguage: newActiveLanguage(pool), - ArchiveChannel: newArchiveChannel(pool), AuditLog: newAuditLogTable(pool), ArchiveMessages: newArchiveMessages(pool), - AutoClose: newAutoCloseTable(pool), AutoCloseExclude: newAutoCloseExclude(pool), Blacklist: newBlacklist(pool), BotStaff: newBotStaff(pool), CategoryUpdateQueue: newCategoryUpdateQueueTable(pool), ChannelCategory: newChannelCategory(pool), ClaimSettings: newClaimSettingsTable(pool), - CloseConfirmation: newCloseConfirmation(pool), CloseReason: newCloseReasonTable(pool), CloseRequest: newCloseRequestTable(pool), CustomIntegrations: newCustomIntegrationTable(pool), - CustomIntegrationGuildCounts: newCustomIntegrationGuildCountsView(pool), CustomIntegrationGuilds: newCustomIntegrationGuildsTable(pool), CustomIntegrationHeaders: newCustomIntegrationHeadersTable(pool), CustomIntegrationPlaceholders: newCustomIntegrationPlaceholdersTable(pool), CustomIntegrationSecretValues: newCustomIntegrationSecretValuesTable(pool), CustomIntegrationSecrets: newCustomIntegrationSecretsTable(pool), CustomColours: newCustomColours(pool), + DashboardOnboarding: newDashboardOnboardingTable(pool), DashboardUsers: newDashboardUsersTable(pool), ArchiveDmMessages: newArchiveDmMessages(pool), DiscordEntitlements: newDiscordEntitlementsTable(pool), DiscordStoreSkus: newDiscordStoreSkusTable(pool), + EmailVerificationCodes: newEmailVerificationCodes(pool), EmbedFields: newEmbedFieldsTable(pool), Embeds: newEmbedsTable(pool), Entitlements: newEntitlementsTable(pool), ExitSurveyResponses: newExitSurveyResponses(pool), Experiment: newExperimentTable(pool), - FeedbackEnabled: newFeedbackEnabled(pool), + ExperimentExposures: newExperimentExposuresTable(pool), FirstResponseTime: newFirstResponseTime(pool), FormInput: newFormInputTable(pool), Forms: newFormsTable(pool), FormInputApiConfig: newFormInputApiConfigTable(pool), FormInputApiHeaders: newFormInputApiHeaderTable(pool), FormInputOption: newFormInputOptionTable(pool), + GalleryListings: newGalleryListingsTable(pool), + GalleryListingTags: newGalleryListingTagsTable(pool), GdprLogs: newGDPRLogs(pool), GlobalBlacklist: newGlobalBlacklist(pool), GuildLeaveTime: newGuildLeaveTime(pool), GuildMetadata: newGuildMetadataTable(pool), + KBArticles: newKBArticles(pool), + KBArticleFeedback: newKBArticleFeedback(pool), + KBCategories: newKBCategories(pool), + KBSettings: newKBSettings(pool), LegacyPremiumEntitlementGuilds: newLegacyPremiumEntitlementGuildsTable(pool), LegacyPremiumEntitlements: newLegacyPremiumEntitlement(pool), MultiPanels: newMultiMultiPanelTable(pool), MultiPanelTargets: newMultiPanelTargets(pool), MultiServerSkus: newMultiServerSkusTable(pool), - NamingScheme: newTicketNamingScheme(pool), + Notifications: newNotificationsTable(pool), + NotificationPreferences: newNotificationPreferencesTable(pool), OnCall: newOnCall(pool), Panel: newPanelTable(pool), PanelAccessControlRules: newPanelAccessControlRules(pool), + PanelKBCategories: newPanelKBCategories(pool), PanelRoleMentions: newPanelRoleMentions(pool), PanelSupportHours: newPanelSupportHoursTable(pool), PanelSupportHoursSettings: newPanelSupportHoursSettingsTable(pool), PanelTeams: newPanelTeamsTable(pool), PanelTicketPermissions: newPanelTicketPermissionsTable(pool), + PanelAutoClose: newPanelAutoCloseTable(pool), PanelUserMention: newPanelUserMention(pool), PanelHereMention: newPanelHereMention(pool), Participants: newParticipantTable(pool), PatreonEntitlements: newPatreonEntitlements(pool), + PolarEntitlements: newPolarEntitlements(pool), + PolarProducts: newPolarProducts(pool), Permissions: newPermissions(pool), PremiumGuilds: newPremiumGuilds(pool), PremiumKeys: newPremiumKeys(pool), @@ -180,6 +202,7 @@ func NewDatabase(pool *pgxpool.Pool) *Database { ServerBlacklist: newServerBlacklist(pool), ServiceRatings: newServiceRatings(pool), Settings: newSettingsTable(pool), + Skus: newSkusTable(pool), StaffOverride: newStaffOverride(pool), SubscriptionSkus: newSubscriptionSkusTable(pool), SupportTeam: newSupportTeamTable(pool), @@ -189,19 +212,18 @@ func NewDatabase(pool *pgxpool.Pool) *Database { Tag: newTag(pool), TicketClaims: newTicketClaims(pool), TicketLastMessage: newTicketLastMessageTable(pool), - TicketLimit: newTicketLimit(pool), TicketMembers: newTicketMembers(pool), - TicketPermissions: newTicketPermissionsTable(pool), Tickets: newTicketTable(pool), UsedKeys: newUsedKeys(pool), - UsersCanClose: newUsersCanClose(pool), + UserEmails: newUserEmails(pool), UserGuilds: newUserGuildsTable(pool), VoteCredits: newVoteCreditsTable(pool), Votes: newVotes(pool), Webhooks: newWebhookTable(pool), - WelcomeMessages: newWelcomeMessages(pool), - TicketLabels: newTicketLabelsTable(pool), - TicketLabelAssignments: newTicketLabelAssignmentsTable(pool), + TicketLabels: newTicketLabelsTable(pool), + TicketLabelAssignments: newTicketLabelAssignmentsTable(pool), + TicketMessageCounts: newTicketMessageCounts(pool), + AdminAnalytics: newAdminAnalyticsTable(pool), Whitelabel: newWhitelabelBotTable(pool), WhitelabelErrors: newWhitelabelErrors(pool), WhitelabelGuilds: newWhitelabelGuilds(pool), @@ -239,16 +261,12 @@ func (d *Database) WithTx(ctx context.Context, f func(tx pgx.Tx) error) error { func (d *Database) CreateTables(ctx context.Context, pool *pgxpool.Pool) { mustCreate(ctx, pool, d.ActiveLanguage, - d.ArchiveChannel, - d.AutoClose, d.Blacklist, d.BotStaff, d.ChannelCategory, d.ClaimSettings, - d.CloseConfirmation, d.CustomIntegrations, d.CustomIntegrationGuilds, - d.CustomIntegrationGuildCounts, d.CustomIntegrationHeaders, d.CustomIntegrationPlaceholders, d.CustomIntegrationSecrets, @@ -259,10 +277,11 @@ func (d *Database) CreateTables(ctx context.Context, pool *pgxpool.Pool) { d.EmbedFields, // depends on embeds d.Entitlements, d.Experiment, + d.ExperimentExposures, d.DiscordEntitlements, // depends on entitlements + d.Skus, // must be created before discord_store_skus, subscription_skus, multi_server_skus, polar_products d.DiscordStoreSkus, // depends on skus d.SubscriptionSkus, // depends on skus - d.FeedbackEnabled, d.Forms, d.FormInput, // depends on forms d.FormInputOption, // depends on form inputs @@ -271,23 +290,28 @@ func (d *Database) CreateTables(ctx context.Context, pool *pgxpool.Pool) { d.GdprLogs, d.GlobalBlacklist, d.GuildLeaveTime, + d.DashboardOnboarding, d.GuildMetadata, d.LegacyPremiumEntitlements, d.LegacyPremiumEntitlementGuilds, d.MultiPanels, d.MultiServerSkus, - d.NamingScheme, d.OnCall, d.Panel, - d.PanelTicketPermissions, // must be created after panels table + d.PanelTicketPermissions, // must be created after panels table d.PanelAccessControlRules, // must be created after panels table d.MultiPanelTargets, // must be created after panels table d.PanelRoleMentions, d.PanelSupportHours, // must be created after panels table d.PanelSupportHoursSettings, // must be created after panels table + d.PanelAutoClose, // must be created after panels table d.PanelUserMention, d.PanelHereMention, d.PatreonEntitlements, + d.PolarEntitlements, // depends on entitlements + d.PolarProducts, // depends on skus + d.AffiliateCodes, // adds 'affiliate' to premium_source enum; must be after entitlements + d.AffiliateReferrals, // depends on affiliate_codes d.Permissions, d.PremiumGuilds, d.PremiumKeys, @@ -302,46 +326,49 @@ func (d *Database) CreateTables(ctx context.Context, pool *pgxpool.Pool) { d.SupportTeamPermissions, // must be created after support_team table d.PanelTeams, // Must be created after panels & support teams tables d.Tag, - d.TicketLimit, - d.TicketPermissions, - d.Tickets, // Must be created before members table - d.TicketLastMessage, // Must be created after Tickets table - d.Participants, // Must be created after Tickets table - d.AutoCloseExclude, // Must be created after Tickets table - d.CloseReason, // Must be created after Tickets table - d.CloseRequest, // Must be created after Tickets table - d.ServiceRatings, // Must be created after Tickets table - d.ExitSurveyResponses, // Must be created after Tickets table - d.ArchiveMessages, // Must be created after Tickets table - d.ArchiveDmMessages, // Must be created after Tickets table - d.CategoryUpdateQueue, // Must be created after Tickets table - d.TicketLabels, // Must be created after Tickets table - d.TicketLabelAssignments, // Must be created after Tickets and TicketLabels tables + d.KBCategories, // Knowledge base categories + d.KBArticles, // Knowledge base articles (references categories) + d.KBArticleFeedback, // Must be created after kb_articles (references articles) + d.KBSettings, // Knowledge base customisation settings + d.PanelKBCategories, // Panel-to-KB-category associations + d.Tickets, // Must be created before members table + d.TicketLastMessage, // Must be created after Tickets table + d.Participants, // Must be created after Tickets table + d.AutoCloseExclude, // Must be created after Tickets table + d.CloseReason, // Must be created after Tickets table + d.CloseRequest, // Must be created after Tickets table + d.ServiceRatings, // Must be created after Tickets table + d.ExitSurveyResponses, // Must be created after Tickets table + d.ArchiveMessages, // Must be created after Tickets table + d.ArchiveDmMessages, // Must be created after Tickets table + d.CategoryUpdateQueue, // Must be created after Tickets table + d.TicketLabels, // Must be created after Tickets table + d.TicketLabelAssignments, // Must be created after Tickets and TicketLabels tables + d.TicketMessageCounts, // Must be created after Tickets table + d.GalleryListings, // Gallery panel listings + d.GalleryListingTags, // Must be created after GalleryListings table d.FirstResponseTime, d.TicketMembers, d.TicketClaims, d.UsedKeys, - d.UsersCanClose, + d.UserEmails, + d.EmailVerificationCodes, + d.Notifications, + d.NotificationPreferences, d.UserGuilds, d.VoteCredits, d.Votes, d.Webhooks, - d.WelcomeMessages, d.Whitelabel, d.WhitelabelErrors, d.WhitelabelGuilds, d.WhitelabelStatuses, d.WhitelabelUsers, d.AuditLog, + d.AdminAnalytics, ) } -func (d *Database) Views() []View { - return []View{ - d.CustomIntegrationGuildCounts, - } -} - func mustCreate(ctx context.Context, pool *pgxpool.Pool, tables ...Table) { for _, table := range tables { if _, err := pool.Exec(ctx, table.Schema()); err != nil { diff --git a/emailverificationcodes.go b/emailverificationcodes.go new file mode 100644 index 0000000..c06a7ae --- /dev/null +++ b/emailverificationcodes.go @@ -0,0 +1,79 @@ +package database + +import ( + "context" + _ "embed" + "errors" + "time" + + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" +) + +type EmailVerificationCodes struct { + *pgxpool.Pool +} + +type EmailVerificationCode struct { + UserId uint64 `json:"user_id"` + Code string `json:"code"` + ExpiresAt time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` + FailedAttempts int `json:"failed_attempts"` +} + +var ( + //go:embed sql/email_verification_codes/schema.sql + emailVerificationCodesSchema string + + //go:embed sql/email_verification_codes/upsert.sql + emailVerificationCodesUpsert string + + //go:embed sql/email_verification_codes/get_by_user_id.sql + emailVerificationCodesGetByUserId string + + //go:embed sql/email_verification_codes/delete.sql + emailVerificationCodesDelete string + + //go:embed sql/email_verification_codes/increment_attempts.sql + emailVerificationCodesIncrementAttempts string +) + +func newEmailVerificationCodes(db *pgxpool.Pool) *EmailVerificationCodes { + return &EmailVerificationCodes{db} +} + +func (EmailVerificationCodes) Schema() string { + return emailVerificationCodesSchema +} + +func (t *EmailVerificationCodes) Upsert(ctx context.Context, userId uint64, code string, expiresAt time.Time) error { + _, err := t.Exec(ctx, emailVerificationCodesUpsert, userId, code, expiresAt) + return err +} + +func (t *EmailVerificationCodes) GetByUserId(ctx context.Context, userId uint64) (*EmailVerificationCode, error) { + var vc EmailVerificationCode + if err := t.QueryRow(ctx, emailVerificationCodesGetByUserId, userId).Scan( + &vc.UserId, &vc.Code, &vc.ExpiresAt, &vc.CreatedAt, &vc.FailedAttempts, + ); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &vc, nil +} + +func (t *EmailVerificationCodes) Delete(ctx context.Context, userId uint64) error { + _, err := t.Exec(ctx, emailVerificationCodesDelete, userId) + return err +} + +func (t *EmailVerificationCodes) IncrementAttempts(ctx context.Context, userId uint64) (int, error) { + var count int + if err := t.QueryRow(ctx, emailVerificationCodesIncrementAttempts, userId).Scan(&count); err != nil { + return 0, err + } + return count, nil +} diff --git a/entitlements.go b/entitlements.go index d672fa4..cc86784 100644 --- a/entitlements.go +++ b/entitlements.go @@ -49,6 +49,12 @@ var ( //go:embed sql/entitlements/update_expires_at.sql entitlementsUpdateExpiresAt string + + //go:embed sql/entitlements/list_all_user_subscriptions_paginated.sql + entitlementsListAllUserSubscriptionsPaginated string + + //go:embed sql/entitlements/count_all_user_subscriptions.sql + entitlementsCountAllUserSubscriptions string ) func newEntitlementsTable(db *pgxpool.Pool) *Entitlements { @@ -263,3 +269,40 @@ func (e *Entitlements) SetExpiresAt(ctx context.Context, tx pgx.Tx, id uuid.UUID _, err := tx.Exec(ctx, entitlementsUpdateExpiresAt, id, expiresAt) return err } + +func (e *Entitlements) ListAllUserSubscriptionsPaginated(ctx context.Context, gracePeriod time.Duration, limit, offset int) ([]model.GuildEntitlementEntry, error) { + rows, err := e.Query(ctx, entitlementsListAllUserSubscriptionsPaginated, gracePeriod, limit, offset) + if err != nil { + return nil, err + } + + var entries []model.GuildEntitlementEntry + for rows.Next() { + var entry model.GuildEntitlementEntry + if err := rows.Scan( + &entry.Id, + &entry.UserId, + &entry.Source, + &entry.ExpiresAt, + &entry.SkuId, + &entry.SkuLabel, + &entry.Tier, + &entry.SkuPriority, + ); err != nil { + return nil, err + } + + entries = append(entries, entry) + } + + return entries, nil +} + +func (e *Entitlements) CountAllUserSubscriptions(ctx context.Context, gracePeriod time.Duration) (int, error) { + var count int + if err := e.QueryRow(ctx, entitlementsCountAllUserSubscriptions, gracePeriod).Scan(&count); err != nil { + return 0, err + } + + return count, nil +} diff --git a/experimentexposures.go b/experimentexposures.go new file mode 100644 index 0000000..4042a16 --- /dev/null +++ b/experimentexposures.go @@ -0,0 +1,136 @@ +package database + +import ( + "context" + "time" + + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" +) + +// ExperimentExposure records that one unit was enrolled in one experiment. It is +// the assignment data GrowthBook reads when computing experiment results. +type ExperimentExposure struct { + // ExperimentKey is GrowthBook's string experiment key. Deliberately not named + // experiment_id: the unrelated legacy experiments table has an integer id + // column, and GrowthBook's assignment query aliases this to experiment_id. + ExperimentKey string `json:"experiment_key"` + VariationId int `json:"variation_id"` + IdentifierType string `json:"identifier_type"` + Identifier string `json:"identifier"` + FeatureKey string `json:"feature_key"` + ExposedAt time.Time `json:"exposed_at"` +} + +type ExperimentExposuresTable struct { + *pgxpool.Pool +} + +func newExperimentExposuresTable(db *pgxpool.Pool) *ExperimentExposuresTable { + return &ExperimentExposuresTable{ + db, + } +} + +// Schema deliberately declares no primary key. The table is append-only and +// written in batches from every worker pod, so a BIGSERIAL would add a contended +// sequence and an index write for a column nothing reads. +// +// Writers deduplicate to roughly one row per unit per experiment per day, but +// duplicates are still possible across a deduplication window boundary or if the +// Redis deduplication layer is unavailable. Any assignment query must therefore +// take the first exposure per unit rather than assume uniqueness, and alias +// experiment_key to the experiment_id name GrowthBook expects: +// +// SELECT identifier AS user_id, +// MIN(exposed_at) AS timestamp, +// experiment_key AS experiment_id, +// variation_id +// FROM experiment_exposures +// WHERE identifier_type = 'guild_id' +// GROUP BY identifier, experiment_key, variation_id +func (e ExperimentExposuresTable) Schema() string { + return ` + CREATE TABLE IF NOT EXISTS experiment_exposures( + "experiment_key" VARCHAR(255) NOT NULL, + "variation_id" INT NOT NULL, + "identifier_type" VARCHAR(64) NOT NULL, + "identifier" VARCHAR(64) NOT NULL, + "feature_key" VARCHAR(255) NOT NULL DEFAULT '', + "exposed_at" TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS experiment_exposures_experiment_exposed_at ON experiment_exposures("experiment_key", "exposed_at"); + CREATE INDEX IF NOT EXISTS experiment_exposures_exposed_at ON experiment_exposures("exposed_at"); + ` +} + +// InsertBatch appends exposures via COPY. Callers batch to keep the write cost +// off the event path. +func (e *ExperimentExposuresTable) InsertBatch(ctx context.Context, exposures []ExperimentExposure) error { + if len(exposures) == 0 { + return nil + } + + rows := make([][]interface{}, 0, len(exposures)) + for _, exposure := range exposures { + rows = append(rows, []interface{}{ + exposure.ExperimentKey, + exposure.VariationId, + exposure.IdentifierType, + exposure.Identifier, + exposure.FeatureKey, + exposure.ExposedAt, + }) + } + + _, err := e.CopyFrom( + ctx, + pgx.Identifier{"experiment_exposures"}, + []string{"experiment_key", "variation_id", "identifier_type", "identifier", "feature_key", "exposed_at"}, + pgx.CopyFromRows(rows), + ) + + return err +} + +// DeleteOlderThan enforces retention. Nothing calls this yet: it needs a caller +// in a daemon, otherwise the table grows without bound. +func (e *ExperimentExposuresTable) DeleteOlderThan(ctx context.Context, age time.Duration) (int64, error) { + query := `DELETE FROM experiment_exposures WHERE "exposed_at" < NOW() - $1::interval;` + + tag, err := e.Exec(ctx, query, age) + if err != nil { + return 0, err + } + + return tag.RowsAffected(), nil +} + +// CountByExperiment reports rows per variation for one experiment, for the admin +// UI to sanity-check that an experiment is actually collecting data. +func (e *ExperimentExposuresTable) CountByExperiment(ctx context.Context, experimentKey string) (map[int]int, error) { + query := ` + SELECT "variation_id", COUNT(DISTINCT "identifier") + FROM experiment_exposures + WHERE "experiment_key" = $1 + GROUP BY "variation_id"; + ` + + rows, err := e.Query(ctx, query, experimentKey) + if err != nil { + return nil, err + } + defer rows.Close() + + counts := make(map[int]int) + for rows.Next() { + var variationId, count int + if err := rows.Scan(&variationId, &count); err != nil { + return nil, err + } + + counts[variationId] = count + } + + return counts, rows.Err() +} diff --git a/feedbackenabled.go b/feedbackenabled.go deleted file mode 100644 index 204896d..0000000 --- a/feedbackenabled.go +++ /dev/null @@ -1,34 +0,0 @@ -package database - -import ( - "context" - "github.com/jackc/pgx/v4" - "github.com/jackc/pgx/v4/pgxpool" -) - -type FeedbackEnabled struct { - *pgxpool.Pool -} - -func newFeedbackEnabled(db *pgxpool.Pool) *FeedbackEnabled { - return &FeedbackEnabled{ - db, - } -} - -func (FeedbackEnabled) Schema() string { - return `CREATE TABLE IF NOT EXISTS feedback_enabled("guild_id" int8 NOT NULL UNIQUE, "feedback_enabled" bool NOT NULL, PRIMARY KEY("guild_id"));` -} - -func (f *FeedbackEnabled) Get(ctx context.Context, guildId uint64) (feedbackEnabled bool, e error) { - if err := f.QueryRow(ctx, `SELECT "feedback_enabled" from feedback_enabled WHERE "guild_id" = $1;`, guildId).Scan(&feedbackEnabled); err != nil && err != pgx.ErrNoRows { - e = err - } - - return -} - -func (f *FeedbackEnabled) Set(ctx context.Context, guildId uint64, feedbackEnabled bool) (err error) { - _, err = f.Exec(ctx, `INSERT INTO feedback_enabled("guild_id", "feedback_enabled") VALUES($1, $2) ON CONFLICT("guild_id") DO UPDATE SET "feedback_enabled" = $2;`, guildId, feedbackEnabled) - return -} diff --git a/firstresponsetime.go b/firstresponsetime.go index 5f72561..a021086 100644 --- a/firstresponsetime.go +++ b/firstresponsetime.go @@ -2,10 +2,12 @@ package database import ( "context" + "fmt" + "time" + "github.com/jackc/pgtype" "github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4/pgxpool" - "time" ) type FirstResponseTime struct { @@ -100,6 +102,82 @@ func (f *FirstResponseTime) GetAverageAllTimeUser(ctx context.Context, guildId, return } +// GetAverageTripleWindow returns the three fixed-window averages. It delegates +// to GetAverageWindows with days=0 and no panel filter, preserving the +// existing signature for the worker module. +func (f *FirstResponseTime) GetAverageTripleWindow(ctx context.Context, guildId uint64) (TripleWindow, error) { + mw, err := f.GetAverageWindows(ctx, guildId, 0, nil) + if err != nil { + return TripleWindow{}, err + } + return mw.TripleWindow(), nil +} + +// GetAverageWindows returns four first-response-time averages in one round trip: +// the exact selected range, all time, monthly, and weekly. The selected window +// equals all-time when days == 0. +func (f *FirstResponseTime) GetAverageWindows(ctx context.Context, guildId uint64, days int, filter *PanelFilter) (MetricWindows, error) { + query := ` +SELECT + AVG(frt.response_time) FILTER (WHERE $2 = 0 OR t.open_time > NOW() - make_interval(days => $2)), + AVG(frt.response_time), + AVG(frt.response_time) FILTER (WHERE t.open_time > NOW() - INTERVAL '30 days'), + AVG(frt.response_time) FILTER (WHERE t.open_time > NOW() - INTERVAL '7 days') +FROM first_response_time frt +INNER JOIN tickets t ON frt.guild_id = t.guild_id AND frt.ticket_id = t.id +WHERE frt.guild_id = $1` + PanelPredicate("t", 3, 4) + + arr, unassigned, err := filter.Args() + if err != nil { + return MetricWindows{}, fmt.Errorf("first response time windows: %w", err) + } + + var mw MetricWindows + if err := f.QueryRow(ctx, query, guildId, days, arr, unassigned).Scan( + &mw.Selected, &mw.AllTime, &mw.Monthly, &mw.Weekly, + ); err != nil { + return MetricWindows{}, err + } + + return mw, nil +} + +func (f *FirstResponseTime) GetAverageByHour(ctx context.Context, guildId uint64, days int, filter *PanelFilter) ([]ResponseTimeByHour, error) { + query := ` +SELECT + EXTRACT(HOUR FROM t.open_time)::int AS hour_of_day, + AVG(frt.response_time) AS avg_response_time +FROM first_response_time frt +INNER JOIN tickets t ON frt.guild_id = t.guild_id AND frt.ticket_id = t.id +WHERE frt.guild_id = $1 + AND ($2 = 0 OR t.open_time > NOW() - make_interval(days => $2))` + + PanelPredicate("t", 3, 4) + ` +GROUP BY hour_of_day +ORDER BY hour_of_day;` + + arr, unassigned, err := filter.Args() + if err != nil { + return nil, fmt.Errorf("response time by hour: %w", err) + } + + rows, err := f.Query(ctx, query, guildId, days, arr, unassigned) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []ResponseTimeByHour + for rows.Next() { + var r ResponseTimeByHour + if err := rows.Scan(&r.HourOfDay, &r.AvgResponseTime); err != nil { + return nil, err + } + results = append(results, r) + } + + return results, nil +} + func (f *FirstResponseTime) Set(ctx context.Context, guildId, userId uint64, ticketId int, responseTime time.Duration) (err error) { query := `INSERT INTO first_response_time("guild_id", "ticket_id", "user_id", "response_time") VALUES($1, $2, $3, $4) ON CONFLICT("guild_id", "ticket_id") DO NOTHING;` _, err = f.Exec(ctx, query, guildId, ticketId, userId, responseTime) diff --git a/forminputapiconfig.go b/forminputapiconfig.go index d8b2296..d9440b4 100644 --- a/forminputapiconfig.go +++ b/forminputapiconfig.go @@ -15,6 +15,8 @@ type FormInputApiConfig struct { EndpointUrl string `json:"endpoint_url"` Method string `json:"method"` CacheDurationSeconds *int `json:"cache_duration_seconds,omitempty"` + NoOptionsMessage *string `json:"no_options_message,omitempty"` + BodyTemplate *string `json:"body_template,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -37,6 +39,7 @@ func (f FormInputApiConfigTable) Schema() string { "endpoint_url" VARCHAR(500) NOT NULL, "method" VARCHAR(10) NOT NULL DEFAULT 'GET', "cache_duration_seconds" INT DEFAULT 300, + "no_options_message" VARCHAR(100), "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY("form_input_id") REFERENCES form_input("id") ON DELETE CASCADE, @@ -45,21 +48,22 @@ func (f FormInputApiConfigTable) Schema() string { PRIMARY KEY("id") ); CREATE INDEX IF NOT EXISTS form_input_api_config_form_input_id ON form_input_api_config("form_input_id"); + + ALTER TABLE form_input_api_config ADD COLUMN IF NOT EXISTS "body_template" TEXT DEFAULT NULL; ` } -func (f *FormInputApiConfigTable) Get(ctx context.Context, formInputId int) (config FormInputApiConfig, ok bool, e error) { - query := ` - SELECT "id", "form_input_id", "endpoint_url", "method", "cache_duration_seconds", "created_at", "updated_at" - FROM form_input_api_config - WHERE "form_input_id" = $1;` +const formInputApiConfigColumns = `"id", "form_input_id", "endpoint_url", "method", "cache_duration_seconds", "no_options_message", "body_template", "created_at", "updated_at"` - err := f.QueryRow(ctx, query, formInputId).Scan( +func (f *FormInputApiConfigTable) scanOne(row pgx.Row) (config FormInputApiConfig, ok bool, e error) { + err := row.Scan( &config.Id, &config.FormInputId, &config.EndpointUrl, &config.Method, &config.CacheDurationSeconds, + &config.NoOptionsMessage, + &config.BodyTemplate, &config.CreatedAt, &config.UpdatedAt, ) @@ -75,36 +79,36 @@ func (f *FormInputApiConfigTable) Get(ctx context.Context, formInputId int) (con return config, true, nil } -func (f *FormInputApiConfigTable) GetById(ctx context.Context, id int) (config FormInputApiConfig, ok bool, e error) { +func (f *FormInputApiConfigTable) Get(ctx context.Context, formInputId int) (config FormInputApiConfig, ok bool, e error) { query := ` - SELECT "id", "form_input_id", "endpoint_url", "method", "cache_duration_seconds", "created_at", "updated_at" + SELECT ` + formInputApiConfigColumns + ` FROM form_input_api_config - WHERE "id" = $1;` + WHERE "form_input_id" = $1;` - err := f.QueryRow(ctx, query, id).Scan( - &config.Id, - &config.FormInputId, - &config.EndpointUrl, - &config.Method, - &config.CacheDurationSeconds, - &config.CreatedAt, - &config.UpdatedAt, - ) + return f.scanOne(f.QueryRow(ctx, query, formInputId)) +} - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return FormInputApiConfig{}, false, nil - } else { - return FormInputApiConfig{}, false, err - } - } +func (f *FormInputApiConfigTable) GetTx(ctx context.Context, tx pgx.Tx, formInputId int) (config FormInputApiConfig, ok bool, e error) { + query := ` + SELECT ` + formInputApiConfigColumns + ` + FROM form_input_api_config + WHERE "form_input_id" = $1;` - return config, true, nil + return f.scanOne(tx.QueryRow(ctx, query, formInputId)) +} + +func (f *FormInputApiConfigTable) GetById(ctx context.Context, id int) (config FormInputApiConfig, ok bool, e error) { + query := ` + SELECT ` + formInputApiConfigColumns + ` + FROM form_input_api_config + WHERE "id" = $1;` + + return f.scanOne(f.QueryRow(ctx, query, id)) } func (f *FormInputApiConfigTable) GetByFormId(ctx context.Context, formId int) ([]FormInputApiConfig, error) { query := ` - SELECT c."id", c."form_input_id", c."endpoint_url", c."method", c."cache_duration_seconds", c."created_at", c."updated_at" + SELECT c."id", c."form_input_id", c."endpoint_url", c."method", c."cache_duration_seconds", c."no_options_message", c."body_template", c."created_at", c."updated_at" FROM form_input_api_config c INNER JOIN form_input i ON c."form_input_id" = i."id" WHERE i."form_id" = $1 @@ -125,6 +129,8 @@ func (f *FormInputApiConfigTable) GetByFormId(ctx context.Context, formId int) ( &config.EndpointUrl, &config.Method, &config.CacheDurationSeconds, + &config.NoOptionsMessage, + &config.BodyTemplate, &config.CreatedAt, &config.UpdatedAt, ); err != nil { @@ -137,36 +143,12 @@ func (f *FormInputApiConfigTable) GetByFormId(ctx context.Context, formId int) ( } func (f *FormInputApiConfigTable) GetByFormInputId(ctx context.Context, formInputId int) (config FormInputApiConfig, ok bool, e error) { - query := ` - SELECT "id", "form_input_id", "endpoint_url", "method", "cache_duration_seconds", "created_at", "updated_at" - FROM form_input_api_config - WHERE "form_input_id" = $1;` - - err := f.QueryRow(ctx, query, formInputId).Scan( - &config.Id, - &config.FormInputId, - &config.EndpointUrl, - &config.Method, - &config.CacheDurationSeconds, - &config.CreatedAt, - &config.UpdatedAt, - ) - - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return FormInputApiConfig{}, false, nil - } else { - return FormInputApiConfig{}, false, err - } - } - - return config, true, nil + return f.Get(ctx, formInputId) } func (f *FormInputApiConfigTable) GetAllByGuild(ctx context.Context, guildId uint64) (map[int]FormInputApiConfig, error) { - query := ` - SELECT c."id", c."form_input_id", c."endpoint_url", c."method", c."cache_duration_seconds", c."created_at", c."updated_at" + SELECT c."id", c."form_input_id", c."endpoint_url", c."method", c."cache_duration_seconds", c."no_options_message", c."body_template", c."created_at", c."updated_at" FROM form_input_api_config c INNER JOIN form_input i ON c."form_input_id" = i."id" INNER JOIN forms f ON i."form_id" = f."form_id" @@ -187,6 +169,8 @@ func (f *FormInputApiConfigTable) GetAllByGuild(ctx context.Context, guildId uin &config.EndpointUrl, &config.Method, &config.CacheDurationSeconds, + &config.NoOptionsMessage, + &config.BodyTemplate, &config.CreatedAt, &config.UpdatedAt, ); err != nil { @@ -198,57 +182,61 @@ func (f *FormInputApiConfigTable) GetAllByGuild(ctx context.Context, guildId uin return configs, rows.Err() } -func (f *FormInputApiConfigTable) Create(ctx context.Context, formInputId int, endpointUrl string, method string, cacheDurationSeconds *int) (int, error) { +func (f *FormInputApiConfigTable) Create(ctx context.Context, formInputId int, endpointUrl string, method string, cacheDurationSeconds *int, noOptionsMessage *string, bodyTemplate *string) (int, error) { query := ` - INSERT INTO form_input_api_config("form_input_id", "endpoint_url", "method", "cache_duration_seconds") - VALUES($1, $2, $3, $4) + INSERT INTO form_input_api_config("form_input_id", "endpoint_url", "method", "cache_duration_seconds", "no_options_message", "body_template") + VALUES($1, $2, $3, $4, $5, $6) RETURNING "id";` var id int - if err := f.QueryRow(ctx, query, formInputId, endpointUrl, method, cacheDurationSeconds).Scan(&id); err != nil { + if err := f.QueryRow(ctx, query, formInputId, endpointUrl, method, cacheDurationSeconds, noOptionsMessage, bodyTemplate).Scan(&id); err != nil { return 0, err } return id, nil } -func (f *FormInputApiConfigTable) CreateTx(ctx context.Context, tx pgx.Tx, formInputId int, endpointUrl string, method string, cacheDurationSeconds *int) (int, error) { +func (f *FormInputApiConfigTable) CreateTx(ctx context.Context, tx pgx.Tx, formInputId int, endpointUrl string, method string, cacheDurationSeconds *int, noOptionsMessage *string, bodyTemplate *string) (int, error) { query := ` - INSERT INTO form_input_api_config("form_input_id", "endpoint_url", "method", "cache_duration_seconds") - VALUES($1, $2, $3, $4) + INSERT INTO form_input_api_config("form_input_id", "endpoint_url", "method", "cache_duration_seconds", "no_options_message", "body_template") + VALUES($1, $2, $3, $4, $5, $6) RETURNING "id";` var id int - if err := tx.QueryRow(ctx, query, formInputId, endpointUrl, method, cacheDurationSeconds).Scan(&id); err != nil { + if err := tx.QueryRow(ctx, query, formInputId, endpointUrl, method, cacheDurationSeconds, noOptionsMessage, bodyTemplate).Scan(&id); err != nil { return 0, err } return id, nil } -func (f *FormInputApiConfigTable) Update(ctx context.Context, id int, endpointUrl string, method string, cacheDurationSeconds *int) error { +func (f *FormInputApiConfigTable) Update(ctx context.Context, id int, endpointUrl string, method string, cacheDurationSeconds *int, noOptionsMessage *string, bodyTemplate *string) error { query := ` UPDATE form_input_api_config SET "endpoint_url" = $2, "method" = $3, "cache_duration_seconds" = $4, + "no_options_message" = $5, + "body_template" = $6, "updated_at" = CURRENT_TIMESTAMP WHERE "id" = $1;` - _, err := f.Exec(ctx, query, id, endpointUrl, method, cacheDurationSeconds) + _, err := f.Exec(ctx, query, id, endpointUrl, method, cacheDurationSeconds, noOptionsMessage, bodyTemplate) return err } -func (f *FormInputApiConfigTable) UpdateTx(ctx context.Context, tx pgx.Tx, id int, endpointUrl string, method string, cacheDurationSeconds *int) error { +func (f *FormInputApiConfigTable) UpdateTx(ctx context.Context, tx pgx.Tx, id int, endpointUrl string, method string, cacheDurationSeconds *int, noOptionsMessage *string, bodyTemplate *string) error { query := ` UPDATE form_input_api_config SET "endpoint_url" = $2, "method" = $3, "cache_duration_seconds" = $4, + "no_options_message" = $5, + "body_template" = $6, "updated_at" = CURRENT_TIMESTAMP WHERE "id" = $1;` - _, err := tx.Exec(ctx, query, id, endpointUrl, method, cacheDurationSeconds) + _, err := tx.Exec(ctx, query, id, endpointUrl, method, cacheDurationSeconds, noOptionsMessage, bodyTemplate) return err } diff --git a/gallery_listing_tags.go b/gallery_listing_tags.go new file mode 100644 index 0000000..45a5c3f --- /dev/null +++ b/gallery_listing_tags.go @@ -0,0 +1,111 @@ +package database + +import ( + "context" + + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" +) + +type GalleryListingTagsTable struct { + *pgxpool.Pool +} + +func newGalleryListingTagsTable(pool *pgxpool.Pool) *GalleryListingTagsTable { + return &GalleryListingTagsTable{ + pool, + } +} + +func (t GalleryListingTagsTable) Schema() string { + return ` +CREATE TABLE IF NOT EXISTS gallery_listing_tags ( + "listing_id" INT NOT NULL REFERENCES gallery_listings("id") ON DELETE CASCADE, + "tag" VARCHAR(30) NOT NULL, + PRIMARY KEY ("listing_id", "tag") +); +CREATE INDEX IF NOT EXISTS gallery_listing_tags_tag_idx ON gallery_listing_tags("tag"); +` +} + +// Set replaces all tags for a listing within a transaction. +func (t *GalleryListingTagsTable) Set(ctx context.Context, listingId int, tags []string) error { + tx, err := t.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + if err := t.deleteByListingWithTx(ctx, tx, listingId); err != nil { + return err + } + + for _, tag := range tags { + if err := t.insertWithTx(ctx, tx, listingId, tag); err != nil { + return err + } + } + + return tx.Commit(ctx) +} + +func (t *GalleryListingTagsTable) deleteByListingWithTx(ctx context.Context, tx pgx.Tx, listingId int) error { + query := `DELETE FROM gallery_listing_tags WHERE "listing_id" = $1;` + _, err := tx.Exec(ctx, query, listingId) + return err +} + +func (t *GalleryListingTagsTable) insertWithTx(ctx context.Context, tx pgx.Tx, listingId int, tag string) error { + query := `INSERT INTO gallery_listing_tags ("listing_id", "tag") VALUES ($1, $2) ON CONFLICT DO NOTHING;` + _, err := tx.Exec(ctx, query, listingId, tag) + return err +} + +// GetByListing returns all tags for a single listing. +func (t *GalleryListingTagsTable) GetByListing(ctx context.Context, listingId int) ([]string, error) { + query := `SELECT "tag" FROM gallery_listing_tags WHERE "listing_id" = $1 ORDER BY "tag" ASC;` + + rows, err := t.Query(ctx, query, listingId) + if err != nil { + return nil, err + } + defer rows.Close() + + var tags []string + for rows.Next() { + var tag string + if err := rows.Scan(&tag); err != nil { + return nil, err + } + tags = append(tags, tag) + } + + return tags, nil +} + +// GetByListings returns tags for multiple listings in a single query, keyed by listing ID. +func (t *GalleryListingTagsTable) GetByListings(ctx context.Context, listingIds []int) (map[int][]string, error) { + if len(listingIds) == 0 { + return make(map[int][]string), nil + } + + query := `SELECT "listing_id", "tag" FROM gallery_listing_tags WHERE "listing_id" = ANY($1) ORDER BY "listing_id", "tag" ASC;` + + rows, err := t.Query(ctx, query, listingIds) + if err != nil { + return nil, err + } + defer rows.Close() + + result := make(map[int][]string) + for rows.Next() { + var listingId int + var tag string + if err := rows.Scan(&listingId, &tag); err != nil { + return nil, err + } + result[listingId] = append(result[listingId], tag) + } + + return result, nil +} diff --git a/gallery_listings.go b/gallery_listings.go new file mode 100644 index 0000000..c02bd72 --- /dev/null +++ b/gallery_listings.go @@ -0,0 +1,501 @@ +package database + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" +) + +type GalleryListingStatus string + +const ( + GalleryListingStatusPending GalleryListingStatus = "pending" + GalleryListingStatusApproved GalleryListingStatus = "approved" + GalleryListingStatusRejected GalleryListingStatus = "rejected" +) + +const ( + GalleryListingTypePanel = "panel" + GalleryListingTypeTag = "tag" + GalleryListingTypeForm = "form" +) + +type GalleryListing struct { + Id int `json:"id"` + SubmitterUserId uint64 `json:"submitter_user_id,string"` + SourceGuildId uint64 `json:"source_guild_id,string"` + ListingType string `json:"listing_type"` + Name string `json:"name"` + Description string `json:"description"` + Category string `json:"category"` + Status GalleryListingStatus `json:"status"` + ReviewNote *string `json:"review_note"` + ReviewedBy *uint64 `json:"reviewed_by,string"` + ReviewedAt *time.Time `json:"reviewed_at"` + ImportCount int `json:"import_count"` + Featured bool `json:"featured"` + SnapshotData []byte `json:"snapshot_data"` + // Panel visual design fields - only populated for panel listings + Title string `json:"title"` + Content string `json:"content"` + Colour int32 `json:"colour"` + ImageUrl *string `json:"image_url"` + ThumbnailUrl *string `json:"thumbnail_url"` + ButtonStyle *int16 `json:"button_style"` + ButtonLabel string `json:"button_label"` + EmojiName *string `json:"emoji_name"` + WelcomeMessage []byte `json:"welcome_message"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type GalleryListingsTable struct { + *pgxpool.Pool +} + +func newGalleryListingsTable(pool *pgxpool.Pool) *GalleryListingsTable { + return &GalleryListingsTable{ + pool, + } +} + +func (t GalleryListingsTable) Schema() string { + return ` +CREATE TABLE IF NOT EXISTS gallery_listings ( + "id" SERIAL PRIMARY KEY, + "submitter_user_id" INT8 NOT NULL, + "source_guild_id" INT8 NOT NULL, + "listing_type" VARCHAR(20) NOT NULL DEFAULT 'panel', + "name" VARCHAR(100) NOT NULL, + "description" TEXT NOT NULL, + "category" VARCHAR(50) NOT NULL, + "status" VARCHAR(20) NOT NULL DEFAULT 'pending', + "review_note" TEXT, + "reviewed_by" INT8, + "reviewed_at" TIMESTAMPTZ, + "import_count" INT NOT NULL DEFAULT 0, + "featured" BOOL NOT NULL DEFAULT false, + "snapshot_data" JSONB, + "title" VARCHAR(255) NOT NULL DEFAULT '', + "content" TEXT NOT NULL DEFAULT '', + "colour" INT4 NOT NULL DEFAULT 0, + "image_url" TEXT, + "thumbnail_url" TEXT, + "button_style" INT2 DEFAULT 1, + "button_label" VARCHAR(80) NOT NULL DEFAULT '', + "emoji_name" VARCHAR(32), + "welcome_message" JSONB, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS gallery_listings_status_idx ON gallery_listings("status"); +CREATE INDEX IF NOT EXISTS gallery_listings_category_idx ON gallery_listings("category"); +CREATE INDEX IF NOT EXISTS gallery_listings_listing_type_idx ON gallery_listings("listing_type"); +CREATE INDEX IF NOT EXISTS gallery_listings_submitter_user_id_idx ON gallery_listings("submitter_user_id"); +CREATE INDEX IF NOT EXISTS gallery_listings_source_guild_id_idx ON gallery_listings("source_guild_id"); +CREATE INDEX IF NOT EXISTS gallery_listings_featured_idx ON gallery_listings("featured") WHERE "featured" = true; +CREATE INDEX IF NOT EXISTS gallery_listings_import_count_idx ON gallery_listings("import_count" DESC); +CREATE INDEX IF NOT EXISTS gallery_listings_created_at_idx ON gallery_listings("created_at" DESC); +` +} + +func (l *GalleryListing) fieldPtrs() []interface{} { + return []interface{}{ + &l.Id, + &l.SubmitterUserId, + &l.SourceGuildId, + &l.ListingType, + &l.Name, + &l.Description, + &l.Category, + &l.Status, + &l.ReviewNote, + &l.ReviewedBy, + &l.ReviewedAt, + &l.ImportCount, + &l.Featured, + &l.SnapshotData, + &l.Title, + &l.Content, + &l.Colour, + &l.ImageUrl, + &l.ThumbnailUrl, + &l.ButtonStyle, + &l.ButtonLabel, + &l.EmojiName, + &l.WelcomeMessage, + &l.CreatedAt, + &l.UpdatedAt, + } +} + +const galleryListingSelectColumns = ` + "id", + "submitter_user_id", + "source_guild_id", + "listing_type", + "name", + "description", + "category", + "status", + "review_note", + "reviewed_by", + "reviewed_at", + "import_count", + "featured", + "snapshot_data", + "title", + "content", + "colour", + "image_url", + "thumbnail_url", + "button_style", + "button_label", + "emoji_name", + "welcome_message", + "created_at", + "updated_at" +` + +func (t *GalleryListingsTable) Create(ctx context.Context, listing GalleryListing) (int, error) { + query := ` +INSERT INTO gallery_listings ( + "submitter_user_id", + "source_guild_id", + "listing_type", + "name", + "description", + "category", + "status", + "snapshot_data", + "title", + "content", + "colour", + "image_url", + "thumbnail_url", + "button_style", + "button_label", + "emoji_name", + "welcome_message" +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) +RETURNING "id";` + + listingType := listing.ListingType + if listingType == "" { + listingType = GalleryListingTypePanel + } + + var id int + err := t.QueryRow(ctx, query, + listing.SubmitterUserId, + listing.SourceGuildId, + listingType, + listing.Name, + listing.Description, + listing.Category, + listing.Status, + listing.SnapshotData, + listing.Title, + listing.Content, + listing.Colour, + listing.ImageUrl, + listing.ThumbnailUrl, + listing.ButtonStyle, + listing.ButtonLabel, + listing.EmojiName, + listing.WelcomeMessage, + ).Scan(&id) + + return id, err +} + +func (t *GalleryListingsTable) GetById(ctx context.Context, id int) (listing GalleryListing, ok bool, e error) { + query := `SELECT ` + galleryListingSelectColumns + ` FROM gallery_listings WHERE "id" = $1;` + + switch err := t.QueryRow(ctx, query, id).Scan(listing.fieldPtrs()...); err { + case nil: + ok = true + case pgx.ErrNoRows: + default: + e = err + } + + return +} + +// GetApproved returns paginated approved listings with optional filters. +// Sort must be "popular" (import_count DESC) or "recent" (created_at DESC). +// Search matches name or description using case-insensitive ILIKE. +// listingType filters by type when non-empty ("panel", "tag", "form"). +func (t *GalleryListingsTable) GetApproved(ctx context.Context, category string, tag string, search string, sort string, listingType string, page int, pageSize int) ([]GalleryListing, int, error) { + var conditions []string + var args []interface{} + + conditions = append(conditions, `"status" = 'approved'`) + + if listingType != "" { + args = append(args, listingType) + conditions = append(conditions, fmt.Sprintf(`"listing_type" = $%d`, len(args))) + } + + if category != "" { + args = append(args, category) + conditions = append(conditions, fmt.Sprintf(`"category" = $%d`, len(args))) + } + + if tag != "" { + args = append(args, tag) + conditions = append(conditions, fmt.Sprintf(`"id" IN (SELECT "listing_id" FROM gallery_listing_tags WHERE "tag" = $%d)`, len(args))) + } + + if search != "" { + args = append(args, "%"+search+"%") + argIdx := len(args) + conditions = append(conditions, fmt.Sprintf(`("name" ILIKE $%d OR "description" ILIKE $%d)`, argIdx, argIdx)) + } + + whereClause := "" + if len(conditions) > 0 { + whereClause = " WHERE " + strings.Join(conditions, " AND ") + } + + // Count total matching records + countQuery := "SELECT COUNT(*) FROM gallery_listings" + whereClause + var total int + if err := t.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil { + return nil, 0, err + } + + // Determine sort order + orderClause := ` ORDER BY "created_at" DESC` + if sort == "popular" { + orderClause = ` ORDER BY "import_count" DESC, "created_at" DESC` + } + + // Pagination + offset := (page - 1) * pageSize + args = append(args, pageSize) + limitClause := fmt.Sprintf(` LIMIT $%d`, len(args)) + args = append(args, offset) + offsetClause := fmt.Sprintf(` OFFSET $%d`, len(args)) + + query := "SELECT " + galleryListingSelectColumns + " FROM gallery_listings" + whereClause + orderClause + limitClause + offsetClause + + rows, err := t.Query(ctx, query, args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var listings []GalleryListing + for rows.Next() { + var listing GalleryListing + if err := rows.Scan(listing.fieldPtrs()...); err != nil { + return nil, 0, err + } + listings = append(listings, listing) + } + + return listings, total, nil +} + +// GetFeatured returns all approved featured listings. +func (t *GalleryListingsTable) GetFeatured(ctx context.Context) ([]GalleryListing, error) { + query := `SELECT ` + galleryListingSelectColumns + ` FROM gallery_listings WHERE "status" = 'approved' AND "featured" = true ORDER BY "import_count" DESC;` + + rows, err := t.Query(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + var listings []GalleryListing + for rows.Next() { + var listing GalleryListing + if err := rows.Scan(listing.fieldPtrs()...); err != nil { + return nil, err + } + listings = append(listings, listing) + } + + return listings, nil +} + +// GetBySubmitter returns all listings submitted by a given user. +func (t *GalleryListingsTable) GetBySubmitter(ctx context.Context, userId uint64) ([]GalleryListing, error) { + query := `SELECT ` + galleryListingSelectColumns + ` FROM gallery_listings WHERE "submitter_user_id" = $1 ORDER BY "created_at" DESC;` + + rows, err := t.Query(ctx, query, userId) + if err != nil { + return nil, err + } + defer rows.Close() + + var listings []GalleryListing + for rows.Next() { + var listing GalleryListing + if err := rows.Scan(listing.fieldPtrs()...); err != nil { + return nil, err + } + listings = append(listings, listing) + } + + return listings, nil +} + +// GetByGuild returns all listings from a given guild. +func (t *GalleryListingsTable) GetByGuild(ctx context.Context, guildId uint64) ([]GalleryListing, error) { + query := `SELECT ` + galleryListingSelectColumns + ` FROM gallery_listings WHERE "source_guild_id" = $1 ORDER BY "created_at" DESC;` + + rows, err := t.Query(ctx, query, guildId) + if err != nil { + return nil, err + } + defer rows.Close() + + var listings []GalleryListing + for rows.Next() { + var listing GalleryListing + if err := rows.Scan(listing.fieldPtrs()...); err != nil { + return nil, err + } + listings = append(listings, listing) + } + + return listings, nil +} + +// GetPending returns all listings with pending status for admin review. +func (t *GalleryListingsTable) GetPending(ctx context.Context) ([]GalleryListing, error) { + return t.GetByStatus(ctx, GalleryListingStatusPending) +} + +// GetByStatus returns all listings filtered by status. +func (t *GalleryListingsTable) GetByStatus(ctx context.Context, status GalleryListingStatus) ([]GalleryListing, error) { + query := `SELECT ` + galleryListingSelectColumns + ` FROM gallery_listings WHERE "status" = $1 ORDER BY "created_at" DESC;` + + rows, err := t.Query(ctx, query, status) + if err != nil { + return nil, err + } + defer rows.Close() + + var listings []GalleryListing + for rows.Next() { + var listing GalleryListing + if err := rows.Scan(listing.fieldPtrs()...); err != nil { + return nil, err + } + listings = append(listings, listing) + } + + return listings, nil +} + +// UpdateStatus updates the listing status along with reviewer information. +func (t *GalleryListingsTable) UpdateStatus(ctx context.Context, id int, status GalleryListingStatus, reviewedBy uint64, reviewNote *string) error { + query := ` +UPDATE gallery_listings +SET "status" = $2, "reviewed_by" = $3, "reviewed_at" = NOW(), "review_note" = $4, "updated_at" = NOW() +WHERE "id" = $1;` + + _, err := t.Exec(ctx, query, id, status, reviewedBy, reviewNote) + return err +} + +// Update updates mutable fields on a gallery listing (category, featured). +func (t *GalleryListingsTable) Update(ctx context.Context, id int, category *string, featured *bool) error { + var setClauses []string + var args []interface{} + args = append(args, id) // $1 is always the id + + if category != nil { + args = append(args, *category) + setClauses = append(setClauses, fmt.Sprintf(`"category" = $%d`, len(args))) + } + + if featured != nil { + args = append(args, *featured) + setClauses = append(setClauses, fmt.Sprintf(`"featured" = $%d`, len(args))) + } + + if len(setClauses) == 0 { + return nil + } + + setClauses = append(setClauses, `"updated_at" = NOW()`) + + query := `UPDATE gallery_listings SET ` + strings.Join(setClauses, ", ") + ` WHERE "id" = $1;` + _, err := t.Exec(ctx, query, args...) + return err +} + +// IncrementImportCount atomically increments the import count for a listing. +func (t *GalleryListingsTable) IncrementImportCount(ctx context.Context, id int) error { + query := `UPDATE gallery_listings SET "import_count" = "import_count" + 1, "updated_at" = NOW() WHERE "id" = $1;` + _, err := t.Exec(ctx, query, id) + return err +} + +// UpdateMetadata updates the name, description, and category of a listing. +func (t *GalleryListingsTable) UpdateMetadata(ctx context.Context, id int, name string, description string, category string) error { + query := `UPDATE gallery_listings SET "name" = $2, "description" = $3, "category" = $4, "updated_at" = NOW() WHERE "id" = $1;` + _, err := t.Exec(ctx, query, id, name, description, category) + return err +} + +// Resubmit replaces a listing's snapshot and metadata, resetting it for review. +func (t *GalleryListingsTable) Resubmit(ctx context.Context, listing GalleryListing) error { + query := ` +UPDATE gallery_listings SET + "name" = $2, + "description" = $3, + "category" = $4, + "status" = $5, + "review_note" = NULL, + "reviewed_by" = NULL, + "reviewed_at" = NULL, + "snapshot_data" = $6, + "title" = $7, + "content" = $8, + "colour" = $9, + "image_url" = $10, + "thumbnail_url" = $11, + "button_style" = $12, + "button_label" = $13, + "emoji_name" = $14, + "welcome_message" = $15, + "updated_at" = NOW() +WHERE "id" = $1;` + + _, err := t.Exec(ctx, query, + listing.Id, + listing.Name, + listing.Description, + listing.Category, + listing.Status, + listing.SnapshotData, + listing.Title, + listing.Content, + listing.Colour, + listing.ImageUrl, + listing.ThumbnailUrl, + listing.ButtonStyle, + listing.ButtonLabel, + listing.EmojiName, + listing.WelcomeMessage, + ) + return err +} + +// Delete removes a gallery listing by ID. +func (t *GalleryListingsTable) Delete(ctx context.Context, id int) error { + query := `DELETE FROM gallery_listings WHERE "id" = $1;` + _, err := t.Exec(ctx, query, id) + return err +} diff --git a/gallery_snapshots.go b/gallery_snapshots.go new file mode 100644 index 0000000..4c0e6f0 --- /dev/null +++ b/gallery_snapshots.go @@ -0,0 +1,43 @@ +package database + +type GalleryPanelSnapshot struct { + Title string `json:"title"` + Content string `json:"content"` + Colour int32 `json:"colour"` + ImageUrl *string `json:"image_url,omitempty"` + ThumbnailUrl *string `json:"thumbnail_url,omitempty"` + ButtonStyle *int16 `json:"button_style"` + ButtonLabel string `json:"button_label"` + EmojiName *string `json:"emoji_name,omitempty"` + WelcomeMessage []byte `json:"welcome_message,omitempty"` +} + +type GalleryTagSnapshot struct { + Content *string `json:"content,omitempty"` + Embed *CustomEmbedWithFields `json:"embed,omitempty"` +} + +type GalleryFormSnapshot struct { + Title string `json:"title"` + Inputs []GalleryFormInputSnapshot `json:"inputs"` +} + +type GalleryFormInputSnapshot struct { + Type int `json:"type"` + Position int `json:"position"` + Style uint8 `json:"style"` + Label string `json:"label"` + Description *string `json:"description,omitempty"` + Placeholder *string `json:"placeholder,omitempty"` + Required bool `json:"required"` + MinLength *uint16 `json:"min_length,omitempty"` + MaxLength *uint16 `json:"max_length,omitempty"` + Options []GalleryFormInputOptionSnapshot `json:"options,omitempty"` +} + +type GalleryFormInputOptionSnapshot struct { + Position int `json:"position"` + Label string `json:"label"` + Description *string `json:"description,omitempty"` + Value string `json:"value"` +} diff --git a/guildpurge.go b/guildpurge.go index 0f551e0..319038f 100644 --- a/guildpurge.go +++ b/guildpurge.go @@ -60,13 +60,10 @@ func (d *Database) PurgeGuildData(ctx context.Context, guildId uint64, logger *z // Other guild-specific tables "active_language", "archive_channel", - "auto_close", "blacklist", "channel_category", "claim_settings", - "close_confirmation", "custom_colours", - "feedback_enabled", "guild_metadata", "legacy_premium_entitlement_guilds", "naming_scheme", @@ -80,7 +77,6 @@ func (d *Database) PurgeGuildData(ctx context.Context, guildId uint64, logger *z "tags", "ticket_limit", "ticket_permissions", - "users_can_close", "user_guilds", "webhooks", "welcome_messages", diff --git a/kbarticle.go b/kbarticle.go new file mode 100644 index 0000000..fc4ab05 --- /dev/null +++ b/kbarticle.go @@ -0,0 +1,445 @@ +package database + +import ( + "context" + "time" + + "github.com/jackc/pgtype" + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" +) + +type KBArticle struct { + Id int `json:"id"` + GuildId uint64 `json:"guild_id,string"` + Title string `json:"title"` + Slug string `json:"slug"` + Description *string `json:"description"` + Content *string `json:"content"` + Embed *CustomEmbedWithFields `json:"embed"` + CategoryIds []int `json:"category_ids"` + Keywords []string `json:"keywords"` + Position int `json:"position"` + Published bool `json:"published"` + ShowHelpfulCount bool `json:"show_helpful_count"` + HelpfulCount int `json:"helpful_count"` + NotHelpfulCount int `json:"not_helpful_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type KBArticlesTable struct { + *pgxpool.Pool +} + +func newKBArticles(db *pgxpool.Pool) *KBArticlesTable { + return &KBArticlesTable{ + Pool: db, + } +} + +func (t KBArticlesTable) Schema() string { + return ` +CREATE TABLE IF NOT EXISTS kb_articles( + "id" SERIAL, + "guild_id" int8 NOT NULL, + "title" varchar(100) NOT NULL, + "slug" varchar(100) NOT NULL, + "description" varchar(255) DEFAULT NULL, + "content" text DEFAULT NULL CONSTRAINT kb_content_length CHECK (length(content) <= 4096), + "embed" JSONB DEFAULT NULL, + "category_ids" int4[] DEFAULT '{}', + "keywords" text[] DEFAULT '{}', + "position" int4 NOT NULL DEFAULT 0, + "published" bool NOT NULL DEFAULT true, + "show_helpful_count" bool NOT NULL DEFAULT false, + "helpful_count" int4 NOT NULL DEFAULT 0, + "not_helpful_count" int4 NOT NULL DEFAULT 0, + "search_vector" tsvector, + "created_at" timestamptz NOT NULL DEFAULT NOW(), + "updated_at" timestamptz NOT NULL DEFAULT NOW(), + PRIMARY KEY("id"), + UNIQUE("guild_id", "slug") +); +CREATE INDEX IF NOT EXISTS kb_articles_guild_id_idx ON kb_articles("guild_id"); +CREATE INDEX IF NOT EXISTS kb_articles_keywords_idx ON kb_articles USING GIN("keywords"); + +CREATE INDEX IF NOT EXISTS kb_articles_search_idx ON kb_articles USING GIN("search_vector"); + +CREATE OR REPLACE FUNCTION kb_articles_search_vector_update() RETURNS trigger AS $$ +BEGIN + NEW.search_vector := + setweight(to_tsvector('english', coalesce(NEW.title, '')), 'A') || + setweight(to_tsvector('english', coalesce(NEW.content, '')), 'B') || + setweight(to_tsvector('english', coalesce(array_to_string(NEW.keywords, ' '), '')), 'C'); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +ALTER TABLE kb_articles ADD COLUMN IF NOT EXISTS "show_helpful_count" bool NOT NULL DEFAULT false; +ALTER TABLE kb_articles ADD COLUMN IF NOT EXISTS "helpful_count" int4 NOT NULL DEFAULT 0; +ALTER TABLE kb_articles ADD COLUMN IF NOT EXISTS "not_helpful_count" int4 NOT NULL DEFAULT 0; + +DROP TRIGGER IF EXISTS kb_articles_search_vector_trigger ON kb_articles; +CREATE TRIGGER kb_articles_search_vector_trigger + BEFORE INSERT OR UPDATE ON kb_articles + FOR EACH ROW EXECUTE FUNCTION kb_articles_search_vector_update(); +` +} + +var kbArticleColumns = `"id", "guild_id", "title", "slug", "description", "content", "embed", "category_ids", "keywords", "position", "published", "show_helpful_count", "helpful_count", "not_helpful_count", "created_at", "updated_at"` + +func scanKBArticle(row pgx.Row) (KBArticle, error) { + var article KBArticle + var embedRaw *string + var categoryIds pgtype.Int4Array + var keywords pgtype.TextArray + + err := row.Scan( + &article.Id, + &article.GuildId, + &article.Title, + &article.Slug, + &article.Description, + &article.Content, + &embedRaw, + &categoryIds, + &keywords, + &article.Position, + &article.Published, + &article.ShowHelpfulCount, + &article.HelpfulCount, + &article.NotHelpfulCount, + &article.CreatedAt, + &article.UpdatedAt, + ) + if err != nil { + return article, err + } + + if embedRaw != nil { + if err := json.UnmarshalFromString(*embedRaw, &article.Embed); err != nil { + return article, err + } + } + + article.CategoryIds = make([]int, 0) + if categoryIds.Status == pgtype.Present { + for _, el := range categoryIds.Elements { + if el.Status == pgtype.Present { + article.CategoryIds = append(article.CategoryIds, int(el.Int)) + } + } + } + + article.Keywords = make([]string, 0) + if keywords.Status == pgtype.Present { + for _, el := range keywords.Elements { + if el.Status == pgtype.Present { + article.Keywords = append(article.Keywords, el.String) + } + } + } + + return article, nil +} + +func (t *KBArticlesTable) GetByGuild(ctx context.Context, guildId uint64) ([]KBArticle, error) { + query := ` +SELECT ` + kbArticleColumns + ` +FROM kb_articles +WHERE "guild_id" = $1 +ORDER BY "position" ASC, "id" ASC; +` + + rows, err := t.Query(ctx, query, guildId) + if err != nil { + return nil, err + } + defer rows.Close() + + var articles []KBArticle + for rows.Next() { + article, err := scanKBArticle(rows) + if err != nil { + return nil, err + } + articles = append(articles, article) + } + + return articles, nil +} + +func (t *KBArticlesTable) Get(ctx context.Context, id int) (KBArticle, bool, error) { + query := ` +SELECT ` + kbArticleColumns + ` +FROM kb_articles +WHERE "id" = $1; +` + + article, err := scanKBArticle(t.QueryRow(ctx, query, id)) + if err != nil { + if err == pgx.ErrNoRows { + return KBArticle{}, false, nil + } + return KBArticle{}, false, err + } + + return article, true, nil +} + +func (t *KBArticlesTable) GetBySlug(ctx context.Context, guildId uint64, slug string) (KBArticle, bool, error) { + query := ` +SELECT ` + kbArticleColumns + ` +FROM kb_articles +WHERE "guild_id" = $1 AND "slug" = $2 AND "published" = true; +` + + article, err := scanKBArticle(t.QueryRow(ctx, query, guildId, slug)) + if err != nil { + if err == pgx.ErrNoRows { + return KBArticle{}, false, nil + } + return KBArticle{}, false, err + } + + return article, true, nil +} + +func (t *KBArticlesTable) GetByCategory(ctx context.Context, guildId uint64, categoryId int) ([]KBArticle, error) { + query := ` +SELECT ` + kbArticleColumns + ` +FROM kb_articles +WHERE "guild_id" = $1 AND $2 = ANY("category_ids") AND "published" = true +ORDER BY "position" ASC, "id" ASC; +` + + rows, err := t.Query(ctx, query, guildId, categoryId) + if err != nil { + return nil, err + } + defer rows.Close() + + var articles []KBArticle + for rows.Next() { + article, err := scanKBArticle(rows) + if err != nil { + return nil, err + } + articles = append(articles, article) + } + + return articles, nil +} + +func (t *KBArticlesTable) Search(ctx context.Context, guildId uint64, query string, limit int) ([]KBArticle, error) { + // Use full-text search with ts_rank for relevance, plus ILIKE fallback for partial matches. + // Title matches (weight A) rank highest, then content (B), then keywords (C). + q := ` +SELECT ` + kbArticleColumns + ` +FROM kb_articles +WHERE "guild_id" = $1 AND "published" = true + AND ( + ("search_vector" IS NOT NULL AND "search_vector" @@ websearch_to_tsquery('english', $2)) + OR "title" ILIKE '%' || $2 || '%' + OR "content" ILIKE '%' || $2 || '%' + OR EXISTS (SELECT 1 FROM unnest("keywords") kw WHERE kw ILIKE '%' || $2 || '%') + ) +ORDER BY + ts_rank(coalesce("search_vector", ''::tsvector), websearch_to_tsquery('english', $2)) DESC, + CASE WHEN "title" ILIKE $2 || '%' THEN 0 ELSE 1 END, + "position" ASC +LIMIT $3; +` + + rows, err := t.Query(ctx, q, guildId, query, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var articles []KBArticle + for rows.Next() { + article, err := scanKBArticle(rows) + if err != nil { + return nil, err + } + articles = append(articles, article) + } + + return articles, nil +} + +func (t *KBArticlesTable) SearchContaining(ctx context.Context, guildId uint64, substring string, limit int) ([]KBArticleSummary, error) { + // Full-text search for autocomplete, with ILIKE fallback for partial/short queries + query := ` +SELECT "id", "title" +FROM kb_articles +WHERE "guild_id" = $1 AND "published" = true + AND ( + ("search_vector" IS NOT NULL AND "search_vector" @@ websearch_to_tsquery('english', $2)) + OR "title" ILIKE '%' || $2 || '%' + ) +ORDER BY + ts_rank(coalesce("search_vector", ''::tsvector), websearch_to_tsquery('english', $2)) DESC, + CASE WHEN "title" ILIKE $2 || '%' THEN 0 ELSE 1 END +LIMIT $3; +` + + rows, err := t.Query(ctx, query, guildId, substring, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []KBArticleSummary + for rows.Next() { + var s KBArticleSummary + if err := rows.Scan(&s.Id, &s.Title); err != nil { + return nil, err + } + results = append(results, s) + } + + return results, nil +} + +// KBArticleSummary is a lightweight projection returned by autocomplete searches. +type KBArticleSummary struct { + Id int `json:"id"` + Title string `json:"title"` +} + +func (t *KBArticlesTable) Create(ctx context.Context, article KBArticle) (int, error) { + query := ` +INSERT INTO kb_articles("guild_id", "title", "slug", "description", "content", "embed", "category_ids", "keywords", "position", "published", "show_helpful_count") +VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) +RETURNING "id"; +` + + var embedRaw *string + if article.Embed != nil { + tmp, err := json.MarshalToString(article.Embed) + if err != nil { + return 0, err + } + embedRaw = &tmp + } + + categoryIds := toInt4Array(article.CategoryIds) + keywords := toTextArray(article.Keywords) + + var id int + err := t.QueryRow(ctx, query, + article.GuildId, + article.Title, + article.Slug, + article.Description, + article.Content, + embedRaw, + categoryIds, + keywords, + article.Position, + article.Published, + article.ShowHelpfulCount, + ).Scan(&id) + return id, err +} + +func (t *KBArticlesTable) Update(ctx context.Context, article KBArticle) error { + query := ` +UPDATE kb_articles +SET "title" = $2, "slug" = $3, "description" = $4, "content" = $5, "embed" = $6, "category_ids" = $7, "keywords" = $8, "position" = $9, "published" = $10, "show_helpful_count" = $11, "updated_at" = NOW() +WHERE "id" = $1 AND "guild_id" = $12; +` + + var embedRaw *string + if article.Embed != nil { + tmp, err := json.MarshalToString(article.Embed) + if err != nil { + return err + } + embedRaw = &tmp + } + + categoryIds := toInt4Array(article.CategoryIds) + keywords := toTextArray(article.Keywords) + + _, err := t.Exec(ctx, query, + article.Id, + article.Title, + article.Slug, + article.Description, + article.Content, + embedRaw, + categoryIds, + keywords, + article.Position, + article.Published, + article.ShowHelpfulCount, + article.GuildId, + ) + return err +} + +// IncrementFeedback records one web vote. Kept out of Update so that saving an +// article cannot overwrite the tally. +func (t *KBArticlesTable) IncrementFeedback(ctx context.Context, guildId uint64, articleId int, helpful bool) error { + query := ` +UPDATE kb_articles +SET "helpful_count" = "helpful_count" + CASE WHEN $3 THEN 1 ELSE 0 END, + "not_helpful_count" = "not_helpful_count" + CASE WHEN $3 THEN 0 ELSE 1 END +WHERE "id" = $2 AND "guild_id" = $1; +` + + _, err := t.Exec(ctx, query, guildId, articleId, helpful) + return err +} + +func (t *KBArticlesTable) SetPositions(ctx context.Context, guildId uint64, ordered []int) error { + if len(ordered) == 0 { + return nil + } + + query := ` +UPDATE kb_articles +SET "position" = data.position, "updated_at" = "updated_at" +FROM (SELECT unnest($2::int[]) AS id, generate_subscripts($2::int[], 1) - 1 AS position) AS data +WHERE kb_articles."id" = data.id AND kb_articles."guild_id" = $1; +` + + _, err := t.Exec(ctx, query, guildId, toInt4Array(ordered)) + return err +} + +func (t *KBArticlesTable) Delete(ctx context.Context, guildId uint64, articleId int) error { + query := `DELETE FROM kb_articles WHERE "guild_id" = $1 AND "id" = $2;` + _, err := t.Exec(ctx, query, guildId, articleId) + return err +} + +func (t *KBArticlesTable) GetCountByGuild(ctx context.Context, guildId uint64) (int, error) { + query := `SELECT COUNT(*) FROM kb_articles WHERE "guild_id" = $1;` + var count int + err := t.QueryRow(ctx, query, guildId).Scan(&count) + return count, err +} + +func toInt4Array(ints []int) pgtype.Int4Array { + if len(ints) == 0 { + return pgtype.Int4Array{ + Status: pgtype.Present, + } + } + + elements := make([]pgtype.Int4, len(ints)) + for i, v := range ints { + elements[i] = pgtype.Int4{Int: int32(v), Status: pgtype.Present} + } + + return pgtype.Int4Array{ + Elements: elements, + Dimensions: []pgtype.ArrayDimension{{Length: int32(len(ints)), LowerBound: 1}}, + Status: pgtype.Present, + } +} diff --git a/kbarticlefeedback.go b/kbarticlefeedback.go new file mode 100644 index 0000000..f3c0ff5 --- /dev/null +++ b/kbarticlefeedback.go @@ -0,0 +1,60 @@ +package database + +import ( + "context" + + "github.com/jackc/pgx/v4/pgxpool" +) + +type KBArticleFeedbackTable struct { + *pgxpool.Pool +} + +func newKBArticleFeedback(db *pgxpool.Pool) *KBArticleFeedbackTable { + return &KBArticleFeedbackTable{ + Pool: db, + } +} + +func (t KBArticleFeedbackTable) Schema() string { + return ` +CREATE TABLE IF NOT EXISTS kb_article_feedback( + "id" serial NOT NULL, + "guild_id" int8 NOT NULL, + "article_id" int4 NOT NULL, + "panel_id" int4 NULL, + "user_id" int8 NOT NULL, + "helpful" bool NOT NULL, + "created_at" timestamptz NOT NULL DEFAULT now(), + FOREIGN KEY("article_id") REFERENCES kb_articles("id") ON DELETE CASCADE, + PRIMARY KEY("id") +); +CREATE UNIQUE INDEX IF NOT EXISTS kb_article_feedback_article_user_uindex ON kb_article_feedback("article_id", "user_id"); +CREATE INDEX IF NOT EXISTS kb_article_feedback_guild_id_idx ON kb_article_feedback("guild_id"); +` +} + +// Set records a single feedback vote for an article. Feedback is deduplicated per +// (article, user): a user's first vote is kept and later clicks are ignored, so the +// count reflects distinct users rather than clicks. +func (t *KBArticleFeedbackTable) Set(ctx context.Context, guildId uint64, articleId int, panelId *int, userId uint64, helpful bool) error { + query := ` +INSERT INTO kb_article_feedback("guild_id", "article_id", "panel_id", "user_id", "helpful") +VALUES($1, $2, $3, $4, $5) +ON CONFLICT("article_id", "user_id") DO NOTHING;` + + _, err := t.Exec(ctx, query, guildId, articleId, panelId, userId, helpful) + return err +} + +// CountHelpful returns the number of distinct users who marked an article as helpful. +func (t *KBArticleFeedbackTable) CountHelpful(ctx context.Context, articleId int) (int, error) { + query := `SELECT COUNT(*) FROM kb_article_feedback WHERE "article_id" = $1 AND "helpful" = true;` + + var count int + if err := t.QueryRow(ctx, query, articleId).Scan(&count); err != nil { + return 0, err + } + + return count, nil +} diff --git a/kbcategory.go b/kbcategory.go new file mode 100644 index 0000000..91c52f5 --- /dev/null +++ b/kbcategory.go @@ -0,0 +1,116 @@ +package database + +import ( + "context" + + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" +) + +type KBCategory struct { + Id int `json:"id"` + GuildId uint64 `json:"guild_id,string"` + Name string `json:"name"` + Emoji *string `json:"emoji"` + Position int `json:"position"` +} + +type KBCategoriesTable struct { + *pgxpool.Pool +} + +func newKBCategories(db *pgxpool.Pool) *KBCategoriesTable { + return &KBCategoriesTable{ + Pool: db, + } +} + +func (t KBCategoriesTable) Schema() string { + return ` +CREATE TABLE IF NOT EXISTS kb_categories( + "id" SERIAL, + "guild_id" int8 NOT NULL, + "name" varchar(50) NOT NULL, + "emoji" varchar(64) DEFAULT NULL, + "position" int4 NOT NULL DEFAULT 0, + PRIMARY KEY("id"), + UNIQUE("guild_id", "name") +); +CREATE INDEX IF NOT EXISTS kb_categories_guild_id_idx ON kb_categories("guild_id"); +` +} + +func (t *KBCategoriesTable) GetByGuild(ctx context.Context, guildId uint64) ([]KBCategory, error) { + query := ` +SELECT "id", "guild_id", "name", "emoji", "position" +FROM kb_categories +WHERE "guild_id" = $1 +ORDER BY "position" ASC, "id" ASC; +` + + rows, err := t.Query(ctx, query, guildId) + if err != nil { + return nil, err + } + defer rows.Close() + + var categories []KBCategory + for rows.Next() { + var cat KBCategory + if err := rows.Scan(&cat.Id, &cat.GuildId, &cat.Name, &cat.Emoji, &cat.Position); err != nil { + return nil, err + } + + categories = append(categories, cat) + } + + return categories, nil +} + +func (t *KBCategoriesTable) Get(ctx context.Context, id int) (KBCategory, bool, error) { + query := ` +SELECT "id", "guild_id", "name", "emoji", "position" +FROM kb_categories +WHERE "id" = $1; +` + + var cat KBCategory + err := t.QueryRow(ctx, query, id).Scan(&cat.Id, &cat.GuildId, &cat.Name, &cat.Emoji, &cat.Position) + if err != nil { + if err == pgx.ErrNoRows { + return KBCategory{}, false, nil + } + return KBCategory{}, false, err + } + + return cat, true, nil +} + +func (t *KBCategoriesTable) Create(ctx context.Context, cat KBCategory) (int, error) { + query := ` +INSERT INTO kb_categories("guild_id", "name", "emoji", "position") +VALUES($1, $2, $3, $4) +RETURNING "id"; +` + + var id int + err := t.QueryRow(ctx, query, cat.GuildId, cat.Name, cat.Emoji, cat.Position).Scan(&id) + return id, err +} + +func (t *KBCategoriesTable) Update(ctx context.Context, cat KBCategory) error { + query := ` +UPDATE kb_categories +SET "name" = $2, "emoji" = $3, "position" = $4 +WHERE "id" = $1 AND "guild_id" = $5; +` + + _, err := t.Exec(ctx, query, cat.Id, cat.Name, cat.Emoji, cat.Position, cat.GuildId) + return err +} + +func (t *KBCategoriesTable) Delete(ctx context.Context, guildId uint64, id int) error { + query := `DELETE FROM kb_categories WHERE "id" = $1 AND "guild_id" = $2;` + _, err := t.Exec(ctx, query, id, guildId) + return err +} diff --git a/kbsettings.go b/kbsettings.go new file mode 100644 index 0000000..6fd0bfc --- /dev/null +++ b/kbsettings.go @@ -0,0 +1,102 @@ +package database + +import ( + "context" + + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" +) + +type KBSettings struct { + GuildId uint64 `json:"guild_id,string"` + PrimaryBg *int `json:"primary_bg"` + CardBg *int `json:"card_bg"` + TextColour *int `json:"text_colour"` + AccentColour *int `json:"accent_colour"` + LogoUrl *string `json:"logo_url"` + HideBranding bool `json:"hide_branding"` +} + +type KBSettingsTable struct { + *pgxpool.Pool +} + +func newKBSettings(db *pgxpool.Pool) *KBSettingsTable { + return &KBSettingsTable{ + Pool: db, + } +} + +// Schema no longer creates the custom domain columns. Databases that already ran +// an earlier version still have custom_domain, domain_verified and the +// domain_status family, plus their indexes; nothing reads or writes them. +// +// They are deliberately not dropped here. DROP COLUMN is irreversible and this +// file runs on every service boot, so removing them is a decision to take +// explicitly rather than a side effect of deploying. +func (t KBSettingsTable) Schema() string { + return ` +CREATE TABLE IF NOT EXISTS kb_settings( + "guild_id" int8 NOT NULL PRIMARY KEY, + "primary_bg" int4 DEFAULT NULL, + "card_bg" int4 DEFAULT NULL, + "text_colour" int4 DEFAULT NULL, + "accent_colour" int4 DEFAULT NULL, + "logo_url" text DEFAULT NULL, + "hide_branding" bool NOT NULL DEFAULT false +); +` +} + +func (t *KBSettingsTable) Get(ctx context.Context, guildId uint64) (KBSettings, bool, error) { + query := ` +SELECT "guild_id", "primary_bg", "card_bg", "text_colour", "accent_colour", "logo_url", "hide_branding" +FROM kb_settings +WHERE "guild_id" = $1; +` + + var settings KBSettings + err := t.QueryRow(ctx, query, guildId).Scan( + &settings.GuildId, + &settings.PrimaryBg, + &settings.CardBg, + &settings.TextColour, + &settings.AccentColour, + &settings.LogoUrl, + &settings.HideBranding, + ) + + if err != nil { + if err == pgx.ErrNoRows { + return KBSettings{}, false, nil + } + return KBSettings{}, false, err + } + + return settings, true, nil +} + +func (t *KBSettingsTable) Set(ctx context.Context, settings KBSettings) error { + query := ` +INSERT INTO kb_settings("guild_id", "primary_bg", "card_bg", "text_colour", "accent_colour", "logo_url", "hide_branding") +VALUES($1, $2, $3, $4, $5, $6, $7) +ON CONFLICT("guild_id") DO UPDATE SET + "primary_bg" = $2, + "card_bg" = $3, + "text_colour" = $4, + "accent_colour" = $5, + "logo_url" = $6, + "hide_branding" = $7; +` + + _, err := t.Exec(ctx, query, + settings.GuildId, + settings.PrimaryBg, + settings.CardBg, + settings.TextColour, + settings.AccentColour, + settings.LogoUrl, + settings.HideBranding, + ) + return err +} diff --git a/multipaneltargets.go b/multipaneltargets.go index 43f869c..572ae44 100644 --- a/multipaneltargets.go +++ b/multipaneltargets.go @@ -56,6 +56,7 @@ SELECT panels.target_category, panels.emoji_name, panels.emoji_id, + panels.emoji_animated, panels.welcome_message, panels.default_team, panels.custom_id, @@ -69,7 +70,7 @@ SELECT panels.disabled, panels.exit_survey_form_id, panels.pending_category, - panels.delete_mentions, + panels.mention_behaviour, panels.transcript_channel_id, panels.use_threads, panels.ticket_notification_channel, @@ -78,6 +79,15 @@ SELECT panels.hide_close_button, panels.hide_close_with_reason_button, panels.hide_claim_button, + panels.show_in_open_command, + panels.store_transcripts, + panels.overflow_enabled, + panels.overflow_category_id, + panels.users_can_close, + panels.close_confirmation, + panels.feedback_enabled, + panels.support_can_view, + panels.support_can_type, multi_panel_targets.custom_label, multi_panel_targets.description, multi_panel_targets.custom_emoji_name, diff --git a/multiserverskus.go b/multiserverskus.go index 30e46fd..523db7a 100644 --- a/multiserverskus.go +++ b/multiserverskus.go @@ -19,6 +19,12 @@ var ( //go:embed sql/multi_server_skus/get_permitted_server_count.sql multiServerSkusGetPermittedServerCount string + + //go:embed sql/multi_server_skus/insert.sql + multiServerSkusInsert string + + //go:embed sql/multi_server_skus/delete.sql + multiServerSkusDelete string ) func newMultiServerSkusTable(db *pgxpool.Pool) *MultiServerSkus { @@ -31,6 +37,18 @@ func (MultiServerSkus) Schema() string { return multiServerSkusSchema } +// Upsert inserts or updates a multi-server SKU entry within a transaction. +func (m *MultiServerSkus) Upsert(ctx context.Context, tx pgx.Tx, skuId uuid.UUID, serversPermitted int) error { + _, err := tx.Exec(ctx, multiServerSkusInsert, skuId, serversPermitted) + return err +} + +// DeleteBySku removes a multi-server SKU entry by its SKU ID within a transaction. +func (m *MultiServerSkus) DeleteBySku(ctx context.Context, tx pgx.Tx, skuId uuid.UUID) error { + _, err := tx.Exec(ctx, multiServerSkusDelete, skuId) + return err +} + func (m *MultiServerSkus) GetPermittedServerCount(ctx context.Context, tx pgx.Tx, skuId uuid.UUID) (int, bool, error) { var count int if err := tx.QueryRow(ctx, multiServerSkusGetPermittedServerCount, skuId).Scan(&count); err != nil { diff --git a/namingscheme.go b/namingscheme.go deleted file mode 100644 index c05031c..0000000 --- a/namingscheme.go +++ /dev/null @@ -1,56 +0,0 @@ -package database - -import ( - "context" - "github.com/jackc/pgx/v4" - "github.com/jackc/pgx/v4/pgxpool" -) - -type NamingScheme string - -const ( - Id NamingScheme = "id" - Username NamingScheme = "username" -) - -type TicketNamingScheme struct { - *pgxpool.Pool -} - -func newTicketNamingScheme(db *pgxpool.Pool) *TicketNamingScheme { - return &TicketNamingScheme{ - db, - } -} - -func (t TicketNamingScheme) Schema() string { - return ` -CREATE TABLE IF NOT EXISTS naming_scheme( - "guild_id" int8 NOT NULL UNIQUE, - "naming_scheme" varchar(16) NOT NULL, - PRIMARY KEY("guild_id") -);` -} - -func (t *TicketNamingScheme) Get(ctx context.Context, guildId uint64) (ns NamingScheme, e error) { - query := `SELECT "naming_scheme" from naming_scheme WHERE "guild_id" = $1` - - var namingScheme string - if err := t.QueryRow(ctx, query, guildId).Scan(&namingScheme); err != nil && err != pgx.ErrNoRows { - e = err - } - - if namingScheme == "" { - ns = Id - } else { - ns = NamingScheme(namingScheme) - } - - return -} - -func (t *TicketNamingScheme) Set(ctx context.Context, guildId uint64, scheme NamingScheme) (err error) { - query := `INSERT INTO naming_scheme("guild_id", "naming_scheme") VALUES($1, $2) ON CONFLICT("guild_id") DO UPDATE SET "naming_scheme" = $2;` - _, err = t.Exec(ctx, query, guildId, scheme) - return -} diff --git a/notificationpreferences.go b/notificationpreferences.go new file mode 100644 index 0000000..cc45699 --- /dev/null +++ b/notificationpreferences.go @@ -0,0 +1,80 @@ +package database + +import ( + "context" + _ "embed" + "errors" + + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" +) + +type NotificationPreferencesTable struct { + *pgxpool.Pool +} + +type NotificationPreference struct { + UserId uint64 `json:"user_id"` + Category string `json:"category"` + DiscordDm bool `json:"discord_dm"` + Email bool `json:"email"` + InApp bool `json:"in_app"` +} + +var ( + //go:embed sql/notification_preferences/schema.sql + notificationPreferencesSchema string + + //go:embed sql/notification_preferences/get_by_user_id.sql + notificationPreferencesGetByUserId string + + //go:embed sql/notification_preferences/get_by_user_id_and_category.sql + notificationPreferencesGetByUserIdAndCategory string + + //go:embed sql/notification_preferences/upsert.sql + notificationPreferencesUpsert string +) + +func newNotificationPreferencesTable(pool *pgxpool.Pool) *NotificationPreferencesTable { + return &NotificationPreferencesTable{pool} +} + +func (NotificationPreferencesTable) Schema() string { + return notificationPreferencesSchema +} + +func (t *NotificationPreferencesTable) GetByUserId(ctx context.Context, userId uint64) ([]NotificationPreference, error) { + rows, err := t.Query(ctx, notificationPreferencesGetByUserId, userId) + if err != nil { + return nil, err + } + defer rows.Close() + + var prefs []NotificationPreference + for rows.Next() { + var p NotificationPreference + if err := rows.Scan(&p.UserId, &p.Category, &p.DiscordDm, &p.Email, &p.InApp); err != nil { + return nil, err + } + prefs = append(prefs, p) + } + return prefs, nil +} + +func (t *NotificationPreferencesTable) GetByUserIdAndCategory(ctx context.Context, userId uint64, category string) (*NotificationPreference, error) { + var p NotificationPreference + if err := t.QueryRow(ctx, notificationPreferencesGetByUserIdAndCategory, userId, category).Scan( + &p.UserId, &p.Category, &p.DiscordDm, &p.Email, &p.InApp, + ); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &p, nil +} + +func (t *NotificationPreferencesTable) Upsert(ctx context.Context, userId uint64, category string, discordDm, email, inApp bool) error { + _, err := t.Exec(ctx, notificationPreferencesUpsert, userId, category, discordDm, email, inApp) + return err +} diff --git a/notifications.go b/notifications.go new file mode 100644 index 0000000..d41b182 --- /dev/null +++ b/notifications.go @@ -0,0 +1,127 @@ +package database + +import ( + "context" + _ "embed" + "time" + + "github.com/jackc/pgx/v4/pgxpool" +) + +type NotificationsTable struct { + *pgxpool.Pool +} + +type Notification struct { + Id int64 `json:"id"` + UserId uint64 `json:"user_id"` + Category string `json:"category"` + Title string `json:"title"` + Body string `json:"body"` + Link *string `json:"link"` + Read bool `json:"read"` + CreatedAt time.Time `json:"created_at"` +} + +var ( + //go:embed sql/notifications/schema.sql + notificationsSchema string + + //go:embed sql/notifications/create.sql + notificationsCreate string + + //go:embed sql/notifications/list_by_user_id.sql + notificationsListByUserId string + + //go:embed sql/notifications/count_unread_by_user_id.sql + notificationsCountUnreadByUserId string + + //go:embed sql/notifications/count_by_user_id.sql + notificationsCountByUserId string + + //go:embed sql/notifications/mark_as_read.sql + notificationsMarkAsRead string + + //go:embed sql/notifications/mark_all_as_read.sql + notificationsMarkAllAsRead string +) + +func newNotificationsTable(pool *pgxpool.Pool) *NotificationsTable { + return &NotificationsTable{pool} +} + +func (NotificationsTable) Schema() string { + return notificationsSchema +} + +func (t *NotificationsTable) Create(ctx context.Context, userId uint64, category, title, body string, link *string) (Notification, error) { + if len(title) > 200 { + title = title[:200] + } + if len(body) > 2000 { + body = body[:2000] + } + + var id int64 + var createdAt time.Time + if err := t.QueryRow(ctx, notificationsCreate, userId, category, title, body, link).Scan(&id, &createdAt); err != nil { + return Notification{}, err + } + + return Notification{ + Id: id, + UserId: userId, + Category: category, + Title: title, + Body: body, + Link: link, + Read: false, + CreatedAt: createdAt, + }, nil +} + +func (t *NotificationsTable) ListByUserId(ctx context.Context, userId uint64, categories []string, limit, offset int) ([]Notification, error) { + rows, err := t.Query(ctx, notificationsListByUserId, userId, categories, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var notifications []Notification + for rows.Next() { + var n Notification + if err := rows.Scan( + &n.Id, &n.UserId, &n.Category, &n.Title, &n.Body, &n.Link, &n.Read, &n.CreatedAt, + ); err != nil { + return nil, err + } + notifications = append(notifications, n) + } + return notifications, nil +} + +func (t *NotificationsTable) CountUnreadByUserId(ctx context.Context, userId uint64) (int, error) { + var count int + if err := t.QueryRow(ctx, notificationsCountUnreadByUserId, userId).Scan(&count); err != nil { + return 0, err + } + return count, nil +} + +func (t *NotificationsTable) CountByUserId(ctx context.Context, userId uint64, categories []string) (int, error) { + var count int + if err := t.QueryRow(ctx, notificationsCountByUserId, userId, categories).Scan(&count); err != nil { + return 0, err + } + return count, nil +} + +func (t *NotificationsTable) MarkAsRead(ctx context.Context, id int64, userId uint64) error { + _, err := t.Exec(ctx, notificationsMarkAsRead, id, userId) + return err +} + +func (t *NotificationsTable) MarkAllAsRead(ctx context.Context, userId uint64) error { + _, err := t.Exec(ctx, notificationsMarkAllAsRead, userId) + return err +} diff --git a/panelautoclose.go b/panelautoclose.go new file mode 100644 index 0000000..35b05bb --- /dev/null +++ b/panelautoclose.go @@ -0,0 +1,64 @@ +package database + +import ( + "context" + "time" + + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" +) + +type PanelAutoCloseSettings struct { + Enabled bool `json:"enabled"` + SinceOpenWithNoResponse *time.Duration `json:"since_open_with_no_response"` + SinceLastMessage *time.Duration `json:"since_last_message"` + OnUserLeave *bool `json:"on_user_leave"` +} + +type PanelAutoCloseTable struct { + *pgxpool.Pool +} + +func newPanelAutoCloseTable(db *pgxpool.Pool) *PanelAutoCloseTable { + return &PanelAutoCloseTable{db} +} + +func (PanelAutoCloseTable) Schema() string { + return ` +CREATE TABLE IF NOT EXISTS panel_auto_close( + "panel_id" int NOT NULL, + "enabled" bool NOT NULL DEFAULT false, + "since_open_with_no_response" interval, + "since_last_message" interval, + "on_user_leave" bool, + FOREIGN KEY("panel_id") REFERENCES panels("panel_id") ON DELETE CASCADE, + PRIMARY KEY("panel_id") +);` +} + +func (t *PanelAutoCloseTable) Get(ctx context.Context, panelId int) (settings PanelAutoCloseSettings, e error) { + query := `SELECT "enabled", "since_open_with_no_response", "since_last_message", "on_user_leave" FROM panel_auto_close WHERE "panel_id" = $1;` + if err := t.QueryRow(ctx, query, panelId).Scan(&settings.Enabled, &settings.SinceOpenWithNoResponse, &settings.SinceLastMessage, &settings.OnUserLeave); err != nil && err != pgx.ErrNoRows { + e = err + } + return +} + +func (t *PanelAutoCloseTable) Set(ctx context.Context, panelId int, settings PanelAutoCloseSettings) (err error) { + query := ` +INSERT INTO panel_auto_close("panel_id", "enabled", "since_open_with_no_response", "since_last_message", "on_user_leave") +VALUES ($1, $2, $3, $4, $5) +ON CONFLICT("panel_id") DO UPDATE SET + "enabled" = $2, + "since_open_with_no_response" = $3, + "since_last_message" = $4, + "on_user_leave" = $5;` + + _, err = t.Exec(ctx, query, panelId, settings.Enabled, settings.SinceOpenWithNoResponse, settings.SinceLastMessage, settings.OnUserLeave) + return +} + +func (t *PanelAutoCloseTable) Delete(ctx context.Context, panelId int) (err error) { + _, err = t.Exec(ctx, `DELETE FROM panel_auto_close WHERE "panel_id" = $1;`, panelId) + return +} diff --git a/panelfilter.go b/panelfilter.go new file mode 100644 index 0000000..8ed17a2 --- /dev/null +++ b/panelfilter.go @@ -0,0 +1,85 @@ +package database + +import ( + "fmt" + + "github.com/jackc/pgtype" +) + +// PanelFilter restricts analytics queries to a subset of panels. A nil +// *PanelFilter means no filtering (all panels, including unassigned). +type PanelFilter struct { + PanelIds []int + IncludeUnassigned bool +} + +// PanelPredicate renders a SQL predicate that filters on panel_id, prefixed +// with " AND ". The caller supplies the table alias and the two parameter +// numbers to bind against. Parameter numbers are stated explicitly rather than +// auto-incremented because several queries reference the predicate more than +// once and some bind parameters out of textual order. +// +// The clause consumes exactly two parameters: +// +// $arrayParam ::int4[] panel IDs, or NULL for "no filter" +// $unassignedParam ::bool whether to include tickets with panel_id IS NULL +func PanelPredicate(alias string, arrayParam, unassignedParam int) string { + return fmt.Sprintf( + ` AND ($%d::int4[] IS NULL OR %s.panel_id = ANY($%d::int4[]) OR ($%d::bool AND %s.panel_id IS NULL))`, + arrayParam, alias, arrayParam, unassignedParam, alias, + ) +} + +// Args returns the two bound values for PanelPredicate: the int4 array and the +// bool. Safe on a nil receiver, where it returns the "no filter" pair (NULL +// array, false). +// +// The empty-versus-NULL distinction matters and is the highest-probability +// silent bug in this change: +// +// Set(nil) -> Status: Null -> $N IS NULL matches everything +// Set([]int32{}) -> present, zero-dimension -> $N IS NULL is false, ANY($N) matches nothing +// +// Getting this backwards makes "unassigned only" silently mean "everything". +func (f *PanelFilter) Args() (pgtype.Int4Array, bool, error) { + if f == nil { + return pgtype.Int4Array{Status: pgtype.Null}, false, nil + } + + if len(f.PanelIds) == 0 && !f.IncludeUnassigned { + // Neither panels nor unassigned selected: no filter. + return pgtype.Int4Array{Status: pgtype.Null}, false, nil + } + + var arr pgtype.Int4Array + + if len(f.PanelIds) == 0 { + // IncludeUnassigned is true but no panel IDs: "unassigned only". + // Must be present-empty (not NULL) so the IS NULL check in the + // predicate correctly excludes all panel-assigned tickets. + if err := arr.Set([]int32{}); err != nil { + return pgtype.Int4Array{}, false, fmt.Errorf("panel filter: setting empty array: %w", err) + } + return arr, true, nil + } + + ids := make([]int32, len(f.PanelIds)) + for i, id := range f.PanelIds { + ids[i] = int32(id) + } + + if err := arr.Set(ids); err != nil { + return pgtype.Int4Array{}, false, fmt.Errorf("panel filter: setting array: %w", err) + } + + return arr, f.IncludeUnassigned, nil +} + +// IsActive reports whether the filter narrows anything. A nil filter is not +// active (it matches all tickets). +func (f *PanelFilter) IsActive() bool { + if f == nil { + return false + } + return len(f.PanelIds) > 0 || f.IncludeUnassigned +} diff --git a/panelkbcategory.go b/panelkbcategory.go new file mode 100644 index 0000000..48faa08 --- /dev/null +++ b/panelkbcategory.go @@ -0,0 +1,81 @@ +package database + +import ( + "context" + + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" +) + +type PanelKBCategoriesTable struct { + *pgxpool.Pool +} + +func newPanelKBCategories(db *pgxpool.Pool) *PanelKBCategoriesTable { + return &PanelKBCategoriesTable{ + Pool: db, + } +} + +func (t PanelKBCategoriesTable) Schema() string { + return ` +CREATE TABLE IF NOT EXISTS panel_kb_categories( + "panel_id" int4 NOT NULL, + "category_id" int4 NOT NULL, + PRIMARY KEY("panel_id", "category_id") +); +` +} + +func (t *PanelKBCategoriesTable) GetByPanel(ctx context.Context, panelId int) ([]int, error) { + query := `SELECT "category_id" FROM panel_kb_categories WHERE "panel_id" = $1;` + + rows, err := t.Query(ctx, query, panelId) + if err != nil { + return nil, err + } + defer rows.Close() + + var categoryIds []int + for rows.Next() { + var id int + if err := rows.Scan(&id); err != nil { + return nil, err + } + categoryIds = append(categoryIds, id) + } + + return categoryIds, nil +} + +func (t *PanelKBCategoriesTable) Set(ctx context.Context, panelId int, categoryIds []int) error { + tx, err := t.Begin(ctx) + if err != nil { + return err + } + + defer tx.Rollback(ctx) + + if err := t.SetWithTx(ctx, tx, panelId, categoryIds); err != nil { + return err + } + + return tx.Commit(ctx) +} + +func (t *PanelKBCategoriesTable) SetWithTx(ctx context.Context, tx pgx.Tx, panelId int, categoryIds []int) error { + // Remove existing category associations for this panel + if _, err := tx.Exec(ctx, `DELETE FROM panel_kb_categories WHERE "panel_id" = $1;`, panelId); err != nil { + return err + } + + // Insert each provided category association + for _, categoryId := range categoryIds { + query := `INSERT INTO panel_kb_categories("panel_id", "category_id") VALUES($1, $2) ON CONFLICT("panel_id", "category_id") DO NOTHING;` + if _, err := tx.Exec(ctx, query, panelId, categoryId); err != nil { + return err + } + } + + return nil +} diff --git a/panels.go b/panels.go index e07f92b..c87458a 100644 --- a/panels.go +++ b/panels.go @@ -8,38 +8,48 @@ import ( ) type Panel struct { - PanelId int `json:"panel_id"` - MessageId uint64 `json:"message_id,string"` - ChannelId uint64 `json:"channel_id,string"` - GuildId uint64 `json:"guild_id,string"` - Title string `json:"title"` - Content string `json:"content"` - Colour int32 `json:"colour"` - TargetCategory uint64 `json:"category_id,string"` - EmojiName *string `json:"emoji_name"` - EmojiId *uint64 `json:"emoji_id,string"` - WelcomeMessageEmbed *int `json:"welcome_message_embed"` - WithDefaultTeam bool `json:"default_team"` - CustomId string `json:"custom_id"` - ImageUrl *string `json:"image_url,omitempty"` - ThumbnailUrl *string `json:"thumbnail_url,omitempty"` - ButtonStyle int `json:"button_style"` - ButtonLabel string `json:"button_label"` - FormId *int `json:"form_id"` - NamingScheme *string `json:"naming_scheme"` - ForceDisabled bool `json:"force_disabled"` - Disabled bool `json:"disabled"` - ExitSurveyFormId *int `json:"exit_survey_form_id"` - PendingCategory *uint64 `json:"pending_category,string"` - DeleteMentions bool `json:"delete_mentions"` - TranscriptChannelId *uint64 `json:"transcript_channel_id,string,omitempty"` - UseThreads bool `json:"use_threads"` - TicketNotificationChannel *uint64 `json:"ticket_notification_channel,string,omitempty"` - CooldownSeconds int `json:"cooldown_seconds"` - TicketLimit *uint8 `json:"ticket_limit,omitempty"` - HideCloseButton bool `json:"hide_close_button"` - HideCloseWithReasonButton bool `json:"hide_close_with_reason_button"` - HideClaimButton bool `json:"hide_claim_button"` + PanelId int `json:"panel_id"` + MessageId uint64 `json:"message_id,string"` + ChannelId uint64 `json:"channel_id,string"` + GuildId uint64 `json:"guild_id,string"` + Title string `json:"title"` + Content string `json:"content"` + Colour int32 `json:"colour"` + TargetCategory uint64 `json:"category_id,string"` + EmojiName *string `json:"emoji_name"` + EmojiId *uint64 `json:"emoji_id,string"` + EmojiAnimated bool `json:"emoji_animated"` + WelcomeMessageEmbed *int `json:"welcome_message_embed"` + WithDefaultTeam bool `json:"default_team"` + CustomId string `json:"custom_id"` + ImageUrl *string `json:"image_url,omitempty"` + ThumbnailUrl *string `json:"thumbnail_url,omitempty"` + ButtonStyle int `json:"button_style,string"` + ButtonLabel string `json:"button_label"` + FormId *int `json:"form_id"` + NamingScheme *string `json:"naming_scheme"` + ForceDisabled bool `json:"force_disabled"` + Disabled bool `json:"disabled"` + ExitSurveyFormId *int `json:"exit_survey_form_id"` + PendingCategory *uint64 `json:"pending_category,string"` + MentionBehaviour string `json:"mention_behaviour"` + TranscriptChannelId *uint64 `json:"transcript_channel_id,string,omitempty"` + UseThreads bool `json:"use_threads"` + TicketNotificationChannel *uint64 `json:"ticket_notification_channel,string,omitempty"` + CooldownSeconds int `json:"cooldown_seconds"` + TicketLimit *uint8 `json:"ticket_limit,omitempty"` + HideCloseButton bool `json:"hide_close_button"` + HideCloseWithReasonButton bool `json:"hide_close_with_reason_button"` + HideClaimButton bool `json:"hide_claim_button"` + ShowInOpenCommand bool `json:"show_in_open_command"` + StoreTranscripts bool `json:"store_transcripts"` + OverflowEnabled bool `json:"overflow_enabled"` + OverflowCategoryId *uint64 `json:"overflow_category_id,string"` + UsersCanClose bool `json:"users_can_close"` + CloseConfirmation bool `json:"close_confirmation"` + FeedbackEnabled bool `json:"feedback_enabled"` + SupportCanView bool `json:"support_can_view"` + SupportCanType bool `json:"support_can_type"` } type PanelWithWelcomeMessage struct { @@ -71,6 +81,7 @@ CREATE TABLE IF NOT EXISTS panels( "target_category" int8 NOT NULL, "emoji_name" varchar(32) DEFAULT NULL, "emoji_id" int8 DEFAULT NULL, + "emoji_animated" bool NOT NULL DEFAULT false, "welcome_message" int NULL, "default_team" bool NOT NULL, "custom_id" varchar(100) NOT NULL, @@ -84,7 +95,7 @@ CREATE TABLE IF NOT EXISTS panels( "disabled" bool NOT NULL DEFAULT false, "exit_survey_form_id" int DEFAULT NULL, "pending_category" int8 DEFAULT NULL, - "delete_mentions" bool NOT NULL DEFAULT false, + "mention_behaviour" varchar(20) NOT NULL DEFAULT 'none', "transcript_channel_id" int8 DEFAULT NULL, "use_threads" bool NOT NULL DEFAULT false, "ticket_notification_channel" int8 DEFAULT NULL, @@ -93,6 +104,15 @@ CREATE TABLE IF NOT EXISTS panels( "hide_close_button" bool NOT NULL DEFAULT false, "hide_close_with_reason_button" bool NOT NULL DEFAULT false, "hide_claim_button" bool NOT NULL DEFAULT false, + "show_in_open_command" bool NOT NULL DEFAULT false, + "store_transcripts" bool NOT NULL DEFAULT true, + "overflow_enabled" bool NOT NULL DEFAULT false, + "overflow_category_id" int8 DEFAULT NULL, + "users_can_close" bool NOT NULL DEFAULT true, + "close_confirmation" bool NOT NULL DEFAULT true, + "feedback_enabled" bool NOT NULL DEFAULT false, + "support_can_view" bool NOT NULL DEFAULT true, + "support_can_type" bool NOT NULL DEFAULT false, FOREIGN KEY ("welcome_message") REFERENCES embeds("id") ON DELETE SET NULL, FOREIGN KEY ("form_id") REFERENCES forms("form_id"), FOREIGN KEY ("exit_survey_form_id") REFERENCES forms("form_id"), @@ -118,6 +138,7 @@ SELECT target_category, emoji_name, emoji_id, + emoji_animated, welcome_message, default_team, custom_id, @@ -131,7 +152,7 @@ SELECT disabled, exit_survey_form_id, pending_category, - delete_mentions, + mention_behaviour, transcript_channel_id, use_threads, ticket_notification_channel, @@ -139,7 +160,16 @@ SELECT ticket_limit, hide_close_button, hide_close_with_reason_button, - hide_claim_button + hide_claim_button, + show_in_open_command, + store_transcripts, + overflow_enabled, + overflow_category_id, + users_can_close, + close_confirmation, + feedback_enabled, + support_can_view, + support_can_type FROM panels WHERE "message_id" = $1; ` @@ -165,6 +195,7 @@ SELECT target_category, emoji_name, emoji_id, + emoji_animated, welcome_message, default_team, custom_id, @@ -178,7 +209,7 @@ SELECT disabled, exit_survey_form_id, pending_category, - delete_mentions, + mention_behaviour, transcript_channel_id, use_threads, ticket_notification_channel, @@ -186,7 +217,16 @@ SELECT ticket_limit, hide_close_button, hide_close_with_reason_button, - hide_claim_button + hide_claim_button, + show_in_open_command, + store_transcripts, + overflow_enabled, + overflow_category_id, + users_can_close, + close_confirmation, + feedback_enabled, + support_can_view, + support_can_type FROM panels WHERE "panel_id" = $1; ` @@ -212,6 +252,7 @@ SELECT panels.target_category, panels.emoji_name, panels.emoji_id, + panels.emoji_animated, panels.welcome_message, panels.default_team, panels.custom_id, @@ -225,7 +266,7 @@ SELECT panels.disabled, panels.exit_survey_form_id, panels.pending_category, - panels.delete_mentions, + panels.mention_behaviour, panels.transcript_channel_id, panels.use_threads, panels.ticket_notification_channel, @@ -234,6 +275,15 @@ SELECT panels.hide_close_button, panels.hide_close_with_reason_button, panels.hide_claim_button, + panels.show_in_open_command, + panels.store_transcripts, + panels.overflow_enabled, + panels.overflow_category_id, + panels.users_can_close, + panels.close_confirmation, + panels.feedback_enabled, + panels.support_can_view, + panels.support_can_type, embeds.id, embeds.guild_id, embeds.title, @@ -321,6 +371,7 @@ SELECT target_category, emoji_name, emoji_id, + emoji_animated, welcome_message, default_team, custom_id, @@ -334,7 +385,7 @@ SELECT disabled, exit_survey_form_id, pending_category, - delete_mentions, + mention_behaviour, transcript_channel_id, use_threads, ticket_notification_channel, @@ -342,7 +393,16 @@ SELECT ticket_limit, hide_close_button, hide_close_with_reason_button, - hide_claim_button + hide_claim_button, + show_in_open_command, + store_transcripts, + overflow_enabled, + overflow_category_id, + users_can_close, + close_confirmation, + feedback_enabled, + support_can_view, + support_can_type FROM panels WHERE "guild_id" = $1 AND "custom_id" = $2; ` @@ -371,6 +431,7 @@ SELECT target_category, emoji_name, emoji_id, + emoji_animated, welcome_message, default_team, custom_id, @@ -384,7 +445,7 @@ SELECT disabled, exit_survey_form_id, pending_category, - delete_mentions, + mention_behaviour, transcript_channel_id, use_threads, ticket_notification_channel, @@ -392,7 +453,16 @@ SELECT ticket_limit, hide_close_button, hide_close_with_reason_button, - hide_claim_button + hide_claim_button, + show_in_open_command, + store_transcripts, + overflow_enabled, + overflow_category_id, + users_can_close, + close_confirmation, + feedback_enabled, + support_can_view, + support_can_type FROM panels WHERE "guild_id" = $1 AND "form_id" = $2; ` @@ -421,6 +491,7 @@ SELECT panels.target_category, panels.emoji_name, panels.emoji_id, + panels.emoji_animated, panels.welcome_message, panels.default_team, panels.custom_id, @@ -434,7 +505,7 @@ SELECT panels.disabled, panels.exit_survey_form_id, panels.pending_category, - panels.delete_mentions, + panels.mention_behaviour, panels.transcript_channel_id, panels.use_threads, panels.ticket_notification_channel, @@ -443,10 +514,19 @@ SELECT panels.hide_close_button, panels.hide_close_with_reason_button, panels.hide_claim_button, + panels.show_in_open_command, + panels.store_transcripts, + panels.overflow_enabled, + panels.overflow_category_id, + panels.users_can_close, + panels.close_confirmation, + panels.feedback_enabled, + panels.support_can_view, + panels.support_can_type FROM panels INNER JOIN forms ON forms.form_id = panels.form_id -WHERE forms.guild_id = $1 AND forms.form_id = $2; +WHERE forms.guild_id = $1 AND forms.custom_id = $2; ` switch err := p.QueryRow(ctx, query, guildId, customId).Scan(panel.fieldPtrs()...); err { @@ -473,6 +553,7 @@ SELECT target_category, emoji_name, emoji_id, + emoji_animated, welcome_message, default_team, custom_id, @@ -486,7 +567,7 @@ SELECT disabled, exit_survey_form_id, pending_category, - delete_mentions, + mention_behaviour, transcript_channel_id, use_threads, ticket_notification_channel, @@ -494,7 +575,16 @@ SELECT ticket_limit, hide_close_button, hide_close_with_reason_button, - hide_claim_button + hide_claim_button, + show_in_open_command, + store_transcripts, + overflow_enabled, + overflow_category_id, + users_can_close, + close_confirmation, + feedback_enabled, + support_can_view, + support_can_type FROM panels WHERE "guild_id" = $1 ORDER BY "panel_id" ASC;` @@ -530,6 +620,7 @@ SELECT panels.target_category, panels.emoji_name, panels.emoji_id, + panels.emoji_animated, panels.welcome_message, panels.default_team, panels.custom_id, @@ -543,7 +634,7 @@ SELECT panels.disabled, panels.exit_survey_form_id, panels.pending_category, - panels.delete_mentions, + panels.mention_behaviour, panels.transcript_channel_id, panels.use_threads, panels.ticket_notification_channel, @@ -552,6 +643,15 @@ SELECT panels.hide_close_button, panels.hide_close_with_reason_button, panels.hide_claim_button, + panels.show_in_open_command, + panels.store_transcripts, + panels.overflow_enabled, + panels.overflow_category_id, + panels.users_can_close, + panels.close_confirmation, + panels.feedback_enabled, + panels.support_can_view, + panels.support_can_type, embeds.id, embeds.guild_id, embeds.title, @@ -656,6 +756,7 @@ INSERT INTO panels( "target_category", "emoji_name", "emoji_id", + "emoji_animated", "welcome_message", "default_team", "custom_id", @@ -669,7 +770,7 @@ INSERT INTO panels( "disabled", "exit_survey_form_id", "pending_category", - "delete_mentions", + "mention_behaviour", "transcript_channel_id", "use_threads", "ticket_notification_channel", @@ -677,9 +778,18 @@ INSERT INTO panels( "ticket_limit", "hide_close_button", "hide_close_with_reason_button", - "hide_claim_button" + "hide_claim_button", + "show_in_open_command", + "store_transcripts", + "overflow_enabled", + "overflow_category_id", + "users_can_close", + "close_confirmation", + "feedback_enabled", + "support_can_view", + "support_can_type" ) -VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31) +VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41) ON CONFLICT("message_id") DO NOTHING RETURNING "panel_id";` @@ -693,6 +803,7 @@ RETURNING "panel_id";` panel.TargetCategory, panel.EmojiName, panel.EmojiId, + panel.EmojiAnimated, panel.WelcomeMessageEmbed, panel.WithDefaultTeam, panel.CustomId, @@ -706,7 +817,7 @@ RETURNING "panel_id";` panel.Disabled, panel.ExitSurveyFormId, panel.PendingCategory, - panel.DeleteMentions, + panel.MentionBehaviour, panel.TranscriptChannelId, panel.UseThreads, panel.TicketNotificationChannel, @@ -715,6 +826,15 @@ RETURNING "panel_id";` panel.HideCloseButton, panel.HideCloseWithReasonButton, panel.HideClaimButton, + panel.ShowInOpenCommand, + panel.StoreTranscripts, + panel.OverflowEnabled, + panel.OverflowCategoryId, + panel.UsersCanClose, + panel.CloseConfirmation, + panel.FeedbackEnabled, + panel.SupportCanView, + panel.SupportCanType, ).Scan(&panelId) return @@ -746,28 +866,38 @@ UPDATE panels "target_category" = $7, "emoji_name" = $8, "emoji_id" = $9, - "welcome_message" = $10, - "default_team" = $11, - "custom_id" = $12, - "image_url" = $13, - "thumbnail_url" = $14, - "button_style" = $15, - "button_label" = $16, - "form_id" = $17, - "naming_scheme" = $18, - "force_disabled" = $19, - "disabled" = $20, - "exit_survey_form_id" = $21, - "pending_category" = $22, - "delete_mentions" = $23, - "transcript_channel_id" = $24, - "use_threads" = $25, - "ticket_notification_channel" = $26, - "cooldown_seconds" = $27, - "ticket_limit" = $28, - "hide_close_button" = $29, - "hide_close_with_reason_button" = $30, - "hide_claim_button" = $31 + "emoji_animated" = $10, + "welcome_message" = $11, + "default_team" = $12, + "custom_id" = $13, + "image_url" = $14, + "thumbnail_url" = $15, + "button_style" = $16, + "button_label" = $17, + "form_id" = $18, + "naming_scheme" = $19, + "force_disabled" = $20, + "disabled" = $21, + "exit_survey_form_id" = $22, + "pending_category" = $23, + "mention_behaviour" = $24, + "transcript_channel_id" = $25, + "use_threads" = $26, + "ticket_notification_channel" = $27, + "cooldown_seconds" = $28, + "ticket_limit" = $29, + "hide_close_button" = $30, + "hide_close_with_reason_button" = $31, + "hide_claim_button" = $32, + "show_in_open_command" = $33, + "store_transcripts" = $34, + "overflow_enabled" = $35, + "overflow_category_id" = $36, + "users_can_close" = $37, + "close_confirmation" = $38, + "feedback_enabled" = $39, + "support_can_view" = $40, + "support_can_type" = $41 WHERE "panel_id" = $1 ;` @@ -782,6 +912,7 @@ UPDATE panels panel.TargetCategory, panel.EmojiName, panel.EmojiId, + panel.EmojiAnimated, panel.WelcomeMessageEmbed, panel.WithDefaultTeam, panel.CustomId, @@ -795,7 +926,7 @@ UPDATE panels panel.Disabled, panel.ExitSurveyFormId, panel.PendingCategory, - panel.DeleteMentions, + panel.MentionBehaviour, panel.TranscriptChannelId, panel.UseThreads, panel.TicketNotificationChannel, @@ -804,11 +935,26 @@ UPDATE panels panel.HideCloseButton, panel.HideCloseWithReasonButton, panel.HideClaimButton, + panel.ShowInOpenCommand, + panel.StoreTranscripts, + panel.OverflowEnabled, + panel.OverflowCategoryId, + panel.UsersCanClose, + panel.CloseConfirmation, + panel.FeedbackEnabled, + panel.SupportCanView, + panel.SupportCanType, ) return err } +func (p *PanelTable) SetOverflow(ctx context.Context, panelId int, enabled bool, categoryId *uint64) (err error) { + query := `UPDATE panels SET "overflow_enabled" = $2, "overflow_category_id" = $3 WHERE "panel_id" = $1;` + _, err = p.Exec(ctx, query, panelId, enabled, categoryId) + return +} + func (p *PanelTable) UpdateMessageId(ctx context.Context, panelId int, messageId uint64) (err error) { query := ` UPDATE panels @@ -898,6 +1044,7 @@ func (p *Panel) fieldPtrs() []interface{} { &p.TargetCategory, &p.EmojiName, &p.EmojiId, + &p.EmojiAnimated, &p.WelcomeMessageEmbed, &p.WithDefaultTeam, &p.CustomId, @@ -911,7 +1058,7 @@ func (p *Panel) fieldPtrs() []interface{} { &p.Disabled, &p.ExitSurveyFormId, &p.PendingCategory, - &p.DeleteMentions, + &p.MentionBehaviour, &p.TranscriptChannelId, &p.UseThreads, &p.TicketNotificationChannel, @@ -920,5 +1067,14 @@ func (p *Panel) fieldPtrs() []interface{} { &p.HideCloseButton, &p.HideCloseWithReasonButton, &p.HideClaimButton, + &p.ShowInOpenCommand, + &p.StoreTranscripts, + &p.OverflowEnabled, + &p.OverflowCategoryId, + &p.UsersCanClose, + &p.CloseConfirmation, + &p.FeedbackEnabled, + &p.SupportCanView, + &p.SupportCanType, } } diff --git a/panelticketpermissions.go b/panelticketpermissions.go index 893ae68..e663a8b 100644 --- a/panelticketpermissions.go +++ b/panelticketpermissions.go @@ -7,6 +7,16 @@ import ( "github.com/jackc/pgx/v4/pgxpool" ) +type TicketPermissions struct { + AddReactions bool `json:"add_reactions"` + SendTTSMessages bool `json:"send_tts_messages"` + EmbedLinks bool `json:"embed_links"` + AttachFiles bool `json:"attach_files"` + UseExternalEmojis bool `json:"use_external_emojis"` + UseExternalStickers bool `json:"use_external_stickers"` + SendVoiceMessages bool `json:"send_voice_messages"` +} + type PanelTicketPermissionsTable struct { *pgxpool.Pool } diff --git a/participants.go b/participants.go index 09329bf..ba953e6 100644 --- a/participants.go +++ b/participants.go @@ -145,7 +145,9 @@ func (p *ParticipantTable) GetParticipatedCount(ctx context.Context, guildId, us query := ` SELECT COUNT(*) FROM participant -WHERE "guild_id" = $1 AND "user_id" = $2; +INNER JOIN tickets +ON tickets.guild_id = participant.guild_id AND tickets.id = participant.ticket_id +WHERE participant.guild_id = $1 AND participant.user_id = $2 AND participant.user_id != tickets.user_id; ` err = p.QueryRow(ctx, query, guildId, userId).Scan(&count) @@ -163,7 +165,10 @@ SELECT COUNT(*) FROM participant INNER JOIN tickets ON tickets.guild_id = participant.guild_id AND tickets.id = participant.ticket_id -WHERE participant.guild_id = $1 AND participant.user_id = $2 AND tickets.open_time > NOW() - $3::interval; +WHERE participant.guild_id = $1 + AND participant.user_id = $2 + AND participant.user_id != tickets.user_id + AND tickets.open_time > NOW() - $3::interval; ` err = p.QueryRow(ctx, query, guildId, userId, parsed).Scan(&count) diff --git a/polarentitlements.go b/polarentitlements.go new file mode 100644 index 0000000..4644e45 --- /dev/null +++ b/polarentitlements.go @@ -0,0 +1,131 @@ +package database + +import ( + "context" + _ "embed" + "errors" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" +) + +type PolarEntitlements struct { + *pgxpool.Pool +} + +func newPolarEntitlements(db *pgxpool.Pool) *PolarEntitlements { + return &PolarEntitlements{ + db, + } +} + +type PolarEntitlement struct { + PolarSubscriptionId string `json:"polar_subscription_id"` + EntitlementId uuid.UUID `json:"entitlement_id"` + UserId uint64 `json:"user_id"` + PolarProductId string `json:"polar_product_id"` + Status string `json:"status"` +} + +type PolarEntitlementWithDetails struct { + PolarEntitlement + SkuId uuid.UUID `json:"sku_id"` + SkuLabel string `json:"sku_label"` + Tier string `json:"tier"` + ExpiresAt *time.Time `json:"expires_at"` + GuildId *uint64 `json:"guild_id"` + PermittedServerCount *int `json:"permitted_server_count,omitempty"` +} + +var ( + //go:embed sql/polar_entitlements/schema.sql + polarEntitlementsSchema string + + //go:embed sql/polar_entitlements/insert.sql + polarEntitlementsInsert string + + //go:embed sql/polar_entitlements/get_by_subscription_id.sql + polarEntitlementsGetBySubscriptionId string + + //go:embed sql/polar_entitlements/list_by_user.sql + polarEntitlementsListByUser string + + //go:embed sql/polar_entitlements/delete_by_subscription_id.sql + polarEntitlementsDeleteBySubscriptionId string + + //go:embed sql/polar_entitlements/update_status.sql + polarEntitlementsUpdateStatus string +) + +func (e PolarEntitlements) Schema() string { + return polarEntitlementsSchema +} + +func (e *PolarEntitlements) Insert(ctx context.Context, tx pgx.Tx, polarSubscriptionId string, entitlementId uuid.UUID, userId uint64, polarProductId string, status string) error { + _, err := tx.Exec(ctx, polarEntitlementsInsert, polarSubscriptionId, entitlementId, userId, polarProductId, status) + return err +} + +func (e *PolarEntitlements) GetBySubscriptionId(ctx context.Context, tx pgx.Tx, polarSubscriptionId string) (*PolarEntitlement, error) { + var ent PolarEntitlement + err := tx.QueryRow(ctx, polarEntitlementsGetBySubscriptionId, polarSubscriptionId).Scan( + &ent.PolarSubscriptionId, + &ent.EntitlementId, + &ent.UserId, + &ent.PolarProductId, + &ent.Status, + ) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + + return &ent, nil +} + +func (e *PolarEntitlements) ListByUser(ctx context.Context, tx pgx.Tx, userId uint64) ([]PolarEntitlementWithDetails, error) { + rows, err := tx.Query(ctx, polarEntitlementsListByUser, userId) + if err != nil { + return nil, err + } + + defer rows.Close() + + var entitlements []PolarEntitlementWithDetails + for rows.Next() { + var ent PolarEntitlementWithDetails + if err := rows.Scan( + &ent.PolarSubscriptionId, + &ent.EntitlementId, + &ent.UserId, + &ent.PolarProductId, + &ent.Status, + &ent.SkuId, + &ent.SkuLabel, + &ent.Tier, + &ent.ExpiresAt, + &ent.GuildId, + &ent.PermittedServerCount, + ); err != nil { + return nil, err + } + + entitlements = append(entitlements, ent) + } + + return entitlements, nil +} + +func (e *PolarEntitlements) DeleteBySubscriptionId(ctx context.Context, tx pgx.Tx, polarSubscriptionId string) error { + _, err := tx.Exec(ctx, polarEntitlementsDeleteBySubscriptionId, polarSubscriptionId) + return err +} + +func (e *PolarEntitlements) UpdateStatus(ctx context.Context, tx pgx.Tx, polarSubscriptionId string, status string) error { + _, err := tx.Exec(ctx, polarEntitlementsUpdateStatus, polarSubscriptionId, status) + return err +} diff --git a/polarproducts.go b/polarproducts.go new file mode 100644 index 0000000..a9f3e98 --- /dev/null +++ b/polarproducts.go @@ -0,0 +1,191 @@ +package database + +import ( + "context" + _ "embed" + "errors" + + "github.com/google/uuid" + "github.com/jackc/pgtype" + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" +) + +type PolarProducts struct { + *pgxpool.Pool +} + +func newPolarProducts(db *pgxpool.Pool) *PolarProducts { + return &PolarProducts{db} +} + +type PolarProduct struct { + Id uuid.UUID `json:"id"` + PolarProductId string `json:"polar_product_id"` + SkuId uuid.UUID `json:"sku_id"` + Name string `json:"name"` + Description string `json:"description"` + Interval string `json:"interval"` + Price int `json:"price"` + Currency string `json:"currency"` + Features []string `json:"features"` + Highlighted bool `json:"highlighted"` + SortOrder int `json:"sort_order"` + Tier string `json:"tier"` + ServersPermitted *int `json:"servers_permitted,omitempty"` +} + +var ( + //go:embed sql/polar_products/schema.sql + polarProductsSchema string + + //go:embed sql/polar_products/list_all.sql + polarProductsListAll string + + //go:embed sql/polar_products/get_by_polar_product_id.sql + polarProductsGetByPolarProductId string + + //go:embed sql/polar_products/insert.sql + polarProductsInsert string + + //go:embed sql/polar_products/update.sql + polarProductsUpdate string + + //go:embed sql/polar_products/delete.sql + polarProductsDelete string +) + +func (t PolarProducts) Schema() string { + return polarProductsSchema +} + +func scanPolarProduct(row pgx.Row) (PolarProduct, error) { + var p PolarProduct + var features pgtype.TextArray + + err := row.Scan( + &p.Id, + &p.PolarProductId, + &p.SkuId, + &p.Name, + &p.Description, + &p.Interval, + &p.Price, + &p.Currency, + &features, + &p.Highlighted, + &p.SortOrder, + &p.Tier, + &p.ServersPermitted, + ) + if err != nil { + return p, err + } + + p.Features = make([]string, 0) + if features.Status == pgtype.Present { + for _, el := range features.Elements { + if el.Status == pgtype.Present { + p.Features = append(p.Features, el.String) + } + } + } + + return p, nil +} + +func (t *PolarProducts) ListAll(ctx context.Context) ([]PolarProduct, error) { + rows, err := t.Pool.Query(ctx, polarProductsListAll) + if err != nil { + return nil, err + } + defer rows.Close() + + var products []PolarProduct + for rows.Next() { + p, err := scanPolarProduct(rows) + if err != nil { + return nil, err + } + products = append(products, p) + } + + if products == nil { + products = make([]PolarProduct, 0) + } + + return products, nil +} + +func (t *PolarProducts) GetByPolarProductId(ctx context.Context, polarProductId string) (*PolarProduct, error) { + p, err := scanPolarProduct(t.Pool.QueryRow(ctx, polarProductsGetByPolarProductId, polarProductId)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &p, nil +} + +func (t *PolarProducts) Create(ctx context.Context, tx pgx.Tx, product PolarProduct) (PolarProduct, error) { + featuresArray := toTextArray(product.Features) + return scanPolarProduct(tx.QueryRow(ctx, polarProductsInsert, + product.PolarProductId, + product.SkuId, + product.Name, + product.Description, + product.Interval, + product.Price, + product.Currency, + featuresArray, + product.Highlighted, + product.SortOrder, + product.Tier, + product.ServersPermitted, + )) +} + +func (t *PolarProducts) Update(ctx context.Context, tx pgx.Tx, product PolarProduct) error { + featuresArray := toTextArray(product.Features) + _, err := tx.Exec(ctx, polarProductsUpdate, + product.Id, + product.PolarProductId, + product.SkuId, + product.Name, + product.Description, + product.Interval, + product.Price, + product.Currency, + featuresArray, + product.Highlighted, + product.SortOrder, + product.Tier, + product.ServersPermitted, + ) + return err +} + +func (t *PolarProducts) Delete(ctx context.Context, tx pgx.Tx, id uuid.UUID) error { + _, err := tx.Exec(ctx, polarProductsDelete, id) + return err +} + +func toTextArray(strs []string) pgtype.TextArray { + if len(strs) == 0 { + return pgtype.TextArray{ + Status: pgtype.Present, + } + } + + elements := make([]pgtype.Text, len(strs)) + for i, s := range strs { + elements[i] = pgtype.Text{String: s, Status: pgtype.Present} + } + + return pgtype.TextArray{ + Elements: elements, + Dimensions: []pgtype.ArrayDimension{{Length: int32(len(strs)), LowerBound: 1}}, + Status: pgtype.Present, + } +} diff --git a/premiumkeys.go b/premiumkeys.go index a2f5310..af04d71 100644 --- a/premiumkeys.go +++ b/premiumkeys.go @@ -2,17 +2,39 @@ package database import ( "context" + _ "embed" "errors" + "time" + "github.com/google/uuid" "github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4/pgxpool" - "time" ) type PremiumKeys struct { *pgxpool.Pool } +var ( + //go:embed sql/premium_keys/list_all.sql + premiumKeysListAll string + + //go:embed sql/premium_keys/count_all.sql + premiumKeysCountAll string +) + +type PremiumKeyEntry struct { + Key uuid.UUID `json:"key"` + Length *time.Duration `json:"length"` + SkuId *uuid.UUID `json:"sku_id"` + GeneratedAt *time.Time `json:"generated_at"` + SkuLabel *string `json:"sku_label"` + Tier *string `json:"tier"` + GuildId *uint64 `json:"guild_id"` + ActivatedBy *uint64 `json:"activated_by"` + IsUsed bool `json:"is_used"` +} + func newPremiumKeys(db *pgxpool.Pool) *PremiumKeys { return &PremiumKeys{ db, @@ -52,3 +74,41 @@ func (k *PremiumKeys) Delete(ctx context.Context, tx pgx.Tx, key uuid.UUID) (tim return length, skuId, true, nil } + +func (k *PremiumKeys) ListAll(ctx context.Context, limit, offset int) ([]PremiumKeyEntry, error) { + rows, err := k.Query(ctx, premiumKeysListAll, limit, offset) + if err != nil { + return nil, err + } + + var entries []PremiumKeyEntry + for rows.Next() { + var entry PremiumKeyEntry + if err := rows.Scan( + &entry.Key, + &entry.Length, + &entry.SkuId, + &entry.GeneratedAt, + &entry.SkuLabel, + &entry.Tier, + &entry.GuildId, + &entry.ActivatedBy, + &entry.IsUsed, + ); err != nil { + return nil, err + } + + entries = append(entries, entry) + } + + return entries, nil +} + +func (k *PremiumKeys) CountAll(ctx context.Context) (int, error) { + var count int + if err := k.QueryRow(ctx, premiumKeysCountAll).Scan(&count); err != nil { + return 0, err + } + + return count, nil +} diff --git a/serverblacklist.go b/serverblacklist.go index ab26ba7..fc4f852 100644 --- a/serverblacklist.go +++ b/serverblacklist.go @@ -104,3 +104,21 @@ func (b *ServerBlacklist) Delete(ctx context.Context, guildId uint64) (err error return } +func (b *ServerBlacklist) ListAllEntries(ctx context.Context) ([]ServerBlacklistEntry, error) { + query := `SELECT "guild_id", "reason", "owner_id", "real_owner_id" FROM server_blacklist;` + rows, err := b.Query(ctx, query) + if err != nil { + return nil, err + } + + var entries []ServerBlacklistEntry + for rows.Next() { + var entry ServerBlacklistEntry + if err := rows.Scan(&entry.GuildId, &entry.Reason, &entry.OwnerId, &entry.RealOwnerId); err != nil { + return nil, err + } + entries = append(entries, entry) + } + return entries, nil +} + diff --git a/serviceratings.go b/serviceratings.go index 3607c67..51e9d0b 100644 --- a/serviceratings.go +++ b/serviceratings.go @@ -2,8 +2,9 @@ package database import ( "context" + "fmt" - "github.com/jackc/pgx/pgtype" + "github.com/jackc/pgtype" "github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4/pgxpool" ) @@ -42,10 +43,33 @@ func (r *ServiceRatings) Get(ctx context.Context, guildId uint64, ticketId int) } } -func (r *ServiceRatings) GetCount(ctx context.Context, guildId uint64) (count int, err error) { - query := `SELECT COUNT(*) from service_ratings WHERE "guild_id" = $1;` - err = r.QueryRow(ctx, query, guildId).Scan(&count) - return +// GetCount is the worker-compatible wrapper. Delegates to GetCountFiltered +// with days=0 and no panel filter. +func (r *ServiceRatings) GetCount(ctx context.Context, guildId uint64) (int, error) { + return r.GetCountFiltered(ctx, guildId, 0, nil) +} + +// GetCountFiltered returns the count of feedback ratings, optionally scoped by +// time window and panel filter. +func (r *ServiceRatings) GetCountFiltered(ctx context.Context, guildId uint64, days int, filter *PanelFilter) (int, error) { + query := ` +SELECT COUNT(*) +FROM service_ratings sr +INNER JOIN tickets t ON sr.guild_id = t.guild_id AND sr.ticket_id = t.id +WHERE sr.guild_id = $1 + AND ($2 = 0 OR t.open_time > NOW() - make_interval(days => $2))` + + PanelPredicate("t", 3, 4) + + arr, unassigned, err := filter.Args() + if err != nil { + return 0, fmt.Errorf("feedback count: %w", err) + } + + var count int + if err := r.QueryRow(ctx, query, guildId, days, arr, unassigned).Scan(&count); err != nil { + return 0, err + } + return count, nil } func (r *ServiceRatings) GetCountClaimedBy(ctx context.Context, guildId, userId uint64) (count int, err error) { @@ -61,21 +85,38 @@ WHERE service_ratings.guild_id = $1 AND ticket_claims.user_id = $2; return } -// TODO: Materialized view? -func (r *ServiceRatings) GetAverage(ctx context.Context, guildId uint64) (average float32, err error) { - // Returns NULL if no ratings - var f *float32 +// GetAverage is the worker-compatible wrapper. Delegates to +// GetAverageFiltered with days=0 and no panel filter. +func (r *ServiceRatings) GetAverage(ctx context.Context, guildId uint64) (float32, error) { + return r.GetAverageFiltered(ctx, guildId, 0, nil) +} - query := `SELECT AVG(rating) from service_ratings WHERE "guild_id" = $1;` - err = r.QueryRow(ctx, query, guildId).Scan(&f) - if f != nil { - average = *f +// GetAverageFiltered returns the average feedback rating, optionally scoped by +// time window and panel filter. +func (r *ServiceRatings) GetAverageFiltered(ctx context.Context, guildId uint64, days int, filter *PanelFilter) (float32, error) { + query := ` +SELECT AVG(sr.rating) +FROM service_ratings sr +INNER JOIN tickets t ON sr.guild_id = t.guild_id AND sr.ticket_id = t.id +WHERE sr.guild_id = $1 + AND ($2 = 0 OR t.open_time > NOW() - make_interval(days => $2))` + + PanelPredicate("t", 3, 4) + + arr, unassigned, err := filter.Args() + if err != nil { + return 0, fmt.Errorf("average rating: %w", err) } - return + var f *float32 + if err := r.QueryRow(ctx, query, guildId, days, arr, unassigned).Scan(&f); err != nil { + return 0, err + } + if f != nil { + return *f, nil + } + return 0, nil } -// TODO: Materialized view? func (r *ServiceRatings) GetAverageClaimedBy(ctx context.Context, guildId, userId uint64) (average float32, err error) { // Returns NULL if no tickets claimed var f *float32 @@ -159,3 +200,106 @@ ON CONFLICT("guild_id", "ticket_id") DO UPDATE SET "rating" = $3;` _, err = r.Exec(ctx, query, guildId, ticketId, rating) return } + +func (r *ServiceRatings) GetDistributionClaimedBy(ctx context.Context, guildId, userId uint64) ([5]int, error) { + query := ` +SELECT sr.rating, COUNT(*) +FROM service_ratings sr +INNER JOIN ticket_claims tc ON sr.guild_id = tc.guild_id AND sr.ticket_id = tc.ticket_id +WHERE sr.guild_id = $1 AND tc.user_id = $2 +GROUP BY sr.rating ORDER BY sr.rating;` + + rows, err := r.Query(ctx, query, guildId, userId) + if err != nil { + return [5]int{}, err + } + defer rows.Close() + + var dist [5]int + for rows.Next() { + var rating, count int + if err := rows.Scan(&rating, &count); err != nil { + return [5]int{}, err + } + if rating >= 1 && rating <= 5 { + dist[rating-1] = count + } + } + + return dist, nil +} + +// GetDistribution is the worker-compatible wrapper. Delegates to +// GetDistributionFiltered with days=0 and no panel filter. +func (r *ServiceRatings) GetDistribution(ctx context.Context, guildId uint64) ([5]int, error) { + return r.GetDistributionFiltered(ctx, guildId, 0, nil) +} + +// GetDistributionFiltered returns the 1-5 star distribution, optionally +// scoped by time window and panel filter. +func (r *ServiceRatings) GetDistributionFiltered(ctx context.Context, guildId uint64, days int, filter *PanelFilter) ([5]int, error) { + query := ` +SELECT sr.rating, COUNT(*) +FROM service_ratings sr +INNER JOIN tickets t ON sr.guild_id = t.guild_id AND sr.ticket_id = t.id +WHERE sr.guild_id = $1 + AND ($2 = 0 OR t.open_time > NOW() - make_interval(days => $2))` + + PanelPredicate("t", 3, 4) + ` +GROUP BY sr.rating ORDER BY sr.rating;` + + arr, unassigned, err := filter.Args() + if err != nil { + return [5]int{}, fmt.Errorf("feedback distribution: %w", err) + } + + rows, err := r.Query(ctx, query, guildId, days, arr, unassigned) + if err != nil { + return [5]int{}, err + } + defer rows.Close() + + var dist [5]int + for rows.Next() { + var rating, count int + if err := rows.Scan(&rating, &count); err != nil { + return [5]int{}, err + } + if rating >= 1 && rating <= 5 { + dist[rating-1] = count + } + } + + return dist, nil +} + +// GetResponseRate is the worker-compatible wrapper. Delegates to +// GetResponseRateFiltered with no panel filter. +func (r *ServiceRatings) GetResponseRate(ctx context.Context, guildId uint64, nDays int) (FeedbackResponseRate, error) { + return r.GetResponseRateFiltered(ctx, guildId, nDays, nil) +} + +func (r *ServiceRatings) GetResponseRateFiltered(ctx context.Context, guildId uint64, nDays int, filter *PanelFilter) (FeedbackResponseRate, error) { + query := ` +SELECT COUNT(t.id) AS closed, COUNT(sr.rating) AS rated +FROM tickets t +LEFT JOIN service_ratings sr ON t.guild_id = sr.guild_id AND t.id = sr.ticket_id +WHERE t.guild_id = $1 AND t.open = false + AND t.close_time > CURRENT_DATE - ($2 - 1) * INTERVAL '1 day'` + + PanelPredicate("t", 3, 4) + + arr, unassigned, err := filter.Args() + if err != nil { + return FeedbackResponseRate{}, fmt.Errorf("feedback response rate: %w", err) + } + + var result FeedbackResponseRate + if err := r.QueryRow(ctx, query, guildId, nDays, arr, unassigned).Scan(&result.ClosedTickets, &result.RatedTickets); err != nil { + return FeedbackResponseRate{}, err + } + + if result.ClosedTickets > 0 { + result.Rate = float64(result.RatedTickets) / float64(result.ClosedTickets) + } + + return result, nil +} diff --git a/settings.go b/settings.go index 213568b..69bfa9a 100644 --- a/settings.go +++ b/settings.go @@ -6,41 +6,19 @@ import ( "github.com/jackc/pgx/v4/pgxpool" ) -// TODO: Migrate all settings to this table type Settings struct { - HideClaimButton bool `json:"hide_claim_button"` - DisableOpenCommand bool `json:"disable_open_command"` - ContextMenuPermissionLevel int `json:"context_menu_permission_level,string"` - ContextMenuAddSender bool `json:"context_menu_add_sender"` - ContextMenuPanel *int `json:"context_menu_panel"` - StoreTranscripts bool `json:"store_transcripts"` - UseThreads bool `json:"use_threads"` - TicketNotificationChannel *uint64 `json:"ticket_notification_channel,string"` - ThreadArchiveDuration int `json:"thread_archive_duration"` - OverflowEnabled bool `json:"overflow_enabled"` - OverflowCategoryId *uint64 `json:"overflow_category_id,string"` // If overflow_enabled and nil, use root - ExitSurveyFormId *uint64 `json:"exit_survey_form_id,string"` - AnonymiseDashboardResponses bool `json:"anonymise_dashboard_responses"` - HideCloseButton bool `json:"hide_close_button"` - HideCloseWithReasonButton bool `json:"hide_close_with_reason_button"` + ContextMenuPermissionLevel int `json:"context_menu_permission_level,string"` + ContextMenuAddSender bool `json:"context_menu_add_sender"` + ContextMenuPanel *int `json:"context_menu_panel"` + AnonymiseDashboardResponses bool `json:"anonymise_dashboard_responses"` } func defaultSettings() Settings { return Settings{ - HideClaimButton: false, - DisableOpenCommand: false, - ContextMenuPermissionLevel: 0, - ContextMenuAddSender: true, - ContextMenuPanel: nil, - StoreTranscripts: true, - UseThreads: false, - TicketNotificationChannel: nil, - ThreadArchiveDuration: 10080, - OverflowEnabled: false, - OverflowCategoryId: nil, - ExitSurveyFormId: nil, - HideCloseButton: false, - HideCloseWithReasonButton: false, + ContextMenuPermissionLevel: 0, + ContextMenuAddSender: true, + ContextMenuPanel: nil, + AnonymiseDashboardResponses: false, } } @@ -58,25 +36,12 @@ func (s SettingsTable) Schema() string { return ` CREATE TABLE IF NOT EXISTS settings( "guild_id" int8 NOT NULL, - "hide_claim_button" bool DEFAULT 'f', - "disable_open_command" bool DEFAULT 'f', "context_menu_permission_level" int DEFAULT '0', "context_menu_add_sender" bool DEFAULT 't', "context_menu_panel" int DEFAULT NULL, - "store_transcripts" bool DEFAULT 't', - "use_threads" bool DEFAULT 'f', - "ticket_notification_channel" int8 DEFAULT NULL, - "thread_archive_duration" int DEFAULT '10080', - "overflow_enabled" bool DEFAULT 'f', - "overflow_category_id" int8 DEFAULT NULL, - "exit_survey_form_id" int4 DEFAULT NULL, "anonymise_dashboard_responses" bool DEFAULT 'f', - "hide_close_button" bool DEFAULT 'f', - "hide_close_with_reason_button" bool DEFAULT 'f', FOREIGN KEY("context_menu_panel") REFERENCES panels("panel_id") ON DELETE SET NULL, - FOREIGN KEY("exit_survey_form_id") REFERENCES forms("form_id") ON DELETE SET NULL, - PRIMARY KEY("guild_id"), - CHECK (use_threads = false OR ticket_notification_channel IS NOT NULL) + PRIMARY KEY("guild_id") ); ` } @@ -84,40 +49,20 @@ CREATE TABLE IF NOT EXISTS settings( func (s *SettingsTable) Get(ctx context.Context, guildId uint64) (Settings, error) { query := ` SELECT - "hide_claim_button", - "disable_open_command", "context_menu_permission_level", "context_menu_add_sender", "context_menu_panel", - "store_transcripts", - "use_threads", - "ticket_notification_channel", - "thread_archive_duration", - "overflow_enabled", - "overflow_category_id", - "anonymise_dashboard_responses", - "hide_close_button", - "hide_close_with_reason_button" + "anonymise_dashboard_responses" FROM settings WHERE "guild_id" = $1; ` var settings Settings err := s.QueryRow(ctx, query, guildId).Scan( - &settings.HideClaimButton, - &settings.DisableOpenCommand, &settings.ContextMenuPermissionLevel, &settings.ContextMenuAddSender, &settings.ContextMenuPanel, - &settings.StoreTranscripts, - &settings.UseThreads, - &settings.TicketNotificationChannel, - &settings.ThreadArchiveDuration, - &settings.OverflowEnabled, - &settings.OverflowCategoryId, &settings.AnonymiseDashboardResponses, - &settings.HideCloseButton, - &settings.HideCloseWithReasonButton, ) if err == nil { @@ -133,86 +78,32 @@ func (s *SettingsTable) Set(ctx context.Context, guildId uint64, settings Settin query := ` INSERT INTO settings( "guild_id", - "hide_claim_button", - "disable_open_command", "context_menu_permission_level", "context_menu_add_sender", "context_menu_panel", - "store_transcripts", - "use_threads", - "ticket_notification_channel", - "thread_archive_duration", - "overflow_enabled", - "overflow_category_id", - "anonymise_dashboard_responses", - "hide_close_button", - "hide_close_with_reason_button" + "anonymise_dashboard_responses" ) -VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) +VALUES($1, $2, $3, $4, $5) ON CONFLICT("guild_id") DO UPDATE SET - "hide_claim_button" = $2, - "disable_open_command" = $3, - "context_menu_permission_level" = $4, - "context_menu_add_sender" = $5, - "context_menu_panel" = $6, - "store_transcripts" = $7, - "use_threads" = $8, - "ticket_notification_channel" = $9, - "thread_archive_duration" = $10, - "overflow_enabled" = $11, - "overflow_category_id" = $12, - "anonymise_dashboard_responses" = $13, - "hide_close_button" = $14, - "hide_close_with_reason_button" = $15 + "context_menu_permission_level" = $2, + "context_menu_add_sender" = $3, + "context_menu_panel" = $4, + "anonymise_dashboard_responses" = $5 ; ` _, err = s.Exec(ctx, query, guildId, - settings.HideClaimButton, - settings.DisableOpenCommand, settings.ContextMenuPermissionLevel, settings.ContextMenuAddSender, settings.ContextMenuPanel, - settings.StoreTranscripts, - settings.UseThreads, - settings.TicketNotificationChannel, - settings.ThreadArchiveDuration, - settings.OverflowEnabled, - settings.OverflowCategoryId, settings.AnonymiseDashboardResponses, - settings.HideCloseButton, - settings.HideCloseWithReasonButton, ) return } -func (s *SettingsTable) SetHideClaimButton(ctx context.Context, guildId uint64, hideClaimButton bool) (err error) { - query := ` -INSERT INTO settings("guild_id", "hide_claim_button") -VALUES($1, $2) -ON CONFLICT("guild_id") -DO UPDATE SET "hide_claim_button" = $2; -` - - _, err = s.Exec(ctx, query, guildId, hideClaimButton) - return -} - -func (s *SettingsTable) SetDisableOpenCommand(ctx context.Context, guildId uint64, disableOpenCommand bool) (err error) { - query := ` -INSERT INTO settings("guild_id", "disable_open_command") -VALUES($1, $2) -ON CONFLICT("guild_id") -DO UPDATE SET "disable_open_command" = $2; -` - - _, err = s.Exec(ctx, query, guildId, disableOpenCommand) - return -} - func (s *SettingsTable) SetContextMenuPermissionLevel(ctx context.Context, guildId uint64, permissionLevel int) (err error) { query := ` INSERT INTO settings("guild_id", "context_menu_permission_level") @@ -225,38 +116,3 @@ DO UPDATE SET "context_menu_permission_level" = $2; return } -func (s *SettingsTable) SetOverflow(ctx context.Context, guildId uint64, enabled bool, categoryId *uint64) (err error) { - query := ` -INSERT INTO settings("guild_id", "overflow_enabled", "overflow_category_id") -VALUES($1, $2, $3) -ON CONFLICT("guild_id") -DO UPDATE SET "overflow_enabled" = $2, "overflow_category_id" = $3; -` - - _, err = s.Exec(ctx, query, guildId, enabled, categoryId) - return -} - -func (s *SettingsTable) EnableThreads(ctx context.Context, guildId uint64, ticketNotificationChannel uint64) (err error) { - query := ` -INSERT INTO settings("guild_id", "use_threads", "ticket_notification_channel") -VALUES($1, true, $2) -ON CONFLICT("guild_id") -DO UPDATE SET "use_threads" = true, "ticket_notification_channel" = $2; -` - - _, err = s.Exec(ctx, query, guildId, ticketNotificationChannel) - return -} - -func (s *SettingsTable) DisableThreads(ctx context.Context, guildId uint64) (err error) { - query := ` -INSERT INTO settings("guild_id", "use_threads", "ticket_notification_channel") -VALUES($1, false, NULL) -ON CONFLICT("guild_id") -DO UPDATE SET "use_threads" = false, "ticket_notification_channel" = NULL; -` - - _, err = s.Exec(ctx, query, guildId) - return -} diff --git a/skus.go b/skus.go new file mode 100644 index 0000000..6bb9f27 --- /dev/null +++ b/skus.go @@ -0,0 +1,128 @@ +package database + +import ( + "context" + _ "embed" + "errors" + + "github.com/TicketsBot-cloud/common/model" + "github.com/google/uuid" + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" +) + +type Skus struct { + *pgxpool.Pool +} + +// SkuWithDetails represents a SKU with its optional subscription and multi-server details. +type SkuWithDetails struct { + model.Sku + Tier *string `json:"tier,omitempty"` + Priority *int32 `json:"priority,omitempty"` + IsGlobal *bool `json:"is_global,omitempty"` + ServersPermitted *int `json:"servers_permitted,omitempty"` +} + +var ( + //go:embed sql/skus/schema.sql + skusSchema string + + //go:embed sql/skus/get.sql + skusGet string + + //go:embed sql/skus/insert.sql + skusInsert string + + //go:embed sql/skus/update.sql + skusUpdate string + + //go:embed sql/skus/delete.sql + skusDelete string + + //go:embed sql/skus/list_all.sql + skusListAll string +) + +func newSkusTable(db *pgxpool.Pool) *Skus { + return &Skus{ + db, + } +} + +func (Skus) Schema() string { + return skusSchema +} + +// ListAll returns all SKUs with their subscription and multi-server details. +func (s *Skus) ListAll(ctx context.Context) ([]SkuWithDetails, error) { + rows, err := s.Query(ctx, skusListAll) + if err != nil { + return nil, err + } + defer rows.Close() + + var skus []SkuWithDetails + for rows.Next() { + var sku SkuWithDetails + if err := rows.Scan( + &sku.Id, + &sku.Label, + &sku.SkuType, + &sku.Tier, + &sku.Priority, + &sku.IsGlobal, + &sku.ServersPermitted, + ); err != nil { + return nil, err + } + + skus = append(skus, sku) + } + + return skus, nil +} + +// GetById returns a single SKU with its details, or nil if not found. +func (s *Skus) GetById(ctx context.Context, id uuid.UUID) (*SkuWithDetails, error) { + var sku SkuWithDetails + if err := s.QueryRow(ctx, skusGet, id).Scan( + &sku.Id, + &sku.Label, + &sku.SkuType, + ); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + + return nil, err + } + + return &sku, nil +} + +// Create inserts a new SKU and its optional subscription and multi-server details within a transaction. +func (s *Skus) Create(ctx context.Context, tx pgx.Tx, label string, skuType model.SkuType) (model.Sku, error) { + var sku model.Sku + if err := tx.QueryRow(ctx, skusInsert, label, skuType).Scan( + &sku.Id, + &sku.Label, + &sku.SkuType, + ); err != nil { + return model.Sku{}, err + } + + return sku, nil +} + +// Update updates the base SKU fields within a transaction. +func (s *Skus) Update(ctx context.Context, tx pgx.Tx, id uuid.UUID, label string, skuType model.SkuType) error { + _, err := tx.Exec(ctx, skusUpdate, id, label, skuType) + return err +} + +// Delete removes a SKU by its ID within a transaction. +func (s *Skus) Delete(ctx context.Context, tx pgx.Tx, id uuid.UUID) error { + _, err := tx.Exec(ctx, skusDelete, id) + return err +} diff --git a/sql/affiliate_codes/count_all.sql b/sql/affiliate_codes/count_all.sql new file mode 100644 index 0000000..ef915ce --- /dev/null +++ b/sql/affiliate_codes/count_all.sql @@ -0,0 +1,3 @@ +SELECT COUNT(*) +FROM affiliate_codes +WHERE ($1::TEXT IS NULL OR "status" = $1); diff --git a/sql/affiliate_codes/create.sql b/sql/affiliate_codes/create.sql new file mode 100644 index 0000000..4f34205 --- /dev/null +++ b/sql/affiliate_codes/create.sql @@ -0,0 +1,3 @@ +INSERT INTO affiliate_codes ("user_id", "code", "status", "discount_basis_points", "credit_percentage") +VALUES ($1, $2, $3, $4, $5) +RETURNING "id", "created_at"; diff --git a/sql/affiliate_codes/get_by_code.sql b/sql/affiliate_codes/get_by_code.sql new file mode 100644 index 0000000..d7ba3f9 --- /dev/null +++ b/sql/affiliate_codes/get_by_code.sql @@ -0,0 +1,5 @@ +SELECT "id", "user_id", "code", "polar_discount_id", "status", + "discount_basis_points", "credit_percentage", + "created_at", "approved_at", "approved_by", "revoked_at" +FROM affiliate_codes +WHERE "code" = $1; diff --git a/sql/affiliate_codes/get_by_id.sql b/sql/affiliate_codes/get_by_id.sql new file mode 100644 index 0000000..b948be0 --- /dev/null +++ b/sql/affiliate_codes/get_by_id.sql @@ -0,0 +1,5 @@ +SELECT "id", "user_id", "code", "polar_discount_id", "status", + "discount_basis_points", "credit_percentage", + "created_at", "approved_at", "approved_by", "revoked_at" +FROM affiliate_codes +WHERE "id" = $1; diff --git a/sql/affiliate_codes/get_by_polar_discount_id.sql b/sql/affiliate_codes/get_by_polar_discount_id.sql new file mode 100644 index 0000000..a1b136c --- /dev/null +++ b/sql/affiliate_codes/get_by_polar_discount_id.sql @@ -0,0 +1,5 @@ +SELECT "id", "user_id", "code", "polar_discount_id", "status", + "discount_basis_points", "credit_percentage", + "created_at", "approved_at", "approved_by", "revoked_at" +FROM affiliate_codes +WHERE "polar_discount_id" = $1; diff --git a/sql/affiliate_codes/get_by_user_id.sql b/sql/affiliate_codes/get_by_user_id.sql new file mode 100644 index 0000000..699d1b7 --- /dev/null +++ b/sql/affiliate_codes/get_by_user_id.sql @@ -0,0 +1,5 @@ +SELECT "id", "user_id", "code", "polar_discount_id", "status", + "discount_basis_points", "credit_percentage", + "created_at", "approved_at", "approved_by", "revoked_at" +FROM affiliate_codes +WHERE "user_id" = $1; diff --git a/sql/affiliate_codes/list_all.sql b/sql/affiliate_codes/list_all.sql new file mode 100644 index 0000000..75950f7 --- /dev/null +++ b/sql/affiliate_codes/list_all.sql @@ -0,0 +1,7 @@ +SELECT "id", "user_id", "code", "polar_discount_id", "status", + "discount_basis_points", "credit_percentage", + "created_at", "approved_at", "approved_by", "revoked_at" +FROM affiliate_codes +WHERE ($1::TEXT IS NULL OR "status" = $1) +ORDER BY "created_at" DESC +LIMIT $2 OFFSET $3; diff --git a/sql/affiliate_codes/schema.sql b/sql/affiliate_codes/schema.sql new file mode 100644 index 0000000..0d0be4c --- /dev/null +++ b/sql/affiliate_codes/schema.sql @@ -0,0 +1,21 @@ +DO $$ BEGIN + ALTER TYPE premium_source ADD VALUE IF NOT EXISTS 'affiliate'; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +CREATE TABLE IF NOT EXISTS affiliate_codes ( + "id" UUID DEFAULT gen_random_uuid() PRIMARY KEY, + "user_id" INT8 NOT NULL UNIQUE, + "code" TEXT NOT NULL UNIQUE, + "polar_discount_id" TEXT, + "status" TEXT NOT NULL DEFAULT 'pending', + "discount_basis_points" INT NOT NULL DEFAULT 500, + "credit_percentage" INT, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "approved_at" TIMESTAMPTZ, + "approved_by" INT8, + "revoked_at" TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_affiliate_codes_user_id ON affiliate_codes("user_id"); +CREATE INDEX IF NOT EXISTS idx_affiliate_codes_status ON affiliate_codes("status"); diff --git a/sql/affiliate_codes/set_polar_discount_id.sql b/sql/affiliate_codes/set_polar_discount_id.sql new file mode 100644 index 0000000..a1c3829 --- /dev/null +++ b/sql/affiliate_codes/set_polar_discount_id.sql @@ -0,0 +1,3 @@ +UPDATE affiliate_codes +SET "polar_discount_id" = $2 +WHERE "id" = $1; diff --git a/sql/affiliate_codes/update_code.sql b/sql/affiliate_codes/update_code.sql new file mode 100644 index 0000000..4713eb2 --- /dev/null +++ b/sql/affiliate_codes/update_code.sql @@ -0,0 +1 @@ +UPDATE affiliate_codes SET code = $2 WHERE id = $1; diff --git a/sql/affiliate_codes/update_rates.sql b/sql/affiliate_codes/update_rates.sql new file mode 100644 index 0000000..93e2b88 --- /dev/null +++ b/sql/affiliate_codes/update_rates.sql @@ -0,0 +1,4 @@ +UPDATE affiliate_codes +SET "discount_basis_points" = $2, + "credit_percentage" = $3 +WHERE "id" = $1; diff --git a/sql/affiliate_codes/update_status.sql b/sql/affiliate_codes/update_status.sql new file mode 100644 index 0000000..2f0ca5c --- /dev/null +++ b/sql/affiliate_codes/update_status.sql @@ -0,0 +1,6 @@ +UPDATE affiliate_codes +SET "status" = $2, + "approved_at" = CASE WHEN $2 = 'active' THEN NOW() ELSE "approved_at" END, + "approved_by" = CASE WHEN $2 = 'active' THEN $3 ELSE "approved_by" END, + "revoked_at" = CASE WHEN $2 = 'revoked' THEN NOW() ELSE "revoked_at" END +WHERE "id" = $1; diff --git a/sql/affiliate_referrals/count_by_affiliate_user_id.sql b/sql/affiliate_referrals/count_by_affiliate_user_id.sql new file mode 100644 index 0000000..930eefe --- /dev/null +++ b/sql/affiliate_referrals/count_by_affiliate_user_id.sql @@ -0,0 +1,3 @@ +SELECT COUNT(*) +FROM affiliate_referrals +WHERE "affiliate_user_id" = $1; diff --git a/sql/affiliate_referrals/count_flagged.sql b/sql/affiliate_referrals/count_flagged.sql new file mode 100644 index 0000000..49683d6 --- /dev/null +++ b/sql/affiliate_referrals/count_flagged.sql @@ -0,0 +1,3 @@ +SELECT COUNT(*) +FROM affiliate_referrals +WHERE "status" = 'flagged'; diff --git a/sql/affiliate_referrals/count_pending.sql b/sql/affiliate_referrals/count_pending.sql new file mode 100644 index 0000000..0ae3432 --- /dev/null +++ b/sql/affiliate_referrals/count_pending.sql @@ -0,0 +1,5 @@ +SELECT COUNT(*) +FROM affiliate_referrals +WHERE "affiliate_user_id" = $1 + AND "status" = 'pending' + AND "redeemable_at" > NOW(); diff --git a/sql/affiliate_referrals/create.sql b/sql/affiliate_referrals/create.sql new file mode 100644 index 0000000..3914b96 --- /dev/null +++ b/sql/affiliate_referrals/create.sql @@ -0,0 +1,7 @@ +INSERT INTO affiliate_referrals ( + "affiliate_code_id", "affiliate_user_id", "referred_user_id", + "polar_subscription_id", "referred_tier", "referred_sku_id", + "purchased_days", "credit_days", "status", "redeemable_at" +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) +RETURNING "id", "created_at"; diff --git a/sql/affiliate_referrals/flag_by_subscription_id.sql b/sql/affiliate_referrals/flag_by_subscription_id.sql new file mode 100644 index 0000000..074b4f1 --- /dev/null +++ b/sql/affiliate_referrals/flag_by_subscription_id.sql @@ -0,0 +1,6 @@ +UPDATE affiliate_referrals +SET "status" = 'flagged', + "voided_at" = NOW(), + "voided_reason" = $2 +WHERE "polar_subscription_id" = $1 + AND "status" = 'redeemed'; diff --git a/sql/affiliate_referrals/get_by_id.sql b/sql/affiliate_referrals/get_by_id.sql new file mode 100644 index 0000000..6e55ae7 --- /dev/null +++ b/sql/affiliate_referrals/get_by_id.sql @@ -0,0 +1,7 @@ +SELECT "id", "affiliate_code_id", "affiliate_user_id", "referred_user_id", + "polar_subscription_id", "referred_tier", "referred_sku_id", + "purchased_days", "credit_days", "status", + "created_at", "redeemable_at", "redeemed_at", + "entitlement_id", "voided_at", "voided_reason" +FROM affiliate_referrals +WHERE "id" = $1; diff --git a/sql/affiliate_referrals/get_by_subscription_id.sql b/sql/affiliate_referrals/get_by_subscription_id.sql new file mode 100644 index 0000000..1c83910 --- /dev/null +++ b/sql/affiliate_referrals/get_by_subscription_id.sql @@ -0,0 +1,7 @@ +SELECT "id", "affiliate_code_id", "affiliate_user_id", "referred_user_id", + "polar_subscription_id", "referred_tier", "referred_sku_id", + "purchased_days", "credit_days", "status", + "created_at", "redeemable_at", "redeemed_at", + "entitlement_id", "voided_at", "voided_reason" +FROM affiliate_referrals +WHERE "polar_subscription_id" = $1; diff --git a/sql/affiliate_referrals/list_by_affiliate_user_id.sql b/sql/affiliate_referrals/list_by_affiliate_user_id.sql new file mode 100644 index 0000000..6c89001 --- /dev/null +++ b/sql/affiliate_referrals/list_by_affiliate_user_id.sql @@ -0,0 +1,9 @@ +SELECT "id", "affiliate_code_id", "affiliate_user_id", "referred_user_id", + "polar_subscription_id", "referred_tier", "referred_sku_id", + "purchased_days", "credit_days", "status", + "created_at", "redeemable_at", "redeemed_at", + "entitlement_id", "voided_at", "voided_reason" +FROM affiliate_referrals +WHERE "affiliate_user_id" = $1 +ORDER BY "created_at" DESC +LIMIT $2 OFFSET $3; diff --git a/sql/affiliate_referrals/list_flagged.sql b/sql/affiliate_referrals/list_flagged.sql new file mode 100644 index 0000000..30a1010 --- /dev/null +++ b/sql/affiliate_referrals/list_flagged.sql @@ -0,0 +1,9 @@ +SELECT "id", "affiliate_code_id", "affiliate_user_id", "referred_user_id", + "polar_subscription_id", "referred_tier", "referred_sku_id", + "purchased_days", "credit_days", "status", + "created_at", "redeemable_at", "redeemed_at", + "entitlement_id", "voided_at", "voided_reason" +FROM affiliate_referrals +WHERE "status" = 'flagged' +ORDER BY "voided_at" DESC +LIMIT $1 OFFSET $2; diff --git a/sql/affiliate_referrals/list_redeemable.sql b/sql/affiliate_referrals/list_redeemable.sql new file mode 100644 index 0000000..6fb4f94 --- /dev/null +++ b/sql/affiliate_referrals/list_redeemable.sql @@ -0,0 +1,11 @@ +SELECT "id", "affiliate_code_id", "affiliate_user_id", "referred_user_id", + "polar_subscription_id", "referred_tier", "referred_sku_id", + "purchased_days", "credit_days", "status", + "created_at", "redeemable_at", "redeemed_at", + "entitlement_id", "voided_at", "voided_reason" +FROM affiliate_referrals +WHERE "affiliate_user_id" = $1 + AND "status" IN ('pending', 'redeemable') + AND "redeemable_at" <= NOW() +ORDER BY "created_at" ASC +FOR UPDATE; diff --git a/sql/affiliate_referrals/schema.sql b/sql/affiliate_referrals/schema.sql new file mode 100644 index 0000000..19fb179 --- /dev/null +++ b/sql/affiliate_referrals/schema.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS affiliate_referrals ( + "id" UUID DEFAULT gen_random_uuid() PRIMARY KEY, + "affiliate_code_id" UUID NOT NULL REFERENCES affiliate_codes("id"), + "affiliate_user_id" INT8 NOT NULL, + "referred_user_id" INT8 NOT NULL, + "polar_subscription_id" TEXT NOT NULL UNIQUE, + "referred_tier" TEXT NOT NULL, + "referred_sku_id" UUID NOT NULL, + "purchased_days" INT NOT NULL, + "credit_days" INT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "redeemable_at" TIMESTAMPTZ NOT NULL, + "redeemed_at" TIMESTAMPTZ, + "entitlement_id" UUID, + "voided_at" TIMESTAMPTZ, + "voided_reason" TEXT +); + +CREATE INDEX IF NOT EXISTS idx_affiliate_referrals_affiliate_user_id ON affiliate_referrals("affiliate_user_id"); +CREATE INDEX IF NOT EXISTS idx_affiliate_referrals_status ON affiliate_referrals("status"); +CREATE INDEX IF NOT EXISTS idx_affiliate_referrals_redeemable_at ON affiliate_referrals("redeemable_at"); diff --git a/sql/affiliate_referrals/set_entitlement_id.sql b/sql/affiliate_referrals/set_entitlement_id.sql new file mode 100644 index 0000000..908cf49 --- /dev/null +++ b/sql/affiliate_referrals/set_entitlement_id.sql @@ -0,0 +1,3 @@ +UPDATE affiliate_referrals +SET "entitlement_id" = $2 +WHERE "id" = $1; diff --git a/sql/affiliate_referrals/sum_redeemable_days.sql b/sql/affiliate_referrals/sum_redeemable_days.sql new file mode 100644 index 0000000..3d3a566 --- /dev/null +++ b/sql/affiliate_referrals/sum_redeemable_days.sql @@ -0,0 +1,5 @@ +SELECT COALESCE(SUM("credit_days"), 0) +FROM affiliate_referrals +WHERE "affiliate_user_id" = $1 + AND "status" IN ('pending', 'redeemable') + AND "redeemable_at" <= NOW(); diff --git a/sql/affiliate_referrals/sum_redeemed_days.sql b/sql/affiliate_referrals/sum_redeemed_days.sql new file mode 100644 index 0000000..9dd0071 --- /dev/null +++ b/sql/affiliate_referrals/sum_redeemed_days.sql @@ -0,0 +1,4 @@ +SELECT COALESCE(SUM("credit_days"), 0) +FROM affiliate_referrals +WHERE "affiliate_user_id" = $1 + AND "status" = 'redeemed'; diff --git a/sql/affiliate_referrals/update_status.sql b/sql/affiliate_referrals/update_status.sql new file mode 100644 index 0000000..39facf4 --- /dev/null +++ b/sql/affiliate_referrals/update_status.sql @@ -0,0 +1,5 @@ +UPDATE affiliate_referrals +SET "status" = $2, + "redeemed_at" = CASE WHEN $2 = 'redeemed' THEN NOW() ELSE "redeemed_at" END, + "voided_at" = CASE WHEN $2 IN ('voided', 'flagged') THEN NOW() ELSE "voided_at" END +WHERE "id" = $1; diff --git a/sql/affiliate_referrals/void_by_subscription_id.sql b/sql/affiliate_referrals/void_by_subscription_id.sql new file mode 100644 index 0000000..decda68 --- /dev/null +++ b/sql/affiliate_referrals/void_by_subscription_id.sql @@ -0,0 +1,6 @@ +UPDATE affiliate_referrals +SET "status" = 'voided', + "voided_at" = NOW(), + "voided_reason" = $2 +WHERE "polar_subscription_id" = $1 + AND "status" IN ('pending', 'redeemable'); diff --git a/sql/category_update_queue/schema.sql b/sql/category_update_queue/schema.sql index f7f43be..2e302e6 100644 --- a/sql/category_update_queue/schema.sql +++ b/sql/category_update_queue/schema.sql @@ -1,4 +1,9 @@ -CREATE TYPE ticket_status AS ENUM ('OPEN', 'PENDING', 'CLOSED'); +DO $$ +BEGIN + CREATE TYPE ticket_status AS ENUM ('OPEN', 'PENDING', 'CLOSED'); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; CREATE TABLE IF NOT EXISTS category_update_queue ( guild_id INT8 NOT NULL, diff --git a/sql/email_verification_codes/delete.sql b/sql/email_verification_codes/delete.sql new file mode 100644 index 0000000..85d4aa7 --- /dev/null +++ b/sql/email_verification_codes/delete.sql @@ -0,0 +1 @@ +DELETE FROM email_verification_codes WHERE "user_id" = $1; diff --git a/sql/email_verification_codes/get_by_user_id.sql b/sql/email_verification_codes/get_by_user_id.sql new file mode 100644 index 0000000..24d564b --- /dev/null +++ b/sql/email_verification_codes/get_by_user_id.sql @@ -0,0 +1,3 @@ +SELECT "user_id", "code", "expires_at", "created_at", "failed_attempts" +FROM email_verification_codes +WHERE "user_id" = $1; diff --git a/sql/email_verification_codes/increment_attempts.sql b/sql/email_verification_codes/increment_attempts.sql new file mode 100644 index 0000000..8ad48f3 --- /dev/null +++ b/sql/email_verification_codes/increment_attempts.sql @@ -0,0 +1 @@ +UPDATE email_verification_codes SET "failed_attempts" = "failed_attempts" + 1 WHERE "user_id" = $1 RETURNING "failed_attempts"; diff --git a/sql/email_verification_codes/schema.sql b/sql/email_verification_codes/schema.sql new file mode 100644 index 0000000..097b5ae --- /dev/null +++ b/sql/email_verification_codes/schema.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS email_verification_codes ( + "user_id" INT8 NOT NULL PRIMARY KEY, + "code" TEXT NOT NULL, + "expires_at" TIMESTAMPTZ NOT NULL, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "failed_attempts" INT NOT NULL DEFAULT 0 +); diff --git a/sql/email_verification_codes/upsert.sql b/sql/email_verification_codes/upsert.sql new file mode 100644 index 0000000..577ace0 --- /dev/null +++ b/sql/email_verification_codes/upsert.sql @@ -0,0 +1,4 @@ +INSERT INTO email_verification_codes ("user_id", "code", "expires_at", "failed_attempts") +VALUES ($1, $2, $3, 0) +ON CONFLICT ("user_id") +DO UPDATE SET "code" = $2, "expires_at" = $3, "created_at" = NOW(), "failed_attempts" = 0; diff --git a/sql/entitlements/count_all_user_subscriptions.sql b/sql/entitlements/count_all_user_subscriptions.sql new file mode 100644 index 0000000..247f8fa --- /dev/null +++ b/sql/entitlements/count_all_user_subscriptions.sql @@ -0,0 +1,11 @@ +SELECT COUNT(*) +FROM entitlements +INNER JOIN skus ON entitlements.sku_id = skus.id +INNER JOIN subscription_skus ON skus.id = subscription_skus.sku_id +WHERE + entitlements.user_id IS NOT NULL + AND + ( + entitlements.expires_at IS NULL OR + entitlements.expires_at > (NOW() - $1::interval) + ); diff --git a/sql/entitlements/list_all_user_subscriptions_paginated.sql b/sql/entitlements/list_all_user_subscriptions_paginated.sql new file mode 100644 index 0000000..b42d8a4 --- /dev/null +++ b/sql/entitlements/list_all_user_subscriptions_paginated.sql @@ -0,0 +1,13 @@ +SELECT entitlements.id, entitlements.user_id, entitlements.source, entitlements.expires_at, skus.id, skus.label, subscription_skus.tier, subscription_skus.priority +FROM entitlements +INNER JOIN skus ON entitlements.sku_id = skus.id +INNER JOIN subscription_skus ON skus.id = subscription_skus.sku_id +WHERE + entitlements.user_id IS NOT NULL + AND + ( + entitlements.expires_at IS NULL OR + entitlements.expires_at > (NOW() - $1::interval) + ) +ORDER BY entitlements.expires_at DESC NULLS FIRST +LIMIT $2 OFFSET $3; diff --git a/sql/multi_server_skus/delete.sql b/sql/multi_server_skus/delete.sql new file mode 100644 index 0000000..969d515 --- /dev/null +++ b/sql/multi_server_skus/delete.sql @@ -0,0 +1 @@ +DELETE FROM multi_server_skus WHERE "sku_id" = $1; diff --git a/sql/multi_server_skus/insert.sql b/sql/multi_server_skus/insert.sql new file mode 100644 index 0000000..9d9ef6b --- /dev/null +++ b/sql/multi_server_skus/insert.sql @@ -0,0 +1,3 @@ +INSERT INTO multi_server_skus ("sku_id", "servers_permitted") +VALUES ($1, $2) +ON CONFLICT ("sku_id") DO UPDATE SET "servers_permitted" = $2; diff --git a/sql/notification_preferences/get_by_user_id.sql b/sql/notification_preferences/get_by_user_id.sql new file mode 100644 index 0000000..17b9d71 --- /dev/null +++ b/sql/notification_preferences/get_by_user_id.sql @@ -0,0 +1,3 @@ +SELECT "user_id", "category", "discord_dm", "email", "in_app" +FROM notification_preferences +WHERE "user_id" = $1; diff --git a/sql/notification_preferences/get_by_user_id_and_category.sql b/sql/notification_preferences/get_by_user_id_and_category.sql new file mode 100644 index 0000000..19f595e --- /dev/null +++ b/sql/notification_preferences/get_by_user_id_and_category.sql @@ -0,0 +1,4 @@ +SELECT "user_id", "category", "discord_dm", "email", "in_app" +FROM notification_preferences +WHERE "user_id" = $1 + AND "category" = $2; diff --git a/sql/notification_preferences/schema.sql b/sql/notification_preferences/schema.sql new file mode 100644 index 0000000..0142b93 --- /dev/null +++ b/sql/notification_preferences/schema.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS notification_preferences ( + "user_id" INT8 NOT NULL, + "category" TEXT NOT NULL, + "discord_dm" BOOLEAN NOT NULL DEFAULT TRUE, + "email" BOOLEAN NOT NULL DEFAULT FALSE, + "in_app" BOOLEAN NOT NULL DEFAULT TRUE, + PRIMARY KEY ("user_id", "category") +); diff --git a/sql/notification_preferences/upsert.sql b/sql/notification_preferences/upsert.sql new file mode 100644 index 0000000..b5184f7 --- /dev/null +++ b/sql/notification_preferences/upsert.sql @@ -0,0 +1,4 @@ +INSERT INTO notification_preferences ("user_id", "category", "discord_dm", "email", "in_app") +VALUES ($1, $2, $3, $4, $5) +ON CONFLICT ("user_id", "category") DO UPDATE +SET "discord_dm" = $3, "email" = $4, "in_app" = $5; diff --git a/sql/notifications/count_by_user_id.sql b/sql/notifications/count_by_user_id.sql new file mode 100644 index 0000000..8be4762 --- /dev/null +++ b/sql/notifications/count_by_user_id.sql @@ -0,0 +1,4 @@ +SELECT COUNT(*) +FROM notifications +WHERE "user_id" = $1 + AND ($2::TEXT[] IS NULL OR "category" = ANY($2)); diff --git a/sql/notifications/count_unread_by_user_id.sql b/sql/notifications/count_unread_by_user_id.sql new file mode 100644 index 0000000..c528b50 --- /dev/null +++ b/sql/notifications/count_unread_by_user_id.sql @@ -0,0 +1,4 @@ +SELECT COUNT(*) +FROM notifications +WHERE "user_id" = $1 + AND "read" = FALSE; diff --git a/sql/notifications/create.sql b/sql/notifications/create.sql new file mode 100644 index 0000000..7ad1e5d --- /dev/null +++ b/sql/notifications/create.sql @@ -0,0 +1,3 @@ +INSERT INTO notifications ("user_id", "category", "title", "body", "link") +VALUES ($1, $2, $3, $4, $5) +RETURNING "id", "created_at"; diff --git a/sql/notifications/list_by_user_id.sql b/sql/notifications/list_by_user_id.sql new file mode 100644 index 0000000..5396719 --- /dev/null +++ b/sql/notifications/list_by_user_id.sql @@ -0,0 +1,6 @@ +SELECT "id", "user_id", "category", "title", "body", "link", "read", "created_at" +FROM notifications +WHERE "user_id" = $1 + AND ($2::TEXT[] IS NULL OR "category" = ANY($2)) +ORDER BY "created_at" DESC +LIMIT $3 OFFSET $4; diff --git a/sql/notifications/mark_all_as_read.sql b/sql/notifications/mark_all_as_read.sql new file mode 100644 index 0000000..1385f42 --- /dev/null +++ b/sql/notifications/mark_all_as_read.sql @@ -0,0 +1,4 @@ +UPDATE notifications +SET "read" = TRUE +WHERE "user_id" = $1 + AND "read" = FALSE; diff --git a/sql/notifications/mark_as_read.sql b/sql/notifications/mark_as_read.sql new file mode 100644 index 0000000..f5e42c1 --- /dev/null +++ b/sql/notifications/mark_as_read.sql @@ -0,0 +1,4 @@ +UPDATE notifications +SET "read" = TRUE +WHERE "id" = $1 + AND "user_id" = $2; diff --git a/sql/notifications/schema.sql b/sql/notifications/schema.sql new file mode 100644 index 0000000..fd316ab --- /dev/null +++ b/sql/notifications/schema.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS notifications ( + "id" BIGSERIAL PRIMARY KEY, + "user_id" INT8 NOT NULL, + "category" TEXT NOT NULL, + "title" TEXT NOT NULL, + "body" TEXT NOT NULL, + "link" TEXT, + "read" BOOLEAN NOT NULL DEFAULT FALSE, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_notifications_user_id_read ON notifications("user_id", "read"); +CREATE INDEX IF NOT EXISTS idx_notifications_user_id_created ON notifications("user_id", "created_at" DESC); diff --git a/sql/polar_entitlements/delete_by_subscription_id.sql b/sql/polar_entitlements/delete_by_subscription_id.sql new file mode 100644 index 0000000..ca33936 --- /dev/null +++ b/sql/polar_entitlements/delete_by_subscription_id.sql @@ -0,0 +1,2 @@ +DELETE FROM polar_entitlements +WHERE "polar_subscription_id" = $1; diff --git a/sql/polar_entitlements/get_by_subscription_id.sql b/sql/polar_entitlements/get_by_subscription_id.sql new file mode 100644 index 0000000..0c96c33 --- /dev/null +++ b/sql/polar_entitlements/get_by_subscription_id.sql @@ -0,0 +1,3 @@ +SELECT "polar_subscription_id", "entitlement_id", "user_id", "polar_product_id", "status" +FROM polar_entitlements +WHERE "polar_subscription_id" = $1; diff --git a/sql/polar_entitlements/insert.sql b/sql/polar_entitlements/insert.sql new file mode 100644 index 0000000..94ec1fd --- /dev/null +++ b/sql/polar_entitlements/insert.sql @@ -0,0 +1,5 @@ +INSERT INTO polar_entitlements ("polar_subscription_id", "entitlement_id", "user_id", "polar_product_id", "status") +VALUES ($1, $2, $3, $4, $5) +ON CONFLICT ("polar_subscription_id") DO UPDATE SET + "entitlement_id" = EXCLUDED."entitlement_id", + "status" = EXCLUDED."status"; diff --git a/sql/polar_entitlements/list_by_user.sql b/sql/polar_entitlements/list_by_user.sql new file mode 100644 index 0000000..4e2ebd5 --- /dev/null +++ b/sql/polar_entitlements/list_by_user.sql @@ -0,0 +1,18 @@ +SELECT + pe."polar_subscription_id", + pe."entitlement_id", + pe."user_id", + pe."polar_product_id", + pe."status", + e."sku_id", + s."label" AS "sku_label", + ss."tier", + e."expires_at", + e."guild_id", + ms."servers_permitted" +FROM polar_entitlements pe +INNER JOIN entitlements e ON pe."entitlement_id" = e."id" +INNER JOIN skus s ON e."sku_id" = s."id" +INNER JOIN subscription_skus ss ON s."id" = ss."sku_id" +LEFT JOIN multi_server_skus ms ON s."id" = ms."sku_id" +WHERE pe."user_id" = $1; diff --git a/sql/polar_entitlements/schema.sql b/sql/polar_entitlements/schema.sql new file mode 100644 index 0000000..5d38cba --- /dev/null +++ b/sql/polar_entitlements/schema.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS polar_entitlements ( + "polar_subscription_id" TEXT NOT NULL, + "entitlement_id" UUID NOT NULL, + "user_id" INT8 NOT NULL, + "polar_product_id" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'active', + PRIMARY KEY ("polar_subscription_id"), + FOREIGN KEY ("entitlement_id") REFERENCES entitlements ("id") ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS polar_entitlements_user_id_idx ON polar_entitlements("user_id"); +CREATE INDEX IF NOT EXISTS polar_entitlements_entitlement_id_idx ON polar_entitlements("entitlement_id"); diff --git a/sql/polar_entitlements/update_status.sql b/sql/polar_entitlements/update_status.sql new file mode 100644 index 0000000..244b81e --- /dev/null +++ b/sql/polar_entitlements/update_status.sql @@ -0,0 +1,3 @@ +UPDATE polar_entitlements +SET "status" = $2 +WHERE "polar_subscription_id" = $1; diff --git a/sql/polar_products/delete.sql b/sql/polar_products/delete.sql new file mode 100644 index 0000000..5d2de79 --- /dev/null +++ b/sql/polar_products/delete.sql @@ -0,0 +1 @@ +DELETE FROM polar_products WHERE "id" = $1; diff --git a/sql/polar_products/get_by_polar_product_id.sql b/sql/polar_products/get_by_polar_product_id.sql new file mode 100644 index 0000000..3fc208a --- /dev/null +++ b/sql/polar_products/get_by_polar_product_id.sql @@ -0,0 +1,4 @@ +SELECT "id", "polar_product_id", "sku_id", "name", "description", "interval", + "price", "currency", "features", "highlighted", "sort_order", "tier", "servers_permitted" +FROM polar_products +WHERE "polar_product_id" = $1; diff --git a/sql/polar_products/insert.sql b/sql/polar_products/insert.sql new file mode 100644 index 0000000..ca99014 --- /dev/null +++ b/sql/polar_products/insert.sql @@ -0,0 +1,5 @@ +INSERT INTO polar_products ("polar_product_id", "sku_id", "name", "description", "interval", + "price", "currency", "features", "highlighted", "sort_order", "tier", "servers_permitted") +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) +RETURNING "id", "polar_product_id", "sku_id", "name", "description", "interval", + "price", "currency", "features", "highlighted", "sort_order", "tier", "servers_permitted"; diff --git a/sql/polar_products/list_all.sql b/sql/polar_products/list_all.sql new file mode 100644 index 0000000..f2aae79 --- /dev/null +++ b/sql/polar_products/list_all.sql @@ -0,0 +1,4 @@ +SELECT "id", "polar_product_id", "sku_id", "name", "description", "interval", + "price", "currency", "features", "highlighted", "sort_order", "tier", "servers_permitted" +FROM polar_products +ORDER BY "sort_order", "price"; diff --git a/sql/polar_products/schema.sql b/sql/polar_products/schema.sql new file mode 100644 index 0000000..2ec3af2 --- /dev/null +++ b/sql/polar_products/schema.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS polar_products ( + "id" UUID DEFAULT gen_random_uuid() PRIMARY KEY, + "polar_product_id" TEXT NOT NULL UNIQUE, + "sku_id" UUID NOT NULL REFERENCES skus("id"), + "name" TEXT NOT NULL, + "description" TEXT NOT NULL DEFAULT '', + "interval" TEXT NOT NULL DEFAULT 'month', + "price" INT NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'usd', + "features" TEXT[] NOT NULL DEFAULT '{}', + "highlighted" BOOLEAN NOT NULL DEFAULT false, + "sort_order" INT NOT NULL DEFAULT 0, + "tier" TEXT NOT NULL DEFAULT 'premium', + "servers_permitted" INT DEFAULT NULL +); diff --git a/sql/polar_products/update.sql b/sql/polar_products/update.sql new file mode 100644 index 0000000..d15b89c --- /dev/null +++ b/sql/polar_products/update.sql @@ -0,0 +1,14 @@ +UPDATE polar_products +SET "polar_product_id" = $2, + "sku_id" = $3, + "name" = $4, + "description" = $5, + "interval" = $6, + "price" = $7, + "currency" = $8, + "features" = $9, + "highlighted" = $10, + "sort_order" = $11, + "tier" = $12, + "servers_permitted" = $13 +WHERE "id" = $1; diff --git a/sql/premium_keys/count_all.sql b/sql/premium_keys/count_all.sql new file mode 100644 index 0000000..d18a42d --- /dev/null +++ b/sql/premium_keys/count_all.sql @@ -0,0 +1 @@ +SELECT (SELECT COUNT(*) FROM premium_keys) + (SELECT COUNT(*) FROM used_keys); diff --git a/sql/premium_keys/list_all.sql b/sql/premium_keys/list_all.sql new file mode 100644 index 0000000..252d6ec --- /dev/null +++ b/sql/premium_keys/list_all.sql @@ -0,0 +1,16 @@ +SELECT pk."key", pk."length", pk."sku_id", pk."generated_at", + skus."label" AS sku_label, subscription_skus."tier", + NULL::int8 AS guild_id, NULL::int8 AS activated_by, false AS is_used +FROM premium_keys pk +INNER JOIN skus ON pk.sku_id = skus.id +INNER JOIN subscription_skus ON skus.id = subscription_skus.sku_id + +UNION ALL + +SELECT uk."key", NULL::interval AS "length", NULL::uuid AS sku_id, + NULL::timestamptz AS generated_at, NULL AS sku_label, NULL::premium_tier AS tier, + uk."guild_id", uk."activated_by", true AS is_used +FROM used_keys uk + +ORDER BY is_used ASC, generated_at DESC NULLS LAST +LIMIT $1 OFFSET $2; diff --git a/sql/skus/delete.sql b/sql/skus/delete.sql new file mode 100644 index 0000000..a496252 --- /dev/null +++ b/sql/skus/delete.sql @@ -0,0 +1 @@ +DELETE FROM skus WHERE "id" = $1; diff --git a/sql/skus/get.sql b/sql/skus/get.sql new file mode 100644 index 0000000..ab86502 --- /dev/null +++ b/sql/skus/get.sql @@ -0,0 +1 @@ +SELECT "id", "label", "type" FROM skus WHERE "id" = $1; diff --git a/sql/skus/insert.sql b/sql/skus/insert.sql new file mode 100644 index 0000000..accc481 --- /dev/null +++ b/sql/skus/insert.sql @@ -0,0 +1,3 @@ +INSERT INTO skus ("label", "type") +VALUES ($1, $2) +RETURNING "id", "label", "type"; diff --git a/sql/skus/list_all.sql b/sql/skus/list_all.sql new file mode 100644 index 0000000..ec526ea --- /dev/null +++ b/sql/skus/list_all.sql @@ -0,0 +1,7 @@ +SELECT s."id", s."label", s."type", + ss."tier", ss."priority", ss."is_global", + ms."servers_permitted" +FROM skus s +LEFT JOIN subscription_skus ss ON s."id" = ss."sku_id" +LEFT JOIN multi_server_skus ms ON s."id" = ms."sku_id" +ORDER BY s."label"; diff --git a/sql/skus/update.sql b/sql/skus/update.sql new file mode 100644 index 0000000..d02e7ba --- /dev/null +++ b/sql/skus/update.sql @@ -0,0 +1 @@ +UPDATE skus SET "label" = $2, "type" = $3 WHERE "id" = $1; diff --git a/sql/subscription_skus/delete.sql b/sql/subscription_skus/delete.sql new file mode 100644 index 0000000..8654a24 --- /dev/null +++ b/sql/subscription_skus/delete.sql @@ -0,0 +1 @@ +DELETE FROM subscription_skus WHERE "sku_id" = $1; diff --git a/sql/subscription_skus/insert.sql b/sql/subscription_skus/insert.sql new file mode 100644 index 0000000..5ef931b --- /dev/null +++ b/sql/subscription_skus/insert.sql @@ -0,0 +1,3 @@ +INSERT INTO subscription_skus ("sku_id", "tier", "priority", "is_global") +VALUES ($1, $2, $3, $4) +ON CONFLICT ("sku_id") DO UPDATE SET "tier" = $2, "priority" = $3, "is_global" = $4; diff --git a/sql/user_emails/delete.sql b/sql/user_emails/delete.sql new file mode 100644 index 0000000..5ef2135 --- /dev/null +++ b/sql/user_emails/delete.sql @@ -0,0 +1 @@ +DELETE FROM user_emails WHERE "user_id" = $1; diff --git a/sql/user_emails/get_by_user_id.sql b/sql/user_emails/get_by_user_id.sql new file mode 100644 index 0000000..bd4be01 --- /dev/null +++ b/sql/user_emails/get_by_user_id.sql @@ -0,0 +1,3 @@ +SELECT "user_id", "email", "verified", "created_at", "updated_at" +FROM user_emails +WHERE "user_id" = $1; diff --git a/sql/user_emails/schema.sql b/sql/user_emails/schema.sql new file mode 100644 index 0000000..8d42431 --- /dev/null +++ b/sql/user_emails/schema.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS user_emails ( + "user_id" INT8 NOT NULL PRIMARY KEY, + "email" TEXT NOT NULL, + "verified" BOOLEAN NOT NULL DEFAULT FALSE, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/sql/user_emails/set_verified.sql b/sql/user_emails/set_verified.sql new file mode 100644 index 0000000..a48aca0 --- /dev/null +++ b/sql/user_emails/set_verified.sql @@ -0,0 +1,3 @@ +UPDATE user_emails +SET "verified" = $2, "updated_at" = NOW() +WHERE "user_id" = $1; diff --git a/sql/user_emails/upsert.sql b/sql/user_emails/upsert.sql new file mode 100644 index 0000000..56c0c38 --- /dev/null +++ b/sql/user_emails/upsert.sql @@ -0,0 +1,4 @@ +INSERT INTO user_emails ("user_id", "email") +VALUES ($1, $2) +ON CONFLICT ("user_id") +DO UPDATE SET "email" = $2, "verified" = FALSE, "updated_at" = NOW(); diff --git a/subscriptionskus.go b/subscriptionskus.go index 94001ae..3a23a70 100644 --- a/subscriptionskus.go +++ b/subscriptionskus.go @@ -24,6 +24,12 @@ var ( //go:embed sql/subscription_skus/search.sql subscriptionSkusSearch string + + //go:embed sql/subscription_skus/insert.sql + subscriptionSkusInsert string + + //go:embed sql/subscription_skus/delete.sql + subscriptionSkusDelete string ) func newSubscriptionSkusTable(db *pgxpool.Pool) *SubscriptionSkus { @@ -56,6 +62,18 @@ func (e *SubscriptionSkus) GetSku(ctx context.Context, tx pgx.Tx, skuId uuid.UUI return &sku, nil } +// Upsert inserts or updates a subscription SKU entry within a transaction. +func (e *SubscriptionSkus) Upsert(ctx context.Context, tx pgx.Tx, skuId uuid.UUID, tier model.EntitlementTier, priority int32, isGlobal bool) error { + _, err := tx.Exec(ctx, subscriptionSkusInsert, skuId, tier, priority, isGlobal) + return err +} + +// DeleteBySku removes a subscription SKU entry by its SKU ID within a transaction. +func (e *SubscriptionSkus) DeleteBySku(ctx context.Context, tx pgx.Tx, skuId uuid.UUID) error { + _, err := tx.Exec(ctx, subscriptionSkusDelete, skuId) + return err +} + func (e *SubscriptionSkus) Search(ctx context.Context, label string, limit int) ([]model.SubscriptionSku, error) { rows, err := e.Query(ctx, subscriptionSkusSearch, label, limit) if err != nil { diff --git a/tag.go b/tag.go index b57582a..d7297d7 100644 --- a/tag.go +++ b/tag.go @@ -14,6 +14,7 @@ type Tag struct { Content *string Embed *CustomEmbedWithFields ApplicationCommandId *uint64 + KBArticleId *int `json:"kb_article_id"` } type TagsTable struct { @@ -38,6 +39,8 @@ CREATE TABLE IF NOT EXISTS tags( PRIMARY KEY("guild_id", "tag_id") ); CREATE INDEX IF NOT EXISTS tags_guild_id_idx ON tags("guild_id"); + +ALTER TABLE tags ADD COLUMN IF NOT EXISTS "kb_article_id" int4 DEFAULT NULL; ` } @@ -49,7 +52,7 @@ func (t *TagsTable) Exists(ctx context.Context, guildId uint64, tagId string) (e func (t *TagsTable) Get(ctx context.Context, guildId uint64, tagId string) (Tag, bool, error) { query := ` -SELECT LOWER(tag_id), "guild_id", "content", "embed", "application_command_id" +SELECT LOWER(tag_id), "guild_id", "content", "embed", "application_command_id", "kb_article_id" FROM tags WHERE "guild_id" = $1 AND LOWER("tag_id") = LOWER($2); ` @@ -62,6 +65,7 @@ WHERE "guild_id" = $1 AND LOWER("tag_id") = LOWER($2); &tag.Content, &embedRaw, &tag.ApplicationCommandId, + &tag.KBArticleId, ) if err != nil { @@ -105,14 +109,14 @@ func (t *TagsTable) GetTagIds(ctx context.Context, guildId uint64) (ids []string func (t *TagsTable) GetByApplicationCommandId(ctx context.Context, guildId, applicationCommandId uint64) (Tag, bool, error) { query := ` -SELECT LOWER(tags.tag_id), tags.guild_id, tags.content, tags.embed, tags.application_command_id +SELECT LOWER(tags.tag_id), tags.guild_id, tags.content, tags.embed, tags.application_command_id, tags.kb_article_id FROM tags WHERE "guild_id" = $1 AND "application_command_id" = $2; ` var tag Tag var embedRaw *string - if err := t.QueryRow(ctx, query, guildId, applicationCommandId).Scan(&tag.Id, &tag.GuildId, &tag.Content, &embedRaw, &tag.ApplicationCommandId); err != nil { + if err := t.QueryRow(ctx, query, guildId, applicationCommandId).Scan(&tag.Id, &tag.GuildId, &tag.Content, &embedRaw, &tag.ApplicationCommandId, &tag.KBArticleId); err != nil { if errors.Is(err, pgx.ErrNoRows) { return Tag{}, false, nil } @@ -131,7 +135,7 @@ WHERE "guild_id" = $1 AND "application_command_id" = $2; func (t *TagsTable) GetByGuild(ctx context.Context, guildId uint64) (map[string]Tag, error) { query := ` -SELECT LOWER(tags.tag_id), tags.guild_id, tags.content, tags.embed, tags.application_command_id +SELECT LOWER(tags.tag_id), tags.guild_id, tags.content, tags.embed, tags.application_command_id, tags.kb_article_id FROM tags WHERE "guild_id" = $1;` @@ -146,7 +150,7 @@ WHERE "guild_id" = $1;` for rows.Next() { var tag Tag var embedRaw *string - if err := rows.Scan(&tag.Id, &tag.GuildId, &tag.Content, &embedRaw, &tag.ApplicationCommandId); err != nil { + if err := rows.Scan(&tag.Id, &tag.GuildId, &tag.Content, &embedRaw, &tag.ApplicationCommandId, &tag.KBArticleId); err != nil { return nil, err } @@ -216,10 +220,10 @@ func (t *TagsTable) GetStartingWith(ctx context.Context, guildId uint64, prefix func (t *TagsTable) Set(ctx context.Context, tag Tag) error { query := ` -INSERT INTO tags("tag_id", "guild_id", "content", "embed", "application_command_id") -VALUES(LOWER($1), $2, $3, $4, $5) +INSERT INTO tags("tag_id", "guild_id", "content", "embed", "application_command_id", "kb_article_id") +VALUES(LOWER($1), $2, $3, $4, $5, $6) ON CONFLICT("tag_id", "guild_id") DO -UPDATE SET "content" = $3, "embed" = $4, "application_command_id" = $5;` +UPDATE SET "content" = $3, "embed" = $4, "application_command_id" = $5, "kb_article_id" = $6;` var embedRaw *string if tag.Embed != nil { @@ -231,10 +235,45 @@ UPDATE SET "content" = $3, "embed" = $4, "application_command_id" = $5;` embedRaw = &tmp } - _, err := t.Exec(ctx, query, tag.Id, tag.GuildId, tag.Content, embedRaw, tag.ApplicationCommandId) + _, err := t.Exec(ctx, query, tag.Id, tag.GuildId, tag.Content, embedRaw, tag.ApplicationCommandId, tag.KBArticleId) return err } +func (t *TagsTable) GetByKBArticleId(ctx context.Context, guildId uint64, articleId int) (Tag, bool, error) { + query := ` +SELECT LOWER(tag_id), "guild_id", "content", "embed", "application_command_id", "kb_article_id" +FROM tags +WHERE "guild_id" = $1 AND "kb_article_id" = $2 +LIMIT 1; +` + + var tag Tag + var embedRaw *string + err := t.QueryRow(ctx, query, guildId, articleId).Scan( + &tag.Id, + &tag.GuildId, + &tag.Content, + &embedRaw, + &tag.ApplicationCommandId, + &tag.KBArticleId, + ) + + if err != nil { + if err == pgx.ErrNoRows { + return Tag{}, false, nil + } + return Tag{}, false, err + } + + if embedRaw != nil { + if err := json.UnmarshalFromString(*embedRaw, &tag.Embed); err != nil { + return Tag{}, false, err + } + } + + return tag, true, nil +} + func (t *TagsTable) Delete(ctx context.Context, guildId uint64, tagId string) (err error) { query := ` DELETE FROM tags diff --git a/ticketclaims.go b/ticketclaims.go index 66bd4ec..8b44dba 100644 --- a/ticketclaims.go +++ b/ticketclaims.go @@ -45,6 +45,26 @@ func (c *TicketClaims) Set(ctx context.Context, guildId uint64, ticketId int, us return } +// TryClaim atomically claims the ticket only if it is currently unclaimed. It returns +// claimed=true if the claim was recorded, or claimed=false with the existing claimer if it +// was already claimed by anyone. +func (c *TicketClaims) TryClaim(ctx context.Context, guildId uint64, ticketId int, userId uint64) (claimed bool, claimer uint64, err error) { + query := `INSERT INTO ticket_claims("guild_id", "ticket_id", "user_id") VALUES($1, $2, $3) ON CONFLICT("guild_id", "ticket_id") DO NOTHING;` + + tag, err := c.Exec(ctx, query, guildId, ticketId, userId) + if err != nil { + return false, 0, err + } + + if tag.RowsAffected() > 0 { + return true, userId, nil + } + + // Already claimed - return the current claimer + claimer, err = c.Get(ctx, guildId, ticketId) + return false, claimer, err +} + func (c *TicketClaims) Delete(ctx context.Context, guildId uint64, ticketId int) (err error) { query := `DELETE FROM ticket_claims WHERE "guild_id"=$1 AND "ticket_id"=$2;` _, err = c.Exec(ctx, query, guildId, ticketId) @@ -75,3 +95,17 @@ func (c *TicketClaims) GetClaimedCount(ctx context.Context, guildId, userId uint return } + +func (c *TicketClaims) GetOpenClaimedCount(ctx context.Context, guildId, userId uint64) (count int, e error) { + query := ` +SELECT COUNT(*) +FROM ticket_claims +INNER JOIN tickets ON ticket_claims.guild_id = tickets.guild_id AND ticket_claims.ticket_id = tickets.id +WHERE ticket_claims.guild_id = $1 AND ticket_claims.user_id = $2 AND tickets.open = true;` + + if err := c.QueryRow(ctx, query, guildId, userId).Scan(&count); err != nil && err != pgx.ErrNoRows { + e = err + } + + return +} diff --git a/ticketlabelassignments.go b/ticketlabelassignments.go index c56338e..fa2736f 100644 --- a/ticketlabelassignments.go +++ b/ticketlabelassignments.go @@ -2,6 +2,7 @@ package database import ( "context" + "fmt" "github.com/jackc/pgtype" "github.com/jackc/pgx/v4/pgxpool" @@ -140,3 +141,37 @@ func (t *TicketLabelAssignmentsTable) Replace(ctx context.Context, guildId uint6 return tx.Commit(ctx) } + +func (t *TicketLabelAssignmentsTable) GetTicketCountByLabel(ctx context.Context, guildId uint64, nDays int, filter *PanelFilter) ([]LabelTicketCount, error) { + query := ` +SELECT tla.label_id, tl.name, tl.colour, COUNT(*) +FROM ticket_label_assignments tla +INNER JOIN ticket_labels tl ON tla.guild_id = tl.guild_id AND tla.label_id = tl.label_id +INNER JOIN tickets t ON tla.guild_id = t.guild_id AND tla.ticket_id = t.id +WHERE tla.guild_id = $1 AND t.open_time > CURRENT_DATE - ($2 - 1) * INTERVAL '1 day'` + + PanelPredicate("t", 3, 4) + ` +GROUP BY tla.label_id, tl.name, tl.colour +ORDER BY COUNT(*) DESC;` + + arr, unassigned, err := filter.Args() + if err != nil { + return nil, fmt.Errorf("ticket count by label: %w", err) + } + + rows, err := t.Query(ctx, query, guildId, nDays, arr, unassigned) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []LabelTicketCount + for rows.Next() { + var r LabelTicketCount + if err := rows.Scan(&r.LabelId, &r.Name, &r.Colour, &r.Count); err != nil { + return nil, err + } + results = append(results, r) + } + + return results, nil +} diff --git a/ticketlimit.go b/ticketlimit.go deleted file mode 100644 index f43687d..0000000 --- a/ticketlimit.go +++ /dev/null @@ -1,45 +0,0 @@ -package database - -import ( - "context" - "github.com/jackc/pgx/v4" - "github.com/jackc/pgx/v4/pgxpool" -) - -type TicketLimit struct { - *pgxpool.Pool -} - -func newTicketLimit(db *pgxpool.Pool) *TicketLimit { - return &TicketLimit{ - db, - } -} - -func (t TicketLimit) Schema() string { - return ` -CREATE TABLE IF NOT EXISTS ticket_limit( - "guild_id" int8 NOT NULL UNIQUE, - "limit" int2 NOT NULL, - PRIMARY KEY("guild_id") -);` -} - -func (t *TicketLimit) Get(ctx context.Context, guildId uint64) (limit uint8, e error) { - query := `SELECT "limit" from ticket_limit WHERE "guild_id" = $1;` - if err := t.QueryRow(ctx, query, guildId).Scan(&limit); err != nil { - if err == pgx.ErrNoRows { - limit = 5 - } else { - e = err - } - } - - return -} - -func (t *TicketLimit) Set(ctx context.Context, guildId uint64, limit uint8) (err error) { - query := `INSERT INTO ticket_limit("guild_id", "limit") VALUES($1, $2) ON CONFLICT("guild_id") DO UPDATE SET "limit" = $2;` - _, err = t.Exec(ctx, query, guildId, limit) - return -} diff --git a/ticketmessagecounts.go b/ticketmessagecounts.go new file mode 100644 index 0000000..3ec124a --- /dev/null +++ b/ticketmessagecounts.go @@ -0,0 +1,142 @@ +package database + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" +) + +type TicketMessageCounts struct { + *pgxpool.Pool +} + +func newTicketMessageCounts(db *pgxpool.Pool) *TicketMessageCounts { + return &TicketMessageCounts{db} +} + +func (t TicketMessageCounts) Schema() string { + return ` +CREATE TABLE IF NOT EXISTS ticket_message_counts( + "guild_id" int8 NOT NULL, + "ticket_id" int4 NOT NULL, + "staff_messages" int4 NOT NULL DEFAULT 0, + "user_messages" int4 NOT NULL DEFAULT 0, + FOREIGN KEY("guild_id", "ticket_id") REFERENCES tickets("guild_id", "id"), + PRIMARY KEY("guild_id", "ticket_id") +); +CREATE INDEX IF NOT EXISTS ticket_message_counts_guild_id ON ticket_message_counts("guild_id"); +` +} + +type MessageCounts struct { + GuildId uint64 `json:"guild_id"` + TicketId int `json:"ticket_id"` + StaffMessages int `json:"staff_messages"` + UserMessages int `json:"user_messages"` +} + +func (t *TicketMessageCounts) Get(ctx context.Context, guildId uint64, ticketId int) (counts MessageCounts, e error) { + query := `SELECT "guild_id", "ticket_id", "staff_messages", "user_messages" FROM ticket_message_counts WHERE "guild_id" = $1 AND "ticket_id" = $2;` + if err := t.QueryRow(ctx, query, guildId, ticketId).Scan(&counts.GuildId, &counts.TicketId, &counts.StaffMessages, &counts.UserMessages); err != nil && err != pgx.ErrNoRows { + e = err + } + + return +} + +func (t *TicketMessageCounts) IncrementStaffMessages(ctx context.Context, guildId uint64, ticketId int) (err error) { + query := ` +INSERT INTO ticket_message_counts("guild_id", "ticket_id", "staff_messages", "user_messages") +VALUES($1, $2, 1, 0) +ON CONFLICT("guild_id", "ticket_id") DO UPDATE SET "staff_messages" = ticket_message_counts."staff_messages" + 1;` + _, err = t.Exec(ctx, query, guildId, ticketId) + return +} + +func (t *TicketMessageCounts) IncrementUserMessages(ctx context.Context, guildId uint64, ticketId int) (err error) { + query := ` +INSERT INTO ticket_message_counts("guild_id", "ticket_id", "staff_messages", "user_messages") +VALUES($1, $2, 0, 1) +ON CONFLICT("guild_id", "ticket_id") DO UPDATE SET "user_messages" = ticket_message_counts."user_messages" + 1;` + _, err = t.Exec(ctx, query, guildId, ticketId) + return +} + +func (t *TicketMessageCounts) Set(ctx context.Context, guildId uint64, ticketId int, staffMessages, userMessages int) (err error) { + query := ` +INSERT INTO ticket_message_counts("guild_id", "ticket_id", "staff_messages", "user_messages") +VALUES($1, $2, $3, $4) +ON CONFLICT("guild_id", "ticket_id") DO UPDATE SET "staff_messages" = $3, "user_messages" = $4;` + _, err = t.Exec(ctx, query, guildId, ticketId, staffMessages, userMessages) + return +} + +type AverageMessageCounts struct { + AvgStaffMessages *float64 `json:"avg_staff_messages"` + AvgUserMessages *float64 `json:"avg_user_messages"` + AvgTotalMessages *float64 `json:"avg_total_messages"` +} + +// GetAverageMessageCounts is the worker-compatible wrapper. Delegates to +// GetAverageMessageCountsFiltered with no panel filter. +func (t *TicketMessageCounts) GetAverageMessageCounts(ctx context.Context, guildId uint64, days int) (AverageMessageCounts, error) { + return t.GetAverageMessageCountsFiltered(ctx, guildId, days, nil) +} + +func (t *TicketMessageCounts) GetAverageMessageCountsFiltered(ctx context.Context, guildId uint64, days int, filter *PanelFilter) (avg AverageMessageCounts, e error) { + query := ` +SELECT + AVG(tmc."staff_messages"), + AVG(tmc."user_messages"), + AVG(tmc."staff_messages" + tmc."user_messages") +FROM ticket_message_counts tmc +INNER JOIN tickets t ON tmc."guild_id" = t."guild_id" AND tmc."ticket_id" = t."id" +WHERE tmc."guild_id" = $1 AND t."open" = false + AND ($2 = 0 OR t."open_time" > NOW() - make_interval(days => $2))` + + PanelPredicate("t", 3, 4) + + arr, unassigned, err := filter.Args() + if err != nil { + return avg, fmt.Errorf("average message counts: %w", err) + } + + if err := t.QueryRow(ctx, query, guildId, days, arr, unassigned).Scan(&avg.AvgStaffMessages, &avg.AvgUserMessages, &avg.AvgTotalMessages); err != nil && err != pgx.ErrNoRows { + e = err + } + return +} + +type OneTouchResolution struct { + OneTouchCount int `json:"one_touch_count"` + TotalClosed int `json:"total_closed"` +} + +// GetOneTouchResolutionRate is the worker-compatible wrapper. Delegates to +// GetOneTouchResolutionRateFiltered with no panel filter. +func (t *TicketMessageCounts) GetOneTouchResolutionRate(ctx context.Context, guildId uint64, days int) (OneTouchResolution, error) { + return t.GetOneTouchResolutionRateFiltered(ctx, guildId, days, nil) +} + +func (t *TicketMessageCounts) GetOneTouchResolutionRateFiltered(ctx context.Context, guildId uint64, days int, filter *PanelFilter) (result OneTouchResolution, e error) { + query := ` +SELECT + COUNT(*) FILTER (WHERE tmc."staff_messages" = 1), + COUNT(*) +FROM ticket_message_counts tmc +INNER JOIN tickets t ON tmc."guild_id" = t."guild_id" AND tmc."ticket_id" = t."id" +WHERE tmc."guild_id" = $1 AND t."open" = false + AND ($2 = 0 OR t."open_time" > NOW() - make_interval(days => $2))` + + PanelPredicate("t", 3, 4) + + arr, unassigned, err := filter.Args() + if err != nil { + return result, fmt.Errorf("one touch resolution: %w", err) + } + + if err := t.QueryRow(ctx, query, guildId, days, arr, unassigned).Scan(&result.OneTouchCount, &result.TotalClosed); err != nil && err != pgx.ErrNoRows { + e = err + } + return +} diff --git a/ticketpermissions.go b/ticketpermissions.go deleted file mode 100644 index bace5ce..0000000 --- a/ticketpermissions.go +++ /dev/null @@ -1,95 +0,0 @@ -package database - -import ( - "context" - "github.com/jackc/pgx/v4" - "github.com/jackc/pgx/v4/pgxpool" -) - -type TicketPermissions struct { - AddReactions bool `json:"add_reactions"` - SendTTSMessages bool `json:"send_tts_messages"` - EmbedLinks bool `json:"embed_links"` - AttachFiles bool `json:"attach_files"` - UseExternalEmojis bool `json:"use_external_emojis"` - UseExternalStickers bool `json:"use_external_stickers"` - SendVoiceMessages bool `json:"send_voice_messages"` -} - -type TicketPermissionsTable struct { - *pgxpool.Pool -} - -func newTicketPermissionsTable(db *pgxpool.Pool) *TicketPermissionsTable { - return &TicketPermissionsTable{ - db, - } -} - -func (c TicketPermissionsTable) Schema() string { - return ` -CREATE TABLE IF NOT EXISTS ticket_permissions( - "guild_id" int8 NOT NULL, - "add_reactions" bool NOT NULL DEFAULT 't', - "send_tts_messages" bool NOT NULL DEFAULT 't', - "embed_links" bool NOT NULL DEFAULT 't', - "attach_files" bool NOT NULL DEFAULT 't', - "use_external_emojis" bool NOT NULL DEFAULT 't', - "use_external_stickers" bool NOT NULL DEFAULT 't', - "send_voice_messages" bool NOT NULL DEFAULT 't', - PRIMARY KEY("guild_id") -); -` -} - -func (c *TicketPermissionsTable) Get(ctx context.Context, guildId uint64) (TicketPermissions, error) { - query := ` -SELECT "add_reactions", "send_tts_messages", "embed_links", "attach_files", "use_external_emojis", "use_external_stickers", "send_voice_messages" -FROM ticket_permissions -WHERE "guild_id" = $1;` - - var permissions TicketPermissions - err := c.QueryRow(ctx, query, guildId).Scan( - &permissions.AddReactions, - &permissions.SendTTSMessages, - &permissions.EmbedLinks, - &permissions.AttachFiles, - &permissions.UseExternalEmojis, - &permissions.UseExternalStickers, - &permissions.SendVoiceMessages, - ) - - if err != nil { - if err == pgx.ErrNoRows { - return TicketPermissions{ - AddReactions: true, - SendTTSMessages: true, - EmbedLinks: true, - AttachFiles: true, - UseExternalEmojis: true, - UseExternalStickers: true, - SendVoiceMessages: true, - }, nil - } else { - return TicketPermissions{}, err - } - } - - return permissions, nil -} - -func (c *TicketPermissionsTable) Set(ctx context.Context, guildId uint64, permissions TicketPermissions) (err error) { - query := ` -INSERT INTO ticket_permissions("guild_id", "add_reactions", "send_tts_messages", "embed_links", "attach_files", "use_external_emojis", "use_external_stickers", "send_voice_messages") -VALUES($1, $2, $3, $4, $5, $6, $7, $8) -ON CONFLICT("guild_id") DO UPDATE SET "add_reactions" = $2, "send_tts_messages" = $3, "embed_links" = $4, "attach_files" = $5, "use_external_emojis" = $6, "use_external_stickers" = $7, "send_voice_messages" = $8;` - - _, err = c.Exec(ctx, query, guildId, permissions.AddReactions, permissions.SendTTSMessages, permissions.EmbedLinks, permissions.AttachFiles, permissions.UseExternalEmojis, permissions.UseExternalStickers, permissions.SendVoiceMessages) - return -} - -func (c *TicketPermissionsTable) Delete(ctx context.Context, guildId uint64) error { - query := `DELETE FROM ticket_permissions WHERE "guild_id"=$1;` - _, err := c.Exec(ctx, query, guildId) - return err -} diff --git a/tickets.go b/tickets.go index 90f0bd4..ba258d7 100644 --- a/tickets.go +++ b/tickets.go @@ -28,6 +28,7 @@ type Ticket struct { JoinMessageId *uint64 `json:"join_message_id"` NotesThreadId *uint64 `json:"notes_thread_id"` Status model.TicketStatus `json:"status"` + Source TicketSource `json:"source"` } type TicketQueryOptions struct { @@ -42,6 +43,7 @@ type TicketQueryOptions struct { Rating int `json:"rating"` LabelIds []int `json:"label_ids"` CloseReasonSearch string `json:"close_reason_search"` + SortBy SortBy `json:"sort_by"` Order OrderType `json:"order_type"` Limit int `json:"limit"` Offset int `json:"offset"` @@ -55,6 +57,14 @@ const ( OrderTypeDescending OrderType = "DESC" ) +type SortBy string + +const ( + SortByTicketId SortBy = "" + SortByRating SortBy = "rating" + SortByCloseReason SortBy = "close_reason" +) + func (o TicketQueryOptions) HasWhereClause() bool { return o.Id == 0 && o.GuildId == 0 && @@ -80,6 +90,13 @@ func newTicketTable(db *pgxpool.Pool) *TicketTable { func (t TicketTable) Schema() string { return ` +DO $$ +BEGIN + CREATE TYPE ticket_status AS ENUM ('OPEN', 'PENDING', 'CLOSED'); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + CREATE TABLE IF NOT EXISTS tickets( "id" int4 NOT NULL, "guild_id" int8 NOT NULL, @@ -95,19 +112,22 @@ CREATE TABLE IF NOT EXISTS tickets( "join_message_id" int8 DEFAULT NULL, "notes_thread_id" int8 DEFAULT NULL, "status" ticket_status NOT NULL, + "source" int2 NOT NULL DEFAULT 0, FOREIGN KEY("panel_id") REFERENCES panels("panel_id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY("id", "guild_id") ); -CREATE TABLE guild_ticket_counters ( +CREATE TABLE IF NOT EXISTS guild_ticket_counters ( guild_id bigint PRIMARY KEY, last_ticket_id integer NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS tickets_channel_id ON tickets("channel_id"); CREATE INDEX IF NOT EXISTS tickets_panel_id ON tickets("panel_id"); +CREATE INDEX IF NOT EXISTS tickets_guild_id_open_time ON tickets("guild_id", "open_time"); +ALTER TABLE tickets ADD COLUMN IF NOT EXISTS "source" int2 NOT NULL DEFAULT 0; ` } -func (t *TicketTable) Create(ctx context.Context, guildId, userId uint64, isThread bool, panelId *int) (id int, err error) { +func (t *TicketTable) Create(ctx context.Context, guildId, userId uint64, isThread bool, panelId *int, source TicketSource) (id int, err error) { tx, err := t.Begin(ctx) if err != nil { return 0, err @@ -119,20 +139,20 @@ func (t *TicketTable) Create(ctx context.Context, guildId, userId uint64, isThre if err := tx.QueryRow(ctx, ` INSERT INTO guild_ticket_counters (guild_id, last_ticket_id) VALUES ($1, 1) - ON CONFLICT (guild_id) DO UPDATE + ON CONFLICT (guild_id) DO UPDATE SET last_ticket_id = guild_ticket_counters.last_ticket_id + 1 RETURNING last_ticket_id`, guildId).Scan(&ticketId); err != nil { return 0, err } query := ` -INSERT INTO tickets("id", "guild_id", "user_id", "open", "open_time", "is_thread", "panel_id", "status") +INSERT INTO tickets("id", "guild_id", "user_id", "open", "open_time", "is_thread", "panel_id", "status", "source") VALUES( - $1, $2, $3, true, NOW(), $4, $5, $6 + $1, $2, $3, true, NOW(), $4, $5, $6, $7 ) RETURNING "id";` - if err := tx.QueryRow(ctx, query, ticketId, guildId, userId, isThread, panelId, model.TicketStatusOpen).Scan(&id); err != nil { + if err := tx.QueryRow(ctx, query, ticketId, guildId, userId, isThread, panelId, model.TicketStatusOpen, source).Scan(&id); err != nil { return 0, err } @@ -262,12 +282,12 @@ SELECT tickets.id, tickets.status FROM tickets` - if o.Rating != 0 { - query += " INNER JOIN service_ratings ON tickets.guild_id = service_ratings.guild_id AND tickets.id = service_ratings.ticket_id " + if o.Rating != 0 || o.SortBy == SortByRating { + query += " LEFT JOIN service_ratings ON tickets.guild_id = service_ratings.guild_id AND tickets.id = service_ratings.ticket_id " } - if o.ClosedById != 0 || o.CloseReasonSearch != "" { - query += " INNER JOIN close_reason ON tickets.guild_id = close_reason.guild_id AND tickets.id = close_reason.ticket_id " + if o.ClosedById != 0 || o.CloseReasonSearch != "" || o.SortBy == SortByCloseReason { + query += " LEFT JOIN close_reason ON tickets.guild_id = close_reason.guild_id AND tickets.id = close_reason.ticket_id " } if o.ClaimedById != 0 { @@ -399,9 +419,17 @@ FROM tickets` needsAnd = true } - // Cannot use prepared statement for this value + // Cannot use prepared statements for these values; both are compared against constants first. if o.Order == OrderTypeAscending || o.Order == OrderTypeDescending { - query += fmt.Sprintf(` ORDER BY "id" %s `, o.Order) + // tickets.id breaks ties so paging stays stable across the many equal sort values. + switch o.SortBy { + case SortByRating: + query += fmt.Sprintf(` ORDER BY service_ratings.rating %s NULLS LAST, tickets.id DESC `, o.Order) + case SortByCloseReason: + query += fmt.Sprintf(` ORDER BY close_reason.close_reason %s NULLS LAST, tickets.id DESC `, o.Order) + default: + query += fmt.Sprintf(` ORDER BY tickets."id" %s `, o.Order) + } } if o.Limit != 0 { @@ -422,11 +450,11 @@ func (o TicketQueryOptions) BuildCountQuery() (query string, args []interface{}, query = "SELECT COUNT(*) FROM tickets" if o.Rating != 0 { - query += " INNER JOIN service_ratings ON tickets.guild_id = service_ratings.guild_id AND tickets.id = service_ratings.ticket_id " + query += " LEFT JOIN service_ratings ON tickets.guild_id = service_ratings.guild_id AND tickets.id = service_ratings.ticket_id " } if o.ClosedById != 0 || o.CloseReasonSearch != "" { - query += " INNER JOIN close_reason ON tickets.guild_id = close_reason.guild_id AND tickets.id = close_reason.ticket_id " + query += " LEFT JOIN close_reason ON tickets.guild_id = close_reason.guild_id AND tickets.id = close_reason.ticket_id " } if o.ClaimedById != 0 { @@ -1287,12 +1315,47 @@ func (t *TicketTable) GetTotalTicketCountInterval(ctx context.Context, guildId u return } -func (t *TicketTable) GetTotalTicketCount(ctx context.Context, guildId uint64) (count int, e error) { - query := `SELECT COUNT(*) FROM tickets WHERE "guild_id" = $1;` - if err := t.QueryRow(ctx, query, guildId).Scan(&count); err != nil { - e = err +// GetTotalTicketCount returns the all-time ticket count for a guild. Delegates +// to GetTotalTicketCountFiltered with no panel filter to preserve the existing +// signature for the worker module. +func (t *TicketTable) GetTotalTicketCount(ctx context.Context, guildId uint64) (int, error) { + return t.GetTotalTicketCountFiltered(ctx, guildId, nil) +} + +// GetTotalTicketCountFiltered returns the all-time ticket count, optionally +// restricted to the given panels. +func (t *TicketTable) GetTotalTicketCountFiltered(ctx context.Context, guildId uint64, filter *PanelFilter) (int, error) { + query := `SELECT COUNT(*) FROM tickets t WHERE t.guild_id = $1` + PanelPredicate("t", 2, 3) + + arr, unassigned, err := filter.Args() + if err != nil { + return 0, fmt.Errorf("total ticket count: %w", err) } - return + + var count int + if err := t.QueryRow(ctx, query, guildId, arr, unassigned).Scan(&count); err != nil { + return 0, err + } + return count, nil +} + +// CountOpenTickets returns the number of currently open tickets, optionally +// restricted to the given panels. This is a bespoke function rather than an +// extension of TicketQueryOptions, whose HasWhereClause() logic is inverted +// and carries its own panel fields. +func (t *TicketTable) CountOpenTickets(ctx context.Context, guildId uint64, filter *PanelFilter) (int, error) { + query := `SELECT COUNT(*) FROM tickets t WHERE t.guild_id = $1 AND t.open = true` + PanelPredicate("t", 2, 3) + + arr, unassigned, err := filter.Args() + if err != nil { + return 0, fmt.Errorf("count open tickets: %w", err) + } + + var count int + if err := t.QueryRow(ctx, query, guildId, arr, unassigned).Scan(&count); err != nil { + return 0, err + } + return count, nil } func (t *TicketTable) Close(ctx context.Context, ticketId int, guildId uint64) (err error) { @@ -1357,3 +1420,309 @@ SET last_ticket_id = EXCLUDED.last_ticket_id;` _, err = t.Exec(ctx, query, guildId) return } + +// GetTicketsPerDay is the worker-compatible wrapper. Delegates to +// GetTicketsPerDayFiltered with no panel filter. +func (t *TicketTable) GetTicketsPerDay(ctx context.Context, guildId uint64, nDays int) ([]CountOnDate, error) { + return t.GetTicketsPerDayFiltered(ctx, guildId, nDays, nil) +} + +// GetTicketsPerDayFiltered returns daily ticket counts over the last nDays. +// The panel predicate goes in the JOIN ON condition, NOT the WHERE, because +// this is a LEFT JOIN against generate_series. Putting it in the WHERE +// silently converts to an inner join and drops zero-count days. +func (t *TicketTable) GetTicketsPerDayFiltered(ctx context.Context, guildId uint64, nDays int, filter *PanelFilter) ([]CountOnDate, error) { + query := ` +SELECT d::date AS date, COUNT(t.id) AS count +FROM generate_series(CURRENT_DATE - ($2 - 1) * INTERVAL '1 day', CURRENT_DATE, '1 day') AS d +LEFT JOIN tickets t ON t.guild_id = $1 AND date_trunc('day', t.open_time AT TIME ZONE 'UTC') = d::date` + + PanelPredicate("t", 3, 4) + ` +GROUP BY d::date +ORDER BY d::date;` + + arr, unassigned, err := filter.Args() + if err != nil { + return nil, fmt.Errorf("tickets per day: %w", err) + } + + rows, err := t.Query(ctx, query, guildId, nDays, arr, unassigned) + if err != nil { + return nil, err + } + defer rows.Close() + + counts := make([]CountOnDate, 0, nDays) + for rows.Next() { + var c CountOnDate + if err := rows.Scan(&c.Date, &c.Count); err != nil { + return nil, err + } + counts = append(counts, c) + } + + return counts, nil +} + +func (t *TicketTable) GetTicketsPerWeek(ctx context.Context, guildId uint64, nDays int, filter *PanelFilter) ([]CountOnDate, error) { + query := ` +SELECT date_trunc('week', t.open_time AT TIME ZONE 'UTC')::date AS date, COUNT(*)::bigint AS count +FROM tickets t +WHERE t.guild_id = $1 AND ($2 = 0 OR t.open_time > CURRENT_DATE - $2 * INTERVAL '1 day')` + + PanelPredicate("t", 3, 4) + ` +GROUP BY 1 +ORDER BY 1;` + + arr, unassigned, err := filter.Args() + if err != nil { + return nil, fmt.Errorf("tickets per week: %w", err) + } + + rows, err := t.Query(ctx, query, guildId, nDays, arr, unassigned) + if err != nil { + return nil, err + } + defer rows.Close() + + var counts []CountOnDate + for rows.Next() { + var c CountOnDate + if err := rows.Scan(&c.Date, &c.Count); err != nil { + return nil, err + } + counts = append(counts, c) + } + + return counts, nil +} + +// GetTicketDurationTripleWindow is the worker-compatible wrapper. Delegates +// to GetDurationWindows with days=0 and no panel filter. +func (t *TicketTable) GetTicketDurationTripleWindow(ctx context.Context, guildId uint64) (TripleWindow, error) { + mw, err := t.GetDurationWindows(ctx, guildId, 0, nil) + if err != nil { + return TripleWindow{}, err + } + return mw.TripleWindow(), nil +} + +// GetDurationWindows returns four resolution-time averages in one round trip: +// the exact selected range, all time, monthly, and weekly. The selected window +// equals all-time when days == 0. +func (t *TicketTable) GetDurationWindows(ctx context.Context, guildId uint64, days int, filter *PanelFilter) (MetricWindows, error) { + query := ` +SELECT + AVG(EXTRACT(EPOCH FROM (t.close_time - t.open_time))) FILTER (WHERE t.close_time IS NOT NULL AND ($2 = 0 OR t.close_time > NOW() - make_interval(days => $2))), + AVG(EXTRACT(EPOCH FROM (t.close_time - t.open_time))) FILTER (WHERE t.close_time IS NOT NULL), + AVG(EXTRACT(EPOCH FROM (t.close_time - t.open_time))) FILTER (WHERE t.close_time IS NOT NULL AND t.close_time > NOW() - INTERVAL '30 days'), + AVG(EXTRACT(EPOCH FROM (t.close_time - t.open_time))) FILTER (WHERE t.close_time IS NOT NULL AND t.close_time > NOW() - INTERVAL '7 days') +FROM tickets t +WHERE t.guild_id = $1` + PanelPredicate("t", 3, 4) + + arr, unassigned, err := filter.Args() + if err != nil { + return MetricWindows{}, fmt.Errorf("duration windows: %w", err) + } + + var selectedSecs, allTimeSecs, monthlySecs, weeklySecs *float64 + if err := t.QueryRow(ctx, query, guildId, days, arr, unassigned).Scan( + &selectedSecs, &allTimeSecs, &monthlySecs, &weeklySecs, + ); err != nil { + return MetricWindows{}, err + } + + return MetricWindows{ + Selected: secondsToDuration(selectedSecs), + AllTime: secondsToDuration(allTimeSecs), + Monthly: secondsToDuration(monthlySecs), + Weekly: secondsToDuration(weeklySecs), + }, nil +} + +func secondsToDuration(secs *float64) *time.Duration { + if secs == nil { + return nil + } + d := time.Duration(*secs * float64(time.Second)) + return &d +} + +func (t *TicketTable) GetTicketCountByPanel(ctx context.Context, guildId uint64, nDays int, filter *PanelFilter) ([]PanelTicketCount, error) { + query := ` +SELECT t.panel_id, COALESCE(p.title, 'No Panel'), COUNT(*) +FROM tickets t +LEFT JOIN panels p ON t.panel_id = p.panel_id +WHERE t.guild_id = $1 AND t.open_time > CURRENT_DATE - ($2 - 1) * INTERVAL '1 day'` + + PanelPredicate("t", 3, 4) + ` +GROUP BY t.panel_id, p.title +ORDER BY COUNT(*) DESC;` + + arr, unassigned, err := filter.Args() + if err != nil { + return nil, fmt.Errorf("ticket count by panel: %w", err) + } + + rows, err := t.Query(ctx, query, guildId, nDays, arr, unassigned) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []PanelTicketCount + for rows.Next() { + var r PanelTicketCount + if err := rows.Scan(&r.PanelId, &r.PanelTitle, &r.Count); err != nil { + return nil, err + } + results = append(results, r) + } + + return results, nil +} + +// GetThreadChannelSplit is the worker-compatible wrapper. Delegates to +// GetThreadChannelSplitFiltered with no panel filter. +func (t *TicketTable) GetThreadChannelSplit(ctx context.Context, guildId uint64, nDays int) (ThreadChannelSplit, error) { + return t.GetThreadChannelSplitFiltered(ctx, guildId, nDays, nil) +} + +func (t *TicketTable) GetThreadChannelSplitFiltered(ctx context.Context, guildId uint64, nDays int, filter *PanelFilter) (ThreadChannelSplit, error) { + query := ` +SELECT + COUNT(*) FILTER (WHERE t.is_thread = true), + COUNT(*) FILTER (WHERE t.is_thread = false) +FROM tickets t +WHERE t.guild_id = $1 AND t.open_time > CURRENT_DATE - ($2 - 1) * INTERVAL '1 day'` + + PanelPredicate("t", 3, 4) + + arr, unassigned, err := filter.Args() + if err != nil { + return ThreadChannelSplit{}, fmt.Errorf("thread channel split: %w", err) + } + + var split ThreadChannelSplit + if err := t.QueryRow(ctx, query, guildId, nDays, arr, unassigned).Scan(&split.ThreadCount, &split.ChannelCount); err != nil { + return ThreadChannelSplit{}, err + } + + return split, nil +} + +func (t *TicketTable) GetPeakHours(ctx context.Context, guildId uint64, days int, filter *PanelFilter) ([]PeakHourEntry, error) { + query := ` +SELECT + EXTRACT(DOW FROM t.open_time AT TIME ZONE 'UTC')::int AS day_of_week, + EXTRACT(HOUR FROM t.open_time AT TIME ZONE 'UTC')::int AS hour_of_day, + COUNT(*)::int AS count +FROM tickets t +WHERE t.guild_id = $1 + AND ($2 = 0 OR t.open_time > NOW() - make_interval(days => $2))` + + PanelPredicate("t", 3, 4) + ` +GROUP BY day_of_week, hour_of_day +ORDER BY day_of_week, hour_of_day;` + + arr, unassigned, err := filter.Args() + if err != nil { + return nil, fmt.Errorf("peak hours: %w", err) + } + + rows, err := t.Query(ctx, query, guildId, days, arr, unassigned) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []PeakHourEntry + for rows.Next() { + var r PeakHourEntry + if err := rows.Scan(&r.DayOfWeek, &r.HourOfDay, &r.Count); err != nil { + return nil, err + } + results = append(results, r) + } + + return results, nil +} + +func (t *TicketTable) GetTicketsBySource(ctx context.Context, guildId uint64, days int, filter *PanelFilter) ([]SourceBreakdown, error) { + query := ` +SELECT t."source", COUNT(*)::int AS count +FROM tickets t +WHERE t.guild_id = $1 + AND ($2 = 0 OR t.open_time > NOW() - make_interval(days => $2))` + + PanelPredicate("t", 3, 4) + ` +GROUP BY t."source" +ORDER BY count DESC;` + + arr, unassigned, err := filter.Args() + if err != nil { + return nil, fmt.Errorf("tickets by source: %w", err) + } + + rows, err := t.Query(ctx, query, guildId, days, arr, unassigned) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []SourceBreakdown + for rows.Next() { + var r SourceBreakdown + if err := rows.Scan(&r.Source, &r.Count); err != nil { + return nil, err + } + results = append(results, r) + } + + return results, nil +} + +func (t *TicketTable) GetBacklogTrend(ctx context.Context, guildId uint64, nDays int, filter *PanelFilter) ([]CountOnDate, error) { + // The same two panel-filter placeholders ($3, $4) are referenced in all + // three CTEs. Args() is appended once; PostgreSQL handles reuse. + panelPred := PanelPredicate("tickets", 3, 4) + + query := ` +WITH opens AS ( + SELECT date_trunc('day', open_time AT TIME ZONE 'UTC')::date AS date, COUNT(*) AS cnt + FROM tickets WHERE guild_id = $1` + panelPred + ` GROUP BY 1 +), closes AS ( + SELECT date_trunc('day', close_time AT TIME ZONE 'UTC')::date AS date, COUNT(*) AS cnt + FROM tickets WHERE guild_id = $1 AND close_time IS NOT NULL` + panelPred + ` GROUP BY 1 +), series AS ( + SELECT d::date AS date FROM generate_series(CURRENT_DATE - ($2 - 1) * INTERVAL '1 day', CURRENT_DATE, '1 day') d +), base_open AS ( + SELECT COUNT(*) AS cnt FROM tickets WHERE guild_id = $1 + AND open_time < CURRENT_DATE - ($2 - 1) * INTERVAL '1 day' + AND (close_time IS NULL OR close_time >= CURRENT_DATE - ($2 - 1) * INTERVAL '1 day')` + panelPred + ` +) +SELECT s.date, + (SELECT cnt FROM base_open) + + SUM(COALESCE(o.cnt, 0)) OVER (ORDER BY s.date) + - SUM(COALESCE(c.cnt, 0)) OVER (ORDER BY s.date) AS count +FROM series s +LEFT JOIN opens o ON o.date = s.date +LEFT JOIN closes c ON c.date = s.date +ORDER BY s.date;` + + arr, unassigned, err := filter.Args() + if err != nil { + return nil, fmt.Errorf("backlog trend: %w", err) + } + + rows, err := t.Query(ctx, query, guildId, nDays, arr, unassigned) + if err != nil { + return nil, err + } + defer rows.Close() + + counts := make([]CountOnDate, 0, nDays) + for rows.Next() { + var c CountOnDate + if err := rows.Scan(&c.Date, &c.Count); err != nil { + return nil, err + } + counts = append(counts, c) + } + + return counts, nil +} diff --git a/ticketsource.go b/ticketsource.go new file mode 100644 index 0000000..bafb431 --- /dev/null +++ b/ticketsource.go @@ -0,0 +1,20 @@ +package database + +type TicketSource int16 + +const ( + TicketSourceUnknown TicketSource = 0 + TicketSourcePanel TicketSource = 1 + TicketSourceCommand TicketSource = 2 +) + +func (s TicketSource) String() string { + switch s { + case TicketSourcePanel: + return "panel" + case TicketSourceCommand: + return "command" + default: + return "unknown" + } +} diff --git a/usercanclose.go b/usercanclose.go deleted file mode 100644 index e3b843b..0000000 --- a/usercanclose.go +++ /dev/null @@ -1,43 +0,0 @@ -package database - -import ( - "context" - "github.com/jackc/pgx/v4" - "github.com/jackc/pgx/v4/pgxpool" -) - -type UsersCanClose struct { - *pgxpool.Pool -} - -func newUsersCanClose(db *pgxpool.Pool) *UsersCanClose { - return &UsersCanClose{ - db, - } -} - -func (u UsersCanClose) Schema() string { - return ` -CREATE TABLE IF NOT EXISTS users_can_close( - "guild_id" int8 NOT NULL UNIQUE, - "users_can_close" bool NOT NULL, - PRIMARY KEY("guild_id") -);` -} - -func (u *UsersCanClose) Get(ctx context.Context, guildId uint64) (usersCanClose bool, e error) { - if err := u.QueryRow(ctx, `SELECT "users_can_close" from users_can_close WHERE "guild_id" = $1;`, guildId).Scan(&usersCanClose); err != nil { - if err == pgx.ErrNoRows { - usersCanClose = true - } else { - e = err - } - } - - return -} - -func (u *UsersCanClose) Set(ctx context.Context, guildId uint64, usersCanClose bool) (err error) { - _, err = u.Exec(ctx, `INSERT INTO users_can_close("guild_id", "users_can_close") VALUES($1, $2) ON CONFLICT("guild_id") DO UPDATE SET "users_can_close" = $2;`, guildId, usersCanClose) - return -} diff --git a/useremails.go b/useremails.go new file mode 100644 index 0000000..0165248 --- /dev/null +++ b/useremails.go @@ -0,0 +1,76 @@ +package database + +import ( + "context" + _ "embed" + "errors" + "time" + + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" +) + +type UserEmails struct { + *pgxpool.Pool +} + +type UserEmail struct { + UserId uint64 `json:"user_id"` + Email string `json:"email"` + Verified bool `json:"verified"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +var ( + //go:embed sql/user_emails/schema.sql + userEmailsSchema string + + //go:embed sql/user_emails/upsert.sql + userEmailsUpsert string + + //go:embed sql/user_emails/get_by_user_id.sql + userEmailsGetByUserId string + + //go:embed sql/user_emails/set_verified.sql + userEmailsSetVerified string + + //go:embed sql/user_emails/delete.sql + userEmailsDelete string +) + +func newUserEmails(db *pgxpool.Pool) *UserEmails { + return &UserEmails{db} +} + +func (UserEmails) Schema() string { + return userEmailsSchema +} + +func (t *UserEmails) Upsert(ctx context.Context, userId uint64, email string) error { + _, err := t.Exec(ctx, userEmailsUpsert, userId, email) + return err +} + +func (t *UserEmails) GetByUserId(ctx context.Context, userId uint64) (*UserEmail, error) { + var ue UserEmail + if err := t.QueryRow(ctx, userEmailsGetByUserId, userId).Scan( + &ue.UserId, &ue.Email, &ue.Verified, &ue.CreatedAt, &ue.UpdatedAt, + ); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &ue, nil +} + +func (t *UserEmails) SetVerified(ctx context.Context, userId uint64, verified bool) error { + _, err := t.Exec(ctx, userEmailsSetVerified, userId, verified) + return err +} + +func (t *UserEmails) Delete(ctx context.Context, userId uint64) error { + _, err := t.Exec(ctx, userEmailsDelete, userId) + return err +} diff --git a/view.go b/view.go deleted file mode 100644 index 504dab8..0000000 --- a/view.go +++ /dev/null @@ -1,7 +0,0 @@ -package database - -import "context" - -type View interface { - Refresh(ctx context.Context) error -} diff --git a/welcomemessage.go b/welcomemessage.go deleted file mode 100644 index ce308ef..0000000 --- a/welcomemessage.go +++ /dev/null @@ -1,42 +0,0 @@ -package database - -import ( - "context" - "github.com/jackc/pgx/v4" - "github.com/jackc/pgx/v4/pgxpool" -) - -type WelcomeMessages struct { - *pgxpool.Pool -} - -func newWelcomeMessages(db *pgxpool.Pool) *WelcomeMessages { - return &WelcomeMessages{ - db, - } -} - -func (w WelcomeMessages) Schema() string { - return ` -CREATE TABLE IF NOT EXISTS welcome_messages( - "guild_id" int8 NOT NULL UNIQUE, - "welcome_message" text NOT NULL, - PRIMARY KEY("guild_id") -);` -} - -func (w *WelcomeMessages) Get(ctx context.Context, guildId uint64) (welcomeMessage string, e error) { - query := `SELECT "welcome_message" from welcome_messages WHERE "guild_id" = $1;` - - if err := w.QueryRow(ctx, query, guildId).Scan(&welcomeMessage); err != nil && err != pgx.ErrNoRows { - e = err - } - - return -} - -func (w *WelcomeMessages) Set(ctx context.Context, guildId uint64, welcomeMessage string) (err error) { - query := `INSERT INTO welcome_messages("guild_id", "welcome_message") VALUES($1, $2) ON CONFLICT("guild_id") DO UPDATE SET "welcome_message" = $2;` - _, err = w.Exec(ctx, query, guildId, welcomeMessage) - return -} diff --git a/whitelabel.go b/whitelabel.go index f266a40..ac9ca32 100644 --- a/whitelabel.go +++ b/whitelabel.go @@ -88,3 +88,45 @@ func (w *WhitelabelBotTable) DeleteByToken(ctx context.Context, token string) er _, err := w.Exec(ctx, query, token) return err } + +func (w *WhitelabelBotTable) GetAll(ctx context.Context) ([]WhitelabelBot, error) { + query := `SELECT "user_id", "bot_id", "public_key", "token" FROM whitelabel;` + + rows, err := w.Query(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + var bots []WhitelabelBot + for rows.Next() { + var bot WhitelabelBot + if err := rows.Scan(&bot.UserId, &bot.BotId, &bot.PublicKey, &bot.Token); err != nil { + return nil, err + } + bots = append(bots, bot) + } + + return bots, nil +} + +func (w *WhitelabelBotTable) GetBotsBySharder(ctx context.Context, sharderTotal, sharderID int) ([]WhitelabelBot, error) { + query := `SELECT "user_id", "bot_id", "public_key", "token" FROM whitelabel WHERE "bot_id" % $1 = $2;` + + rows, err := w.Query(ctx, query, sharderTotal, sharderID) + if err != nil { + return nil, err + } + defer rows.Close() + + var bots []WhitelabelBot + for rows.Next() { + var bot WhitelabelBot + if err := rows.Scan(&bot.UserId, &bot.BotId, &bot.PublicKey, &bot.Token); err != nil { + return nil, err + } + bots = append(bots, bot) + } + + return bots, nil +}