Initial commit
CI / test (push) Canceled after 0s

This commit is contained in:
2026-09-12 22:22:17 +02:00
commit 904d14b64c
314 changed files with 31884 additions and 0 deletions
+175
View File
@@ -0,0 +1,175 @@
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()
}