package postgres import ( "context" "database/sql" "encoding/json" "time" "git.gobha.me/xcaliber/chat-switchboard/models" ) type AttachmentStore struct{} func NewAttachmentStore() *AttachmentStore { return &AttachmentStore{} } // ── columns shared across queries ────────── const attachmentCols = `id, channel_id, user_id, message_id, project_id, filename, content_type, size_bytes, storage_key, extracted_text, metadata, created_at` // scanAttachment scans a row into an Attachment struct. func scanAttachment(row interface{ Scan(dest ...interface{}) error }) (*models.Attachment, error) { var a models.Attachment var messageID sql.NullString var projectID sql.NullString var extractedText sql.NullString var metadataJSON []byte err := row.Scan( &a.ID, &a.ChannelID, &a.UserID, &messageID, &projectID, &a.Filename, &a.ContentType, &a.SizeBytes, &a.StorageKey, &extractedText, &metadataJSON, &a.CreatedAt, ) if err != nil { return nil, err } a.MessageID = NullableStringPtr(messageID) a.ProjectID = NullableStringPtr(projectID) if extractedText.Valid { a.ExtractedText = &extractedText.String } if len(metadataJSON) > 0 { json.Unmarshal(metadataJSON, &a.Metadata) } return &a, nil } func (s *AttachmentStore) Create(ctx context.Context, a *models.Attachment) error { return DB.QueryRowContext(ctx, ` INSERT INTO attachments (channel_id, user_id, message_id, project_id, filename, content_type, size_bytes, storage_key, extracted_text, metadata) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING id, created_at`, a.ChannelID, a.UserID, models.NullString(a.MessageID), models.NullString(a.ProjectID), a.Filename, a.ContentType, a.SizeBytes, a.StorageKey, models.NullString(a.ExtractedText), ToJSON(a.Metadata), ).Scan(&a.ID, &a.CreatedAt) } func (s *AttachmentStore) GetByID(ctx context.Context, id string) (*models.Attachment, error) { row := DB.QueryRowContext(ctx, `SELECT `+attachmentCols+` FROM attachments WHERE id = $1`, id) return scanAttachment(row) } func (s *AttachmentStore) GetByChannel(ctx context.Context, channelID string) ([]models.Attachment, error) { rows, err := DB.QueryContext(ctx, `SELECT `+attachmentCols+` FROM attachments WHERE channel_id = $1 ORDER BY created_at`, channelID) if err != nil { return nil, err } defer rows.Close() var out []models.Attachment for rows.Next() { a, err := scanAttachment(rows) if err != nil { return nil, err } out = append(out, *a) } return out, rows.Err() } func (s *AttachmentStore) GetByMessage(ctx context.Context, messageID string) ([]models.Attachment, error) { rows, err := DB.QueryContext(ctx, `SELECT `+attachmentCols+` FROM attachments WHERE message_id = $1 ORDER BY created_at`, messageID) if err != nil { return nil, err } defer rows.Close() var out []models.Attachment for rows.Next() { a, err := scanAttachment(rows) if err != nil { return nil, err } out = append(out, *a) } return out, rows.Err() } // GetByProject returns all attachments for a project (v0.22.4). func (s *AttachmentStore) GetByProject(ctx context.Context, projectID string) ([]models.Attachment, error) { rows, err := DB.QueryContext(ctx, `SELECT `+attachmentCols+` FROM attachments WHERE project_id = $1 ORDER BY created_at`, projectID) if err != nil { return nil, err } defer rows.Close() var out []models.Attachment for rows.Next() { a, err := scanAttachment(rows) if err != nil { return nil, err } out = append(out, *a) } return out, rows.Err() } func (s *AttachmentStore) SetMessageID(ctx context.Context, attachmentID, messageID string) error { _, err := DB.ExecContext(ctx, `UPDATE attachments SET message_id = $1 WHERE id = $2`, messageID, attachmentID) return err } func (s *AttachmentStore) UpdateMetadata(ctx context.Context, id string, metadata map[string]interface{}) error { // Merge into existing metadata using jsonb || operator metaJSON, err := json.Marshal(metadata) if err != nil { return err } _, err = DB.ExecContext(ctx, `UPDATE attachments SET metadata = metadata || $1::jsonb WHERE id = $2`, metaJSON, id) return err } func (s *AttachmentStore) SetExtractedText(ctx context.Context, id string, text string) error { _, err := DB.ExecContext(ctx, `UPDATE attachments SET extracted_text = $1 WHERE id = $2`, text, id) return err } // Delete removes an attachment and returns the deleted row (for storage cleanup). func (s *AttachmentStore) Delete(ctx context.Context, id string) (*models.Attachment, error) { row := DB.QueryRowContext(ctx, `DELETE FROM attachments WHERE id = $1 RETURNING `+attachmentCols, id) return scanAttachment(row) } // DeleteByChannel removes all attachments for a channel and returns storage keys. func (s *AttachmentStore) DeleteByChannel(ctx context.Context, channelID string) ([]string, error) { rows, err := DB.QueryContext(ctx, `DELETE FROM attachments WHERE channel_id = $1 RETURNING storage_key`, channelID) if err != nil { return nil, err } defer rows.Close() var keys []string for rows.Next() { var key string if err := rows.Scan(&key); err != nil { return keys, err } keys = append(keys, key) } return keys, rows.Err() } func (s *AttachmentStore) UserUsageBytes(ctx context.Context, userID string) (int64, error) { var total sql.NullInt64 err := DB.QueryRowContext(ctx, `SELECT COALESCE(SUM(size_bytes), 0) FROM attachments WHERE user_id = $1`, userID).Scan(&total) if err != nil { return 0, err } return total.Int64, nil } func (s *AttachmentStore) ListOrphans(ctx context.Context, olderThan time.Duration) ([]models.Attachment, error) { cutoff := time.Now().Add(-olderThan) rows, err := DB.QueryContext(ctx, `SELECT `+attachmentCols+` FROM attachments WHERE message_id IS NULL AND created_at < $1 ORDER BY created_at`, cutoff) if err != nil { return nil, err } defer rows.Close() var out []models.Attachment for rows.Next() { a, err := scanAttachment(rows) if err != nil { return nil, err } out = append(out, *a) } return out, rows.Err() }