Files
kandev/apps/backend/internal/notifications/store/sqlite.go
T

296 lines
8.5 KiB
Go

package store
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/db/dialect"
"github.com/kandev/kandev/internal/notifications/models"
)
type sqliteRepository struct {
db *sqlx.DB // writer
ro *sqlx.DB // reader
ownsDB bool
}
var _ Repository = (*sqliteRepository)(nil)
func newSQLiteRepositoryWithDB(writer, reader *sqlx.DB) (*sqliteRepository, error) {
return newSQLiteRepository(writer, reader, false)
}
func newSQLiteRepository(writer, reader *sqlx.DB, ownsDB bool) (*sqliteRepository, error) {
repo := &sqliteRepository{db: writer, ro: reader, ownsDB: ownsDB}
if err := repo.initSchema(); err != nil {
if ownsDB {
if closeErr := writer.Close(); closeErr != nil {
return nil, fmt.Errorf("failed to close database after schema error: %w", closeErr)
}
}
return nil, fmt.Errorf("failed to initialize schema: %w", err)
}
return repo, nil
}
func (r *sqliteRepository) Close() error {
if !r.ownsDB {
return nil
}
return r.db.Close()
}
func (r *sqliteRepository) initSchema() error {
schema := `
CREATE TABLE IF NOT EXISTS notification_providers (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL,
config TEXT DEFAULT '{}',
enabled INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS notification_subscriptions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
provider_id TEXT NOT NULL,
event_type TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
UNIQUE(provider_id, event_type),
FOREIGN KEY (provider_id) REFERENCES notification_providers(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS notification_deliveries (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
provider_id TEXT NOT NULL,
event_type TEXT NOT NULL,
task_session_id TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
UNIQUE(provider_id, event_type, task_session_id)
);
CREATE INDEX IF NOT EXISTS idx_notification_providers_user_id ON notification_providers(user_id);
CREATE INDEX IF NOT EXISTS idx_notification_subscriptions_provider_id ON notification_subscriptions(provider_id);
CREATE INDEX IF NOT EXISTS idx_notification_subscriptions_user_id ON notification_subscriptions(user_id);
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_session_id ON notification_deliveries(task_session_id);
`
_, err := r.db.Exec(schema)
return err
}
func (r *sqliteRepository) CreateProvider(ctx context.Context, provider *models.Provider) error {
provider.ID = uuid.New().String()
now := time.Now().UTC()
provider.CreatedAt = now
provider.UpdatedAt = now
if provider.Config == nil {
provider.Config = map[string]interface{}{}
}
configJSON, err := json.Marshal(provider.Config)
if err != nil {
return fmt.Errorf("failed to serialize provider config: %w", err)
}
_, err = r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO notification_providers (id, user_id, name, type, config, enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`), provider.ID, provider.UserID, provider.Name, provider.Type, string(configJSON), dialect.BoolToInt(provider.Enabled), provider.CreatedAt, provider.UpdatedAt)
return err
}
func (r *sqliteRepository) UpdateProvider(ctx context.Context, provider *models.Provider) error {
provider.UpdatedAt = time.Now().UTC()
if provider.Config == nil {
provider.Config = map[string]interface{}{}
}
configJSON, err := json.Marshal(provider.Config)
if err != nil {
return fmt.Errorf("failed to serialize provider config: %w", err)
}
_, err = r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE notification_providers
SET name = ?, type = ?, config = ?, enabled = ?, updated_at = ?
WHERE id = ?
`), provider.Name, provider.Type, string(configJSON), dialect.BoolToInt(provider.Enabled), provider.UpdatedAt, provider.ID)
return err
}
func (r *sqliteRepository) GetProvider(ctx context.Context, id string) (*models.Provider, error) {
row := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
SELECT id, user_id, name, type, config, enabled, created_at, updated_at
FROM notification_providers
WHERE id = ?
`), id)
return scanProvider(row)
}
func (r *sqliteRepository) ListProvidersByUser(ctx context.Context, userID string) ([]*models.Provider, error) {
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(`
SELECT id, user_id, name, type, config, enabled, created_at, updated_at
FROM notification_providers
WHERE user_id = ?
ORDER BY created_at ASC
`), userID)
if err != nil {
return nil, err
}
defer func() {
_ = rows.Close()
}()
var providers []*models.Provider
for rows.Next() {
provider, err := scanProvider(rows)
if err != nil {
return nil, err
}
providers = append(providers, provider)
}
if err := rows.Err(); err != nil {
return nil, err
}
return providers, nil
}
func (r *sqliteRepository) DeleteProvider(ctx context.Context, id string) error {
_, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM notification_providers WHERE id = ?`), id)
return err
}
func (r *sqliteRepository) ListSubscriptionsByProvider(ctx context.Context, providerID string) ([]*models.Subscription, error) {
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(`
SELECT id, user_id, provider_id, event_type, enabled, created_at, updated_at
FROM notification_subscriptions
WHERE provider_id = ?
ORDER BY created_at ASC
`), providerID)
if err != nil {
return nil, err
}
defer func() {
_ = rows.Close()
}()
var subs []*models.Subscription
for rows.Next() {
sub, err := scanSubscription(rows)
if err != nil {
return nil, err
}
subs = append(subs, sub)
}
if err := rows.Err(); err != nil {
return nil, err
}
return subs, nil
}
func (r *sqliteRepository) ReplaceSubscriptions(ctx context.Context, providerID, userID string, events []string) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, r.db.Rebind(`DELETE FROM notification_subscriptions WHERE provider_id = ?`), providerID); err != nil {
_ = tx.Rollback()
return err
}
now := time.Now().UTC()
for _, eventType := range events {
subID := uuid.New().String()
if _, err := tx.ExecContext(ctx, r.db.Rebind(`
INSERT INTO notification_subscriptions (id, user_id, provider_id, event_type, enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`), subID, userID, providerID, eventType, dialect.BoolToInt(true), now, now); err != nil {
_ = tx.Rollback()
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
return nil
}
func (r *sqliteRepository) InsertDelivery(ctx context.Context, delivery *models.Delivery) (bool, error) {
delivery.ID = uuid.New().String()
delivery.CreatedAt = time.Now().UTC()
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO notification_deliveries (id, user_id, provider_id, event_type, task_session_id, created_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT DO NOTHING
`), delivery.ID, delivery.UserID, delivery.ProviderID, delivery.EventType, delivery.TaskSessionID, delivery.CreatedAt)
if err != nil {
return false, err
}
rows, err := result.RowsAffected()
if err != nil {
return false, err
}
return rows > 0, nil
}
func (r *sqliteRepository) DeleteDelivery(ctx context.Context, providerID, eventType, taskSessionID string) error {
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
DELETE FROM notification_deliveries
WHERE provider_id = ? AND event_type = ? AND task_session_id = ?
`), providerID, eventType, taskSessionID)
return err
}
func scanProvider(scanner interface{ Scan(dest ...any) error }) (*models.Provider, error) {
provider := &models.Provider{}
var configJSON string
var enabledInt int
if err := scanner.Scan(
&provider.ID,
&provider.UserID,
&provider.Name,
&provider.Type,
&configJSON,
&enabledInt,
&provider.CreatedAt,
&provider.UpdatedAt,
); err != nil {
return nil, err
}
provider.Enabled = enabledInt == 1
if configJSON != "" && configJSON != "{}" {
if err := json.Unmarshal([]byte(configJSON), &provider.Config); err != nil {
return nil, fmt.Errorf("failed to deserialize provider config: %w", err)
}
} else {
provider.Config = map[string]interface{}{}
}
return provider, nil
}
func scanSubscription(scanner interface{ Scan(dest ...any) error }) (*models.Subscription, error) {
sub := &models.Subscription{}
var enabledInt int
if err := scanner.Scan(
&sub.ID,
&sub.UserID,
&sub.ProviderID,
&sub.EventType,
&enabledInt,
&sub.CreatedAt,
&sub.UpdatedAt,
); err != nil {
return nil, err
}
sub.Enabled = enabledInt == 1
return sub, nil
}