diff --git a/go.mod b/go.mod index d12c39f3..e78b76d2 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( google.golang.org/grpc v1.53.0 gopkg.in/DataDog/dd-trace-go.v1 v1.47.0 gopkg.in/macaroon.v2 v2.1.0 - gorm.io/gorm v1.25.0 + gorm.io/gorm v1.25.4 ) require ( @@ -55,6 +55,7 @@ require ( github.com/fergusstrange/embedded-postgres v1.19.0 // indirect github.com/glebarez/go-sqlite v1.20.3 // indirect github.com/go-errors/errors v1.4.2 // indirect + github.com/go-gormigrate/gormigrate/v2 v2.1.1 // indirect github.com/go-logr/logr v1.2.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-macaroon-bakery/macaroonpb v1.0.0 // indirect diff --git a/go.sum b/go.sum index d7dc6030..b7f055fe 100644 --- a/go.sum +++ b/go.sum @@ -252,6 +252,8 @@ github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3Bop github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gormigrate/gormigrate/v2 v2.1.1 h1:eGS0WTFRV30r103lU8JNXY27KbviRnqqIDobW3EV3iY= +github.com/go-gormigrate/gormigrate/v2 v2.1.1/go.mod h1:L7nJ620PFDKei9QOhJzqA8kRCk+E3UbV2f5gv+1ndLc= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= @@ -1319,6 +1321,8 @@ gorm.io/driver/sqlserver v1.0.4 h1:V15fszi0XAo7fbx3/cF50ngshDSN4QT0MXpWTylyPTY= gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA= gorm.io/gorm v1.25.0 h1:+KtYtb2roDz14EQe4bla8CbQlmb9dN3VejSai3lprfU= gorm.io/gorm v1.25.0/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= +gorm.io/gorm v1.25.4 h1:iyNd8fNAe8W9dvtlgeRI5zSVZPsq3OpcTu37cYcpCmw= +gorm.io/gorm v1.25.4/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/handle_payment_request.go b/handle_payment_request.go index cbd6aa94..5323f282 100644 --- a/handle_payment_request.go +++ b/handle_payment_request.go @@ -102,7 +102,7 @@ func (svc *Service) HandlePayInvoiceEvent(ctx context.Context, request *Nip47Req }, }, ss) } - payment.Preimage = preimage + payment.Preimage = &preimage nostrEvent.State = NOSTR_EVENT_STATE_HANDLER_EXECUTED svc.db.Save(&nostrEvent) svc.db.Save(&payment) diff --git a/main.go b/main.go index 305b605b..de7addd0 100644 --- a/main.go +++ b/main.go @@ -12,6 +12,7 @@ import ( "time" echologrus "github.com/davrux/echo-logrus/v4" + "github.com/getAlby/nostr-wallet-connect/migrations" "github.com/glebarez/sqlite" "github.com/joho/godotenv" "github.com/kelseyhightower/envconfig" @@ -79,11 +80,7 @@ func main() { sqlDb.SetMaxIdleConns(cfg.DatabaseMaxIdleConns) sqlDb.SetConnMaxLifetime(time.Duration(cfg.DatabaseConnMaxLifetime) * time.Second) - // Migrate the schema - err = db.AutoMigrate(&User{}, &App{}, &AppPermission{}, &NostrEvent{}, &Payment{}, &Identity{}) - if err != nil { - log.Fatalf("Failed migrate DB %v", err) - } + migrations.Migrate(db) if cfg.NostrSecretKey == "" { if cfg.LNBackendType == AlbyBackendType { diff --git a/migrations/202309271616.go b/migrations/202309271616.go new file mode 100644 index 00000000..c6476c0c --- /dev/null +++ b/migrations/202309271616.go @@ -0,0 +1,45 @@ +package migrations + +import ( + _ "embed" + "log" + + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +//go:embed initial_migration_postgres.sql +var initialMigrationPostgres string +//go:embed initial_migration_sqlite.sql +var initialMigrationSqlite string + +// Initial migration +var _202309271616 = &gormigrate.Migration { + ID: "202309271616", + Migrate: func(tx *gorm.DB) error { + + // only execute migration if apps table doesn't exist + err := tx.Exec("Select * from apps").Error; + if err != nil { + // find which initial migration should be executed + var initialMigration string + if tx.Dialector.Name() == "postgres" { + initialMigration = initialMigrationPostgres + } else if tx.Dialector.Name() == "sqlite" { + initialMigration = initialMigrationSqlite + } else { + log.Fatalf("unsupported database type: %s", tx.Dialector.Name()) + } + + err := tx.Exec(initialMigration).Error + if err != nil { + return err + } + } + + return nil + }, + Rollback: func(tx *gorm.DB) error { + return nil; + }, +} \ No newline at end of file diff --git a/migrations/202309271617.go b/migrations/202309271617.go new file mode 100644 index 00000000..358f0981 --- /dev/null +++ b/migrations/202309271617.go @@ -0,0 +1,22 @@ +package migrations + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +// Update payments with preimage as an empty string to use NULL instead +var _202309271617 = &gormigrate.Migration { + ID: "202309271617", + Migrate: func(tx *gorm.DB) error { + err := tx.Table("payments").Where("preimage = ?", "").Update("preimage", nil).Error; + + if err != nil { + return err + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + return nil; + }, +} \ No newline at end of file diff --git a/migrations/202309271618.go b/migrations/202309271618.go new file mode 100644 index 00000000..90d93f7f --- /dev/null +++ b/migrations/202309271618.go @@ -0,0 +1,34 @@ +package migrations + +import ( + "log" + + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +// Create a composite index to improve performance of summing payments in the current budget period +var _202309271618 = &gormigrate.Migration { + ID: "202309271618", + Migrate: func(tx *gorm.DB) error { + + var sql string + if tx.Dialector.Name() == "postgres" { + sql = "CREATE INDEX idx_payment_sum ON payments USING btree (app_id, preimage, created_at) INCLUDE(amount)" + } else if tx.Dialector.Name() == "sqlite" { + sql = "CREATE INDEX idx_payment_sum ON payments (app_id, preimage, created_at)" + } else { + log.Fatalf("unsupported database type: %s", tx.Dialector.Name()) + } + + err := tx.Exec(sql).Error + if err != nil { + return err + } + + return nil + }, + Rollback: func(tx *gorm.DB) error { + return nil; + }, +} \ No newline at end of file diff --git a/migrations/README.md b/migrations/README.md new file mode 100644 index 00000000..4c3f2c1c --- /dev/null +++ b/migrations/README.md @@ -0,0 +1,33 @@ +# Creating a new migration + +1. Create a new file based on the current date and time (see existing migration format) +2. Copy the following code and update MY_ID_HERE and MY_COMMENT_HERE and DO_SOMETHING_HERE +3. Add the ID to the list of migrations in migrate.go +4. If possible, add a rollback function. + +*For Postgres/Sqlite specific migrations, see the [initial migration](202309271616.go)* + +```go +package migrations + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +// MY_COMMENT_HERE +var _MY_ID_HERE = &gormigrate.Migration { + ID: "MY_ID_HERE", + Migrate: func(tx *gorm.DB) error { + err := DO_SOMETHING_HERE.Error; + + if err != nil { + return err + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + return nil; + }, +} +``` diff --git a/migrations/initial_migration_postgres.sql b/migrations/initial_migration_postgres.sql new file mode 100644 index 00000000..13c681dc --- /dev/null +++ b/migrations/initial_migration_postgres.sql @@ -0,0 +1,182 @@ +CREATE TABLE app_permissions ( + id bigint NOT NULL, + app_id bigint, + request_method text, + max_amount bigint, + budget_renewal text, + expires_at timestamp with time zone, + created_at timestamp with time zone, + updated_at timestamp with time zone +); + +CREATE SEQUENCE app_permissions_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE app_permissions_id_seq OWNED BY app_permissions.id; + +CREATE TABLE apps ( + id bigint NOT NULL, + user_id bigint, + name text, + description text, + nostr_pubkey text, + created_at timestamp with time zone, + updated_at timestamp with time zone +); + +CREATE SEQUENCE apps_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE apps_id_seq OWNED BY apps.id; + +CREATE TABLE identities ( + id bigint NOT NULL, + created_at timestamp with time zone, + updated_at timestamp with time zone, + deleted_at timestamp with time zone, + privkey text +); + +CREATE SEQUENCE identities_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE identities_id_seq OWNED BY identities.id; + +CREATE TABLE nostr_events ( + id bigint NOT NULL, + app_id bigint, + nostr_id text, + reply_id text, + content text, + state text, + replied_at timestamp with time zone, + created_at timestamp with time zone, + updated_at timestamp with time zone +); + +CREATE SEQUENCE nostr_events_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE nostr_events_id_seq OWNED BY nostr_events.id; + +CREATE TABLE payments ( + id bigint NOT NULL, + app_id bigint, + nostr_event_id bigint, + amount bigint, + payment_request text, + preimage text, + created_at timestamp with time zone, + updated_at timestamp with time zone +); + +CREATE SEQUENCE payments_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE payments_id_seq OWNED BY payments.id; + +CREATE TABLE users ( + id bigint NOT NULL, + alby_identifier text, + access_token text, + refresh_token text, + email text, + expiry timestamp with time zone, + lightning_address text, + created_at timestamp with time zone, + updated_at timestamp with time zone +); + +CREATE SEQUENCE users_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE users_id_seq OWNED BY users.id; + +ALTER TABLE ONLY app_permissions ALTER COLUMN id SET DEFAULT nextval('app_permissions_id_seq'::regclass); + +ALTER TABLE ONLY apps ALTER COLUMN id SET DEFAULT nextval('apps_id_seq'::regclass); + +ALTER TABLE ONLY identities ALTER COLUMN id SET DEFAULT nextval('identities_id_seq'::regclass); + +ALTER TABLE ONLY nostr_events ALTER COLUMN id SET DEFAULT nextval('nostr_events_id_seq'::regclass); + +ALTER TABLE ONLY payments ALTER COLUMN id SET DEFAULT nextval('payments_id_seq'::regclass); + +ALTER TABLE ONLY users ALTER COLUMN id SET DEFAULT nextval('users_id_seq'::regclass); + +ALTER TABLE ONLY app_permissions + ADD CONSTRAINT app_permissions_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY apps + ADD CONSTRAINT apps_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY identities + ADD CONSTRAINT identities_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY nostr_events + ADD CONSTRAINT nostr_events_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY payments + ADD CONSTRAINT payments_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY users + ADD CONSTRAINT users_pkey PRIMARY KEY (id); + +CREATE INDEX idx_app_permissions_app_id ON app_permissions USING btree (app_id); + +CREATE INDEX idx_app_permissions_request_method ON app_permissions USING btree (request_method); + +CREATE INDEX idx_apps_nostr_pubkey ON apps USING btree (nostr_pubkey); + +CREATE INDEX idx_apps_user_id ON apps USING btree (user_id); + +CREATE INDEX idx_identities_deleted_at ON identities USING btree (deleted_at); + +CREATE INDEX idx_nostr_events_app_id ON nostr_events USING btree (app_id); + +CREATE UNIQUE INDEX idx_nostr_events_nostr_id ON nostr_events USING btree (nostr_id); + +CREATE INDEX idx_payments_app_id ON payments USING btree (app_id); + +CREATE INDEX idx_payments_nostr_event_id ON payments USING btree (nostr_event_id); + +CREATE UNIQUE INDEX idx_users_alby_identifier ON users USING btree (alby_identifier); + +ALTER TABLE ONLY app_permissions + ADD CONSTRAINT fk_app_permissions_app FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE; + +ALTER TABLE ONLY nostr_events + ADD CONSTRAINT fk_nostr_events_app FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE; + +ALTER TABLE ONLY payments + ADD CONSTRAINT fk_payments_app FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE; + +ALTER TABLE ONLY payments + ADD CONSTRAINT fk_payments_nostr_event FOREIGN KEY (nostr_event_id) REFERENCES nostr_events(id); + +ALTER TABLE ONLY apps + ADD CONSTRAINT fk_users_apps FOREIGN KEY (user_id) REFERENCES users(id); \ No newline at end of file diff --git a/migrations/initial_migration_sqlite.sql b/migrations/initial_migration_sqlite.sql new file mode 100644 index 00000000..458caa5b --- /dev/null +++ b/migrations/initial_migration_sqlite.sql @@ -0,0 +1,16 @@ +CREATE TABLE `apps` (`id` integer,`user_id` integer,`name` text,`description` text,`nostr_pubkey` text,`created_at` datetime,`updated_at` datetime,PRIMARY KEY (`id`),CONSTRAINT `fk_users_apps` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`)); +CREATE INDEX `idx_apps_nostr_pubkey` ON `apps`(`nostr_pubkey`); +CREATE INDEX `idx_apps_user_id` ON `apps`(`user_id`); +CREATE TABLE `app_permissions` (`id` integer,`app_id` integer,`request_method` text,`max_amount` integer,`budget_renewal` text,`expires_at` datetime,`created_at` datetime,`updated_at` datetime,PRIMARY KEY (`id`),CONSTRAINT `fk_app_permissions_app` FOREIGN KEY (`app_id`) REFERENCES `apps`(`id`) ON DELETE CASCADE); +CREATE INDEX `idx_app_permissions_request_method` ON `app_permissions`(`request_method`); +CREATE INDEX `idx_app_permissions_app_id` ON `app_permissions`(`app_id`); +CREATE TABLE `payments` (`id` integer,`app_id` integer,`nostr_event_id` integer,`amount` integer,`payment_request` text,`preimage` text,`created_at` datetime,`updated_at` datetime, `preimage2` text,PRIMARY KEY (`id`),CONSTRAINT `fk_payments_app` FOREIGN KEY (`app_id`) REFERENCES `apps`(`id`) ON DELETE CASCADE,CONSTRAINT `fk_payments_nostr_event` FOREIGN KEY (`nostr_event_id`) REFERENCES `nostr_events`(`id`)); +CREATE INDEX `idx_payments_nostr_event_id` ON `payments`(`nostr_event_id`); +CREATE INDEX `idx_payments_app_id` ON `payments`(`app_id`); +CREATE TABLE `identities` (`id` integer,`created_at` datetime,`updated_at` datetime,`deleted_at` datetime,`privkey` text,PRIMARY KEY (`id`)); +CREATE INDEX `idx_identities_deleted_at` ON `identities`(`deleted_at`); +CREATE TABLE IF NOT EXISTS "users" (`id` integer,`alby_identifier` text UNIQUE,`access_token` text,`refresh_token` text,`email` text,`expiry` datetime,`lightning_address` text,`created_at` datetime,`updated_at` datetime,PRIMARY KEY (`id`)); +CREATE UNIQUE INDEX `idx_users_alby_identifier` ON `users`(`alby_identifier`); +CREATE TABLE IF NOT EXISTS "nostr_events" (`id` integer,`app_id` integer,`nostr_id` text UNIQUE,`reply_id` text,`content` text,`state` text,`replied_at` datetime,`created_at` datetime,`updated_at` datetime,PRIMARY KEY (`id`),CONSTRAINT `fk_nostr_events_app` FOREIGN KEY (`app_id`) REFERENCES `apps`(`id`) ON DELETE CASCADE); +CREATE UNIQUE INDEX `idx_nostr_events_nostr_id` ON `nostr_events`(`nostr_id`); +CREATE INDEX `idx_nostr_events_app_id` ON `nostr_events`(`app_id`); diff --git a/migrations/migrate.go b/migrations/migrate.go new file mode 100644 index 00000000..87d2c556 --- /dev/null +++ b/migrations/migrate.go @@ -0,0 +1,22 @@ +package migrations + +import ( + "github.com/go-gormigrate/gormigrate/v2" + log "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +func Migrate(db *gorm.DB) { + + m := gormigrate.New(db, gormigrate.DefaultOptions, []*gormigrate.Migration{ + _202309271616, + _202309271617, + _202309271618, + }) + + err := m.Migrate() + if err != nil { + log.Fatalf("Migration failed: %v", err) + } + log.Println("Any pending migrations ran successfully") +} \ No newline at end of file diff --git a/models.go b/models.go index 620232d9..e4b54315 100644 --- a/models.go +++ b/models.go @@ -49,8 +49,8 @@ type AlbyMe struct { } type User struct { - ID uint `gorm:"primaryKey"` - AlbyIdentifier string `gorm:"uniqueIndex" validate:"required"` + ID uint + AlbyIdentifier string `validate:"required"` AccessToken string `validate:"required"` RefreshToken string `validate:"required"` Email string @@ -62,21 +62,21 @@ type User struct { } type App struct { - ID uint `gorm:"primaryKey"` - UserId uint `gorm:"index" validate:"required"` - User User `gorm:"constraint:OnDelete:CASCADE"` + ID uint + UserId uint `validate:"required"` + User User Name string `validate:"required"` Description string - NostrPubkey string `gorm:"index"` + NostrPubkey string `validate:"required"` CreatedAt time.Time UpdatedAt time.Time } type AppPermission struct { - ID uint `gorm:"primaryKey"` - AppId uint `gorm:"index" validate:"required"` - App App `gorm:"constraint:OnDelete:CASCADE"` - RequestMethod string `gorm:"index" validate:"required"` + ID uint + AppId uint `validate:"required"` + App App + RequestMethod string `validate:"required"` MaxAmount int BudgetRenewal string ExpiresAt time.Time @@ -85,10 +85,10 @@ type AppPermission struct { } type NostrEvent struct { - ID uint `gorm:"primaryKey"` - AppId uint `gorm:"index" validate:"required"` - App App `gorm:"constraint:OnDelete:CASCADE"` - NostrId string `gorm:"uniqueIndex" validate:"required"` + ID uint + AppId uint `validate:"required"` + App App + NostrId string `validate:"required"` ReplyId string Content string State string @@ -98,14 +98,14 @@ type NostrEvent struct { } type Payment struct { - ID uint `gorm:"primaryKey"` - AppId uint `gorm:"index" validate:"required"` - App App `gorm:"constraint:OnDelete:CASCADE"` - NostrEventId uint `gorm:"index" validate:"required"` + ID uint + AppId uint `validate:"required"` + App App + NostrEventId uint `validate:"required"` NostrEvent NostrEvent Amount uint PaymentRequest string - Preimage string + Preimage *string CreatedAt time.Time UpdatedAt time.Time }