Files
pargar/backend/internal/store/chat_export.go
T

244 lines
7.3 KiB
Go
Raw Normal View History

package store
import (
"context"
"errors"
"strings"
"time"
"unicode/utf8"
"github.com/jackc/pgx/v5"
"pargar/backend/internal/models"
)
var (
ErrChatExportTokenNotFound = errors.New("chat export token not found")
ErrChatExportTokenRevoked = errors.New("chat export token revoked")
ErrChatExportTokenExpired = errors.New("chat export token expired")
ErrChatExportTokenUsed = errors.New("chat export token already used")
ErrChatExportTokenInvalid = errors.New("chat export token invalid")
)
const chatExportTokenCols = `id, token_hash, token_enc, label, expires_at, revoked_at, used_at,
created_by, created_at, last_used_at`
// ChatExportPair is one student↔mentor conversation found in chat_messages.
type ChatExportPair struct {
StudentID int64
StudentName string
MentorID int64
MentorName string
}
// ChatExportStats is an approximate size summary for the full chat archive.
type ChatExportStats struct {
ConversationCount int64 `json:"conversationCount"`
MessageCount int64 `json:"messageCount"`
AttachmentBytes int64 `json:"attachmentBytes"`
BodyBytes int64 `json:"bodyBytes"`
// EstimatedBytes is a rough upper-bound for the ZIP (attachments + compressed text overhead).
EstimatedBytes int64 `json:"estimatedBytes"`
}
// ChatExportStats returns approximate totals for the project-wide chat archive.
func (s *Store) ChatExportStats(ctx context.Context) (*ChatExportStats, error) {
var st ChatExportStats
err := s.pool.QueryRow(ctx, `
SELECT
COALESCE((SELECT COUNT(*) FROM (SELECT DISTINCT user_id, mentor_id FROM chat_messages) p), 0),
COUNT(*),
COALESCE(SUM(COALESCE(attachment_size, 0)), 0),
COALESCE(SUM(OCTET_LENGTH(COALESCE(body, ''))), 0)
FROM chat_messages`).Scan(
&st.ConversationCount,
&st.MessageCount,
&st.AttachmentBytes,
&st.BodyBytes,
)
if err != nil {
return nil, err
}
// Text/JSON compresses well in ZIP; media usually does not. Bias toward a safe upper bound.
const perMessageJSON int64 = 160
const perConversationJSON int64 = 120
const zipEnvelope int64 = 4096
textPacked := st.BodyBytes/2 + st.MessageCount*perMessageJSON + st.ConversationCount*perConversationJSON + zipEnvelope
st.EstimatedBytes = st.AttachmentBytes + textPacked
return &st, nil
}
// ListChatExportPairs returns every distinct conversation pair with display names.
func (s *Store) ListChatExportPairs(ctx context.Context) ([]ChatExportPair, error) {
rows, err := s.pool.Query(ctx, `
SELECT m.user_id, COALESCE(su.name, ''), m.mentor_id, COALESCE(mu.name, '')
FROM (
SELECT DISTINCT user_id, mentor_id FROM chat_messages
) m
LEFT JOIN users su ON su.id = m.user_id
LEFT JOIN users mu ON mu.id = m.mentor_id
ORDER BY m.user_id, m.mentor_id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ChatExportPair
for rows.Next() {
var p ChatExportPair
if err := rows.Scan(&p.StudentID, &p.StudentName, &p.MentorID, &p.MentorName); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
func chatExportTokenActive(tok *models.ChatExportToken, now time.Time) bool {
return tok != nil && tok.RevokedAt == nil && tok.UsedAt == nil && tok.ExpiresAt.After(now)
}
func scanChatExportToken(row rowScanner) (*models.ChatExportToken, error) {
var tok models.ChatExportToken
var tokenHash, tokenEnc string
err := row.Scan(
&tok.ID, &tokenHash, &tokenEnc, &tok.Label, &tok.ExpiresAt, &tok.RevokedAt, &tok.UsedAt,
&tok.CreatedBy, &tok.CreatedAt, &tok.LastUsedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrChatExportTokenNotFound
}
if err != nil {
return nil, err
}
tok.Active = chatExportTokenActive(&tok, time.Now().UTC())
// Stash enc on Token for decrypt by callers; clear/replace before JSON if unused.
tok.Token = tokenEnc
return &tok, nil
}
func (s *Store) CreateChatExportToken(
ctx context.Context,
tokenHash, tokenEnc, label string,
expiresAt time.Time,
createdBy int64,
) (*models.ChatExportToken, error) {
label = strings.TrimSpace(label)
if utf8.RuneCountInString(label) > 120 {
label = string([]rune(label)[:120])
}
if expiresAt.IsZero() || !expiresAt.After(time.Now().UTC()) {
return nil, errors.New("expiresAt must be in the future")
}
row := s.pool.QueryRow(ctx, `
INSERT INTO chat_export_tokens (token_hash, token_enc, label, expires_at, created_by)
VALUES ($1, $2, $3, $4, $5)
RETURNING `+chatExportTokenCols,
tokenHash, tokenEnc, label, expiresAt.UTC(), createdBy)
return scanChatExportToken(row)
}
func (s *Store) ListChatExportTokens(ctx context.Context, limit int) ([]models.ChatExportToken, error) {
if limit <= 0 {
limit = 50
}
if limit > 200 {
limit = 200
}
rows, err := s.pool.Query(ctx, `
SELECT `+chatExportTokenCols+`
FROM chat_export_tokens
ORDER BY created_at DESC
LIMIT $1`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.ChatExportToken
for rows.Next() {
tok, err := scanChatExportToken(rows)
if err != nil {
return nil, err
}
out = append(out, *tok)
}
return out, rows.Err()
}
func (s *Store) GetChatExportTokenByHash(ctx context.Context, tokenHash string) (*models.ChatExportToken, error) {
row := s.pool.QueryRow(ctx, `
SELECT `+chatExportTokenCols+`
FROM chat_export_tokens WHERE token_hash=$1`, tokenHash)
return scanChatExportToken(row)
}
func (s *Store) GetChatExportTokenByID(ctx context.Context, id int64) (*models.ChatExportToken, error) {
row := s.pool.QueryRow(ctx, `
SELECT `+chatExportTokenCols+`
FROM chat_export_tokens WHERE id=$1`, id)
return scanChatExportToken(row)
}
// ValidateChatExportToken returns nil when the token is active (not revoked, unused, not expired).
func ValidateChatExportToken(tok *models.ChatExportToken, now time.Time) error {
if tok == nil {
return ErrChatExportTokenNotFound
}
if tok.RevokedAt != nil {
return ErrChatExportTokenRevoked
}
if tok.UsedAt != nil {
return ErrChatExportTokenUsed
}
if !tok.ExpiresAt.After(now) {
return ErrChatExportTokenExpired
}
return nil
}
// ConsumeChatExportToken marks a token used atomically (single-use download links).
func (s *Store) ConsumeChatExportToken(ctx context.Context, id int64) (*models.ChatExportToken, error) {
row := s.pool.QueryRow(ctx, `
UPDATE chat_export_tokens
SET used_at = now(), last_used_at = now()
WHERE id = $1
AND revoked_at IS NULL
AND used_at IS NULL
AND expires_at > now()
RETURNING `+chatExportTokenCols, id)
tok, err := scanChatExportToken(row)
if err != nil {
if errors.Is(err, ErrChatExportTokenNotFound) {
existing, getErr := s.GetChatExportTokenByID(ctx, id)
if getErr != nil {
return nil, getErr
}
if err := ValidateChatExportToken(existing, time.Now().UTC()); err != nil {
return existing, err
}
return existing, ErrChatExportTokenInvalid
}
return nil, err
}
return tok, nil
}
func (s *Store) RevokeChatExportToken(ctx context.Context, id int64) (*models.ChatExportToken, error) {
row := s.pool.QueryRow(ctx, `
UPDATE chat_export_tokens
SET revoked_at = now()
WHERE id = $1 AND revoked_at IS NULL
RETURNING `+chatExportTokenCols, id)
tok, err := scanChatExportToken(row)
if err != nil {
if errors.Is(err, ErrChatExportTokenNotFound) {
existing, getErr := s.GetChatExportTokenByID(ctx, id)
if getErr != nil {
return nil, getErr
}
return existing, ErrChatExportTokenInvalid
}
return nil, err
}
return tok, nil
}