feat(backend): add domain store, learning services, and seed data
Persist LMS entities and implement hearts/watch rules plus curriculum bootstrap.
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user