Skip to content

Commit 89991eb

Browse files
add reading feature
1 parent 5bb7b35 commit 89991eb

21 files changed

+880
-214
lines changed

.env.docker

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ HOST=0.0.0.0
33
DATABASE_URL=postgres://forgotten_user:123456@postgres:5432/forgotten_db?sslmode=disable
44
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
55
ENVIRONMENT=development
6+
DB_AUTO_MIGRATE=true
7+
DB_RUN_MIGRATIONS=false
8+
MIGRATIONS_PATH=internal/database/migrations
69

710
DB_MAX_OPEN_CONNS=25
811
DB_MAX_IDLE_CONNS=5

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ HOST=localhost
33
DATABASE_URL=postgres://user:password@localhost:5432/myapp_db?sslmode=disable
44
JWT_SECRET=your-super-secret-jwt-key
55
ENVIRONMENT=development
6+
DB_AUTO_MIGRATE=true
7+
DB_RUN_MIGRATIONS=false
8+
MIGRATIONS_PATH=path/to/migrations
69

710
DB_MAX_OPEN_CONNS=1
811
DB_MAX_IDLE_CONNS=1

internal/config/config.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ type DatabaseConfig struct {
2626
URL string
2727
MaxOpenConns int
2828
MaxIdleConns int
29+
AutoMigrate bool
30+
RunMigrations bool
31+
MigrationsPath string
2932
}
3033

