From 32939fbfb59773f32e8467cc1c40fa70f5e9ecee Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 27 Mar 2026 22:43:46 +0100 Subject: [PATCH] refactor(infra): remove zitare + clock NestJS backends, add shared-hono package Both apps are fully local-first via Dexie.js + mana-sync. Their NestJS backends were pure CRUD wrappers (20 + 31 source files) that are no longer needed. Changes: - Add packages/shared-hono: JWT auth via JWKS (jose), Drizzle DB factory, health route, generic GDPR admin handler, error middleware - Migrate zitare lists page from fetch() to listsStore (local-first) - Rewrite clock timers store from API-based to timerCollection (Dexie) - Update clock +layout.svelte CommandBar search to use local collections - Remove zitare-backend + clock-backend from docker-compose, CI/CD, Prometheus, env generation, setup scripts - Add docs/TECHNOLOGY_AUDIT_2026_03.md with full repo analysis Net result: -2 Docker containers, -2 ports, -2728 lines of code Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/cd-macmini.yml | 12 +- .github/workflows/ci.yml | 87 +-- apps/clock/apps/backend/Dockerfile | 54 -- apps/clock/apps/backend/docker-entrypoint.sh | 23 - apps/clock/apps/backend/drizzle.config.ts | 3 - apps/clock/apps/backend/package.json | 60 -- .../backend/src/admin/admin.controller.ts | 47 -- .../apps/backend/src/admin/admin.module.ts | 12 - .../apps/backend/src/admin/admin.service.ts | 145 ----- .../src/admin/dto/user-data-response.dto.ts | 17 - .../src/admin/guards/service-auth.guard.ts | 40 -- .../backend/src/alarm/alarm.controller.ts | 45 -- .../apps/backend/src/alarm/alarm.module.ts | 10 - .../apps/backend/src/alarm/alarm.service.ts | 82 --- .../clock/apps/backend/src/alarm/dto/index.ts | 85 --- apps/clock/apps/backend/src/app.module.ts | 33 - apps/clock/apps/backend/src/db/connection.ts | 38 -- .../apps/backend/src/db/database.module.ts | 28 - .../backend/src/db/schema/alarms.schema.ts | 27 - .../clock/apps/backend/src/db/schema/index.ts | 4 - .../backend/src/db/schema/presets.schema.ts | 24 - .../backend/src/db/schema/timers.schema.ts | 18 - .../src/db/schema/world-clocks.schema.ts | 13 - apps/clock/apps/backend/src/instrument.ts | 8 - apps/clock/apps/backend/src/main.ts | 9 - .../apps/backend/src/preset/dto/index.ts | 65 -- .../backend/src/preset/preset.controller.ts | 40 -- .../apps/backend/src/preset/preset.module.ts | 10 - .../apps/backend/src/preset/preset.service.ts | 65 -- .../clock/apps/backend/src/timer/dto/index.ts | 32 - .../backend/src/timer/timer.controller.ts | 55 -- .../apps/backend/src/timer/timer.module.ts | 10 - .../apps/backend/src/timer/timer.service.ts | 129 ---- .../apps/backend/src/world-clock/dto/index.ts | 15 - .../src/world-clock/world-clock.controller.ts | 41 -- .../src/world-clock/world-clock.module.ts | 10 - .../src/world-clock/world-clock.service.ts | 122 ---- apps/clock/apps/backend/tsconfig.json | 27 - .../apps/web/src/lib/stores/timers.svelte.ts | 346 +++++------ .../apps/web/src/routes/(app)/+layout.svelte | 37 +- apps/zitare/apps/backend/Dockerfile | 99 --- apps/zitare/apps/backend/docker-entrypoint.sh | 36 -- apps/zitare/apps/backend/drizzle.config.ts | 6 - apps/zitare/apps/backend/package.json | 57 -- .../backend/src/admin/admin.controller.ts | 34 -- .../apps/backend/src/admin/admin.module.ts | 12 - .../apps/backend/src/admin/admin.service.ts | 87 --- .../src/admin/dto/user-data-response.dto.ts | 17 - .../src/admin/guards/service-auth.guard.ts | 36 -- apps/zitare/apps/backend/src/app.module.ts | 27 - apps/zitare/apps/backend/src/db/connection.ts | 38 -- .../apps/backend/src/db/database.module.ts | 29 - apps/zitare/apps/backend/src/db/migrate.ts | 29 - .../backend/src/db/schema/favorites.schema.ts | 17 - .../apps/backend/src/db/schema/index.ts | 2 - .../src/db/schema/user-lists.schema.ts | 17 - .../src/favorite/favorite.controller.ts | 52 -- .../backend/src/favorite/favorite.module.ts | 10 - .../backend/src/favorite/favorite.service.ts | 34 -- apps/zitare/apps/backend/src/instrument.ts | 8 - .../apps/backend/src/list/list.controller.ts | 99 --- .../apps/backend/src/list/list.module.ts | 10 - .../apps/backend/src/list/list.service.ts | 76 --- apps/zitare/apps/backend/src/main.ts | 9 - apps/zitare/apps/backend/tsconfig.json | 27 - .../web/src/routes/(app)/lists/+page.svelte | 95 +-- docker/prometheus/prometheus.yml | 14 +- docs/TECHNOLOGY_AUDIT_2026_03.md | 577 ++++++++++++++++++ package.json | 15 +- packages/shared-hono/package.json | 30 + packages/shared-hono/src/admin.ts | 74 +++ packages/shared-hono/src/auth.ts | 122 ++++ packages/shared-hono/src/db.ts | 49 ++ packages/shared-hono/src/error.ts | 47 ++ packages/shared-hono/src/health.ts | 36 ++ packages/shared-hono/src/index.ts | 41 ++ packages/shared-hono/src/types.ts | 20 + packages/shared-hono/tsconfig.json | 14 + scripts/generate-env.mjs | 28 +- scripts/mac-mini/ensure-containers-running.sh | 1 - scripts/setup-databases.sh | 4 +- 81 files changed, 1236 insertions(+), 2727 deletions(-) delete mode 100644 apps/clock/apps/backend/Dockerfile delete mode 100755 apps/clock/apps/backend/docker-entrypoint.sh delete mode 100644 apps/clock/apps/backend/drizzle.config.ts delete mode 100644 apps/clock/apps/backend/package.json delete mode 100644 apps/clock/apps/backend/src/admin/admin.controller.ts delete mode 100644 apps/clock/apps/backend/src/admin/admin.module.ts delete mode 100644 apps/clock/apps/backend/src/admin/admin.service.ts delete mode 100644 apps/clock/apps/backend/src/admin/dto/user-data-response.dto.ts delete mode 100644 apps/clock/apps/backend/src/admin/guards/service-auth.guard.ts delete mode 100644 apps/clock/apps/backend/src/alarm/alarm.controller.ts delete mode 100644 apps/clock/apps/backend/src/alarm/alarm.module.ts delete mode 100644 apps/clock/apps/backend/src/alarm/alarm.service.ts delete mode 100644 apps/clock/apps/backend/src/alarm/dto/index.ts delete mode 100644 apps/clock/apps/backend/src/app.module.ts delete mode 100644 apps/clock/apps/backend/src/db/connection.ts delete mode 100644 apps/clock/apps/backend/src/db/database.module.ts delete mode 100644 apps/clock/apps/backend/src/db/schema/alarms.schema.ts delete mode 100644 apps/clock/apps/backend/src/db/schema/index.ts delete mode 100644 apps/clock/apps/backend/src/db/schema/presets.schema.ts delete mode 100644 apps/clock/apps/backend/src/db/schema/timers.schema.ts delete mode 100644 apps/clock/apps/backend/src/db/schema/world-clocks.schema.ts delete mode 100644 apps/clock/apps/backend/src/instrument.ts delete mode 100644 apps/clock/apps/backend/src/main.ts delete mode 100644 apps/clock/apps/backend/src/preset/dto/index.ts delete mode 100644 apps/clock/apps/backend/src/preset/preset.controller.ts delete mode 100644 apps/clock/apps/backend/src/preset/preset.module.ts delete mode 100644 apps/clock/apps/backend/src/preset/preset.service.ts delete mode 100644 apps/clock/apps/backend/src/timer/dto/index.ts delete mode 100644 apps/clock/apps/backend/src/timer/timer.controller.ts delete mode 100644 apps/clock/apps/backend/src/timer/timer.module.ts delete mode 100644 apps/clock/apps/backend/src/timer/timer.service.ts delete mode 100644 apps/clock/apps/backend/src/world-clock/dto/index.ts delete mode 100644 apps/clock/apps/backend/src/world-clock/world-clock.controller.ts delete mode 100644 apps/clock/apps/backend/src/world-clock/world-clock.module.ts delete mode 100644 apps/clock/apps/backend/src/world-clock/world-clock.service.ts delete mode 100644 apps/clock/apps/backend/tsconfig.json delete mode 100644 apps/zitare/apps/backend/Dockerfile delete mode 100644 apps/zitare/apps/backend/docker-entrypoint.sh delete mode 100644 apps/zitare/apps/backend/drizzle.config.ts delete mode 100644 apps/zitare/apps/backend/package.json delete mode 100644 apps/zitare/apps/backend/src/admin/admin.controller.ts delete mode 100644 apps/zitare/apps/backend/src/admin/admin.module.ts delete mode 100644 apps/zitare/apps/backend/src/admin/admin.service.ts delete mode 100644 apps/zitare/apps/backend/src/admin/dto/user-data-response.dto.ts delete mode 100644 apps/zitare/apps/backend/src/admin/guards/service-auth.guard.ts delete mode 100644 apps/zitare/apps/backend/src/app.module.ts delete mode 100644 apps/zitare/apps/backend/src/db/connection.ts delete mode 100644 apps/zitare/apps/backend/src/db/database.module.ts delete mode 100644 apps/zitare/apps/backend/src/db/migrate.ts delete mode 100644 apps/zitare/apps/backend/src/db/schema/favorites.schema.ts delete mode 100644 apps/zitare/apps/backend/src/db/schema/index.ts delete mode 100644 apps/zitare/apps/backend/src/db/schema/user-lists.schema.ts delete mode 100644 apps/zitare/apps/backend/src/favorite/favorite.controller.ts delete mode 100644 apps/zitare/apps/backend/src/favorite/favorite.module.ts delete mode 100644 apps/zitare/apps/backend/src/favorite/favorite.service.ts delete mode 100644 apps/zitare/apps/backend/src/instrument.ts delete mode 100644 apps/zitare/apps/backend/src/list/list.controller.ts delete mode 100644 apps/zitare/apps/backend/src/list/list.module.ts delete mode 100644 apps/zitare/apps/backend/src/list/list.service.ts delete mode 100644 apps/zitare/apps/backend/src/main.ts delete mode 100644 apps/zitare/apps/backend/tsconfig.json create mode 100644 docs/TECHNOLOGY_AUDIT_2026_03.md create mode 100644 packages/shared-hono/package.json create mode 100644 packages/shared-hono/src/admin.ts create mode 100644 packages/shared-hono/src/auth.ts create mode 100644 packages/shared-hono/src/db.ts create mode 100644 packages/shared-hono/src/error.ts create mode 100644 packages/shared-hono/src/health.ts create mode 100644 packages/shared-hono/src/index.ts create mode 100644 packages/shared-hono/src/types.ts create mode 100644 packages/shared-hono/tsconfig.json diff --git a/.github/workflows/cd-macmini.yml b/.github/workflows/cd-macmini.yml index 163c60266..c46a6f51a 100644 --- a/.github/workflows/cd-macmini.yml +++ b/.github/workflows/cd-macmini.yml @@ -32,7 +32,6 @@ on: - todo-web - calendar-backend - calendar-web - - clock-backend - clock-web - contacts-backend - contacts-web @@ -72,7 +71,6 @@ jobs: todo-web: ${{ steps.changes.outputs.todo-web }} calendar-backend: ${{ steps.changes.outputs.calendar-backend }} calendar-web: ${{ steps.changes.outputs.calendar-web }} - clock-backend: ${{ steps.changes.outputs.clock-backend }} clock-web: ${{ steps.changes.outputs.clock-web }} contacts-backend: ${{ steps.changes.outputs.contacts-backend }} contacts-web: ${{ steps.changes.outputs.contacts-web }} @@ -129,7 +127,7 @@ jobs: check_changes "todo-web" "apps/todo/apps/web/" "apps/todo/packages/" check_changes "calendar-backend" "apps/calendar/apps/backend/" "apps/calendar/packages/" check_changes "calendar-web" "apps/calendar/apps/web/" "apps/calendar/packages/" - check_changes "clock-backend" "apps/clock/apps/backend/" "apps/clock/packages/" + # clock-backend: REMOVED — migrated to local-first check_changes "clock-web" "apps/clock/apps/web/" "apps/clock/packages/" check_changes "contacts-backend" "apps/contacts/apps/backend/" "apps/contacts/packages/" check_changes "contacts-web" "apps/contacts/apps/web/" "apps/contacts/packages/" @@ -142,7 +140,7 @@ jobs: # Check if anything needs deploying ANY="false" - for svc in matrix-web mana-core-auth chat-backend chat-web todo-backend todo-web calendar-backend calendar-web clock-backend clock-web contacts-backend contacts-web mukke-backend mukke-web storage-backend storage-web mana-matrix-bot mana-landing-builder; do + for svc in matrix-web mana-core-auth chat-backend chat-web todo-backend todo-web calendar-backend calendar-web clock-web contacts-backend contacts-web mukke-backend mukke-web storage-backend storage-web mana-matrix-bot mana-landing-builder; do val=$(grep "^$svc=" $GITHUB_OUTPUT | tail -1 | cut -d= -f2) if [ "$val" == "true" ]; then ANY="true" @@ -211,7 +209,7 @@ jobs: if [ "${{ needs.detect-changes.outputs.todo-web }}" == "true" ]; then SERVICES="$SERVICES todo-web"; fi if [ "${{ needs.detect-changes.outputs.calendar-backend }}" == "true" ]; then SERVICES="$SERVICES calendar-backend"; fi if [ "${{ needs.detect-changes.outputs.calendar-web }}" == "true" ]; then SERVICES="$SERVICES calendar-web"; fi - if [ "${{ needs.detect-changes.outputs.clock-backend }}" == "true" ]; then SERVICES="$SERVICES clock-backend"; fi + # clock-backend: REMOVED if [ "${{ needs.detect-changes.outputs.clock-web }}" == "true" ]; then SERVICES="$SERVICES clock-web"; fi if [ "${{ needs.detect-changes.outputs.contacts-backend }}" == "true" ]; then SERVICES="$SERVICES contacts-backend"; fi if [ "${{ needs.detect-changes.outputs.contacts-web }}" == "true" ]; then SERVICES="$SERVICES contacts-web"; fi @@ -331,7 +329,7 @@ jobs: todo-web) echo "http://localhost:5011/health" ;; calendar-backend) echo "http://localhost:3032/health" ;; calendar-web) echo "http://localhost:5012/health" ;; - clock-backend) echo "http://localhost:3033/health" ;; + # clock-backend: REMOVED clock-web) echo "http://localhost:5013/health" ;; contacts-backend) echo "http://localhost:3034/health" ;; contacts-web) echo "http://localhost:5014/health" ;; @@ -348,7 +346,7 @@ jobs: SERVICES="${{ steps.services.outputs.services }}" if [ "$DEPLOY_ALL" == "true" ]; then - SERVICES="mana-auth matrix-web chat-backend chat-web todo-backend todo-web calendar-backend calendar-web clock-backend clock-web contacts-backend contacts-web mukke-backend mukke-web storage-backend storage-web" + SERVICES="mana-auth matrix-web chat-backend chat-web todo-backend todo-web calendar-backend calendar-web clock-web contacts-backend contacts-web mukke-backend mukke-web storage-backend storage-web" fi HEALTH_RESULTS="" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dbbd00ea8..d433932e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,7 +58,6 @@ jobs: todo-web: ${{ steps.changes.outputs.todo-web }} calendar-backend: ${{ steps.changes.outputs.calendar-backend }} calendar-web: ${{ steps.changes.outputs.calendar-web }} - clock-backend: ${{ steps.changes.outputs.clock-backend }} clock-web: ${{ steps.changes.outputs.clock-web }} contacts-backend: ${{ steps.changes.outputs.contacts-backend }} contacts-web: ${{ steps.changes.outputs.contacts-web }} @@ -72,7 +71,6 @@ jobs: skilltree-backend: ${{ steps.changes.outputs.skilltree-backend }} skilltree-web: ${{ steps.changes.outputs.skilltree-web }} mana-matrix-bot: ${{ steps.changes.outputs.mana-matrix-bot }} - zitare-backend: ${{ steps.changes.outputs.zitare-backend }} any-changes: ${{ steps.changes.outputs.any-changes }} steps: - name: Checkout code @@ -95,7 +93,6 @@ jobs: echo "todo-web=true" >> $GITHUB_OUTPUT echo "calendar-backend=true" >> $GITHUB_OUTPUT echo "calendar-web=true" >> $GITHUB_OUTPUT - echo "clock-backend=true" >> $GITHUB_OUTPUT echo "clock-web=true" >> $GITHUB_OUTPUT echo "contacts-backend=true" >> $GITHUB_OUTPUT echo "contacts-web=true" >> $GITHUB_OUTPUT @@ -109,7 +106,6 @@ jobs: echo "skilltree-backend=true" >> $GITHUB_OUTPUT echo "skilltree-web=true" >> $GITHUB_OUTPUT echo "mana-matrix-bot=true" >> $GITHUB_OUTPUT - echo "zitare-backend=true" >> $GITHUB_OUTPUT echo "any-changes=true" >> $GITHUB_OUTPUT exit 0 fi @@ -136,7 +132,6 @@ jobs: echo "todo-web=true" >> $GITHUB_OUTPUT echo "calendar-backend=true" >> $GITHUB_OUTPUT echo "calendar-web=true" >> $GITHUB_OUTPUT - echo "clock-backend=true" >> $GITHUB_OUTPUT echo "clock-web=true" >> $GITHUB_OUTPUT echo "contacts-backend=true" >> $GITHUB_OUTPUT echo "contacts-web=true" >> $GITHUB_OUTPUT @@ -150,7 +145,6 @@ jobs: echo "skilltree-backend=true" >> $GITHUB_OUTPUT echo "skilltree-web=true" >> $GITHUB_OUTPUT echo "mana-matrix-bot=true" >> $GITHUB_OUTPUT - echo "zitare-backend=true" >> $GITHUB_OUTPUT echo "any-changes=true" >> $GITHUB_OUTPUT exit 0 fi @@ -255,13 +249,7 @@ jobs: echo "calendar-web=false" >> $GITHUB_OUTPUT fi - # clock-backend - CLOCK_BACKEND_CHANGED=$(check_pattern "apps/clock/apps/backend/|apps/clock/packages/") - if [ "$COMMON_CHANGED" == "true" ] || [ "$SHARED_AUTH_CHANGED" == "true" ] || [ "$CLOCK_BACKEND_CHANGED" == "true" ]; then - echo "clock-backend=true" >> $GITHUB_OUTPUT - else - echo "clock-backend=false" >> $GITHUB_OUTPUT - fi + # clock-backend: REMOVED — migrated to local-first # clock-web CLOCK_WEB_CHANGED=$(check_pattern "apps/clock/apps/web/|apps/clock/packages/") @@ -367,13 +355,7 @@ jobs: echo "mana-matrix-bot=false" >> $GITHUB_OUTPUT fi - # zitare-backend - ZITARE_BACKEND_CHANGED=$(check_pattern "apps/zitare/apps/backend/|apps/zitare/packages/") - if [ "$COMMON_CHANGED" == "true" ] || [ "$SHARED_AUTH_CHANGED" == "true" ] || [ "$ZITARE_BACKEND_CHANGED" == "true" ]; then - echo "zitare-backend=true" >> $GITHUB_OUTPUT - else - echo "zitare-backend=false" >> $GITHUB_OUTPUT - fi + # zitare-backend: REMOVED — migrated to local-first # Check if any service needs building if grep -q "=true" $GITHUB_OUTPUT; then @@ -397,7 +379,7 @@ jobs: echo "| todo-web | ${{ steps.changes.outputs.todo-web }} |" >> $GITHUB_STEP_SUMMARY echo "| calendar-backend | ${{ steps.changes.outputs.calendar-backend }} |" >> $GITHUB_STEP_SUMMARY echo "| calendar-web | ${{ steps.changes.outputs.calendar-web }} |" >> $GITHUB_STEP_SUMMARY - echo "| clock-backend | ${{ steps.changes.outputs.clock-backend }} |" >> $GITHUB_STEP_SUMMARY + echo "| clock-backend | removed |" >> $GITHUB_STEP_SUMMARY echo "| clock-web | ${{ steps.changes.outputs.clock-web }} |" >> $GITHUB_STEP_SUMMARY echo "| contacts-backend | ${{ steps.changes.outputs.contacts-backend }} |" >> $GITHUB_STEP_SUMMARY echo "| contacts-web | ${{ steps.changes.outputs.contacts-web }} |" >> $GITHUB_STEP_SUMMARY @@ -410,7 +392,7 @@ jobs: echo "| nutriphi-web | ${{ steps.changes.outputs.nutriphi-web }} |" >> $GITHUB_STEP_SUMMARY echo "| skilltree-backend | ${{ steps.changes.outputs.skilltree-backend }} |" >> $GITHUB_STEP_SUMMARY echo "| skilltree-web | ${{ steps.changes.outputs.skilltree-web }} |" >> $GITHUB_STEP_SUMMARY - echo "| zitare-backend | ${{ steps.changes.outputs.zitare-backend }} |" >> $GITHUB_STEP_SUMMARY + echo "| zitare-backend | removed |" >> $GITHUB_STEP_SUMMARY # =========================================== # Validation job - runs on PRs @@ -737,34 +719,7 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max - build-clock-backend: - name: Build clock-backend - runs-on: ubuntu-latest - needs: detect-changes - if: needs.detect-changes.outputs.clock-backend == 'true' - steps: - - uses: actions/checkout@v4 - - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 - - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/metadata-action@v5 - id: meta - with: - images: ghcr.io/${{ github.repository_owner }}/clock-backend - tags: type=raw,value=latest - - uses: docker/build-push-action@v5 - with: - context: . - file: apps/clock/apps/backend/Dockerfile - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.meta.outputs.tags }} - cache-from: type=gha - cache-to: type=gha,mode=max + # build-clock-backend: REMOVED — migrated to local-first build-clock-web: name: Build clock-web @@ -1149,34 +1104,4 @@ jobs: cache-to: type=gha,mode=max # =========================================== - # Zitare Backend - # =========================================== - - build-zitare-backend: - name: Build zitare-backend - runs-on: ubuntu-latest - needs: detect-changes - if: needs.detect-changes.outputs.zitare-backend == 'true' - steps: - - uses: actions/checkout@v4 - - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 - - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/metadata-action@v5 - id: meta - with: - images: ghcr.io/${{ github.repository_owner }}/zitare-backend - tags: type=raw,value=latest - - uses: docker/build-push-action@v5 - with: - context: . - file: apps/zitare/apps/backend/Dockerfile - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.meta.outputs.tags }} - cache-from: type=gha - cache-to: type=gha,mode=max + # Zitare Backend: REMOVED — migrated to local-first diff --git a/apps/clock/apps/backend/Dockerfile b/apps/clock/apps/backend/Dockerfile deleted file mode 100644 index 40949b883..000000000 --- a/apps/clock/apps/backend/Dockerfile +++ /dev/null @@ -1,54 +0,0 @@ -# syntax=docker/dockerfile:1 -# Build stage — inherits pre-built shared packages from nestjs-base -FROM nestjs-base:local AS builder - -# Copy clock-specific packages and backend -COPY apps/clock/packages ./apps/clock/packages -COPY apps/clock/apps/backend ./apps/clock/apps/backend - -# Reinstall to link app-specific dependencies -RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --ignore-scripts - -# Build the backend -WORKDIR /app/apps/clock/apps/backend -RUN pnpm build - -# Remove devDependencies and unnecessary files from node_modules -WORKDIR /app -RUN pnpm prune --prod --no-optional 2>/dev/null || true \ - && find node_modules -name '*.ts' -not -name '*.d.ts' -delete 2>/dev/null || true \ - && find node_modules -name '*.map' -delete 2>/dev/null || true \ - && find node_modules -type d \( -name 'test' -o -name 'tests' -o -name '__tests__' -o -name 'docs' \) -prune -exec rm -rf {} + 2>/dev/null || true - -# Production stage -FROM node:20-alpine AS production - -# Install postgresql-client for health checks -RUN apk add --no-cache postgresql-client - -WORKDIR /app - -# Copy everything from builder (including node_modules) -COPY --from=builder /app/pnpm-workspace.yaml ./ -COPY --from=builder /app/package.json ./ -COPY --from=builder /app/pnpm-lock.yaml ./ -COPY --from=builder /app/node_modules ./node_modules -COPY --from=builder /app/packages ./packages -COPY --from=builder /app/apps/clock ./apps/clock - -# Copy entrypoint script -COPY apps/clock/apps/backend/docker-entrypoint.sh /usr/local/bin/ -RUN chmod +x /usr/local/bin/docker-entrypoint.sh - -WORKDIR /app/apps/clock/apps/backend - -# Expose port -EXPOSE 3017 - -# Health check -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:3017/api/v1/health || exit 1 - -# Run entrypoint script -ENTRYPOINT ["docker-entrypoint.sh"] -CMD ["node", "dist/main.js"] diff --git a/apps/clock/apps/backend/docker-entrypoint.sh b/apps/clock/apps/backend/docker-entrypoint.sh deleted file mode 100755 index 61f611cdd..000000000 --- a/apps/clock/apps/backend/docker-entrypoint.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/bin/sh -set -e - -echo "=== Clock Backend Entrypoint ===" - -# Wait for PostgreSQL to be ready -echo "Waiting for PostgreSQL..." -until pg_isready -h ${DB_HOST:-postgres} -p ${DB_PORT:-5432} -U ${DB_USER:-postgres} 2>/dev/null; do - echo "PostgreSQL is unavailable - sleeping" - sleep 2 -done -echo "PostgreSQL is up!" - -cd /app/apps/clock/apps/backend - -# Run schema push -echo "Pushing database schema..." -npx drizzle-kit push --config drizzle.config.ts --force -echo "Schema push completed!" - -# Execute the main command -echo "Starting application..." -exec "$@" diff --git a/apps/clock/apps/backend/drizzle.config.ts b/apps/clock/apps/backend/drizzle.config.ts deleted file mode 100644 index ab0e40d28..000000000 --- a/apps/clock/apps/backend/drizzle.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { createDrizzleConfig } from '@manacore/shared-drizzle-config'; - -export default createDrizzleConfig({ dbName: 'clock' }); diff --git a/apps/clock/apps/backend/package.json b/apps/clock/apps/backend/package.json deleted file mode 100644 index db6587b2b..000000000 --- a/apps/clock/apps/backend/package.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "name": "@clock/backend", - "version": "0.2.0", - "private": true, - "scripts": { - "build": "nest build", - "start": "nest start", - "dev": "nest start --watch", - "start:dev": "nest start --watch", - "start:debug": "nest start --debug --watch", - "start:prod": "node dist/main", - "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", - "type-check": "tsc --noEmit", - "migration:generate": "drizzle-kit generate", - "migration:run": "tsx src/db/migrate.ts", - "db:push": "drizzle-kit push", - "db:studio": "drizzle-kit studio", - "db:seed": "tsx src/db/seed.ts" - }, - "dependencies": { - "@manacore/shared-error-tracking": "workspace:*", - "@clock/shared": "workspace:*", - "@manacore/shared-nestjs-auth": "workspace:*", - "@manacore/shared-nestjs-health": "workspace:*", - "@manacore/shared-nestjs-metrics": "workspace:*", - "@manacore/shared-nestjs-setup": "workspace:*", - "@nestjs/common": "^10.4.15", - "@nestjs/config": "^3.3.0", - "@nestjs/core": "^10.4.15", - "@nestjs/platform-express": "^10.4.15", - "@nestjs/schedule": "^4.1.2", - "class-transformer": "^0.5.1", - "class-validator": "^0.14.1", - "dotenv": "^16.4.7", - "drizzle-kit": "^0.30.2", - "drizzle-orm": "^0.38.3", - "postgres": "^3.4.5", - "prom-client": "^15.1.0", - "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.1" - }, - "devDependencies": { - "@nestjs/cli": "^10.4.9", - "@nestjs/schematics": "^10.2.3", - "@types/express": "^5.0.0", - "@types/node": "^22.10.2", - "@typescript-eslint/eslint-plugin": "^8.18.1", - "@typescript-eslint/parser": "^8.18.1", - "eslint": "^9.17.0", - "eslint-config-prettier": "^9.1.0", - "eslint-plugin-prettier": "^5.2.1", - "prettier": "^3.4.2", - "source-map-support": "^0.5.21", - "ts-loader": "^9.5.1", - "ts-node": "^10.9.2", - "tsconfig-paths": "^4.2.0", - "tsx": "^4.19.2", - "typescript": "^5.7.2" - } -} diff --git a/apps/clock/apps/backend/src/admin/admin.controller.ts b/apps/clock/apps/backend/src/admin/admin.controller.ts deleted file mode 100644 index d57fa7127..000000000 --- a/apps/clock/apps/backend/src/admin/admin.controller.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { - Controller, - Get, - Delete, - Param, - UseGuards, - Logger, - HttpCode, - HttpStatus, -} from '@nestjs/common'; -import { AdminService } from './admin.service'; -import { ServiceAuthGuard } from './guards/service-auth.guard'; -import { UserDataResponse, DeleteUserDataResponse } from './dto/user-data-response.dto'; - -/** - * Admin controller for user data queries - * Used by mana-core-auth aggregation service - * Protected by X-Service-Key authentication - */ -@Controller('admin') -@UseGuards(ServiceAuthGuard) -export class AdminController { - private readonly logger = new Logger(AdminController.name); - - constructor(private readonly adminService: AdminService) {} - - /** - * Get user data counts for a specific user - * GET /api/v1/admin/user-data/:userId - */ - @Get('user-data/:userId') - async getUserData(@Param('userId') userId: string): Promise { - this.logger.log(`Admin request: getUserData for userId=${userId}`); - return this.adminService.getUserData(userId); - } - - /** - * Delete all user data (GDPR right to be forgotten) - * DELETE /api/v1/admin/user-data/:userId - */ - @Delete('user-data/:userId') - @HttpCode(HttpStatus.OK) - async deleteUserData(@Param('userId') userId: string): Promise { - this.logger.log(`Admin request: deleteUserData for userId=${userId}`); - return this.adminService.deleteUserData(userId); - } -} diff --git a/apps/clock/apps/backend/src/admin/admin.module.ts b/apps/clock/apps/backend/src/admin/admin.module.ts deleted file mode 100644 index a8f6ed50c..000000000 --- a/apps/clock/apps/backend/src/admin/admin.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { AdminController } from './admin.controller'; -import { AdminService } from './admin.service'; -import { DatabaseModule } from '../db/database.module'; - -@Module({ - imports: [ConfigModule, DatabaseModule], - controllers: [AdminController], - providers: [AdminService], -}) -export class AdminModule {} diff --git a/apps/clock/apps/backend/src/admin/admin.service.ts b/apps/clock/apps/backend/src/admin/admin.service.ts deleted file mode 100644 index fe87d0ae4..000000000 --- a/apps/clock/apps/backend/src/admin/admin.service.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { Inject } from '@nestjs/common'; -import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; -import { eq, sql, desc } from 'drizzle-orm'; -import * as schema from '../db/schema'; -import { - UserDataResponse, - DeleteUserDataResponse, - EntityCount, -} from './dto/user-data-response.dto'; - -@Injectable() -export class AdminService { - private readonly logger = new Logger(AdminService.name); - - constructor( - @Inject('DATABASE_CONNECTION') - private readonly db: PostgresJsDatabase - ) {} - - /** - * Get user data counts for a specific user - */ - async getUserData(userId: string): Promise { - this.logger.log(`Getting user data for userId: ${userId}`); - - // Count alarms - const alarmsResult = await this.db - .select({ count: sql`count(*)::int` }) - .from(schema.alarms) - .where(eq(schema.alarms.userId, userId)); - const alarmsCount = alarmsResult[0]?.count ?? 0; - - // Count timers - const timersResult = await this.db - .select({ count: sql`count(*)::int` }) - .from(schema.timers) - .where(eq(schema.timers.userId, userId)); - const timersCount = timersResult[0]?.count ?? 0; - - // Count world clocks - const worldClocksResult = await this.db - .select({ count: sql`count(*)::int` }) - .from(schema.worldClocks) - .where(eq(schema.worldClocks.userId, userId)); - const worldClocksCount = worldClocksResult[0]?.count ?? 0; - - // Count presets - const presetsResult = await this.db - .select({ count: sql`count(*)::int` }) - .from(schema.presets) - .where(eq(schema.presets.userId, userId)); - const presetsCount = presetsResult[0]?.count ?? 0; - - // Get last activity (most recent alarm update) - const lastAlarm = await this.db - .select({ updatedAt: schema.alarms.updatedAt }) - .from(schema.alarms) - .where(eq(schema.alarms.userId, userId)) - .orderBy(desc(schema.alarms.updatedAt)) - .limit(1); - const lastActivityAt = lastAlarm[0]?.updatedAt?.toISOString(); - - const entities: EntityCount[] = [ - { entity: 'alarms', count: alarmsCount, label: 'Wecker' }, - { entity: 'timers', count: timersCount, label: 'Timer' }, - { entity: 'world_clocks', count: worldClocksCount, label: 'Weltuhren' }, - { entity: 'presets', count: presetsCount, label: 'Vorlagen' }, - ]; - - const totalCount = alarmsCount + timersCount + worldClocksCount + presetsCount; - - return { - entities, - totalCount, - lastActivityAt, - }; - } - - /** - * Delete all user data (GDPR right to be forgotten) - */ - async deleteUserData(userId: string): Promise { - this.logger.log(`Deleting user data for userId: ${userId}`); - - const deletedCounts: EntityCount[] = []; - let totalDeleted = 0; - - // Delete alarms - const deletedAlarms = await this.db - .delete(schema.alarms) - .where(eq(schema.alarms.userId, userId)) - .returning(); - deletedCounts.push({ - entity: 'alarms', - count: deletedAlarms.length, - label: 'Wecker', - }); - totalDeleted += deletedAlarms.length; - - // Delete timers - const deletedTimers = await this.db - .delete(schema.timers) - .where(eq(schema.timers.userId, userId)) - .returning(); - deletedCounts.push({ - entity: 'timers', - count: deletedTimers.length, - label: 'Timer', - }); - totalDeleted += deletedTimers.length; - - // Delete world clocks - const deletedWorldClocks = await this.db - .delete(schema.worldClocks) - .where(eq(schema.worldClocks.userId, userId)) - .returning(); - deletedCounts.push({ - entity: 'world_clocks', - count: deletedWorldClocks.length, - label: 'Weltuhren', - }); - totalDeleted += deletedWorldClocks.length; - - // Delete presets - const deletedPresets = await this.db - .delete(schema.presets) - .where(eq(schema.presets.userId, userId)) - .returning(); - deletedCounts.push({ - entity: 'presets', - count: deletedPresets.length, - label: 'Vorlagen', - }); - totalDeleted += deletedPresets.length; - - this.logger.log(`Deleted ${totalDeleted} records for userId: ${userId}`); - - return { - success: true, - deletedCounts, - totalDeleted, - }; - } -} diff --git a/apps/clock/apps/backend/src/admin/dto/user-data-response.dto.ts b/apps/clock/apps/backend/src/admin/dto/user-data-response.dto.ts deleted file mode 100644 index 562a2eb6d..000000000 --- a/apps/clock/apps/backend/src/admin/dto/user-data-response.dto.ts +++ /dev/null @@ -1,17 +0,0 @@ -export interface EntityCount { - entity: string; - count: number; - label: string; -} - -export interface UserDataResponse { - entities: EntityCount[]; - totalCount: number; - lastActivityAt?: string; -} - -export interface DeleteUserDataResponse { - success: boolean; - deletedCounts: EntityCount[]; - totalDeleted: number; -} diff --git a/apps/clock/apps/backend/src/admin/guards/service-auth.guard.ts b/apps/clock/apps/backend/src/admin/guards/service-auth.guard.ts deleted file mode 100644 index 81b60d0a4..000000000 --- a/apps/clock/apps/backend/src/admin/guards/service-auth.guard.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { - Injectable, - CanActivate, - ExecutionContext, - UnauthorizedException, - Logger, -} from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { Request } from 'express'; - -/** - * Guard for internal service-to-service authentication using X-Service-Key header - * Used by mana-core-auth to query user data across backends - */ -@Injectable() -export class ServiceAuthGuard implements CanActivate { - private readonly logger = new Logger(ServiceAuthGuard.name); - private readonly serviceKey: string; - - constructor(private readonly configService: ConfigService) { - this.serviceKey = this.configService.get('ADMIN_SERVICE_KEY', 'dev-admin-key'); - } - - canActivate(context: ExecutionContext): boolean { - const request = context.switchToHttp().getRequest(); - const providedKey = request.headers['x-service-key'] as string; - - if (!providedKey) { - this.logger.warn('Missing X-Service-Key header'); - throw new UnauthorizedException('Missing service key'); - } - - if (providedKey !== this.serviceKey) { - this.logger.warn('Invalid service key provided'); - throw new UnauthorizedException('Invalid service key'); - } - - return true; - } -} diff --git a/apps/clock/apps/backend/src/alarm/alarm.controller.ts b/apps/clock/apps/backend/src/alarm/alarm.controller.ts deleted file mode 100644 index 64962cc81..000000000 --- a/apps/clock/apps/backend/src/alarm/alarm.controller.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Controller, Get, Post, Put, Patch, Delete, Body, Param, UseGuards } from '@nestjs/common'; -import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; -import { AlarmService } from './alarm.service'; -import { CreateAlarmDto, UpdateAlarmDto } from './dto'; - -@Controller('alarms') -@UseGuards(JwtAuthGuard) -export class AlarmController { - constructor(private readonly alarmService: AlarmService) {} - - @Get() - async findAll(@CurrentUser() user: CurrentUserData) { - return this.alarmService.findAll(user.userId); - } - - @Get(':id') - async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { - return this.alarmService.findByIdOrThrow(id, user.userId); - } - - @Post() - async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateAlarmDto) { - return this.alarmService.create(user.userId, dto); - } - - @Put(':id') - async update( - @CurrentUser() user: CurrentUserData, - @Param('id') id: string, - @Body() dto: UpdateAlarmDto - ) { - return this.alarmService.update(id, user.userId, dto); - } - - @Patch(':id/toggle') - async toggle(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { - return this.alarmService.toggle(id, user.userId); - } - - @Delete(':id') - async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { - await this.alarmService.delete(id, user.userId); - return { success: true }; - } -} diff --git a/apps/clock/apps/backend/src/alarm/alarm.module.ts b/apps/clock/apps/backend/src/alarm/alarm.module.ts deleted file mode 100644 index 209c91378..000000000 --- a/apps/clock/apps/backend/src/alarm/alarm.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { AlarmController } from './alarm.controller'; -import { AlarmService } from './alarm.service'; - -@Module({ - controllers: [AlarmController], - providers: [AlarmService], - exports: [AlarmService], -}) -export class AlarmModule {} diff --git a/apps/clock/apps/backend/src/alarm/alarm.service.ts b/apps/clock/apps/backend/src/alarm/alarm.service.ts deleted file mode 100644 index be886d51f..000000000 --- a/apps/clock/apps/backend/src/alarm/alarm.service.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { Injectable, Inject, NotFoundException } from '@nestjs/common'; -import { eq, and } from 'drizzle-orm'; -import { DATABASE_CONNECTION } from '../db/database.module'; -import { type Database } from '../db/connection'; -import { alarms, type Alarm } from '../db/schema'; -import { CreateAlarmDto, UpdateAlarmDto } from './dto'; - -@Injectable() -export class AlarmService { - constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} - - async findAll(userId: string): Promise { - return this.db.select().from(alarms).where(eq(alarms.userId, userId)); - } - - async findById(id: string, userId: string): Promise { - const result = await this.db - .select() - .from(alarms) - .where(and(eq(alarms.id, id), eq(alarms.userId, userId))) - .limit(1); - return result[0] || null; - } - - async findByIdOrThrow(id: string, userId: string): Promise { - const alarm = await this.findById(id, userId); - if (!alarm) { - throw new NotFoundException(`Alarm with id ${id} not found`); - } - return alarm; - } - - async create(userId: string, dto: CreateAlarmDto): Promise { - const result = await this.db - .insert(alarms) - .values({ - userId, - label: dto.label, - time: dto.time, - enabled: dto.enabled ?? true, - repeatDays: dto.repeatDays, - snoozeMinutes: dto.snoozeMinutes ?? 5, - sound: dto.sound ?? 'default', - vibrate: dto.vibrate ?? true, - }) - .returning(); - return result[0]; - } - - async update(id: string, userId: string, dto: UpdateAlarmDto): Promise { - await this.findByIdOrThrow(id, userId); - - const result = await this.db - .update(alarms) - .set({ - ...dto, - updatedAt: new Date(), - }) - .where(and(eq(alarms.id, id), eq(alarms.userId, userId))) - .returning(); - return result[0]; - } - - async toggle(id: string, userId: string): Promise { - const alarm = await this.findByIdOrThrow(id, userId); - - const result = await this.db - .update(alarms) - .set({ - enabled: !alarm.enabled, - updatedAt: new Date(), - }) - .where(and(eq(alarms.id, id), eq(alarms.userId, userId))) - .returning(); - return result[0]; - } - - async delete(id: string, userId: string): Promise { - await this.findByIdOrThrow(id, userId); - await this.db.delete(alarms).where(and(eq(alarms.id, id), eq(alarms.userId, userId))); - } -} diff --git a/apps/clock/apps/backend/src/alarm/dto/index.ts b/apps/clock/apps/backend/src/alarm/dto/index.ts deleted file mode 100644 index 3ea1e51ee..000000000 --- a/apps/clock/apps/backend/src/alarm/dto/index.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { - IsString, - IsOptional, - IsBoolean, - IsArray, - IsNumber, - Min, - Max, - Matches, -} from 'class-validator'; - -export class CreateAlarmDto { - @IsOptional() - @IsString() - label?: string; - - @IsString() - @Matches(/^([01]\d|2[0-3]):([0-5]\d):([0-5]\d)$/, { - message: 'time must be in HH:MM:SS format', - }) - time!: string; - - @IsOptional() - @IsBoolean() - enabled?: boolean; - - @IsOptional() - @IsArray() - @IsNumber({}, { each: true }) - @Min(0, { each: true }) - @Max(6, { each: true }) - repeatDays?: number[]; - - @IsOptional() - @IsNumber() - @Min(1) - @Max(60) - snoozeMinutes?: number; - - @IsOptional() - @IsString() - sound?: string; - - @IsOptional() - @IsBoolean() - vibrate?: boolean; -} - -export class UpdateAlarmDto { - @IsOptional() - @IsString() - label?: string; - - @IsOptional() - @IsString() - @Matches(/^([01]\d|2[0-3]):([0-5]\d):([0-5]\d)$/, { - message: 'time must be in HH:MM:SS format', - }) - time?: string; - - @IsOptional() - @IsBoolean() - enabled?: boolean; - - @IsOptional() - @IsArray() - @IsNumber({}, { each: true }) - @Min(0, { each: true }) - @Max(6, { each: true }) - repeatDays?: number[]; - - @IsOptional() - @IsNumber() - @Min(1) - @Max(60) - snoozeMinutes?: number; - - @IsOptional() - @IsString() - sound?: string; - - @IsOptional() - @IsBoolean() - vibrate?: boolean; -} diff --git a/apps/clock/apps/backend/src/app.module.ts b/apps/clock/apps/backend/src/app.module.ts deleted file mode 100644 index 0e9517293..000000000 --- a/apps/clock/apps/backend/src/app.module.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { ScheduleModule } from '@nestjs/schedule'; -import { MetricsModule } from '@manacore/shared-nestjs-metrics'; -import { DatabaseModule } from './db/database.module'; -import { HealthModule } from '@manacore/shared-nestjs-health'; -import { AlarmModule } from './alarm/alarm.module'; -import { TimerModule } from './timer/timer.module'; -import { WorldClockModule } from './world-clock/world-clock.module'; -import { PresetModule } from './preset/preset.module'; -import { AdminModule } from './admin/admin.module'; - -@Module({ - imports: [ - ConfigModule.forRoot({ - isGlobal: true, - envFilePath: '.env', - }), - ScheduleModule.forRoot(), - MetricsModule.register({ - prefix: 'clock_', - excludePaths: ['/health'], - }), - DatabaseModule, - HealthModule.forRoot({ serviceName: 'clock-backend' }), - AlarmModule, - TimerModule, - WorldClockModule, - PresetModule, - AdminModule, - ], -}) -export class AppModule {} diff --git a/apps/clock/apps/backend/src/db/connection.ts b/apps/clock/apps/backend/src/db/connection.ts deleted file mode 100644 index fccc63f4a..000000000 --- a/apps/clock/apps/backend/src/db/connection.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { drizzle } from 'drizzle-orm/postgres-js'; -import * as schema from './schema'; - -// Use require for postgres to avoid ESM/CommonJS interop issues -// eslint-disable-next-line @typescript-eslint/no-var-requires -const postgres = require('postgres'); - -let connection: ReturnType | null = null; -let db: ReturnType | null = null; - -export function getConnection(databaseUrl: string) { - if (!connection) { - connection = postgres(databaseUrl, { - max: 10, - idle_timeout: 20, - connect_timeout: 10, - }); - } - return connection; -} - -export function getDb(databaseUrl: string) { - if (!db) { - const conn = getConnection(databaseUrl); - db = drizzle(conn, { schema }); - } - return db; -} - -export async function closeConnection() { - if (connection) { - await connection.end(); - connection = null; - db = null; - } -} - -export type Database = ReturnType; diff --git a/apps/clock/apps/backend/src/db/database.module.ts b/apps/clock/apps/backend/src/db/database.module.ts deleted file mode 100644 index b4d1f2af6..000000000 --- a/apps/clock/apps/backend/src/db/database.module.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Module, Global, OnModuleDestroy } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { getDb, closeConnection, type Database } from './connection'; - -export const DATABASE_CONNECTION = 'DATABASE_CONNECTION'; - -@Global() -@Module({ - providers: [ - { - provide: DATABASE_CONNECTION, - useFactory: (configService: ConfigService): Database => { - const databaseUrl = configService.get('DATABASE_URL'); - if (!databaseUrl) { - throw new Error('DATABASE_URL environment variable is not set'); - } - return getDb(databaseUrl); - }, - inject: [ConfigService], - }, - ], - exports: [DATABASE_CONNECTION], -}) -export class DatabaseModule implements OnModuleDestroy { - async onModuleDestroy() { - await closeConnection(); - } -} diff --git a/apps/clock/apps/backend/src/db/schema/alarms.schema.ts b/apps/clock/apps/backend/src/db/schema/alarms.schema.ts deleted file mode 100644 index aea182ed6..000000000 --- a/apps/clock/apps/backend/src/db/schema/alarms.schema.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { - pgTable, - uuid, - text, - varchar, - time, - boolean, - integer, - timestamp, -} from 'drizzle-orm/pg-core'; - -export const alarms = pgTable('alarms', { - id: uuid('id').primaryKey().defaultRandom(), - userId: text('user_id').notNull(), - label: varchar('label', { length: 255 }), - time: time('time').notNull(), - enabled: boolean('enabled').default(true).notNull(), - repeatDays: integer('repeat_days').array(), // [0-6] for weekdays (0=Sun) - snoozeMinutes: integer('snooze_minutes').default(5), - sound: varchar('sound', { length: 100 }).default('default'), - vibrate: boolean('vibrate').default(true), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), -}); - -export type Alarm = typeof alarms.$inferSelect; -export type NewAlarm = typeof alarms.$inferInsert; diff --git a/apps/clock/apps/backend/src/db/schema/index.ts b/apps/clock/apps/backend/src/db/schema/index.ts deleted file mode 100644 index 457e6721d..000000000 --- a/apps/clock/apps/backend/src/db/schema/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './alarms.schema'; -export * from './timers.schema'; -export * from './world-clocks.schema'; -export * from './presets.schema'; diff --git a/apps/clock/apps/backend/src/db/schema/presets.schema.ts b/apps/clock/apps/backend/src/db/schema/presets.schema.ts deleted file mode 100644 index 0f5ebf24f..000000000 --- a/apps/clock/apps/backend/src/db/schema/presets.schema.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { pgTable, uuid, text, varchar, integer, jsonb, timestamp } from 'drizzle-orm/pg-core'; - -export interface PresetSettings { - // For pomodoro presets - workDuration?: number; // in seconds - breakDuration?: number; // in seconds - longBreakDuration?: number; // in seconds - sessionsBeforeLongBreak?: number; - // For timer presets - sound?: string; -} - -export const presets = pgTable('presets', { - id: uuid('id').primaryKey().defaultRandom(), - userId: text('user_id').notNull(), - type: varchar('type', { length: 20 }).notNull(), // 'timer' | 'pomodoro' - name: varchar('name', { length: 255 }).notNull(), - durationSeconds: integer('duration_seconds').notNull(), - settings: jsonb('settings').$type(), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), -}); - -export type Preset = typeof presets.$inferSelect; -export type NewPreset = typeof presets.$inferInsert; diff --git a/apps/clock/apps/backend/src/db/schema/timers.schema.ts b/apps/clock/apps/backend/src/db/schema/timers.schema.ts deleted file mode 100644 index f70308218..000000000 --- a/apps/clock/apps/backend/src/db/schema/timers.schema.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { pgTable, uuid, text, varchar, integer, timestamp } from 'drizzle-orm/pg-core'; - -export const timers = pgTable('timers', { - id: uuid('id').primaryKey().defaultRandom(), - userId: text('user_id').notNull(), - label: varchar('label', { length: 255 }), - durationSeconds: integer('duration_seconds').notNull(), - remainingSeconds: integer('remaining_seconds'), - status: varchar('status', { length: 20 }).default('idle').notNull(), // idle, running, paused, finished - startedAt: timestamp('started_at', { withTimezone: true }), - pausedAt: timestamp('paused_at', { withTimezone: true }), - sound: varchar('sound', { length: 100 }).default('default'), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), -}); - -export type Timer = typeof timers.$inferSelect; -export type NewTimer = typeof timers.$inferInsert; diff --git a/apps/clock/apps/backend/src/db/schema/world-clocks.schema.ts b/apps/clock/apps/backend/src/db/schema/world-clocks.schema.ts deleted file mode 100644 index b0508865e..000000000 --- a/apps/clock/apps/backend/src/db/schema/world-clocks.schema.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { pgTable, uuid, text, varchar, integer, timestamp } from 'drizzle-orm/pg-core'; - -export const worldClocks = pgTable('world_clocks', { - id: uuid('id').primaryKey().defaultRandom(), - userId: text('user_id').notNull(), - timezone: varchar('timezone', { length: 100 }).notNull(), // IANA timezone e.g. 'America/New_York' - cityName: varchar('city_name', { length: 255 }).notNull(), - sortOrder: integer('sort_order').default(0).notNull(), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), -}); - -export type WorldClock = typeof worldClocks.$inferSelect; -export type NewWorldClock = typeof worldClocks.$inferInsert; diff --git a/apps/clock/apps/backend/src/instrument.ts b/apps/clock/apps/backend/src/instrument.ts deleted file mode 100644 index fdca49982..000000000 --- a/apps/clock/apps/backend/src/instrument.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { initErrorTracking } from '@manacore/shared-error-tracking'; - -initErrorTracking({ - serviceName: 'clock-backend', - environment: process.env.NODE_ENV, - release: process.env.APP_VERSION, - debug: process.env.NODE_ENV === 'development', -}); diff --git a/apps/clock/apps/backend/src/main.ts b/apps/clock/apps/backend/src/main.ts deleted file mode 100644 index dd8e7c7f7..000000000 --- a/apps/clock/apps/backend/src/main.ts +++ /dev/null @@ -1,9 +0,0 @@ -import './instrument'; -import { bootstrapApp } from '@manacore/shared-nestjs-setup'; -import { AppModule } from './app.module'; - -bootstrapApp(AppModule, { - defaultPort: 3017, - serviceName: 'Clock', - additionalCorsOrigins: ['http://localhost:5186'], -}); diff --git a/apps/clock/apps/backend/src/preset/dto/index.ts b/apps/clock/apps/backend/src/preset/dto/index.ts deleted file mode 100644 index 4a77a6113..000000000 --- a/apps/clock/apps/backend/src/preset/dto/index.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { IsString, IsOptional, IsNumber, Min, Max, IsIn, ValidateNested } from 'class-validator'; -import { Type } from 'class-transformer'; - -class PresetSettingsDto { - @IsOptional() - @IsNumber() - @Min(1) - workDuration?: number; - - @IsOptional() - @IsNumber() - @Min(1) - breakDuration?: number; - - @IsOptional() - @IsNumber() - @Min(1) - longBreakDuration?: number; - - @IsOptional() - @IsNumber() - @Min(1) - @Max(10) - sessionsBeforeLongBreak?: number; - - @IsOptional() - @IsString() - sound?: string; -} - -export class CreatePresetDto { - @IsString() - @IsIn(['timer', 'pomodoro']) - type!: string; - - @IsString() - name!: string; - - @IsNumber() - @Min(1) - @Max(86400) - durationSeconds!: number; - - @IsOptional() - @ValidateNested() - @Type(() => PresetSettingsDto) - settings?: PresetSettingsDto; -} - -export class UpdatePresetDto { - @IsOptional() - @IsString() - name?: string; - - @IsOptional() - @IsNumber() - @Min(1) - @Max(86400) - durationSeconds?: number; - - @IsOptional() - @ValidateNested() - @Type(() => PresetSettingsDto) - settings?: PresetSettingsDto; -} diff --git a/apps/clock/apps/backend/src/preset/preset.controller.ts b/apps/clock/apps/backend/src/preset/preset.controller.ts deleted file mode 100644 index 6416e1e0e..000000000 --- a/apps/clock/apps/backend/src/preset/preset.controller.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common'; -import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; -import { PresetService } from './preset.service'; -import { CreatePresetDto, UpdatePresetDto } from './dto'; - -@Controller('presets') -@UseGuards(JwtAuthGuard) -export class PresetController { - constructor(private readonly presetService: PresetService) {} - - @Get() - async findAll(@CurrentUser() user: CurrentUserData) { - return this.presetService.findAll(user.userId); - } - - @Get(':id') - async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { - return this.presetService.findByIdOrThrow(id, user.userId); - } - - @Post() - async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreatePresetDto) { - return this.presetService.create(user.userId, dto); - } - - @Put(':id') - async update( - @CurrentUser() user: CurrentUserData, - @Param('id') id: string, - @Body() dto: UpdatePresetDto - ) { - return this.presetService.update(id, user.userId, dto); - } - - @Delete(':id') - async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { - await this.presetService.delete(id, user.userId); - return { success: true }; - } -} diff --git a/apps/clock/apps/backend/src/preset/preset.module.ts b/apps/clock/apps/backend/src/preset/preset.module.ts deleted file mode 100644 index ebf6266b3..000000000 --- a/apps/clock/apps/backend/src/preset/preset.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { PresetController } from './preset.controller'; -import { PresetService } from './preset.service'; - -@Module({ - controllers: [PresetController], - providers: [PresetService], - exports: [PresetService], -}) -export class PresetModule {} diff --git a/apps/clock/apps/backend/src/preset/preset.service.ts b/apps/clock/apps/backend/src/preset/preset.service.ts deleted file mode 100644 index 2518b99b0..000000000 --- a/apps/clock/apps/backend/src/preset/preset.service.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Injectable, Inject, NotFoundException } from '@nestjs/common'; -import { eq, and } from 'drizzle-orm'; -import { DATABASE_CONNECTION } from '../db/database.module'; -import { type Database } from '../db/connection'; -import { presets, type Preset, type PresetSettings } from '../db/schema'; -import { CreatePresetDto, UpdatePresetDto } from './dto'; - -@Injectable() -export class PresetService { - constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} - - async findAll(userId: string): Promise { - return this.db.select().from(presets).where(eq(presets.userId, userId)); - } - - async findById(id: string, userId: string): Promise { - const result = await this.db - .select() - .from(presets) - .where(and(eq(presets.id, id), eq(presets.userId, userId))) - .limit(1); - return result[0] || null; - } - - async findByIdOrThrow(id: string, userId: string): Promise { - const preset = await this.findById(id, userId); - if (!preset) { - throw new NotFoundException(`Preset with id ${id} not found`); - } - return preset; - } - - async create(userId: string, dto: CreatePresetDto): Promise { - const result = await this.db - .insert(presets) - .values({ - userId, - type: dto.type, - name: dto.name, - durationSeconds: dto.durationSeconds, - settings: dto.settings as PresetSettings, - }) - .returning(); - return result[0]; - } - - async update(id: string, userId: string, dto: UpdatePresetDto): Promise { - await this.findByIdOrThrow(id, userId); - - const result = await this.db - .update(presets) - .set({ - ...dto, - settings: dto.settings as PresetSettings, - }) - .where(and(eq(presets.id, id), eq(presets.userId, userId))) - .returning(); - return result[0]; - } - - async delete(id: string, userId: string): Promise { - await this.findByIdOrThrow(id, userId); - await this.db.delete(presets).where(and(eq(presets.id, id), eq(presets.userId, userId))); - } -} diff --git a/apps/clock/apps/backend/src/timer/dto/index.ts b/apps/clock/apps/backend/src/timer/dto/index.ts deleted file mode 100644 index 07ea774cc..000000000 --- a/apps/clock/apps/backend/src/timer/dto/index.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { IsString, IsOptional, IsNumber, Min, Max } from 'class-validator'; - -export class CreateTimerDto { - @IsOptional() - @IsString() - label?: string; - - @IsNumber() - @Min(1) - @Max(86400) // Max 24 hours - durationSeconds!: number; - - @IsOptional() - @IsString() - sound?: string; -} - -export class UpdateTimerDto { - @IsOptional() - @IsString() - label?: string; - - @IsOptional() - @IsNumber() - @Min(1) - @Max(86400) - durationSeconds?: number; - - @IsOptional() - @IsString() - sound?: string; -} diff --git a/apps/clock/apps/backend/src/timer/timer.controller.ts b/apps/clock/apps/backend/src/timer/timer.controller.ts deleted file mode 100644 index 0a93efed6..000000000 --- a/apps/clock/apps/backend/src/timer/timer.controller.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common'; -import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; -import { TimerService } from './timer.service'; -import { CreateTimerDto, UpdateTimerDto } from './dto'; - -@Controller('timers') -@UseGuards(JwtAuthGuard) -export class TimerController { - constructor(private readonly timerService: TimerService) {} - - @Get() - async findAll(@CurrentUser() user: CurrentUserData) { - return this.timerService.findAll(user.userId); - } - - @Get(':id') - async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { - return this.timerService.findByIdOrThrow(id, user.userId); - } - - @Post() - async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateTimerDto) { - return this.timerService.create(user.userId, dto); - } - - @Put(':id') - async update( - @CurrentUser() user: CurrentUserData, - @Param('id') id: string, - @Body() dto: UpdateTimerDto - ) { - return this.timerService.update(id, user.userId, dto); - } - - @Post(':id/start') - async start(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { - return this.timerService.start(id, user.userId); - } - - @Post(':id/pause') - async pause(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { - return this.timerService.pause(id, user.userId); - } - - @Post(':id/reset') - async reset(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { - return this.timerService.reset(id, user.userId); - } - - @Delete(':id') - async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { - await this.timerService.delete(id, user.userId); - return { success: true }; - } -} diff --git a/apps/clock/apps/backend/src/timer/timer.module.ts b/apps/clock/apps/backend/src/timer/timer.module.ts deleted file mode 100644 index 753fec76f..000000000 --- a/apps/clock/apps/backend/src/timer/timer.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { TimerController } from './timer.controller'; -import { TimerService } from './timer.service'; - -@Module({ - controllers: [TimerController], - providers: [TimerService], - exports: [TimerService], -}) -export class TimerModule {} diff --git a/apps/clock/apps/backend/src/timer/timer.service.ts b/apps/clock/apps/backend/src/timer/timer.service.ts deleted file mode 100644 index 279bf6d89..000000000 --- a/apps/clock/apps/backend/src/timer/timer.service.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common'; -import { eq, and } from 'drizzle-orm'; -import { DATABASE_CONNECTION } from '../db/database.module'; -import { type Database } from '../db/connection'; -import { timers, type Timer } from '../db/schema'; -import { CreateTimerDto, UpdateTimerDto } from './dto'; - -@Injectable() -export class TimerService { - constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} - - async findAll(userId: string): Promise { - return this.db.select().from(timers).where(eq(timers.userId, userId)); - } - - async findById(id: string, userId: string): Promise { - const result = await this.db - .select() - .from(timers) - .where(and(eq(timers.id, id), eq(timers.userId, userId))) - .limit(1); - return result[0] || null; - } - - async findByIdOrThrow(id: string, userId: string): Promise { - const timer = await this.findById(id, userId); - if (!timer) { - throw new NotFoundException(`Timer with id ${id} not found`); - } - return timer; - } - - async create(userId: string, dto: CreateTimerDto): Promise { - const result = await this.db - .insert(timers) - .values({ - userId, - label: dto.label, - durationSeconds: dto.durationSeconds, - remainingSeconds: dto.durationSeconds, - status: 'idle', - sound: dto.sound ?? 'default', - }) - .returning(); - return result[0]; - } - - async update(id: string, userId: string, dto: UpdateTimerDto): Promise { - await this.findByIdOrThrow(id, userId); - - const result = await this.db - .update(timers) - .set({ - ...dto, - updatedAt: new Date(), - }) - .where(and(eq(timers.id, id), eq(timers.userId, userId))) - .returning(); - return result[0]; - } - - async start(id: string, userId: string): Promise { - const timer = await this.findByIdOrThrow(id, userId); - - if (timer.status === 'running') { - throw new BadRequestException('Timer is already running'); - } - - const result = await this.db - .update(timers) - .set({ - status: 'running', - startedAt: new Date(), - pausedAt: null, - updatedAt: new Date(), - }) - .where(and(eq(timers.id, id), eq(timers.userId, userId))) - .returning(); - return result[0]; - } - - async pause(id: string, userId: string): Promise { - const timer = await this.findByIdOrThrow(id, userId); - - if (timer.status !== 'running') { - throw new BadRequestException('Timer is not running'); - } - - // Calculate remaining seconds - const elapsed = timer.startedAt - ? Math.floor((Date.now() - timer.startedAt.getTime()) / 1000) - : 0; - const remaining = Math.max(0, (timer.remainingSeconds ?? timer.durationSeconds) - elapsed); - - const result = await this.db - .update(timers) - .set({ - status: 'paused', - remainingSeconds: remaining, - pausedAt: new Date(), - updatedAt: new Date(), - }) - .where(and(eq(timers.id, id), eq(timers.userId, userId))) - .returning(); - return result[0]; - } - - async reset(id: string, userId: string): Promise { - const timer = await this.findByIdOrThrow(id, userId); - - const result = await this.db - .update(timers) - .set({ - status: 'idle', - remainingSeconds: timer.durationSeconds, - startedAt: null, - pausedAt: null, - updatedAt: new Date(), - }) - .where(and(eq(timers.id, id), eq(timers.userId, userId))) - .returning(); - return result[0]; - } - - async delete(id: string, userId: string): Promise { - await this.findByIdOrThrow(id, userId); - await this.db.delete(timers).where(and(eq(timers.id, id), eq(timers.userId, userId))); - } -} diff --git a/apps/clock/apps/backend/src/world-clock/dto/index.ts b/apps/clock/apps/backend/src/world-clock/dto/index.ts deleted file mode 100644 index 1ebf978d2..000000000 --- a/apps/clock/apps/backend/src/world-clock/dto/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { IsString, IsArray, IsUUID } from 'class-validator'; - -export class CreateWorldClockDto { - @IsString() - timezone!: string; - - @IsString() - cityName!: string; -} - -export class ReorderWorldClocksDto { - @IsArray() - @IsUUID('4', { each: true }) - ids!: string[]; -} diff --git a/apps/clock/apps/backend/src/world-clock/world-clock.controller.ts b/apps/clock/apps/backend/src/world-clock/world-clock.controller.ts deleted file mode 100644 index 4cc262d12..000000000 --- a/apps/clock/apps/backend/src/world-clock/world-clock.controller.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common'; -import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; -import { WorldClockService } from './world-clock.service'; -import { CreateWorldClockDto, ReorderWorldClocksDto } from './dto'; - -@Controller('world-clocks') -@UseGuards(JwtAuthGuard) -export class WorldClockController { - constructor(private readonly worldClockService: WorldClockService) {} - - @Get() - async findAll(@CurrentUser() user: CurrentUserData) { - return this.worldClockService.findAll(user.userId); - } - - @Post() - async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateWorldClockDto) { - return this.worldClockService.create(user.userId, dto); - } - - @Put('reorder') - async reorder(@CurrentUser() user: CurrentUserData, @Body() dto: ReorderWorldClocksDto) { - return this.worldClockService.reorder(user.userId, dto.ids); - } - - @Delete(':id') - async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { - await this.worldClockService.delete(id, user.userId); - return { success: true }; - } -} - -@Controller('timezones') -export class TimezoneController { - constructor(private readonly worldClockService: WorldClockService) {} - - @Get('search') - async search(@Query('q') query: string) { - return this.worldClockService.searchTimezones(query); - } -} diff --git a/apps/clock/apps/backend/src/world-clock/world-clock.module.ts b/apps/clock/apps/backend/src/world-clock/world-clock.module.ts deleted file mode 100644 index 8403e5f01..000000000 --- a/apps/clock/apps/backend/src/world-clock/world-clock.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { WorldClockController, TimezoneController } from './world-clock.controller'; -import { WorldClockService } from './world-clock.service'; - -@Module({ - controllers: [WorldClockController, TimezoneController], - providers: [WorldClockService], - exports: [WorldClockService], -}) -export class WorldClockModule {} diff --git a/apps/clock/apps/backend/src/world-clock/world-clock.service.ts b/apps/clock/apps/backend/src/world-clock/world-clock.service.ts deleted file mode 100644 index c25e33792..000000000 --- a/apps/clock/apps/backend/src/world-clock/world-clock.service.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { Injectable, Inject, NotFoundException } from '@nestjs/common'; -import { eq, and, asc } from 'drizzle-orm'; -import { DATABASE_CONNECTION } from '../db/database.module'; -import { type Database } from '../db/connection'; -import { worldClocks, type WorldClock } from '../db/schema'; -import { CreateWorldClockDto } from './dto'; - -// Common timezones with city names -const TIMEZONES = [ - { timezone: 'America/New_York', city: 'New York' }, - { timezone: 'America/Los_Angeles', city: 'Los Angeles' }, - { timezone: 'America/Chicago', city: 'Chicago' }, - { timezone: 'America/Denver', city: 'Denver' }, - { timezone: 'America/Toronto', city: 'Toronto' }, - { timezone: 'America/Vancouver', city: 'Vancouver' }, - { timezone: 'America/Mexico_City', city: 'Mexico City' }, - { timezone: 'America/Sao_Paulo', city: 'São Paulo' }, - { timezone: 'America/Buenos_Aires', city: 'Buenos Aires' }, - { timezone: 'Europe/London', city: 'London' }, - { timezone: 'Europe/Paris', city: 'Paris' }, - { timezone: 'Europe/Berlin', city: 'Berlin' }, - { timezone: 'Europe/Rome', city: 'Rome' }, - { timezone: 'Europe/Madrid', city: 'Madrid' }, - { timezone: 'Europe/Amsterdam', city: 'Amsterdam' }, - { timezone: 'Europe/Vienna', city: 'Vienna' }, - { timezone: 'Europe/Zurich', city: 'Zurich' }, - { timezone: 'Europe/Moscow', city: 'Moscow' }, - { timezone: 'Europe/Istanbul', city: 'Istanbul' }, - { timezone: 'Asia/Tokyo', city: 'Tokyo' }, - { timezone: 'Asia/Shanghai', city: 'Shanghai' }, - { timezone: 'Asia/Hong_Kong', city: 'Hong Kong' }, - { timezone: 'Asia/Singapore', city: 'Singapore' }, - { timezone: 'Asia/Seoul', city: 'Seoul' }, - { timezone: 'Asia/Mumbai', city: 'Mumbai' }, - { timezone: 'Asia/Dubai', city: 'Dubai' }, - { timezone: 'Asia/Bangkok', city: 'Bangkok' }, - { timezone: 'Asia/Jakarta', city: 'Jakarta' }, - { timezone: 'Australia/Sydney', city: 'Sydney' }, - { timezone: 'Australia/Melbourne', city: 'Melbourne' }, - { timezone: 'Pacific/Auckland', city: 'Auckland' }, - { timezone: 'Pacific/Honolulu', city: 'Honolulu' }, - { timezone: 'Africa/Cairo', city: 'Cairo' }, - { timezone: 'Africa/Johannesburg', city: 'Johannesburg' }, -]; - -@Injectable() -export class WorldClockService { - constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} - - async findAll(userId: string): Promise { - return this.db - .select() - .from(worldClocks) - .where(eq(worldClocks.userId, userId)) - .orderBy(asc(worldClocks.sortOrder)); - } - - async findById(id: string, userId: string): Promise { - const result = await this.db - .select() - .from(worldClocks) - .where(and(eq(worldClocks.id, id), eq(worldClocks.userId, userId))) - .limit(1); - return result[0] || null; - } - - async findByIdOrThrow(id: string, userId: string): Promise { - const clock = await this.findById(id, userId); - if (!clock) { - throw new NotFoundException(`World clock with id ${id} not found`); - } - return clock; - } - - async create(userId: string, dto: CreateWorldClockDto): Promise { - // Get the max sort order for this user - const existing = await this.findAll(userId); - const maxOrder = existing.length > 0 ? Math.max(...existing.map((c) => c.sortOrder)) : -1; - - const result = await this.db - .insert(worldClocks) - .values({ - userId, - timezone: dto.timezone, - cityName: dto.cityName, - sortOrder: maxOrder + 1, - }) - .returning(); - return result[0]; - } - - async reorder(userId: string, ids: string[]): Promise { - // Update sort order for each world clock - for (let i = 0; i < ids.length; i++) { - await this.db - .update(worldClocks) - .set({ sortOrder: i }) - .where(and(eq(worldClocks.id, ids[i]), eq(worldClocks.userId, userId))); - } - - return this.findAll(userId); - } - - async delete(id: string, userId: string): Promise { - await this.findByIdOrThrow(id, userId); - await this.db - .delete(worldClocks) - .where(and(eq(worldClocks.id, id), eq(worldClocks.userId, userId))); - } - - searchTimezones(query: string): { timezone: string; city: string }[] { - if (!query || query.length < 2) { - return TIMEZONES.slice(0, 10); - } - - const lowerQuery = query.toLowerCase(); - return TIMEZONES.filter( - (tz) => - tz.city.toLowerCase().includes(lowerQuery) || tz.timezone.toLowerCase().includes(lowerQuery) - ).slice(0, 20); - } -} diff --git a/apps/clock/apps/backend/tsconfig.json b/apps/clock/apps/backend/tsconfig.json deleted file mode 100644 index 27971033a..000000000 --- a/apps/clock/apps/backend/tsconfig.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2021", - "module": "commonjs", - "moduleResolution": "node", - "declaration": true, - "removeComments": true, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "allowSyntheticDefaultImports": true, - "outDir": "./dist", - "baseUrl": "./", - "rootDir": "./src", - "incremental": true, - "skipLibCheck": true, - "strictNullChecks": true, - "noImplicitAny": true, - "strictBindCallApply": true, - "forceConsistentCasingInFileNames": true, - "noFallthroughCasesInSwitch": true, - "esModuleInterop": true, - "resolveJsonModule": true, - "sourceMap": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/apps/clock/apps/web/src/lib/stores/timers.svelte.ts b/apps/clock/apps/web/src/lib/stores/timers.svelte.ts index 4813fda64..1d8d4bf4b 100644 --- a/apps/clock/apps/web/src/lib/stores/timers.svelte.ts +++ b/apps/clock/apps/web/src/lib/stores/timers.svelte.ts @@ -1,19 +1,43 @@ /** - * Timers Store - Manages timer state using Svelte 5 runes - * Supports both authenticated (cloud) and guest (session) modes + * Timers Store — Local-First with Dexie.js + * + * All reads and writes go to IndexedDB first. + * When authenticated, changes sync to the server in the background. + * Same public API as before so components don't need changes. */ -import { api } from '$lib/api/client'; -import { sessionTimersStore } from './session-timers.svelte'; -import { authStore } from './auth.svelte'; +import { timerCollection, type LocalTimer } from '$lib/data/local-store'; import type { Timer, CreateTimerInput, UpdateTimerInput } from '@clock/shared'; import { ClockEvents } from '@manacore/shared-utils/analytics'; -// State +// State — populated from IndexedDB let timers = $state([]); let loading = $state(false); let error = $state(null); +/** Convert a LocalTimer (IndexedDB record) to the shared Timer type. */ +function toTimer(local: LocalTimer): Timer { + return { + id: local.id, + userId: 'local', + label: local.label, + durationSeconds: local.durationSeconds, + remainingSeconds: local.remainingSeconds, + status: local.status, + startedAt: local.startedAt, + pausedAt: local.pausedAt, + sound: local.sound, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +/** Load timers from IndexedDB into the reactive state. */ +async function refreshTimers() { + const localTimers = await timerCollection.getAll(); + timers = localTimers.map(toTimer); +} + export const timersStore = { // Getters get timers() { @@ -30,204 +54,201 @@ export const timersStore = { }, /** - * Fetch all timers from the backend - * In guest mode, loads from session storage + * Fetch all timers — reads from IndexedDB. */ async fetchTimers() { loading = true; error = null; - - // Guest mode: load from session storage - if (!authStore.isAuthenticated) { - timers = sessionTimersStore.timers; + try { + await refreshTimers(); + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to fetch timers'; + console.error('Failed to fetch timers:', e); + } finally { loading = false; - return { success: true }; } - - // Authenticated: fetch from API - const response = await api.get('/timers'); - - if (response.error) { - error = response.error.message; - loading = false; - return { success: false, error: response.error.message }; - } - - timers = response.data || []; - loading = false; return { success: true }; }, /** - * Create a new timer - * In guest mode, creates in session storage + * Create a new timer — writes to IndexedDB instantly. */ async createTimer(input: CreateTimerInput) { - // Guest mode: create in session storage - if (!authStore.isAuthenticated) { - const timer = sessionTimersStore.createTimer(input); - timers = [...timers, timer]; - return { success: true, data: timer }; - } + error = null; + try { + const newLocal: LocalTimer = { + id: crypto.randomUUID(), + label: input.label ?? null, + durationSeconds: input.durationSeconds, + remainingSeconds: null, + status: 'idle', + startedAt: null, + pausedAt: null, + sound: input.sound ?? null, + }; - // Authenticated: create via API - const response = await api.post('/timers', input); - - if (response.error) { - return { success: false, error: response.error.message }; + const inserted = await timerCollection.insert(newLocal); + const newTimer = toTimer(inserted); + timers = [...timers, newTimer]; + return { success: true, data: newTimer }; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to create timer'; + console.error('Failed to create timer:', e); + return { success: false, error: error }; } - - if (response.data) { - timers = [...timers, response.data]; - } - return { success: true, data: response.data }; }, /** - * Update a timer - * In guest mode, updates in session storage + * Update a timer — writes to IndexedDB instantly. */ async updateTimer(id: string, input: UpdateTimerInput) { - // Guest mode: update in session storage - if (!authStore.isAuthenticated || sessionTimersStore.isSessionTimer(id)) { - const timer = sessionTimersStore.updateTimer(id, input); - if (timer) { - timers = timers.map((t) => (t.id === id ? timer : t)); - return { success: true, data: timer }; + error = null; + try { + const updateData: Partial = {}; + if (input.label !== undefined) updateData.label = input.label ?? null; + if (input.durationSeconds !== undefined) updateData.durationSeconds = input.durationSeconds; + if (input.sound !== undefined) updateData.sound = input.sound ?? null; + + const updated = await timerCollection.update(id, updateData); + if (updated) { + const updatedTimer = toTimer(updated); + timers = timers.map((t) => (t.id === id ? updatedTimer : t)); + return { success: true, data: updatedTimer }; } return { success: false, error: 'Timer not found' }; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to update timer'; + console.error('Failed to update timer:', e); + return { success: false, error: error }; } - - // Authenticated: update via API - const response = await api.patch(`/timers/${id}`, input); - - if (response.error) { - return { success: false, error: response.error.message }; - } - - if (response.data) { - timers = timers.map((t) => (t.id === id ? response.data! : t)); - } - return { success: true, data: response.data }; }, /** - * Start a timer - * In guest mode, starts in session storage + * Start a timer — sets status to running with current timestamp. */ async startTimer(id: string) { - // Guest mode: start in session storage - if (!authStore.isAuthenticated || sessionTimersStore.isSessionTimer(id)) { - const timer = sessionTimersStore.startTimer(id); - if (timer) { - timers = timers.map((t) => (t.id === id ? timer : t)); - return { success: true, data: timer }; + error = null; + try { + const existing = await timerCollection.get(id); + if (!existing) return { success: false, error: 'Timer not found' }; + + const updateData: Partial = { + status: 'running', + startedAt: new Date().toISOString(), + pausedAt: null, + }; + + // If resuming from pause, keep remaining seconds + if (existing.status !== 'paused') { + updateData.remainingSeconds = existing.durationSeconds; + } + + const updated = await timerCollection.update(id, updateData); + if (updated) { + const updatedTimer = toTimer(updated); + timers = timers.map((t) => (t.id === id ? updatedTimer : t)); + ClockEvents.timerStarted( + (updatedTimer as Timer & { type?: string }).type as 'pomodoro' | 'stopwatch' | 'countdown' + ); + return { success: true, data: updatedTimer }; } return { success: false, error: 'Timer not found' }; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to start timer'; + console.error('Failed to start timer:', e); + return { success: false, error: error }; } - - // Authenticated: start via API - const response = await api.post(`/timers/${id}/start`); - - if (response.error) { - return { success: false, error: response.error.message }; - } - - if (response.data) { - timers = timers.map((t) => (t.id === id ? response.data! : t)); - ClockEvents.timerStarted(response.data.type as 'pomodoro' | 'stopwatch' | 'countdown'); - } - return { success: true, data: response.data }; }, /** - * Pause a timer - * In guest mode, pauses in session storage + * Pause a timer — calculates remaining seconds and saves. */ async pauseTimer(id: string) { - // Guest mode: pause in session storage - if (!authStore.isAuthenticated || sessionTimersStore.isSessionTimer(id)) { - const timer = sessionTimersStore.pauseTimer(id); - if (timer) { - timers = timers.map((t) => (t.id === id ? timer : t)); - return { success: true, data: timer }; + error = null; + try { + const existing = await timerCollection.get(id); + if (!existing) return { success: false, error: 'Timer not found' }; + + // Calculate remaining seconds + let remaining = existing.remainingSeconds ?? existing.durationSeconds; + if (existing.startedAt) { + const elapsed = (Date.now() - new Date(existing.startedAt).getTime()) / 1000; + remaining = Math.max(0, remaining - elapsed); + } + + const updateData: Partial = { + status: 'paused', + pausedAt: new Date().toISOString(), + remainingSeconds: Math.round(remaining), + startedAt: null, + }; + + const updated = await timerCollection.update(id, updateData); + if (updated) { + const updatedTimer = toTimer(updated); + timers = timers.map((t) => (t.id === id ? updatedTimer : t)); + return { success: true, data: updatedTimer }; } return { success: false, error: 'Timer not found' }; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to pause timer'; + console.error('Failed to pause timer:', e); + return { success: false, error: error }; } - - // Authenticated: pause via API - const response = await api.post(`/timers/${id}/pause`); - - if (response.error) { - return { success: false, error: response.error.message }; - } - - if (response.data) { - timers = timers.map((t) => (t.id === id ? response.data! : t)); - } - return { success: true, data: response.data }; }, /** - * Reset a timer - * In guest mode, resets in session storage + * Reset a timer — back to idle with full duration. */ async resetTimer(id: string) { - // Guest mode: reset in session storage - if (!authStore.isAuthenticated || sessionTimersStore.isSessionTimer(id)) { - const timer = sessionTimersStore.resetTimer(id); - if (timer) { - timers = timers.map((t) => (t.id === id ? timer : t)); - return { success: true, data: timer }; + error = null; + try { + const updateData: Partial = { + status: 'idle', + remainingSeconds: null, + startedAt: null, + pausedAt: null, + }; + + const updated = await timerCollection.update(id, updateData); + if (updated) { + const updatedTimer = toTimer(updated); + timers = timers.map((t) => (t.id === id ? updatedTimer : t)); + return { success: true, data: updatedTimer }; } return { success: false, error: 'Timer not found' }; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to reset timer'; + console.error('Failed to reset timer:', e); + return { success: false, error: error }; } - - // Authenticated: reset via API - const response = await api.post(`/timers/${id}/reset`); - - if (response.error) { - return { success: false, error: response.error.message }; - } - - if (response.data) { - timers = timers.map((t) => (t.id === id ? response.data! : t)); - } - return { success: true, data: response.data }; }, /** - * Delete a timer - * In guest mode, deletes from session storage + * Delete a timer — removes from IndexedDB instantly. */ async deleteTimer(id: string) { - // Guest mode: delete from session storage - if (!authStore.isAuthenticated || sessionTimersStore.isSessionTimer(id)) { - sessionTimersStore.deleteTimer(id); + error = null; + try { + await timerCollection.delete(id); timers = timers.filter((t) => t.id !== id); return { success: true }; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to delete timer'; + console.error('Failed to delete timer:', e); + return { success: false, error: error }; } - - // Authenticated: delete via API - const response = await api.delete(`/timers/${id}`); - - if (response.error) { - return { success: false, error: response.error.message }; - } - - timers = timers.filter((t) => t.id !== id); - return { success: true }; }, /** - * Update local timer state (for countdown display) + * Update local timer state (for countdown display). */ updateLocalState(id: string, updates: Partial) { timers = timers.map((t) => (t.id === id ? { ...t, ...updates } : t)); }, /** - * Clear all timers (local state only) + * Clear all timers (local state only). */ clear() { timers = []; @@ -235,52 +256,21 @@ export const timersStore = { }, /** - * Get session timer count (for guest mode banner) + * No longer relevant — all timers are local and editable. */ get sessionTimerCount(): number { - return sessionTimersStore.count; + return 0; }, - /** - * Check if there are session timers - */ get hasSessionTimers(): boolean { - return sessionTimersStore.count > 0; + return false; }, - /** - * Migrate session timers to cloud after login - */ async migrateSessionTimers(): Promise { - if (!authStore.isAuthenticated) return; - - const sessionTimers = sessionTimersStore.getAllTimers(); - if (sessionTimers.length === 0) return; - - // Create each timer via API - for (const timer of sessionTimers) { - try { - await api.post('/timers', { - label: timer.label, - durationSeconds: timer.durationSeconds, - sound: timer.sound, - }); - } catch (e) { - console.error('Failed to migrate timer:', e); - } - } - - // Clear session data after migration - sessionTimersStore.clear(); - - // Reload timers from server - await this.fetchTimers(); + // No-op: local-first mode handles data persistence automatically. }, - /** - * Check if a timer ID is a session timer - */ - isSessionTimer(id: string): boolean { - return sessionTimersStore.isSessionTimer(id); + isSessionTimer(_id: string): boolean { + return false; }, }; diff --git a/apps/clock/apps/web/src/routes/(app)/+layout.svelte b/apps/clock/apps/web/src/routes/(app)/+layout.svelte index a38353739..75de9843f 100644 --- a/apps/clock/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/clock/apps/web/src/routes/(app)/+layout.svelte @@ -1,4 +1,5 @@ @@ -187,7 +118,7 @@ class="w-10 h-10 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto" > - {:else if lists.length === 0} + {:else if listsStore.lists.length === 0}
{:else}
- {#each lists as list (list.id)} + {#each listsStore.lists as list (list.id)} 1 Content Service | +| **Eliminieren** | Photos (mana-media Proxy), Skilltree (Frontend-only) | minimal | Backend loeschen | + +### Beispiel: Zitare Backend + +Das Zitare Backend hat **20 TypeScript-Dateien** und genau zwei Features: + +```typescript +// app.module.ts - Das ist der gesamte Service: +@Module({ + imports: [ + ConfigModule.forRoot({ isGlobal: true }), + DatabaseModule, + FavoriteModule, // CRUD fuer Favoriten + ListModule, // CRUD fuer Listen + HealthModule.forRoot({ serviceName: 'quote-backend' }), + MetricsModule.register({ prefix: 'zitare_' }), + AdminModule, + ], +}) +export class AppModule {} +``` + +Dafuer laeuft ein eigener Docker-Container mit eigenem Port, eigener Health-Check, eigenem Prometheus-Scrape-Target. + +### Empfehlung: Konsolidierung auf 2-4 Backends + +Da bereits **mana-sync (Go)** als zentraler Sync-Server existiert und **local-first** das Paradigma ist, brauchen die meisten NestJS-Backends nicht mehr zu existieren. Die Daten leben in IndexedDB und synchen ueber mana-sync nach PostgreSQL. + +| Service | Zweck | Ersetzt | +|---------|-------|---------| +| **mana-sync** (Go) | Sync, CRUD, Echtzeit | 7+ CRUD-only Backends | +| **mana-core-auth** (NestJS) | Auth, Credits, Billing, Admin | Bleibt | +| **mana-compute** (Hono/Bun) | App-spezifische Logik (RRULE, Reminders, AI-Pipelines) | Todo-Server-Pattern fuer alle | +| **mana-chat** (NestJS) | AI Chat (Streaming, Azure OpenAI) | Chat-Backend | + +**Geschaetzte Einsparung:** ~5.500 Zeilen duplizierter Code, 7-8 Docker-Container weniger, ~1 GB RAM. + +### Duplizierten Boilerplate pro Backend + +| Pattern | Zeilen pro Backend | Total (19x) | Einsparbar | +|---------|-------------------|-------------|-----------| +| app.module.ts | 40 | 760 | ~70% | +| main.ts bootstrap | 10 | 190 | ~80% | +| database.module.ts | 35 | 665 | ~90% | +| CRUD Controllers (Durchschnitt) | 60 | 1.140 | ~65% | +| CRUD Services (Durchschnitt) | 80 | 1.520 | ~70% | +| Entity Schemas (Durchschnitt) | 25 | 475 | auto-generierbar | +| **Gesamt** | **~290** | **~5.500** | **~85%** | + +--- + +## 2. NestJS vs. Alternativen + +### Problem mit NestJS fuer CRUD-Backends + +- Schwer (~50 MB node_modules pro Backend) +- Viel Boilerplate (Module, Controller, Service, DTO fuer jede Entity) +- Overkill fuer simple CRUD-Operationen +- NestJS Version-Drift: 18 Backends auf ^10.4.x, ManaDeck auf ^11.0.1 + +### Empfehlung + +| Technologie | Wann verwenden | +|-------------|---------------| +| **Hono + Bun** | Leichte Compute-Server (wie der Todo-Server) | +| **Go (mana-sync)** | Performance-kritisch, Echtzeit, WebSocket | +| **NestJS** | Nur fuer mana-core-auth (komplex, viele Module, 174 Source Files) | + +Der Todo Hono/Bun Server ist ein gutes Pattern: + +```json +{ + "dependencies": { + "drizzle-orm": "^0.45.1", + "hono": "^4.7.0", + "postgres": "^3.4.5", + "rrule": "^2.8.1" + } +} +``` + +Leicht, schnell, genau die Abhaengigkeiten die er braucht. + +--- + +## 3. Sprachen-Wildwuchs: 4 Runtime-Umgebungen + +Aktuell im Einsatz: + +| Sprache | Services | Berechtigung | +|---------|----------|-------------| +| **TypeScript/Node.js** (NestJS) | 19 Backends + Auth + 5 Infra-Services | Historisch, aber zu viel | +| **Python** (FastAPI) | mana-llm, mana-stt, mana-tts, mana-voice-bot, mana-image-gen | Berechtigt (ML/AI Libraries) | +| **Go** | mana-sync, mana-matrix-bot, mana-api-gateway-go | Berechtigt (Performance) | +| **Bun** (Hono) | Todo Server | Gutes Pattern | + +### Problem: Drei angefangene Go-Rewrites + +| Service | Go-Version Status | NestJS-Version Status | +|---------|------------------|----------------------| +| mana-api-gateway-go | ~35% fertig | Funktional | +| mana-search-go | <10%, kaum Code | Funktional | +| mana-matrix-bot | Fertig (21 Services -> 1 Go Binary) | Entfernt | + +### Empfehlung + +Entscheidet euch: +- **Option A:** Go fuer alle Infrastruktur-Services (sync, gateway, search), TypeScript nur fuer Auth +- **Option B:** Alles bei TypeScript lassen, aber NestJS durch Hono/Bun ersetzen + +Mischt nicht beides halbfertig. Die angefangenen Go-Rewrites (api-gateway-go, search-go) entweder fertigstellen oder loeschen. + +--- + +## 4. Mobile Apps: Expo-Version-Divergenz + +| App | Expo SDK | React Native | NativeWind | +|-----|----------|-------------|------------| +| context, chat | **52** | 0.76 | ^3.4.0 | +| picture, manadeck, manacore | **54** | 0.81 | ^4.2.1 | +| matrix | **55** | 0.83 | latest | + +### Empfehlung + +Alle auf **Expo SDK 55** (neueste) bringen. Bei 7 Mobile-Apps ist das noch machbar. Expo hat gute Upgrade-Guides. Zusaetzlich: + +- NativeWind Version vereinheitlichen (v3 vs v4 ist ein Breaking Change) +- Navigation-Timing-Workarounds fixen (requestAnimationFrame-Hacks in Picture Mobile) + +--- + +## 5. Frontend: Svelte 5 + SvelteKit + +### Bewertung: Gut + +- Alle 22 Web-Apps auf Svelte 5.41.0 und SvelteKit 2.47.1 (konsistent) +- Runes-Mode durchgaengig (korrekt: `$state`, `$derived`, `$effect`) +- Shared packages gut extrahiert (shared-auth, shared-ui, shared-theme) +- Tailwind CSS 4 durchgaengig +- TypeScript strict mode ueberall + +### Probleme + +- 2 Apps skippen type-check (`picture/web`, `chat/web`) wegen shared-ui Type-Errors +- Store-Pattern wird manuell kopiert statt shared (siehe Abschnitt 12) + +### Kein Handlungsbedarf bei der Technologie-Wahl. Svelte 5 war die richtige Wahl. + +--- + +## 6. Local-First (Dexie + mana-sync) + +### Bewertung: Stark + +Der Local-first Stack (Dexie.js IndexedDB + Go Sync-Server + WebSocket Push) ist architektonisch solid: + +``` +Guest: App -> IndexedDB (Dexie.js) -> UI (kein Sync) +Logged in: App -> IndexedDB -> UI -> SyncEngine -> mana-sync (Go) -> PostgreSQL + <- WebSocket push <- +``` + +- 19/22 Apps bereits migriert +- Guest-Mode funktioniert +- Offline-CRUD moeglich +- Field-level LWW Conflict Resolution + +### ABER: mana-sync braucht dringend Haertung + +| Metrik | Wert | Bewertung | +|--------|------|-----------| +| Go-Quellcode | **426 Zeilen** | Zu wenig fuer die kritischste Komponente | +| Tests | **0** | **Kritisch** | +| Dokumentation | Minimal | Kein CLAUDE.md | +| Error Handling | Unklar | Nicht dokumentiert | + +**mana-sync ist der Single Point of Failure des gesamten Stacks.** Wenn der Sync-Server Daten verliert oder falsch merged, betrifft das alle 19 Apps. + +### Empfehlung + +Investiert hier sofort: +1. Go Tests (table-driven tests fuer Sync-Logik, Conflict Resolution) +2. Dokumentation (CLAUDE.md, Sync-Protokoll, Conflict-Resolution-Strategie) +3. Error Handling und Recovery +4. Monitoring/Alerting (Sync-Failures, Latenz, Connection-Counts) +5. Horizontal Scaling Strategie (aktuell: Single Instance) + +--- + +## 7. Infrastruktur & Deployment + +### 67 Docker-Container auf einem Mac Mini + +| Tier | Container | Anzahl | +|------|-----------|--------| +| Infra | Postgres, Redis, MinIO, Nginx, Backup | 5 | +| Core | Auth, API Gateway, Search, SearXNG, Media, Landing Builder | 6 | +| App Backends | 19x NestJS | 19 | +| App Frontends | 19x SvelteKit | 19 | +| Matrix | Synapse, Element, Bot | 3 | +| AI/ML | mana-llm, Ollama, STT, TTS, Image-Gen | 5+ | +| Monitoring | Grafana, VictoriaMetrics, Alertmanager, cAdvisor, Exporters, Loki, GlitchTip | 11 | +| Misc | Watchtower, Games | 2+ | +| **Gesamt** | | **~67** | + +### Empfehlung + +Mit Backend-Konsolidierung (Abschnitt 1) und Frontend-Konsolidierung: + +1. **Backend-Konsolidierung:** 19 NestJS -> 4-6 Services = **-13 Container** +2. **SvelteKit-Apps konsolidieren:** Statt 19 separate Node-Container, ein Nginx-Reverse-Proxy der alle statischen Builds served = **-18 Container** +3. **Monitoring vereinfachen:** Node-Exporter, cAdvisor, Redis-Exporter, Postgres-Exporter sind fuer einen Mac Mini Overkill = **-4 Container** + +**Ziel: ~30 Container statt 67.** + +### Production-Compose ist gut aufgesetzt + +Positiv: +- PostgreSQL mit tuning (shared_buffers=512MB, WAL config) +- Automatisches Backup (hourly pg_dumpall + daily pg_basebackup) +- Health-Checks auf allen Services +- Deploy-Metrics werden in PostgreSQL getrackt +- Matrix-Notifications bei Deploy-Failures +- Cloudflare Tunnel fuer Zugang + +Negativ: +- Kein Staging-Environment +- Kein Blue-Green Deployment +- Kein automatischer Rollback bei Health-Check Failure +- Mac Mini ist Single Point of Failure + +--- + +## 8. Datenbank + +### Bewertung: Grundsolide + +- PostgreSQL 16 mit guter Konfiguration +- Drizzle ORM ueberall konsistent (Code-first Schemas, Type-safe) +- Backup-Strategie vorhanden +- Schema-Isolation pro Service + +### Aktuelle Situation + +- 23 separate Datenbanken in einer PostgreSQL-Instanz +- Jeder Service hat eigenes Drizzle-Schema +- `db:push` fuer Schema-Aenderungen (kein manuelles SQL) +- `db:studio` fuer Daten-Inspektion + +### Empfehlung + +- Drizzle ist perfekt hier. Bleibt dabei. +- Ueberlegt ob Konsolidierung auf weniger DBs mit Schema-Isolation (PostgreSQL Schemas statt separate DBs) sinnvoll ist +- Drizzle-Kit Version vereinheitlichen (aktuell Mix aus ^0.30.2 und ^0.38.3) + +--- + +## 9. Testing + +### Bewertung: Kritisch + +| Bereich | Test-Dateien | Abdeckung | +|---------|-------------|-----------| +| Apps (24 Stueck) | ~162 (inkl. node_modules-Artefakte) | Duenn | +| Services (16 Stueck) | ~23 | **11 von 16 Services haben 0 Tests** | +| mana-core-auth | Gut (5 E2E Specs) | Einziger Service mit guter Abdeckung | +| mana-sync | **0 Tests** | Kritischster Service ohne Tests | +| mana-llm | Vorhanden (Python) | Unit Tests | + +### Services ohne Tests (kritisch) + +- mana-sync (Sync-Engine - Datenverlust-Risiko!) +- mana-search +- mana-crawler +- mana-notify +- mana-media +- mana-api-gateway +- mana-image-gen +- mana-stt +- mana-tts +- mana-voice-bot +- mana-landing-builder + +### Empfehlung (Prioritaet vor Go-Live) + +1. **mana-sync:** Integration Tests fuer Sync-Logik und Conflict Resolution (Go) +2. **mana-core-auth:** Weiter ausbauen (bereits gut) +3. **@manacore/local-store:** Unit Tests fuer SyncEngine und Conflict Resolution +4. **E2E Tests:** Fuer die wichtigsten User-Flows (Auth -> CRUD -> Sync -> Multi-Device) + +--- + +## 10. Shared Packages + +### Bewertung: Zu viele (55 Stueck) + +Sinnvolle Packages: +- `@manacore/local-store` - Kern der Local-first Architektur +- `@manacore/shared-auth` - Auth-Abstraktion +- `@manacore/shared-nestjs-auth` - NestJS JWT Guards +- `@manacore/shared-ui` - UI-Komponenten (hat aber Type-Errors) +- `@manacore/shared-tailwind` - Tailwind Config +- `@manacore/shared-vite-config` - Vite Config + +Zu granulare Packages (Konsolidierungskandidaten): + +| Aktuell (einzeln) | Konsolidiert zu | +|-------------------|----------------| +| shared-credit-service, shared-credit-ui, credit-operations | `@manacore/credits` | +| shared-feedback-service, shared-feedback-types, shared-feedback-ui | `@manacore/feedback` | +| shared-help-content, shared-help-mobile, shared-help-types, shared-help-ui | `@manacore/help` | +| shared-subscription-types, shared-subscription-ui | `@manacore/subscriptions` | +| shared-nestjs-health, shared-nestjs-metrics, shared-nestjs-setup | `@manacore/nestjs-bootstrap` | +| manadeck-database, nutriphi-database, spiral-db | Zurueck in die jeweiligen Apps | + +### Ziel: ~25-30 Packages statt 55 + +--- + +## 11. CI/CD Pipeline + +### Bewertung: Funktional + +**CI (ci.yml):** +- Laeuft auf PRs und Push zu main/dev +- Change-Detection: Nur geaenderte Services werden gebaut +- Type-Check, Lint, Format, Tests +- Docker-Image-Builds selektiv + +**CD (cd-macmini.yml):** +- Self-hosted Runner auf Mac Mini +- Erkennt geaenderte Services automatisch +- Baut Base-Images (nestjs-base, sveltekit-base) wenn noetig +- Health-Checks nach Deploy +- Deploy-Metrics in PostgreSQL +- Matrix-Notifications bei Failures + +### Root package.json: 294 Scripts + +Das ist nicht mehr wartbar. Jede neue App fuegt ~15 Scripts hinzu. + +### Fehlend + +- **Kein Staging-Environment** - dev und main Branches only +- **Kein automatischer Rollback** - Bei Deploy-Failure manuelles Recovery +- **Kein Blue-Green Deployment** - Alle User bekommen Update sofort +- **Keine automatische Deployment-Trigger** - workflow_dispatch ist manuell + +--- + +## 12. Frontend Store-Duplikation + +Ueber 15 SvelteKit-Apps haben **identische Kopien** der gleichen Store-Dateien: + +``` +apps/*/apps/web/src/lib/stores/auth.svelte.ts # ~80 Zeilen, 15x kopiert +apps/*/apps/web/src/lib/stores/user-settings.svelte.ts # ~60 Zeilen, 15x kopiert +apps/*/apps/web/src/lib/stores/theme.svelte.ts # ~40 Zeilen, 15x kopiert +``` + +### Geschaetzte Duplikation: ~500+ Zeilen ueber 15 Apps + +Das Package `@manacore/shared-stores` existiert bereits, wird aber nicht voll genutzt. Diese Stores sollten dort zentralisiert werden. + +--- + +## 13. Services-Analyse (Detail) + +### Services Maturity Matrix + +| Service | Tech | Tests | Error Handling | Config | Completeness | +|---------|------|-------|----------------|--------|-------------| +| mana-core-auth | NestJS/TS | Gut (E2E + Integration) | Gut | Env-driven | 95% | +| mana-llm | Python/FastAPI | Unit Tests | Implizit | Pydantic | 80% | +| mana-api-gateway | NestJS/TS | Keine | Gut | Env-driven | 75% | +| mana-search | NestJS/TS | Keine | Maessig | Env-driven | 70% | +| mana-crawler | NestJS/TS | Keine | Minimal | Env-driven | 75% | +| mana-media | NestJS/TS | Keine | Minimal | Env-driven | 70% | +| mana-notify | NestJS/TS | Keine | Minimal | Env-driven | 75% | +| mana-stt | Python/FastAPI | Keine | Keine | Env | 55% | +| mana-tts | Python/FastAPI | Keine | Keine | Env | 55% | +| mana-image-gen | Python/FastAPI | Keine | Keine | Env | 50% | +| mana-voice-bot | Python/FastAPI | Keine | Keine | Basic | 40% | +| mana-sync | Go | **Keine** | **Unklar** | Minimal | **40%** | +| mana-matrix-bot | Go | Keine | Unklar | Minimal | 50% | +| mana-api-gateway-go | Go | Keine | Unklar | Minimal | 35% | +| mana-landing-builder | NestJS/TS | Keine | Minimal | Env-driven | 60% | +| mana-search-go | Go | Keine | Keine | Keine | 10% | + +### Port-Konflikte + +| Port | Beansprucht von | Problem | +|------|----------------|---------| +| 3025 | mana-llm **UND** mana-image-gen | Koennen nicht parallel laufen | +| 3030 | mana-api-gateway **UND** mana-landing-builder **UND** mana-api-gateway-go | Dreifach-Konflikt | + +--- + +## 14. Sicherheit & fehlende Stuecke + +### Sicherheit + +| Thema | Status | +|-------|--------| +| JWT (EdDSA) | Gut - Better Auth + jose Library | +| CORS | Konfiguriert, aber 20+ Domains hardcoded in docker-compose | +| Passkeys/WebAuthn | Implementiert | +| SMTP (Brevo) | Konfiguriert | +| Secrets | Env-basiert, aber Default-Passwoerter in dev compose | +| Rate Limiting | Vorhanden (Redis-based) | + +### Fehlende Stuecke + +| Luecke | Risiko | Aufwand | +|--------|--------|---------| +| Kein Distributed Tracing (OpenTelemetry) | Debugging in Prod unmoeglich | 2-3 Tage | +| Kein Staging-Environment | Bugs erst in Prod sichtbar | 1 Tag | +| shared-ui Type-Errors | 2 Apps skippen type-check | 2-3 Tage | +| Kein API-Dokumentation (OpenAPI/Swagger) | Nur mana-core-auth hat Swagger | 1 Woche | +| Logging nicht standardisiert | Winston vs NestJS Logger vs FastAPI vs Go | 3-5 Tage | +| Keine Correlation IDs | Requests nicht ueber Services verfolgbar | 2 Tage | + +--- + +## 15. Was NICHT geaendert werden sollte + +Diese Technologie-Entscheidungen sind gut und sollten beibehalten werden: + +| Technologie | Grund | +|-------------|-------| +| **Svelte 5 + SvelteKit** | Modern, performant, gut umgesetzt | +| **Local-first Architektur** | Zukunftssicher, Guest-Mode, Offline-CRUD | +| **Dexie.js** | Solide IndexedDB-Abstraktion | +| **PostgreSQL + Drizzle ORM** | Type-safe, bewaehrtes Duo | +| **Better Auth** | Self-hosted, EdDSA JWT, Passkeys | +| **MinIO** | S3-kompatibel, self-hosted | +| **Python fuer ML/AI** | Einzige sinnvolle Wahl (STT, TTS, LLM, Image-Gen) | +| **Go fuer mana-sync** | Performance-kritisch, kleine Binary | +| **Turborepo** | Funktioniert gut fuer Monorepo-Orchestrierung | +| **Cloudflare Tunnel** | Einfaches Routing ohne oeffentliche IP | +| **Astro fuer Landing Pages** | Perfekt fuer statische Marketing-Seiten | + +--- + +## 16. Priorisierte Empfehlungen + +### Phase 1: Vor Go-Live (Kritisch) + +| # | Massnahme | Aufwand | Impact | +|---|-----------|---------|--------| +| 1 | **mana-sync hardenen** - Tests, Docs, Error Handling | 1 Woche | Datensicherheit | +| 2 | **7 triviale Backends eliminieren/konsolidieren** (Zitare, Clock, Presi, CityCorners, Questions, Context, Photos) | 1 Woche | -7 Container, -3.500 LOC | +| 3 | **Port-Konflikte loesen** (3025, 3030) | 1 Stunde | Services laufen parallel | +| 4 | **shared-ui Type-Errors fixen** | 2-3 Tage | Alle Apps type-checken wieder | +| 5 | **Expo-Versionen alignen** (alle auf SDK 55) | 3-5 Tage | Konsistenz, Security | + +### Phase 2: Kurzfristig + +| # | Massnahme | Aufwand | Impact | +|---|-----------|---------|--------| +| 6 | **Go-Rewrites entscheiden** (api-gateway-go, search-go: fertig oder loeschen) | Entscheidung | Klarheit | +| 7 | **Store-Duplikation fixen** (shared-stores richtig nutzen) | 2-3 Tage | -500 LOC Duplikation | +| 8 | **Shared Packages konsolidieren** (55 -> ~30) | 1 Woche | Einfacheres Dependency-Management | +| 9 | **SvelteKit-Container konsolidieren** (Nginx statt 19 Node-Container) | 3-5 Tage | -18 Container | +| 10 | **Staging-Environment einrichten** | 1 Tag | Bugs vor Prod fangen | + +### Phase 3: Mittelfristig + +| # | Massnahme | Aufwand | Impact | +|---|-----------|---------|--------| +| 11 | **OpenTelemetry Tracing** einfuehren | 3-5 Tage | Debugging in Prod | +| 12 | **Structured Logging** (JSON, Correlation IDs) | 3-5 Tage | Log-Aggregation funktioniert | +| 13 | **Test-Coverage aufbauen** (Fokus: Sync, Auth, CRUD-Pfade) | Fortlaufend | Qualitaet | +| 14 | **NestJS-Version alignen** (alle auf 10.4 oder 11.0) | 2-3 Tage | Konsistenz | +| 15 | **Monitoring vereinfachen** (weniger Exporters, mehr App-Level Metrics) | 2-3 Tage | -4 Container | + +### Phase 4: Langfristig + +| # | Massnahme | Aufwand | Impact | +|---|-----------|---------|--------| +| 16 | **Root package.json Scripts aufraemen** (294 -> CLI Tool) | 1 Woche | Developer Experience | +| 17 | **Mac-Mini Scripts konsolidieren** (31 Shell Scripts -> 1 CLI) | 1 Woche | Wartbarkeit | +| 18 | **API-Dokumentation** (OpenAPI/Swagger fuer alle Services) | 2 Wochen | Developer Experience | +| 19 | **Blue-Green Deployment** | 1 Woche | Zero-Downtime Deploys | +| 20 | **Mobile Platform-Paritaet** - fehlende Apps implementieren oder entfernen | Fortlaufend | Klarheit | + +--- + +## Anhang: Bestandsaufnahme + +### Apps (24) + +calendar, chat, citycorners, clock, contacts, context, docs, inventar, manacore, manadeck, matrix, mukke, nutriphi, photos, picture, planta, playground, presi, questions, skilltree, storage, todo, traces, zitare + +### Services (17) + +it-landing, mana-api-gateway, mana-api-gateway-go, mana-core-auth, mana-crawler, mana-image-gen, mana-landing-builder, mana-llm, mana-matrix-bot, mana-media, mana-notify, mana-search, mana-stt, mana-sync, mana-tts, mana-voice-bot, ollama-metrics-proxy + +### Shared Packages (55) + +credit-operations, eslint-config, local-store, mana-core-nestjs-integration, manadeck-database, notify-client, nutriphi-database, qr-export, shared-api-client, shared-app-onboarding, shared-auth, shared-auth-stores, shared-auth-ui, shared-branding, shared-config, shared-credit-service, shared-credit-ui, shared-drizzle-config, shared-error-tracking, shared-errors, shared-feedback-service, shared-feedback-types, shared-feedback-ui, shared-gpu, shared-help-content, shared-help-mobile, shared-help-types, shared-help-ui, shared-i18n, shared-icons, shared-landing-ui, shared-llm, shared-logger, shared-nestjs-auth, shared-nestjs-health, shared-nestjs-metrics, shared-nestjs-setup, shared-profile-ui, shared-pwa, shared-splitscreen, shared-storage, shared-stores, shared-subscription-types, shared-subscription-ui, shared-tags, shared-tailwind, shared-theme, shared-theme-ui, shared-tsconfig, shared-types, shared-ui, shared-utils, shared-vite-config, spiral-db, test-config, wallpaper-generator + +### Games (3) + +voxelava, whopixels, worldream + +### Archivierte Apps (apps-archived/) + +bauntown, memoro, news, nutriphi, reader, uload, wisekeep diff --git a/package.json b/package.json index 5abbe95de..67a4369ef 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "dev:manacore:landing": "pnpm --filter @manacore/landing dev", "dev:manacore:mobile": "pnpm --filter @manacore/mobile dev", "dev:manacore:app": "pnpm --filter @manacore/web dev", - "dev:manacore:backends": "concurrently -n auth,chat,calendar,contacts,todo,zitare,picture,manadeck,clock -c blue,green,yellow,magenta,cyan,red,white,gray,orange \"pnpm dev:auth\" \"pnpm dev:chat:backend\" \"pnpm dev:calendar:backend\" \"pnpm dev:contacts:backend\" \"pnpm dev:todo:backend\" \"pnpm dev:zitare:backend\" \"pnpm dev:picture:backend\" \"pnpm dev:manadeck:backend\" \"pnpm dev:clock:backend\"", + "dev:manacore:backends": "concurrently -n auth,chat,calendar,contacts,todo,picture,manadeck -c blue,green,yellow,magenta,cyan,white,gray \"pnpm dev:auth\" \"pnpm dev:chat:backend\" \"pnpm dev:calendar:backend\" \"pnpm dev:contacts:backend\" \"pnpm dev:todo:backend\" \"pnpm dev:picture:backend\" \"pnpm dev:manadeck:backend\"", "dev:manacore:full": "concurrently -n web,backends -c cyan,yellow \"pnpm dev:manacore:web\" \"pnpm dev:manacore:backends\"", "dev:manadeck:web": "pnpm --filter @manadeck/web dev", "dev:manadeck:landing": "pnpm --filter @manadeck/landing dev", @@ -60,9 +60,8 @@ "dev:zitare:mobile": "pnpm --filter @zitare/mobile dev", "dev:zitare:web": "pnpm --filter @zitare/web dev", "dev:zitare:landing": "pnpm --filter @zitare/landing dev", - "dev:zitare:backend": "pnpm --filter @zitare/backend dev", - "dev:zitare:app": "turbo run dev --filter=@zitare/web --filter=@zitare/backend", - "dev:zitare:full": "./scripts/setup-databases.sh zitare && ./scripts/setup-databases.sh auth && concurrently -n auth,backend,web -c blue,green,cyan \"pnpm dev:auth\" \"pnpm dev:zitare:backend\" \"pnpm dev:zitare:web\"", + "dev:zitare:app": "pnpm dev:zitare:web", + "dev:zitare:full": "concurrently -n auth,sync,web -c blue,magenta,cyan \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:zitare:web\"", "contacts:dev": "turbo run dev --filter=contacts...", "dev:contacts:mobile": "pnpm --filter @contacts/mobile dev", "dev:contacts:web": "pnpm --filter @contacts/web dev", @@ -86,12 +85,8 @@ "clock:dev": "turbo run dev --filter=clock...", "dev:clock:web": "pnpm --filter @clock/web dev", "dev:clock:landing": "pnpm --filter @clock/landing dev", - "dev:clock:backend": "pnpm --filter @clock/backend dev", - "dev:clock:app": "turbo run dev --filter=@clock/web --filter=@clock/backend", - "dev:clock:full": "./scripts/setup-databases.sh clock && ./scripts/setup-databases.sh auth && concurrently -n auth,backend,web -c blue,green,cyan \"pnpm dev:auth\" \"pnpm dev:clock:backend\" \"pnpm dev:clock:web\"", - "clock:db:push": "pnpm --filter @clock/backend db:push", - "clock:db:studio": "pnpm --filter @clock/backend db:studio", - "clock:db:seed": "pnpm --filter @clock/backend db:seed", + "dev:clock:app": "pnpm dev:clock:web", + "dev:clock:full": "concurrently -n auth,sync,web -c blue,magenta,cyan \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:clock:web\"", "matrix:dev": "turbo run dev --filter=matrix...", "dev:matrix:web": "pnpm --filter @matrix/web dev", "dev:matrix:mobile": "pnpm --filter @matrix/mobile dev", diff --git a/packages/shared-hono/package.json b/packages/shared-hono/package.json new file mode 100644 index 000000000..4ad1d79fd --- /dev/null +++ b/packages/shared-hono/package.json @@ -0,0 +1,30 @@ +{ + "name": "@manacore/shared-hono", + "version": "0.1.0", + "private": true, + "description": "Shared Hono infrastructure: auth, health, admin, error handling for lightweight compute servers", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./auth": "./src/auth.ts", + "./db": "./src/db.ts", + "./health": "./src/health.ts", + "./admin": "./src/admin.ts", + "./error": "./src/error.ts" + }, + "scripts": { + "type-check": "tsc --noEmit" + }, + "dependencies": { + "hono": "^4.7.0", + "jose": "^6.0.11", + "drizzle-orm": "^0.45.1", + "postgres": "^3.4.5" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "typescript": "^5.9.3" + } +} diff --git a/packages/shared-hono/src/admin.ts b/packages/shared-hono/src/admin.ts new file mode 100644 index 000000000..760b57d03 --- /dev/null +++ b/packages/shared-hono/src/admin.ts @@ -0,0 +1,74 @@ +/** + * Generic GDPR admin routes for Hono servers. + * + * Provides user-data count and deletion endpoints called by + * mana-core-auth for GDPR compliance (right to be forgotten). + * + * Each app defines which tables contain user data; this module + * handles the routing and service-key authentication. + */ + +import { Hono } from 'hono'; +import { eq, sql } from 'drizzle-orm'; +import type { PgTable } from 'drizzle-orm/pg-core'; +import { serviceAuthMiddleware } from './auth'; + +interface UserTable { + /** Drizzle table reference */ + table: PgTable; + /** Name shown in the response (e.g. "tasks", "favorites") */ + name: string; + /** The user_id column on this table */ + userIdColumn: ReturnType; +} + +/** + * Create admin routes for GDPR compliance. + * + * Usage: + * ```ts + * import { adminRoutes } from '@manacore/shared-hono/admin'; + * import { tasks, projects, reminders } from './db'; + * + * app.route('/api/v1/admin', adminRoutes(db, [ + * { table: tasks, name: 'tasks', userIdColumn: tasks.userId }, + * { table: projects, name: 'projects', userIdColumn: projects.userId }, + * { table: reminders, name: 'reminders', userIdColumn: reminders.userId }, + * ])); + * ``` + */ +export function adminRoutes(db: any, tables: UserTable[]): Hono { + const route = new Hono(); + + route.use('/*', serviceAuthMiddleware()); + + /** Get user data counts across all tables. */ + route.get('/user-data/:userId', async (c) => { + const userId = c.req.param('userId'); + const counts: Record = {}; + + for (const { table, name, userIdColumn } of tables) { + const [result] = await db + .select({ count: sql`count(*)` }) + .from(table) + .where(eq(userIdColumn, userId)); + counts[name] = Number(result?.count ?? 0); + } + + return c.json({ userId, counts }); + }); + + /** Delete all user data (GDPR right to be forgotten). */ + route.delete('/user-data/:userId', async (c) => { + const userId = c.req.param('userId'); + + // Delete in reverse order (children before parents if there are FKs) + for (const { table, userIdColumn } of [...tables].reverse()) { + await db.delete(table).where(eq(userIdColumn, userId)); + } + + return c.json({ userId, deleted: true, message: 'All user data deleted' }); + }); + + return route; +} diff --git a/packages/shared-hono/src/auth.ts b/packages/shared-hono/src/auth.ts new file mode 100644 index 000000000..73425402b --- /dev/null +++ b/packages/shared-hono/src/auth.ts @@ -0,0 +1,122 @@ +/** + * JWT authentication middleware for Hono servers. + * + * Verifies EdDSA JWTs from mana-core-auth via JWKS (cached). + * Drop-in replacement for @manacore/shared-nestjs-auth JwtAuthGuard. + * + * Sets `userId`, `userEmail`, `userRole` on Hono context. + */ + +import type { Context, Next } from 'hono'; +import { HTTPException } from 'hono/http-exception'; +import { createRemoteJWKSet, jwtVerify } from 'jose'; + +const AUTH_URL = () => process.env.MANA_CORE_AUTH_URL ?? 'http://localhost:3001'; +const SERVICE_KEY = () => process.env.MANA_CORE_SERVICE_KEY ?? ''; + +/** Cached JWKS - jose handles refetch cooldown (~10 min) */ +let cachedJWKS: ReturnType | null = null; +let cachedJWKSUrl: string | null = null; + +function getJWKS(): ReturnType { + const jwksUrl = `${AUTH_URL()}/api/v1/auth/jwks`; + + if (cachedJWKS && cachedJWKSUrl === jwksUrl) { + return cachedJWKS; + } + + cachedJWKS = createRemoteJWKSet(new URL(jwksUrl)); + cachedJWKSUrl = jwksUrl; + return cachedJWKS; +} + +/** + * Build the issuer allowlist — accepts auth service URL variants + * (internal Docker URL, public URL, localhost). + */ +function getIssuers(): string[] { + const issuers = new Set(); + const jwtIssuer = process.env.JWT_ISSUER; + const authUrl = process.env.MANA_CORE_AUTH_URL; + if (jwtIssuer) issuers.add(jwtIssuer); + if (authUrl) issuers.add(authUrl); + issuers.add('https://auth.mana.how'); + issuers.add('http://localhost:3001'); + return [...issuers]; +} + +/** + * JWT auth middleware — verifies Bearer token via JWKS. + * + * Usage: + * ```ts + * const app = new Hono(); + * app.use('/api/*', authMiddleware()); + * app.get('/api/profile', (c) => { + * const userId = c.get('userId'); + * return c.json({ userId }); + * }); + * ``` + */ +export function authMiddleware() { + return async (c: Context, next: Next) => { + // Dev bypass + if (process.env.NODE_ENV === 'development' && process.env.DEV_BYPASS_AUTH === 'true') { + c.set('userId', process.env.DEV_USER_ID ?? '00000000-0000-0000-0000-000000000000'); + c.set('userEmail', 'dev@example.com'); + c.set('userRole', 'user'); + return next(); + } + + const auth = c.req.header('Authorization'); + if (!auth?.startsWith('Bearer ')) { + throw new HTTPException(401, { message: 'Missing authorization header' }); + } + + const token = auth.slice(7); + + try { + const jwks = getJWKS(); + const audience = process.env.JWT_AUDIENCE ?? 'manacore'; + + const { payload } = await jwtVerify(token, jwks, { + issuer: getIssuers(), + audience, + }); + + if (!payload.sub) { + throw new HTTPException(401, { message: 'Token missing subject claim' }); + } + + c.set('userId', payload.sub); + c.set('userEmail', (payload as Record).email ?? ''); + c.set('userRole', (payload as Record).role ?? 'user'); + c.set('sessionId', (payload as Record).sid ?? ''); + return next(); + } catch (err) { + if (err instanceof HTTPException) throw err; + console.error('[auth] Token verification failed:', err instanceof Error ? err.message : err); + throw new HTTPException(401, { message: 'Invalid or expired token' }); + } + }; +} + +/** + * Service key auth middleware — validates X-Service-Key header. + * Used for admin/GDPR endpoints called by mana-core-auth. + * + * Usage: + * ```ts + * app.use('/api/v1/admin/*', serviceAuthMiddleware()); + * ``` + */ +export function serviceAuthMiddleware() { + return async (c: Context, next: Next) => { + const key = c.req.header('X-Service-Key'); + const expected = SERVICE_KEY(); + if (!key || !expected || key !== expected) { + throw new HTTPException(401, { message: 'Invalid service key' }); + } + return next(); + }; +} diff --git a/packages/shared-hono/src/db.ts b/packages/shared-hono/src/db.ts new file mode 100644 index 000000000..12ce5b7d5 --- /dev/null +++ b/packages/shared-hono/src/db.ts @@ -0,0 +1,49 @@ +/** + * Drizzle ORM database connection factory for Hono servers. + * + * Provides a lightweight connection with sensible defaults. + * Each server defines its own minimal schema (only tables it needs). + */ + +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; + +export interface DbOptions { + /** PostgreSQL connection URL */ + url?: string; + /** Max connections (default: 5) */ + maxConnections?: number; + /** Idle timeout in seconds (default: 20) */ + idleTimeout?: number; +} + +/** + * Create a Drizzle database instance with postgres.js driver. + * + * Usage: + * ```ts + * import { createDb } from '@manacore/shared-hono/db'; + * import { tasks, projects } from './schema'; + * + * const db = createDb({ + * schema: { tasks, projects }, + * }); + * ``` + */ +export function createDb>( + opts?: DbOptions & { schema?: TSchema } +) { + const url = + opts?.url ?? + process.env.DATABASE_URL ?? + 'postgresql://manacore:devpassword@localhost:5432/mana'; + + const connection = postgres(url, { + max: opts?.maxConnections ?? 5, + idle_timeout: opts?.idleTimeout ?? 20, + }); + + return drizzle(connection, { + schema: opts?.schema as TSchema, + }); +} diff --git a/packages/shared-hono/src/error.ts b/packages/shared-hono/src/error.ts new file mode 100644 index 000000000..b7601ed80 --- /dev/null +++ b/packages/shared-hono/src/error.ts @@ -0,0 +1,47 @@ +/** + * Error handling middleware for Hono servers. + * + * Catches unhandled errors and returns consistent JSON responses. + */ + +import type { Context } from 'hono'; +import { HTTPException } from 'hono/http-exception'; + +/** + * Global error handler — register with `app.onError(errorHandler)`. + * + * Usage: + * ```ts + * import { errorHandler } from '@manacore/shared-hono/error'; + * const app = new Hono(); + * app.onError(errorHandler); + * ``` + */ +export function errorHandler(err: Error, c: Context) { + if (err instanceof HTTPException) { + return c.json( + { + error: err.message, + status: err.status, + }, + err.status + ); + } + + console.error('[error]', err); + + return c.json( + { + error: 'Internal server error', + status: 500, + }, + 500 + ); +} + +/** + * Not-found handler — register with `app.notFound(notFoundHandler)`. + */ +export function notFoundHandler(c: Context) { + return c.json({ error: 'Not found', status: 404 }, 404); +} diff --git a/packages/shared-hono/src/health.ts b/packages/shared-hono/src/health.ts new file mode 100644 index 000000000..e61fb1e84 --- /dev/null +++ b/packages/shared-hono/src/health.ts @@ -0,0 +1,36 @@ +/** + * Health check route for Hono servers. + * + * Returns JSON compatible with the NestJS HealthModule format + * so monitoring/health-checks work without changes. + */ + +import { Hono } from 'hono'; + +const startTime = Date.now(); + +/** + * Create a health check route. + * + * Usage: + * ```ts + * import { healthRoute } from '@manacore/shared-hono/health'; + * app.route('/health', healthRoute('calendar-server')); + * ``` + */ +export function healthRoute(serviceName: string, version?: string): Hono { + const route = new Hono(); + + route.get('/', (c) => + c.json({ + status: 'ok', + service: serviceName, + runtime: 'bun', + timestamp: new Date().toISOString(), + uptime: Math.floor((Date.now() - startTime) / 1000), + ...(version ? { version } : {}), + }) + ); + + return route; +} diff --git a/packages/shared-hono/src/index.ts b/packages/shared-hono/src/index.ts new file mode 100644 index 000000000..23bddf98e --- /dev/null +++ b/packages/shared-hono/src/index.ts @@ -0,0 +1,41 @@ +/** + * @manacore/shared-hono — Shared infrastructure for Hono + Bun compute servers. + * + * Replaces NestJS boilerplate (Module, Controller, Guard, HealthModule, MetricsModule) + * with lightweight Hono equivalents. + * + * Usage: + * ```ts + * import { Hono } from 'hono'; + * import { cors } from 'hono/cors'; + * import { logger } from 'hono/logger'; + * import { authMiddleware, serviceAuthMiddleware } from '@manacore/shared-hono/auth'; + * import { createDb } from '@manacore/shared-hono/db'; + * import { healthRoute } from '@manacore/shared-hono/health'; + * import { adminRoutes } from '@manacore/shared-hono/admin'; + * import { errorHandler, notFoundHandler } from '@manacore/shared-hono/error'; + * + * const app = new Hono(); + * app.onError(errorHandler); + * app.notFound(notFoundHandler); + * app.use('*', logger()); + * app.use('*', cors({ origin: process.env.CORS_ORIGINS?.split(',') ?? ['http://localhost:5173'] })); + * + * app.route('/health', healthRoute('my-server')); + * app.use('/api/*', authMiddleware()); + * app.route('/api/v1/admin', adminRoutes(db, userTables)); + * + * // App-specific compute routes + * app.route('/api/v1/compute', myRoutes); + * + * export default { port: Number(process.env.PORT ?? 3019), fetch: app.fetch }; + * ``` + */ + +export { authMiddleware, serviceAuthMiddleware } from './auth'; +export { createDb } from './db'; +export type { DbOptions } from './db'; +export { healthRoute } from './health'; +export { adminRoutes } from './admin'; +export { errorHandler, notFoundHandler } from './error'; +export type { CurrentUserData, AuthVariables } from './types'; diff --git a/packages/shared-hono/src/types.ts b/packages/shared-hono/src/types.ts new file mode 100644 index 000000000..e369b1355 --- /dev/null +++ b/packages/shared-hono/src/types.ts @@ -0,0 +1,20 @@ +/** + * User data extracted from a verified JWT token. + * Compatible with @manacore/shared-nestjs-auth CurrentUserData. + */ +export interface CurrentUserData { + userId: string; + email: string; + role: string; + sessionId?: string; +} + +/** + * Hono context variables set by auth middleware. + */ +export interface AuthVariables { + userId: string; + userEmail: string; + userRole: string; + sessionId?: string; +} diff --git a/packages/shared-hono/tsconfig.json b/packages/shared-hono/tsconfig.json new file mode 100644 index 000000000..d84bd5f24 --- /dev/null +++ b/packages/shared-hono/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/scripts/generate-env.mjs b/scripts/generate-env.mjs index 8b166f3e9..736ecc899 100644 --- a/scripts/generate-env.mjs +++ b/scripts/generate-env.mjs @@ -248,19 +248,7 @@ const APP_CONFIGS = [ }, }, - // Zitare Backend (NestJS) - { - path: 'apps/zitare/apps/backend/.env', - vars: { - NODE_ENV: () => 'development', - PORT: (env) => env.ZITARE_BACKEND_PORT || '3007', - DATABASE_URL: (env) => env.ZITARE_DATABASE_URL, - MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL, - DEV_BYPASS_AUTH: () => 'true', - DEV_USER_ID: (env) => env.DEV_USER_ID || '00000000-0000-0000-0000-000000000000', - CORS_ORIGINS: (env) => env.CORS_ORIGINS, - }, - }, + // Zitare Backend: REMOVED — migrated to local-first // Zitare Mobile (Expo) { @@ -506,19 +494,7 @@ const APP_CONFIGS = [ }, }, - // Clock Backend (NestJS) - { - path: 'apps/clock/apps/backend/.env', - vars: { - NODE_ENV: () => 'development', - PORT: (env) => env.CLOCK_BACKEND_PORT || '3017', - DATABASE_URL: (env) => env.CLOCK_DATABASE_URL, - MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL, - DEV_BYPASS_AUTH: () => 'true', - DEV_USER_ID: (env) => env.DEV_USER_ID || '00000000-0000-0000-0000-000000000000', - CORS_ORIGINS: (env) => env.CORS_ORIGINS, - }, - }, + // Clock Backend: REMOVED — migrated to local-first // Clock Web (SvelteKit) { diff --git a/scripts/mac-mini/ensure-containers-running.sh b/scripts/mac-mini/ensure-containers-running.sh index f2711d46c..264974d14 100755 --- a/scripts/mac-mini/ensure-containers-running.sh +++ b/scripts/mac-mini/ensure-containers-running.sh @@ -154,7 +154,6 @@ for container in $ALL_PROBLEM_CONTAINERS; do mana-app-calendar-web) SERVICE_NAME="calendar-web" ;; mana-app-calendar-backend) SERVICE_NAME="calendar-backend" ;; mana-app-clock-web) SERVICE_NAME="clock-web" ;; - mana-app-clock-backend) SERVICE_NAME="clock-backend" ;; mana-app-contacts-web) SERVICE_NAME="contacts-web" ;; mana-app-contacts-backend) SERVICE_NAME="contacts-backend" ;; mana-app-storage-web) SERVICE_NAME="storage-web" ;; diff --git a/scripts/setup-databases.sh b/scripts/setup-databases.sh index b6c4231a9..ab8eec860 100755 --- a/scripts/setup-databases.sh +++ b/scripts/setup-databases.sh @@ -104,7 +104,7 @@ setup_service() { ;; zitare) create_db_if_not_exists "zitare" - push_schema "@zitare/backend" "zitare" + # Schema managed by mana-sync (backend removed) ;; contacts) create_db_if_not_exists "contacts" @@ -116,7 +116,7 @@ setup_service() { ;; clock) create_db_if_not_exists "clock" - push_schema "@clock/backend" "clock" + # Schema managed by mana-sync (backend removed) ;; todo) create_db_if_not_exists "todo"