diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f18ca68 --- /dev/null +++ b/.env.example @@ -0,0 +1,70 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Colectivo — environment variables +# Copy to .env and fill in real values. Never commit .env to version control. +# ───────────────────────────────────────────────────────────────────────────── + +# ── Supabase ────────────────────────────────────────────────────────────────── + +# URL of the self-hosted Supabase instance (Kong gateway) +PUBLIC_SUPABASE_URL=http://localhost:8001 + +# Supabase anon key (public, safe to expose in the browser) +# Generated during `supabase start` or set manually in supabase/config.toml +PUBLIC_SUPABASE_ANON_KEY= + +# Supabase service role key (private — never expose in the browser) +# Used only in Edge Functions or server-side scripts +SUPABASE_SERVICE_ROLE_KEY= + +# JWT secret — MUST be set to the Keycloak RS256 public key (PEM format, one line) +# Supabase uses this to validate Keycloak-issued JWTs for RLS +# Rotate simultaneously with Keycloak signing key rotation +SUPABASE_JWT_SECRET= + +# ── Keycloak ────────────────────────────────────────────────────────────────── + +# Public URL of the Keycloak server (used by the browser for OIDC redirects) +PUBLIC_KEYCLOAK_URL=http://localhost:8080 + +# Keycloak realm name +PUBLIC_KEYCLOAK_REALM=colectivo + +# Keycloak client ID (public PKCE client — no secret) +PUBLIC_KEYCLOAK_CLIENT_ID=colectivo-web + +# Keycloak admin credentials (used by infra scripts only — never in app code) +KEYCLOAK_ADMIN=admin +KEYCLOAK_ADMIN_PASSWORD= + +# ── Email (Resend) ──────────────────────────────────────────────────────────── + +# Resend API key — used by Edge Functions to send invitation emails +RESEND_API_KEY= + +# From address for outgoing emails +RESEND_FROM_EMAIL=noreply@yourdomain.com + +# ── Backup (Backblaze B2) ───────────────────────────────────────────────────── + +B2_BUCKET_NAME=colectivo-backups +B2_APPLICATION_KEY_ID= +B2_APPLICATION_KEY= + +# ── App ─────────────────────────────────────────────────────────────────────── + +# Publicly accessible origin of the app (no trailing slash) +# Used in Edge Functions to build invitation URLs +PUBLIC_APP_URL=http://localhost:5173 + +# ── Docker / Infra ──────────────────────────────────────────────────────────── + +# Postgres superuser password (used by Supabase internally) +POSTGRES_PASSWORD= + +# Supabase dashboard username/password (Studio) +DASHBOARD_USERNAME=supabase +DASHBOARD_PASSWORD= + +# Production SSH target for deploy script +DEPLOY_HOST=user@your-vps-ip +DEPLOY_PATH=/opt/colectivo diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0f92370 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + check: + name: Lint & Type-check + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Type-check + run: pnpm turbo run check + + - name: Lint + run: pnpm turbo run lint + continue-on-error: true # lint warnings should not block CI initially + + build: + name: Build + runs-on: ubuntu-latest + needs: check + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm turbo run build + env: + # Placeholder values for build-time env vars + PUBLIC_SUPABASE_URL: http://localhost:8000 + PUBLIC_SUPABASE_ANON_KEY: placeholder + PUBLIC_KEYCLOAK_URL: http://localhost:8080 + PUBLIC_KEYCLOAK_REALM: colectivo + PUBLIC_KEYCLOAK_CLIENT_ID: colectivo-web + PUBLIC_APP_URL: http://localhost:5173 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..699ce73 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,98 @@ +name: Deploy + +on: + push: + branches: [main] + workflow_dispatch: + inputs: + image_tag: + description: Docker image tag to deploy + required: false + default: latest + +jobs: + build-and-push: + name: Build & Push Docker image + runs-on: ubuntu-latest + + permissions: + contents: read + packages: write + + outputs: + image_tag: ${{ steps.meta.outputs.version }} + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }}/colectivo-web + tags: | + type=sha,prefix=,suffix=,format=short + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + file: apps/web/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + PUBLIC_SUPABASE_URL=${{ secrets.PUBLIC_SUPABASE_URL }} + PUBLIC_SUPABASE_ANON_KEY=${{ secrets.PUBLIC_SUPABASE_ANON_KEY }} + PUBLIC_KEYCLOAK_URL=${{ secrets.PUBLIC_KEYCLOAK_URL }} + PUBLIC_KEYCLOAK_REALM=${{ secrets.PUBLIC_KEYCLOAK_REALM }} + PUBLIC_KEYCLOAK_CLIENT_ID=${{ secrets.PUBLIC_KEYCLOAK_CLIENT_ID }} + PUBLIC_APP_URL=${{ secrets.PUBLIC_APP_URL }} + + deploy: + name: Deploy to production + runs-on: ubuntu-latest + needs: build-and-push + environment: production + + steps: + - uses: actions/checkout@v4 + + - name: Setup SSH + run: | + mkdir -p ~/.ssh + echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -H "${{ secrets.DEPLOY_HOST_IP }}" >> ~/.ssh/known_hosts + + - name: Deploy + run: | + IMAGE_TAG="${{ needs.build-and-push.outputs.image_tag }}" \ + DEPLOY_HOST="${{ secrets.DEPLOY_HOST }}" \ + DEPLOY_PATH="${{ secrets.DEPLOY_PATH }}" \ + bash infra/scripts/deploy.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a279e0e --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# Dependencies +node_modules/ +.pnpm-store/ + +# Build outputs +.svelte-kit/ +build/ +dist/ + +# Environment files +.env +.env.dev +.env.production +.env.local +.env.*.local + +# Supabase local +supabase/.branches/ +supabase/.temp/ + +# Editor +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Turbo +.turbo/ + +# Docker +infra/volumes/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..3e6c04e --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +shamefully-hoist=false +strict-peer-dependencies=false diff --git a/CLAUDE.md b/CLAUDE.md index 622371f..edabf82 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,6 +61,7 @@ just dev # start docker-compose.dev.yml + SvelteKit dev server just dev-stop # stop local environment just db-reset # drop + migrate + seed (dev) just db-migrate # apply pending migrations (dev) +just db-seed # apply supabase/seed.sql to the running dev db just db-types # regenerate TS types from Supabase schema → packages/types just kc-export # export Keycloak realm → keycloak/realm-export.json just kc-open # open Keycloak Admin Console (localhost:8080) @@ -70,6 +71,16 @@ just restore supabase # restore a specific dump just logs # docker compose logs -f (prod) ``` +## Local Dev Endpoints + +| Service | URL | Notes | +|---|---|---| +| SvelteKit app | http://localhost:5173 | | +| Supabase Studio | http://localhost:54323 | | +| Supabase API (Kong) | http://localhost:8001 | Port 8000 reserved by other services | +| Keycloak Admin | http://localhost:8080/admin | admin / admin (dev only) | +| PostgreSQL | localhost:5432 | postgres / postgres (dev only) | + ## Domain Model The central organizing unit is the **Collective** (household group), not the individual user. All content belongs to the Collective. diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..23dec4b --- /dev/null +++ b/Justfile @@ -0,0 +1,100 @@ +# Colectivo — task runner +# Requires: just, docker, pnpm + +set dotenv-load := true + +# Alias: docker compose with correct project root so .env is always found +dc_dev := "docker compose --env-file .env -f infra/docker-compose.dev.yml" +dc_prod := "docker compose --env-file .env -f infra/docker-compose.prod.yml" + +# ── Dev ─────────────────────────────────────────────────────────────────────── + +# Start full local stack (Docker services + SvelteKit dev server) +dev: + {{dc_dev}} up -d + pnpm --filter @colectivo/web dev + +# Stop local Docker stack +dev-stop: + {{dc_dev}} down + +# Stop and remove all volumes (full reset) +dev-clean: + {{dc_dev}} down -v + +# ── Database ────────────────────────────────────────────────────────────────── + +# Drop, migrate, and seed the dev database +db-reset: + supabase db reset + +# Apply pending migrations +db-migrate: + supabase db push + +# Apply dev seed data +db-seed: + docker exec -i colectivo-dev-db-1 psql -U postgres < supabase/seed.sql + +# Regenerate TypeScript types from Supabase schema +db-types: + supabase gen types typescript --local > packages/types/src/database.ts + echo "Types written to packages/types/src/database.ts" + +# Open Supabase Studio +db-open: + open http://localhost:54323 + +# ── Keycloak ────────────────────────────────────────────────────────────────── + +# Export Keycloak realm to keycloak/realm-export.json +kc-export: + {{dc_dev}} exec keycloak \ + /opt/keycloak/bin/kc.sh export \ + --realm colectivo \ + --users realm_file \ + --file /tmp/realm-export.json + {{dc_dev}} cp keycloak:/tmp/realm-export.json keycloak/realm-export.json + echo "Exported to keycloak/realm-export.json" + +# Open Keycloak Admin Console +kc-open: + open http://localhost:8080/admin + +# ── Build ───────────────────────────────────────────────────────────────────── + +# Build all packages +build: + pnpm turbo run build + +# Type-check all packages +check: + pnpm turbo run check + +# Lint all packages +lint: + pnpm turbo run lint + +# Run tests +test: + pnpm turbo run test + +# ── Backup & Restore ────────────────────────────────────────────────────────── + +# Take a manual backup of a service (e.g. just backup supabase) +backup service: + infra/scripts/backup.sh {{service}} + +# Restore a backup file (e.g. just restore supabase backups/2024-01-01.dump) +restore service file: + infra/scripts/restore.sh {{service}} {{file}} + +# ── Production ──────────────────────────────────────────────────────────────── + +# Deploy to production via SSH +deploy: + infra/scripts/deploy.sh + +# Stream production Docker logs +logs: + {{dc_prod}} logs -f diff --git a/README.md b/README.md index 66a556e..c47eda7 100644 --- a/README.md +++ b/README.md @@ -76,8 +76,11 @@ colectivo/ │ └── themes/ # tema personalizado (opcional, v2) │ ├── infra/ -│ ├── docker-compose.dev.yml # Supabase + Keycloak + SvelteKit dev +│ ├── docker-compose.dev.yml # Supabase + Keycloak dev (Kong en puerto 8001) │ ├── docker-compose.prod.yml # producción en VPS +│ ├── kong.yml # Kong declarativo (rutas API) +│ ├── db-init/ +│ │ └── 00-role-passwords.sh # ajusta passwords de roles internos de Supabase al arrancar │ └── scripts/ │ ├── backup.sh # backup manual de una BD concreta │ ├── backup-cron.sh # ejecutado por cron: decide tipo (diario/semanal/mensual) y purga @@ -104,6 +107,7 @@ just dev # levanta docker-compose.dev.yml + SvelteKit dev server just dev-stop # para el entorno local just db-reset # drop + migrate + seed (dev) just db-migrate # aplica migraciones pendientes (dev) +just db-seed # aplica supabase/seed.sql a la BD dev en marcha just db-types # genera tipos TS desde schema Supabase → packages/types just kc-export # exporta realm de Keycloak → keycloak/realm-export.json just kc-open # abre Keycloak Admin Console en el navegador (localhost:8080) @@ -117,7 +121,15 @@ just logs # docker compose logs -f (prod) --- +## Endpoints en desarrollo local +| Servicio | URL | Notas | +|----------|-----|-------| +| App SvelteKit | http://localhost:5173 | | +| Supabase Studio | http://localhost:54323 | | +| Supabase API (Kong) | http://localhost:8001 | Puerto 8000 puede estar ocupado por otros servicios | +| Keycloak Admin | http://localhost:8080/admin | admin / admin (solo dev) | +| PostgreSQL | localhost:5432 | postgres / postgres (solo dev) | --- diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000..a1ee512 --- /dev/null +++ b/apps/web/Dockerfile @@ -0,0 +1,55 @@ +# Multi-stage build for the SvelteKit Node.js server +# Build args map to PUBLIC_* env vars baked at build time (SvelteKit requires this) + +FROM node:22-alpine AS base +RUN corepack enable pnpm + +# ── Dependencies ────────────────────────────────────────────────────────────── +FROM base AS deps +WORKDIR /app +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ +COPY apps/web/package.json ./apps/web/ +COPY packages/types/package.json ./packages/types/ +RUN pnpm install --frozen-lockfile + +# ── Build ───────────────────────────────────────────────────────────────────── +FROM base AS builder +WORKDIR /app + +ARG PUBLIC_SUPABASE_URL +ARG PUBLIC_SUPABASE_ANON_KEY +ARG PUBLIC_KEYCLOAK_URL +ARG PUBLIC_KEYCLOAK_REALM +ARG PUBLIC_KEYCLOAK_CLIENT_ID +ARG PUBLIC_APP_URL + +ENV PUBLIC_SUPABASE_URL=$PUBLIC_SUPABASE_URL +ENV PUBLIC_SUPABASE_ANON_KEY=$PUBLIC_SUPABASE_ANON_KEY +ENV PUBLIC_KEYCLOAK_URL=$PUBLIC_KEYCLOAK_URL +ENV PUBLIC_KEYCLOAK_REALM=$PUBLIC_KEYCLOAK_REALM +ENV PUBLIC_KEYCLOAK_CLIENT_ID=$PUBLIC_KEYCLOAK_CLIENT_ID +ENV PUBLIC_APP_URL=$PUBLIC_APP_URL + +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules +COPY . . + +RUN pnpm --filter @colectivo/types build 2>/dev/null || true +RUN pnpm --filter @colectivo/web build + +# ── Runtime ─────────────────────────────────────────────────────────────────── +FROM node:22-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV PORT=3000 + +# Only copy the compiled output +COPY --from=builder /app/apps/web/build ./build +COPY --from=builder /app/apps/web/package.json ./ + +# Install production-only deps for the Node adapter +RUN npm install --omit=dev --ignore-scripts 2>/dev/null || true + +EXPOSE 3000 +CMD ["node", "build"] diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json new file mode 100644 index 0000000..9f8bd06 --- /dev/null +++ b/apps/web/messages/en.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "nav_lists": "Lists", + "nav_tasks": "Tasks", + "nav_notes": "Notes", + "nav_search": "Search", + "nav_settings": "Settings", + "app_name": "Colectivo", + "loading": "Loading…", + "error_generic": "Something went wrong. Please try again.", + "login_title": "Sign in to Colectivo", + "login_button": "Sign in", + "logout_button": "Sign out", + "onboarding_title": "Welcome to Colectivo", + "onboarding_create_collective": "Create a collective", + "onboarding_join_collective": "Join with an invitation link", + "collective_name_label": "Collective name", + "collective_name_placeholder": "e.g. Our Home", + "settings_title": "Settings", + "settings_display_name": "Display name", + "settings_language": "Language", + "settings_avatar": "Avatar", + "settings_save": "Save changes", + "settings_saved": "Changes saved", + "invite_member": "Invite member", + "invite_email_label": "Email address", + "invite_send": "Send invitation", + "lists_title": "Shopping Lists", + "list_add_item": "Add item…", + "list_to_buy": "To buy", + "list_checked": "Checked", + "list_finish_shopping": "Finish shopping", + "list_finish_confirm": "Mark this list as completed?", + "list_finish_confirm_yes": "Yes, finish", + "list_finish_confirm_no": "Keep shopping", + "tasks_title": "Tasks", + "notes_title": "Notes", + "search_title": "Search", + "search_placeholder": "Search lists, tasks, notes…", + "trash_restore": "Restore", + "trash_delete_permanent": "Delete permanently", + "trash_empty": "Trash is empty", + "trash_expires_in": "Expires in {days} days", + "confirm_delete": "Delete?", + "cancel": "Cancel", + "save": "Save", + "delete": "Delete", + "edit": "Edit", + "add": "Add", + "done": "Done" +} diff --git a/apps/web/messages/es.json b/apps/web/messages/es.json new file mode 100644 index 0000000..55bb88f --- /dev/null +++ b/apps/web/messages/es.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "nav_lists": "Listas", + "nav_tasks": "Tareas", + "nav_notes": "Notas", + "nav_search": "Buscar", + "nav_settings": "Ajustes", + "app_name": "Colectivo", + "loading": "Cargando…", + "error_generic": "Algo salió mal. Por favor, inténtalo de nuevo.", + "login_title": "Accede a Colectivo", + "login_button": "Iniciar sesión", + "logout_button": "Cerrar sesión", + "onboarding_title": "Bienvenido a Colectivo", + "onboarding_create_collective": "Crear un colectivo", + "onboarding_join_collective": "Unirme con un enlace de invitación", + "collective_name_label": "Nombre del colectivo", + "collective_name_placeholder": "p. ej. Nuestro Hogar", + "settings_title": "Ajustes", + "settings_display_name": "Nombre visible", + "settings_language": "Idioma", + "settings_avatar": "Avatar", + "settings_save": "Guardar cambios", + "settings_saved": "Cambios guardados", + "invite_member": "Invitar miembro", + "invite_email_label": "Correo electrónico", + "invite_send": "Enviar invitación", + "lists_title": "Listas de la compra", + "list_add_item": "Añadir producto…", + "list_to_buy": "Por comprar", + "list_checked": "Marcado", + "list_finish_shopping": "Terminar compra", + "list_finish_confirm": "¿Marcar esta lista como completada?", + "list_finish_confirm_yes": "Sí, terminar", + "list_finish_confirm_no": "Seguir comprando", + "tasks_title": "Tareas", + "notes_title": "Notas", + "search_title": "Buscar", + "search_placeholder": "Busca listas, tareas, notas…", + "trash_restore": "Restaurar", + "trash_delete_permanent": "Eliminar definitivamente", + "trash_empty": "La papelera está vacía", + "trash_expires_in": "Expira en {days} días", + "confirm_delete": "¿Eliminar?", + "cancel": "Cancelar", + "save": "Guardar", + "delete": "Eliminar", + "edit": "Editar", + "add": "Añadir", + "done": "Listo" +} diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..4753ebb --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,42 @@ +{ + "name": "@colectivo/web", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "lint": "prettier --check . && eslint .", + "format": "prettier --write ." + }, + "dependencies": { + "@colectivo/types": "workspace:*", + "@inlang/paraglide-sveltekit": "^0.12.3", + "@supabase/supabase-js": "^2.46.2", + "idb": "^8.0.1", + "keycloak-js": "^26.0.7", + "svelte-dnd-action": "^0.9.51" + }, + "devDependencies": { + "@sveltejs/adapter-node": "^5.2.11", + "@sveltejs/kit": "^2.15.1", + "@sveltejs/vite-plugin-svelte": "^5.0.3", + "@vite-pwa/sveltekit": "^0.6.6", + "autoprefixer": "^10.4.20", + "eslint": "^9.18.0", + "eslint-plugin-svelte": "^2.46.1", + "postcss": "^8.5.1", + "prettier": "^3.4.2", + "prettier-plugin-svelte": "^3.3.2", + "prettier-plugin-tailwindcss": "^0.6.9", + "svelte": "^5.17.3", + "svelte-check": "^4.1.1", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.3", + "vite": "^6.0.7", + "workbox-window": "^7.3.0" + } +} diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js new file mode 100644 index 0000000..0f77216 --- /dev/null +++ b/apps/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +}; diff --git a/apps/web/project.inlang/settings.json b/apps/web/project.inlang/settings.json new file mode 100644 index 0000000..10aae03 --- /dev/null +++ b/apps/web/project.inlang/settings.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://inlang.com/schema/project-settings", + "sourceLanguageTag": "en", + "languageTags": ["en", "es"], + "modules": [ + "https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js", + "https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@0.9.5/dist/index.js" + ], + "plugin.inlang.messageFormat": { + "pathPattern": "./messages/{languageTag}.json" + } +} diff --git a/apps/web/src/app.css b/apps/web/src/app.css new file mode 100644 index 0000000..3a74129 --- /dev/null +++ b/apps/web/src/app.css @@ -0,0 +1,45 @@ +@import 'tailwindcss/base'; +@import 'tailwindcss/components'; +@import 'tailwindcss/utilities'; + +/* Inter font */ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); + +/* Design tokens — light mode */ +:root { + --background: 248 250 252; /* slate-50 */ + --surface: 255 255 255; /* white */ + --surface-raised: 241 245 249; /* slate-100 */ + --border: 226 232 240; /* slate-200 */ + --text-primary: 15 23 42; /* slate-900 */ + --text-secondary: 100 116 139; /* slate-500 */ + --text-muted: 148 163 184; /* slate-400 */ +} + +/* Design tokens — dark mode */ +.dark { + --background: 15 23 42; /* slate-950 */ + --surface: 30 41 59; /* slate-800 → closer to slate-900 card */ + --surface-raised: 51 65 85; /* slate-700 */ + --border: 51 65 85; /* slate-700 */ + --text-primary: 248 250 252; /* slate-50 */ + --text-secondary: 148 163 184; /* slate-400 */ + --text-muted: 100 116 139; /* slate-500 */ +} + +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; +} + +body { + font-family: 'Inter', system-ui, sans-serif; + background-color: hsl(var(--background)); + color: hsl(var(--text-primary)); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} diff --git a/apps/web/src/app.d.ts b/apps/web/src/app.d.ts new file mode 100644 index 0000000..b380675 --- /dev/null +++ b/apps/web/src/app.d.ts @@ -0,0 +1,16 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +import type { KeycloakUser } from '$lib/auth'; + +declare global { + namespace App { + interface Locals { + user: KeycloakUser | null; + } + // interface Error {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/apps/web/src/app.html b/apps/web/src/app.html new file mode 100644 index 0000000..ddb5575 --- /dev/null +++ b/apps/web/src/app.html @@ -0,0 +1,13 @@ + + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts new file mode 100644 index 0000000..699debd --- /dev/null +++ b/apps/web/src/lib/auth.ts @@ -0,0 +1,78 @@ +import Keycloak from 'keycloak-js'; +import { browser } from '$app/environment'; +import { PUBLIC_KEYCLOAK_URL, PUBLIC_KEYCLOAK_REALM, PUBLIC_KEYCLOAK_CLIENT_ID } from '$env/static/public'; + +export interface KeycloakUser { + id: string; + email: string; + displayName: string; + preferredUsername: string; +} + +let keycloak: Keycloak | null = null; + +export function getKeycloak(): Keycloak { + if (!keycloak) { + keycloak = new Keycloak({ + url: PUBLIC_KEYCLOAK_URL, + realm: PUBLIC_KEYCLOAK_REALM, + clientId: PUBLIC_KEYCLOAK_CLIENT_ID + }); + } + return keycloak; +} + +export async function initAuth(): Promise { + if (!browser) return false; + + const kc = getKeycloak(); + + const authenticated = await kc.init({ + onLoad: 'check-sso', + silentCheckSsoRedirectUri: `${window.location.origin}/silent-check-sso.html`, + // Safari blocks third-party cookies in iframes — must be false + checkLoginIframe: false, + pkceMethod: 'S256' + }); + + if (authenticated) { + // Schedule proactive token refresh (60s before expiry) + scheduleTokenRefresh(kc); + } + + return authenticated; +} + +function scheduleTokenRefresh(kc: Keycloak): void { + setInterval( + async () => { + try { + await kc.updateToken(60); + } catch { + // Token refresh failed — redirect to login + kc.login(); + } + }, + 30 * 1000 // check every 30s + ); +} + +export function getUser(kc: Keycloak): KeycloakUser | null { + if (!kc.authenticated || !kc.tokenParsed) return null; + + const token = kc.tokenParsed as Record; + return { + id: token['sub'] as string, + email: token['email'] as string, + displayName: (token['name'] as string) ?? (token['preferred_username'] as string), + preferredUsername: token['preferred_username'] as string + }; +} + +export async function login(): Promise { + getKeycloak().login(); +} + +export async function logout(): Promise { + getKeycloak().logout({ redirectUri: window.location.origin }); +} diff --git a/apps/web/src/lib/i18n.ts b/apps/web/src/lib/i18n.ts new file mode 100644 index 0000000..7a41972 --- /dev/null +++ b/apps/web/src/lib/i18n.ts @@ -0,0 +1,6 @@ +import { createI18n } from '@inlang/paraglide-sveltekit'; +import * as runtime from '$lib/paraglide/runtime'; + +export const i18n = createI18n(runtime, { + defaultLanguageTag: 'en' +}); diff --git a/apps/web/src/lib/stores/auth.ts b/apps/web/src/lib/stores/auth.ts new file mode 100644 index 0000000..5928d3d --- /dev/null +++ b/apps/web/src/lib/stores/auth.ts @@ -0,0 +1,10 @@ +import { writable, derived } from 'svelte/store'; +import type Keycloak from 'keycloak-js'; +import type { KeycloakUser } from '$lib/auth'; + +export const keycloak = writable(null); +export const isAuthenticated = writable(false); +export const currentUser = writable(null); +export const authLoading = writable(true); + +export const token = derived(keycloak, ($kc) => $kc?.token ?? null); diff --git a/apps/web/src/lib/stores/collective.ts b/apps/web/src/lib/stores/collective.ts new file mode 100644 index 0000000..031be77 --- /dev/null +++ b/apps/web/src/lib/stores/collective.ts @@ -0,0 +1,22 @@ +import { writable } from 'svelte/store'; + +export interface Collective { + id: string; + name: string; + emoji: string; + created_at: string; +} + +export interface CollectiveMember { + user_id: string; + collective_id: string; + role: 'admin' | 'member' | 'guest'; + display_name: string; + avatar_type: 'initials' | 'emoji' | 'upload'; + avatar_emoji: string | null; + avatar_url: string | null; +} + +export const currentCollective = writable(null); +export const collectiveMembers = writable([]); +export const userCollectives = writable([]); diff --git a/apps/web/src/lib/supabase.ts b/apps/web/src/lib/supabase.ts new file mode 100644 index 0000000..879b7d4 --- /dev/null +++ b/apps/web/src/lib/supabase.ts @@ -0,0 +1,44 @@ +import { createClient, type SupabaseClient } from '@supabase/supabase-js'; +import { browser } from '$app/environment'; +import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public'; +import type { Database } from '@colectivo/types'; + +let supabase: SupabaseClient | null = null; + +export function getSupabase(): SupabaseClient { + if (!supabase) { + supabase = createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, { + auth: { + // Auth is handled by Keycloak — disable Supabase GoTrue auth flows + autoRefreshToken: false, + persistSession: false, + detectSessionInUrl: false + }, + global: { + headers: {} + } + }); + } + return supabase; +} + +/** + * Set the Keycloak access token on the Supabase client so RLS policies + * resolve auth.uid() to the Keycloak sub claim. + */ +export function setSupabaseToken(accessToken: string): void { + const client = getSupabase(); + // @ts-expect-error — internal API to inject a custom JWT + client.rest.headers['Authorization'] = `Bearer ${accessToken}`; + if (browser) { + // Also set on the realtime client + // @ts-expect-error + client.realtime.setAuth(accessToken); + } +} + +export function clearSupabaseToken(): void { + const client = getSupabase(); + // @ts-expect-error + delete client.rest.headers['Authorization']; +} diff --git a/apps/web/src/routes/(app)/+layout.svelte b/apps/web/src/routes/(app)/+layout.svelte new file mode 100644 index 0000000..75a10d9 --- /dev/null +++ b/apps/web/src/routes/(app)/+layout.svelte @@ -0,0 +1,83 @@ + + +{#if $authLoading} +
+ {m.loading()} +
+{:else if !$isAuthenticated} + +{:else} +
+ + + + +
+ +
+
+{/if} diff --git a/apps/web/src/routes/(app)/lists/+page.svelte b/apps/web/src/routes/(app)/lists/+page.svelte new file mode 100644 index 0000000..b380755 --- /dev/null +++ b/apps/web/src/routes/(app)/lists/+page.svelte @@ -0,0 +1,12 @@ + + +
+
+

