Initial commit
Build & Push / build (push) Canceled after 0s

This commit is contained in:
2026-08-05 19:31:35 +03:00
commit a33fa24e99
272 changed files with 40566 additions and 0 deletions
@@ -0,0 +1,45 @@
package database
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgtype"
"github.com/TelegramExchange/pkg/transaction"
"github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
)
func (d *Database) CreatePost(ctx context.Context, post domain.Post) error {
query := `INSERT INTO post (id, channel_id, message_id, text, published_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (channel_id, message_id) DO UPDATE
SET text = EXCLUDED.text,
published_at = COALESCE(post.published_at, EXCLUDED.published_at),
updated_at = CURRENT_TIMESTAMP;`
txOrPool := transaction.TryExtractTX(ctx)
dto := createPostDTO{
ID: pgtype.UUID{Bytes: post.ID, Valid: true},
ChannelID: pgtype.UUID{Bytes: post.ChannelID, Valid: true},
MessageID: pgtype.Int4{Int32: int32(post.MessageID), Valid: true},
Text: pgtype.Text{String: post.Text, Valid: true},
PublishedAt: pgtype.Timestamptz{Time: post.PublishedAt, Valid: !post.PublishedAt.IsZero()},
}
_, err := txOrPool.Exec(ctx, query, dto.ID, dto.ChannelID, dto.MessageID, dto.Text, dto.PublishedAt)
if err != nil {
return fmt.Errorf("txOrPool.Exec: %w", err)
}
return nil
}
type createPostDTO struct {
ID pgtype.UUID
ChannelID pgtype.UUID
MessageID pgtype.Int4
Text pgtype.Text
PublishedAt pgtype.Timestamptz
}
@@ -0,0 +1,40 @@
package database
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/TelegramExchange/pkg/transaction"
"github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
)
func (d *Database) CreateViewsSnapshot(ctx context.Context, snapshot domain.ViewsSnapshot) error {
query := `INSERT INTO post_views_history (id, views_count, fetched_at, post_id)
VALUES ($1, $2, $3, $4);`
txOrPool := transaction.TryExtractTX(ctx)
dto := createViewsSnapshotDTO{
ID: pgtype.UUID{Bytes: uuid.New(), Valid: true},
ViewsCount: pgtype.Int4{Int32: int32(snapshot.ViewsCount), Valid: true},
FetchedAt: pgtype.Timestamptz{Time: snapshot.FetchedAt, Valid: true},
PostID: pgtype.UUID{Bytes: snapshot.PostID, Valid: true},
}
_, err := txOrPool.Exec(ctx, query, dto.ID, dto.ViewsCount, dto.FetchedAt, dto.PostID)
if err != nil {
return fmt.Errorf("txOrPool.Exec: %w", err)
}
return nil
}
type createViewsSnapshotDTO struct {
ID pgtype.UUID
ViewsCount pgtype.Int4
FetchedAt pgtype.Timestamptz
PostID pgtype.UUID
}
@@ -0,0 +1,7 @@
package database
type Database struct{}
func New() *Database {
return &Database{}
}
@@ -0,0 +1,37 @@
package database
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgtype"
"github.com/TelegramExchange/pkg/transaction"
"github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
)
func (d *Database) DeletePost(ctx context.Context, p domain.Post) error {
query := `UPDATE post
SET deleted_from_channel_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE channel_id = $1 AND message_id = $2;`
txOrPool := transaction.TryExtractTX(ctx)
dto := deletePostDTO{
ChannelID: pgtype.UUID{Bytes: p.ChannelID, Valid: true},
MessageID: pgtype.Int4{Int32: int32(p.MessageID), Valid: true},
}
_, err := txOrPool.Exec(ctx, query, dto.ChannelID, dto.MessageID)
if err != nil {
return fmt.Errorf("txOrPool.Exec: %w", err)
}
return nil
}
type deletePostDTO struct {
ChannelID pgtype.UUID
MessageID pgtype.Int4
}
@@ -0,0 +1,88 @@
package database
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
"github.com/rs/zerolog/log"
"github.com/TelegramExchange/pkg/transaction"
"github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
)
func (d *Database) GetChannels(ctx context.Context) []domain.Channel {
query := `SELECT
id,
telegram_id,
username,
title,
access_hash,
pts,
invite_link,
is_accessible
FROM channel
WHERE deleted_at IS NULL
AND is_accessible = true;`
txOrPool := transaction.TryExtractTX(ctx)
rows, err := txOrPool.Query(ctx, query)
if err != nil {
log.Error().Err(err).Msg("txOrPool.Query failed")
return []domain.Channel{}
}
defer rows.Close()
channels := make([]domain.Channel, 0)
for rows.Next() {
var dto getChannelsDTO
err = rows.Scan(dto.destination()...)
if err != nil {
log.Error().Err(err).Msg("rows.Scan failed")
return []domain.Channel{}
}
channels = append(channels, dto.toDomain())
}
return channels
}
type getChannelsDTO struct {
ID pgtype.UUID
TelegramID pgtype.Int8
Username pgtype.Text
Title pgtype.Text
AccessHash pgtype.Int8
Pts pgtype.Int4
InviteLink pgtype.Text
IsAccessible pgtype.Bool
}
func (dto *getChannelsDTO) destination() []any {
return []any{
&dto.ID,
&dto.TelegramID,
&dto.Username,
&dto.Title,
&dto.AccessHash,
&dto.Pts,
&dto.InviteLink,
&dto.IsAccessible,
}
}
func (dto *getChannelsDTO) toDomain() domain.Channel {
return domain.Channel{
ID: dto.ID.Bytes,
TelegramID: domain.NormalizeChatID(dto.TelegramID.Int64),
Username: dto.Username.String,
Title: dto.Title.String,
AccessHash: dto.AccessHash.Int64,
Pts: int(dto.Pts.Int32),
InviteLink: dto.InviteLink.String,
IsAccessible: dto.IsAccessible.Bool,
}
}
@@ -0,0 +1,89 @@
package database
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgtype"
"github.com/TelegramExchange/pkg/transaction"
"github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
)
func (d *Database) GetChannelsWithTrackedPosts(ctx context.Context) ([]domain.Channel, error) {
query := `SELECT DISTINCT
c.id,
c.telegram_id,
c.username,
c.title,
c.access_hash,
c.pts,
c.invite_link,
c.is_accessible
FROM channel c
INNER JOIN placement p ON p.channel_id = c.id
INNER JOIN placement_post pp ON pp.placement_id = p.id
INNER JOIN post po ON po.id = pp.post_id
WHERE po.deleted_from_channel_at IS NULL
AND c.is_accessible = true;`
txOrPool := transaction.TryExtractTX(ctx)
rows, err := txOrPool.Query(ctx, query)
if err != nil {
return nil, fmt.Errorf("txOrPool.Query: %w", err)
}
defer rows.Close()
channels := make([]domain.Channel, 0)
for rows.Next() {
var dto getChannelsWithTrackedPostsDTO
err = rows.Scan(dto.destination()...)
if err != nil {
return nil, fmt.Errorf("rows.Scan: %w", err)
}
channels = append(channels, dto.toDomain())
}
return channels, nil
}
type getChannelsWithTrackedPostsDTO struct {
ID pgtype.UUID
TelegramID pgtype.Int8
Username pgtype.Text
Title pgtype.Text
AccessHash pgtype.Int8
Pts pgtype.Int4
InviteLink pgtype.Text
IsAccessible pgtype.Bool
}
func (dto *getChannelsWithTrackedPostsDTO) destination() []any {
return []any{
&dto.ID,
&dto.TelegramID,
&dto.Username,
&dto.Title,
&dto.AccessHash,
&dto.Pts,
&dto.InviteLink,
&dto.IsAccessible,
}
}
func (dto *getChannelsWithTrackedPostsDTO) toDomain() domain.Channel {
return domain.Channel{
ID: dto.ID.Bytes,
TelegramID: domain.NormalizeChatID(dto.TelegramID.Int64),
Username: dto.Username.String,
Title: dto.Title.String,
AccessHash: dto.AccessHash.Int64,
Pts: int(dto.Pts.Int32),
InviteLink: dto.InviteLink.String,
IsAccessible: dto.IsAccessible.Bool,
}
}
@@ -0,0 +1,91 @@
package database
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgtype"
"github.com/TelegramExchange/pkg/transaction"
"github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
)
func (d *Database) GetTrackedPosts(ctx context.Context, channel domain.Channel) ([]domain.Post, error) {
query := `SELECT DISTINCT
p.id,
p.channel_id,
p.message_id,
p.text,
CASE
WHEN c.username IS NOT NULL THEN CONCAT('https://t.me/', c.username, '/', p.message_id)
WHEN c.telegram_id IS NOT NULL THEN CONCAT('https://t.me/c/', (-c.telegram_id - 1000000000000), '/', p.message_id)
ELSE ''
END as link,
COALESCE(
(SELECT pvh.views_count
FROM post_views_history pvh
WHERE pvh.post_id = p.id
ORDER BY pvh.fetched_at DESC
LIMIT 1),
0
) as views
FROM post p
INNER JOIN channel c ON c.id = p.channel_id
INNER JOIN placement_post pp ON pp.post_id = p.id
WHERE p.channel_id = $1
AND p.deleted_from_channel_at IS NULL;`
txOrPool := transaction.TryExtractTX(ctx)
rows, err := txOrPool.Query(ctx, query, pgtype.UUID{Bytes: channel.ID, Valid: true})
if err != nil {
return nil, fmt.Errorf("txOrPool.Query: %w", err)
}
defer rows.Close()
posts := make([]domain.Post, 0)
for rows.Next() {
var dto getTrackedPostsDTO
err = rows.Scan(dto.destination()...)
if err != nil {
return nil, fmt.Errorf("rows.Scan: %w", err)
}
posts = append(posts, dto.toDomain())
}
return posts, nil
}
type getTrackedPostsDTO struct {
ID pgtype.UUID
ChannelID pgtype.UUID
MessageID pgtype.Int4
Text pgtype.Text
Link pgtype.Text
Views pgtype.Int4
}
func (dto *getTrackedPostsDTO) destination() []any {
return []any{
&dto.ID,
&dto.ChannelID,
&dto.MessageID,
&dto.Text,
&dto.Link,
&dto.Views,
}
}
func (dto *getTrackedPostsDTO) toDomain() domain.Post {
return domain.Post{
ID: dto.ID.Bytes,
ChannelID: dto.ChannelID.Bytes,
MessageID: int(dto.MessageID.Int32),
Text: dto.Text.String,
Link: dto.Link.String,
Views: int(dto.Views.Int32),
}
}
@@ -0,0 +1,124 @@
package database
import (
"context"
"fmt"
"strings"
"github.com/TelegramExchange/pkg/transaction"
"github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
"github.com/jackc/pgx/v5/pgtype"
)
func (d *Database) UpdateChannelIfNotAccessible(ctx context.Context, channel domain.Channel) error {
query := `UPDATE channel
SET telegram_id = $2,
username = $3,
title = $4,
access_hash = $5,
pts = 0,
is_accessible = TRUE,
invite_link = $6,
updated_at = CURRENT_TIMESTAMP
WHERE telegram_id = $1
AND is_accessible = FALSE;`
txOrPool := transaction.TryExtractTX(ctx)
username := strings.TrimSpace(channel.Username)
var usernameDTO pgtype.Text
if username != "" {
usernameDTO = pgtype.Text{String: username, Valid: true}
} else {
usernameDTO = pgtype.Text{Valid: false}
}
inviteLink := strings.TrimSpace(channel.InviteLink)
var inviteLinkDTO pgtype.Text
if inviteLink != "" {
inviteLinkDTO = pgtype.Text{String: inviteLink, Valid: true}
} else {
inviteLinkDTO = pgtype.Text{Valid: false}
}
result, err := txOrPool.Exec(ctx, query,
channel.TelegramID,
pgtype.Int8{Int64: channel.TelegramID, Valid: true},
usernameDTO,
pgtype.Text{String: channel.Title, Valid: true},
pgtype.Int8{Int64: channel.AccessHash, Valid: true},
inviteLinkDTO,
)
if err != nil {
return fmt.Errorf("txOrPool.Exec: %w", err)
}
// Log if no rows were updated (channel either doesn't exist or is already accessible)
rowsAffected := result.RowsAffected()
if rowsAffected == 0 {
// Channel doesn't exist or is already accessible - this is fine
return nil
}
return nil
}
func (d *Database) UpdateChannel(ctx context.Context, channel domain.Channel) error {
query := `UPDATE channel
SET telegram_id = $2,
username = $3,
title = $4,
access_hash = $5,
pts = $6,
is_accessible = $7,
invite_link = $8,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1;`
txOrPool := transaction.TryExtractTX(ctx)
username := strings.TrimSpace(channel.Username)
var usernameDTO pgtype.Text
if username != "" {
usernameDTO = pgtype.Text{String: username, Valid: true}
} else {
usernameDTO = pgtype.Text{Valid: false}
}
inviteLink := strings.TrimSpace(channel.InviteLink)
var inviteLinkDTO pgtype.Text
if inviteLink != "" {
inviteLinkDTO = pgtype.Text{String: inviteLink, Valid: true}
} else {
inviteLinkDTO = pgtype.Text{Valid: false}
}
dto := updateChannelDTO{
ID: pgtype.UUID{Bytes: channel.ID, Valid: true},
TelegramID: pgtype.Int8{Int64: channel.TelegramID, Valid: true},
Username: usernameDTO,
Title: pgtype.Text{String: channel.Title, Valid: true},
AccessHash: pgtype.Int8{Int64: channel.AccessHash, Valid: true},
Pts: pgtype.Int4{Int32: int32(channel.Pts), Valid: true},
IsAccessible: pgtype.Bool{Bool: channel.IsAccessible, Valid: true},
InviteLink: inviteLinkDTO,
}
_, err := txOrPool.Exec(ctx, query, dto.ID, dto.TelegramID, dto.Username, dto.Title, dto.AccessHash, dto.Pts, dto.IsAccessible, dto.InviteLink)
if err != nil {
return fmt.Errorf("txOrPool.Exec: %w", err)
}
return nil
}
type updateChannelDTO struct {
ID pgtype.UUID
TelegramID pgtype.Int8
Username pgtype.Text
Title pgtype.Text
AccessHash pgtype.Int8
Pts pgtype.Int4
IsAccessible pgtype.Bool
InviteLink pgtype.Text
}