added actions repository implementation to database layer

This commit is contained in:
d1nch8g
2026-04-16 22:38:35 +03:00
parent 1c0bd3512e
commit 7a47929c06
5 changed files with 283 additions and 19 deletions
+8 -8
View File
@@ -2,6 +2,7 @@ package database
import (
"context"
"encoding/json"
"errors"
"time"
@@ -52,11 +53,10 @@ type Notification struct {
// Action records an interaction between Jules and a user.
type Action struct {
ID uuid.UUID
UserID uuid.UUID
Type string // "user_msg", "jules_msg", "call", "ping_contact"
Content string
CreatedAt time.Time
OwnerID uuid.UUID
InitiatorID uuid.UUID
ExecutedAt time.Time
Payload json.RawMessage
}
// Users manages user persistence.
@@ -98,9 +98,9 @@ type Notifications interface {
Delete(ctx context.Context, id uuid.UUID) error
}
// Actions manages the action log.
// Actions represents the action log.
type Actions interface {
Log(ctx context.Context, a *Action) error
Recent(ctx context.Context, userID uuid.UUID, limit int) ([]Action, error)
DeleteOlderThan(ctx context.Context, userID uuid.UUID, t time.Time) error
Recent(ctx context.Context, ownerID uuid.UUID, limit int) ([]Action, error)
DeleteOlderThan(ctx context.Context, ownerID uuid.UUID, t time.Time) (int64, error)
}
+65
View File
@@ -0,0 +1,65 @@
package postgres
import (
"context"
"database/sql"
"errors"
"time"
"github.com/d1nch8g/jules/database"
"github.com/google/uuid"
)
type Actions struct {
conn *sql.DB
}
func (a *Actions) Log(ctx context.Context, action *database.Action) error {
if action.Payload == nil {
return errors.New("payload is required")
}
_, err := a.conn.ExecContext(ctx, `
INSERT INTO actions (owner_id, initiator_id, executed_at, payload)
VALUES ($1, $2, $3, $4)
`, action.OwnerID, action.InitiatorID, action.ExecutedAt, action.Payload)
return err
}
func (a *Actions) Recent(ctx context.Context, ownerID uuid.UUID, limit int) ([]database.Action, error) {
rows, err := a.conn.QueryContext(ctx, `
SELECT owner_id, initiator_id, executed_at, payload
FROM actions
WHERE owner_id = $1
ORDER BY executed_at DESC
LIMIT $2
`, ownerID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var actions []database.Action
for rows.Next() {
var action database.Action
if err := rows.Scan(&action.OwnerID, &action.InitiatorID, &action.ExecutedAt, &action.Payload); err != nil {
return nil, err
}
actions = append(actions, action)
}
if err := rows.Err(); err != nil {
return nil, err
}
return actions, nil
}
func (a *Actions) DeleteOlderThan(ctx context.Context, ownerID uuid.UUID, t time.Time) (int64, error) {
result, err := a.conn.ExecContext(ctx, `
DELETE FROM actions
WHERE owner_id = $1 AND executed_at < $2
`, ownerID, t)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
+199
View File
@@ -0,0 +1,199 @@
package postgres
import (
"context"
"encoding/json"
"errors"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/d1nch8g/jules/database"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestActions_Log(t *testing.T) {
db := setupTestDB(t)
user, _ := db.Users.Create(context.Background())
payload, _ := json.Marshal(map[string]any{"type": "user_msg", "text": "hello"})
action := &database.Action{
OwnerID: user.ID,
InitiatorID: user.ID,
ExecutedAt: time.Now().UTC(),
Payload: payload,
}
err := db.Actions.Log(context.Background(), action)
require.NoError(t, err)
actions, err := db.Actions.Recent(context.Background(), user.ID, 10)
require.NoError(t, err)
assert.Len(t, actions, 1)
assert.Equal(t, user.ID, actions[0].OwnerID)
assert.Equal(t, user.ID, actions[0].InitiatorID)
assert.JSONEq(t, string(payload), string(actions[0].Payload))
}
func TestActions_Log_NilPayload(t *testing.T) {
db := setupTestDB(t)
user, _ := db.Users.Create(context.Background())
err := db.Actions.Log(context.Background(), &database.Action{
OwnerID: user.ID,
InitiatorID: user.ID,
ExecutedAt: time.Now().UTC(),
Payload: nil,
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "payload is required")
}
func TestActions_Log_InvalidJSON(t *testing.T) {
db := setupTestDB(t)
user, _ := db.Users.Create(context.Background())
err := db.Actions.Log(context.Background(), &database.Action{
OwnerID: user.ID,
InitiatorID: user.ID,
ExecutedAt: time.Now().UTC(),
Payload: json.RawMessage(`{invalid}`),
})
assert.Error(t, err)
}
func TestActions_Recent(t *testing.T) {
db := setupTestDB(t)
user, _ := db.Users.Create(context.Background())
payload1, _ := json.Marshal(map[string]any{"type": "user_msg", "text": "first"})
payload2, _ := json.Marshal(map[string]any{"type": "user_msg", "text": "second"})
db.Actions.Log(context.Background(), &database.Action{
OwnerID: user.ID,
InitiatorID: user.ID,
ExecutedAt: time.Now().UTC().Add(-time.Hour),
Payload: payload1,
})
db.Actions.Log(context.Background(), &database.Action{
OwnerID: user.ID,
InitiatorID: user.ID,
ExecutedAt: time.Now().UTC(),
Payload: payload2,
})
actions, err := db.Actions.Recent(context.Background(), user.ID, 10)
require.NoError(t, err)
assert.Len(t, actions, 2)
assert.JSONEq(t, string(payload2), string(actions[0].Payload))
assert.JSONEq(t, string(payload1), string(actions[1].Payload))
actions, err = db.Actions.Recent(context.Background(), user.ID, 1)
require.NoError(t, err)
assert.Len(t, actions, 1)
}
func TestActions_DeleteOlderThan(t *testing.T) {
db := setupTestDB(t)
user, _ := db.Users.Create(context.Background())
oldTime := time.Now().UTC().Add(-48 * time.Hour).Truncate(time.Second)
newTime := time.Now().UTC().Truncate(time.Second)
payload, _ := json.Marshal(map[string]any{"type": "user_msg", "text": "test"})
db.Actions.Log(context.Background(), &database.Action{
OwnerID: user.ID,
InitiatorID: user.ID,
ExecutedAt: oldTime,
Payload: payload,
})
db.Actions.Log(context.Background(), &database.Action{
OwnerID: user.ID,
InitiatorID: user.ID,
ExecutedAt: newTime,
Payload: payload,
})
deleted, err := db.Actions.DeleteOlderThan(context.Background(), user.ID, time.Now().UTC().Add(-24*time.Hour))
require.NoError(t, err)
assert.Equal(t, int64(1), deleted)
actions, _ := db.Actions.Recent(context.Background(), user.ID, 10)
assert.Len(t, actions, 1)
assert.WithinDuration(t, newTime, actions[0].ExecutedAt, time.Second)
}
func TestActions_Recent_QueryError(t *testing.T) {
conn, mock, _ := sqlmock.New()
defer conn.Close()
db := &Actions{conn: conn}
mock.ExpectQuery(`SELECT owner_id, initiator_id, executed_at, payload FROM actions`).
WillReturnError(errors.New("db down"))
_, err := db.Recent(context.Background(), uuid.New(), 10)
assert.Error(t, err)
}
func TestActions_Recent_ScanError(t *testing.T) {
conn, mock, _ := sqlmock.New()
defer conn.Close()
db := &Actions{conn: conn}
rows := sqlmock.NewRows([]string{"owner_id", "initiator_id", "executed_at", "payload"}).
AddRow(uuid.New(), uuid.New(), time.Now(), nil)
mock.ExpectQuery(`SELECT owner_id, initiator_id, executed_at, payload FROM actions`).
WillReturnRows(rows)
_, err := db.Recent(context.Background(), uuid.New(), 10)
assert.Error(t, err)
}
func TestActions_DeleteOlderThan_Error(t *testing.T) {
conn, mock, _ := sqlmock.New()
defer conn.Close()
db := &Actions{conn: conn}
mock.ExpectExec(`DELETE FROM actions`).
WillReturnError(errors.New("db down"))
_, err := db.DeleteOlderThan(context.Background(), uuid.New(), time.Now())
assert.Error(t, err)
}
func TestActions_Log_DatabaseError(t *testing.T) {
db := setupTestDB(t)
user, _ := db.Users.Create(context.Background())
db.Close()
payload, _ := json.Marshal(map[string]any{"type": "user_msg"})
err := db.Actions.Log(context.Background(), &database.Action{
OwnerID: user.ID,
InitiatorID: user.ID,
ExecutedAt: time.Now().UTC(),
Payload: payload,
})
assert.Error(t, err)
}
func TestActions_Recent_RowsErr(t *testing.T) {
conn, mock, _ := sqlmock.New()
defer conn.Close()
db := &Actions{conn: conn}
rows := sqlmock.NewRows([]string{"owner_id", "initiator_id", "executed_at", "payload"}).
AddRow(uuid.New(), uuid.New(), time.Now(), json.RawMessage(`{"type":"test"}`)).
RowError(0, errors.New("connection lost"))
mock.ExpectQuery(`SELECT owner_id, initiator_id, executed_at, payload FROM actions`).
WillReturnRows(rows)
_, err := db.Recent(context.Background(), uuid.New(), 10)
assert.Error(t, err)
}
+9 -11
View File
@@ -1,8 +1,8 @@
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
preferred_chat TEXT NOT NULL DEFAULT 'telegram',
language TEXT NOT NULL DEFAULT 'en',
timezone TEXT NOT NULL DEFAULT 'UTC'
preferred_chat TEXT NOT NULL,
language TEXT NOT NULL,
timezone TEXT NOT NULL
);
CREATE TABLE chats (
@@ -33,7 +33,7 @@ CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
initiator_id UUID REFERENCES users(id) ON DELETE SET NULL,
scheduled_at TIMESTAMPTZ NOT NULL,
scheduled_at TIMESTAMP NOT NULL,
content TEXT NOT NULL
);
@@ -41,12 +41,10 @@ CREATE INDEX idx_notifications_scheduled_at ON notifications(scheduled_at);
CREATE INDEX idx_notifications_user_id ON notifications(user_id);
CREATE TABLE actions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL CHECK (type IN ('user_msg', 'jules_msg', 'call', 'ping_contact')),
content TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
initiator_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
executed_at TIMESTAMP NOT NULL DEFAULT NOW(),
payload JSONB NOT NULL
);
CREATE INDEX idx_actions_user_id ON actions(user_id);
CREATE INDEX idx_actions_created_at ON actions(created_at);
CREATE INDEX idx_actions_owner_executed ON actions(owner_id, executed_at DESC);
+2
View File
@@ -15,6 +15,7 @@ type DB struct {
Facts Facts
Contacts Contacts
Notifications Notifications
Actions Actions
conn *sql.DB
}
@@ -44,6 +45,7 @@ func New(connString string) (*DB, error) {
Facts: Facts{conn: conn},
Contacts: Contacts{conn: conn},
Notifications: Notifications{conn: conn},
Actions: Actions{conn: conn},
conn: conn,
}, nil