package postgres import ( "context" "database/sql" "gardomatic.kleiax.de/internal/storage" ) // JournalModel implements storage.JournalModelInterface for PostgreSQL and // enforces the garden boundary on entries and attachments. type JournalModel struct{ DB *sql.DB } const journalColumns = `e.id, e.garden_id, e.author_id, u.name, u.color, e.entry_type, e.title, e.body, e.created_at, e.updated_at, e.version` func scanJournalEntry(s scanner) (storage.JournalEntry, error) { var entry storage.JournalEntry err := s.Scan(&entry.ID, &entry.GardenID, &entry.AuthorID, &entry.AuthorName, &entry.AuthorColor, &entry.EntryType, &entry.Title, &entry.Body, &entry.CreatedAt, &entry.UpdatedAt, &entry.Version) return entry, err } // Insert creates a journal entry and its tags atomically. func (m JournalModel) Insert(entry storage.JournalEntry) (storage.JournalEntry, error) { ctx, cancel := contextWithTimeout() defer cancel() if entry.EntryType == "" { entry.EntryType = storage.JournalEntryTypeJournal } err := m.DB.QueryRowContext(ctx, `INSERT INTO journal_entries(garden_id,author_id,entry_type,title,body,created_at) VALUES($1,$2,$3,$4,$5,$6) RETURNING id,created_at,updated_at,version`, entry.GardenID, entry.AuthorID, entry.EntryType, entry.Title, entry.Body, entry.CreatedAt).Scan(&entry.ID, &entry.CreatedAt, &entry.UpdatedAt, &entry.Version) if err != nil { return storage.JournalEntry{}, recordError(err) } return entry, nil } // Get returns an entry with tags and attachment metadata within its garden. func (m JournalModel) Get(gardenID, id int) (storage.JournalEntry, error) { ctx, cancel := contextWithTimeout() defer cancel() entry, err := scanJournalEntry(m.DB.QueryRowContext(ctx, `SELECT `+journalColumns+` FROM journal_entries e JOIN users u ON u.id=e.author_id WHERE e.garden_id=$1 AND e.id=$2`, gardenID, id)) if err != nil { return storage.JournalEntry{}, recordError(err) } entry.Attachments, err = m.attachments(ctx, entry.ID) return entry, err } // GetAllForGarden lists entries of an optional type in a garden. func (m JournalModel) GetAllForGarden(gardenID int, entryType storage.JournalEntryType) ([]storage.JournalEntry, error) { ctx, cancel := contextWithTimeout() defer cancel() if entryType == "" { entryType = storage.JournalEntryTypeJournal } rows, err := m.DB.QueryContext(ctx, `SELECT `+journalColumns+` FROM journal_entries e JOIN users u ON u.id=e.author_id WHERE e.garden_id=$1 AND e.entry_type=$2 ORDER BY e.created_at DESC,e.id DESC`, gardenID, entryType) if err != nil { return nil, err } defer rows.Close() entries := []storage.JournalEntry{} for rows.Next() { entry, scanErr := scanJournalEntry(rows) if scanErr != nil { return nil, scanErr } entries = append(entries, entry) } if err = rows.Err(); err != nil { return nil, err } for i := range entries { entries[i].Attachments, err = m.attachments(ctx, entries[i].ID) if err != nil { return nil, err } } return entries, nil } // Update changes an entry and its tags atomically using optimistic locking. func (m JournalModel) Update(gardenID int, entry storage.JournalEntry) (storage.JournalEntry, error) { ctx, cancel := contextWithTimeout() defer cancel() err := m.DB.QueryRowContext(ctx, `UPDATE journal_entries SET title=$1,body=$2,created_at=$3,updated_at=now(),version=version+1 WHERE garden_id=$4 AND id=$5 AND version=$6 RETURNING created_at,updated_at,version`, entry.Title, entry.Body, entry.CreatedAt, gardenID, entry.ID, entry.Version).Scan(&entry.CreatedAt, &entry.UpdatedAt, &entry.Version) if err == sql.ErrNoRows { return storage.JournalEntry{}, storage.ErrEditConflict } if err != nil { return storage.JournalEntry{}, err } return entry, nil } // Delete removes an entry within its garden. func (m JournalModel) Delete(gardenID, id int) error { return deleteByID(m.DB, `DELETE FROM journal_entries WHERE garden_id=$1 AND id=$2`, gardenID, id) } // InsertAttachment adds inline media or a library-image link to an entry. func (m JournalModel) InsertAttachment(gardenID, entryID int, attachment storage.JournalAttachment) (storage.JournalAttachment, error) { ctx, cancel := contextWithTimeout() defer cancel() var entryType storage.JournalEntryType if err := m.DB.QueryRowContext(ctx, `SELECT entry_type FROM journal_entries WHERE garden_id=$1 AND id=$2`, gardenID, entryID).Scan(&entryType); err != nil { return storage.JournalAttachment{}, recordError(err) } if attachment.ImageID != nil { image, err := ImageModel(m).Get(gardenID, *attachment.ImageID) if err != nil { return storage.JournalAttachment{}, err } attachment.MediaType, attachment.Size = image.MediaType, image.Size if attachment.FileName == "" { attachment.FileName = image.FileName } if attachment.FileName == "" { attachment.FileName = "Bild" } } else if len(attachment.MediaType) > 6 && attachment.MediaType[:6] == "image/" { image, err := ImageModel(m).Insert(storage.Image{GardenID: gardenID, FileName: attachment.FileName, MediaType: attachment.MediaType, Data: attachment.Data, Source: string(entryType)}) if err != nil { return storage.JournalAttachment{}, err } attachment.ImageID = &image.ID } data := attachment.Data if attachment.ImageID != nil { data = []byte{} } size := int64(len(attachment.Data)) if attachment.ImageID != nil { size = attachment.Size } err := m.DB.QueryRowContext(ctx, `INSERT INTO journal_attachments(journal_entry_id,file_name,media_type,data,size,image_id) SELECT e.id,$1,$2,$3,$4,$5 FROM journal_entries e WHERE e.garden_id=$6 AND e.id=$7 RETURNING id,created_at`, attachment.FileName, attachment.MediaType, data, size, attachment.ImageID, gardenID, entryID).Scan(&attachment.ID, &attachment.CreatedAt) if err != nil { return storage.JournalAttachment{}, recordError(err) } attachment.EntryID, attachment.Size = entryID, size attachment.Data = nil return attachment, nil } // GetAttachment returns attachment metadata and data within its garden and entry. func (m JournalModel) GetAttachment(gardenID, entryID, attachmentID int) (storage.JournalAttachment, error) { ctx, cancel := contextWithTimeout() defer cancel() var a storage.JournalAttachment err := m.DB.QueryRowContext(ctx, `SELECT a.id,a.journal_entry_id,a.file_name,a.media_type,a.size,a.created_at,COALESCE(i.data,a.data),a.image_id FROM journal_attachments a JOIN journal_entries e ON e.id=a.journal_entry_id LEFT JOIN images i ON i.id=a.image_id WHERE e.garden_id=$1 AND e.id=$2 AND a.id=$3`, gardenID, entryID, attachmentID).Scan(&a.ID, &a.EntryID, &a.FileName, &a.MediaType, &a.Size, &a.CreatedAt, &a.Data, &a.ImageID) if err != nil { return storage.JournalAttachment{}, recordError(err) } return a, nil } // DeleteAttachment removes an attachment within its garden and entry. func (m JournalModel) DeleteAttachment(gardenID, entryID, attachmentID int) error { return deleteByID(m.DB, `DELETE FROM journal_attachments a USING journal_entries e WHERE a.journal_entry_id=e.id AND e.garden_id=$1 AND e.id=$2 AND a.id=$3`, gardenID, entryID, attachmentID) } func (m JournalModel) attachments(ctx context.Context, entryID int) ([]storage.JournalAttachment, error) { rows, err := m.DB.QueryContext(ctx, `SELECT id,journal_entry_id,file_name,media_type,size,created_at,image_id FROM journal_attachments WHERE journal_entry_id=$1 ORDER BY id`, entryID) if err != nil { return nil, err } defer rows.Close() result := []storage.JournalAttachment{} for rows.Next() { var a storage.JournalAttachment if err = rows.Scan(&a.ID, &a.EntryID, &a.FileName, &a.MediaType, &a.Size, &a.CreatedAt, &a.ImageID); err != nil { return nil, err } result = append(result, a) } return result, rows.Err() }