diff --git a/alembic/versions/0017_add_projects.py b/alembic/versions/0017_add_projects.py
new file mode 100644
index 0000000..251129f
--- /dev/null
+++ b/alembic/versions/0017_add_projects.py
@@ -0,0 +1,48 @@
+"""Add projects table and project_id to notes."""
+
+from alembic import op
+
+revision = "0017"
+down_revision = "0016"
+
+
+def upgrade() -> None:
+ op.execute("""
+ CREATE TABLE IF NOT EXISTS projects (
+ id SERIAL PRIMARY KEY,
+ user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
+ title TEXT NOT NULL DEFAULT '',
+ description TEXT NOT NULL DEFAULT '',
+ goal TEXT NOT NULL DEFAULT '',
+ status TEXT NOT NULL DEFAULT 'active',
+ color TEXT,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+ )
+ """)
+ op.execute("CREATE INDEX IF NOT EXISTS ix_projects_user_id ON projects(user_id)")
+ op.execute("CREATE INDEX IF NOT EXISTS ix_projects_status ON projects(status)")
+ op.execute("ALTER TABLE notes ADD COLUMN IF NOT EXISTS project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL")
+ op.execute("CREATE INDEX IF NOT EXISTS ix_notes_project_id ON notes(project_id)")
+ # Add FK constraint to existing parent_id column (was nullable with no FK)
+ # Only add if constraint doesn't already exist
+ op.execute("""
+ DO $$
+ BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_constraint WHERE conname = 'notes_parent_id_fkey'
+ ) THEN
+ ALTER TABLE notes ADD CONSTRAINT notes_parent_id_fkey
+ FOREIGN KEY (parent_id) REFERENCES notes(id) ON DELETE SET NULL;
+ END IF;
+ END $$;
+ """)
+
+
+def downgrade() -> None:
+ op.execute("ALTER TABLE notes DROP CONSTRAINT IF EXISTS notes_parent_id_fkey")
+ op.execute("DROP INDEX IF EXISTS ix_notes_project_id")
+ op.execute("ALTER TABLE notes DROP COLUMN IF EXISTS project_id")
+ op.execute("DROP INDEX IF EXISTS ix_projects_status")
+ op.execute("DROP INDEX IF EXISTS ix_projects_user_id")
+ op.execute("DROP TABLE IF EXISTS projects")
diff --git a/alembic/versions/0018_add_push_subscriptions.py b/alembic/versions/0018_add_push_subscriptions.py
new file mode 100644
index 0000000..152c5c0
--- /dev/null
+++ b/alembic/versions/0018_add_push_subscriptions.py
@@ -0,0 +1,28 @@
+"""Add push_subscriptions table for web push notifications."""
+
+from alembic import op
+
+revision = "0018"
+down_revision = "0017"
+
+
+def upgrade() -> None:
+ op.execute("""
+ CREATE TABLE IF NOT EXISTS push_subscriptions (
+ id SERIAL PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ endpoint TEXT NOT NULL,
+ p256dh TEXT NOT NULL,
+ auth TEXT NOT NULL,
+ user_agent TEXT,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ last_used TIMESTAMP WITH TIME ZONE,
+ UNIQUE(user_id, endpoint)
+ )
+ """)
+ op.execute("CREATE INDEX IF NOT EXISTS ix_push_subscriptions_user_id ON push_subscriptions(user_id)")
+
+
+def downgrade() -> None:
+ op.execute("DROP INDEX IF EXISTS ix_push_subscriptions_user_id")
+ op.execute("DROP TABLE IF EXISTS push_subscriptions")
diff --git a/alembic/versions/0019_add_events.py b/alembic/versions/0019_add_events.py
new file mode 100644
index 0000000..5e28170
--- /dev/null
+++ b/alembic/versions/0019_add_events.py
@@ -0,0 +1,37 @@
+"""Add events table for internal CalDAV (Radicale) linkage."""
+
+from alembic import op
+
+revision = "0019"
+down_revision = "0018"
+
+
+def upgrade() -> None:
+ op.execute("""
+ CREATE TABLE IF NOT EXISTS events (
+ id SERIAL PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL,
+ uid TEXT NOT NULL,
+ title TEXT NOT NULL DEFAULT '',
+ start_dt TIMESTAMP WITH TIME ZONE NOT NULL,
+ end_dt TIMESTAMP WITH TIME ZONE,
+ all_day BOOLEAN NOT NULL DEFAULT FALSE,
+ description TEXT NOT NULL DEFAULT '',
+ location TEXT NOT NULL DEFAULT '',
+ recurrence TEXT,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ UNIQUE(user_id, uid)
+ )
+ """)
+ op.execute("CREATE INDEX IF NOT EXISTS ix_events_user_id ON events(user_id)")
+ op.execute("CREATE INDEX IF NOT EXISTS ix_events_project_id ON events(project_id)")
+ op.execute("CREATE INDEX IF NOT EXISTS ix_events_start_dt ON events(start_dt)")
+
+
+def downgrade() -> None:
+ op.execute("DROP INDEX IF EXISTS ix_events_start_dt")
+ op.execute("DROP INDEX IF EXISTS ix_events_project_id")
+ op.execute("DROP INDEX IF EXISTS ix_events_user_id")
+ op.execute("DROP TABLE IF EXISTS events")
diff --git a/alembic/versions/0020_add_milestones.py b/alembic/versions/0020_add_milestones.py
new file mode 100644
index 0000000..0af72ed
--- /dev/null
+++ b/alembic/versions/0020_add_milestones.py
@@ -0,0 +1,38 @@
+"""Add milestones table and milestone_id to notes."""
+
+from alembic import op
+
+revision = "0020"
+down_revision = "0019"
+
+
+def upgrade() -> None:
+ op.execute("""
+ CREATE TABLE IF NOT EXISTS milestones (
+ id SERIAL PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
+ title TEXT NOT NULL DEFAULT '',
+ description TEXT,
+ status TEXT NOT NULL DEFAULT 'active',
+ order_index INTEGER NOT NULL DEFAULT 0,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+ )
+ """)
+ op.execute("CREATE INDEX IF NOT EXISTS ix_milestones_user_id ON milestones(user_id)")
+ op.execute("CREATE INDEX IF NOT EXISTS ix_milestones_project_id ON milestones(project_id)")
+
+ op.execute("""
+ ALTER TABLE notes
+ ADD COLUMN IF NOT EXISTS milestone_id INTEGER REFERENCES milestones(id) ON DELETE SET NULL
+ """)
+ op.execute("CREATE INDEX IF NOT EXISTS ix_notes_milestone_id ON notes(milestone_id)")
+
+
+def downgrade() -> None:
+ op.execute("DROP INDEX IF EXISTS ix_notes_milestone_id")
+ op.execute("ALTER TABLE notes DROP COLUMN IF EXISTS milestone_id")
+ op.execute("DROP INDEX IF EXISTS ix_milestones_project_id")
+ op.execute("DROP INDEX IF EXISTS ix_milestones_user_id")
+ op.execute("DROP TABLE IF EXISTS milestones")
diff --git a/docker-compose.yml b/docker-compose.yml
index 1aadce2..1879389 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -21,6 +21,10 @@ services:
# SEARXNG_URL: "http://searxng:8080"
# IMAGE_CACHE_DIR: /data/images # default, change if using a different mount path
# IMAGE_MAX_BYTES: "5242880" # 5 MB per image, adjust if needed
+ # Push notifications (VAPID keys - generate with: python -c "from py_vapid import Vapid01; v=Vapid01(); v.generate_keys(); print(v.private_key, v.public_key)")
+ VAPID_PRIVATE_KEY: "${VAPID_PRIVATE_KEY:-}"
+ VAPID_PUBLIC_KEY: "${VAPID_PUBLIC_KEY:-}"
+ VAPID_CLAIMS_SUB: "${VAPID_CLAIMS_SUB:-mailto:admin@fabledassistant.local}"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"]
interval: 10s
diff --git a/frontend/index.html b/frontend/index.html
index 1915d1d..3cde003 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -4,6 +4,12 @@
+
+
+
+
+
+