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 @@ + + + + + + Fabled Assistant diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json new file mode 100644 index 0000000..463e6d0 --- /dev/null +++ b/frontend/public/manifest.json @@ -0,0 +1,17 @@ +{ + "name": "Fabled Assistant", + "short_name": "Fabled", + "description": "Your self-hosted second brain with AI assistance", + "start_url": "/", + "display": "standalone", + "background_color": "#1a1a2e", + "theme_color": "#6c63ff", + "orientation": "portrait-primary", + "icons": [ + { + "src": "/favicon.ico", + "sizes": "any", + "type": "image/x-icon" + } + ] +} diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000..cd54d9c --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,35 @@ +// Service Worker for push notifications and PWA support +self.addEventListener('push', event => { + if (!event.data) return; + const data = event.data.json(); + event.waitUntil( + self.registration.showNotification(data.title || 'Fabled Assistant', { + body: data.body || '', + icon: '/favicon.ico', + badge: '/favicon.ico', + data: { url: data.url || '/' }, + }) + ); +}); + +self.addEventListener('notificationclick', event => { + event.notification.close(); + event.waitUntil( + clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clientList => { + const url = event.notification.data.url; + for (const client of clientList) { + if (client.url.includes(url) && 'focus' in client) { + return client.focus(); + } + } + if (clients.openWindow) { + return clients.openWindow(url); + } + }) + ); +}); + +// PWA: serve cached assets for offline support (minimal) +self.addEventListener('fetch', event => { + // Pass-through — no offline caching in v1 +}); diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index 6c5a707..1ad4dca 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -73,6 +73,7 @@ router.afterEach(() => { -