{m.lists_title()}

+
+
+ +
+
diff --git a/apps/web/src/routes/(app)/notes/+page.svelte b/apps/web/src/routes/(app)/notes/+page.svelte new file mode 100644 index 0000000..455ddde --- /dev/null +++ b/apps/web/src/routes/(app)/notes/+page.svelte @@ -0,0 +1,12 @@ + + +
+
+

{m.notes_title()}

+
+
+ +
+
diff --git a/apps/web/src/routes/(app)/search/+page.svelte b/apps/web/src/routes/(app)/search/+page.svelte new file mode 100644 index 0000000..863e461 --- /dev/null +++ b/apps/web/src/routes/(app)/search/+page.svelte @@ -0,0 +1,12 @@ + + +
+
+

{m.search_title()}

+
+
+ +
+
diff --git a/apps/web/src/routes/(app)/settings/+page.svelte b/apps/web/src/routes/(app)/settings/+page.svelte new file mode 100644 index 0000000..d9545da --- /dev/null +++ b/apps/web/src/routes/(app)/settings/+page.svelte @@ -0,0 +1,12 @@ + + +
+
+

{m.settings_title()}

+
+
+ +
+
diff --git a/apps/web/src/routes/(app)/tasks/+page.svelte b/apps/web/src/routes/(app)/tasks/+page.svelte new file mode 100644 index 0000000..e6c4984 --- /dev/null +++ b/apps/web/src/routes/(app)/tasks/+page.svelte @@ -0,0 +1,12 @@ + + +
+
+

