added actions repository implementation to database layer
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user