feat(store): schedules, notification_channels, domain last_check_status + методы

This commit is contained in:
2026-07-04 13:10:42 +07:00
parent 1cdb32b747
commit 6fd847a909
10 changed files with 814 additions and 5 deletions
+148
View File
@@ -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
}
+35 -5
View File
@@ -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
}
+20
View File
@@ -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"`
+110
View File
@@ -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
}
@@ -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;
+15
View File
@@ -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;
+6
View File
@@ -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;
+15
View File
@@ -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;
+267
View File
@@ -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)
}
}
+174
View File
@@ -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})
}