Skip to content

Commit 1502759

Browse files
committed
Basic SSF reading plan integration
1 parent 9212825 commit 1502759

35 files changed

+2018
-24
lines changed

backend/.env.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,11 @@ OTEL_SAMPLING_RATIO=1.0
5454
# Database Query Logging
5555
# Enable detailed SQL query logging (note: replaces OTEL database tracing when enabled)
5656
DB_LOG_QUERIES=false
57+
58+
# SSF API Configuration
59+
# Configuration for syncing content from Studies at Faith (SSF) API
60+
SSF_API_BASE_URL=https://api.sssf.life
61+
SSF_API_KEY=your-ssf-api-key
62+
SSF_DEBUG_MODE=false
63+
SSF_API_TIMEOUT=10s
64+
SSF_SYNC_KEY=your-ssf-sync-key

backend/cmd/server/main.go

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import (
3030
"github.com/bcc-media/wayfarer/internal/middleware"
3131
"github.com/bcc-media/wayfarer/internal/otel"
3232
"github.com/bcc-media/wayfarer/internal/services"
33+
"github.com/bcc-media/wayfarer/internal/ssf"
3334
"github.com/gin-contrib/cors"
3435
"github.com/gin-gonic/gin"
3536
"github.com/ravilushqa/otelgqlgen"
@@ -118,6 +119,25 @@ func main() {
118119
slog.Warn("Members API client not initialized - missing configuration")
119120
}
120121

122+
// Initialize SSF client and sync service
123+
var ssfSyncService *ssf.SyncService
124+
if cfg.SSF.APIKey != "" {
125+
ssfClient := ssf.New(ssf.Config{
126+
BaseURL: cfg.SSF.BaseURL,
127+
APIKey: cfg.SSF.APIKey,
128+
DebugMode: cfg.SSF.DebugMode,
129+
Timeout: cfg.SSF.Timeout,
130+
}, lgr)
131+
slog.Info("SSF API client initialized",
132+
"base_url", cfg.SSF.BaseURL,
133+
"debug_mode", cfg.SSF.DebugMode,
134+
)
135+
136+
ssfSyncService = ssf.NewSyncService(ssfClient, db.Queries, lgr)
137+
} else {
138+
slog.Warn("SSF API client not initialized - missing API key")
139+
}
140+
121141
// Initialize cache with default configuration
122142
cacheInstance, err := cache.NewCacheWithRegistry(cache.DefaultConfig())
123143
if err != nil {
@@ -262,6 +282,16 @@ func main() {
262282
}
263283
router.POST("/api/v1/content-events", middleware.APIKeyAuth(cfg.APIKey), webhookHandler.HandleContentEvent)
264284

285+
// SSF sync endpoint (triggered by external cron/scheduler)
286+
if ssfSyncService != nil && cfg.SSF.SyncKey != "" {
287+
ssfHandler := &handlers.SSFHandler{
288+
SyncService: ssfSyncService,
289+
SyncKey: cfg.SSF.SyncKey,
290+
}
291+
router.POST("/ssf/sync/:slug", ssfHandler.HandleSyncPlan)
292+
slog.Info("SSF sync endpoint registered at POST /ssf/sync/:slug")
293+
}
294+
265295
// GraphQL API endpoint
266296
router.POST("/graphql", middleware.LanguageExtractor(), middleware.JWTAuth(cfg.JWT), graphqlHandler(apiHandler))
267297
if cfg.Server.Environment != "production" {
@@ -302,10 +332,10 @@ func main() {
302332
slog.Info("Shutting down server...")
303333

304334
// Graceful shutdown with timeout
305-
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
335+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
306336
defer cancel()
307337

308-
if err := srv.Shutdown(ctx); err != nil {
338+
if err := srv.Shutdown(shutdownCtx); err != nil {
309339
slog.Error("Server forced to shutdown", "error", err)
310340
os.Exit(1)
311341
}

backend/go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ require (
1212
github.com/exaring/otelpgx v0.9.3
1313
github.com/gin-contrib/cors v1.7.6
1414
github.com/gin-gonic/gin v1.11.0
15+
github.com/go-resty/resty/v2 v2.17.0
1516
github.com/golang-jwt/jwt/v5 v5.3.0
1617
github.com/google/go-cmp v0.7.0
1718
github.com/google/uuid v1.6.0
@@ -161,7 +162,7 @@ require (
161162
golang.org/x/net v0.47.0 // indirect
162163
golang.org/x/sync v0.18.0 // indirect
163164
golang.org/x/sys v0.38.0 // indirect
164-
golang.org/x/time v0.9.0 // indirect
165+
golang.org/x/time v0.12.0 // indirect
165166
golang.org/x/tools v0.38.0 // indirect
166167
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect
167168
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect

backend/go.sum

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
140140
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
141141
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
142142
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
143+
github.com/go-resty/resty/v2 v2.17.0 h1:pW9DeXcaL4Rrym4EZ8v7L19zZiIlWPg5YXAcVmt+gN0=
144+
github.com/go-resty/resty/v2 v2.17.0/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
143145
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
144146
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
145147
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
@@ -508,8 +510,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
508510
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
509511
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
510512
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
511-
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
512-
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
513+
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
514+
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
513515
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
514516
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
515517
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=

backend/internal/config/config.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ type Config struct {
2121
Members MembersConfig
2222
Auth0 Auth0Config
2323
OTEL OTELConfig
24+
SSF SSFConfig
2425
}
2526

2627
// ServerConfig holds HTTP server configuration
@@ -84,6 +85,15 @@ type OTELConfig struct {
8485
SamplingRatio float64 // Sampling ratio (0.0 to 1.0)
8586
}
8687

88+
// SSFConfig holds SSF API configuration
89+
type SSFConfig struct {
90+
BaseURL string // SSF API base URL
91+
APIKey string // Bearer token for authentication
92+
DebugMode bool // Enable verbose request/response logging
93+
Timeout time.Duration // Request timeout
94+
SyncKey string // Static key for sync endpoint authentication
95+
}
96+
8797
// Load reads all environment variables and returns a Config struct
8898
// This should be called once at application startup
8999
func Load() (*Config, error) {
@@ -136,6 +146,13 @@ func Load() (*Config, error) {
136146
ExporterInsecure: getEnvAsBool("OTEL_EXPORTER_INSECURE", true),
137147
SamplingRatio: getEnvAsFloat("OTEL_SAMPLING_RATIO", 1.0),
138148
},
149+
SSF: SSFConfig{
150+
BaseURL: getEnv("SSF_API_BASE_URL", "https://api.sssf.life"),
151+
APIKey: getEnv("SSF_API_KEY", ""),
152+
DebugMode: getEnvAsBool("SSF_DEBUG_MODE", false),
153+
Timeout: getEnvAsDuration("SSF_API_TIMEOUT", 10*time.Second),
154+
SyncKey: getEnv("SSF_SYNC_KEY", ""),
155+
},
139156
}
140157

141158
// Validate required fields
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
-- +goose Up
2+
-- +goose StatementBegin
3+
4+
-- Migration: Add ssf_content table
5+
-- Stores plan chapter items synced from SSF API
6+
7+
CREATE TABLE ssf_content (
8+
id CHAR(28) PRIMARY KEY CHECK (id ~ '^SC[0-9A-Z]{26}$'),
9+
plan_id TEXT NOT NULL,
10+
task_id TEXT NOT NULL,
11+
content_id TEXT,
12+
content_type TEXT NOT NULL,
13+
published_at TIMESTAMPTZ,
14+
synced_at TIMESTAMPTZ NOT NULL DEFAULT now(),
15+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
16+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
17+
UNIQUE (plan_id, task_id)
18+
);
19+
20+
-- Shadow table for translations (follows existing pattern)
21+
CREATE TABLE ssf_content_translations (
22+
ssf_content_id CHAR(28) NOT NULL REFERENCES ssf_content(id) ON DELETE CASCADE,
23+
language_code VARCHAR(10) NOT NULL,
24+
title TEXT,
25+
created_at TIMESTAMPTZ DEFAULT now(),
26+
updated_at TIMESTAMPTZ DEFAULT now(),
27+
PRIMARY KEY (ssf_content_id, language_code)
28+
);
29+
30+
CREATE INDEX idx_ssf_content_plan ON ssf_content(plan_id);
31+
CREATE INDEX idx_ssf_content_task ON ssf_content(task_id);
32+
CREATE INDEX idx_ssf_content_content ON ssf_content(content_id) WHERE content_id IS NOT NULL;
33+
CREATE INDEX idx_ssf_content_type ON ssf_content(content_type);
34+
CREATE INDEX idx_ssf_content_published ON ssf_content(published_at) WHERE published_at IS NOT NULL;
35+
36+
COMMENT ON TABLE ssf_content IS 'Stores plan chapter items synced from SSF API';
37+
COMMENT ON COLUMN ssf_content.plan_id IS 'SSF Plan identifier';
38+
COMMENT ON COLUMN ssf_content.task_id IS 'SSF PlanChapterItem ID (unique within plan)';
39+
COMMENT ON COLUMN ssf_content.content_id IS 'Nested content ID (MediaEpisode, Song, BookChapter, etc.)';
40+
COMMENT ON COLUMN ssf_content.content_type IS 'Type of content: media_episode, song, book_chapter, periodical_article, bible_chapter, bible_verses';
41+
COMMENT ON COLUMN ssf_content.published_at IS 'Content publication date from SSF';
42+
43+
-- Add updated_at trigger
44+
CREATE TRIGGER ssf_content_updated_at
45+
BEFORE UPDATE ON ssf_content
46+
FOR EACH ROW
47+
EXECUTE FUNCTION update_updated_at_column();
48+
49+
CREATE TRIGGER ssf_content_translations_updated_at
50+
BEFORE UPDATE ON ssf_content_translations
51+
FOR EACH ROW
52+
EXECUTE FUNCTION update_updated_at_column();
53+
54+
-- +goose StatementEnd
55+
56+
-- +goose Down
57+
-- +goose StatementBegin
58+
59+
DROP TRIGGER IF EXISTS ssf_content_translations_updated_at ON ssf_content_translations;
60+
DROP TRIGGER IF EXISTS ssf_content_updated_at ON ssf_content;
61+
DROP TABLE IF EXISTS ssf_content_translations;
62+
DROP TABLE IF EXISTS ssf_content;
63+
64+
-- +goose StatementEnd
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
-- +goose Up
2+
-- +goose StatementBegin
3+
4+
-- Migration: Rename ssf_content to external_content and add source column
5+
-- Generalizes the SSF-specific tables to support multiple external content sources
6+
7+
-- Rename tables
8+
ALTER TABLE ssf_content RENAME TO external_content;
9+
ALTER TABLE ssf_content_translations RENAME TO external_content_translations;
10+
11+
-- Add source column with default 'ssf'
12+
ALTER TABLE external_content ADD COLUMN source TEXT NOT NULL DEFAULT 'ssf';
13+
14+
-- Update ID check constraint
15+
ALTER TABLE external_content DROP CONSTRAINT ssf_content_id_check;
16+
ALTER TABLE external_content ADD CONSTRAINT external_content_id_check CHECK (id ~ '^EC[0-9A-Z]{26}$');
17+
18+
-- Update foreign key constraint name
19+
ALTER TABLE external_content_translations
20+
DROP CONSTRAINT ssf_content_translations_ssf_content_id_fkey;
21+
ALTER TABLE external_content_translations
22+
ADD CONSTRAINT external_content_translations_external_content_id_fkey
23+
FOREIGN KEY (ssf_content_id) REFERENCES external_content(id) ON DELETE CASCADE;
24+
25+
-- Rename column in translations table
26+
ALTER TABLE external_content_translations
27+
RENAME COLUMN ssf_content_id TO external_content_id;
28+
29+
-- Drop old indexes
30+
DROP INDEX IF EXISTS idx_ssf_content_plan;
31+
DROP INDEX IF EXISTS idx_ssf_content_task;
32+
DROP INDEX IF EXISTS idx_ssf_content_content;
33+
DROP INDEX IF EXISTS idx_ssf_content_type;
34+
DROP INDEX IF EXISTS idx_ssf_content_published;
35+
36+
-- Create new indexes
37+
CREATE INDEX idx_external_content_plan ON external_content(plan_id);
38+
CREATE INDEX idx_external_content_task ON external_content(task_id);
39+
CREATE INDEX idx_external_content_content ON external_content(content_id) WHERE content_id IS NOT NULL;
40+
CREATE INDEX idx_external_content_type ON external_content(content_type);
41+
CREATE INDEX idx_external_content_published ON external_content(published_at) WHERE published_at IS NOT NULL;
42+
CREATE INDEX idx_external_content_source ON external_content(source);
43+
44+
-- Drop old triggers
45+
DROP TRIGGER IF EXISTS ssf_content_updated_at ON external_content;
46+
DROP TRIGGER IF EXISTS ssf_content_translations_updated_at ON external_content_translations;
47+
48+
-- Create new triggers
49+
CREATE TRIGGER external_content_updated_at
50+
BEFORE UPDATE ON external_content
51+
FOR EACH ROW
52+
EXECUTE FUNCTION update_updated_at_column();
53+
54+
CREATE TRIGGER external_content_translations_updated_at
55+
BEFORE UPDATE ON external_content_translations
56+
FOR EACH ROW
57+
EXECUTE FUNCTION update_updated_at_column();
58+
59+
-- Update comments
60+
COMMENT ON TABLE external_content IS 'Stores content items synced from external sources (SSF, etc.)';
61+
COMMENT ON COLUMN external_content.source IS 'Content source identifier (e.g., ssf)';
62+
COMMENT ON COLUMN external_content.plan_id IS 'External plan identifier';
63+
COMMENT ON COLUMN external_content.task_id IS 'External item ID (unique within plan)';
64+
COMMENT ON COLUMN external_content.content_id IS 'Nested content ID (MediaEpisode, Song, BookChapter, etc.)';
65+
COMMENT ON COLUMN external_content.content_type IS 'Type of content: media_episode, song, book_chapter, periodical_article, bible_chapter, bible_verses';
66+
COMMENT ON COLUMN external_content.published_at IS 'Content publication date';
67+
68+
-- +goose StatementEnd
69+
70+
-- +goose Down
71+
-- +goose StatementBegin
72+
73+
-- Drop new triggers
74+
DROP TRIGGER IF EXISTS external_content_updated_at ON external_content;
75+
DROP TRIGGER IF EXISTS external_content_translations_updated_at ON external_content_translations;
76+
77+
-- Recreate old triggers
78+
CREATE TRIGGER ssf_content_updated_at
79+
BEFORE UPDATE ON external_content
80+
FOR EACH ROW
81+
EXECUTE FUNCTION update_updated_at_column();
82+
83+
CREATE TRIGGER ssf_content_translations_updated_at
84+
BEFORE UPDATE ON external_content_translations
85+
FOR EACH ROW
86+
EXECUTE FUNCTION update_updated_at_column();
87+
88+
-- Drop new indexes
89+
DROP INDEX IF EXISTS idx_external_content_plan;
90+
DROP INDEX IF EXISTS idx_external_content_task;
91+
DROP INDEX IF EXISTS idx_external_content_content;
92+
DROP INDEX IF EXISTS idx_external_content_type;
93+
DROP INDEX IF EXISTS idx_external_content_published;
94+
DROP INDEX IF EXISTS idx_external_content_source;
95+
96+
-- Recreate old indexes
97+
CREATE INDEX idx_ssf_content_plan ON external_content(plan_id);
98+
CREATE INDEX idx_ssf_content_task ON external_content(task_id);
99+
CREATE INDEX idx_ssf_content_content ON external_content(content_id) WHERE content_id IS NOT NULL;
100+
CREATE INDEX idx_ssf_content_type ON external_content(content_type);
101+
CREATE INDEX idx_ssf_content_published ON external_content(published_at) WHERE published_at IS NOT NULL;
102+
103+
-- Rename column back in translations table
104+
ALTER TABLE external_content_translations
105+
RENAME COLUMN external_content_id TO ssf_content_id;
106+
107+
-- Update foreign key constraint name back
108+
ALTER TABLE external_content_translations
109+
DROP CONSTRAINT external_content_translations_external_content_id_fkey;
110+
ALTER TABLE external_content_translations
111+
ADD CONSTRAINT ssf_content_translations_ssf_content_id_fkey
112+
FOREIGN KEY (ssf_content_id) REFERENCES external_content(id) ON DELETE CASCADE;
113+
114+
-- Update ID check constraint back
115+
ALTER TABLE external_content DROP CONSTRAINT external_content_id_check;
116+
ALTER TABLE external_content ADD CONSTRAINT ssf_content_id_check CHECK (id ~ '^SC[0-9A-Z]{26}$');
117+
118+
-- Drop source column
119+
ALTER TABLE external_content DROP COLUMN source;
120+
121+
-- Rename tables back
122+
ALTER TABLE external_content RENAME TO ssf_content;
123+
ALTER TABLE external_content_translations RENAME TO ssf_content_translations;
124+
125+
-- +goose StatementEnd

0 commit comments

Comments
 (0)