mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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) <noreply@anthropic.com>
This commit is contained in:
parent
82de69476f
commit
32939fbfb5
81 changed files with 1236 additions and 2727 deletions
12
.github/workflows/cd-macmini.yml
vendored
12
.github/workflows/cd-macmini.yml
vendored
|
|
@ -32,7 +32,6 @@ on:
|
||||||
- todo-web
|
- todo-web
|
||||||
- calendar-backend
|
- calendar-backend
|
||||||
- calendar-web
|
- calendar-web
|
||||||
- clock-backend
|
|
||||||
- clock-web
|
- clock-web
|
||||||
- contacts-backend
|
- contacts-backend
|
||||||
- contacts-web
|
- contacts-web
|
||||||
|
|
@ -72,7 +71,6 @@ jobs:
|
||||||
todo-web: ${{ steps.changes.outputs.todo-web }}
|
todo-web: ${{ steps.changes.outputs.todo-web }}
|
||||||
calendar-backend: ${{ steps.changes.outputs.calendar-backend }}
|
calendar-backend: ${{ steps.changes.outputs.calendar-backend }}
|
||||||
calendar-web: ${{ steps.changes.outputs.calendar-web }}
|
calendar-web: ${{ steps.changes.outputs.calendar-web }}
|
||||||
clock-backend: ${{ steps.changes.outputs.clock-backend }}
|
|
||||||
clock-web: ${{ steps.changes.outputs.clock-web }}
|
clock-web: ${{ steps.changes.outputs.clock-web }}
|
||||||
contacts-backend: ${{ steps.changes.outputs.contacts-backend }}
|
contacts-backend: ${{ steps.changes.outputs.contacts-backend }}
|
||||||
contacts-web: ${{ steps.changes.outputs.contacts-web }}
|
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 "todo-web" "apps/todo/apps/web/" "apps/todo/packages/"
|
||||||
check_changes "calendar-backend" "apps/calendar/apps/backend/" "apps/calendar/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 "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 "clock-web" "apps/clock/apps/web/" "apps/clock/packages/"
|
||||||
check_changes "contacts-backend" "apps/contacts/apps/backend/" "apps/contacts/packages/"
|
check_changes "contacts-backend" "apps/contacts/apps/backend/" "apps/contacts/packages/"
|
||||||
check_changes "contacts-web" "apps/contacts/apps/web/" "apps/contacts/packages/"
|
check_changes "contacts-web" "apps/contacts/apps/web/" "apps/contacts/packages/"
|
||||||
|
|
@ -142,7 +140,7 @@ jobs:
|
||||||
|
|
||||||
# Check if anything needs deploying
|
# Check if anything needs deploying
|
||||||
ANY="false"
|
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)
|
val=$(grep "^$svc=" $GITHUB_OUTPUT | tail -1 | cut -d= -f2)
|
||||||
if [ "$val" == "true" ]; then
|
if [ "$val" == "true" ]; then
|
||||||
ANY="true"
|
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.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-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.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.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-backend }}" == "true" ]; then SERVICES="$SERVICES contacts-backend"; fi
|
||||||
if [ "${{ needs.detect-changes.outputs.contacts-web }}" == "true" ]; then SERVICES="$SERVICES contacts-web"; 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" ;;
|
todo-web) echo "http://localhost:5011/health" ;;
|
||||||
calendar-backend) echo "http://localhost:3032/health" ;;
|
calendar-backend) echo "http://localhost:3032/health" ;;
|
||||||
calendar-web) echo "http://localhost:5012/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" ;;
|
clock-web) echo "http://localhost:5013/health" ;;
|
||||||
contacts-backend) echo "http://localhost:3034/health" ;;
|
contacts-backend) echo "http://localhost:3034/health" ;;
|
||||||
contacts-web) echo "http://localhost:5014/health" ;;
|
contacts-web) echo "http://localhost:5014/health" ;;
|
||||||
|
|
@ -348,7 +346,7 @@ jobs:
|
||||||
SERVICES="${{ steps.services.outputs.services }}"
|
SERVICES="${{ steps.services.outputs.services }}"
|
||||||
|
|
||||||
if [ "$DEPLOY_ALL" == "true" ]; then
|
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
|
fi
|
||||||
|
|
||||||
HEALTH_RESULTS=""
|
HEALTH_RESULTS=""
|
||||||
|
|
|
||||||
87
.github/workflows/ci.yml
vendored
87
.github/workflows/ci.yml
vendored
|
|
@ -58,7 +58,6 @@ jobs:
|
||||||
todo-web: ${{ steps.changes.outputs.todo-web }}
|
todo-web: ${{ steps.changes.outputs.todo-web }}
|
||||||
calendar-backend: ${{ steps.changes.outputs.calendar-backend }}
|
calendar-backend: ${{ steps.changes.outputs.calendar-backend }}
|
||||||
calendar-web: ${{ steps.changes.outputs.calendar-web }}
|
calendar-web: ${{ steps.changes.outputs.calendar-web }}
|
||||||
clock-backend: ${{ steps.changes.outputs.clock-backend }}
|
|
||||||
clock-web: ${{ steps.changes.outputs.clock-web }}
|
clock-web: ${{ steps.changes.outputs.clock-web }}
|
||||||
contacts-backend: ${{ steps.changes.outputs.contacts-backend }}
|
contacts-backend: ${{ steps.changes.outputs.contacts-backend }}
|
||||||
contacts-web: ${{ steps.changes.outputs.contacts-web }}
|
contacts-web: ${{ steps.changes.outputs.contacts-web }}
|
||||||
|
|
@ -72,7 +71,6 @@ jobs:
|
||||||
skilltree-backend: ${{ steps.changes.outputs.skilltree-backend }}
|
skilltree-backend: ${{ steps.changes.outputs.skilltree-backend }}
|
||||||
skilltree-web: ${{ steps.changes.outputs.skilltree-web }}
|
skilltree-web: ${{ steps.changes.outputs.skilltree-web }}
|
||||||
mana-matrix-bot: ${{ steps.changes.outputs.mana-matrix-bot }}
|
mana-matrix-bot: ${{ steps.changes.outputs.mana-matrix-bot }}
|
||||||
zitare-backend: ${{ steps.changes.outputs.zitare-backend }}
|
|
||||||
any-changes: ${{ steps.changes.outputs.any-changes }}
|
any-changes: ${{ steps.changes.outputs.any-changes }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
|
|
@ -95,7 +93,6 @@ jobs:
|
||||||
echo "todo-web=true" >> $GITHUB_OUTPUT
|
echo "todo-web=true" >> $GITHUB_OUTPUT
|
||||||
echo "calendar-backend=true" >> $GITHUB_OUTPUT
|
echo "calendar-backend=true" >> $GITHUB_OUTPUT
|
||||||
echo "calendar-web=true" >> $GITHUB_OUTPUT
|
echo "calendar-web=true" >> $GITHUB_OUTPUT
|
||||||
echo "clock-backend=true" >> $GITHUB_OUTPUT
|
|
||||||
echo "clock-web=true" >> $GITHUB_OUTPUT
|
echo "clock-web=true" >> $GITHUB_OUTPUT
|
||||||
echo "contacts-backend=true" >> $GITHUB_OUTPUT
|
echo "contacts-backend=true" >> $GITHUB_OUTPUT
|
||||||
echo "contacts-web=true" >> $GITHUB_OUTPUT
|
echo "contacts-web=true" >> $GITHUB_OUTPUT
|
||||||
|
|
@ -109,7 +106,6 @@ jobs:
|
||||||
echo "skilltree-backend=true" >> $GITHUB_OUTPUT
|
echo "skilltree-backend=true" >> $GITHUB_OUTPUT
|
||||||
echo "skilltree-web=true" >> $GITHUB_OUTPUT
|
echo "skilltree-web=true" >> $GITHUB_OUTPUT
|
||||||
echo "mana-matrix-bot=true" >> $GITHUB_OUTPUT
|
echo "mana-matrix-bot=true" >> $GITHUB_OUTPUT
|
||||||
echo "zitare-backend=true" >> $GITHUB_OUTPUT
|
|
||||||
echo "any-changes=true" >> $GITHUB_OUTPUT
|
echo "any-changes=true" >> $GITHUB_OUTPUT
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
@ -136,7 +132,6 @@ jobs:
|
||||||
echo "todo-web=true" >> $GITHUB_OUTPUT
|
echo "todo-web=true" >> $GITHUB_OUTPUT
|
||||||
echo "calendar-backend=true" >> $GITHUB_OUTPUT
|
echo "calendar-backend=true" >> $GITHUB_OUTPUT
|
||||||
echo "calendar-web=true" >> $GITHUB_OUTPUT
|
echo "calendar-web=true" >> $GITHUB_OUTPUT
|
||||||
echo "clock-backend=true" >> $GITHUB_OUTPUT
|
|
||||||
echo "clock-web=true" >> $GITHUB_OUTPUT
|
echo "clock-web=true" >> $GITHUB_OUTPUT
|
||||||
echo "contacts-backend=true" >> $GITHUB_OUTPUT
|
echo "contacts-backend=true" >> $GITHUB_OUTPUT
|
||||||
echo "contacts-web=true" >> $GITHUB_OUTPUT
|
echo "contacts-web=true" >> $GITHUB_OUTPUT
|
||||||
|
|
@ -150,7 +145,6 @@ jobs:
|
||||||
echo "skilltree-backend=true" >> $GITHUB_OUTPUT
|
echo "skilltree-backend=true" >> $GITHUB_OUTPUT
|
||||||
echo "skilltree-web=true" >> $GITHUB_OUTPUT
|
echo "skilltree-web=true" >> $GITHUB_OUTPUT
|
||||||
echo "mana-matrix-bot=true" >> $GITHUB_OUTPUT
|
echo "mana-matrix-bot=true" >> $GITHUB_OUTPUT
|
||||||
echo "zitare-backend=true" >> $GITHUB_OUTPUT
|
|
||||||
echo "any-changes=true" >> $GITHUB_OUTPUT
|
echo "any-changes=true" >> $GITHUB_OUTPUT
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
@ -255,13 +249,7 @@ jobs:
|
||||||
echo "calendar-web=false" >> $GITHUB_OUTPUT
|
echo "calendar-web=false" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# clock-backend
|
# clock-backend: REMOVED — migrated to local-first
|
||||||
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-web
|
# clock-web
|
||||||
CLOCK_WEB_CHANGED=$(check_pattern "apps/clock/apps/web/|apps/clock/packages/")
|
CLOCK_WEB_CHANGED=$(check_pattern "apps/clock/apps/web/|apps/clock/packages/")
|
||||||
|
|
@ -367,13 +355,7 @@ jobs:
|
||||||
echo "mana-matrix-bot=false" >> $GITHUB_OUTPUT
|
echo "mana-matrix-bot=false" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# zitare-backend
|
# zitare-backend: REMOVED — migrated to local-first
|
||||||
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
|
|
||||||
|
|
||||||
# Check if any service needs building
|
# Check if any service needs building
|
||||||
if grep -q "=true" $GITHUB_OUTPUT; then
|
if grep -q "=true" $GITHUB_OUTPUT; then
|
||||||
|
|
@ -397,7 +379,7 @@ jobs:
|
||||||
echo "| todo-web | ${{ steps.changes.outputs.todo-web }} |" >> $GITHUB_STEP_SUMMARY
|
echo "| todo-web | ${{ steps.changes.outputs.todo-web }} |" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "| calendar-backend | ${{ steps.changes.outputs.calendar-backend }} |" >> $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 "| 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 "| clock-web | ${{ steps.changes.outputs.clock-web }} |" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "| contacts-backend | ${{ steps.changes.outputs.contacts-backend }} |" >> $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
|
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 "| nutriphi-web | ${{ steps.changes.outputs.nutriphi-web }} |" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "| skilltree-backend | ${{ steps.changes.outputs.skilltree-backend }} |" >> $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 "| 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
|
# Validation job - runs on PRs
|
||||||
|
|
@ -737,34 +719,7 @@ jobs:
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
build-clock-backend:
|
# build-clock-backend: REMOVED — migrated to local-first
|
||||||
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-web:
|
build-clock-web:
|
||||||
name: Build clock-web
|
name: Build clock-web
|
||||||
|
|
@ -1149,34 +1104,4 @@ jobs:
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
# ===========================================
|
# ===========================================
|
||||||
# Zitare Backend
|
# Zitare Backend: REMOVED — migrated to local-first
|
||||||
# ===========================================
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
|
||||||
|
|
@ -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 "$@"
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
import { createDrizzleConfig } from '@manacore/shared-drizzle-config';
|
|
||||||
|
|
||||||
export default createDrizzleConfig({ dbName: 'clock' });
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<UserDataResponse> {
|
|
||||||
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<DeleteUserDataResponse> {
|
|
||||||
this.logger.log(`Admin request: deleteUserData for userId=${userId}`);
|
|
||||||
return this.adminService.deleteUserData(userId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 {}
|
|
||||||
|
|
@ -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<typeof schema>
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user data counts for a specific user
|
|
||||||
*/
|
|
||||||
async getUserData(userId: string): Promise<UserDataResponse> {
|
|
||||||
this.logger.log(`Getting user data for userId: ${userId}`);
|
|
||||||
|
|
||||||
// Count alarms
|
|
||||||
const alarmsResult = await this.db
|
|
||||||
.select({ count: sql<number>`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<number>`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<number>`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<number>`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<DeleteUserDataResponse> {
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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<string>('ADMIN_SERVICE_KEY', 'dev-admin-key');
|
|
||||||
}
|
|
||||||
|
|
||||||
canActivate(context: ExecutionContext): boolean {
|
|
||||||
const request = context.switchToHttp().getRequest<Request>();
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 {}
|
|
||||||
|
|
@ -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<Alarm[]> {
|
|
||||||
return this.db.select().from(alarms).where(eq(alarms.userId, userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
async findById(id: string, userId: string): Promise<Alarm | null> {
|
|
||||||
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<Alarm> {
|
|
||||||
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<Alarm> {
|
|
||||||
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<Alarm> {
|
|
||||||
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<Alarm> {
|
|
||||||
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<void> {
|
|
||||||
await this.findByIdOrThrow(id, userId);
|
|
||||||
await this.db.delete(alarms).where(and(eq(alarms.id, id), eq(alarms.userId, userId)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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 {}
|
|
||||||
|
|
@ -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<typeof postgres> | null = null;
|
|
||||||
let db: ReturnType<typeof drizzle> | 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<typeof getDb>;
|
|
||||||
|
|
@ -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<string>('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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
export * from './alarms.schema';
|
|
||||||
export * from './timers.schema';
|
|
||||||
export * from './world-clocks.schema';
|
|
||||||
export * from './presets.schema';
|
|
||||||
|
|
@ -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<PresetSettings>(),
|
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type Preset = typeof presets.$inferSelect;
|
|
||||||
export type NewPreset = typeof presets.$inferInsert;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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',
|
|
||||||
});
|
|
||||||
|
|
@ -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'],
|
|
||||||
});
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 {}
|
|
||||||
|
|
@ -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<Preset[]> {
|
|
||||||
return this.db.select().from(presets).where(eq(presets.userId, userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
async findById(id: string, userId: string): Promise<Preset | null> {
|
|
||||||
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<Preset> {
|
|
||||||
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<Preset> {
|
|
||||||
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<Preset> {
|
|
||||||
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<void> {
|
|
||||||
await this.findByIdOrThrow(id, userId);
|
|
||||||
await this.db.delete(presets).where(and(eq(presets.id, id), eq(presets.userId, userId)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 {}
|
|
||||||
|
|
@ -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<Timer[]> {
|
|
||||||
return this.db.select().from(timers).where(eq(timers.userId, userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
async findById(id: string, userId: string): Promise<Timer | null> {
|
|
||||||
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<Timer> {
|
|
||||||
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<Timer> {
|
|
||||||
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<Timer> {
|
|
||||||
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<Timer> {
|
|
||||||
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<Timer> {
|
|
||||||
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<Timer> {
|
|
||||||
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<void> {
|
|
||||||
await this.findByIdOrThrow(id, userId);
|
|
||||||
await this.db.delete(timers).where(and(eq(timers.id, id), eq(timers.userId, userId)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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[];
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 {}
|
|
||||||
|
|
@ -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<WorldClock[]> {
|
|
||||||
return this.db
|
|
||||||
.select()
|
|
||||||
.from(worldClocks)
|
|
||||||
.where(eq(worldClocks.userId, userId))
|
|
||||||
.orderBy(asc(worldClocks.sortOrder));
|
|
||||||
}
|
|
||||||
|
|
||||||
async findById(id: string, userId: string): Promise<WorldClock | null> {
|
|
||||||
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<WorldClock> {
|
|
||||||
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<WorldClock> {
|
|
||||||
// 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<WorldClock[]> {
|
|
||||||
// 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<void> {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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"]
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +1,43 @@
|
||||||
/**
|
/**
|
||||||
* Timers Store - Manages timer state using Svelte 5 runes
|
* Timers Store — Local-First with Dexie.js
|
||||||
* Supports both authenticated (cloud) and guest (session) modes
|
*
|
||||||
|
* 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 { timerCollection, type LocalTimer } from '$lib/data/local-store';
|
||||||
import { sessionTimersStore } from './session-timers.svelte';
|
|
||||||
import { authStore } from './auth.svelte';
|
|
||||||
import type { Timer, CreateTimerInput, UpdateTimerInput } from '@clock/shared';
|
import type { Timer, CreateTimerInput, UpdateTimerInput } from '@clock/shared';
|
||||||
import { ClockEvents } from '@manacore/shared-utils/analytics';
|
import { ClockEvents } from '@manacore/shared-utils/analytics';
|
||||||
|
|
||||||
// State
|
// State — populated from IndexedDB
|
||||||
let timers = $state<Timer[]>([]);
|
let timers = $state<Timer[]>([]);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(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 = {
|
export const timersStore = {
|
||||||
// Getters
|
// Getters
|
||||||
get timers() {
|
get timers() {
|
||||||
|
|
@ -30,204 +54,201 @@ export const timersStore = {
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch all timers from the backend
|
* Fetch all timers — reads from IndexedDB.
|
||||||
* In guest mode, loads from session storage
|
|
||||||
*/
|
*/
|
||||||
async fetchTimers() {
|
async fetchTimers() {
|
||||||
loading = true;
|
loading = true;
|
||||||
error = null;
|
error = null;
|
||||||
|
try {
|
||||||
// Guest mode: load from session storage
|
await refreshTimers();
|
||||||
if (!authStore.isAuthenticated) {
|
} catch (e) {
|
||||||
timers = sessionTimersStore.timers;
|
error = e instanceof Error ? e.message : 'Failed to fetch timers';
|
||||||
|
console.error('Failed to fetch timers:', e);
|
||||||
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
return { success: true };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authenticated: fetch from API
|
|
||||||
const response = await api.get<Timer[]>('/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 };
|
return { success: true };
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new timer
|
* Create a new timer — writes to IndexedDB instantly.
|
||||||
* In guest mode, creates in session storage
|
|
||||||
*/
|
*/
|
||||||
async createTimer(input: CreateTimerInput) {
|
async createTimer(input: CreateTimerInput) {
|
||||||
// Guest mode: create in session storage
|
error = null;
|
||||||
if (!authStore.isAuthenticated) {
|
try {
|
||||||
const timer = sessionTimersStore.createTimer(input);
|
const newLocal: LocalTimer = {
|
||||||
timers = [...timers, timer];
|
id: crypto.randomUUID(),
|
||||||
return { success: true, data: timer };
|
label: input.label ?? null,
|
||||||
}
|
durationSeconds: input.durationSeconds,
|
||||||
|
remainingSeconds: null,
|
||||||
|
status: 'idle',
|
||||||
|
startedAt: null,
|
||||||
|
pausedAt: null,
|
||||||
|
sound: input.sound ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
// Authenticated: create via API
|
const inserted = await timerCollection.insert(newLocal);
|
||||||
const response = await api.post<Timer>('/timers', input);
|
const newTimer = toTimer(inserted);
|
||||||
|
timers = [...timers, newTimer];
|
||||||
if (response.error) {
|
return { success: true, data: newTimer };
|
||||||
return { success: false, error: response.error.message };
|
} 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
|
* Update a timer — writes to IndexedDB instantly.
|
||||||
* In guest mode, updates in session storage
|
|
||||||
*/
|
*/
|
||||||
async updateTimer(id: string, input: UpdateTimerInput) {
|
async updateTimer(id: string, input: UpdateTimerInput) {
|
||||||
// Guest mode: update in session storage
|
error = null;
|
||||||
if (!authStore.isAuthenticated || sessionTimersStore.isSessionTimer(id)) {
|
try {
|
||||||
const timer = sessionTimersStore.updateTimer(id, input);
|
const updateData: Partial<LocalTimer> = {};
|
||||||
if (timer) {
|
if (input.label !== undefined) updateData.label = input.label ?? null;
|
||||||
timers = timers.map((t) => (t.id === id ? timer : t));
|
if (input.durationSeconds !== undefined) updateData.durationSeconds = input.durationSeconds;
|
||||||
return { success: true, data: timer };
|
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' };
|
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<Timer>(`/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
|
* Start a timer — sets status to running with current timestamp.
|
||||||
* In guest mode, starts in session storage
|
|
||||||
*/
|
*/
|
||||||
async startTimer(id: string) {
|
async startTimer(id: string) {
|
||||||
// Guest mode: start in session storage
|
error = null;
|
||||||
if (!authStore.isAuthenticated || sessionTimersStore.isSessionTimer(id)) {
|
try {
|
||||||
const timer = sessionTimersStore.startTimer(id);
|
const existing = await timerCollection.get(id);
|
||||||
if (timer) {
|
if (!existing) return { success: false, error: 'Timer not found' };
|
||||||
timers = timers.map((t) => (t.id === id ? timer : t));
|
|
||||||
return { success: true, data: timer };
|
const updateData: Partial<LocalTimer> = {
|
||||||
|
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' };
|
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<Timer>(`/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
|
* Pause a timer — calculates remaining seconds and saves.
|
||||||
* In guest mode, pauses in session storage
|
|
||||||
*/
|
*/
|
||||||
async pauseTimer(id: string) {
|
async pauseTimer(id: string) {
|
||||||
// Guest mode: pause in session storage
|
error = null;
|
||||||
if (!authStore.isAuthenticated || sessionTimersStore.isSessionTimer(id)) {
|
try {
|
||||||
const timer = sessionTimersStore.pauseTimer(id);
|
const existing = await timerCollection.get(id);
|
||||||
if (timer) {
|
if (!existing) return { success: false, error: 'Timer not found' };
|
||||||
timers = timers.map((t) => (t.id === id ? timer : t));
|
|
||||||
return { success: true, data: timer };
|
// 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<LocalTimer> = {
|
||||||
|
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' };
|
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<Timer>(`/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
|
* Reset a timer — back to idle with full duration.
|
||||||
* In guest mode, resets in session storage
|
|
||||||
*/
|
*/
|
||||||
async resetTimer(id: string) {
|
async resetTimer(id: string) {
|
||||||
// Guest mode: reset in session storage
|
error = null;
|
||||||
if (!authStore.isAuthenticated || sessionTimersStore.isSessionTimer(id)) {
|
try {
|
||||||
const timer = sessionTimersStore.resetTimer(id);
|
const updateData: Partial<LocalTimer> = {
|
||||||
if (timer) {
|
status: 'idle',
|
||||||
timers = timers.map((t) => (t.id === id ? timer : t));
|
remainingSeconds: null,
|
||||||
return { success: true, data: timer };
|
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' };
|
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<Timer>(`/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
|
* Delete a timer — removes from IndexedDB instantly.
|
||||||
* In guest mode, deletes from session storage
|
|
||||||
*/
|
*/
|
||||||
async deleteTimer(id: string) {
|
async deleteTimer(id: string) {
|
||||||
// Guest mode: delete from session storage
|
error = null;
|
||||||
if (!authStore.isAuthenticated || sessionTimersStore.isSessionTimer(id)) {
|
try {
|
||||||
sessionTimersStore.deleteTimer(id);
|
await timerCollection.delete(id);
|
||||||
timers = timers.filter((t) => t.id !== id);
|
timers = timers.filter((t) => t.id !== id);
|
||||||
return { success: true };
|
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<Timer>) {
|
updateLocalState(id: string, updates: Partial<Timer>) {
|
||||||
timers = timers.map((t) => (t.id === id ? { ...t, ...updates } : t));
|
timers = timers.map((t) => (t.id === id ? { ...t, ...updates } : t));
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear all timers (local state only)
|
* Clear all timers (local state only).
|
||||||
*/
|
*/
|
||||||
clear() {
|
clear() {
|
||||||
timers = [];
|
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 {
|
get sessionTimerCount(): number {
|
||||||
return sessionTimersStore.count;
|
return 0;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if there are session timers
|
|
||||||
*/
|
|
||||||
get hasSessionTimers(): boolean {
|
get hasSessionTimers(): boolean {
|
||||||
return sessionTimersStore.count > 0;
|
return false;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Migrate session timers to cloud after login
|
|
||||||
*/
|
|
||||||
async migrateSessionTimers(): Promise<void> {
|
async migrateSessionTimers(): Promise<void> {
|
||||||
if (!authStore.isAuthenticated) return;
|
// No-op: local-first mode handles data persistence automatically.
|
||||||
|
|
||||||
const sessionTimers = sessionTimersStore.getAllTimers();
|
|
||||||
if (sessionTimers.length === 0) return;
|
|
||||||
|
|
||||||
// Create each timer via API
|
|
||||||
for (const timer of sessionTimers) {
|
|
||||||
try {
|
|
||||||
await api.post<Timer>('/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();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
isSessionTimer(_id: string): boolean {
|
||||||
* Check if a timer ID is a session timer
|
return false;
|
||||||
*/
|
|
||||||
isSessionTimer(id: string): boolean {
|
|
||||||
return sessionTimersStore.isSessionTimer(id);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { setContext } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { locale } from 'svelte-i18n';
|
import { locale } from 'svelte-i18n';
|
||||||
|
|
@ -25,14 +26,21 @@
|
||||||
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
|
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
|
||||||
import { getPillAppItems } from '@manacore/shared-branding';
|
import { getPillAppItems } from '@manacore/shared-branding';
|
||||||
import { setLocale, supportedLocales } from '$lib/i18n';
|
import { setLocale, supportedLocales } from '$lib/i18n';
|
||||||
import { alarmsApi } from '$lib/api/alarms';
|
import { alarmCollection, timerCollection } from '$lib/data/local-store';
|
||||||
import { timersApi } from '$lib/api/timers';
|
|
||||||
import { clockOnboarding } from '$lib/stores/app-onboarding.svelte';
|
import { clockOnboarding } from '$lib/stores/app-onboarding.svelte';
|
||||||
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
|
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
|
||||||
import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
|
import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
|
||||||
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
|
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
|
||||||
import { clockStore } from '$lib/data/local-store';
|
import { clockStore } from '$lib/data/local-store';
|
||||||
import { tagStore } from '$lib/stores/tags.svelte';
|
import {
|
||||||
|
tagLocalStore,
|
||||||
|
tagMutations,
|
||||||
|
useAllTags as useAllSharedTags,
|
||||||
|
} from '@manacore/shared-stores';
|
||||||
|
|
||||||
|
// Shared tag store (local-first)
|
||||||
|
const allTags = useAllSharedTags();
|
||||||
|
setContext('tags', allTags);
|
||||||
|
|
||||||
// Guest welcome modal state
|
// Guest welcome modal state
|
||||||
let showGuestWelcome = $state(false);
|
let showGuestWelcome = $state(false);
|
||||||
|
|
@ -81,8 +89,8 @@
|
||||||
const results: CommandBarItem[] = [];
|
const results: CommandBarItem[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Search alarms
|
// Search alarms (local-first — reads from IndexedDB)
|
||||||
const alarms = await alarmsApi.getAll();
|
const alarms = await alarmCollection.getAll();
|
||||||
const matchingAlarms = alarms
|
const matchingAlarms = alarms
|
||||||
.filter((alarm) => alarm.label?.toLowerCase().includes(queryLower))
|
.filter((alarm) => alarm.label?.toLowerCase().includes(queryLower))
|
||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
|
|
@ -93,8 +101,8 @@
|
||||||
}));
|
}));
|
||||||
results.push(...matchingAlarms);
|
results.push(...matchingAlarms);
|
||||||
|
|
||||||
// Search timers
|
// Search timers (local-first — reads from IndexedDB)
|
||||||
const timers = await timersApi.getAll();
|
const timers = await timerCollection.getAll();
|
||||||
const matchingTimers = timers
|
const matchingTimers = timers
|
||||||
.filter((timer) => timer.label?.toLowerCase().includes(queryLower))
|
.filter((timer) => timer.label?.toLowerCase().includes(queryLower))
|
||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
|
|
@ -256,12 +264,14 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAuthReady() {
|
async function handleAuthReady() {
|
||||||
// Initialize local-first database (opens IndexedDB, seeds guest data)
|
// Initialize local-first databases (opens IndexedDB, seeds guest data)
|
||||||
await clockStore.initialize();
|
await Promise.all([clockStore.initialize(), tagLocalStore.initialize()]);
|
||||||
|
|
||||||
// If authenticated, start syncing to server
|
// If authenticated, start syncing to server
|
||||||
if (authStore.isAuthenticated) {
|
if (authStore.isAuthenticated) {
|
||||||
clockStore.startSync(() => authStore.getValidToken());
|
const getToken = () => authStore.getValidToken();
|
||||||
|
clockStore.startSync(getToken);
|
||||||
|
tagMutations.startSync(getToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize collapsed state from localStorage
|
// Initialize collapsed state from localStorage
|
||||||
|
|
@ -274,9 +284,9 @@
|
||||||
// Show guest welcome modal on first visit
|
// Show guest welcome modal on first visit
|
||||||
initGuestWelcome();
|
initGuestWelcome();
|
||||||
|
|
||||||
// Load user settings and tags (these need auth / central service)
|
// Load user settings (requires auth)
|
||||||
if (authStore.isAuthenticated) {
|
if (authStore.isAuthenticated) {
|
||||||
await Promise.all([userSettings.load(), tagStore.fetchTags()]);
|
await userSettings.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect to start page if on root and a custom start page is set
|
// Redirect to start page if on root and a custom start page is set
|
||||||
|
|
@ -327,7 +337,7 @@
|
||||||
<!-- TagStrip (above PillNav, toggled via Tags pill) -->
|
<!-- TagStrip (above PillNav, toggled via Tags pill) -->
|
||||||
{#if isTagStripVisible}
|
{#if isTagStripVisible}
|
||||||
<TagStrip
|
<TagStrip
|
||||||
tags={tagStore.tags.map((t) => ({
|
tags={allTags.value.map((t) => ({
|
||||||
id: t.id,
|
id: t.id,
|
||||||
name: t.name,
|
name: t.name,
|
||||||
color: t.color || '#3b82f6',
|
color: t.color || '#3b82f6',
|
||||||
|
|
@ -336,7 +346,6 @@
|
||||||
onToggle={() => {}}
|
onToggle={() => {}}
|
||||||
onClear={() => {}}
|
onClear={() => {}}
|
||||||
managementHref="/tags"
|
managementHref="/tags"
|
||||||
loading={tagStore.loading}
|
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
# syntax=docker/dockerfile:1
|
|
||||||
# Build stage
|
|
||||||
FROM node:20-alpine AS builder
|
|
||||||
|
|
||||||
# Install pnpm
|
|
||||||
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy root workspace files
|
|
||||||
COPY pnpm-workspace.yaml ./
|
|
||||||
COPY package.json ./
|
|
||||||
COPY pnpm-lock.yaml ./
|
|
||||||
COPY patches ./patches
|
|
||||||
|
|
||||||
# Copy shared packages (required dependencies)
|
|
||||||
COPY packages/shared-drizzle-config ./packages/shared-drizzle-config
|
|
||||||
COPY packages/shared-errors ./packages/shared-errors
|
|
||||||
COPY packages/shared-nestjs-auth ./packages/shared-nestjs-auth
|
|
||||||
COPY packages/shared-nestjs-health ./packages/shared-nestjs-health
|
|
||||||
COPY packages/shared-nestjs-metrics ./packages/shared-nestjs-metrics
|
|
||||||
COPY packages/shared-nestjs-setup ./packages/shared-nestjs-setup
|
|
||||||
COPY packages/shared-tsconfig ./packages/shared-tsconfig
|
|
||||||
COPY packages/shared-error-tracking ./packages/shared-error-tracking
|
|
||||||
COPY packages/shared-nestjs-setup ./packages/shared-nestjs-setup
|
|
||||||
|
|
||||||
# Copy zitare content package
|
|
||||||
COPY apps/zitare/packages/content ./apps/zitare/packages/content
|
|
||||||
|
|
||||||
# Copy zitare backend
|
|
||||||
COPY apps/zitare/apps/backend ./apps/zitare/apps/backend
|
|
||||||
|
|
||||||
# Install dependencies (ignore scripts since generate-env.mjs isn't in Docker context)
|
|
||||||
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --ignore-scripts
|
|
||||||
|
|
||||||
# Build shared packages first (in dependency order)
|
|
||||||
WORKDIR /app/packages/shared-errors
|
|
||||||
RUN pnpm build
|
|
||||||
|
|
||||||
WORKDIR /app/packages/shared-nestjs-auth
|
|
||||||
RUN pnpm build
|
|
||||||
|
|
||||||
WORKDIR /app/packages/shared-nestjs-health
|
|
||||||
RUN pnpm build
|
|
||||||
|
|
||||||
WORKDIR /app/packages/shared-nestjs-metrics
|
|
||||||
RUN pnpm build
|
|
||||||
|
|
||||||
WORKDIR /app/packages/shared-nestjs-setup
|
|
||||||
RUN pnpm build
|
|
||||||
|
|
||||||
# Build zitare content package
|
|
||||||
WORKDIR /app/apps/zitare/packages/content
|
|
||||||
RUN pnpm build
|
|
||||||
|
|
||||||
# Build the backend
|
|
||||||
|
|
||||||
WORKDIR /app/packages/shared-nestjs-setup
|
|
||||||
RUN pnpm build
|
|
||||||
|
|
||||||
WORKDIR /app/packages/shared-error-tracking
|
|
||||||
RUN pnpm build
|
|
||||||
|
|
||||||
WORKDIR /app/apps/zitare/apps/backend
|
|
||||||
RUN pnpm build
|
|
||||||
|
|
||||||
# Production stage
|
|
||||||
FROM node:20-alpine AS production
|
|
||||||
|
|
||||||
# Install pnpm and postgresql-client for health checks
|
|
||||||
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate \
|
|
||||||
&& 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/zitare ./apps/zitare
|
|
||||||
|
|
||||||
# Copy entrypoint script
|
|
||||||
COPY apps/zitare/apps/backend/docker-entrypoint.sh /usr/local/bin/
|
|
||||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
|
||||||
|
|
||||||
WORKDIR /app/apps/zitare/apps/backend
|
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 3007
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
|
||||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3007/health || exit 1
|
|
||||||
|
|
||||||
# Run entrypoint script
|
|
||||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
|
||||||
CMD ["node", "dist/main.js"]
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "=== Zitare 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:-zitare} 2>/dev/null; do
|
|
||||||
echo "PostgreSQL is unavailable - sleeping"
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
echo "PostgreSQL is up!"
|
|
||||||
|
|
||||||
cd /app/apps/zitare/apps/backend
|
|
||||||
|
|
||||||
# Run schema push (for development) or migrations (for production)
|
|
||||||
if [ "$NODE_ENV" = "production" ] && [ -d "src/db/migrations/meta" ]; then
|
|
||||||
echo "Running database migrations..."
|
|
||||||
npx tsx src/db/migrate.ts
|
|
||||||
echo "Migrations completed!"
|
|
||||||
else
|
|
||||||
echo "Pushing database schema (development mode)..."
|
|
||||||
npx drizzle-kit push --force
|
|
||||||
echo "Schema push completed!"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Run seed if seed file exists
|
|
||||||
if [ -f "src/db/seed.ts" ]; then
|
|
||||||
echo "Running database seed..."
|
|
||||||
npx tsx src/db/seed.ts
|
|
||||||
echo "Seed completed!"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Execute the main command
|
|
||||||
echo "Starting application..."
|
|
||||||
exec "$@"
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
import { createDrizzleConfig } from '@manacore/shared-drizzle-config';
|
|
||||||
|
|
||||||
export default createDrizzleConfig({
|
|
||||||
dbName: 'zitare',
|
|
||||||
additionalEnvVars: ['ZITARE_DATABASE_URL'],
|
|
||||||
});
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
{
|
|
||||||
"name": "@zitare/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:*",
|
|
||||||
"@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",
|
|
||||||
"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",
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +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';
|
|
||||||
|
|
||||||
@Controller('admin')
|
|
||||||
@UseGuards(ServiceAuthGuard)
|
|
||||||
export class AdminController {
|
|
||||||
private readonly logger = new Logger(AdminController.name);
|
|
||||||
|
|
||||||
constructor(private readonly adminService: AdminService) {}
|
|
||||||
|
|
||||||
@Get('user-data/:userId')
|
|
||||||
async getUserData(@Param('userId') userId: string): Promise<UserDataResponse> {
|
|
||||||
this.logger.log(`Admin request: getUserData for userId=${userId}`);
|
|
||||||
return this.adminService.getUserData(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete('user-data/:userId')
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
async deleteUserData(@Param('userId') userId: string): Promise<DeleteUserDataResponse> {
|
|
||||||
this.logger.log(`Admin request: deleteUserData for userId=${userId}`);
|
|
||||||
return this.adminService.deleteUserData(userId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 {}
|
|
||||||
|
|
@ -1,87 +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<typeof schema>
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async getUserData(userId: string): Promise<UserDataResponse> {
|
|
||||||
this.logger.log(`Getting user data for userId: ${userId}`);
|
|
||||||
|
|
||||||
// Count favorites
|
|
||||||
const favoritesResult = await this.db
|
|
||||||
.select({ count: sql<number>`count(*)::int` })
|
|
||||||
.from(schema.favorites)
|
|
||||||
.where(eq(schema.favorites.userId, userId));
|
|
||||||
const favoritesCount = favoritesResult[0]?.count ?? 0;
|
|
||||||
|
|
||||||
// Count user lists
|
|
||||||
const userListsResult = await this.db
|
|
||||||
.select({ count: sql<number>`count(*)::int` })
|
|
||||||
.from(schema.userLists)
|
|
||||||
.where(eq(schema.userLists.userId, userId));
|
|
||||||
const userListsCount = userListsResult[0]?.count ?? 0;
|
|
||||||
|
|
||||||
// Get last activity
|
|
||||||
const lastFavorite = await this.db
|
|
||||||
.select({ createdAt: schema.favorites.createdAt })
|
|
||||||
.from(schema.favorites)
|
|
||||||
.where(eq(schema.favorites.userId, userId))
|
|
||||||
.orderBy(desc(schema.favorites.createdAt))
|
|
||||||
.limit(1);
|
|
||||||
const lastActivityAt = lastFavorite[0]?.createdAt?.toISOString();
|
|
||||||
|
|
||||||
const entities: EntityCount[] = [
|
|
||||||
{ entity: 'favorites', count: favoritesCount, label: 'Favorites' },
|
|
||||||
{ entity: 'user_lists', count: userListsCount, label: 'User Lists' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const totalCount = favoritesCount + userListsCount;
|
|
||||||
|
|
||||||
return { entities, totalCount, lastActivityAt };
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteUserData(userId: string): Promise<DeleteUserDataResponse> {
|
|
||||||
this.logger.log(`Deleting user data for userId: ${userId}`);
|
|
||||||
|
|
||||||
const deletedCounts: EntityCount[] = [];
|
|
||||||
let totalDeleted = 0;
|
|
||||||
|
|
||||||
// Delete favorites
|
|
||||||
const deletedFavorites = await this.db
|
|
||||||
.delete(schema.favorites)
|
|
||||||
.where(eq(schema.favorites.userId, userId))
|
|
||||||
.returning();
|
|
||||||
deletedCounts.push({ entity: 'favorites', count: deletedFavorites.length, label: 'Favorites' });
|
|
||||||
totalDeleted += deletedFavorites.length;
|
|
||||||
|
|
||||||
// Delete user lists
|
|
||||||
const deletedUserLists = await this.db
|
|
||||||
.delete(schema.userLists)
|
|
||||||
.where(eq(schema.userLists.userId, userId))
|
|
||||||
.returning();
|
|
||||||
deletedCounts.push({
|
|
||||||
entity: 'user_lists',
|
|
||||||
count: deletedUserLists.length,
|
|
||||||
label: 'User Lists',
|
|
||||||
});
|
|
||||||
totalDeleted += deletedUserLists.length;
|
|
||||||
|
|
||||||
this.logger.log(`Deleted ${totalDeleted} records for userId: ${userId}`);
|
|
||||||
|
|
||||||
return { success: true, deletedCounts, totalDeleted };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
import {
|
|
||||||
Injectable,
|
|
||||||
CanActivate,
|
|
||||||
ExecutionContext,
|
|
||||||
UnauthorizedException,
|
|
||||||
Logger,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { Request } from 'express';
|
|
||||||
|
|
||||||
@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<string>('ADMIN_SERVICE_KEY', 'dev-admin-key');
|
|
||||||
}
|
|
||||||
|
|
||||||
canActivate(context: ExecutionContext): boolean {
|
|
||||||
const request = context.switchToHttp().getRequest<Request>();
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { ConfigModule } from '@nestjs/config';
|
|
||||||
import { DatabaseModule } from './db/database.module';
|
|
||||||
import { FavoriteModule } from './favorite/favorite.module';
|
|
||||||
import { ListModule } from './list/list.module';
|
|
||||||
import { HealthModule } from '@manacore/shared-nestjs-health';
|
|
||||||
import { MetricsModule } from '@manacore/shared-nestjs-metrics';
|
|
||||||
import { AdminModule } from './admin/admin.module';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [
|
|
||||||
ConfigModule.forRoot({
|
|
||||||
isGlobal: true,
|
|
||||||
envFilePath: '.env',
|
|
||||||
}),
|
|
||||||
DatabaseModule,
|
|
||||||
FavoriteModule,
|
|
||||||
ListModule,
|
|
||||||
HealthModule.forRoot({ serviceName: 'quote-backend' }),
|
|
||||||
MetricsModule.register({
|
|
||||||
prefix: 'zitare_',
|
|
||||||
excludePaths: ['/health'],
|
|
||||||
}),
|
|
||||||
AdminModule,
|
|
||||||
],
|
|
||||||
})
|
|
||||||
export class AppModule {}
|
|
||||||
|
|
@ -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<typeof postgres> | null = null;
|
|
||||||
let db: ReturnType<typeof drizzle> | 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<typeof getDb>;
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
import { Module, Global, OnModuleDestroy } from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { getDb, closeConnection } from './connection';
|
|
||||||
import type { Database } from './connection';
|
|
||||||
|
|
||||||
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
|
|
||||||
|
|
||||||
@Global()
|
|
||||||
@Module({
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
provide: DATABASE_CONNECTION,
|
|
||||||
useFactory: (configService: ConfigService): Database => {
|
|
||||||
const databaseUrl = configService.get<string>('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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
|
||||||
import { migrate } from 'drizzle-orm/postgres-js/migrator';
|
|
||||||
import * as dotenv from 'dotenv';
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
||||||
const postgres = require('postgres');
|
|
||||||
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
async function runMigrations() {
|
|
||||||
const databaseUrl = process.env.DATABASE_URL;
|
|
||||||
|
|
||||||
if (!databaseUrl) {
|
|
||||||
throw new Error('DATABASE_URL environment variable is not set');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Running migrations...');
|
|
||||||
|
|
||||||
const sql = postgres(databaseUrl, { max: 1 });
|
|
||||||
const db = drizzle(sql);
|
|
||||||
|
|
||||||
await migrate(db, { migrationsFolder: './src/db/migrations' });
|
|
||||||
|
|
||||||
await sql.end();
|
|
||||||
|
|
||||||
console.log('Migrations completed successfully!');
|
|
||||||
}
|
|
||||||
|
|
||||||
runMigrations().catch(console.error);
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import { pgTable, uuid, text, timestamp, unique, varchar } from 'drizzle-orm/pg-core';
|
|
||||||
|
|
||||||
export const favorites = pgTable(
|
|
||||||
'favorites',
|
|
||||||
{
|
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
|
||||||
userId: text('user_id').notNull(),
|
|
||||||
quoteId: varchar('quote_id', { length: 100 }).notNull(), // References static quote ID from shared package
|
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
|
||||||
},
|
|
||||||
(table) => ({
|
|
||||||
uniqueUserQuote: unique().on(table.userId, table.quoteId),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
export type Favorite = typeof favorites.$inferSelect;
|
|
||||||
export type NewFavorite = typeof favorites.$inferInsert;
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
export * from './favorites.schema';
|
|
||||||
export * from './user-lists.schema';
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import { pgTable, uuid, text, timestamp, jsonb } from 'drizzle-orm/pg-core';
|
|
||||||
|
|
||||||
export const userLists = pgTable('user_lists', {
|
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
|
||||||
userId: text('user_id').notNull(),
|
|
||||||
name: text('name').notNull(),
|
|
||||||
description: text('description'),
|
|
||||||
quoteIds: jsonb('quote_ids').$type<string[]>().default([]), // References static quote IDs from shared package
|
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
|
||||||
.defaultNow()
|
|
||||||
.$onUpdate(() => new Date())
|
|
||||||
.notNull(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type UserList = typeof userLists.$inferSelect;
|
|
||||||
export type NewUserList = typeof userLists.$inferInsert;
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
import {
|
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
Post,
|
|
||||||
Delete,
|
|
||||||
Body,
|
|
||||||
Param,
|
|
||||||
UseGuards,
|
|
||||||
ConflictException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
|
||||||
import { FavoriteService } from './favorite.service';
|
|
||||||
import { IsString, IsNotEmpty } from 'class-validator';
|
|
||||||
|
|
||||||
class CreateFavoriteDto {
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
quoteId!: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Controller('favorites')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
export class FavoriteController {
|
|
||||||
constructor(private readonly favoriteService: FavoriteService) {}
|
|
||||||
|
|
||||||
@Get()
|
|
||||||
async findAll(@CurrentUser() user: CurrentUserData) {
|
|
||||||
const favorites = await this.favoriteService.findByUserId(user.userId);
|
|
||||||
return { favorites };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post()
|
|
||||||
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateFavoriteDto) {
|
|
||||||
// Check if already favorited
|
|
||||||
const exists = await this.favoriteService.exists(user.userId, dto.quoteId);
|
|
||||||
if (exists) {
|
|
||||||
throw new ConflictException('Quote already in favorites');
|
|
||||||
}
|
|
||||||
|
|
||||||
const favorite = await this.favoriteService.create({
|
|
||||||
userId: user.userId,
|
|
||||||
quoteId: dto.quoteId,
|
|
||||||
});
|
|
||||||
return { favorite };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete(':quoteId')
|
|
||||||
async delete(@CurrentUser() user: CurrentUserData, @Param('quoteId') quoteId: string) {
|
|
||||||
await this.favoriteService.delete(user.userId, quoteId);
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { FavoriteController } from './favorite.controller';
|
|
||||||
import { FavoriteService } from './favorite.service';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
controllers: [FavoriteController],
|
|
||||||
providers: [FavoriteService],
|
|
||||||
exports: [FavoriteService],
|
|
||||||
})
|
|
||||||
export class FavoriteModule {}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
import { Injectable, Inject } from '@nestjs/common';
|
|
||||||
import { eq, and } from 'drizzle-orm';
|
|
||||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
|
||||||
import { Database } from '../db/connection';
|
|
||||||
import { favorites } from '../db/schema';
|
|
||||||
import type { Favorite, NewFavorite } from '../db/schema';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class FavoriteService {
|
|
||||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
|
||||||
|
|
||||||
async findByUserId(userId: string): Promise<Favorite[]> {
|
|
||||||
return this.db.select().from(favorites).where(eq(favorites.userId, userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(data: NewFavorite): Promise<Favorite> {
|
|
||||||
const [favorite] = await this.db.insert(favorites).values(data).returning();
|
|
||||||
return favorite;
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(userId: string, quoteId: string): Promise<void> {
|
|
||||||
await this.db
|
|
||||||
.delete(favorites)
|
|
||||||
.where(and(eq(favorites.userId, userId), eq(favorites.quoteId, quoteId)));
|
|
||||||
}
|
|
||||||
|
|
||||||
async exists(userId: string, quoteId: string): Promise<boolean> {
|
|
||||||
const result = await this.db
|
|
||||||
.select()
|
|
||||||
.from(favorites)
|
|
||||||
.where(and(eq(favorites.userId, userId), eq(favorites.quoteId, quoteId)));
|
|
||||||
return result.length > 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
import { initErrorTracking } from '@manacore/shared-error-tracking';
|
|
||||||
|
|
||||||
initErrorTracking({
|
|
||||||
serviceName: 'zitare-backend',
|
|
||||||
environment: process.env.NODE_ENV,
|
|
||||||
release: process.env.APP_VERSION,
|
|
||||||
debug: process.env.NODE_ENV === 'development',
|
|
||||||
});
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common';
|
|
||||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
|
||||||
import { ListService } from './list.service';
|
|
||||||
import { IsString, IsNotEmpty, IsOptional, IsArray } from 'class-validator';
|
|
||||||
|
|
||||||
class CreateListDto {
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
name!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
class UpdateListDto {
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
name?: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
description?: string;
|
|
||||||
|
|
||||||
@IsArray()
|
|
||||||
@IsString({ each: true })
|
|
||||||
@IsOptional()
|
|
||||||
quoteIds?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
class AddQuoteDto {
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
quoteId!: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Controller('lists')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
export class ListController {
|
|
||||||
constructor(private readonly listService: ListService) {}
|
|
||||||
|
|
||||||
@Get()
|
|
||||||
async findAll(@CurrentUser() user: CurrentUserData) {
|
|
||||||
const lists = await this.listService.findByUserId(user.userId);
|
|
||||||
return { lists };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(':id')
|
|
||||||
async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
|
||||||
const list = await this.listService.findById(user.userId, id);
|
|
||||||
return { list };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post()
|
|
||||||
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateListDto) {
|
|
||||||
const list = await this.listService.create({
|
|
||||||
userId: user.userId,
|
|
||||||
name: dto.name,
|
|
||||||
description: dto.description,
|
|
||||||
});
|
|
||||||
return { list };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Put(':id')
|
|
||||||
async update(
|
|
||||||
@CurrentUser() user: CurrentUserData,
|
|
||||||
@Param('id') id: string,
|
|
||||||
@Body() dto: UpdateListDto
|
|
||||||
) {
|
|
||||||
const list = await this.listService.update(user.userId, id, dto);
|
|
||||||
return { list };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete(':id')
|
|
||||||
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
|
||||||
await this.listService.delete(user.userId, id);
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post(':id/quotes')
|
|
||||||
async addQuote(
|
|
||||||
@CurrentUser() user: CurrentUserData,
|
|
||||||
@Param('id') id: string,
|
|
||||||
@Body() dto: AddQuoteDto
|
|
||||||
) {
|
|
||||||
const list = await this.listService.addQuoteToList(user.userId, id, dto.quoteId);
|
|
||||||
return { list };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete(':id/quotes/:quoteId')
|
|
||||||
async removeQuote(
|
|
||||||
@CurrentUser() user: CurrentUserData,
|
|
||||||
@Param('id') id: string,
|
|
||||||
@Param('quoteId') quoteId: string
|
|
||||||
) {
|
|
||||||
const list = await this.listService.removeQuoteFromList(user.userId, id, quoteId);
|
|
||||||
return { list };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { ListController } from './list.controller';
|
|
||||||
import { ListService } from './list.service';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
controllers: [ListController],
|
|
||||||
providers: [ListService],
|
|
||||||
exports: [ListService],
|
|
||||||
})
|
|
||||||
export class ListModule {}
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
|
||||||
import { eq, and } from 'drizzle-orm';
|
|
||||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
|
||||||
import { Database } from '../db/connection';
|
|
||||||
import { userLists } from '../db/schema';
|
|
||||||
import type { UserList, NewUserList } from '../db/schema';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class ListService {
|
|
||||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
|
||||||
|
|
||||||
async findByUserId(userId: string): Promise<UserList[]> {
|
|
||||||
return this.db.select().from(userLists).where(eq(userLists.userId, userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
async findById(userId: string, listId: string): Promise<UserList> {
|
|
||||||
const [list] = await this.db
|
|
||||||
.select()
|
|
||||||
.from(userLists)
|
|
||||||
.where(and(eq(userLists.id, listId), eq(userLists.userId, userId)));
|
|
||||||
|
|
||||||
if (!list) {
|
|
||||||
throw new NotFoundException('List not found');
|
|
||||||
}
|
|
||||||
return list;
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(data: NewUserList): Promise<UserList> {
|
|
||||||
const [list] = await this.db.insert(userLists).values(data).returning();
|
|
||||||
return list;
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(
|
|
||||||
userId: string,
|
|
||||||
listId: string,
|
|
||||||
data: Partial<Pick<UserList, 'name' | 'description' | 'quoteIds'>>
|
|
||||||
): Promise<UserList> {
|
|
||||||
const [list] = await this.db
|
|
||||||
.update(userLists)
|
|
||||||
.set(data)
|
|
||||||
.where(and(eq(userLists.id, listId), eq(userLists.userId, userId)))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (!list) {
|
|
||||||
throw new NotFoundException('List not found');
|
|
||||||
}
|
|
||||||
return list;
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(userId: string, listId: string): Promise<void> {
|
|
||||||
const result = await this.db
|
|
||||||
.delete(userLists)
|
|
||||||
.where(and(eq(userLists.id, listId), eq(userLists.userId, userId)));
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
throw new NotFoundException('List not found');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async addQuoteToList(userId: string, listId: string, quoteId: string): Promise<UserList> {
|
|
||||||
const list = await this.findById(userId, listId);
|
|
||||||
const quoteIds = list.quoteIds || [];
|
|
||||||
|
|
||||||
if (!quoteIds.includes(quoteId)) {
|
|
||||||
quoteIds.push(quoteId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.update(userId, listId, { quoteIds });
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeQuoteFromList(userId: string, listId: string, quoteId: string): Promise<UserList> {
|
|
||||||
const list = await this.findById(userId, listId);
|
|
||||||
const quoteIds = (list.quoteIds || []).filter((id) => id !== quoteId);
|
|
||||||
return this.update(userId, listId, { quoteIds });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
import './instrument';
|
|
||||||
import { bootstrapApp } from '@manacore/shared-nestjs-setup';
|
|
||||||
import { AppModule } from './app.module';
|
|
||||||
|
|
||||||
bootstrapApp(AppModule, {
|
|
||||||
defaultPort: 3007,
|
|
||||||
serviceName: 'Quote',
|
|
||||||
additionalCorsOrigins: ['http://localhost:5177'],
|
|
||||||
});
|
|
||||||
|
|
@ -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"]
|
|
||||||
}
|
|
||||||
|
|
@ -4,18 +4,9 @@
|
||||||
import { _ } from 'svelte-i18n';
|
import { _ } from 'svelte-i18n';
|
||||||
import { authStore } from '$lib/stores/auth.svelte';
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
import { toast } from '$lib/stores/toast.svelte';
|
import { toast } from '$lib/stores/toast.svelte';
|
||||||
|
import { listsStore } from '$lib/stores/lists.svelte';
|
||||||
import { ZitareEvents } from '@manacore/shared-utils/analytics';
|
import { ZitareEvents } from '@manacore/shared-utils/analytics';
|
||||||
|
|
||||||
interface QuoteList {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
quoteIds: string[];
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let lists = $state<QuoteList[]>([]);
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
let deletingId = $state<string | null>(null);
|
let deletingId = $state<string | null>(null);
|
||||||
|
|
@ -23,69 +14,16 @@
|
||||||
let newListName = $state('');
|
let newListName = $state('');
|
||||||
let newListDescription = $state('');
|
let newListDescription = $state('');
|
||||||
|
|
||||||
// Get backend URL
|
|
||||||
function getBackendUrl(): string {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string })
|
|
||||||
.__PUBLIC_BACKEND_URL__;
|
|
||||||
return injectedUrl || 'http://localhost:3007';
|
|
||||||
}
|
|
||||||
return 'http://localhost:3007';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchLists() {
|
|
||||||
if (!authStore.isAuthenticated) {
|
|
||||||
loading = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = await authStore.getValidToken();
|
|
||||||
if (!token) {
|
|
||||||
loading = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${getBackendUrl()}/api/lists`, {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
});
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
lists = data.lists || [];
|
|
||||||
} else {
|
|
||||||
toast.error($_('common.error'));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch lists:', error);
|
|
||||||
toast.error($_('common.error'));
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createList() {
|
async function createList() {
|
||||||
if (!newListName.trim() || saving) return;
|
if (!newListName.trim() || saving) return;
|
||||||
|
|
||||||
const token = await authStore.getValidToken();
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
saving = true;
|
saving = true;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${getBackendUrl()}/api/lists`, {
|
const created = await listsStore.createList(
|
||||||
method: 'POST',
|
newListName.trim(),
|
||||||
headers: {
|
newListDescription.trim() || undefined
|
||||||
'Content-Type': 'application/json',
|
);
|
||||||
Authorization: `Bearer ${token}`,
|
if (created) {
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
name: newListName.trim(),
|
|
||||||
description: newListDescription.trim() || undefined,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
lists = [...lists, data.list];
|
|
||||||
ZitareEvents.listCreated();
|
ZitareEvents.listCreated();
|
||||||
showCreateModal = false;
|
showCreateModal = false;
|
||||||
newListName = '';
|
newListName = '';
|
||||||
|
|
@ -104,18 +42,10 @@
|
||||||
async function deleteList(listId: string) {
|
async function deleteList(listId: string) {
|
||||||
if (deletingId || !confirm($_('lists.confirmDelete'))) return;
|
if (deletingId || !confirm($_('lists.confirmDelete'))) return;
|
||||||
|
|
||||||
const token = await authStore.getValidToken();
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
deletingId = listId;
|
deletingId = listId;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${getBackendUrl()}/api/lists/${listId}`, {
|
const success = await listsStore.deleteList(listId);
|
||||||
method: 'DELETE',
|
if (success) {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
lists = lists.filter((l) => l.id !== listId);
|
|
||||||
ZitareEvents.listDeleted();
|
ZitareEvents.listDeleted();
|
||||||
} else {
|
} else {
|
||||||
toast.error($_('lists.detail.toast.deleteError'));
|
toast.error($_('lists.detail.toast.deleteError'));
|
||||||
|
|
@ -128,8 +58,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(async () => {
|
||||||
fetchLists();
|
await listsStore.loadLists();
|
||||||
|
loading = false;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -187,7 +118,7 @@
|
||||||
class="w-10 h-10 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto"
|
class="w-10 h-10 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
{:else if lists.length === 0}
|
{:else if listsStore.lists.length === 0}
|
||||||
<div class="text-center py-12 bg-surface-elevated rounded-2xl">
|
<div class="text-center py-12 bg-surface-elevated rounded-2xl">
|
||||||
<svg
|
<svg
|
||||||
class="w-16 h-16 mx-auto text-foreground-muted mb-4"
|
class="w-16 h-16 mx-auto text-foreground-muted mb-4"
|
||||||
|
|
@ -207,7 +138,7 @@
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="grid gap-4">
|
<div class="grid gap-4">
|
||||||
{#each lists as list (list.id)}
|
{#each listsStore.lists as list (list.id)}
|
||||||
<a
|
<a
|
||||||
href="/lists/{list.id}"
|
href="/lists/{list.id}"
|
||||||
class="block p-6 bg-surface-elevated rounded-2xl hover:shadow-lg transition-all group"
|
class="block p-6 bg-surface-elevated rounded-2xl hover:shadow-lg transition-all group"
|
||||||
|
|
|
||||||
|
|
@ -77,12 +77,7 @@ scrape_configs:
|
||||||
metrics_path: '/metrics'
|
metrics_path: '/metrics'
|
||||||
scrape_interval: 30s
|
scrape_interval: 30s
|
||||||
|
|
||||||
# Clock Backend
|
# Clock Backend: REMOVED — migrated to local-first
|
||||||
- job_name: 'clock-backend'
|
|
||||||
static_configs:
|
|
||||||
- targets: ['clock-backend:3033']
|
|
||||||
metrics_path: '/metrics'
|
|
||||||
scrape_interval: 30s
|
|
||||||
|
|
||||||
# Contacts Backend
|
# Contacts Backend
|
||||||
- job_name: 'contacts-backend'
|
- job_name: 'contacts-backend'
|
||||||
|
|
@ -126,12 +121,7 @@ scrape_configs:
|
||||||
metrics_path: '/metrics'
|
metrics_path: '/metrics'
|
||||||
scrape_interval: 30s
|
scrape_interval: 30s
|
||||||
|
|
||||||
# Zitare Backend
|
# Zitare Backend: REMOVED — migrated to local-first
|
||||||
- job_name: 'zitare-backend'
|
|
||||||
static_configs:
|
|
||||||
- targets: ['zitare-backend:3007']
|
|
||||||
metrics_path: '/metrics'
|
|
||||||
scrape_interval: 30s
|
|
||||||
|
|
||||||
# Mukke Backend
|
# Mukke Backend
|
||||||
- job_name: 'mukke-backend'
|
- job_name: 'mukke-backend'
|
||||||
|
|
|
||||||
577
docs/TECHNOLOGY_AUDIT_2026_03.md
Normal file
577
docs/TECHNOLOGY_AUDIT_2026_03.md
Normal file
|
|
@ -0,0 +1,577 @@
|
||||||
|
# Technology Audit - ManaCore Monorepo
|
||||||
|
|
||||||
|
**Datum:** 27. Maerz 2026
|
||||||
|
**Status:** Noch nichts live - vollstaendiger Umbau moeglich
|
||||||
|
**Scope:** Gesamtes Repository - Architektur, Services, Frontend, Infrastruktur, Testing, Deployment
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zusammenfassung
|
||||||
|
|
||||||
|
| Bereich | Bewertung | Handlungsbedarf |
|
||||||
|
|---------|-----------|-----------------|
|
||||||
|
| Architektur-Vision (Local-first) | Stark | Kein Umbau noetig |
|
||||||
|
| Backend-Proliferation | **Kritisch** | 19 NestJS + 1 Go + 1 Hono = 21 Backends |
|
||||||
|
| Frontend (SvelteKit) | Gut | Versionen konsistent, Svelte 5 durchgaengig |
|
||||||
|
| Mobile (Expo) | Mittel | Version-Divergenz (SDK 52/54/55) |
|
||||||
|
| Shared Packages | Zu granular | 55 Packages, viele konsolidierbar |
|
||||||
|
| Infrastructure | Ambitioniert | 67 Container auf einem Mac Mini |
|
||||||
|
| Testing | **Kritisch** | ~162 Testdateien fuer 24 Apps + 16 Services |
|
||||||
|
| Services-Mix | Problematisch | 4 Sprachen (TS, Python, Go, Bun) |
|
||||||
|
| CI/CD | Funktional | Change-Detection gut, kein Staging |
|
||||||
|
| Datenbank | Grundsolide | PostgreSQL 16 + Drizzle ORM |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Inhaltsverzeichnis
|
||||||
|
|
||||||
|
1. [Groesstes Problem: 19 identische NestJS-Backends](#1-groesstes-problem-19-identische-nestjs-backends)
|
||||||
|
2. [NestJS vs. Alternativen](#2-nestjs-vs-alternativen)
|
||||||
|
3. [Sprachen-Wildwuchs](#3-sprachen-wildwuchs-4-runtime-umgebungen)
|
||||||
|
4. [Mobile Apps: Expo-Version-Divergenz](#4-mobile-apps-expo-version-divergenz)
|
||||||
|
5. [Frontend: Svelte 5 + SvelteKit](#5-frontend-svelte-5--sveltekit)
|
||||||
|
6. [Local-First Architektur](#6-local-first-dexie--mana-sync)
|
||||||
|
7. [Infrastruktur & Deployment](#7-infrastruktur--deployment)
|
||||||
|
8. [Datenbank](#8-datenbank)
|
||||||
|
9. [Testing](#9-testing)
|
||||||
|
10. [Shared Packages](#10-shared-packages)
|
||||||
|
11. [CI/CD Pipeline](#11-cicd-pipeline)
|
||||||
|
12. [Frontend Store-Duplikation](#12-frontend-store-duplikation)
|
||||||
|
13. [Services-Analyse](#13-services-analyse-detail)
|
||||||
|
14. [Sicherheit & fehlende Stuecke](#14-sicherheit--fehlende-stuecke)
|
||||||
|
15. [Was nicht geaendert werden sollte](#15-was-nicht-geaendert-werden-sollte)
|
||||||
|
16. [Priorisierte Empfehlungen](#16-priorisierte-empfehlungen)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Groesstes Problem: 19 identische NestJS-Backends
|
||||||
|
|
||||||
|
Das ist mit Abstand das dringendste Thema. Es gibt **19 separate NestJS-Backends**, die alle quasi identisch aufgebaut sind:
|
||||||
|
|
||||||
|
```
|
||||||
|
ConfigModule + DatabaseModule + HealthModule + MetricsModule + AdminModule + CRUD-Module
|
||||||
|
```
|
||||||
|
|
||||||
|
Jeder einzelne Backend-Service:
|
||||||
|
- Ist ein eigener Docker-Container (~50 MB node_modules)
|
||||||
|
- Braucht seinen eigenen Port (3007, 3031, 3032, 3033, 3034...)
|
||||||
|
- Hat eigene Health-Checks, Metrics, Auth-Integration
|
||||||
|
- Wird separat gebaut, deployed, ueberwacht
|
||||||
|
- Dupliziert ~300-400 Zeilen identischen Boilerplate
|
||||||
|
|
||||||
|
### Backend-Komplexitaets-Analyse
|
||||||
|
|
||||||
|
| Kategorie | Backends | Source Files | Empfehlung |
|
||||||
|
|-----------|----------|-------------|------------|
|
||||||
|
| **Komplex (behalten)** | Chat, Todo, Calendar, Contacts, Storage, Mukke | 60-89 pro Backend | Eigenstaendige Services |
|
||||||
|
| **AI-spezialisiert (behalten)** | Picture, Planta, NutriPhi | 30-50 pro Backend | Eigene AI-Pipelines |
|
||||||
|
| **Triviales CRUD (konsolidieren)** | Zitare (20!), Clock (31), Presi, CityCorners, Questions, Context | 20-35 pro Backend | -> 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
|
||||||
15
package.json
15
package.json
|
|
@ -34,7 +34,7 @@
|
||||||
"dev:manacore:landing": "pnpm --filter @manacore/landing dev",
|
"dev:manacore:landing": "pnpm --filter @manacore/landing dev",
|
||||||
"dev:manacore:mobile": "pnpm --filter @manacore/mobile dev",
|
"dev:manacore:mobile": "pnpm --filter @manacore/mobile dev",
|
||||||
"dev:manacore:app": "pnpm --filter @manacore/web 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: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:web": "pnpm --filter @manadeck/web dev",
|
||||||
"dev:manadeck:landing": "pnpm --filter @manadeck/landing dev",
|
"dev:manadeck:landing": "pnpm --filter @manadeck/landing dev",
|
||||||
|
|
@ -60,9 +60,8 @@
|
||||||
"dev:zitare:mobile": "pnpm --filter @zitare/mobile dev",
|
"dev:zitare:mobile": "pnpm --filter @zitare/mobile dev",
|
||||||
"dev:zitare:web": "pnpm --filter @zitare/web dev",
|
"dev:zitare:web": "pnpm --filter @zitare/web dev",
|
||||||
"dev:zitare:landing": "pnpm --filter @zitare/landing dev",
|
"dev:zitare:landing": "pnpm --filter @zitare/landing dev",
|
||||||
"dev:zitare:backend": "pnpm --filter @zitare/backend dev",
|
"dev:zitare:app": "pnpm dev:zitare:web",
|
||||||
"dev:zitare:app": "turbo run dev --filter=@zitare/web --filter=@zitare/backend",
|
"dev:zitare:full": "concurrently -n auth,sync,web -c blue,magenta,cyan \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:zitare:web\"",
|
||||||
"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\"",
|
|
||||||
"contacts:dev": "turbo run dev --filter=contacts...",
|
"contacts:dev": "turbo run dev --filter=contacts...",
|
||||||
"dev:contacts:mobile": "pnpm --filter @contacts/mobile dev",
|
"dev:contacts:mobile": "pnpm --filter @contacts/mobile dev",
|
||||||
"dev:contacts:web": "pnpm --filter @contacts/web dev",
|
"dev:contacts:web": "pnpm --filter @contacts/web dev",
|
||||||
|
|
@ -86,12 +85,8 @@
|
||||||
"clock:dev": "turbo run dev --filter=clock...",
|
"clock:dev": "turbo run dev --filter=clock...",
|
||||||
"dev:clock:web": "pnpm --filter @clock/web dev",
|
"dev:clock:web": "pnpm --filter @clock/web dev",
|
||||||
"dev:clock:landing": "pnpm --filter @clock/landing dev",
|
"dev:clock:landing": "pnpm --filter @clock/landing dev",
|
||||||
"dev:clock:backend": "pnpm --filter @clock/backend dev",
|
"dev:clock:app": "pnpm dev:clock:web",
|
||||||
"dev:clock:app": "turbo run dev --filter=@clock/web --filter=@clock/backend",
|
"dev:clock:full": "concurrently -n auth,sync,web -c blue,magenta,cyan \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:clock:web\"",
|
||||||
"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",
|
|
||||||
"matrix:dev": "turbo run dev --filter=matrix...",
|
"matrix:dev": "turbo run dev --filter=matrix...",
|
||||||
"dev:matrix:web": "pnpm --filter @matrix/web dev",
|
"dev:matrix:web": "pnpm --filter @matrix/web dev",
|
||||||
"dev:matrix:mobile": "pnpm --filter @matrix/mobile dev",
|
"dev:matrix:mobile": "pnpm --filter @matrix/mobile dev",
|
||||||
|
|
|
||||||
30
packages/shared-hono/package.json
Normal file
30
packages/shared-hono/package.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
74
packages/shared-hono/src/admin.ts
Normal file
74
packages/shared-hono/src/admin.ts
Normal file
|
|
@ -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<typeof sql>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string, number> = {};
|
||||||
|
|
||||||
|
for (const { table, name, userIdColumn } of tables) {
|
||||||
|
const [result] = await db
|
||||||
|
.select({ count: sql<number>`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;
|
||||||
|
}
|
||||||
122
packages/shared-hono/src/auth.ts
Normal file
122
packages/shared-hono/src/auth.ts
Normal file
|
|
@ -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<typeof createRemoteJWKSet> | null = null;
|
||||||
|
let cachedJWKSUrl: string | null = null;
|
||||||
|
|
||||||
|
function getJWKS(): ReturnType<typeof createRemoteJWKSet> {
|
||||||
|
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<string>();
|
||||||
|
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<string, unknown>).email ?? '');
|
||||||
|
c.set('userRole', (payload as Record<string, unknown>).role ?? 'user');
|
||||||
|
c.set('sessionId', (payload as Record<string, unknown>).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();
|
||||||
|
};
|
||||||
|
}
|
||||||
49
packages/shared-hono/src/db.ts
Normal file
49
packages/shared-hono/src/db.ts
Normal file
|
|
@ -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<TSchema extends Record<string, unknown>>(
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
47
packages/shared-hono/src/error.ts
Normal file
47
packages/shared-hono/src/error.ts
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
36
packages/shared-hono/src/health.ts
Normal file
36
packages/shared-hono/src/health.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
41
packages/shared-hono/src/index.ts
Normal file
41
packages/shared-hono/src/index.ts
Normal file
|
|
@ -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';
|
||||||
20
packages/shared-hono/src/types.ts
Normal file
20
packages/shared-hono/src/types.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
14
packages/shared-hono/tsconfig.json
Normal file
14
packages/shared-hono/tsconfig.json
Normal file
|
|
@ -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"]
|
||||||
|
}
|
||||||
|
|
@ -248,19 +248,7 @@ const APP_CONFIGS = [
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// Zitare Backend (NestJS)
|
// Zitare Backend: REMOVED — migrated to local-first
|
||||||
{
|
|
||||||
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 Mobile (Expo)
|
// Zitare Mobile (Expo)
|
||||||
{
|
{
|
||||||
|
|
@ -506,19 +494,7 @@ const APP_CONFIGS = [
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// Clock Backend (NestJS)
|
// Clock Backend: REMOVED — migrated to local-first
|
||||||
{
|
|
||||||
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 Web (SvelteKit)
|
// Clock Web (SvelteKit)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -154,7 +154,6 @@ for container in $ALL_PROBLEM_CONTAINERS; do
|
||||||
mana-app-calendar-web) SERVICE_NAME="calendar-web" ;;
|
mana-app-calendar-web) SERVICE_NAME="calendar-web" ;;
|
||||||
mana-app-calendar-backend) SERVICE_NAME="calendar-backend" ;;
|
mana-app-calendar-backend) SERVICE_NAME="calendar-backend" ;;
|
||||||
mana-app-clock-web) SERVICE_NAME="clock-web" ;;
|
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-web) SERVICE_NAME="contacts-web" ;;
|
||||||
mana-app-contacts-backend) SERVICE_NAME="contacts-backend" ;;
|
mana-app-contacts-backend) SERVICE_NAME="contacts-backend" ;;
|
||||||
mana-app-storage-web) SERVICE_NAME="storage-web" ;;
|
mana-app-storage-web) SERVICE_NAME="storage-web" ;;
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ setup_service() {
|
||||||
;;
|
;;
|
||||||
zitare)
|
zitare)
|
||||||
create_db_if_not_exists "zitare"
|
create_db_if_not_exists "zitare"
|
||||||
push_schema "@zitare/backend" "zitare"
|
# Schema managed by mana-sync (backend removed)
|
||||||
;;
|
;;
|
||||||
contacts)
|
contacts)
|
||||||
create_db_if_not_exists "contacts"
|
create_db_if_not_exists "contacts"
|
||||||
|
|
@ -116,7 +116,7 @@ setup_service() {
|
||||||
;;
|
;;
|
||||||
clock)
|
clock)
|
||||||
create_db_if_not_exists "clock"
|
create_db_if_not_exists "clock"
|
||||||
push_schema "@clock/backend" "clock"
|
# Schema managed by mana-sync (backend removed)
|
||||||
;;
|
;;
|
||||||
todo)
|
todo)
|
||||||
create_db_if_not_exists "todo"
|
create_db_if_not_exists "todo"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue