Fase 0: scaffold monorepo, SvelteKit skeleton, and dev infrastructure
- Turborepo + pnpm workspaces with apps/web and packages/types - SvelteKit app: Tailwind, Paraglide i18n (en/es), keycloak-js, Supabase client, auth/collective stores, PWA service worker skeleton, all route placeholders - packages/types: domain types (User, Collective, ShoppingList, etc.) and database.ts placeholder for generated types - Justfile with dev, db-*, kc-*, build, backup, restore, deploy recipes - infra/docker-compose.dev.yml: Postgres 15, GoTrue, PostgREST, Realtime v2.83, Storage, Kong (port 8001), Studio, Keycloak 24 - infra/docker-compose.prod.yml, kong.yml, db-init scripts, backup/deploy scripts - keycloak/realm-export.json with colectivo realm and 5 dev test users - supabase/config.toml and seed.sql with sample collective and items - GitHub Actions: ci.yml (lint+typecheck+build) and deploy.yml (GHCR + SSH) - .env.example documenting all required variables - Fixed docker-compose issues: Studio image tag, Kong port conflict (8001), internal role passwords init script, Realtime METRICS_JWT_SECRET/APP_NAME Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
70
.env.example
Normal file
70
.env.example
Normal file
@@ -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
|
||||
65
.github/workflows/ci.yml
vendored
Normal file
65
.github/workflows/ci.yml
vendored
Normal file
@@ -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
|
||||
98
.github/workflows/deploy.yml
vendored
Normal file
98
.github/workflows/deploy.yml
vendored
Normal file
@@ -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
|
||||
35
.gitignore
vendored
Normal file
35
.gitignore
vendored
Normal file
@@ -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/
|
||||
2
.npmrc
Normal file
2
.npmrc
Normal file
@@ -0,0 +1,2 @@
|
||||
shamefully-hoist=false
|
||||
strict-peer-dependencies=false
|
||||
11
CLAUDE.md
11
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 <file> # 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.
|
||||
|
||||
100
Justfile
Normal file
100
Justfile
Normal file
@@ -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
|
||||
14
README.md
14
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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
55
apps/web/Dockerfile
Normal file
55
apps/web/Dockerfile
Normal file
@@ -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"]
|
||||
51
apps/web/messages/en.json
Normal file
51
apps/web/messages/en.json
Normal file
@@ -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"
|
||||
}
|
||||
51
apps/web/messages/es.json
Normal file
51
apps/web/messages/es.json
Normal file
@@ -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"
|
||||
}
|
||||
42
apps/web/package.json
Normal file
42
apps/web/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
6
apps/web/postcss.config.js
Normal file
6
apps/web/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
12
apps/web/project.inlang/settings.json
Normal file
12
apps/web/project.inlang/settings.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
45
apps/web/src/app.css
Normal file
45
apps/web/src/app.css
Normal file
@@ -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;
|
||||
}
|
||||
16
apps/web/src/app.d.ts
vendored
Normal file
16
apps/web/src/app.d.ts
vendored
Normal file
@@ -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 {};
|
||||
13
apps/web/src/app.html
Normal file
13
apps/web/src/app.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="%paraglide.lang%">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
78
apps/web/src/lib/auth.ts
Normal file
78
apps/web/src/lib/auth.ts
Normal file
@@ -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<boolean> {
|
||||
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<string, unknown>;
|
||||
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<void> {
|
||||
getKeycloak().login();
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
getKeycloak().logout({ redirectUri: window.location.origin });
|
||||
}
|
||||
6
apps/web/src/lib/i18n.ts
Normal file
6
apps/web/src/lib/i18n.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createI18n } from '@inlang/paraglide-sveltekit';
|
||||
import * as runtime from '$lib/paraglide/runtime';
|
||||
|
||||
export const i18n = createI18n(runtime, {
|
||||
defaultLanguageTag: 'en'
|
||||
});
|
||||
10
apps/web/src/lib/stores/auth.ts
Normal file
10
apps/web/src/lib/stores/auth.ts
Normal file
@@ -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<Keycloak | null>(null);
|
||||
export const isAuthenticated = writable<boolean>(false);
|
||||
export const currentUser = writable<KeycloakUser | null>(null);
|
||||
export const authLoading = writable<boolean>(true);
|
||||
|
||||
export const token = derived(keycloak, ($kc) => $kc?.token ?? null);
|
||||
22
apps/web/src/lib/stores/collective.ts
Normal file
22
apps/web/src/lib/stores/collective.ts
Normal file
@@ -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<Collective | null>(null);
|
||||
export const collectiveMembers = writable<CollectiveMember[]>([]);
|
||||
export const userCollectives = writable<Collective[]>([]);
|
||||
44
apps/web/src/lib/supabase.ts
Normal file
44
apps/web/src/lib/supabase.ts
Normal file
@@ -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<Database> | null = null;
|
||||
|
||||
export function getSupabase(): SupabaseClient<Database> {
|
||||
if (!supabase) {
|
||||
supabase = createClient<Database>(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'];
|
||||
}
|
||||
83
apps/web/src/routes/(app)/+layout.svelte
Normal file
83
apps/web/src/routes/(app)/+layout.svelte
Normal file
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { isAuthenticated, currentUser, authLoading } from '$lib/stores/auth';
|
||||
import { currentCollective } from '$lib/stores/collective';
|
||||
import { logout } from '$lib/auth';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
const navItems = [
|
||||
{ href: '/lists', label: () => m.nav_lists(), icon: '🛒' },
|
||||
{ href: '/tasks', label: () => m.nav_tasks(), icon: '✓' },
|
||||
{ href: '/notes', label: () => m.nav_notes(), icon: '📄' },
|
||||
{ href: '/search', label: () => m.nav_search(), icon: '🔍' }
|
||||
];
|
||||
|
||||
$: activePath = $page.url.pathname;
|
||||
</script>
|
||||
|
||||
{#if $authLoading}
|
||||
<div class="flex h-screen items-center justify-center bg-background">
|
||||
<span class="text-text-secondary text-sm">{m.loading()}</span>
|
||||
</div>
|
||||
{:else if !$isAuthenticated}
|
||||
<!-- Keycloak redirect is triggered in root layout; show nothing -->
|
||||
{:else}
|
||||
<div class="flex h-screen overflow-hidden bg-background">
|
||||
<!-- Sidebar -->
|
||||
<aside class="flex w-56 flex-shrink-0 flex-col border-r border-slate-200 bg-surface dark:border-slate-700">
|
||||
<!-- Collective name -->
|
||||
<div class="flex h-14 items-center gap-2 border-b border-slate-200 px-4 dark:border-slate-700">
|
||||
<span class="text-xl">{$currentCollective?.emoji ?? '🏠'}</span>
|
||||
<span class="truncate text-sm font-semibold text-slate-900 dark:text-slate-50">
|
||||
{$currentCollective?.name ?? m.app_name()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex-1 overflow-y-auto px-2 py-3">
|
||||
{#each navItems as item}
|
||||
<a
|
||||
href={item.href}
|
||||
class="mb-0.5 flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors
|
||||
{activePath.startsWith(item.href)
|
||||
? 'bg-slate-100 text-slate-900 dark:bg-slate-700 dark:text-slate-50'
|
||||
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-50'}"
|
||||
>
|
||||
<span class="text-base">{item.icon}</span>
|
||||
{item.label()}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<!-- User + settings -->
|
||||
<div class="border-t border-slate-200 p-3 dark:border-slate-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<!-- Avatar -->
|
||||
<div
|
||||
class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-slate-200 text-xs font-semibold text-slate-700 dark:bg-slate-600 dark:text-slate-200"
|
||||
>
|
||||
{$currentUser?.displayName?.slice(0, 2).toUpperCase() ?? '?'}
|
||||
</div>
|
||||
<span class="truncate text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{$currentUser?.displayName ?? ''}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
href="/settings"
|
||||
class="rounded-md p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-600 dark:hover:bg-slate-700 dark:hover:text-slate-300"
|
||||
aria-label={m.nav_settings()}
|
||||
>
|
||||
⚙
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="flex flex-1 flex-col overflow-hidden">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
{/if}
|
||||
12
apps/web/src/routes/(app)/lists/+page.svelte
Normal file
12
apps/web/src/routes/(app)/lists/+page.svelte
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
</script>
|
||||
|
||||
<div class="flex flex-1 flex-col overflow-hidden">
|
||||
<header class="flex h-14 items-center border-b border-slate-200 px-6 dark:border-slate-700">
|
||||
<h1 class="text-base font-semibold text-slate-900 dark:text-slate-50">{m.lists_title()}</h1>
|
||||
</header>
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<!-- Fase 2a: shopping lists content -->
|
||||
</div>
|
||||
</div>
|
||||
12
apps/web/src/routes/(app)/notes/+page.svelte
Normal file
12
apps/web/src/routes/(app)/notes/+page.svelte
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
</script>
|
||||
|
||||
<div class="flex flex-1 flex-col overflow-hidden">
|
||||
<header class="flex h-14 items-center border-b border-slate-200 px-6 dark:border-slate-700">
|
||||
<h1 class="text-base font-semibold text-slate-900 dark:text-slate-50">{m.notes_title()}</h1>
|
||||
</header>
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<!-- Fase 3: notes content -->
|
||||
</div>
|
||||
</div>
|
||||
12
apps/web/src/routes/(app)/search/+page.svelte
Normal file
12
apps/web/src/routes/(app)/search/+page.svelte
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
</script>
|
||||
|
||||
<div class="flex flex-1 flex-col overflow-hidden">
|
||||
<header class="flex h-14 items-center border-b border-slate-200 px-6 dark:border-slate-700">
|
||||
<h1 class="text-base font-semibold text-slate-900 dark:text-slate-50">{m.search_title()}</h1>
|
||||
</header>
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<!-- Fase 4: search content -->
|
||||
</div>
|
||||
</div>
|
||||
12
apps/web/src/routes/(app)/settings/+page.svelte
Normal file
12
apps/web/src/routes/(app)/settings/+page.svelte
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
</script>
|
||||
|
||||
<div class="flex flex-1 flex-col overflow-hidden">
|
||||
<header class="flex h-14 items-center border-b border-slate-200 px-6 dark:border-slate-700">
|
||||
<h1 class="text-base font-semibold text-slate-900 dark:text-slate-50">{m.settings_title()}</h1>
|
||||
</header>
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<!-- Fase 1: settings content (display name, avatar, language) -->
|
||||
</div>
|
||||
</div>
|
||||
12
apps/web/src/routes/(app)/tasks/+page.svelte
Normal file
12
apps/web/src/routes/(app)/tasks/+page.svelte
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
</script>
|
||||
|
||||
<div class="flex flex-1 flex-col overflow-hidden">
|
||||
<header class="flex h-14 items-center border-b border-slate-200 px-6 dark:border-slate-700">
|
||||
<h1 class="text-base font-semibold text-slate-900 dark:text-slate-50">{m.tasks_title()}</h1>
|
||||
</header>
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<!-- Fase 3: tasks content -->
|
||||
</div>
|
||||
</div>
|
||||
51
apps/web/src/routes/+layout.svelte
Normal file
51
apps/web/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { initAuth, getKeycloak, getUser } from '$lib/auth';
|
||||
import { setSupabaseToken, clearSupabaseToken } from '$lib/supabase';
|
||||
import { keycloak, isAuthenticated, currentUser, authLoading } from '$lib/stores/auth';
|
||||
import { ParaglideJS } from '@inlang/paraglide-sveltekit';
|
||||
import { i18n } from '$lib/i18n';
|
||||
|
||||
const PUBLIC_ROUTES = ['/invitation'];
|
||||
|
||||
onMount(async () => {
|
||||
const kc = getKeycloak();
|
||||
keycloak.set(kc);
|
||||
|
||||
const authenticated = await initAuth();
|
||||
isAuthenticated.set(authenticated);
|
||||
|
||||
if (authenticated) {
|
||||
const user = getUser(kc);
|
||||
currentUser.set(user);
|
||||
if (kc.token) setSupabaseToken(kc.token);
|
||||
|
||||
// Keep token in sync with Supabase client
|
||||
kc.onTokenRefreshed = () => {
|
||||
if (kc.token) setSupabaseToken(kc.token);
|
||||
};
|
||||
kc.onAuthLogout = () => {
|
||||
clearSupabaseToken();
|
||||
isAuthenticated.set(false);
|
||||
currentUser.set(null);
|
||||
};
|
||||
}
|
||||
|
||||
authLoading.set(false);
|
||||
|
||||
// Redirect unauthenticated users unless on a public route
|
||||
if (!authenticated) {
|
||||
const isPublic = PUBLIC_ROUTES.some((r) => $page.url.pathname.startsWith(r));
|
||||
if (!isPublic) {
|
||||
kc.login();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<ParaglideJS {i18n}>
|
||||
<slot />
|
||||
</ParaglideJS>
|
||||
16
apps/web/src/routes/+page.svelte
Normal file
16
apps/web/src/routes/+page.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { isAuthenticated, authLoading } from '$lib/stores/auth';
|
||||
|
||||
onMount(() => {
|
||||
const unsubLoading = authLoading.subscribe((loading) => {
|
||||
if (!loading) {
|
||||
unsubLoading();
|
||||
goto('/lists', { replaceState: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Redirect root to /lists — handled in onMount after auth resolves -->
|
||||
25
apps/web/src/routes/onboarding/+page.svelte
Normal file
25
apps/web/src/routes/onboarding/+page.svelte
Normal file
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen flex-col items-center justify-center bg-background p-6">
|
||||
<div class="w-full max-w-sm space-y-6">
|
||||
<div class="text-center">
|
||||
<h1 class="text-2xl font-bold text-slate-900 dark:text-slate-50">{m.onboarding_title()}</h1>
|
||||
</div>
|
||||
|
||||
<!-- Fase 1: collective creation / join form -->
|
||||
<div class="space-y-3">
|
||||
<button
|
||||
class="w-full rounded-md bg-slate-900 px-4 py-3 text-sm font-medium text-white hover:bg-slate-700 dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-200"
|
||||
>
|
||||
{m.onboarding_create_collective()}
|
||||
</button>
|
||||
<button
|
||||
class="w-full rounded-md border border-slate-200 bg-surface px-4 py-3 text-sm font-medium text-slate-700 hover:bg-slate-50 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
>
|
||||
{m.onboarding_join_collective()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
50
apps/web/src/service-worker.ts
Normal file
50
apps/web/src/service-worker.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/// <reference types="@sveltejs/kit" />
|
||||
/// <reference no-default-lib="true"/>
|
||||
/// <reference lib="esnext" />
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
11
apps/web/static/silent-check-sso.html
Normal file
11
apps/web/static/silent-check-sso.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Silent Check SSO</title>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
parent.postMessage(location.href, location.origin);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
17
apps/web/svelte.config.js
Normal file
17
apps/web/svelte.config.js
Normal file
@@ -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;
|
||||
20
apps/web/tailwind.config.ts
Normal file
20
apps/web/tailwind.config.ts
Normal file
@@ -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) / <alpha-value>)',
|
||||
surface: 'hsl(var(--surface) / <alpha-value>)',
|
||||
'surface-raised': 'hsl(var(--surface-raised) / <alpha-value>)'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
} satisfies Config;
|
||||
14
apps/web/tsconfig.json
Normal file
14
apps/web/tsconfig.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
53
apps/web/vite.config.ts
Normal file
53
apps/web/vite.config.ts
Normal file
@@ -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
|
||||
}
|
||||
});
|
||||
26
infra/db-init/00-role-passwords.sh
Executable file
26
infra/db-init/00-role-passwords.sh
Executable file
@@ -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"
|
||||
11
infra/db-init/00-role-passwords.sql
Normal file
11
infra/db-init/00-role-passwords.sql
Normal file
@@ -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';
|
||||
231
infra/docker-compose.dev.yml
Normal file
231
infra/docker-compose.dev.yml
Normal file
@@ -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:
|
||||
233
infra/docker-compose.prod.yml
Normal file
233
infra/docker-compose.prod.yml
Normal file
@@ -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:
|
||||
94
infra/kong.yml
Normal file
94
infra/kong.yml
Normal file
@@ -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
|
||||
27
infra/scripts/backup-cron.sh
Executable file
27
infra/scripts/backup-cron.sh
Executable file
@@ -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"
|
||||
49
infra/scripts/backup.sh
Executable file
49
infra/scripts/backup.sh
Executable file
@@ -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"
|
||||
53
infra/scripts/deploy.sh
Executable file
53
infra/scripts/deploy.sh
Executable file
@@ -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"
|
||||
37
infra/scripts/restore.sh
Executable file
37
infra/scripts/restore.sh
Executable file
@@ -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 <service> <file>}"
|
||||
FILE="${2:?Usage: restore.sh <service> <file>}"
|
||||
|
||||
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
|
||||
141
keycloak/realm-export.json
Normal file
141
keycloak/realm-export.json
Normal file
@@ -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": {}
|
||||
}
|
||||
}
|
||||
19
package.json
Normal file
19
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
18
packages/types/package.json
Normal file
18
packages/types/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
14
packages/types/src/database.ts
Normal file
14
packages/types/src/database.ts
Normal file
@@ -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<string, never>;
|
||||
Views: Record<string, never>;
|
||||
Functions: Record<string, never>;
|
||||
Enums: Record<string, never>;
|
||||
};
|
||||
}
|
||||
127
packages/types/src/domain.ts
Normal file
127
packages/types/src/domain.ts
Normal file
@@ -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;
|
||||
}
|
||||
2
packages/types/src/index.ts
Normal file
2
packages/types/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './domain.js';
|
||||
export * from './database.js';
|
||||
14
packages/types/tsconfig.json
Normal file
14
packages/types/tsconfig.json
Normal file
@@ -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"]
|
||||
}
|
||||
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
- "apps/*"
|
||||
- "packages/*"
|
||||
82
supabase/config.toml
Normal file
82
supabase/config.toml
Normal file
@@ -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"
|
||||
60
supabase/seed.sql
Normal file
60
supabase/seed.sql
Normal file
@@ -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;
|
||||
22
turbo.json
Normal file
22
turbo.json
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user