Compare commits
11 Commits
fa1a422747
...
v1.0.11
| Author | SHA1 | Date | |
|---|---|---|---|
| 6bf10fda2f | |||
| c840825549 | |||
| 9936cfd544 | |||
| b42c36ebdd | |||
| 395199f9b2 | |||
| a37b259f47 | |||
| 0606b71890 | |||
| b4fc4fc955 | |||
| 8774e85529 | |||
| 2b2f4f46c0 | |||
| c5306a15d1 |
@@ -20,6 +20,10 @@ on:
|
||||
description: "Run Playwright E2E gate"
|
||||
required: true
|
||||
default: "true"
|
||||
skip_tests:
|
||||
description: "Skip the entire tests stage"
|
||||
required: true
|
||||
default: "false"
|
||||
deploy:
|
||||
description: "Run production deploy over SSH"
|
||||
required: true
|
||||
@@ -31,7 +35,7 @@ on:
|
||||
require_manual_approval:
|
||||
description: "Require approval token for deploy job"
|
||||
required: true
|
||||
default: "true"
|
||||
default: "false"
|
||||
approval_token:
|
||||
description: "Approval token value when manual approval is required"
|
||||
required: false
|
||||
@@ -96,31 +100,36 @@ jobs:
|
||||
timeout-minutes: 120
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Skip tests stage
|
||||
if: ${{ github.event.inputs.skip_tests == 'true' }}
|
||||
run: echo "Skipping tests stage because skip_tests=true"
|
||||
|
||||
- name: Check out promoted ref
|
||||
if: ${{ github.event.inputs.skip_tests != 'true' }}
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: ${{ needs.prepare.outputs.target_ref }}
|
||||
|
||||
- name: Set up Python for backend tests
|
||||
if: ${{ github.event.inputs.run_backend_tests != 'false' || github.event.inputs.run_playwright_tests != 'false' }}
|
||||
if: ${{ github.event.inputs.skip_tests != 'true' && (github.event.inputs.run_backend_tests != 'false' || github.event.inputs.run_playwright_tests != 'false') }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install backend dependencies (runner python)
|
||||
if: ${{ github.event.inputs.run_backend_tests != 'false' }}
|
||||
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_backend_tests != 'false' }}
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r backend/requirements.txt
|
||||
|
||||
- name: Run backend unit tests
|
||||
if: ${{ github.event.inputs.run_backend_tests != 'false' }}
|
||||
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_backend_tests != 'false' }}
|
||||
run: |
|
||||
cd backend
|
||||
pytest -q
|
||||
|
||||
- name: Set up Node.js
|
||||
if: ${{ github.event.inputs.run_frontend_tests != 'false' || github.event.inputs.run_playwright_tests != 'false' }}
|
||||
if: ${{ github.event.inputs.skip_tests != 'true' && (github.event.inputs.run_frontend_tests != 'false' || github.event.inputs.run_playwright_tests != 'false') }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20.19.0"
|
||||
@@ -128,33 +137,35 @@ jobs:
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install frontend dependencies
|
||||
if: ${{ github.event.inputs.run_frontend_tests != 'false' || github.event.inputs.run_playwright_tests != 'false' }}
|
||||
if: ${{ github.event.inputs.skip_tests != 'true' && (github.event.inputs.run_frontend_tests != 'false' || github.event.inputs.run_playwright_tests != 'false') }}
|
||||
run: npm ci
|
||||
working-directory: frontend
|
||||
|
||||
- name: Run frontend unit tests
|
||||
if: ${{ github.event.inputs.run_frontend_tests != 'false' }}
|
||||
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_frontend_tests != 'false' }}
|
||||
run: npm run test:unit --if-present
|
||||
working-directory: frontend
|
||||
|
||||
- name: Create backend venv for Playwright webServer
|
||||
if: ${{ github.event.inputs.run_playwright_tests != 'false' }}
|
||||
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_playwright_tests != 'false' }}
|
||||
run: |
|
||||
python -m venv backend/.venv
|
||||
backend/.venv/bin/python -m pip install --upgrade pip
|
||||
backend/.venv/bin/python -m pip install -r backend/requirements.txt
|
||||
|
||||
- name: Install Playwright browsers
|
||||
if: ${{ github.event.inputs.run_playwright_tests != 'false' }}
|
||||
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_playwright_tests != 'false' }}
|
||||
run: npx playwright install --with-deps
|
||||
working-directory: frontend
|
||||
|
||||
- name: Run Playwright tests
|
||||
if: ${{ github.event.inputs.run_playwright_tests != 'false' }}
|
||||
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_playwright_tests != 'false' }}
|
||||
run: npx playwright test --reporter=line
|
||||
working-directory: frontend
|
||||
env:
|
||||
CI: "true"
|
||||
PLAYWRIGHT_BASE_URL: "https://localhost:5173"
|
||||
E2E_ACCESS_TOKEN_EXPIRY_MINUTES: "180"
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -190,13 +201,27 @@ jobs:
|
||||
script: |
|
||||
set -euo pipefail
|
||||
|
||||
cd /opt
|
||||
if [ -d "chore/.git" ]; then
|
||||
cd chore
|
||||
repo_dir="${{ secrets.DEPLOY_PROD_PATH }}"
|
||||
if [ -z "$repo_dir" ]; then
|
||||
repo_dir="$HOME/chore"
|
||||
fi
|
||||
|
||||
repo_parent=$(dirname "$repo_dir")
|
||||
if [ ! -d "$repo_parent" ]; then
|
||||
mkdir -p "$repo_parent"
|
||||
fi
|
||||
if [ ! -w "$repo_parent" ]; then
|
||||
echo "Deploy user $(whoami) cannot write to ${repo_parent}."
|
||||
echo "Set DEPLOY_PROD_PATH to a writable location or grant write access before rerunning promotion."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -d "$repo_dir/.git" ]; then
|
||||
cd "$repo_dir"
|
||||
git fetch --all --tags
|
||||
else
|
||||
git clone --branch "${{ needs.prepare.outputs.target_ref }}" https://git.ryankegel.com/ryan/chore.git chore
|
||||
cd chore
|
||||
git clone --branch "${{ needs.prepare.outputs.target_ref }}" https://git.ryankegel.com/ryan/chore.git "$repo_dir"
|
||||
cd "$repo_dir"
|
||||
fi
|
||||
|
||||
git checkout "${{ needs.prepare.outputs.target_ref }}"
|
||||
@@ -236,17 +261,20 @@ jobs:
|
||||
PREV_DIGEST_TOKEN_SECRET=""
|
||||
PREV_VAPID_PUBLIC_KEY=""
|
||||
PREV_VAPID_PRIVATE_KEY=""
|
||||
PREV_REFRESH_TOKEN_EXPIRY_DAYS=""
|
||||
if [ -f .env ]; then
|
||||
PREV_SECRET_KEY=$(grep '^SECRET_KEY=' .env | head -n1 | cut -d '=' -f2- || true)
|
||||
PREV_DIGEST_TOKEN_SECRET=$(grep '^DIGEST_TOKEN_SECRET=' .env | head -n1 | cut -d '=' -f2- || true)
|
||||
PREV_VAPID_PUBLIC_KEY=$(grep '^VAPID_PUBLIC_KEY=' .env | head -n1 | cut -d '=' -f2- || true)
|
||||
PREV_VAPID_PRIVATE_KEY=$(grep '^VAPID_PRIVATE_KEY=' .env | head -n1 | cut -d '=' -f2- || true)
|
||||
PREV_REFRESH_TOKEN_EXPIRY_DAYS=$(grep '^REFRESH_TOKEN_EXPIRY_DAYS=' .env | head -n1 | cut -d '=' -f2- || true)
|
||||
fi
|
||||
|
||||
SECRET_KEY_VALUE="${{ secrets.PROD_SECRET_KEY }}"
|
||||
DIGEST_TOKEN_SECRET_VALUE="${{ secrets.PROD_DIGEST_TOKEN_SECRET }}"
|
||||
VAPID_PUBLIC_KEY_VALUE="${{ secrets.PROD_VAPID_PUBLIC_KEY }}"
|
||||
VAPID_PRIVATE_KEY_VALUE="${{ secrets.PROD_VAPID_PRIVATE_KEY }}"
|
||||
REFRESH_TOKEN_EXPIRY_DAYS_VALUE="${{ secrets.PROD_REFRESH_TOKEN_EXPIRY_DAYS }}"
|
||||
|
||||
if [ -z "$SECRET_KEY_VALUE" ]; then
|
||||
if [ -n "$PREV_SECRET_KEY" ]; then
|
||||
@@ -276,10 +304,25 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$REFRESH_TOKEN_EXPIRY_DAYS_VALUE" ]; then
|
||||
if [ -n "$PREV_REFRESH_TOKEN_EXPIRY_DAYS" ]; then
|
||||
REFRESH_TOKEN_EXPIRY_DAYS_VALUE="$PREV_REFRESH_TOKEN_EXPIRY_DAYS"
|
||||
else
|
||||
REFRESH_TOKEN_EXPIRY_DAYS_VALUE="90"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! printf '%s' "$REFRESH_TOKEN_EXPIRY_DAYS_VALUE" | grep -Eq '^[0-9]+$'; then
|
||||
echo "REFRESH_TOKEN_EXPIRY_DAYS must be a positive integer, got: $REFRESH_TOKEN_EXPIRY_DAYS_VALUE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > .env << EOF
|
||||
FRONTEND_URL=${{ secrets.PROD_FRONTEND_URL }}
|
||||
BACKEND_HOST_PORT=${{ secrets.PROD_BACKEND_HOST_PORT }}
|
||||
FRONTEND_HOST_PORT=${{ secrets.PROD_FRONTEND_HOST_PORT }}
|
||||
SECRET_KEY=${SECRET_KEY_VALUE}
|
||||
REFRESH_TOKEN_EXPIRY_DAYS=${{ secrets.PROD_REFRESH_TOKEN_EXPIRY_DAYS }}
|
||||
REFRESH_TOKEN_EXPIRY_DAYS=${REFRESH_TOKEN_EXPIRY_DAYS_VALUE}
|
||||
DIGEST_TOKEN_SECRET=${DIGEST_TOKEN_SECRET_VALUE}
|
||||
VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY_VALUE}
|
||||
VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY_VALUE}
|
||||
@@ -291,7 +334,52 @@ jobs:
|
||||
|
||||
chmod 600 .env .rollback-meta
|
||||
|
||||
backend_host_port=$(grep '^BACKEND_HOST_PORT=' .env | head -n1 | cut -d '=' -f2- || true)
|
||||
frontend_host_port=$(grep '^FRONTEND_HOST_PORT=' .env | head -n1 | cut -d '=' -f2- || true)
|
||||
if [ -z "$backend_host_port" ]; then
|
||||
backend_host_port="5001"
|
||||
fi
|
||||
if [ -z "$frontend_host_port" ]; then
|
||||
frontend_host_port="4601"
|
||||
fi
|
||||
|
||||
docker-compose down
|
||||
|
||||
wait_for_container_removal() {
|
||||
container_name="$1"
|
||||
attempts=0
|
||||
while docker ps -a --format '{{.Names}}' | grep -qx "$container_name"; do
|
||||
attempts=$((attempts + 1))
|
||||
if [ "$attempts" -ge 12 ]; then
|
||||
echo "Container ${container_name} still exists after waiting for teardown."
|
||||
docker ps -a --filter "name=^${container_name}$"
|
||||
return 1
|
||||
fi
|
||||
echo "Waiting for ${container_name} to be removed..."
|
||||
sleep 5
|
||||
done
|
||||
}
|
||||
|
||||
wait_for_port_release() {
|
||||
port="$1"
|
||||
attempts=0
|
||||
while docker ps -a --format '{{.ID}} {{.Names}} {{.Ports}} {{.Status}}' | grep -E "(^|[[:space:]])0\.0\.0\.0:${port}->|:::${port}->" >/dev/null 2>&1; do
|
||||
attempts=$((attempts + 1))
|
||||
if [ "$attempts" -ge 12 ]; then
|
||||
echo "Port ${port} is still allocated after teardown. Blocking container(s):"
|
||||
docker ps -a --format '{{.ID}} {{.Names}} {{.Ports}} {{.Status}}' | grep -E "(^|[[:space:]])0\.0\.0\.0:${port}->|:::${port}->" || true
|
||||
return 1
|
||||
fi
|
||||
echo "Waiting for port ${port} to be released..."
|
||||
sleep 5
|
||||
done
|
||||
}
|
||||
|
||||
wait_for_container_removal chores-app-frontend-prod
|
||||
wait_for_container_removal chores-app-backend-prod
|
||||
wait_for_port_release "$frontend_host_port"
|
||||
wait_for_port_release "$backend_host_port"
|
||||
|
||||
docker-compose pull
|
||||
docker-compose up -d
|
||||
|
||||
@@ -301,18 +389,22 @@ jobs:
|
||||
frontend_running=$(docker inspect -f '{{.State.Running}}' chores-app-frontend-prod 2>/dev/null || echo false)
|
||||
|
||||
backend_ok=false
|
||||
if curl -fsS http://localhost:5001/version >/dev/null 2>&1; then
|
||||
if curl -fsS "http://localhost:${backend_host_port}/version" >/dev/null 2>&1; then
|
||||
backend_ok=true
|
||||
fi
|
||||
|
||||
frontend_ok=false
|
||||
if curl -kfsS https://localhost/ >/dev/null 2>&1; then
|
||||
if curl -kfsS "https://localhost:${frontend_host_port}/" >/dev/null 2>&1; then
|
||||
frontend_ok=true
|
||||
fi
|
||||
|
||||
if [ "$backend_running" != "true" ] || [ "$frontend_running" != "true" ] || [ "$backend_ok" != "true" ] || [ "$frontend_ok" != "true" ]; then
|
||||
echo "Post-deploy health check failed."
|
||||
docker-compose ps
|
||||
echo "--- Backend logs (last 200 lines) ---"
|
||||
docker logs --tail 200 chores-app-backend-prod || true
|
||||
echo "--- Frontend logs (last 120 lines) ---"
|
||||
docker logs --tail 120 chores-app-frontend-prod || true
|
||||
|
||||
if [ "${{ github.event.inputs.rollback_on_failed_healthcheck }}" = "true" ]; then
|
||||
echo "Attempting rollback using predeploy-backup image tags..."
|
||||
@@ -320,7 +412,7 @@ jobs:
|
||||
docker tag git.ryankegel.com:3000/kegel/chores/frontend:predeploy-backup git.ryankegel.com:3000/kegel/chores/frontend:latest || true
|
||||
docker-compose up -d
|
||||
sleep 20
|
||||
curl -fsS http://localhost:5001/version >/dev/null
|
||||
curl -fsS "http://localhost:${backend_host_port}/version" >/dev/null
|
||||
fi
|
||||
|
||||
exit 1
|
||||
@@ -384,3 +476,28 @@ jobs:
|
||||
- tests: ${{ needs.tests.result }}
|
||||
- deploy: ${{ needs.deploy.result }}
|
||||
- tag: ${{ needs.create-release-tag.result }}
|
||||
|
||||
- name: Send email when promotion succeeds
|
||||
if: ${{ needs.prepare.result == 'success' && needs.tests.result == 'success' && (needs.deploy.result == 'success' || needs.deploy.result == 'skipped') && (needs.create-release-tag.result == 'success' || needs.create-release-tag.result == 'skipped') }}
|
||||
uses: dawidd6/action-send-mail@v3
|
||||
with:
|
||||
server_address: smtp.gmail.com
|
||||
server_port: 465
|
||||
username: ${{ secrets.MAIL_USER }}
|
||||
password: ${{ secrets.MAIL_PASSWORD }}
|
||||
secure: true
|
||||
to: ${{ secrets.MAIL_TO }}
|
||||
from: Gitea <git@git.ryankegel.com>
|
||||
subject: Promotion succeeded - ${{ gitea.repository }} [${{ needs.prepare.outputs.target_ref }}@${{ needs.prepare.outputs.commit_sha }}]
|
||||
convert_markdown: true
|
||||
html_body: |
|
||||
### Production promotion succeeded
|
||||
|
||||
- Repository: ${{ gitea.repository }}
|
||||
- Ref: ${{ needs.prepare.outputs.target_ref }}
|
||||
- Commit: ${{ needs.prepare.outputs.commit_sha }}
|
||||
- Release tag: ${{ needs.prepare.outputs.release_tag }}
|
||||
- prepare: ${{ needs.prepare.result }}
|
||||
- tests: ${{ needs.tests.result }}
|
||||
- deploy: ${{ needs.deploy.result }}
|
||||
- tag: ${{ needs.create-release-tag.result }}
|
||||
|
||||
231
.github/specs/plan-routinesFeature.prompt.md
vendored
Normal file
231
.github/specs/plan-routinesFeature.prompt.md
vendored
Normal file
@@ -0,0 +1,231 @@
|
||||
# Plan: Routines Feature
|
||||
|
||||
## Summary
|
||||
|
||||
A "Routines" system lets parents define a named checklist of simple items (e.g., "Morning Routine": Make Bed, Get Dressed, Eat Breakfast) worth X points. Children see routines in a scrollable list between Chores and Rewards in child mode. Tapping a routine opens a detail view showing the item list; a "Done" button submits it for parent approval. Points are awarded on parent approval. Supports scheduling (days/interval), deadlines, time extension, and per-child point overrides — all parallel to the existing chore system.
|
||||
|
||||
---
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
- **Routine items** are their own minimal entity (name + optional image, no points). Points belong to the routine.
|
||||
- **No per-item completion tracking** — detail screen is informational only. "Done" button submits the whole routine.
|
||||
- **Assignment**: per-child, same pattern as tasks (child.routines: list[str]).
|
||||
- **Parent approval**: approve/reject the whole routine (no item-level detail).
|
||||
- **Scheduling**: new RoutineSchedule model parallel to ChoreSchedule (same structure, routine_id instead of task_id).
|
||||
- **Point overrides**: reuse ChildOverride with entity_type='routine'.
|
||||
- **Pending confirmations**: extend PendingConfirmation entity_type to include 'routine'.
|
||||
- **Routine editor**: single-page with details section (name, points, image) + items sub-panel below.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Backend Models & DB
|
||||
|
||||
1. Create `backend/models/routine.py` — fields: name, points, image_id, user_id (inherits BaseModel). Mirror Task model.
|
||||
2. Create `backend/models/routine_item.py` — fields: routine_id, name, image_id, order (int). No points.
|
||||
3. Create `backend/models/routine_schedule.py` — copy of ChoreSchedule replacing task_id→routine_id. Same DayConfig, modes, deadline fields.
|
||||
4. Create `backend/models/routine_extension.py` — fields: child_id, routine_id, date (ISO). Mirror TaskExtension.
|
||||
5. Extend `backend/models/pending_confirmation.py` — add 'routine' to entity_type Literal.
|
||||
6. Extend `backend/models/child.py` — add `routines: list[str]` field (default=[]).
|
||||
7. Create DB layers (mirror existing patterns):
|
||||
- `backend/db/routines.py` — get/add/update/delete/list by user
|
||||
- `backend/db/routine_items.py` — get_items_for_routine/add/update/delete/delete_for_routine
|
||||
- `backend/db/routine_schedules.py` — get/upsert/delete/delete_for_child/delete_for_routine
|
||||
- `backend/db/routine_extensions.py` — get/add/delete_for_child_routine
|
||||
8. Extend `backend/db/child_overrides.py` entity_type to allow 'routine'.
|
||||
|
||||
## Phase 2: Backend SSE Events
|
||||
|
||||
9. Create event type files (mirror existing pattern):
|
||||
- `backend/events/types/routine_modified.py` — payload: routine_id, operation (ADD|EDIT|DELETE)
|
||||
- `backend/events/types/child_routines_set.py` — payload: child_id, routine_ids
|
||||
- `backend/events/types/routine_schedule_modified.py` — payload: child_id, routine_id, operation (SET|DELETED)
|
||||
- `backend/events/types/routine_time_extended.py` — payload: child_id, routine_id
|
||||
- `backend/events/types/child_routine_confirmation.py` — payload: child_id, routine_id, operation (PENDING|APPROVED|REJECTED|RESET)
|
||||
10. Update `backend/events/types/event_types.py` — add: ROUTINE_MODIFIED, CHILD_ROUTINES_SET, ROUTINE_SCHEDULE_MODIFIED, ROUTINE_TIME_EXTENDED, CHILD_ROUTINE_CONFIRMATION
|
||||
|
||||
## Phase 3: Backend API
|
||||
|
||||
11. Create `backend/api/routine_api.py`:
|
||||
- `PUT /routine/add` — create routine
|
||||
- `GET /routine/<id>` — fetch single
|
||||
- `GET /routine/list` — list by user
|
||||
- `PUT /routine/<id>/edit` — update
|
||||
- `DELETE /routine/<id>` — delete (cascade: remove from all children, delete items, schedules, overrides)
|
||||
12. Create `backend/api/routine_item_api.py`:
|
||||
- `PUT /routine/<routine_id>/item/add` — add item
|
||||
- `PUT /routine/<routine_id>/item/<item_id>/edit` — edit item name/image
|
||||
- `DELETE /routine/<routine_id>/item/<item_id>` — remove item
|
||||
- `GET /routine/<routine_id>/items` — list items
|
||||
13. Create `backend/api/child_routine_api.py`:
|
||||
- `POST /child/<id>/assign-routine` — add routine to child
|
||||
- `POST /child/<id>/remove-routine` — remove from child
|
||||
- `PUT /child/<id>/set-routines` — replace all assigned routines
|
||||
- `GET /child/<id>/list-routines` — list assigned routines with schedule, pending_status, extension_date, image_url, custom_value, and embedded items
|
||||
- `GET /child/<id>/list-assignable-routines` — routines not yet assigned
|
||||
- `POST /child/<id>/confirm-routine` — child submits routine as pending (creates PendingConfirmation entity_type='routine')
|
||||
- `POST /child/<id>/cancel-routine-confirmation` — cancel pending
|
||||
14. Create `backend/api/routine_schedule_api.py`:
|
||||
- `GET /child/<child_id>/routine/<routine_id>/schedule` — fetch
|
||||
- `PUT /child/<child_id>/routine/<routine_id>/schedule` — create/update
|
||||
- `DELETE /child/<child_id>/routine/<routine_id>/schedule` — delete
|
||||
- `POST /child/<child_id>/routine/<routine_id>/extend` — extend deadline
|
||||
15. Extend `backend/api/pending_confirmation.py`:
|
||||
- Add `GET /pending-confirmations?type=routine` support (or ensure existing endpoint includes routines)
|
||||
- Add `POST /child/<id>/approve-routine/<confirmation_id>` — approve (award points via override or routine.points)
|
||||
- Add `POST /child/<id>/reject-routine/<confirmation_id>` — reject
|
||||
- Add `POST /child/<id>/reset-routine/<confirmation_id>` — reset
|
||||
16. Update `backend/api/child_api.py` — cascade delete routines/schedules/extensions when child deleted.
|
||||
17. Update `backend/main.py` — register all new blueprints.
|
||||
|
||||
## Phase 4: TypeScript Models & API Helpers
|
||||
|
||||
18. Update `frontend/src/common/models.ts`:
|
||||
- Add `Routine` (id, name, points, image_id)
|
||||
- Add `RoutineItem` (id, routine_id, name, image_id, order)
|
||||
- Add `ChildRoutine` (extends Routine + custom_value, schedule, extension_date, pending_status, approved_at, items: RoutineItem[])
|
||||
- Add `RoutineSchedule` (mirror ChoreSchedule with routine_id)
|
||||
- Add new SSE payload types: RoutineModifiedEventPayload, ChildRoutinesSetEventPayload, RoutineScheduleModifiedPayload, RoutineTimeExtendedPayload, ChildRoutineConfirmationPayload
|
||||
19. Update `frontend/src/common/backendEvents.ts` — add new event type string constants.
|
||||
20. Update `frontend/src/common/api.ts` — add helpers: confirmRoutine(), cancelRoutineConfirmation(), setChildRoutineOverride().
|
||||
|
||||
## Phase 5: Frontend — Child Mode
|
||||
|
||||
21. Create `frontend/src/components/routine/RoutineDetailView.vue`:
|
||||
- Receives childId + routineId as route params
|
||||
- Fetches routine data (name, points, image, items list) via `GET /child/<id>/list-routines` (or dedicated detail endpoint)
|
||||
- Displays routine header (name, image, points)
|
||||
- Vertical card list of items (name + image, non-interactive)
|
||||
- "Done" button → RoutineConfirmDialog → calls confirmRoutine() → routine becomes 'pending'
|
||||
- If pending_status='pending': "Done" shows cancel dialog instead
|
||||
- If approved today: shows "COMPLETED" stamp (read-only)
|
||||
- If expired: shows "TOO LATE" (no action)
|
||||
- Back navigation to ChildView
|
||||
- Register SSE `child_routine_confirmation` to update status reactively
|
||||
22. Update `frontend/src/components/child/ChildView.vue`:
|
||||
- Add `routines: ref<string[]>` and `childRoutineListRef`
|
||||
- Add Routines `ScrollingList` between Chores list and Rewards list
|
||||
- fetchBaseUrl: `/api/child/${child.id}/list-routines`
|
||||
- itemKey: `'routines'`
|
||||
- filter-fn: filter by schedule (isScheduledToday equivalent for routines)
|
||||
- sort-fn: completed → pending → scheduled → general
|
||||
- getItemClass: similar chore-inactive logic for expired/completed
|
||||
- On routine click → navigate to RoutineDetailView route instead of triggering inline
|
||||
- Register new SSE handlers: routine_modified, child_routines_set, routine_schedule_modified, routine_time_extended, child_routine_confirmation
|
||||
- Expiry timer logic for routine deadlines (parallel to existing chore timer logic)
|
||||
23. Add routes:
|
||||
- `/child/:id/routine/:routineId` → RoutineDetailView
|
||||
|
||||
## Phase 6: Frontend — Parent Mode (Routine Library)
|
||||
|
||||
24. Create `frontend/src/components/routine/RoutineEditor.vue`:
|
||||
- Top section: EntityEditForm-style fields — name (text), points (number), image picker
|
||||
- Bottom section: Items sub-panel
|
||||
- List of existing items (name + image thumbnail + delete button)
|
||||
- "Add item" row: inline input for name + optional image picker + confirm button
|
||||
- Each item has edit-in-place or edit button
|
||||
- Save button submits both routine details and item changes
|
||||
- On edit mode: pre-populate all fields and items
|
||||
25. Create `frontend/src/views/parent/RoutinesView.vue`:
|
||||
- ItemList of all routines with add/edit/delete
|
||||
- Add → RoutineEditor (create mode)
|
||||
- Edit → RoutineEditor (edit mode)
|
||||
26. Add parent routes under TaskSubNav (4th tab — "Routines"):
|
||||
- `/parent/tasks/routines` — library list (RoutinesView)
|
||||
- `/parent/tasks/routines/add` — create (RoutineEditor)
|
||||
- `/parent/tasks/routines/:id/edit` — edit (RoutineEditor)
|
||||
27. Add tab to `frontend/src/components/task/TaskSubNav.vue` — "Routines" tab pointing to `/parent/tasks/routines`
|
||||
|
||||
## Phase 7: Frontend — Parent Mode (Per-Child Routine Management)
|
||||
|
||||
28. Extend ParentView (or child management component) to include a "Routines" section per child:
|
||||
- ItemList showing child's assigned routines
|
||||
- Assign button → RoutineAssignView (parallel to ChoreAssignView)
|
||||
- Kebab menu per routine item:
|
||||
- "Edit Routine" → navigate to `/parent/tasks/routines/:id/edit`
|
||||
- "Set Schedule" → open ScheduleModal with entityType='routine'
|
||||
- "Edit Points" → set ChildOverride with entity_type='routine'
|
||||
- "Extend Deadline" → call extend endpoint
|
||||
- Register SSE events to refresh list: routine_modified, child_routines_set, routine_schedule_modified, routine_time_extended, child_routine_confirmation, child_override_set
|
||||
29. Create `frontend/src/components/routine/RoutineAssignView.vue` — selectable ItemList of unassigned routines (parallel to ChoreAssignView).
|
||||
30. Extend pending confirmation approval UI — if entity_type='routine', fetch routine name and show in approval card. Approve → POST approve-routine endpoint. Reject → POST reject-routine.
|
||||
|
||||
## Phase 8: Push Notifications for Routine Pending
|
||||
|
||||
31. In `backend/api/child_routine_api.py` — after creating the PendingConfirmation for a routine, call `send_push_to_user(user_id, payload)` (mirror `child_api.py` chore confirmation pattern). Payload: `type='routine_confirmed'`, title = "Routine Pending", body = "{child_name} completed {routine_name}", include approve/reject action tokens.
|
||||
|
||||
## Phase 9: ScheduleModal entityType Refactor
|
||||
|
||||
32. Refactor `ScheduleModal.vue` — replace hard-coded `task_id` with `entityType: 'task' | 'routine'` + `entityId` props. All API calls to `.../schedule` and `.../extend` switch on `entityType` to route to either `/child/<childId>/task/<entityId>/...` or `/child/<childId>/routine/<entityId>/...`.
|
||||
33. Update all current ScheduleModal usages to explicitly pass `entityType='task'` so existing behavior is preserved.
|
||||
34. Routine kebab "Set Schedule" passes `entityType='routine'`.
|
||||
|
||||
---
|
||||
|
||||
## Relevant Files
|
||||
|
||||
**New backend:**
|
||||
|
||||
- `backend/models/routine.py`, `routine_item.py`, `routine_schedule.py`, `routine_extension.py`
|
||||
- `backend/db/routines.py`, `routine_items.py`, `routine_schedules.py`, `routine_extensions.py`
|
||||
- `backend/api/routine_api.py`, `routine_item_api.py`, `child_routine_api.py`, `routine_schedule_api.py`
|
||||
- `backend/events/types/routine_modified.py`, `child_routines_set.py`, `routine_schedule_modified.py`, `routine_time_extended.py`, `child_routine_confirmation.py`
|
||||
|
||||
**Modified backend:**
|
||||
|
||||
- `backend/models/child.py` — add routines field
|
||||
- `backend/models/pending_confirmation.py` — extend entity_type Literal
|
||||
- `backend/db/child_overrides.py` — allow 'routine' entity_type
|
||||
- `backend/api/child_api.py` — cascade deletes
|
||||
- `backend/api/pending_confirmation.py` — routine approval endpoints
|
||||
- `backend/events/types/event_types.py` — new constants
|
||||
- `backend/main.py` — register blueprints
|
||||
|
||||
**New frontend:**
|
||||
|
||||
- `frontend/src/components/routine/RoutineEditor.vue`
|
||||
- `frontend/src/components/routine/RoutineDetailView.vue`
|
||||
- `frontend/src/components/routine/RoutineAssignView.vue`
|
||||
- `frontend/src/views/parent/RoutinesView.vue`
|
||||
|
||||
**Modified frontend:**
|
||||
|
||||
- `frontend/src/common/models.ts` — new interfaces + SSE payload types
|
||||
- `frontend/src/common/backendEvents.ts` — new event constants
|
||||
- `frontend/src/common/api.ts` — new helpers
|
||||
- `frontend/src/components/child/ChildView.vue` — add routines list + navigation
|
||||
- `frontend/src/components/task/TaskSubNav.vue` — add Routines tab
|
||||
- `frontend/src/components/shared/ScheduleModal.vue` — entityType refactor
|
||||
- ParentView component — add routines section
|
||||
- Router — new routes
|
||||
|
||||
**Reference patterns:**
|
||||
|
||||
- `backend/api/chore_api.py` + `chore_schedule_api.py` — mirror for routine equivalents
|
||||
- `backend/models/chore_schedule.py` — copy for RoutineSchedule
|
||||
- `backend/models/task_extension.py` — copy for routine_extension.py
|
||||
- `frontend/src/components/shared/ScrollingList.vue` — reuse for routines in child view
|
||||
- `frontend/src/components/shared/EntityEditForm.vue` — reuse in RoutineEditor top section
|
||||
- `backend/utils/push_sender.py` + `child_api.py` chore confirm (~L1186) — push notification pattern
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
1. Create a routine with 2+ items via Tasks → Routines tab, assign to a child, verify it appears in child view
|
||||
2. Child submits "Done" → push notification fires → pending confirmation appears in parent view → approve → points credited
|
||||
3. Reject flow → no points awarded
|
||||
4. Schedule a routine for specific days → verify it only appears on those days
|
||||
5. Set a deadline → verify "TOO LATE" stamp after deadline passes
|
||||
6. Extend deadline via kebab → verify stamp removed for that day
|
||||
7. Set per-child point override → verify override value shown and applied on approval
|
||||
8. Delete routine → cascades (removed from children, items/schedules/extensions/overrides deleted)
|
||||
9. SSE: parent sees routine confirmation in notifications without page refresh
|
||||
10. ScheduleModal: existing chore schedule still works after entityType refactor
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Reordering routine items (order field exists but no drag-and-drop UI planned)
|
||||
- Per-item completion tracking
|
||||
@@ -39,6 +39,9 @@ UserQuery = Query()
|
||||
TokenQuery = Query()
|
||||
TOKEN_EXPIRY_MINUTES = 60 * 4
|
||||
RESET_PASSWORD_TOKEN_EXPIRY_MINUTES = 10
|
||||
try:
|
||||
ACCESS_TOKEN_EXPIRY_MINUTES = int(os.environ.get('ACCESS_TOKEN_EXPIRY_MINUTES', '15'))
|
||||
except ValueError:
|
||||
ACCESS_TOKEN_EXPIRY_MINUTES = 15
|
||||
E2E_TEST_EMAIL = 'e2e@test.com'
|
||||
E2E_TEST_PASSWORD = 'E2eTestPass1!'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# file: config/version.py
|
||||
import os
|
||||
|
||||
BASE_VERSION = "1.0.10" # update manually when releasing features
|
||||
BASE_VERSION = "1.0.11" # update manually when releasing features
|
||||
|
||||
def get_full_version() -> str:
|
||||
"""
|
||||
|
||||
@@ -58,6 +58,13 @@ def send_push_to_user(user_id: str, payload: dict) -> int:
|
||||
f'Removing stale push subscription {sub.id} for user {user_id} (HTTP {status_code})'
|
||||
)
|
||||
delete_subscription_by_id(sub.id)
|
||||
elif status_code == 403 and 'VAPID credentials' in str(e):
|
||||
# The subscription was created with a different VAPID keypair.
|
||||
# Remove it so the client can register a fresh subscription next time.
|
||||
logger.info(
|
||||
f'Removing VAPID-mismatched subscription {sub.id} for user {user_id} (HTTP 403)'
|
||||
)
|
||||
delete_subscription_by_id(sub.id)
|
||||
else:
|
||||
logger.error(
|
||||
f'WebPushException for subscription {sub.id} (user {user_id}): {e}'
|
||||
|
||||
@@ -6,10 +6,15 @@ services:
|
||||
image: git.ryankegel.com:3000/kegel/chores/backend:latest # Or specific version tag
|
||||
container_name: chores-app-backend-prod # Added for easy identification
|
||||
ports:
|
||||
- "5001:5000" # Host 5001 -> Container 5000
|
||||
- "${BACKEND_HOST_PORT:-5001}:5000" # Host port -> Container 5000
|
||||
environment:
|
||||
- FLASK_ENV=production
|
||||
- FRONTEND_URL=${FRONTEND_URL}
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- REFRESH_TOKEN_EXPIRY_DAYS=${REFRESH_TOKEN_EXPIRY_DAYS}
|
||||
- DIGEST_TOKEN_SECRET=${DIGEST_TOKEN_SECRET}
|
||||
- VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
|
||||
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
|
||||
volumes:
|
||||
- chores-app-backend-data:/app/data # Assuming backend data storage; adjust path as needed
|
||||
networks:
|
||||
@@ -20,7 +25,7 @@ services:
|
||||
image: git.ryankegel.com:3000/kegel/chores/frontend:latest # Or specific version tag
|
||||
container_name: chores-app-frontend-prod # Added for easy identification
|
||||
ports:
|
||||
- "443:443" # Host 443 -> Container 443 (HTTPS)
|
||||
- "${FRONTEND_HOST_PORT:-4601}:443" # Host port -> Container 443 (HTTPS)
|
||||
environment:
|
||||
- BACKEND_HOST=chores-app-backend # Points to internal backend service
|
||||
depends_on:
|
||||
|
||||
@@ -2,20 +2,20 @@
|
||||
"cookies": [
|
||||
{
|
||||
"name": "refresh_token",
|
||||
"value": "oh4sQB_Q1Se4xydZHTIolU6FhvlKPIlDPsxfd_qe4wQ",
|
||||
"value": "1aCKS1DujEPmHYGCELY7JiJ-LfxGjw7_HK1uCymlbh8",
|
||||
"domain": "localhost",
|
||||
"path": "/api/auth",
|
||||
"expires": 1784867824.852037,
|
||||
"expires": 1785207783.273047,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Strict"
|
||||
},
|
||||
{
|
||||
"name": "access_token",
|
||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiI4NGY1Y2I3NS05NWU1LTRjMTItOTExOS02MzdkYjJlMWY1MGYiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzcwOTI3MjR9.7-d4EvJEYtYSi_5ZFiU7tuxgc4ilVMBxhwzgAQZNTvY",
|
||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiJhNzY5YzY4Zi04MWQ5LTRiNjctODQwYS1mY2Q5NTVkNzAwNzYiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3Nzc0NDI1ODN9.vdmfmIx0Dg4zKq9HTV7N7tjDbSGaO3EOeGqJbhFSOcQ",
|
||||
"domain": "localhost",
|
||||
"path": "/",
|
||||
"expires": 1777092724.851439,
|
||||
"expires": 1777442583.273,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Lax"
|
||||
@@ -27,11 +27,11 @@
|
||||
"localStorage": [
|
||||
{
|
||||
"name": "authSyncEvent",
|
||||
"value": "{\"type\":\"logout\",\"at\":1777091824594}"
|
||||
"value": "{\"type\":\"logout\",\"at\":1777431783134}"
|
||||
},
|
||||
{
|
||||
"name": "parentAuth",
|
||||
"value": "{\"expiresAt\":1777264625262}"
|
||||
"value": "{\"expiresAt\":1777604583443}"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,20 +2,20 @@
|
||||
"cookies": [
|
||||
{
|
||||
"name": "refresh_token",
|
||||
"value": "lIhurtvmo0oGcimN_8v59h9y3CrglEAloQO1QFw8RsQ",
|
||||
"value": "eDlr3-DaFNFoWQE3EDAybLFpw7TiR3Maj05kLIkZfu0",
|
||||
"domain": "localhost",
|
||||
"path": "/api/auth",
|
||||
"expires": 1784867824.995155,
|
||||
"expires": 1785207783.382249,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Strict"
|
||||
},
|
||||
{
|
||||
"name": "access_token",
|
||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1kZWxldGVAdGVzdC5jb20iLCJ1c2VyX2lkIjoiOWYzYTljNWMtM2IwMy00YTZlLTliNmUtYjE0MTQzZWNhMzNkIiwidG9rZW5fdmVyc2lvbiI6MCwiZXhwIjoxNzc3MDkyNzI0fQ.UB5MCIZ2PHbn3UyohNzmFbOCb3TR-KHxJPWB9qkWwCQ",
|
||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1kZWxldGVAdGVzdC5jb20iLCJ1c2VyX2lkIjoiNGYwNmY0YTMtMGQwMC00NzJhLWJkMjQtYjM1YjdkMjg4ZDFiIiwidG9rZW5fdmVyc2lvbiI6MCwiZXhwIjoxNzc3NDQyNTgzfQ.F2TXov3xbhFa20P5NLt6J5HfNDlY2aXlnPoSYYDJpoQ",
|
||||
"domain": "localhost",
|
||||
"path": "/",
|
||||
"expires": 1777092724.99406,
|
||||
"expires": 1777442583.38218,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Lax"
|
||||
@@ -27,11 +27,11 @@
|
||||
"localStorage": [
|
||||
{
|
||||
"name": "authSyncEvent",
|
||||
"value": "{\"type\":\"logout\",\"at\":1777091824687}"
|
||||
"value": "{\"type\":\"logout\",\"at\":1777431783225}"
|
||||
},
|
||||
{
|
||||
"name": "parentAuth",
|
||||
"value": "{\"expiresAt\":1777264625511}"
|
||||
"value": "{\"expiresAt\":1777604583575}"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,20 +2,20 @@
|
||||
"cookies": [
|
||||
{
|
||||
"name": "refresh_token",
|
||||
"value": "uMExObHukEdFnlszorcDq2XlbRW0AeIV71x_vSovEF4",
|
||||
"value": "BGnvmJr7XNXaCsXlzh1k6RcJPIGVxac6_LBXS22gXIA",
|
||||
"domain": "localhost",
|
||||
"path": "/api/auth",
|
||||
"expires": 1784867821.107956,
|
||||
"expires": 1785207781.614965,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Strict"
|
||||
},
|
||||
{
|
||||
"name": "access_token",
|
||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiI2ZWE5YWZmZC1jMGExLTRkMzUtOGJmYS1mZDBkOWY5NzFmOWQiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzcwOTI3MjF9.rb64zKqS6uXqkgX_4kke37Rc6om68elt4Qa24Fy9NPo",
|
||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiI4YmQxMWY5NC1jNWJhLTRhNmEtOGZhMi1hZjZkYzk3Y2YyMjgiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3Nzc0NDI1ODF9.WVDBt-zfwVMHCWr78maSGWAyfQZhWOSobX5oXKXLHwg",
|
||||
"domain": "localhost",
|
||||
"path": "/",
|
||||
"expires": 1777092721.107403,
|
||||
"expires": 1777442581.61492,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Lax"
|
||||
@@ -27,11 +27,11 @@
|
||||
"localStorage": [
|
||||
{
|
||||
"name": "authSyncEvent",
|
||||
"value": "{\"type\":\"logout\",\"at\":1777091820880}"
|
||||
"value": "{\"type\":\"logout\",\"at\":1777431781473}"
|
||||
},
|
||||
{
|
||||
"name": "parentAuth",
|
||||
"value": "{\"expiresAt\":1777264621275}"
|
||||
"value": "{\"expiresAt\":1777604581743}"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -7,6 +7,12 @@ import { fileURLToPath } from 'url'
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const TEST_IMAGE = path.join(__dirname, '../../.resources/crown.png')
|
||||
|
||||
/** Navigate to the parent children list and wait for the Add Child button to be ready. */
|
||||
async function gotoParentList(page: import('@playwright/test').Page): Promise<void> {
|
||||
await page.goto('/')
|
||||
await expect(page.getByRole('button', { name: 'Add Child' })).toBeVisible({ timeout: 10000 })
|
||||
}
|
||||
|
||||
async function deleteNamedChildren(request: any, names: string[]) {
|
||||
const res = await request.get('/api/child/list')
|
||||
const data = await res.json()
|
||||
@@ -38,7 +44,7 @@ test.describe('Create Child', () => {
|
||||
await deleteNamedChildren(request, ['Alice'])
|
||||
|
||||
// 1. Navigate to app root - router redirects to /parent (children list) when parent-authenticated
|
||||
await page.goto('/')
|
||||
await gotoParentList(page)
|
||||
await expect(page).toHaveURL('/parent')
|
||||
|
||||
// 2. Click the 'Add Child' FAB
|
||||
@@ -123,7 +129,7 @@ test.describe('Create Child', () => {
|
||||
await deleteNamedChildren(request, ['Grace'])
|
||||
|
||||
// 1. Navigate to app root - router redirects to /parent (children list)
|
||||
await page.goto('/')
|
||||
await gotoParentList(page)
|
||||
await page.getByRole('button', { name: 'Add Child' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible()
|
||||
|
||||
|
||||
@@ -2,10 +2,16 @@
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
/** Navigate to the parent children list and wait for the Add Child button to be ready. */
|
||||
async function gotoParentList(page: import('@playwright/test').Page): Promise<void> {
|
||||
await page.goto('/')
|
||||
await expect(page.getByRole('button', { name: 'Add Child' })).toBeVisible({ timeout: 10000 })
|
||||
}
|
||||
|
||||
test.describe('Create Child', () => {
|
||||
test('Cancel navigates back without saving', async ({ page }) => {
|
||||
// 1. Navigate to app root - router redirects to /parent (children list)
|
||||
await page.goto('/')
|
||||
// 1. Navigate to parent list and wait for it to fully load
|
||||
await gotoParentList(page)
|
||||
await page.getByRole('button', { name: 'Add Child' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible()
|
||||
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { STORAGE_STATE_CC } from '../../e2e-constants'
|
||||
|
||||
/** Navigate to the parent children list and wait for the Add Child button to be ready. */
|
||||
async function gotoParentList(page: import('@playwright/test').Page): Promise<void> {
|
||||
await page.goto('/')
|
||||
await expect(page.getByRole('button', { name: 'Add Child' })).toBeVisible({ timeout: 10000 })
|
||||
}
|
||||
|
||||
test.describe('Create Child', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
@@ -17,7 +23,7 @@ test.describe('Create Child', () => {
|
||||
}
|
||||
|
||||
// 1. Open two browser tabs both on /parent (children list)
|
||||
await page.goto('/')
|
||||
await gotoParentList(page)
|
||||
await expect(page).toHaveURL('/parent')
|
||||
|
||||
const tab2 = await context.newPage()
|
||||
@@ -64,7 +70,7 @@ test.describe('Create Child', () => {
|
||||
}
|
||||
|
||||
// 1. Tab 1: parent mode
|
||||
await page.goto('/')
|
||||
await gotoParentList(page)
|
||||
await expect(page).toHaveURL('/parent')
|
||||
|
||||
// 2. Tab 2: isolated browser context so removing parentAuth doesn't affect Tab 1
|
||||
|
||||
@@ -2,12 +2,18 @@
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
/** Navigate to the parent children list and wait for the Add Child button to be ready. */
|
||||
async function gotoParentList(page: import('@playwright/test').Page): Promise<void> {
|
||||
await page.goto('/')
|
||||
await expect(page.getByRole('button', { name: 'Add Child' })).toBeVisible({ timeout: 10000 })
|
||||
}
|
||||
|
||||
test.describe('Create Child', () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode')
|
||||
|
||||
// Navigate to app root - router redirects to /parent (children list) when parent-authenticated
|
||||
await page.goto('/')
|
||||
// Navigate to parent list and wait for it to fully load before clicking Add Child
|
||||
await gotoParentList(page)
|
||||
await page.getByRole('button', { name: 'Add Child' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
const CHILD_NAME = 'DigestApproveChoreChild'
|
||||
const CHORE_NAME = 'DigestApproveChoreChore'
|
||||
const CHORE_POINTS = 8
|
||||
const DIGEST_ACTION_BASE_URL = process.env.PLAYWRIGHT_BASE_URL || 'https://localhost:5173'
|
||||
|
||||
async function createChild(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get('/api/child/list')
|
||||
@@ -81,7 +82,7 @@ test.describe('Digest Action Token — Approve Chore', () => {
|
||||
}) => {
|
||||
const unauthCtx = await playwright.request.newContext({
|
||||
ignoreHTTPSErrors: true,
|
||||
baseURL: 'https://localhost:5173',
|
||||
baseURL: DIGEST_ACTION_BASE_URL,
|
||||
})
|
||||
const res = await unauthCtx.get(`/api/digest-action/${approveToken}`, {
|
||||
maxRedirects: 0,
|
||||
|
||||
@@ -6,6 +6,7 @@ const CHILD_NAME = 'DigestApproveRewardChild'
|
||||
const REWARD_NAME = 'DigestApproveRewardReward'
|
||||
const REWARD_COST = 15
|
||||
const INITIAL_POINTS = 50
|
||||
const DIGEST_ACTION_BASE_URL = process.env.PLAYWRIGHT_BASE_URL || 'https://localhost:5173'
|
||||
|
||||
async function createChild(
|
||||
request: APIRequestContext,
|
||||
@@ -78,7 +79,7 @@ test.describe('Digest Action Token — Approve Reward', () => {
|
||||
}) => {
|
||||
const unauthCtx = await playwright.request.newContext({
|
||||
ignoreHTTPSErrors: true,
|
||||
baseURL: 'https://localhost:5173',
|
||||
baseURL: DIGEST_ACTION_BASE_URL,
|
||||
})
|
||||
const res = await unauthCtx.get(`/api/digest-action/${approveToken}`, {
|
||||
maxRedirects: 0,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
const CHILD_NAME = 'DigestDenyChoreChild'
|
||||
const CHORE_NAME = 'DigestDenyChoreChore'
|
||||
const CHORE_POINTS = 8
|
||||
const DIGEST_ACTION_BASE_URL = process.env.PLAYWRIGHT_BASE_URL || 'https://localhost:5173'
|
||||
|
||||
async function createChild(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get('/api/child/list')
|
||||
@@ -71,7 +72,7 @@ test.describe('Digest Action Token — Deny Chore', () => {
|
||||
}) => {
|
||||
const unauthCtx = await playwright.request.newContext({
|
||||
ignoreHTTPSErrors: true,
|
||||
baseURL: 'https://localhost:5173',
|
||||
baseURL: DIGEST_ACTION_BASE_URL,
|
||||
})
|
||||
const res = await unauthCtx.get(`/api/digest-action/${denyToken}`, {
|
||||
maxRedirects: 0,
|
||||
|
||||
@@ -6,6 +6,7 @@ const CHILD_NAME = 'DigestDenyRewardChild'
|
||||
const REWARD_NAME = 'DigestDenyRewardReward'
|
||||
const REWARD_COST = 15
|
||||
const INITIAL_POINTS = 50
|
||||
const DIGEST_ACTION_BASE_URL = process.env.PLAYWRIGHT_BASE_URL || 'https://localhost:5173'
|
||||
|
||||
async function createChild(
|
||||
request: APIRequestContext,
|
||||
@@ -78,7 +79,7 @@ test.describe('Digest Action Token — Deny Reward', () => {
|
||||
}) => {
|
||||
const unauthCtx = await playwright.request.newContext({
|
||||
ignoreHTTPSErrors: true,
|
||||
baseURL: 'https://localhost:5173',
|
||||
baseURL: DIGEST_ACTION_BASE_URL,
|
||||
})
|
||||
const res = await unauthCtx.get(`/api/digest-action/${denyToken}`, {
|
||||
maxRedirects: 0,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
const CHILD_NAME = 'DigestErrorChild'
|
||||
const CHORE_NAME = 'DigestErrorChore'
|
||||
const CHORE_POINTS = 5
|
||||
const DIGEST_ACTION_BASE_URL = process.env.PLAYWRIGHT_BASE_URL || 'https://localhost:5173'
|
||||
|
||||
async function createChild(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get('/api/child/list')
|
||||
@@ -33,11 +34,13 @@ async function createTask(
|
||||
async function getUnauthContext(playwright: any) {
|
||||
return playwright.request.newContext({
|
||||
ignoreHTTPSErrors: true,
|
||||
baseURL: 'https://localhost:5173',
|
||||
baseURL: DIGEST_ACTION_BASE_URL,
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Digest Action Token — Error Paths', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test('Simple chore creation test', async ({ page }) => {
|
||||
// Navigate to chores
|
||||
await page.goto('/parent/tasks/chores')
|
||||
|
||||
// Click the Create Chore FAB
|
||||
await page.getByRole('button', { name: 'Create Chore' }).click()
|
||||
|
||||
// Wait for form to load
|
||||
await expect(page.locator('text=Chore Name')).toBeVisible()
|
||||
|
||||
// Fill form using custom evaluation
|
||||
await page.evaluate(() => {
|
||||
// Fill name
|
||||
const nameInput = document.querySelector('input[type="text"], input:not([type])')
|
||||
if (nameInput) {
|
||||
nameInput.value = 'Simple Chore Test'
|
||||
nameInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
nameInput.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
|
||||
// Fill points
|
||||
const pointsInput = document.querySelector('input[type="number"]')
|
||||
if (pointsInput) {
|
||||
pointsInput.value = '5'
|
||||
pointsInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
pointsInput.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
|
||||
// Click first image to select it
|
||||
const firstImage = document.querySelector('img[alt*="Image"]')
|
||||
if (firstImage) {
|
||||
firstImage.click()
|
||||
}
|
||||
})
|
||||
|
||||
// Wait a moment for validation to trigger
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Click Create button
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
|
||||
// Verify we're back on the list page and item was created
|
||||
// locate the name element, then move up to the row container
|
||||
const choreName = page.locator('text=Simple Chore Test').first()
|
||||
const choreRow = choreName.locator('..')
|
||||
await expect(choreRow).toBeVisible()
|
||||
// the row container should display the correct points value
|
||||
await expect(choreRow.locator('text=5 pts')).toBeVisible()
|
||||
})
|
||||
@@ -32,6 +32,13 @@ async function restoreProfile(request: APIRequestContext, profile: ProfileData):
|
||||
})
|
||||
}
|
||||
|
||||
/** Navigate to /parent/profile and wait for the form to finish loading. */
|
||||
async function gotoProfile(page: import('@playwright/test').Page): Promise<void> {
|
||||
await page.goto('/parent/profile')
|
||||
// EntityEditForm hides the form behind v-if while loading=true; wait for it to render.
|
||||
await expect(page.getByLabel('First Name')).toBeVisible({ timeout: 10000 })
|
||||
}
|
||||
|
||||
test.describe('User Profile – editing', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
@@ -46,7 +53,7 @@ test.describe('User Profile – editing', () => {
|
||||
})
|
||||
|
||||
test('Profile page loads with correct data', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await gotoProfile(page)
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'User Profile' })).toBeVisible()
|
||||
await expect(page.getByLabel('First Name')).toHaveValue(E2E_FIRST_NAME)
|
||||
@@ -68,13 +75,13 @@ test.describe('User Profile – editing', () => {
|
||||
})
|
||||
|
||||
test('Save is disabled when form is clean (not dirty)', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await gotoProfile(page)
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Save is disabled when First Name is empty', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await gotoProfile(page)
|
||||
|
||||
await page.getByLabel('First Name').fill('')
|
||||
await page.getByLabel('First Name').blur()
|
||||
@@ -83,7 +90,7 @@ test.describe('User Profile – editing', () => {
|
||||
})
|
||||
|
||||
test('Save is disabled when Last Name is empty', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await gotoProfile(page)
|
||||
|
||||
await page.getByLabel('Last Name').fill('')
|
||||
await page.getByLabel('Last Name').blur()
|
||||
@@ -92,7 +99,7 @@ test.describe('User Profile – editing', () => {
|
||||
})
|
||||
|
||||
test('Save is disabled when both name fields are empty', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await gotoProfile(page)
|
||||
|
||||
await page.getByLabel('First Name').fill('')
|
||||
await page.getByLabel('Last Name').fill('')
|
||||
@@ -101,7 +108,7 @@ test.describe('User Profile – editing', () => {
|
||||
})
|
||||
|
||||
test('Save enables when a name is changed', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await gotoProfile(page)
|
||||
|
||||
await page.getByLabel('First Name').fill('UpdatedE2E')
|
||||
|
||||
@@ -109,7 +116,7 @@ test.describe('User Profile – editing', () => {
|
||||
})
|
||||
|
||||
test('Save persists name changes and shows confirmation modal', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await gotoProfile(page)
|
||||
|
||||
await page.getByLabel('First Name').fill('UpdatedE2E')
|
||||
await page.getByLabel('Last Name').fill('UpdatedTester')
|
||||
@@ -121,25 +128,25 @@ test.describe('User Profile – editing', () => {
|
||||
await dialog.getByRole('button', { name: 'OK' }).click()
|
||||
|
||||
// OK navigates back; go back to profile to verify persistence
|
||||
await page.goto('/parent/profile')
|
||||
await gotoProfile(page)
|
||||
await expect(page.getByLabel('First Name')).toHaveValue('UpdatedE2E')
|
||||
await expect(page.getByLabel('Last Name')).toHaveValue('UpdatedTester')
|
||||
})
|
||||
|
||||
test('Cancel discards unsaved changes', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await gotoProfile(page)
|
||||
|
||||
await page.getByLabel('First Name').fill('Discarded')
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
// Navigate back to verify no changes were saved
|
||||
await page.goto('/parent/profile')
|
||||
await gotoProfile(page)
|
||||
await expect(page.getByLabel('First Name')).toHaveValue(E2E_FIRST_NAME)
|
||||
})
|
||||
|
||||
test('Email field is read-only', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await gotoProfile(page)
|
||||
|
||||
const emailInput = page.locator('#email')
|
||||
await expect(emailInput).toBeDisabled()
|
||||
@@ -147,7 +154,7 @@ test.describe('User Profile – editing', () => {
|
||||
})
|
||||
|
||||
test('Change profile image (built-in)', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await gotoProfile(page)
|
||||
|
||||
// Wait for images to load
|
||||
await page.waitForSelector('.selectable-image')
|
||||
@@ -181,7 +188,7 @@ test.describe('User Profile – editing', () => {
|
||||
await page.locator('.modal-dialog').getByRole('button', { name: 'OK' }).click()
|
||||
|
||||
// Re-visit and confirm selection persists
|
||||
await page.goto('/parent/profile')
|
||||
await gotoProfile(page)
|
||||
await page.waitForSelector('.selectable-image')
|
||||
const selectedSrc = await page.locator('.selectable-image.selected').getAttribute('src')
|
||||
expect(selectedSrc).toBeTruthy()
|
||||
@@ -191,7 +198,7 @@ test.describe('User Profile – editing', () => {
|
||||
})
|
||||
|
||||
test('Upload a custom profile image', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await gotoProfile(page)
|
||||
|
||||
await page.waitForSelector('.selectable-image')
|
||||
|
||||
@@ -214,7 +221,7 @@ test.describe('User Profile – editing', () => {
|
||||
})
|
||||
|
||||
test('Change Password shows email-sent modal', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await gotoProfile(page)
|
||||
|
||||
await page.getByRole('button', { name: 'Change Password' }).click()
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Test group', () => {
|
||||
test('seed', async ({ page }) => {
|
||||
// generate code here.
|
||||
});
|
||||
});
|
||||
14
frontend/package-lock.json
generated
14
frontend/package-lock.json
generated
@@ -16,6 +16,7 @@
|
||||
"@tsconfig/node22": "^22.0.2",
|
||||
"@types/jsdom": "^27.0.0",
|
||||
"@types/node": "^22.18.11",
|
||||
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vitest/eslint-plugin": "^1.3.23",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
@@ -2171,6 +2172,19 @@
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-basic-ssl": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.3.0.tgz",
|
||||
"integrity": "sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-vue": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.1.tgz",
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"@tsconfig/node22": "^22.0.2",
|
||||
"@types/jsdom": "^27.0.0",
|
||||
"@types/node": "^22.18.11",
|
||||
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vitest/eslint-plugin": "^1.3.23",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
const frontendBaseUrl = process.env.PLAYWRIGHT_BASE_URL || 'https://localhost:5173'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
/* Run tests in files in parallel */
|
||||
@@ -32,7 +34,7 @@ export default defineConfig({
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('')`. */
|
||||
baseURL: 'https://localhost:5173',
|
||||
baseURL: frontendBaseUrl,
|
||||
ignoreHTTPSErrors: true,
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
@@ -220,7 +222,7 @@ export default defineConfig({
|
||||
webServer: [
|
||||
{
|
||||
command: 'npm run dev',
|
||||
url: 'https://localhost:5173',
|
||||
url: frontendBaseUrl,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
@@ -242,6 +244,7 @@ export default defineConfig({
|
||||
DB_ENV: 'e2e',
|
||||
DATA_ENV: 'e2e',
|
||||
SECRET_KEY: 'dev-secret-key-change-in-production',
|
||||
ACCESS_TOKEN_EXPIRY_MINUTES: process.env.E2E_ACCESS_TOKEN_EXPIRY_MINUTES || '180',
|
||||
REFRESH_TOKEN_EXPIRY_DAYS: '90',
|
||||
DIGEST_TOKEN_SECRET: 'dev-digest-token',
|
||||
VAPID_PUBLIC_KEY:
|
||||
|
||||
@@ -622,10 +622,10 @@ onUnmounted(() => {
|
||||
:sort-fn="childChoreSort"
|
||||
>
|
||||
<template #item="{ item }: { item: ChildTask }">
|
||||
<span v-if="isChoreExpired(item)" class="chore-stamp">TOO LATE</span>
|
||||
<span v-else-if="isChoreCompletedToday(item)" class="chore-stamp completed-stamp"
|
||||
<span v-if="isChoreCompletedToday(item)" class="chore-stamp completed-stamp"
|
||||
>COMPLETED</span
|
||||
>
|
||||
<span v-else-if="isChoreExpired(item)" class="chore-stamp">TOO LATE</span>
|
||||
<span v-else-if="item.pending_status === 'pending'" class="chore-stamp pending-stamp"
|
||||
>PENDING</span
|
||||
>
|
||||
|
||||
@@ -987,14 +987,14 @@ function goToAssignRewards() {
|
||||
</Teleport>
|
||||
</div>
|
||||
|
||||
<!-- TOO LATE badge -->
|
||||
<span v-if="isChoreExpired(item)" class="chore-stamp">TOO LATE</span>
|
||||
<!-- PENDING badge -->
|
||||
<span v-else-if="isChorePending(item)" class="chore-stamp pending-stamp">PENDING</span>
|
||||
<!-- COMPLETED badge -->
|
||||
<span v-else-if="isChoreCompletedToday(item)" class="chore-stamp completed-stamp"
|
||||
<span v-if="isChoreCompletedToday(item)" class="chore-stamp completed-stamp"
|
||||
>COMPLETED</span
|
||||
>
|
||||
<!-- TOO LATE badge -->
|
||||
<span v-else-if="isChoreExpired(item)" class="chore-stamp">TOO LATE</span>
|
||||
<!-- PENDING badge -->
|
||||
<span v-else-if="isChorePending(item)" class="chore-stamp pending-stamp">PENDING</span>
|
||||
|
||||
<div class="item-name">{{ item.name }}</div>
|
||||
<img v-if="item.image_url" :src="item.image_url" alt="Task Image" class="item-image" />
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
subscribeToPushWithResult,
|
||||
unsubscribeFromPush,
|
||||
isPushOptedOut,
|
||||
ensurePushSubscriptionSynced,
|
||||
} from '@/services/pushSubscription'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -66,6 +67,16 @@ async function fetchUserProfile() {
|
||||
if (userImageId.value) {
|
||||
await loadAvatarImages(userImageId.value)
|
||||
}
|
||||
|
||||
// Silent startup self-heal: if user has push enabled, ensure browser + backend
|
||||
// subscription state are synchronized (no permission prompt).
|
||||
if (
|
||||
isParentAuthenticated.value &&
|
||||
data.push_notifications_enabled !== false &&
|
||||
!isPushOptedOut()
|
||||
) {
|
||||
void ensurePushSubscriptionSynced()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error fetching user profile:', e)
|
||||
profileLoading.value = false
|
||||
|
||||
@@ -36,6 +36,31 @@ function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
return Uint8Array.from([...rawData].map((c) => c.charCodeAt(0)))
|
||||
}
|
||||
|
||||
function uint8ArraysEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||
if (a.length !== b.length) return false
|
||||
for (let i = 0; i < a.length; i += 1) {
|
||||
if (a[i] !== b[i]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function postSubscriptionToBackend(subscription: PushSubscription): Promise<boolean> {
|
||||
const subJson = subscription.toJSON()
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
|
||||
const res = await fetch('/api/push-subscription', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
endpoint: subJson.endpoint,
|
||||
keys: subJson.keys,
|
||||
timezone,
|
||||
}),
|
||||
})
|
||||
|
||||
return res.ok
|
||||
}
|
||||
|
||||
/**
|
||||
* Request notification permission and subscribe to push notifications.
|
||||
* Posts the subscription to the backend, including the user's timezone.
|
||||
@@ -105,32 +130,79 @@ export async function subscribeToPushWithResult(): Promise<PushSubscribeResult>
|
||||
}
|
||||
}
|
||||
|
||||
const subJson = subscription.toJSON()
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch('/api/push-subscription', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
endpoint: subJson.endpoint,
|
||||
keys: subJson.keys,
|
||||
timezone,
|
||||
}),
|
||||
})
|
||||
const ok = await postSubscriptionToBackend(subscription)
|
||||
if (!ok) {
|
||||
return { ok: false, reason: 'backend_error', detail: 'HTTP error posting subscription' }
|
||||
}
|
||||
} catch (fetchErr) {
|
||||
console.warn('[Push] Network error posting subscription:', fetchErr)
|
||||
return { ok: false, reason: 'backend_error', detail: String(fetchErr) }
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '')
|
||||
console.warn(`[Push] Backend rejected subscription: HTTP ${res.status}`, body)
|
||||
return { ok: false, reason: 'backend_error', detail: `HTTP ${res.status}: ${body}` }
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
/**
|
||||
* Silent self-heal path used on app startup/login.
|
||||
* - Never prompts for permission.
|
||||
* - If already subscribed with a stale VAPID key, rotates the browser subscription.
|
||||
* - Ensures backend has the current endpoint/keys.
|
||||
*/
|
||||
export async function ensurePushSubscriptionSynced(): Promise<boolean> {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return false
|
||||
if (Notification.permission !== 'granted') return false
|
||||
|
||||
const vapidKey = await fetchVapidPublicKey()
|
||||
if (!vapidKey) return false
|
||||
|
||||
let registration: ServiceWorkerRegistration
|
||||
try {
|
||||
await navigator.serviceWorker.register('/sw.js')
|
||||
registration = await Promise.race([
|
||||
navigator.serviceWorker.ready,
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('SW ready timeout')), 10_000),
|
||||
),
|
||||
])
|
||||
} catch (err) {
|
||||
console.warn('[Push] Service worker not ready for self-heal:', err)
|
||||
return false
|
||||
}
|
||||
|
||||
const desiredServerKey = urlBase64ToUint8Array(vapidKey)
|
||||
let subscription = await registration.pushManager.getSubscription()
|
||||
|
||||
if (subscription) {
|
||||
const existingServerKeyBuffer = subscription.options?.applicationServerKey
|
||||
if (existingServerKeyBuffer) {
|
||||
const existingServerKey = new Uint8Array(existingServerKeyBuffer)
|
||||
const keyMatches = uint8ArraysEqual(existingServerKey, desiredServerKey)
|
||||
if (!keyMatches) {
|
||||
await subscription.unsubscribe()
|
||||
subscription = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!subscription) {
|
||||
try {
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: desiredServerKey,
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('[Push] Failed to create subscription in self-heal:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await postSubscriptionToBackend(subscription)
|
||||
} catch (err) {
|
||||
console.warn('[Push] Failed to sync subscription in self-heal:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import basicSsl from '@vitejs/plugin-basic-ssl'
|
||||
import fs from 'fs'
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
@@ -13,10 +14,10 @@ export default defineConfig(({ mode }) => {
|
||||
: undefined
|
||||
|
||||
return {
|
||||
plugins: [vue()],
|
||||
plugins: [vue(), basicSsl()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
https: httpsConfig,
|
||||
https: httpsConfig ?? true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: `http://${backendHost}:5000`,
|
||||
|
||||
Reference in New Issue
Block a user