feat(storage): add audit persistence
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
ZARINHUB_ACCESS_TOKEN=
|
ZARINHUB_ACCESS_TOKEN=
|
||||||
ZARINHUB_ENDPOINT=https://zarin-hub.com/api/v5/KYC/CardToIban
|
ZARINHUB_ENDPOINT=https://zarin-hub.com/api/v5/KYC/CardToIban
|
||||||
ZARINHUB_TIMEOUT=5s
|
ZARINHUB_TIMEOUT=5s
|
||||||
|
|
||||||
|
DATABASE_PATH=./card-to-iban.db
|
||||||
|
|||||||
@@ -1,3 +1,17 @@
|
|||||||
module card-to-iban
|
module card-to-iban
|
||||||
|
|
||||||
go 1.27.1
|
go 1.27.1
|
||||||
|
|
||||||
|
require modernc.org/sqlite v1.59.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
|
modernc.org/libc v1.75.7 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.12.1 // indirect
|
||||||
|
)
|
||||||
|
|||||||
50
backend/go.sum
Normal file
50
backend/go.sum
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||||
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
|
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||||
|
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||||
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||||
|
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||||
|
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
|
||||||
|
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
|
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
|
||||||
|
modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=
|
||||||
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
|
||||||
|
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew=
|
||||||
|
modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
|
||||||
|
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||||
|
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A=
|
||||||
|
modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
26
backend/internal/audit/record.go
Normal file
26
backend/internal/audit/record.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Status string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusSuccess Status = "success"
|
||||||
|
StatusValidationFailed Status = "validation_failed"
|
||||||
|
StatusAuthenticationError Status = "authentication_error"
|
||||||
|
StatusUpstreamRejected Status = "upstream_rejected"
|
||||||
|
StatusUpstreamTimeout Status = "upstream_timeout"
|
||||||
|
StatusUpstreamUnavailable Status = "upstream_unavailable"
|
||||||
|
StatusInternalError Status = "internal_error"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Record struct {
|
||||||
|
RequestID string
|
||||||
|
CreatedAt time.Time
|
||||||
|
Duration time.Duration
|
||||||
|
ClientIP *string
|
||||||
|
Status Status
|
||||||
|
UpstreamHTTPStatus *int
|
||||||
|
MaskedCard string
|
||||||
|
ResponseJSON []byte
|
||||||
|
}
|
||||||
80
backend/internal/repository/audit_repository.go
Normal file
80
backend/internal/repository/audit_repository.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"card-to-iban/internal/audit"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuditRepository struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuditRepository(
|
||||||
|
db *sql.DB,
|
||||||
|
) *AuditRepository {
|
||||||
|
return &AuditRepository{
|
||||||
|
db: db,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AuditRepository) Save(
|
||||||
|
ctx context.Context,
|
||||||
|
record audit.Record,
|
||||||
|
) error {
|
||||||
|
var clientIP any
|
||||||
|
|
||||||
|
if record.ClientIP != nil {
|
||||||
|
clientIP = *record.ClientIP
|
||||||
|
}
|
||||||
|
|
||||||
|
var upstreamHTTPStatus any
|
||||||
|
|
||||||
|
if record.UpstreamHTTPStatus != nil {
|
||||||
|
upstreamHTTPStatus = *record.UpstreamHTTPStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
var responseJSON any
|
||||||
|
|
||||||
|
if len(record.ResponseJSON) > 0 {
|
||||||
|
responseJSON = string(record.ResponseJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
INSERT INTO audit_records (
|
||||||
|
request_id,
|
||||||
|
created_at,
|
||||||
|
duration_ms,
|
||||||
|
client_ip,
|
||||||
|
final_status,
|
||||||
|
upstream_http_status,
|
||||||
|
masked_card,
|
||||||
|
response_json
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err := r.db.ExecContext(
|
||||||
|
ctx,
|
||||||
|
query,
|
||||||
|
record.RequestID,
|
||||||
|
record.CreatedAt.UTC().Format("2006-01-02T15:04:05.000000000Z07:00"),
|
||||||
|
record.Duration.Milliseconds(),
|
||||||
|
clientIP,
|
||||||
|
string(record.Status),
|
||||||
|
upstreamHTTPStatus,
|
||||||
|
record.MaskedCard,
|
||||||
|
responseJSON,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"insert audit record: %w",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
183
backend/internal/repository/audit_repository_test.go
Normal file
183
backend/internal/repository/audit_repository_test.go
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"card-to-iban/internal/audit"
|
||||||
|
"card-to-iban/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAuditRepositorySave(t *testing.T) {
|
||||||
|
databasePath := filepath.Join(
|
||||||
|
t.TempDir(),
|
||||||
|
"test.db",
|
||||||
|
)
|
||||||
|
|
||||||
|
db, err := storage.OpenSQLite(databasePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf(
|
||||||
|
"OpenSQLite() error = %v",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := storage.Migrate(ctx, db); err != nil {
|
||||||
|
t.Fatalf(
|
||||||
|
"Migrate() error = %v",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := storage.Migrate(ctx, db); err != nil {
|
||||||
|
t.Fatalf(
|
||||||
|
"second Migrate() error = %v",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := NewAuditRepository(db)
|
||||||
|
|
||||||
|
clientIP := "127.0.0.1"
|
||||||
|
upstreamStatus := 200
|
||||||
|
|
||||||
|
record := audit.Record{
|
||||||
|
RequestID: "request-test-123",
|
||||||
|
|
||||||
|
CreatedAt: time.Date(
|
||||||
|
2026,
|
||||||
|
time.September,
|
||||||
|
21,
|
||||||
|
8,
|
||||||
|
30,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
time.UTC,
|
||||||
|
),
|
||||||
|
|
||||||
|
Duration: 150 * time.Millisecond,
|
||||||
|
|
||||||
|
ClientIP: &clientIP,
|
||||||
|
|
||||||
|
Status: audit.StatusSuccess,
|
||||||
|
|
||||||
|
UpstreamHTTPStatus: &upstreamStatus,
|
||||||
|
|
||||||
|
MaskedCard: "603799******5678",
|
||||||
|
|
||||||
|
ResponseJSON: []byte(
|
||||||
|
`{"data":{"iban":"IR820540102680020817909002"}}`,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.Save(ctx, record); err != nil {
|
||||||
|
t.Fatalf(
|
||||||
|
"Save() error = %v",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
requestID string
|
||||||
|
durationMS int64
|
||||||
|
storedClientIP string
|
||||||
|
finalStatus string
|
||||||
|
storedHTTPStatus int
|
||||||
|
maskedCard string
|
||||||
|
storedResponseJSON string
|
||||||
|
)
|
||||||
|
|
||||||
|
err = db.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
request_id,
|
||||||
|
duration_ms,
|
||||||
|
client_ip,
|
||||||
|
final_status,
|
||||||
|
upstream_http_status,
|
||||||
|
masked_card,
|
||||||
|
response_json
|
||||||
|
FROM audit_records
|
||||||
|
WHERE request_id = ?
|
||||||
|
`,
|
||||||
|
record.RequestID,
|
||||||
|
).Scan(
|
||||||
|
&requestID,
|
||||||
|
&durationMS,
|
||||||
|
&storedClientIP,
|
||||||
|
&finalStatus,
|
||||||
|
&storedHTTPStatus,
|
||||||
|
&maskedCard,
|
||||||
|
&storedResponseJSON,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf(
|
||||||
|
"query stored audit record: %v",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if requestID != record.RequestID {
|
||||||
|
t.Errorf(
|
||||||
|
"request ID = %q, want %q",
|
||||||
|
requestID,
|
||||||
|
record.RequestID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if durationMS != record.Duration.Milliseconds() {
|
||||||
|
t.Errorf(
|
||||||
|
"duration = %d, want %d",
|
||||||
|
durationMS,
|
||||||
|
record.Duration.Milliseconds(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if storedClientIP != clientIP {
|
||||||
|
t.Errorf(
|
||||||
|
"client IP = %q, want %q",
|
||||||
|
storedClientIP,
|
||||||
|
clientIP,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if finalStatus != string(record.Status) {
|
||||||
|
t.Errorf(
|
||||||
|
"status = %q, want %q",
|
||||||
|
finalStatus,
|
||||||
|
record.Status,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if storedHTTPStatus != upstreamStatus {
|
||||||
|
t.Errorf(
|
||||||
|
"HTTP status = %d, want %d",
|
||||||
|
storedHTTPStatus,
|
||||||
|
upstreamStatus,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if maskedCard != record.MaskedCard {
|
||||||
|
t.Errorf(
|
||||||
|
"masked card = %q, want %q",
|
||||||
|
maskedCard,
|
||||||
|
record.MaskedCard,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if storedResponseJSON != string(record.ResponseJSON) {
|
||||||
|
t.Errorf(
|
||||||
|
"response JSON = %q, want %q",
|
||||||
|
storedResponseJSON,
|
||||||
|
record.ResponseJSON,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS audit_records (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|
||||||
|
request_id TEXT NOT NULL UNIQUE,
|
||||||
|
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
|
||||||
|
duration_ms INTEGER NOT NULL,
|
||||||
|
|
||||||
|
client_ip TEXT NULL,
|
||||||
|
|
||||||
|
final_status TEXT NOT NULL,
|
||||||
|
|
||||||
|
upstream_http_status INTEGER NULL,
|
||||||
|
|
||||||
|
masked_card TEXT NOT NULL,
|
||||||
|
|
||||||
|
response_json TEXT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_records_created_at
|
||||||
|
ON audit_records(created_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_records_final_status
|
||||||
|
ON audit_records(final_status);
|
||||||
47
backend/internal/storage/sqlite.go
Normal file
47
backend/internal/storage/sqlite.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
_ "embed"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed migrations/001_create_audit_records.sql
|
||||||
|
var auditMigration string
|
||||||
|
|
||||||
|
func OpenSQLite(path string) (*sql.DB, error) {
|
||||||
|
db, err := sql.Open("sqlite", path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open sqlite database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SQLite permits only one writer at a time.
|
||||||
|
// Keeping a small connection pool makes behavior predictable
|
||||||
|
// for this assessment-sized service.
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
|
||||||
|
if err := db.Ping(); err != nil {
|
||||||
|
db.Close()
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("ping sqlite database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Migrate(
|
||||||
|
ctx context.Context,
|
||||||
|
db *sql.DB,
|
||||||
|
) error {
|
||||||
|
if _, err := db.ExecContext(
|
||||||
|
ctx,
|
||||||
|
auditMigration,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("apply audit migration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user