package repository import ( "context" "encoding/json" "errors" "fmt" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "github.com/geo-platform/tenant-api/internal/ops/domain" ) var ErrDesktopBugReportNotFound = errors.New("desktop bug report not found") type DesktopBugReportRepository struct { pool *pgxpool.Pool } func NewDesktopBugReportRepository(pool *pgxpool.Pool) *DesktopBugReportRepository { return &DesktopBugReportRepository{pool: pool} } type CreateDesktopBugReportInput struct { ID string ReportType string Status string Severity string TenantID *int64 WorkspaceID *int64 UserID *int64 DesktopClientID *string CrashGUID *string CrashReportID *string ProcessType *string ElectronVersion *string AppVersion *string ProductName *string Platform *string OSArch *string DeviceName *string Title string Description *string DumpObjectKey *string DumpFileName *string DumpSizeBytes *int64 DumpSHA256 *string FormFields []byte RuntimeSnapshot []byte AppLogTail *string UploaderIP *string UserAgent *string OccurredAt time.Time } type DesktopBugReportFilter struct { Keyword string Status string Severity string ReportType string Platform string Process string TenantID *int64 ClientID string Limit int Offset int } type UpdateDesktopBugReportStateInput struct { Status string Severity string ResolutionNote *string } const desktopBugReportSelectColumns = ` br.id::text, br.report_type, br.status, br.severity, br.tenant_id, t.name, br.workspace_id, w.name, br.user_id, u.name, u.phone, u.email, br.desktop_client_id::text, br.crash_guid, br.crash_report_id, br.process_type, br.electron_version, br.app_version, br.product_name, br.platform, br.os_arch, br.device_name, br.title, br.description, br.dump_object_key, br.dump_file_name, br.dump_size_bytes, br.dump_sha256, br.form_fields, br.runtime_snapshot, br.app_log_tail, br.resolution_note, br.uploader_ip, br.user_agent, br.occurred_at, br.resolved_at, br.created_at, br.updated_at` const desktopBugReportFromClause = ` FROM desktop_bug_reports br LEFT JOIN tenants t ON t.id = br.tenant_id LEFT JOIN workspaces w ON w.id = br.workspace_id LEFT JOIN users u ON u.id = br.user_id` func (r *DesktopBugReportRepository) Create(ctx context.Context, in CreateDesktopBugReportInput) (*domain.DesktopBugReport, error) { var id string if err := r.pool.QueryRow(ctx, ` INSERT INTO desktop_bug_reports ( id, report_type, status, severity, tenant_id, workspace_id, user_id, desktop_client_id, crash_guid, crash_report_id, process_type, electron_version, app_version, product_name, platform, os_arch, device_name, title, description, dump_object_key, dump_file_name, dump_size_bytes, dump_sha256, form_fields, runtime_snapshot, app_log_tail, uploader_ip, user_agent, occurred_at ) VALUES ( $1::uuid, $2, $3, $4, $5, $6, $7, $8::uuid, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24::jsonb, $25::jsonb, $26, $27, $28, $29 ) RETURNING id::text`, in.ID, in.ReportType, in.Status, in.Severity, in.TenantID, in.WorkspaceID, in.UserID, in.DesktopClientID, in.CrashGUID, in.CrashReportID, in.ProcessType, in.ElectronVersion, in.AppVersion, in.ProductName, in.Platform, in.OSArch, in.DeviceName, in.Title, in.Description, in.DumpObjectKey, in.DumpFileName, in.DumpSizeBytes, in.DumpSHA256, string(defaultJSON(in.FormFields)), string(defaultJSON(in.RuntimeSnapshot)), in.AppLogTail, in.UploaderIP, in.UserAgent, in.OccurredAt).Scan(&id); err != nil { return nil, err } return r.GetByID(ctx, id) } func (r *DesktopBugReportRepository) List(ctx context.Context, f DesktopBugReportFilter) ([]domain.DesktopBugReport, int64, error) { args := []any{} where := "1=1" if f.Keyword != "" { args = append(args, "%"+f.Keyword+"%") where += fmt.Sprintf(` AND ( br.id::text ILIKE $%d OR COALESCE(br.title, '') ILIKE $%d OR COALESCE(br.description, '') ILIKE $%d OR COALESCE(br.crash_guid, '') ILIKE $%d OR COALESCE(br.crash_report_id, '') ILIKE $%d OR COALESCE(br.dump_object_key, '') ILIKE $%d OR COALESCE(br.device_name, '') ILIKE $%d OR COALESCE(t.name, '') ILIKE $%d OR COALESCE(w.name, '') ILIKE $%d OR COALESCE(u.name, '') ILIKE $%d OR COALESCE(u.phone, '') ILIKE $%d OR COALESCE(u.email, '') ILIKE $%d )`, len(args), len(args), len(args), len(args), len(args), len(args), len(args), len(args), len(args), len(args), len(args), len(args)) } if f.Status != "" { args = append(args, f.Status) where += fmt.Sprintf(" AND br.status = $%d", len(args)) } if f.Severity != "" { args = append(args, f.Severity) where += fmt.Sprintf(" AND br.severity = $%d", len(args)) } if f.ReportType != "" { args = append(args, f.ReportType) where += fmt.Sprintf(" AND br.report_type = $%d", len(args)) } if f.Platform != "" { args = append(args, f.Platform) where += fmt.Sprintf(" AND br.platform = $%d", len(args)) } if f.Process != "" { args = append(args, f.Process) where += fmt.Sprintf(" AND br.process_type = $%d", len(args)) } if f.TenantID != nil { args = append(args, *f.TenantID) where += fmt.Sprintf(" AND br.tenant_id = $%d", len(args)) } if f.ClientID != "" { args = append(args, f.ClientID) where += fmt.Sprintf(" AND br.desktop_client_id = $%d::uuid", len(args)) } var total int64 if err := r.pool.QueryRow(ctx, "SELECT COUNT(*) "+desktopBugReportFromClause+" WHERE "+where, args...).Scan(&total); err != nil { return nil, 0, err } limit := f.Limit if limit <= 0 || limit > 200 { limit = 20 } offset := f.Offset if offset < 0 { offset = 0 } args = append(args, limit, offset) limitArg := len(args) - 1 offsetArg := len(args) rows, err := r.pool.Query(ctx, fmt.Sprintf(` SELECT %s %s WHERE %s ORDER BY br.created_at DESC, br.id DESC LIMIT $%d OFFSET $%d`, desktopBugReportSelectColumns, desktopBugReportFromClause, where, limitArg, offsetArg), args...) if err != nil { return nil, 0, err } defer rows.Close() items := make([]domain.DesktopBugReport, 0, limit) for rows.Next() { item, scanErr := scanDesktopBugReport(rows) if scanErr != nil { return nil, 0, scanErr } items = append(items, *item) } return items, total, rows.Err() } func (r *DesktopBugReportRepository) GetByID(ctx context.Context, id string) (*domain.DesktopBugReport, error) { return scanDesktopBugReport(r.pool.QueryRow(ctx, ` SELECT `+desktopBugReportSelectColumns+` `+desktopBugReportFromClause+` WHERE br.id = $1::uuid`, id)) } func (r *DesktopBugReportRepository) UpdateState(ctx context.Context, id string, in UpdateDesktopBugReportStateInput) (*domain.DesktopBugReport, error) { var updatedID string if err := r.pool.QueryRow(ctx, ` UPDATE desktop_bug_reports br SET status = $2, severity = $3, resolution_note = $4, resolved_at = CASE WHEN $2 IN ('resolved', 'ignored') AND resolved_at IS NULL THEN NOW() WHEN $2 NOT IN ('resolved', 'ignored') THEN NULL ELSE resolved_at END, updated_at = NOW() WHERE br.id = $1::uuid RETURNING br.id::text`, id, in.Status, in.Severity, in.ResolutionNote).Scan(&updatedID); err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrDesktopBugReportNotFound } return nil, err } return r.GetByID(ctx, updatedID) } func scanDesktopBugReport(row pgx.Row) (*domain.DesktopBugReport, error) { var item domain.DesktopBugReport var tenantID, workspaceID, userID, dumpSize pgtype.Int8 var tenantName, workspaceName, userName, userPhone, userEmail pgtype.Text var desktopClientID, crashGUID, crashReportID, processType, electronVersion, appVersion pgtype.Text var productName, platform, osArch, deviceName, description, dumpObjectKey, dumpFileName pgtype.Text var dumpSHA256, appLogTail, resolutionNote, uploaderIP, userAgent pgtype.Text var formFields, runtimeSnapshot []byte var resolvedAt pgtype.Timestamptz err := row.Scan( &item.ID, &item.ReportType, &item.Status, &item.Severity, &tenantID, &tenantName, &workspaceID, &workspaceName, &userID, &userName, &userPhone, &userEmail, &desktopClientID, &crashGUID, &crashReportID, &processType, &electronVersion, &appVersion, &productName, &platform, &osArch, &deviceName, &item.Title, &description, &dumpObjectKey, &dumpFileName, &dumpSize, &dumpSHA256, &formFields, &runtimeSnapshot, &appLogTail, &resolutionNote, &uploaderIP, &userAgent, &item.OccurredAt, &resolvedAt, &item.CreatedAt, &item.UpdatedAt, ) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrDesktopBugReportNotFound } return nil, err } item.TenantID = int64PtrFromPg(tenantID) item.TenantName = stringPtrFromPg(tenantName) item.WorkspaceID = int64PtrFromPg(workspaceID) item.WorkspaceName = stringPtrFromPg(workspaceName) item.UserID = int64PtrFromPg(userID) item.UserName = stringPtrFromPg(userName) item.UserPhone = stringPtrFromPg(userPhone) item.UserEmail = stringPtrFromPg(userEmail) item.DesktopClientID = stringPtrFromPg(desktopClientID) item.CrashGUID = stringPtrFromPg(crashGUID) item.CrashReportID = stringPtrFromPg(crashReportID) item.ProcessType = stringPtrFromPg(processType) item.ElectronVersion = stringPtrFromPg(electronVersion) item.AppVersion = stringPtrFromPg(appVersion) item.ProductName = stringPtrFromPg(productName) item.Platform = stringPtrFromPg(platform) item.OSArch = stringPtrFromPg(osArch) item.DeviceName = stringPtrFromPg(deviceName) item.Description = stringPtrFromPg(description) item.DumpObjectKey = stringPtrFromPg(dumpObjectKey) item.DumpFileName = stringPtrFromPg(dumpFileName) item.DumpSizeBytes = int64PtrFromPg(dumpSize) item.DumpSHA256 = stringPtrFromPg(dumpSHA256) item.FormFields = json.RawMessage(defaultJSON(formFields)) item.RuntimeSnapshot = json.RawMessage(defaultJSON(runtimeSnapshot)) item.AppLogTail = stringPtrFromPg(appLogTail) item.ResolutionNote = stringPtrFromPg(resolutionNote) item.UploaderIP = stringPtrFromPg(uploaderIP) item.UserAgent = stringPtrFromPg(userAgent) item.ResolvedAt = timePtrFromPg(resolvedAt) return &item, nil } func defaultJSON(value []byte) []byte { if len(value) == 0 || !json.Valid(value) { return []byte("{}") } return value }