3134
type JWTConfig struct {
@@ -52,16 +55,22 @@ func Load() *Config {
5255
log.Printf("no .env file found or error loading it: %v", err)
5356
}
5457

58+
env := getEnv("ENVIRONMENT", "development")
59+
defaultAutoMigrate := env != "production"
60+
5561
return &Config{
5662
Server: ServerConfig{
5763
Port: getEnv("PORT", "8080"),
5864
Host: getEnv("HOST", "localhost"),
59-
Environment: getEnv("ENVIRONMENT", "development"),
65+
Environment: getEnv("ENVIRONMENT", env),
6066
},
6167
Database: DatabaseConfig{
6268
URL: getEnv("DATABASE_URL", "postgres://user:password@localhost:5432/dbname?sslmode=disable"),
6369
MaxOpenConns: getEnvAsInt("DB_MAX_OPEN_CONNS", 25),
6470
MaxIdleConns: getEnvAsInt("DB_MAX_IDLE_CONNS", 5),
71+
AutoMigrate: getEnvAsBool("DB_AUTO_MIGRATE", defaultAutoMigrate),
72+
RunMigrations: getEnvAsBool("DB_RUN_MIGRATIONS", false),
73+
MigrationsPath: getEnv("MIGRATIONS_PATH", "internal/database/migrations"),
6574
},
6675
JWT: JWTConfig{
6776
Secret: getEnv("JWT_SECRET", "default_secret"),

internal/database/connection.go

Lines changed: 37 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import (
1212
"gorm.io/gorm/logger"
1313
)
1414

15-
func Connect(cfg *config.Config) (*gorm.DB, error) {
15+
func Connect(cfg *config.Config) (*gorm.DB, error) {
1616
logLevel := logger.Silent
1717
if cfg.Server.Environment == "development" {
1818
logLevel = logger.Info
@@ -34,28 +34,46 @@ func Connect(cfg *config.Config) (*gorm.DB, error) {
3434
sqlDB.SetMaxIdleConns(cfg.Database.MaxIdleConns)
3535

3636
// Periodically publish DB connection stats
37-
go func() {
38-
ticker := time.NewTicker(5 * time.Second)
39-
defer ticker.Stop()
40-
for range ticker.C {
41-
stats := sqlDB.Stats()
42-
metrics.UpdateDBMetrics(stats.OpenConnections, stats.Idle)
43-
}
44-
}()
37+
go func() {
38+
ticker := time.NewTicker(5 * time.Second)
39+
defer ticker.Stop()
40+
for range ticker.C {
41+
stats := sqlDB.Stats()
42+
metrics.UpdateDBMetrics(stats.OpenConnections, stats.Idle)
43+
}
44+
}()
4545

4646
if err := autoMigrate(db); err != nil {
4747
log.Printf("migration error: %v", err)
4848
return nil, err
4949
}
5050

51-
// Seed the database with initial data for testing
52-
if cfg.Server.Environment == "development" {
53-
if err := SeedForTest(db); err != nil {
54-
log.Printf("seeding error: %v", err)
55-
// Don't fail if seeding fails, just log it
51+
if cfg.Server.Environment == "production" {
52+
if cfg.Database.RunMigrations {
53+
if err := RunMigrations(db, cfg.Database.MigrationsPath); err != nil {
54+
log.Printf("migration error: %v", err)
55+
return nil, err
56+
}
57+
} else {
58+
log.Println("Production: skipping AutoMigrate; run SQL migrations externally (make migrate-up).")
59+
}
60+
} else {
61+
// dev/test automigrate if enabled
62+
if cfg.Database.AutoMigrate {
63+
if err := autoMigrate(db); err != nil {
64+
log.Printf("autoMigrate error: %v", err)
65+
return nil, err
66+
}
5667
}
5768
}
5869

70+
if cfg.Server.Environment == "development" {
71+
if err := SeedForTest(db); err != nil {
72+
log.Printf("seeding error: %v", err)
73+
// non-fatal
74+
}
75+
}
76+
5977
log.Println("Database connected successfully")
6078
return db, nil
6179
}
@@ -67,12 +85,13 @@ func autoMigrate(db *gorm.DB) error {
6785
models.Club{},
6886
models.ClubMembership{},
6987
models.Event{},
70-
models.EventRSVP{},
71-
models.Annotation{},
72-
models.AnnotationLike{},
88+
models.EventRSVP{},
7389
models.Comment{},
7490
models.CommentLike{},
7591
models.Post{},
7692
models.PostLike{},
93+
models.UserBookProgress{},
94+
models.ClubBookAssignment{},
95+
models.ReadingLog{},
7796
)
78-
}
97+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
BEGIN;
2+
DROP TRIGGER IF EXISTS trg_club_assignments_updated ON club_book_assignments;
3+
DROP TRIGGER IF EXISTS trg_user_book_progress_updated ON user_book_progress;
4+
DROP FUNCTION IF EXISTS set_updated_at();
5+
6+
DROP TABLE IF EXISTS club_book_assignments;
7+
DROP TABLE IF EXISTS reading_logs;
8+
DROP TABLE IF EXISTS user_book_progress;
9+
COMMIT;
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
BEGIN;
2+
3+
-- Track per-user progress on a book
4+
CREATE TABLE IF NOT EXISTS user_book_progress (
5+
id BIGSERIAL PRIMARY KEY,
6+
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
7+
book_id BIGINT NOT NULL REFERENCES books(id) ON DELETE CASCADE,
8+
status TEXT NOT NULL DEFAULT 'reading', -- not_started|reading|paused|finished
9+
current_page INT,
10+
percent NUMERIC(5,2), -- denorm
11+
started_at TIMESTAMPTZ DEFAULT NOW(),
12+
finished_at TIMESTAMPTZ,
13+
updated_at TIMESTAMPTZ DEFAULT NOW(),
14+
UNIQUE (user_id, book_id)
15+
);
16+
17+
CREATE INDEX IF NOT EXISTS idx_user_book_progress_user ON user_book_progress(user_id);
18+
CREATE INDEX IF NOT EXISTS idx_user_book_progress_book ON user_book_progress(book_id);
19+
20+
-- stores each update with deltas for history and reporting
21+
CREATE TABLE IF NOT EXISTS reading_logs (
22+
id BIGSERIAL PRIMARY KEY,
23+
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
24+
book_id BIGINT NOT NULL REFERENCES books(id) ON DELETE CASCADE,
25+
club_id BIGINT REFERENCES clubs(id) ON DELETE SET NULL,
26+
assignment_id BIGINT, -- forward ref; not FK to avoid circular on create order
27+
pages_delta INT, -- how many pages were read in this session
28+
from_page INT,
29+
to_page INT,
30+
minutes INT,
31+
note TEXT,
32+
created_at TIMESTAMPTZ DEFAULT NOW()
33+
);
34+
35+
CREATE INDEX IF NOT EXISTS idx_reading_logs_user ON reading_logs(user_id);
36+
CREATE INDEX IF NOT EXISTS idx_reading_logs_book ON reading_logs(book_id);
37+
CREATE INDEX IF NOT EXISTS idx_reading_logs_club ON reading_logs(club_id);
38+
39+
-- Club-level assignments (what the club is reading now / history)
40+
CREATE TABLE IF NOT EXISTS club_book_assignments (
41+
id BIGSERIAL PRIMARY KEY,
42+
club_id BIGINT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
43+
book_id BIGINT NOT NULL REFERENCES books(id) ON DELETE CASCADE,
44+
status TEXT NOT NULL DEFAULT 'active', -- active|completed|archived
45+
start_date DATE DEFAULT CURRENT_DATE,
46+
due_date DATE,
47+
completed_at TIMESTAMPTZ,
48+
target_page INT, -- "checkpoint" page for the club
49+
checkpoint TEXT, -- description
50+
created_at TIMESTAMPTZ DEFAULT NOW(),
51+
updated_at TIMESTAMPTZ DEFAULT NOW()
52+
);
53+
54+
CREATE INDEX IF NOT EXISTS idx_club_assignments_club ON club_book_assignments(club_id);
55+
CREATE INDEX IF NOT EXISTS idx_club_assignments_book ON club_book_assignments(book_id);
56+
57+
-- trigger to keep updated_at current
58+
CREATE OR REPLACE FUNCTION set_updated_at() RETURNS TRIGGER AS $$
59+
BEGIN
60+
NEW.updated_at = NOW();
61+
RETURN NEW;
62+
END;
63+
$$ LANGUAGE plpgsql;
64+
65+
DROP TRIGGER IF EXISTS trg_user_book_progress_updated ON user_book_progress;
66+
CREATE TRIGGER trg_user_book_progress_updated BEFORE UPDATE ON user_book_progress
67+
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
68+
69+
DROP TRIGGER IF EXISTS trg_club_assignments_updated ON club_book_assignments;
70+
CREATE TRIGGER trg_club_assignments_updated BEFORE UPDATE ON club_book_assignments
71+
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
72+
73+
COMMIT;
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
BEGIN;
2+
3+
ALTER TABLE clubs ALTER COLUMN owner_id SET NOT NULL;
4+
5+
ALTER TABLE clubs DROP CONSTRAINT IF EXISTS fk_users_owned_clubs;
6+
7+
ALTER TABLE clubs
8+
ADD CONSTRAINT fk_users_owned_clubs
9+
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE RESTRICT;
10+
11+
COMMIT;
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
BEGIN;
2+
3+
-- allow NULL owner_id
4+
ALTER TABLE clubs ALTER COLUMN owner_id DROP NOT NULL;
5+
6+
-- drop old FK (name may vary by environment; drop both if present)
7+
DO $$
8+
BEGIN
9+
IF EXISTS (
10+
SELECT 1 FROM information_schema.table_constraints
11+
WHERE constraint_name = 'fk_users_owned_clubs' AND table_name = 'clubs'
12+
) THEN
13+
ALTER TABLE clubs DROP CONSTRAINT fk_users_owned_clubs;
14+
END IF;
15+
EXCEPTION WHEN undefined_object THEN
16+
-- ignore
17+
END$$;
18+
19+
-- try dropping any default-named FK as a fallback
20+
DO $$
21+
DECLARE
22+
conname text;
23+
BEGIN
24+
SELECT tc.constraint_name INTO conname
25+
FROM information_schema.table_constraints tc
26+
JOIN information_schema.key_column_usage kcu
27+
ON tc.constraint_name = kcu.constraint_name
28+
WHERE tc.table_name = 'clubs'
29+
AND tc.constraint_type = 'FOREIGN KEY'
30+
AND kcu.column_name = 'owner_id'
31+
LIMIT 1;
32+
IF conname IS NOT NULL THEN
33+
EXECUTE format('ALTER TABLE clubs DROP CONSTRAINT %I', conname);
34+
END IF;
35+
END$$;
36+
37+
-- recreate with SET NULL
38+
ALTER TABLE clubs
39+
ADD CONSTRAINT fk_users_owned_clubs
40+
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE SET NULL;
41+
42+
COMMIT;

internal/database/seed.go

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,10 @@ type SeedData struct {
3232
func Seed(db *gorm.DB) error {
3333
log.Println("starting database seeding")
3434

35-
var userCount int64
36-
37-
db.Model(&models.User{}).Count(&userCount)
38-
if userCount > 0 {
39-
log.Println("database already seeded")
40-
return nil
41-
}
35+
if err := truncateAll(db); err != nil {
36+
log.Printf("truncate error: %v", err)
37+
return err
38+
}
4239

4340
data, err := os.ReadFile("data/seed_users.json")
4441
if err != nil {
@@ -87,13 +84,32 @@ func Seed(db *gorm.DB) error {
8784
func SeedForTest(db *gorm.DB) error {
8885
log.Println("starting test database seeding")
8986

90-
if err := db.Exec("DELETE FROM users").Error; err != nil {
91-
return err
92-
}
87+
if err := truncateAll(db); err != nil {
88+
log.Printf("truncate error: %v", err)
89+
return err
90+
}
9391

9492
if err := db.Exec("ALTER SEQUENCE users_id_seq RESTART WITH 1").Error; err != nil {
9593
log.Printf("could not reset user ID sequence: %v", err)
9694
}
9795

9896
return Seed(db)
9997
}
98+
99+
func truncateAll(db *gorm.DB) error {
100+
return db.Exec(`
101+
TRUNCATE TABLE
102+
post_likes,
103+
comment_likes,
104+
comments,
105+
posts,
106+
event_rsvps,
107+
events,
108+
club_moderators,
109+
club_memberships,
110+
clubs,
111+
books,
112+
users
113+
RESTART IDENTITY CASCADE
114+
`).Error
115+
}

0 commit comments

Comments
 (0)