Changeset 0.22.8 (#150)
This commit is contained in:
@@ -1,210 +0,0 @@
|
||||
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()
|
||||
}
|
||||
251
server/store/postgres/file.go
Normal file
251
server/store/postgres/file.go
Normal file
@@ -0,0 +1,251 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
)
|
||||
|
||||
// FileStore implements store.FileStore against the `files` table.
|
||||
//
|
||||
type FileStore struct{}
|
||||
|
||||
func NewFileStore() *FileStore { return &FileStore{} }
|
||||
|
||||
const fileCols = `id, channel_id, user_id, message_id, project_id, origin,
|
||||
filename, content_type, size_bytes, storage_key, display_hint,
|
||||
extracted_text, metadata, created_at, updated_at`
|
||||
|
||||
func scanFile(row interface{ Scan(dest ...interface{}) error }) (*models.File, error) {
|
||||
var f models.File
|
||||
var messageID sql.NullString
|
||||
var projectID sql.NullString
|
||||
var extractedText sql.NullString
|
||||
var metadataJSON []byte
|
||||
|
||||
err := row.Scan(
|
||||
&f.ID, &f.ChannelID, &f.UserID, &messageID, &projectID, &f.Origin,
|
||||
&f.Filename, &f.ContentType, &f.SizeBytes,
|
||||
&f.StorageKey, &f.DisplayHint,
|
||||
&extractedText, &metadataJSON, &f.CreatedAt, &f.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
f.MessageID = NullableStringPtr(messageID)
|
||||
f.ProjectID = NullableStringPtr(projectID)
|
||||
if extractedText.Valid {
|
||||
f.ExtractedText = &extractedText.String
|
||||
}
|
||||
if len(metadataJSON) > 0 {
|
||||
json.Unmarshal(metadataJSON, &f.Metadata)
|
||||
}
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
func (s *FileStore) Create(ctx context.Context, f *models.File) error {
|
||||
if f.Origin == "" {
|
||||
f.Origin = models.FileOriginUserUpload
|
||||
}
|
||||
if f.DisplayHint == "" {
|
||||
f.DisplayHint = models.FileHintDownload
|
||||
}
|
||||
return DB.QueryRowContext(ctx, `
|
||||
INSERT INTO files (channel_id, user_id, message_id, project_id, origin,
|
||||
filename, content_type, size_bytes, storage_key, display_hint,
|
||||
extracted_text, metadata)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
||||
RETURNING id, created_at, updated_at`,
|
||||
f.ChannelID, f.UserID, models.NullString(f.MessageID),
|
||||
models.NullString(f.ProjectID), f.Origin,
|
||||
f.Filename, f.ContentType, f.SizeBytes,
|
||||
f.StorageKey, f.DisplayHint, models.NullString(f.ExtractedText),
|
||||
ToJSON(f.Metadata),
|
||||
).Scan(&f.ID, &f.CreatedAt, &f.UpdatedAt)
|
||||
}
|
||||
|
||||
func (s *FileStore) GetByID(ctx context.Context, id string) (*models.File, error) {
|
||||
row := DB.QueryRowContext(ctx, `SELECT `+fileCols+` FROM files WHERE id = $1`, id)
|
||||
return scanFile(row)
|
||||
}
|
||||
|
||||
func (s *FileStore) GetByChannel(ctx context.Context, channelID string, origin string) ([]models.File, error) {
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if origin != "" {
|
||||
rows, err = DB.QueryContext(ctx,
|
||||
`SELECT `+fileCols+` FROM files WHERE channel_id = $1 AND origin = $2 ORDER BY created_at`,
|
||||
channelID, origin)
|
||||
} else {
|
||||
rows, err = DB.QueryContext(ctx,
|
||||
`SELECT `+fileCols+` FROM files WHERE channel_id = $1 ORDER BY created_at`,
|
||||
channelID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.File
|
||||
for rows.Next() {
|
||||
f, err := scanFile(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *FileStore) GetByMessage(ctx context.Context, messageID string) ([]models.File, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`SELECT `+fileCols+` FROM files WHERE message_id = $1 ORDER BY created_at`, messageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.File
|
||||
for rows.Next() {
|
||||
f, err := scanFile(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *FileStore) GetByProject(ctx context.Context, projectID string) ([]models.File, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`SELECT `+fileCols+` FROM files WHERE project_id = $1 ORDER BY created_at`, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.File
|
||||
for rows.Next() {
|
||||
f, err := scanFile(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *FileStore) GetByUser(ctx context.Context, userID string, page, perPage int) ([]models.File, int, error) {
|
||||
var total int
|
||||
err := DB.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM files WHERE user_id = $1`, userID).Scan(&total)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
offset := (page - 1) * perPage
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`SELECT `+fileCols+` FROM files WHERE user_id = $1
|
||||
ORDER BY created_at DESC LIMIT $2 OFFSET $3`, userID, perPage, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.File
|
||||
for rows.Next() {
|
||||
f, err := scanFile(rows)
|
||||
if err != nil {
|
||||
return nil, total, err
|
||||
}
|
||||
out = append(out, *f)
|
||||
}
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
|
||||
func (s *FileStore) SetMessageID(ctx context.Context, fileID, messageID string) error {
|
||||
_, err := DB.ExecContext(ctx,
|
||||
`UPDATE files SET message_id = $1, updated_at = NOW() WHERE id = $2`,
|
||||
messageID, fileID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *FileStore) UpdateMetadata(ctx context.Context, id string, metadata map[string]interface{}) error {
|
||||
metaJSON, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = DB.ExecContext(ctx,
|
||||
`UPDATE files SET metadata = metadata || $1::jsonb, updated_at = NOW() WHERE id = $2`,
|
||||
metaJSON, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *FileStore) SetExtractedText(ctx context.Context, id string, text string) error {
|
||||
_, err := DB.ExecContext(ctx,
|
||||
`UPDATE files SET extracted_text = $1, updated_at = NOW() WHERE id = $2`,
|
||||
text, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *FileStore) Delete(ctx context.Context, id string) (*models.File, error) {
|
||||
row := DB.QueryRowContext(ctx,
|
||||
`DELETE FROM files WHERE id = $1 RETURNING `+fileCols, id)
|
||||
return scanFile(row)
|
||||
}
|
||||
|
||||
func (s *FileStore) DeleteByChannel(ctx context.Context, channelID string) ([]string, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`DELETE FROM files 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 *FileStore) UserUsageBytes(ctx context.Context, userID string) (int64, error) {
|
||||
var total sql.NullInt64
|
||||
err := DB.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(SUM(size_bytes), 0) FROM files WHERE user_id = $1`,
|
||||
userID).Scan(&total)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return total.Int64, nil
|
||||
}
|
||||
|
||||
func (s *FileStore) ListOrphans(ctx context.Context, olderThan time.Duration) ([]models.File, error) {
|
||||
cutoff := time.Now().Add(-olderThan)
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`SELECT `+fileCols+` FROM files
|
||||
WHERE message_id IS NULL AND origin = 'user_upload' AND created_at < $1
|
||||
ORDER BY created_at`, cutoff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.File
|
||||
for rows.Next() {
|
||||
f, err := scanFile(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -27,7 +27,7 @@ func NewStores(db *sql.DB) store.Stores {
|
||||
Usage: NewUsageStore(),
|
||||
Pricing: NewPricingStore(),
|
||||
Extensions: NewExtensionStore(),
|
||||
Attachments: NewAttachmentStore(),
|
||||
Files: NewFileStore(),
|
||||
KnowledgeBases: NewKnowledgeBaseStore(),
|
||||
Groups: NewGroupStore(),
|
||||
ResourceGrants: NewResourceGrantStore(),
|
||||
|
||||
Reference in New Issue
Block a user