{m.tasks_title()}

+
+
+ +
+
diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte new file mode 100644 index 0000000..e5b68ed --- /dev/null +++ b/apps/web/src/routes/+layout.svelte @@ -0,0 +1,51 @@ + + + + + diff --git a/apps/web/src/routes/+page.svelte b/apps/web/src/routes/+page.svelte new file mode 100644 index 0000000..67a581c --- /dev/null +++ b/apps/web/src/routes/+page.svelte @@ -0,0 +1,16 @@ + + + diff --git a/apps/web/src/routes/onboarding/+page.svelte b/apps/web/src/routes/onboarding/+page.svelte new file mode 100644 index 0000000..3609aa6 --- /dev/null +++ b/apps/web/src/routes/onboarding/+page.svelte @@ -0,0 +1,25 @@ + + +
+
+
+

{m.onboarding_title()}

+
+ + +
+ + +
+
+
diff --git a/apps/web/src/service-worker.ts b/apps/web/src/service-worker.ts new file mode 100644 index 0000000..3345011 --- /dev/null +++ b/apps/web/src/service-worker.ts @@ -0,0 +1,50 @@ +/// +/// +/// +/// + +import { build, files, version } from '$service-worker'; +import { precacheAndRoute } from 'workbox-precaching'; +import { registerRoute, NavigationRoute } from 'workbox-routing'; +import { NetworkFirst, CacheFirst } from 'workbox-strategies'; +import { BackgroundSyncPlugin } from 'workbox-background-sync'; + +declare const self: ServiceWorkerGlobalScope; + +const CACHE_NAME = `colectivo-${version}`; +const ASSETS = [...build, ...files]; + +// Precache all static assets +precacheAndRoute(ASSETS.map((url) => ({ url, revision: version }))); + +// Cache-first for static assets (JS, CSS, fonts, icons) +registerRoute( + ({ request }) => + request.destination === 'style' || + request.destination === 'script' || + request.destination === 'font' || + request.destination === 'image', + new CacheFirst({ cacheName: `${CACHE_NAME}-assets` }) +); + +// Network-first for app routes (HTML navigation) +registerRoute( + new NavigationRoute( + new NetworkFirst({ + cacheName: `${CACHE_NAME}-pages`, + networkTimeoutSeconds: 3 + }) + ) +); + +// Background Sync for pending offline operations +const bgSyncPlugin = new BackgroundSyncPlugin('pending-ops-queue', { + maxRetentionTime: 24 * 60 // 24 hours +}); + +// Listen for messages from the app +self.addEventListener('message', (event) => { + if (event.data?.type === 'SKIP_WAITING') { + self.skipWaiting(); + } +}); diff --git a/apps/web/static/silent-check-sso.html b/apps/web/static/silent-check-sso.html new file mode 100644 index 0000000..9fcbc29 --- /dev/null +++ b/apps/web/static/silent-check-sso.html @@ -0,0 +1,11 @@ + + + + Silent Check SSO + + + + + diff --git a/apps/web/svelte.config.js b/apps/web/svelte.config.js new file mode 100644 index 0000000..4d1f727 --- /dev/null +++ b/apps/web/svelte.config.js @@ -0,0 +1,17 @@ +import adapter from '@sveltejs/adapter-node'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter({ + out: 'build' + }), + alias: { + $lib: './src/lib' + } + } +}; + +export default config; diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts new file mode 100644 index 0000000..7526b0c --- /dev/null +++ b/apps/web/tailwind.config.ts @@ -0,0 +1,20 @@ +import type { Config } from 'tailwindcss'; + +export default { + content: ['./src/**/*.{html,js,svelte,ts}'], + darkMode: 'class', + theme: { + extend: { + fontFamily: { + sans: ['Inter', 'system-ui', 'sans-serif'] + }, + colors: { + // Semantic aliases over Tailwind slate + background: 'hsl(var(--background) / )', + surface: 'hsl(var(--surface) / )', + 'surface-raised': 'hsl(var(--surface-raised) / )' + } + } + }, + plugins: [] +} satisfies Config; diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..a8f10c8 --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000..aec0995 --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,53 @@ +import { defineConfig } from 'vite'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { SvelteKitPWA } from '@vite-pwa/sveltekit'; +import { paraglide } from '@inlang/paraglide-sveltekit/vite'; + +export default defineConfig({ + plugins: [ + paraglide({ + project: './project.inlang', + outdir: './src/lib/paraglide' + }), + sveltekit(), + SvelteKitPWA({ + srcDir: './src', + mode: 'development', + strategies: 'injectManifest', + filename: 'service-worker.ts', + scope: '/', + base: '/', + manifest: { + name: 'Colectivo', + short_name: 'Colectivo', + description: 'Gestión colaborativa del hogar', + theme_color: '#0f172a', + background_color: '#f7f9fb', + display: 'standalone', + orientation: 'portrait', + scope: '/', + start_url: '/', + icons: [ + { src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' }, + { src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' }, + { src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' } + ] + }, + injectManifest: { + globPatterns: ['client/**/*.{js,css,ico,png,svg,webp,woff,woff2}'] + }, + workbox: { + globPatterns: ['client/**/*.{js,css,ico,png,svg,webp,woff,woff2}'] + }, + devOptions: { + enabled: true, + suppressWarnings: true, + type: 'module', + navigateFallback: '/' + } + }) + ], + server: { + port: 5173 + } +}); diff --git a/infra/db-init/00-role-passwords.sh b/infra/db-init/00-role-passwords.sh new file mode 100755 index 0000000..b2eb1c2 --- /dev/null +++ b/infra/db-init/00-role-passwords.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# Runs once on first container start (docker-entrypoint-initdb.d). +# Sets Supabase internal role passwords to POSTGRES_PASSWORD and creates +# required schemas with correct grants. + +set -e + +PASS="${POSTGRES_PASSWORD:-postgres}" + +psql -v ON_ERROR_STOP=1 --username supabase_admin --dbname postgres <<-EOSQL + -- Set passwords for all Supabase internal roles. + -- The supabase/postgres image creates them with hardcoded defaults; + -- this overrides them so services connect with POSTGRES_PASSWORD. + ALTER ROLE authenticator WITH PASSWORD '${PASS}'; + ALTER ROLE supabase_auth_admin WITH PASSWORD '${PASS}'; + ALTER ROLE supabase_replication_admin WITH PASSWORD '${PASS}'; + ALTER ROLE supabase_storage_admin WITH PASSWORD '${PASS}'; + ALTER ROLE pgbouncer WITH PASSWORD '${PASS}'; + + -- Create the _realtime schema required by supabase/realtime + CREATE SCHEMA IF NOT EXISTS _realtime; + GRANT ALL ON SCHEMA _realtime TO supabase_replication_admin; + ALTER ROLE supabase_replication_admin SET search_path TO _realtime; +EOSQL + +echo "==> db-init: role passwords and schemas configured" diff --git a/infra/db-init/00-role-passwords.sql b/infra/db-init/00-role-passwords.sql new file mode 100644 index 0000000..7d41e73 --- /dev/null +++ b/infra/db-init/00-role-passwords.sql @@ -0,0 +1,11 @@ +-- Set passwords for Supabase internal roles to match POSTGRES_PASSWORD. +-- The supabase/postgres image creates these roles with hardcoded default passwords; +-- this script overrides them so all services can authenticate with POSTGRES_PASSWORD. +-- Runs once on first container start (docker-entrypoint-initdb.d). +-- Must run as supabase_admin (superuser) which this image uses for initdb scripts. + +ALTER ROLE authenticator WITH PASSWORD 'REPLACE_PASSWORD'; +ALTER ROLE supabase_auth_admin WITH PASSWORD 'REPLACE_PASSWORD'; +ALTER ROLE supabase_replication_admin WITH PASSWORD 'REPLACE_PASSWORD'; +ALTER ROLE supabase_storage_admin WITH PASSWORD 'REPLACE_PASSWORD'; +ALTER ROLE pgbouncer WITH PASSWORD 'REPLACE_PASSWORD'; diff --git a/infra/docker-compose.dev.yml b/infra/docker-compose.dev.yml new file mode 100644 index 0000000..6fc2cb3 --- /dev/null +++ b/infra/docker-compose.dev.yml @@ -0,0 +1,231 @@ +name: colectivo-dev + +# Self-hosted Supabase stack + Keycloak for local development. +# Based on: https://github.com/supabase/supabase/tree/master/docker +# +# Start: just dev +# Stop: just dev-stop +# Reset: just dev-clean (drops volumes) + +services: + + # ── PostgreSQL 15 ─────────────────────────────────────────────────────────── + db: + image: supabase/postgres:15.1.1.78 + restart: unless-stopped + healthcheck: + test: pg_isready -U postgres -h localhost + interval: 5s + timeout: 5s + retries: 10 + ports: + - "5432:5432" + environment: + POSTGRES_HOST: /var/run/postgresql + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + JWT_SECRET: ${SUPABASE_JWT_SECRET} + JWT_EXP: 3600 + volumes: + - db-data:/var/lib/postgresql/data + # Sets internal role passwords to POSTGRES_PASSWORD on first init. + - ./db-init:/docker-entrypoint-initdb.d:ro + + # ── GoTrue (Supabase Auth) ────────────────────────────────────────────────── + auth: + image: supabase/gotrue:v2.158.1 + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + GOTRUE_API_HOST: "0.0.0.0" + GOTRUE_API_PORT: 9999 + API_EXTERNAL_URL: ${PUBLIC_APP_URL:-http://localhost:5173} + GOTRUE_API_EXTERNAL_URL: ${PUBLIC_APP_URL:-http://localhost:5173} + GOTRUE_DB_DRIVER: postgres + GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD:-postgres}@db:5432/postgres + GOTRUE_SITE_URL: ${PUBLIC_APP_URL:-http://localhost:5173} + GOTRUE_JWT_SECRET: ${SUPABASE_JWT_SECRET} + GOTRUE_JWT_EXP: 3600 + GOTRUE_DISABLE_SIGNUP: "true" + # OIDC: Keycloak as external provider + GOTRUE_EXTERNAL_KEYCLOAK_ENABLED: "true" + GOTRUE_EXTERNAL_KEYCLOAK_CLIENT_ID: ${PUBLIC_KEYCLOAK_CLIENT_ID:-colectivo-web} + GOTRUE_EXTERNAL_KEYCLOAK_SECRET: "" + GOTRUE_EXTERNAL_KEYCLOAK_REDIRECT_URI: ${PUBLIC_APP_URL:-http://localhost:5173}/auth/callback + GOTRUE_MAILER_AUTOCONFIRM: "true" + + # ── PostgREST ─────────────────────────────────────────────────────────────── + rest: + image: postgrest/postgrest:v12.2.0 + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD:-postgres}@db:5432/postgres + PGRST_DB_SCHEMAS: public,storage,graphql_public + PGRST_DB_ANON_ROLE: anon + PGRST_JWT_SECRET: ${SUPABASE_JWT_SECRET} + PGRST_DB_USE_LEGACY_GUCS: "false" + PGRST_APP_SETTINGS_JWT_SECRET: ${SUPABASE_JWT_SECRET} + PGRST_APP_SETTINGS_JWT_EXP: 3600 + + # ── Realtime ───────────────────────────────────────────────────────────────── + realtime: + image: supabase/realtime:v2.83.0 + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + PORT: 4000 + DB_HOST: db + DB_PORT: 5432 + DB_USER: supabase_replication_admin + DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + DB_NAME: postgres + DB_AFTER_CONNECT_QUERY: SET search_path TO _realtime + DB_ENC_KEY: supabaserealtime + API_JWT_SECRET: ${SUPABASE_JWT_SECRET} + FLY_ALLOC_ID: fly123 + FLY_APP_NAME: realtime + APP_NAME: realtime + SECRET_KEY_BASE: UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3bhZkeYTvU + ERL_AFLAGS: -proto_dist inet_tcp + ENABLE_TAILSCALE: "false" + DNS_NODES: "''" + RLIMIT_NOFILE: "" + METRICS_JWT_SECRET: ${SUPABASE_JWT_SECRET} + MAX_HEADER_LENGTH: 4096 + command: sh -c "/app/bin/migrate && /app/bin/realtime eval 'Realtime.Release.seeds(Realtime.Repo)' && /app/bin/server" + + # ── Storage ────────────────────────────────────────────────────────────────── + storage: + image: supabase/storage-api:v1.11.13 + restart: unless-stopped + depends_on: + db: + condition: service_healthy + rest: + condition: service_started + environment: + ANON_KEY: ${PUBLIC_SUPABASE_ANON_KEY} + SERVICE_KEY: ${SUPABASE_SERVICE_ROLE_KEY} + POSTGREST_URL: http://rest:3000 + PGRST_JWT_SECRET: ${SUPABASE_JWT_SECRET} + DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD:-postgres}@db:5432/postgres + FILE_SIZE_LIMIT: 52428800 + STORAGE_BACKEND: file + FILE_STORAGE_BACKEND_PATH: /var/lib/storage + TENANT_ID: stub + REGION: local + GLOBAL_S3_BUCKET: stub + ENABLE_IMAGE_TRANSFORMATION: "true" + IMGPROXY_URL: http://imgproxy:5001 + volumes: + - storage-data:/var/lib/storage + + # ── ImgProxy (image transformations for Storage) ────────────────────────── + imgproxy: + image: darthsim/imgproxy:v3.8.0 + restart: unless-stopped + environment: + IMGPROXY_BIND: ":5001" + IMGPROXY_LOCAL_FILESYSTEM_ROOT: / + IMGPROXY_USE_ETAG: "true" + IMGPROXY_ENABLE_WEBP_DETECTION: "true" + volumes: + - storage-data:/var/lib/storage:ro + + # ── Kong (API Gateway) ──────────────────────────────────────────────────── + kong: + image: kong:2.8.1 + restart: unless-stopped + ports: + - "8001:8000" # HTTP API (8000 may conflict; use http://localhost:8001) + - "8443:8443" # HTTPS API + depends_on: + db: + condition: service_healthy + environment: + KONG_DATABASE: "off" + KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml + KONG_DNS_ORDER: LAST,A,CNAME + KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth + KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k + KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k + SUPABASE_ANON_KEY: ${PUBLIC_SUPABASE_ANON_KEY} + SUPABASE_SERVICE_KEY: ${SUPABASE_SERVICE_ROLE_KEY} + volumes: + - ./kong.yml:/home/kong/kong.yml:ro + + # ── Supabase Studio ─────────────────────────────────────────────────────── + studio: + image: supabase/studio:2026.04.08-sha-205cbe7 + restart: unless-stopped + ports: + - "54323:3000" + # Override the built-in healthcheck (it uses node fetch which fails inside the container). + # The studio works fine; this just prevents a misleading "unhealthy" status. + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:3000/api/platform/profile || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + depends_on: + db: + condition: service_healthy + environment: + STUDIO_PG_META_URL: http://meta:8080 + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + DEFAULT_ORGANIZATION_NAME: Colectivo + DEFAULT_PROJECT_NAME: colectivo-dev + SUPABASE_URL: http://kong:8000 + SUPABASE_PUBLIC_URL: ${PUBLIC_SUPABASE_URL:-http://localhost:8001} + SUPABASE_ANON_KEY: ${PUBLIC_SUPABASE_ANON_KEY} + SUPABASE_SERVICE_KEY: ${SUPABASE_SERVICE_ROLE_KEY} + AUTH_JWT_SECRET: ${SUPABASE_JWT_SECRET} + LOGFLARE_API_KEY: "" + LOGFLARE_URL: http://analytics:4000 + NEXT_PUBLIC_ENABLE_LOGS: "true" + NEXT_ANALYTICS_BACKEND_PROVIDER: postgres + + # ── pg-meta (metadata API for Studio) ─────────────────────────────────── + meta: + image: supabase/postgres-meta:v0.83.2 + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + PG_META_PORT: 8080 + PG_META_DB_HOST: db + PG_META_DB_PORT: 5432 + PG_META_DB_NAME: postgres + PG_META_DB_USER: supabase + PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + + # ── Keycloak 24 ────────────────────────────────────────────────────────── + keycloak: + image: quay.io/keycloak/keycloak:24.0.5 + restart: unless-stopped + ports: + - "8080:8080" + command: start-dev --import-realm + environment: + KC_DB: dev-file + KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN:-admin} + KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin} + KC_HTTP_PORT: 8080 + KC_HOSTNAME_STRICT: "false" + KC_HOSTNAME_STRICT_HTTPS: "false" + volumes: + - keycloak-data:/opt/keycloak/data + - ../keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro + +volumes: + db-data: + storage-data: + keycloak-data: diff --git a/infra/docker-compose.prod.yml b/infra/docker-compose.prod.yml new file mode 100644 index 0000000..2cb2312 --- /dev/null +++ b/infra/docker-compose.prod.yml @@ -0,0 +1,233 @@ +name: colectivo-prod + +# Production stack. nginx is managed externally (not in this compose file). +# All services are on the internal `colectivo` network only — no ports exposed +# except Kong (proxied by nginx). +# +# Deploy: just deploy +# Logs: just logs + +services: + + # ── PostgreSQL 15 ─────────────────────────────────────────────────────────── + db: + image: supabase/postgres:15.1.1.78 + restart: always + healthcheck: + test: pg_isready -U postgres -h localhost + interval: 10s + timeout: 5s + retries: 10 + environment: + POSTGRES_HOST: /var/run/postgresql + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + JWT_SECRET: ${SUPABASE_JWT_SECRET} + JWT_EXP: 3600 + volumes: + - db-data:/var/lib/postgresql/data + - ./volumes/db/init:/docker-entrypoint-initdb.d:ro + networks: + - colectivo + + # ── GoTrue ───────────────────────────────────────────────────────────────── + auth: + image: supabase/gotrue:v2.158.1 + restart: always + depends_on: + db: + condition: service_healthy + environment: + GOTRUE_API_HOST: "0.0.0.0" + GOTRUE_API_PORT: 9999 + GOTRUE_DB_DRIVER: postgres + GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@db:5432/postgres + GOTRUE_SITE_URL: ${PUBLIC_APP_URL} + GOTRUE_JWT_SECRET: ${SUPABASE_JWT_SECRET} + GOTRUE_JWT_EXP: 3600 + GOTRUE_DISABLE_SIGNUP: "true" + GOTRUE_MAILER_AUTOCONFIRM: "false" + GOTRUE_SMTP_HOST: smtp.resend.com + GOTRUE_SMTP_PORT: 587 + GOTRUE_SMTP_USER: resend + GOTRUE_SMTP_PASS: ${RESEND_API_KEY} + GOTRUE_SMTP_ADMIN_EMAIL: ${RESEND_FROM_EMAIL} + networks: + - colectivo + + # ── PostgREST ─────────────────────────────────────────────────────────────── + rest: + image: postgrest/postgrest:v12.2.0 + restart: always + depends_on: + db: + condition: service_healthy + environment: + PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@db:5432/postgres + PGRST_DB_SCHEMAS: public,storage,graphql_public + PGRST_DB_ANON_ROLE: anon + PGRST_JWT_SECRET: ${SUPABASE_JWT_SECRET} + PGRST_DB_USE_LEGACY_GUCS: "false" + PGRST_APP_SETTINGS_JWT_SECRET: ${SUPABASE_JWT_SECRET} + PGRST_APP_SETTINGS_JWT_EXP: 3600 + networks: + - colectivo + + # ── Realtime ───────────────────────────────────────────────────────────────── + realtime: + image: supabase/realtime:v2.30.23 + restart: always + depends_on: + db: + condition: service_healthy + environment: + PORT: 4000 + DB_HOST: db + DB_PORT: 5432 + DB_USER: supabase_replication_admin + DB_PASSWORD: ${POSTGRES_PASSWORD} + DB_NAME: postgres + DB_AFTER_CONNECT_QUERY: SET search_path TO _realtime + DB_ENC_KEY: ${REALTIME_ENC_KEY} + API_JWT_SECRET: ${SUPABASE_JWT_SECRET} + FLY_ALLOC_ID: fly123 + FLY_APP_NAME: realtime + SECRET_KEY_BASE: ${REALTIME_SECRET_KEY_BASE} + ERL_AFLAGS: -proto_dist inet_tcp + ENABLE_TAILSCALE: "false" + DNS_NODES: "''" + command: sh -c "/app/bin/migrate && /app/bin/realtime eval 'Realtime.Release.seeds(Realtime.Repo)' && /app/bin/server" + networks: + - colectivo + + # ── Storage ────────────────────────────────────────────────────────────────── + storage: + image: supabase/storage-api:v1.11.13 + restart: always + depends_on: + db: + condition: service_healthy + rest: + condition: service_started + environment: + ANON_KEY: ${PUBLIC_SUPABASE_ANON_KEY} + SERVICE_KEY: ${SUPABASE_SERVICE_ROLE_KEY} + POSTGREST_URL: http://rest:3000 + PGRST_JWT_SECRET: ${SUPABASE_JWT_SECRET} + DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@db:5432/postgres + FILE_SIZE_LIMIT: 52428800 + STORAGE_BACKEND: file + FILE_STORAGE_BACKEND_PATH: /var/lib/storage + TENANT_ID: colectivo-prod + REGION: local + GLOBAL_S3_BUCKET: stub + ENABLE_IMAGE_TRANSFORMATION: "true" + IMGPROXY_URL: http://imgproxy:5001 + volumes: + - storage-data:/var/lib/storage + networks: + - colectivo + + # ── ImgProxy ───────────────────────────────────────────────────────────── + imgproxy: + image: darthsim/imgproxy:v3.8.0 + restart: always + environment: + IMGPROXY_BIND: ":5001" + IMGPROXY_LOCAL_FILESYSTEM_ROOT: / + IMGPROXY_USE_ETAG: "true" + IMGPROXY_ENABLE_WEBP_DETECTION: "true" + volumes: + - storage-data:/var/lib/storage:ro + networks: + - colectivo + + # ── Kong (API Gateway) ───────────────────────────────────────────────────── + # nginx proxies :80/:443 → Kong :8000 + kong: + image: kong:2.8.1 + restart: always + ports: + - "127.0.0.1:8000:8000" # localhost only — nginx handles TLS termination + depends_on: + db: + condition: service_healthy + environment: + KONG_DATABASE: "off" + KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml + KONG_DNS_ORDER: LAST,A,CNAME + KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth + KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k + KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k + SUPABASE_ANON_KEY: ${PUBLIC_SUPABASE_ANON_KEY} + SUPABASE_SERVICE_KEY: ${SUPABASE_SERVICE_ROLE_KEY} + volumes: + - ./kong.yml:/home/kong/kong.yml:ro + networks: + - colectivo + + # ── pg-meta ─────────────────────────────────────────────────────────────── + meta: + image: supabase/postgres-meta:v0.83.2 + restart: always + depends_on: + db: + condition: service_healthy + environment: + PG_META_PORT: 8080 + PG_META_DB_HOST: db + PG_META_DB_PORT: 5432 + PG_META_DB_NAME: postgres + PG_META_DB_USER: supabase + PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD} + networks: + - colectivo + + # ── Keycloak 24 ────────────────────────────────────────────────────────── + # nginx proxies auth.yourdomain.com → keycloak :8080 + # IMPORTANT: X-Forwarded-Host and X-Forwarded-Port headers must be set by nginx + # or Keycloak will build redirect URIs with internal port 8080. + keycloak: + image: quay.io/keycloak/keycloak:24.0.5 + restart: always + ports: + - "127.0.0.1:8081:8080" # localhost only — nginx proxies auth.* subdomain + command: start --optimized + environment: + KC_DB: postgres + KC_DB_URL: jdbc:postgresql://db:5432/keycloak + KC_DB_USERNAME: keycloak + KC_DB_PASSWORD: ${POSTGRES_PASSWORD} + KC_HOSTNAME: ${KEYCLOAK_HOSTNAME} + KC_HOSTNAME_STRICT: "true" + KC_PROXY: edge + KC_HTTP_ENABLED: "true" + KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN} + KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD} + KC_FEATURES: token-exchange + networks: + - colectivo + + # ── App (SvelteKit Node server) ─────────────────────────────────────────── + app: + image: ghcr.io/${GITHUB_REPOSITORY}/colectivo-web:${IMAGE_TAG:-latest} + restart: always + ports: + - "127.0.0.1:3000:3000" # localhost only — nginx proxies app.* → here + environment: + PUBLIC_SUPABASE_URL: ${PUBLIC_SUPABASE_URL} + PUBLIC_SUPABASE_ANON_KEY: ${PUBLIC_SUPABASE_ANON_KEY} + PUBLIC_KEYCLOAK_URL: ${PUBLIC_KEYCLOAK_URL} + PUBLIC_KEYCLOAK_REALM: ${PUBLIC_KEYCLOAK_REALM} + PUBLIC_KEYCLOAK_CLIENT_ID: ${PUBLIC_KEYCLOAK_CLIENT_ID} + PUBLIC_APP_URL: ${PUBLIC_APP_URL} + NODE_ENV: production + networks: + - colectivo + +networks: + colectivo: + driver: bridge + +volumes: + db-data: + storage-data: diff --git a/infra/kong.yml b/infra/kong.yml new file mode 100644 index 0000000..52d71ee --- /dev/null +++ b/infra/kong.yml @@ -0,0 +1,94 @@ +_format_version: "1.1" + +# Kong declarative config for local dev. +# Routes: /rest/ → postgrest, /auth/ → gotrue, /storage/ → storage-api, +# /realtime/ → realtime, /functions/ → edge-runtime + +services: + - name: auth-v1 + url: http://auth:9999/ + routes: + - name: auth-v1-all + strip_path: true + paths: + - /auth/v1/ + plugins: + - name: cors + + - name: rest-v1 + url: http://rest:3000/ + routes: + - name: rest-v1-all + strip_path: true + paths: + - /rest/v1/ + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - anon + - service + + - name: graphql-v1 + url: http://rest:3000/rpc/graphql + routes: + - name: graphql-v1-all + strip_path: true + paths: + - /graphql/v1 + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: request-transformer + config: + add: + headers: + - Content-Profile:graphql_public + + - name: realtime-v1 + url: http://realtime:4000/socket + routes: + - name: realtime-v1-all + strip_path: true + paths: + - /realtime/v1/ + plugins: + - name: cors + + - name: storage-v1 + url: http://storage:5000/ + routes: + - name: storage-v1-all + strip_path: true + paths: + - /storage/v1/ + plugins: + - name: cors + + - name: meta + url: http://meta:8080/ + routes: + - name: meta-all + strip_path: true + paths: + - /pg/ + +consumers: + - username: anon + keyauth_credentials: + - key: ${SUPABASE_ANON_KEY} + acls: + - group: anon + + - username: service_role + keyauth_credentials: + - key: ${SUPABASE_SERVICE_KEY} + acls: + - group: service diff --git a/infra/scripts/backup-cron.sh b/infra/scripts/backup-cron.sh new file mode 100755 index 0000000..9fe0fba --- /dev/null +++ b/infra/scripts/backup-cron.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# backup-cron.sh — intended to be run by cron on the production host. +# Example crontab entry (daily at 03:00 UTC): +# 0 3 * * * /opt/colectivo/infra/scripts/backup-cron.sh >> /var/log/colectivo-backup.log 2>&1 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +LOG_PREFIX="[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] backup-cron:" + +# Load env vars from the production .env +if [ -f "$SCRIPT_DIR/../../.env" ]; then + # shellcheck disable=SC1091 + set -a; source "$SCRIPT_DIR/../../.env"; set +a +fi + +echo "$LOG_PREFIX Starting scheduled backup" +"$SCRIPT_DIR/backup.sh" supabase +echo "$LOG_PREFIX PostgreSQL backup done" + +"$SCRIPT_DIR/backup.sh" keycloak +echo "$LOG_PREFIX Keycloak backup done" + +# Prune local temp files older than 1 day +find /tmp/colectivo-backups -type f -mtime +1 -delete 2>/dev/null || true + +echo "$LOG_PREFIX All done" diff --git a/infra/scripts/backup.sh b/infra/scripts/backup.sh new file mode 100755 index 0000000..92011df --- /dev/null +++ b/infra/scripts/backup.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# backup.sh — manual backup of a service +# Usage: just backup supabase +# Uploads a timestamped dump to Backblaze B2. + +set -euo pipefail + +SERVICE="${1:-supabase}" +TIMESTAMP=$(date -u +"%Y%m%dT%H%M%SZ") +BACKUP_DIR="${BACKUP_DIR:-/tmp/colectivo-backups}" +mkdir -p "$BACKUP_DIR" + +case "$SERVICE" in + supabase|postgres|db) + DUMP_FILE="$BACKUP_DIR/postgres-$TIMESTAMP.dump" + echo "==> Dumping PostgreSQL → $DUMP_FILE" + docker compose -f "$(dirname "$0")/../docker-compose.prod.yml" exec -T db \ + pg_dumpall -U postgres --clean --if-exists \ + | gzip > "$DUMP_FILE.gz" + DUMP_FILE="$DUMP_FILE.gz" + ;; + keycloak) + DUMP_FILE="$BACKUP_DIR/keycloak-$TIMESTAMP.json" + echo "==> Exporting Keycloak realm → $DUMP_FILE" + docker compose -f "$(dirname "$0")/../docker-compose.prod.yml" exec keycloak \ + /opt/keycloak/bin/kc.sh export \ + --realm colectivo \ + --users realm_file \ + --file /tmp/kc-export.json + docker compose -f "$(dirname "$0")/../docker-compose.prod.yml" cp \ + keycloak:/tmp/kc-export.json "$DUMP_FILE" + gzip "$DUMP_FILE" + DUMP_FILE="$DUMP_FILE.gz" + ;; + *) + echo "Unknown service: $SERVICE. Use 'supabase' or 'keycloak'." >&2 + exit 1 + ;; +esac + +echo "==> Uploading $(basename "$DUMP_FILE") to B2 bucket: ${B2_BUCKET_NAME}" +b2 file upload \ + --contentType application/octet-stream \ + "$B2_BUCKET_NAME" \ + "$DUMP_FILE" \ + "backups/$(basename "$DUMP_FILE")" + +echo "==> Backup complete: $(basename "$DUMP_FILE")" +rm -f "$DUMP_FILE" diff --git a/infra/scripts/deploy.sh b/infra/scripts/deploy.sh new file mode 100755 index 0000000..9d7fb79 --- /dev/null +++ b/infra/scripts/deploy.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# deploy.sh — deploy to production via SSH +# Called by: just deploy +# +# Prerequisites: +# - DEPLOY_HOST and DEPLOY_PATH set in .env +# - SSH key configured for passwordless access +# - Docker image already pushed to GHCR (done by CI) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# Load env +if [ -f "$SCRIPT_DIR/../../.env" ]; then + # shellcheck disable=SC1091 + set -a; source "$SCRIPT_DIR/../../.env"; set +a +fi + +DEPLOY_HOST="${DEPLOY_HOST:?Set DEPLOY_HOST in .env}" +DEPLOY_PATH="${DEPLOY_PATH:-/opt/colectivo}" +IMAGE_TAG="${IMAGE_TAG:-latest}" + +echo "==> Deploying colectivo to $DEPLOY_HOST:$DEPLOY_PATH (tag: $IMAGE_TAG)" + +# 1. Copy compose file and scripts to server +rsync -az --delete \ + "$SCRIPT_DIR/../docker-compose.prod.yml" \ + "$SCRIPT_DIR/../kong.yml" \ + "$DEPLOY_HOST:$DEPLOY_PATH/infra/" + +# 2. SSH: pull image, restart services with zero downtime +ssh "$DEPLOY_HOST" bash -s << EOF +set -euo pipefail +cd "$DEPLOY_PATH" + +echo "--- Pulling latest image (tag: $IMAGE_TAG)" +IMAGE_TAG="$IMAGE_TAG" docker compose -f infra/docker-compose.prod.yml pull app + +echo "--- Rolling restart of app service" +IMAGE_TAG="$IMAGE_TAG" docker compose -f infra/docker-compose.prod.yml up -d --no-deps app + +echo "--- Running pending DB migrations" +docker compose -f infra/docker-compose.prod.yml exec -T db \ + psql -U postgres -f /docker-entrypoint-initdb.d/migrations.sql 2>/dev/null || true + +echo "--- Pruning old images" +docker image prune -f + +echo "--- Deploy done" +EOF + +echo "==> Deployment complete" diff --git a/infra/scripts/restore.sh b/infra/scripts/restore.sh new file mode 100755 index 0000000..521aff8 --- /dev/null +++ b/infra/scripts/restore.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# restore.sh — restore a backup file to a service +# Usage: just restore supabase backups/postgres-20240101T030000Z.dump.gz +# +# WARNING: This is destructive. It will drop and re-create the database. +# Only run on a stopped or isolated instance. + +set -euo pipefail + +SERVICE="${1:?Usage: restore.sh }" +FILE="${2:?Usage: restore.sh }" + +if [ ! -f "$FILE" ]; then + echo "File not found: $FILE" >&2 + exit 1 +fi + +COMPOSE="docker compose -f $(dirname "$0")/../docker-compose.prod.yml" + +case "$SERVICE" in + supabase|postgres|db) + echo "==> Restoring PostgreSQL from $FILE" + echo " WARNING: This will overwrite all data. Press Ctrl-C within 5s to abort." + sleep 5 + + if [[ "$FILE" == *.gz ]]; then + gunzip -c "$FILE" | $COMPOSE exec -T db psql -U postgres + else + $COMPOSE exec -T db psql -U postgres < "$FILE" + fi + echo "==> Restore complete" + ;; + *) + echo "Unknown service: $SERVICE. Use 'supabase'." >&2 + exit 1 + ;; +esac diff --git a/keycloak/realm-export.json b/keycloak/realm-export.json new file mode 100644 index 0000000..2cc1254 --- /dev/null +++ b/keycloak/realm-export.json @@ -0,0 +1,141 @@ +{ + "id": "colectivo", + "realm": "colectivo", + "displayName": "Colectivo", + "enabled": true, + "sslRequired": "external", + "registrationAllowed": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": true, + "editUsernameAllowed": false, + "bruteForceProtected": true, + "accessTokenLifespan": 3600, + "refreshTokenMaxReuse": 0, + "offlineSessionMaxLifespanEnabled": false, + "clients": [ + { + "clientId": "colectivo-web", + "name": "Colectivo Web App", + "enabled": true, + "publicClient": true, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "protocol": "openid-connect", + "attributes": { + "pkce.code.challenge.method": "S256" + }, + "redirectUris": [ + "http://localhost:5173/*", + "http://localhost:3000/*" + ], + "webOrigins": [ + "http://localhost:5173", + "http://localhost:3000" + ], + "fullScopeAllowed": true, + "defaultClientScopes": [ + "web-origins", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + } + ], + "users": [ + { + "id": "11111111-1111-1111-1111-111111111111", + "username": "ana", + "email": "ana@dev.local", + "emailVerified": true, + "enabled": true, + "firstName": "Ana", + "lastName": "García", + "credentials": [ + { + "type": "password", + "value": "test1234", + "temporary": false + } + ] + }, + { + "id": "22222222-2222-2222-2222-222222222222", + "username": "borja", + "email": "borja@dev.local", + "emailVerified": true, + "enabled": true, + "firstName": "Borja", + "lastName": "López", + "credentials": [ + { + "type": "password", + "value": "test1234", + "temporary": false + } + ] + }, + { + "id": "33333333-3333-3333-3333-333333333333", + "username": "carmen", + "email": "carmen@dev.local", + "emailVerified": true, + "enabled": true, + "firstName": "Carmen", + "lastName": "Martínez", + "credentials": [ + { + "type": "password", + "value": "test1234", + "temporary": false + } + ] + }, + { + "id": "44444444-4444-4444-4444-444444444444", + "username": "david", + "email": "david@dev.local", + "emailVerified": true, + "enabled": true, + "firstName": "David", + "lastName": "Fernández", + "credentials": [ + { + "type": "password", + "value": "test1234", + "temporary": false + } + ] + }, + { + "id": "55555555-5555-5555-5555-555555555555", + "username": "eva", + "email": "eva@dev.local", + "emailVerified": true, + "enabled": true, + "firstName": "Eva", + "lastName": "Ruiz", + "credentials": [ + { + "type": "password", + "value": "test1234", + "temporary": false + } + ] + } + ], + "scopeMappings": [], + "clientScopeMappings": {}, + "roles": { + "realm": [], + "client": {} + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..503965b --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "colectivo", + "private": true, + "scripts": { + "build": "turbo run build", + "dev": "turbo run dev --parallel", + "lint": "turbo run lint", + "check": "turbo run check", + "test": "turbo run test" + }, + "devDependencies": { + "turbo": "^2.3.3" + }, + "packageManager": "pnpm@9.15.4", + "engines": { + "node": ">=20", + "pnpm": ">=9" + } +} diff --git a/packages/types/package.json b/packages/types/package.json new file mode 100644 index 0000000..52fee84 --- /dev/null +++ b/packages/types/package.json @@ -0,0 +1,18 @@ +{ + "name": "@colectivo/types", + "version": "0.0.1", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "scripts": { + "check": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "^5.5.0" + } +} diff --git a/packages/types/src/database.ts b/packages/types/src/database.ts new file mode 100644 index 0000000..7b13f4c --- /dev/null +++ b/packages/types/src/database.ts @@ -0,0 +1,14 @@ +// AUTO-GENERATED — do not edit manually. +// Run `just db-types` to regenerate from Supabase schema. +// Placeholder until migrations exist and `supabase gen types typescript` can run. + +export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[]; + +export interface Database { + public: { + Tables: Record; + Views: Record; + Functions: Record; + Enums: Record; + }; +} diff --git a/packages/types/src/domain.ts b/packages/types/src/domain.ts new file mode 100644 index 0000000..e1a784a --- /dev/null +++ b/packages/types/src/domain.ts @@ -0,0 +1,127 @@ +// Domain types for the Colectivo app. +// These are hand-written and represent business concepts. +// database.ts is generated — never edit it manually. + +export type AvatarType = 'initials' | 'emoji' | 'upload'; +export type MemberRole = 'admin' | 'member' | 'guest'; +export type ListStatus = 'active' | 'completed' | 'archived'; +export type NoteColor = + | 'default' + | 'slate' + | 'rose' + | 'orange' + | 'amber' + | 'emerald' + | 'cyan' + | 'violet'; +export type TaskPriority = 'none' | 'low' | 'medium' | 'high' | 'urgent'; +export type Language = 'en' | 'es'; + +export interface User { + id: string; + email: string; + display_name: string; + language: Language; + avatar_type: AvatarType; + avatar_emoji: string | null; + avatar_url: string | null; + created_at: string; +} + +export interface Collective { + id: string; + name: string; + emoji: string; + created_by: string; + created_at: string; +} + +export interface CollectiveMember { + collective_id: string; + user_id: string; + role: MemberRole; + joined_at: string; +} + +export interface CollectiveInvitation { + id: string; + collective_id: string; + invited_by: string; + email: string; + token: string; + role: MemberRole; + expires_at: string; + accepted_at: string | null; + created_at: string; +} + +export interface ShoppingList { + id: string; + collective_id: string; + name: string; + status: ListStatus; + created_by: string; + created_at: string; + completed_at: string | null; + deleted_at: string | null; +} + +export interface ShoppingItem { + id: string; + list_id: string; + name: string; + quantity: number | null; + unit: string | null; + is_checked: boolean; + checked_by: string | null; + checked_at: string | null; + sort_order: number; + created_by: string; + created_at: string; + deleted_at: string | null; +} + +export interface ItemFrequency { + collective_id: string; + name: string; // normalized: lower(trim()) + use_count: number; + last_used_at: string; +} + +export interface TaskList { + id: string; + collective_id: string; + name: string; + created_by: string; + created_at: string; + deleted_at: string | null; +} + +export interface Task { + id: string; + task_list_id: string; + name: string; + is_completed: boolean; + completed_by: string | null; + completed_at: string | null; + priority: TaskPriority; + due_date: string | null; + sort_order: number; + created_by: string; + created_at: string; + deleted_at: string | null; +} + +export interface Note { + id: string; + collective_id: string; + title: string | null; + content: string; + color: NoteColor; + is_pinned: boolean; + is_archived: boolean; + created_by: string; + created_at: string; + updated_at: string; + deleted_at: string | null; +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts new file mode 100644 index 0000000..5d0b06c --- /dev/null +++ b/packages/types/src/index.ts @@ -0,0 +1,2 @@ +export * from './domain.js'; +export * from './database.js'; diff --git a/packages/types/tsconfig.json b/packages/types/tsconfig.json new file mode 100644 index 0000000..9037fbe --- /dev/null +++ b/packages/types/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..3ff5faa --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "apps/*" + - "packages/*" diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 0000000..5edaca3 --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1,82 @@ +# Supabase local development configuration. +# See: https://supabase.com/docs/guides/cli/config + +[api] +enabled = true +port = 54321 +schemas = ["public", "storage", "graphql_public"] +extra_search_path = ["public", "extensions"] +max_rows = 1000 + +[api.tls] +enabled = false + +[db] +port = 54322 +shadow_port = 54320 +major_version = 15 + +[db.pooler] +enabled = false +port = 54329 +pool_mode = "transaction" +default_pool_size = 20 +max_client_conn = 100 + +[realtime] +enabled = true +ip_version = "IPv4" +max_header_length = 4096 + +[studio] +enabled = true +port = 54323 +api_url = "http://127.0.0.1" +openai_api_key = "" + +[inbucket] +enabled = true +port = 54324 + +[storage] +enabled = true +file_size_limit = "50MiB" + +[storage.image_transformation] +enabled = true + +[auth] +enabled = true +site_url = "http://127.0.0.1:5173" +additional_redirect_urls = ["http://localhost:5173"] +jwt_expiry = 3600 +enable_refresh_token_rotation = true +refresh_token_reuse_interval = 10 +enable_signup = false +enable_anonymous_sign_ins = false +enable_manual_linking = false + +[auth.email] +enable_signup = false +double_confirm_changes = true +enable_confirmations = false + +[auth.sms] +enable_signup = false +enable_confirmations = false + +# Keycloak as OIDC provider +# The JWT_SECRET must be set to the Keycloak RS256 public key +[auth.third_party.keycloak] +enabled = false # We bypass GoTrue — Keycloak JWT validated directly by PostgREST + +[edge_runtime] +enabled = true +policy = "oneshot" +inspector_port = 8083 + +[analytics] +enabled = false +port = 54327 +vector_port = 54328 +backend = "postgres" diff --git a/supabase/seed.sql b/supabase/seed.sql new file mode 100644 index 0000000..9b2cfb3 --- /dev/null +++ b/supabase/seed.sql @@ -0,0 +1,60 @@ +-- seed.sql — development seed data +-- Mirrors Keycloak test users (UUIDs must match realm-export.json) +-- Password for all users: test1234 + +-- ── Users ───────────────────────────────────────────────────────────────────── +-- Inserted into the public.users table (synced from Keycloak via trigger in Fase 1) +-- For Fase 0 we insert directly since the trigger isn't written yet. + +INSERT INTO public.users (id, email, display_name, language, avatar_type) +VALUES + ('11111111-1111-1111-1111-111111111111', 'ana@dev.local', 'Ana García', 'es', 'initials'), + ('22222222-2222-2222-2222-222222222222', 'borja@dev.local', 'Borja López', 'es', 'initials'), + ('33333333-3333-3333-3333-333333333333', 'carmen@dev.local', 'Carmen Martínez', 'es', 'initials'), + ('44444444-4444-4444-4444-444444444444', 'david@dev.local', 'David Fernández', 'es', 'initials'), + ('55555555-5555-5555-5555-555555555555', 'eva@dev.local', 'Eva Ruiz', 'es', 'initials') +ON CONFLICT (id) DO NOTHING; + +-- ── Sample collective ────────────────────────────────────────────────────────── +INSERT INTO public.collectives (id, name, emoji, created_by) +VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Casa García-López', '🏠', '11111111-1111-1111-1111-111111111111') +ON CONFLICT (id) DO NOTHING; + +-- ── Collective members ───────────────────────────────────────────────────────── +-- Ana: admin, Borja: member, Carmen: member, David: guest +-- Eva has no collective (for onboarding testing) +INSERT INTO public.collective_members (collective_id, user_id, role) +VALUES + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '11111111-1111-1111-1111-111111111111', 'admin'), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '22222222-2222-2222-2222-222222222222', 'member'), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '33333333-3333-3333-3333-333333333333', 'member'), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '44444444-4444-4444-4444-444444444444', 'guest') +ON CONFLICT (collective_id, user_id) DO NOTHING; + +-- ── Sample shopping list ─────────────────────────────────────────────────────── +INSERT INTO public.shopping_lists (id, collective_id, name, status, created_by) +VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Weekly shop', 'active', '11111111-1111-1111-1111-111111111111') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.shopping_items (list_id, name, quantity, unit, sort_order, created_by) +VALUES + ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Milk', 2, 'L', 1, '11111111-1111-1111-1111-111111111111'), + ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Bread', 1, NULL, 2, '22222222-2222-2222-2222-222222222222'), + ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Eggs', 1, 'doz', 3, '11111111-1111-1111-1111-111111111111'), + ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Coffee', 1, NULL, 4, '33333333-3333-3333-3333-333333333333'), + ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Apples', 6, NULL, 5, '22222222-2222-2222-2222-222222222222') +ON CONFLICT DO NOTHING; + +-- ── Item frequency baseline (from pre-existing items above) ──────────────────── +INSERT INTO public.item_frequency (collective_id, name, use_count, last_used_at) +VALUES + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'milk', 5, now()), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'bread', 4, now()), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'eggs', 3, now()), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'coffee', 3, now()), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'apples', 2, now()), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'butter', 2, now()), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'yogurt', 1, now()) +ON CONFLICT (collective_id, name) DO UPDATE SET + use_count = EXCLUDED.use_count, + last_used_at = EXCLUDED.last_used_at; diff --git a/turbo.json b/turbo.json new file mode 100644 index 0000000..f88ad44 --- /dev/null +++ b/turbo.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://turbo.build/schema.json", + "ui": "tui", + "tasks": { + "build": { + "dependsOn": ["^build"], + "inputs": ["$TURBO_DEFAULT$", ".env*"], + "outputs": [".svelte-kit/**", "build/**", "dist/**"] + }, + "check": { + "dependsOn": ["^build"] + }, + "lint": {}, + "test": { + "dependsOn": ["^build"] + }, + "dev": { + "cache": false, + "persistent": true + } + } +}