From 6fd847a90971f439f31373767365efc614b2d29f Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 4 Jul 2026 13:10:42 +0700 Subject: [PATCH] =?UTF-8?q?feat(store):=20schedules,=20notification=5Fchan?= =?UTF-8?q?nels,=20domain=20last=5Fcheck=5Fstatus=20+=20=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BE=D0=B4=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/store/db/channels.sql.go | 148 ++++++++++ internal/store/db/domains.sql.go | 40 ++- internal/store/db/models.go | 20 ++ internal/store/db/schedules.sql.go | 110 ++++++++ .../store/migrations/0004_schedule_notify.sql | 24 ++ internal/store/queries/channels.sql | 15 + internal/store/queries/domains.sql | 6 + internal/store/queries/schedules.sql | 15 + internal/store/schedule_test.go | 267 ++++++++++++++++++ internal/store/tenant.go | 174 ++++++++++++ 10 files changed, 814 insertions(+), 5 deletions(-) create mode 100644 internal/store/db/channels.sql.go create mode 100644 internal/store/db/schedules.sql.go create mode 100644 internal/store/migrations/0004_schedule_notify.sql create mode 100644 internal/store/queries/channels.sql create mode 100644 internal/store/queries/schedules.sql create mode 100644 internal/store/schedule_test.go diff --git a/internal/store/db/channels.sql.go b/internal/store/db/channels.sql.go new file mode 100644 index 0000000..f6e6ffa --- /dev/null +++ b/internal/store/db/channels.sql.go @@ -0,0 +1,148 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: channels.sql + +package db + +import ( + "context" + + "github.com/google/uuid" +) + +const createChannel = `-- name: CreateChannel :one +INSERT INTO notification_channels (id, project_id, type, config, secret_enc) +VALUES ($1, $2, $3, $4, $5) RETURNING id, project_id, type, config, secret_enc, enabled, created_at +` + +type CreateChannelParams struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + Type string `json:"type"` + Config []byte `json:"config"` + SecretEnc string `json:"secret_enc"` +} + +func (q *Queries) CreateChannel(ctx context.Context, arg CreateChannelParams) (NotificationChannel, error) { + row := q.db.QueryRow(ctx, createChannel, + arg.ID, + arg.ProjectID, + arg.Type, + arg.Config, + arg.SecretEnc, + ) + var i NotificationChannel + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.Type, + &i.Config, + &i.SecretEnc, + &i.Enabled, + &i.CreatedAt, + ) + return i, err +} + +const deleteChannel = `-- name: DeleteChannel :exec +DELETE FROM notification_channels WHERE id = $1 AND project_id = $2 +` + +type DeleteChannelParams struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` +} + +func (q *Queries) DeleteChannel(ctx context.Context, arg DeleteChannelParams) error { + _, err := q.db.Exec(ctx, deleteChannel, arg.ID, arg.ProjectID) + return err +} + +const getChannel = `-- name: GetChannel :one +SELECT id, project_id, type, config, secret_enc, enabled, created_at FROM notification_channels WHERE id = $1 AND project_id = $2 +` + +type GetChannelParams struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` +} + +func (q *Queries) GetChannel(ctx context.Context, arg GetChannelParams) (NotificationChannel, error) { + row := q.db.QueryRow(ctx, getChannel, arg.ID, arg.ProjectID) + var i NotificationChannel + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.Type, + &i.Config, + &i.SecretEnc, + &i.Enabled, + &i.CreatedAt, + ) + return i, err +} + +const listChannels = `-- name: ListChannels :many +SELECT id, project_id, type, config, secret_enc, enabled, created_at FROM notification_channels WHERE project_id = $1 ORDER BY created_at +` + +func (q *Queries) ListChannels(ctx context.Context, projectID uuid.UUID) ([]NotificationChannel, error) { + rows, err := q.db.Query(ctx, listChannels, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []NotificationChannel + for rows.Next() { + var i NotificationChannel + if err := rows.Scan( + &i.ID, + &i.ProjectID, + &i.Type, + &i.Config, + &i.SecretEnc, + &i.Enabled, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listEnabledChannels = `-- name: ListEnabledChannels :many +SELECT id, project_id, type, config, secret_enc, enabled, created_at FROM notification_channels WHERE project_id = $1 AND enabled ORDER BY created_at +` + +func (q *Queries) ListEnabledChannels(ctx context.Context, projectID uuid.UUID) ([]NotificationChannel, error) { + rows, err := q.db.Query(ctx, listEnabledChannels, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []NotificationChannel + for rows.Next() { + var i NotificationChannel + if err := rows.Scan( + &i.ID, + &i.ProjectID, + &i.Type, + &i.Config, + &i.SecretEnc, + &i.Enabled, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/store/db/domains.sql.go b/internal/store/db/domains.sql.go index 527bd66..dfa3255 100644 --- a/internal/store/db/domains.sql.go +++ b/internal/store/db/domains.sql.go @@ -15,7 +15,7 @@ import ( const createDomain = `-- name: CreateDomain :one INSERT INTO domains (id, project_id, provider_account_id, zone_name, zone_id, template_id) VALUES ($1, $2, $3, $4, $5, $6) -RETURNING id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at +RETURNING id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at, last_check_status ` type CreateDomainParams struct { @@ -45,6 +45,7 @@ func (q *Queries) CreateDomain(ctx context.Context, arg CreateDomainParams) (Dom &i.ZoneID, &i.TemplateID, &i.CreatedAt, + &i.LastCheckStatus, ) return i, err } @@ -64,7 +65,7 @@ func (q *Queries) DeleteDomain(ctx context.Context, arg DeleteDomainParams) erro } const getDomain = `-- name: GetDomain :one -SELECT id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at FROM domains WHERE id = $1 AND project_id = $2 +SELECT id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at, last_check_status FROM domains WHERE id = $1 AND project_id = $2 ` type GetDomainParams struct { @@ -83,15 +84,27 @@ func (q *Queries) GetDomain(ctx context.Context, arg GetDomainParams) (Domain, e &i.ZoneID, &i.TemplateID, &i.CreatedAt, + &i.LastCheckStatus, ) return i, err } +const getDomainStatus = `-- name: GetDomainStatus :one +SELECT last_check_status FROM domains WHERE id = $1 +` + +func (q *Queries) GetDomainStatus(ctx context.Context, id uuid.UUID) (string, error) { + row := q.db.QueryRow(ctx, getDomainStatus, id) + var last_check_status string + err := row.Scan(&last_check_status) + return last_check_status, err +} + const importDomain = `-- name: ImportDomain :one INSERT INTO domains (id, project_id, provider_account_id, zone_name, zone_id, template_id) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (project_id, zone_id) DO NOTHING -RETURNING id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at +RETURNING id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at, last_check_status ` type ImportDomainParams struct { @@ -121,12 +134,13 @@ func (q *Queries) ImportDomain(ctx context.Context, arg ImportDomainParams) (Dom &i.ZoneID, &i.TemplateID, &i.CreatedAt, + &i.LastCheckStatus, ) return i, err } const listDomains = `-- name: ListDomains :many -SELECT id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at FROM domains WHERE project_id = $1 ORDER BY created_at +SELECT id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at, last_check_status FROM domains WHERE project_id = $1 ORDER BY created_at ` func (q *Queries) ListDomains(ctx context.Context, projectID uuid.UUID) ([]Domain, error) { @@ -146,6 +160,7 @@ func (q *Queries) ListDomains(ctx context.Context, projectID uuid.UUID) ([]Domai &i.ZoneID, &i.TemplateID, &i.CreatedAt, + &i.LastCheckStatus, ); err != nil { return nil, err } @@ -189,9 +204,23 @@ func (q *Queries) LoadDomainFull(ctx context.Context, arg LoadDomainFullParams) return i, err } +const setDomainStatus = `-- name: SetDomainStatus :exec +UPDATE domains SET last_check_status = $2 WHERE id = $1 +` + +type SetDomainStatusParams struct { + ID uuid.UUID `json:"id"` + LastCheckStatus string `json:"last_check_status"` +} + +func (q *Queries) SetDomainStatus(ctx context.Context, arg SetDomainStatusParams) error { + _, err := q.db.Exec(ctx, setDomainStatus, arg.ID, arg.LastCheckStatus) + return err +} + const updateDomainTemplate = `-- name: UpdateDomainTemplate :one UPDATE domains SET template_id = $3 WHERE id = $1 AND project_id = $2 -RETURNING id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at +RETURNING id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at, last_check_status ` type UpdateDomainTemplateParams struct { @@ -211,6 +240,7 @@ func (q *Queries) UpdateDomainTemplate(ctx context.Context, arg UpdateDomainTemp &i.ZoneID, &i.TemplateID, &i.CreatedAt, + &i.LastCheckStatus, ) return i, err } diff --git a/internal/store/db/models.go b/internal/store/db/models.go index d541484..0c440e7 100644 --- a/internal/store/db/models.go +++ b/internal/store/db/models.go @@ -25,6 +25,17 @@ type Domain struct { ZoneID string `json:"zone_id"` TemplateID *uuid.UUID `json:"template_id"` CreatedAt pgtype.Timestamptz `json:"created_at"` + LastCheckStatus string `json:"last_check_status"` +} + +type NotificationChannel struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + Type string `json:"type"` + Config []byte `json:"config"` + SecretEnc string `json:"secret_enc"` + Enabled bool `json:"enabled"` + CreatedAt pgtype.Timestamptz `json:"created_at"` } type Project struct { @@ -43,6 +54,15 @@ type ProviderAccount struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type Schedule struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + IntervalSeconds int32 `json:"interval_seconds"` + Enabled bool `json:"enabled"` + LastRunAt pgtype.Timestamptz `json:"last_run_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type Session struct { ID uuid.UUID `json:"id"` UserID uuid.UUID `json:"user_id"` diff --git a/internal/store/db/schedules.sql.go b/internal/store/db/schedules.sql.go new file mode 100644 index 0000000..97b05ce --- /dev/null +++ b/internal/store/db/schedules.sql.go @@ -0,0 +1,110 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: schedules.sql + +package db + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +const getSchedule = `-- name: GetSchedule :one +SELECT id, project_id, interval_seconds, enabled, last_run_at, created_at FROM schedules WHERE project_id = $1 +` + +func (q *Queries) GetSchedule(ctx context.Context, projectID uuid.UUID) (Schedule, error) { + row := q.db.QueryRow(ctx, getSchedule, projectID) + var i Schedule + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.IntervalSeconds, + &i.Enabled, + &i.LastRunAt, + &i.CreatedAt, + ) + return i, err +} + +const listDueSchedules = `-- name: ListDueSchedules :many +SELECT id, project_id, interval_seconds, enabled, last_run_at, created_at FROM schedules +WHERE enabled AND (last_run_at IS NULL OR last_run_at + (interval_seconds || ' seconds')::interval <= $1) +` + +func (q *Queries) ListDueSchedules(ctx context.Context, lastRunAt pgtype.Timestamptz) ([]Schedule, error) { + rows, err := q.db.Query(ctx, listDueSchedules, lastRunAt) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Schedule + for rows.Next() { + var i Schedule + if err := rows.Scan( + &i.ID, + &i.ProjectID, + &i.IntervalSeconds, + &i.Enabled, + &i.LastRunAt, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const touchScheduleRun = `-- name: TouchScheduleRun :exec +UPDATE schedules SET last_run_at = $2 WHERE project_id = $1 +` + +type TouchScheduleRunParams struct { + ProjectID uuid.UUID `json:"project_id"` + LastRunAt pgtype.Timestamptz `json:"last_run_at"` +} + +func (q *Queries) TouchScheduleRun(ctx context.Context, arg TouchScheduleRunParams) error { + _, err := q.db.Exec(ctx, touchScheduleRun, arg.ProjectID, arg.LastRunAt) + return err +} + +const upsertSchedule = `-- name: UpsertSchedule :one +INSERT INTO schedules (id, project_id, interval_seconds, enabled) +VALUES ($1, $2, $3, $4) +ON CONFLICT (project_id) DO UPDATE SET interval_seconds = $3, enabled = $4 +RETURNING id, project_id, interval_seconds, enabled, last_run_at, created_at +` + +type UpsertScheduleParams struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + IntervalSeconds int32 `json:"interval_seconds"` + Enabled bool `json:"enabled"` +} + +func (q *Queries) UpsertSchedule(ctx context.Context, arg UpsertScheduleParams) (Schedule, error) { + row := q.db.QueryRow(ctx, upsertSchedule, + arg.ID, + arg.ProjectID, + arg.IntervalSeconds, + arg.Enabled, + ) + var i Schedule + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.IntervalSeconds, + &i.Enabled, + &i.LastRunAt, + &i.CreatedAt, + ) + return i, err +} diff --git a/internal/store/migrations/0004_schedule_notify.sql b/internal/store/migrations/0004_schedule_notify.sql new file mode 100644 index 0000000..c318dfd --- /dev/null +++ b/internal/store/migrations/0004_schedule_notify.sql @@ -0,0 +1,24 @@ +-- +goose Up +CREATE TABLE schedules ( + id uuid PRIMARY KEY, + project_id uuid NOT NULL UNIQUE REFERENCES projects(id) ON DELETE CASCADE, + interval_seconds int NOT NULL DEFAULT 3600, + enabled boolean NOT NULL DEFAULT false, + last_run_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE TABLE notification_channels ( + id uuid PRIMARY KEY, + project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + type text NOT NULL, + config jsonb NOT NULL, + secret_enc text NOT NULL DEFAULT '', + enabled boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now() +); +ALTER TABLE domains ADD COLUMN last_check_status text NOT NULL DEFAULT 'unknown'; + +-- +goose Down +ALTER TABLE domains DROP COLUMN last_check_status; +DROP TABLE notification_channels; +DROP TABLE schedules; diff --git a/internal/store/queries/channels.sql b/internal/store/queries/channels.sql new file mode 100644 index 0000000..6c2e75d --- /dev/null +++ b/internal/store/queries/channels.sql @@ -0,0 +1,15 @@ +-- name: CreateChannel :one +INSERT INTO notification_channels (id, project_id, type, config, secret_enc) +VALUES ($1, $2, $3, $4, $5) RETURNING *; + +-- name: ListChannels :many +SELECT * FROM notification_channels WHERE project_id = $1 ORDER BY created_at; + +-- name: ListEnabledChannels :many +SELECT * FROM notification_channels WHERE project_id = $1 AND enabled ORDER BY created_at; + +-- name: GetChannel :one +SELECT * FROM notification_channels WHERE id = $1 AND project_id = $2; + +-- name: DeleteChannel :exec +DELETE FROM notification_channels WHERE id = $1 AND project_id = $2; diff --git a/internal/store/queries/domains.sql b/internal/store/queries/domains.sql index 9e16759..72a8392 100644 --- a/internal/store/queries/domains.sql +++ b/internal/store/queries/domains.sql @@ -28,3 +28,9 @@ FROM domains d JOIN provider_accounts a ON a.id = d.provider_account_id LEFT JOIN templates t ON t.id = d.template_id WHERE d.id = $1 AND d.project_id = $2; + +-- name: GetDomainStatus :one +SELECT last_check_status FROM domains WHERE id = $1; + +-- name: SetDomainStatus :exec +UPDATE domains SET last_check_status = $2 WHERE id = $1; diff --git a/internal/store/queries/schedules.sql b/internal/store/queries/schedules.sql new file mode 100644 index 0000000..3075565 --- /dev/null +++ b/internal/store/queries/schedules.sql @@ -0,0 +1,15 @@ +-- name: GetSchedule :one +SELECT * FROM schedules WHERE project_id = $1; + +-- name: UpsertSchedule :one +INSERT INTO schedules (id, project_id, interval_seconds, enabled) +VALUES ($1, $2, $3, $4) +ON CONFLICT (project_id) DO UPDATE SET interval_seconds = $3, enabled = $4 +RETURNING *; + +-- name: ListDueSchedules :many +SELECT * FROM schedules +WHERE enabled AND (last_run_at IS NULL OR last_run_at + (interval_seconds || ' seconds')::interval <= $1); + +-- name: TouchScheduleRun :exec +UPDATE schedules SET last_run_at = $2 WHERE project_id = $1; diff --git a/internal/store/schedule_test.go b/internal/store/schedule_test.go new file mode 100644 index 0000000..1ef49aa --- /dev/null +++ b/internal/store/schedule_test.go @@ -0,0 +1,267 @@ +package store + +import ( + "encoding/json" + "errors" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// TestUpsertSchedule_InsertThenUpdate verifies UpsertSchedule inserts a new +// row for a project on the first call and updates that same row (rather +// than inserting a second one) on a subsequent call, per the +// ON CONFLICT (project_id) DO UPDATE clause. +func TestUpsertSchedule_InsertThenUpdate(t *testing.T) { + s, ctx := newStore(t) + _, p, err := s.RegisterUser(ctx, "sched-upsert@example.com", "argon2-hash") + if err != nil { + t.Fatal(err) + } + + created, err := s.UpsertSchedule(ctx, p.ID, 1800, true) + if err != nil { + t.Fatal(err) + } + if created.IntervalSeconds != 1800 || !created.Enabled { + t.Fatalf("unexpected created schedule: %+v", created) + } + + updated, err := s.UpsertSchedule(ctx, p.ID, 7200, false) + if err != nil { + t.Fatal(err) + } + if updated.ID != created.ID { + t.Fatalf("expected same row id, got created=%s updated=%s", created.ID, updated.ID) + } + if updated.IntervalSeconds != 7200 || updated.Enabled { + t.Fatalf("unexpected updated schedule: %+v", updated) + } + + got, err := s.GetSchedule(ctx, p.ID) + if err != nil { + t.Fatal(err) + } + if got.IntervalSeconds != 7200 || got.Enabled { + t.Fatalf("GetSchedule mismatch after update: %+v", got) + } +} + +// TestGetSchedule_NoRowReturnsErrNoRows verifies the contract used by the API +// layer (Task 5): a project with no schedule row yet returns pgx.ErrNoRows, +// which the API translates into the default {interval:3600, enabled:false}. +func TestGetSchedule_NoRowReturnsErrNoRows(t *testing.T) { + s, ctx := newStore(t) + _, p, err := s.RegisterUser(ctx, "sched-norow@example.com", "argon2-hash") + if err != nil { + t.Fatal(err) + } + + if _, err := s.GetSchedule(ctx, p.ID); !errors.Is(err, pgx.ErrNoRows) { + t.Fatalf("expected pgx.ErrNoRows, got %v", err) + } +} + +// TestListDueSchedules verifies the due-selection logic: an enabled schedule +// that never ran (last_run_at IS NULL) is due; a disabled schedule is never +// due; and an enabled schedule that ran recently with a long interval is not +// yet due. +func TestListDueSchedules(t *testing.T) { + s, ctx := newStore(t) + now := time.Now().UTC() + + _, neverRunProject, err := s.RegisterUser(ctx, "sched-neverrun@example.com", "argon2-hash") + if err != nil { + t.Fatal(err) + } + if _, err := s.UpsertSchedule(ctx, neverRunProject.ID, 3600, true); err != nil { + t.Fatal(err) + } + + _, disabledProject, err := s.RegisterUser(ctx, "sched-disabled@example.com", "argon2-hash") + if err != nil { + t.Fatal(err) + } + if _, err := s.UpsertSchedule(ctx, disabledProject.ID, 60, false); err != nil { + t.Fatal(err) + } + + _, recentProject, err := s.RegisterUser(ctx, "sched-recent@example.com", "argon2-hash") + if err != nil { + t.Fatal(err) + } + if _, err := s.UpsertSchedule(ctx, recentProject.ID, 3600, true); err != nil { + t.Fatal(err) + } + if err := s.TouchScheduleRun(ctx, recentProject.ID, now); err != nil { + t.Fatal(err) + } + + due, err := s.ListDueSchedules(ctx, now) + if err != nil { + t.Fatal(err) + } + + byProject := make(map[uuid.UUID]bool, len(due)) + for _, d := range due { + byProject[d.ProjectID] = true + } + + if !byProject[neverRunProject.ID] { + t.Errorf("expected enabled/never-run schedule for project %s to be due", neverRunProject.ID) + } + if byProject[disabledProject.ID] { + t.Errorf("did not expect disabled schedule for project %s to be due", disabledProject.ID) + } + if byProject[recentProject.ID] { + t.Errorf("did not expect recently-run schedule (long interval) for project %s to be due", recentProject.ID) + } +} + +// TestTouchScheduleRun_SetsLastRunAt verifies TouchScheduleRun persists +// last_run_at, which GetSchedule then returns as a non-nil *time.Time close +// to the value passed in. +func TestTouchScheduleRun_SetsLastRunAt(t *testing.T) { + s, ctx := newStore(t) + _, p, err := s.RegisterUser(ctx, "sched-touch@example.com", "argon2-hash") + if err != nil { + t.Fatal(err) + } + if _, err := s.UpsertSchedule(ctx, p.ID, 3600, true); err != nil { + t.Fatal(err) + } + + at := time.Now().UTC().Truncate(time.Second) + if err := s.TouchScheduleRun(ctx, p.ID, at); err != nil { + t.Fatal(err) + } + + got, err := s.GetSchedule(ctx, p.ID) + if err != nil { + t.Fatal(err) + } + if got.LastRunAt == nil { + t.Fatal("expected non-nil LastRunAt after TouchScheduleRun") + } + if diff := got.LastRunAt.Sub(at); diff < -time.Second || diff > time.Second { + t.Fatalf("expected LastRunAt ~%v, got %v", at, *got.LastRunAt) + } +} + +// TestChannelCRUD_ScopedByProject verifies CreateChannel/ListChannels/ +// GetChannel/DeleteChannel round-trip correctly and that GetChannel scopes +// by project_id: looking up a channel with the wrong project ID must fail +// with pgx.ErrNoRows rather than returning another tenant's channel. +func TestChannelCRUD_ScopedByProject(t *testing.T) { + s, ctx := newStore(t) + _, p1, err := s.RegisterUser(ctx, "chan-owner@example.com", "argon2-hash") + if err != nil { + t.Fatal(err) + } + _, p2, err := s.RegisterUser(ctx, "chan-other@example.com", "argon2-hash") + if err != nil { + t.Fatal(err) + } + + cfg := json.RawMessage(`{"webhook_url":"https://example.com/hook"}`) + ch, err := s.CreateChannel(ctx, p1.ID, "telegram", cfg, "enc-secret") + if err != nil { + t.Fatal(err) + } + // jsonb round-trips through Postgres with its own canonical formatting + // (e.g. a space after ':'), so compare decoded values rather than raw + // bytes. + var gotCfg, wantCfg map[string]string + if err := json.Unmarshal(ch.Config, &gotCfg); err != nil { + t.Fatalf("unmarshal returned config: %v", err) + } + if err := json.Unmarshal(cfg, &wantCfg); err != nil { + t.Fatalf("unmarshal expected config: %v", err) + } + if ch.Type != "telegram" || !ch.Enabled || gotCfg["webhook_url"] != wantCfg["webhook_url"] || ch.SecretEnc != "enc-secret" { + t.Fatalf("unexpected created channel: %+v", ch) + } + + list, err := s.ListChannels(ctx, p1.ID) + if err != nil { + t.Fatal(err) + } + if len(list) != 1 || list[0].ID != ch.ID { + t.Fatalf("unexpected ListChannels result: %+v", list) + } + + enabledList, err := s.ListEnabledChannels(ctx, p1.ID) + if err != nil { + t.Fatal(err) + } + if len(enabledList) != 1 || enabledList[0].ID != ch.ID { + t.Fatalf("unexpected ListEnabledChannels result: %+v", enabledList) + } + + got, err := s.GetChannel(ctx, ch.ID, p1.ID) + if err != nil { + t.Fatal(err) + } + if got.ID != ch.ID { + t.Fatalf("GetChannel mismatch: %+v", got) + } + + if _, err := s.GetChannel(ctx, ch.ID, p2.ID); !errors.Is(err, pgx.ErrNoRows) { + t.Fatalf("expected pgx.ErrNoRows for foreign project, got %v", err) + } + + if err := s.DeleteChannel(ctx, ch.ID, p1.ID); err != nil { + t.Fatal(err) + } + if _, err := s.GetChannel(ctx, ch.ID, p1.ID); !errors.Is(err, pgx.ErrNoRows) { + t.Fatalf("expected pgx.ErrNoRows after delete, got %v", err) + } +} + +// TestDomainStatus_RoundTrip verifies SetDomainStatus/GetDomainStatus +// round-trip, and that a freshly-imported domain defaults to "unknown" per +// the migration's DEFAULT 'unknown'. +func TestDomainStatus_RoundTrip(t *testing.T) { + s, ctx := newStore(t) + _, p, err := s.RegisterUser(ctx, "domain-status@example.com", "argon2-hash") + if err != nil { + t.Fatal(err) + } + acc, err := s.CreateAccount(ctx, p.ID, "selectel", "enc-blob", "test") + if err != nil { + t.Fatal(err) + } + d, err := s.CreateDomain(ctx, p.ID, acc.ID, "example.com", "zone-1", nil) + if err != nil { + t.Fatal(err) + } + + status, err := s.GetDomainStatus(ctx, d.ID) + if err != nil { + t.Fatal(err) + } + if status != "unknown" { + t.Fatalf("expected default status 'unknown', got %q", status) + } + + if err := s.SetDomainStatus(ctx, d.ID, "ok"); err != nil { + t.Fatal(err) + } + status, err = s.GetDomainStatus(ctx, d.ID) + if err != nil { + t.Fatal(err) + } + if status != "ok" { + t.Fatalf("expected status 'ok' after SetDomainStatus, got %q", status) + } + + domains, err := s.ListDomains(ctx, p.ID) + if err != nil { + t.Fatal(err) + } + if len(domains) != 1 || domains[0].LastCheckStatus != "ok" { + t.Fatalf("expected ListDomains to reflect updated status: %+v", domains) + } +} diff --git a/internal/store/tenant.go b/internal/store/tenant.go index fccc469..60c15eb 100644 --- a/internal/store/tenant.go +++ b/internal/store/tenant.go @@ -2,6 +2,7 @@ package store import ( "context" + "encoding/json" "errors" "time" @@ -137,12 +138,14 @@ type Domain struct { ZoneName string ZoneID string TemplateID *uuid.UUID + LastCheckStatus string } func domainFromDB(d db.Domain) Domain { return Domain{ ID: d.ID, ProjectID: d.ProjectID, ProviderAccountID: d.ProviderAccountID, ZoneName: d.ZoneName, ZoneID: d.ZoneID, TemplateID: d.TemplateID, + LastCheckStatus: d.LastCheckStatus, } } @@ -231,6 +234,19 @@ func (s *Store) SetDomainTemplate(ctx context.Context, domainID, projectID uuid. return domainFromDB(d), nil } +// GetDomainStatus returns the last known check status for a domain (Фаза 3 +// scheduler/checker). Callers scope access to the domain themselves (e.g. +// via a prior GetDomain) — this lookup is by primary key alone. +func (s *Store) GetDomainStatus(ctx context.Context, domainID uuid.UUID) (string, error) { + return s.q.GetDomainStatus(ctx, domainID) +} + +// SetDomainStatus records the outcome of the most recent check/apply run for +// a domain (e.g. "ok", "drift", "error"). +func (s *Store) SetDomainStatus(ctx context.Context, domainID uuid.UUID, status string) error { + return s.q.SetDomainStatus(ctx, db.SetDomainStatusParams{ID: domainID, LastCheckStatus: status}) +} + // User and Project are provider-neutral domain structs for the auth/tenant // layer (Фаза 2), mirroring the Account/Template/Domain wrappers above so // callers never need to import internal/store/db directly. @@ -369,3 +385,161 @@ func (s *Store) RegisterUser(ctx context.Context, email, passwordHash string) (U } return toUser(dbu), toProject(dbp), nil } + +// Schedule and Channel are provider-neutral domain structs for the +// scheduler/notifications layer (Фаза 3), mirroring the wrappers above so +// callers never need to import internal/store/db or pgtype directly. + +type Schedule struct { + ID uuid.UUID + ProjectID uuid.UUID + IntervalSeconds int32 + Enabled bool + LastRunAt *time.Time +} + +// timeFromTimestamptz converts a nullable pgtype.Timestamptz (schedules.last_run_at) +// into a *time.Time, nil when the column is NULL (schedule never ran). +func timeFromTimestamptz(t pgtype.Timestamptz) *time.Time { + if !t.Valid { + return nil + } + tt := t.Time + return &tt +} + +// timestamptzFromTime is the inverse of timeFromTimestamptz, used to pass a +// Go time.Time (or nil) into a nullable timestamptz query parameter. +func timestamptzFromTime(t *time.Time) pgtype.Timestamptz { + if t == nil { + return pgtype.Timestamptz{} + } + return pgtype.Timestamptz{Time: *t, Valid: true} +} + +func scheduleFromDB(s db.Schedule) Schedule { + return Schedule{ + ID: s.ID, + ProjectID: s.ProjectID, + IntervalSeconds: s.IntervalSeconds, + Enabled: s.Enabled, + LastRunAt: timeFromTimestamptz(s.LastRunAt), + } +} + +// GetSchedule looks up the schedule row for projectID. When no schedule has +// ever been created for the project it returns pgx.ErrNoRows unwrapped — +// the API layer (Task 5) is expected to treat that as the default schedule +// {interval: 3600, enabled: false} rather than an error. +func (s *Store) GetSchedule(ctx context.Context, projectID uuid.UUID) (Schedule, error) { + sc, err := s.q.GetSchedule(ctx, projectID) + if err != nil { + return Schedule{}, err + } + return scheduleFromDB(sc), nil +} + +// UpsertSchedule creates or updates the (single, UNIQUE) schedule row for a +// project: an existing row has its interval/enabled flag updated in place +// rather than a second row being inserted. +func (s *Store) UpsertSchedule(ctx context.Context, projectID uuid.UUID, interval int32, enabled bool) (Schedule, error) { + sc, err := s.q.UpsertSchedule(ctx, db.UpsertScheduleParams{ + ID: uuid.New(), ProjectID: projectID, IntervalSeconds: interval, Enabled: enabled, + }) + if err != nil { + return Schedule{}, err + } + return scheduleFromDB(sc), nil +} + +// ListDueSchedules returns every enabled schedule that is due to run at +// `now`: either it has never run (last_run_at IS NULL) or its interval has +// elapsed since the last run. +func (s *Store) ListDueSchedules(ctx context.Context, now time.Time) ([]Schedule, error) { + rows, err := s.q.ListDueSchedules(ctx, pgtype.Timestamptz{Time: now, Valid: true}) + if err != nil { + return nil, err + } + out := make([]Schedule, 0, len(rows)) + for _, r := range rows { + out = append(out, scheduleFromDB(r)) + } + return out, nil +} + +// TouchScheduleRun records that a project's schedule ran at `at`, so the +// next ListDueSchedules call excludes it until the interval elapses again. +func (s *Store) TouchScheduleRun(ctx context.Context, projectID uuid.UUID, at time.Time) error { + return s.q.TouchScheduleRun(ctx, db.TouchScheduleRunParams{ + ProjectID: projectID, LastRunAt: timestamptzFromTime(&at), + }) +} + +type Channel struct { + ID uuid.UUID + ProjectID uuid.UUID + Type string + Config json.RawMessage + SecretEnc string + Enabled bool +} + +// channelFromDB never logs SecretEnc — callers must not either (secrets are +// encrypted at rest, but the plaintext blob should still stay out of logs). +func channelFromDB(c db.NotificationChannel) Channel { + return Channel{ + ID: c.ID, ProjectID: c.ProjectID, Type: c.Type, + Config: json.RawMessage(c.Config), SecretEnc: c.SecretEnc, Enabled: c.Enabled, + } +} + +func (s *Store) CreateChannel(ctx context.Context, projectID uuid.UUID, ctype string, config json.RawMessage, secretEnc string) (Channel, error) { + c, err := s.q.CreateChannel(ctx, db.CreateChannelParams{ + ID: uuid.New(), ProjectID: projectID, Type: ctype, Config: []byte(config), SecretEnc: secretEnc, + }) + if err != nil { + return Channel{}, err + } + return channelFromDB(c), nil +} + +func (s *Store) ListChannels(ctx context.Context, projectID uuid.UUID) ([]Channel, error) { + rows, err := s.q.ListChannels(ctx, projectID) + if err != nil { + return nil, err + } + out := make([]Channel, 0, len(rows)) + for _, r := range rows { + out = append(out, channelFromDB(r)) + } + return out, nil +} + +// ListEnabledChannels returns only the channels a project has enabled — used +// by the notification dispatcher (Фаза 3) so disabled channels are silently +// skipped rather than filtered by every caller. +func (s *Store) ListEnabledChannels(ctx context.Context, projectID uuid.UUID) ([]Channel, error) { + rows, err := s.q.ListEnabledChannels(ctx, projectID) + if err != nil { + return nil, err + } + out := make([]Channel, 0, len(rows)) + for _, r := range rows { + out = append(out, channelFromDB(r)) + } + return out, nil +} + +// GetChannel is scoped by projectID: a channel ID belonging to another +// tenant's project returns pgx.ErrNoRows rather than the foreign channel. +func (s *Store) GetChannel(ctx context.Context, id, projectID uuid.UUID) (Channel, error) { + c, err := s.q.GetChannel(ctx, db.GetChannelParams{ID: id, ProjectID: projectID}) + if err != nil { + return Channel{}, err + } + return channelFromDB(c), nil +} + +func (s *Store) DeleteChannel(ctx context.Context, id, projectID uuid.UUID) error { + return s.q.DeleteChannel(ctx, db.DeleteChannelParams{ID: id, ProjectID: projectID}) +}