From 4873449e16f96859caefc2f76223d0457116b755 Mon Sep 17 00:00:00 2001 From: Nico Date: Wed, 11 Feb 2026 23:45:15 -0800 Subject: [PATCH] initial commit --- .gitea/workflows/deploy.yml | 69 + .github/copilot-instructions.md | 123 + .gitignore | 101 + DEBUGGING_INSTRUCTIONS.md | 137 + PROJECT_INSTRUCTIONS.md | 50 + README.md | 22 + apps/web/__tests__/auth.test.ts | 130 + apps/web/__tests__/bucket-usage.test.ts | 65 + apps/web/__tests__/buckets.test.ts | 85 + apps/web/__tests__/entries.test.ts | 5 + apps/web/__tests__/group-ownership.test.ts | 149 + apps/web/__tests__/group-settings.test.ts | 118 + apps/web/__tests__/groups.test.ts | 109 + apps/web/__tests__/invite-links.test.ts | 128 + apps/web/__tests__/recurring-entries.test.ts | 77 + apps/web/__tests__/run-tests.cjs | 67 + apps/web/__tests__/server-only-loader.mjs | 18 + apps/web/__tests__/server-only-stub.js | 1 + apps/web/__tests__/server-only.cjs | 17 + apps/web/__tests__/spendings.test.ts | 98 + apps/web/__tests__/tags.test.ts | 81 + apps/web/__tests__/test-helpers.ts | 67 + apps/web/app/api/auth/login/route.ts | 36 + apps/web/app/api/auth/logout/route.ts | 20 + apps/web/app/api/auth/me/route.ts | 9 + apps/web/app/api/auth/register/route.ts | 38 + apps/web/app/api/buckets/[id]/route.ts | 78 + apps/web/app/api/buckets/route.ts | 67 + apps/web/app/api/entries/[id]/route.ts | 99 + apps/web/app/api/entries/route.ts | 88 + apps/web/app/api/groups/active/route.ts | 35 + apps/web/app/api/groups/audit/route.ts | 19 + apps/web/app/api/groups/delete/route.ts | 18 + .../app/api/groups/invites/delete/route.ts | 23 + .../app/api/groups/invites/revive/route.ts | 27 + .../app/api/groups/invites/revoke/route.ts | 23 + apps/web/app/api/groups/invites/route.ts | 41 + apps/web/app/api/groups/join/route.ts | 22 + .../app/api/groups/members/approve/route.ts | 24 + .../app/api/groups/members/demote/route.ts | 23 + apps/web/app/api/groups/members/deny/route.ts | 23 + apps/web/app/api/groups/members/kick/route.ts | 23 + .../web/app/api/groups/members/leave/route.ts | 19 + .../app/api/groups/members/promote/route.ts | 23 + apps/web/app/api/groups/members/route.ts | 20 + .../groups/members/transfer-owner/route.ts | 23 + apps/web/app/api/groups/rename/route.ts | 22 + apps/web/app/api/groups/route.ts | 33 + apps/web/app/api/groups/settings/route.ts | 38 + .../web/app/api/invite-links/[token]/route.ts | 41 + .../app/api/recurring-entries/[id]/route.ts | 98 + apps/web/app/api/recurring-entries/route.ts | 87 + apps/web/app/api/tags/[name]/route.ts | 20 + apps/web/app/api/tags/route.ts | 35 + apps/web/app/favicon.ico | Bin 0 -> 901 bytes apps/web/app/globals.css | 179 + apps/web/app/groups/[id]/settings/page.tsx | 14 + apps/web/app/groups/settings/page.tsx | 16 + apps/web/app/invite/[token]/page.tsx | 282 + apps/web/app/layout.tsx | 21 + apps/web/app/login/page.tsx | 141 + apps/web/app/page.tsx | 10 + apps/web/app/register/page.tsx | 90 + apps/web/components/app-frame.tsx | 26 + apps/web/components/app-providers.tsx | 20 + apps/web/components/bucket-card.tsx | 151 + apps/web/components/buckets-panel.tsx | 230 + apps/web/components/confirm-slide-modal.tsx | 105 + apps/web/components/dashboard-content.tsx | 45 + apps/web/components/entries-panel.tsx | 785 ++ apps/web/components/entry-details-modal.tsx | 387 + apps/web/components/group-dropdown.tsx | 300 + .../web/components/group-settings-content.tsx | 1046 +++ apps/web/components/navbar.tsx | 186 + apps/web/components/new-bucket-modal.tsx | 222 + apps/web/components/new-entry-modal.tsx | 297 + apps/web/components/notifications-toaster.tsx | 55 + .../components/recurring-entries-panel.tsx | 73 + apps/web/components/tag-input.tsx | 260 + apps/web/e2e/auth.spec.ts | 21 + apps/web/e2e/groups.spec.ts | 28 + apps/web/e2e/smoke.spec.ts | 6 + apps/web/e2e/spendings.spec.ts | 54 + apps/web/e2e/test-helpers.ts | 13 + apps/web/hooks/auth-context.tsx | 21 + apps/web/hooks/entry-mutation-context.tsx | 30 + apps/web/hooks/groups-context.tsx | 21 + apps/web/hooks/notifications-context.tsx | 83 + apps/web/hooks/use-auth.ts | 72 + apps/web/hooks/use-buckets.ts | 119 + apps/web/hooks/use-entries.ts | 124 + apps/web/hooks/use-group-audit.ts | 33 + apps/web/hooks/use-group-invites.ts | 82 + apps/web/hooks/use-group-members.ts | 136 + apps/web/hooks/use-group-settings.ts | 43 + apps/web/hooks/use-groups.ts | 100 + apps/web/hooks/use-invite-link.ts | 64 + apps/web/hooks/use-recurring-entries.ts | 99 + apps/web/hooks/use-tags.ts | 59 + apps/web/lib/auth.ts | 1 + apps/web/lib/client/auth.ts | 24 + apps/web/lib/client/buckets.ts | 68 + apps/web/lib/client/entries.ts | 76 + apps/web/lib/client/entry-mutation-events.ts | 13 + apps/web/lib/client/fetch-json.ts | 32 + apps/web/lib/client/group-audit.ts | 20 + apps/web/lib/client/group-invites.ts | 47 + apps/web/lib/client/group-members.ts | 75 + apps/web/lib/client/group-settings.ts | 17 + apps/web/lib/client/groups.ts | 44 + apps/web/lib/client/invite-links.ts | 32 + apps/web/lib/client/recurring-entries.ts | 73 + apps/web/lib/client/tags.ts | 18 + apps/web/lib/db.ts | 1 + apps/web/lib/groups.ts | 1 + apps/web/lib/server/auth-service.ts | 63 + apps/web/lib/server/auth.ts | 45 + apps/web/lib/server/buckets.ts | 220 + apps/web/lib/server/db.ts | 45 + apps/web/lib/server/entries.ts | 208 + apps/web/lib/server/errors.ts | 107 + apps/web/lib/server/group-access.ts | 37 + apps/web/lib/server/group-audit.ts | 90 + apps/web/lib/server/group-invites.ts | 353 + apps/web/lib/server/group-members.ts | 371 + apps/web/lib/server/group-settings.ts | 32 + apps/web/lib/server/groups.ts | 176 + apps/web/lib/server/recurring-entries.ts | 23 + apps/web/lib/server/request.ts | 13 + apps/web/lib/server/session.ts | 35 + apps/web/lib/server/tags.ts | 152 + apps/web/lib/session.ts | 1 + apps/web/lib/shared/bucket-icons.ts | 405 + apps/web/lib/shared/bucket-usage.ts | 76 + apps/web/lib/shared/types.ts | 32 + apps/web/lib/spendings.ts | 1 + apps/web/next-env.d.ts | 6 + apps/web/next.config.mjs | 1 + apps/web/package.json | 39 + apps/web/playwright.config.ts | 17 + apps/web/postcss.config.js | 1 + apps/web/public/icons/icon-48.png | Bin 0 -> 2291 bytes apps/web/public/icons/navbar-settings.png | Bin 0 -> 30707 bytes apps/web/scripts/check-no-group-id-routes.cjs | 22 + apps/web/tailwind.config.ts | 7 + apps/web/tsconfig.json | 41 + apps/web/types/pg.d.ts | 17 + docker-compose.dev.yml | 20 + docker-compose.yml | 10 + docker/Dockerfile | 28 + docker/Dockerfile.dev | 12 + docs/01_inspect_then_lifecycle_restructure.md | 80 + docs/02_PLAN.md | 122 + ...IVOT_CHANGELOG_AND_IMPLEMENTATION_NOTES.md | 170 + docs/POSTGRES_RATELIMITTING_REFERENCE.md | 65 + ...REDIS_RATELIMITTING_MIGRATION_REFERENCE.md | 95 + docs/SEED_DATA.md | 53 + ...INGS_TO_ENTRIES_SCHEMA_MIGRATION_RECORD.md | 138 + docs/app_dev_plan/01_auth.md | 83 + docs/app_dev_plan/02_login_ux_enhancements.md | 53 + docs/app_dev_plan/03_dashboard_dev_server.md | 35 + docs/app_dev_plan/04_groups_switcher.md | 53 + docs/app_dev_plan/05_invite_code_modal.md | 27 + docs/app_dev_plan/06_groups_ui_alignment.md | 14 + docs/app_dev_plan/07_invite_modal_root.md | 13 + docs/app_dev_plan/08_spendings_crud.md | 42 + docs/app_dev_plan/09_hooks_api_calls.md | 24 + docs/bucket-usage-audit.md | 28 + docs/seed-ui.sql | 298 + package-lock.json | 7122 +++++++++++++++++ package.json | 18 + packages/db/migrations/001_init.sql | 76 + packages/db/migrations/002_amount_dollars.sql | 6 + packages/db/migrations/002_tags.sql | 26 + .../003_group_settings_owner_invites.sql | 88 + packages/db/migrations/004_entries_pivot.sql | 27 + packages/db/migrations/005_buckets.sql | 23 + packages/db/migrations/006_bucket_usage.sql | 11 + packages/db/package.json | 16 + packages/db/src/migrate.js | 102 + packages/db/src/selftest.js | 28 + packages/db/test-ui-seed/index.js | 110 + packages/shared/package.json | 9 + packages/shared/src/index.ts | 4 + scripts/dev-rebuild.sh | 6 + 185 files changed, 21374 insertions(+) create mode 100644 .gitea/workflows/deploy.yml create mode 100644 .github/copilot-instructions.md create mode 100644 .gitignore create mode 100644 DEBUGGING_INSTRUCTIONS.md create mode 100644 PROJECT_INSTRUCTIONS.md create mode 100644 README.md create mode 100644 apps/web/__tests__/auth.test.ts create mode 100644 apps/web/__tests__/bucket-usage.test.ts create mode 100644 apps/web/__tests__/buckets.test.ts create mode 100644 apps/web/__tests__/entries.test.ts create mode 100644 apps/web/__tests__/group-ownership.test.ts create mode 100644 apps/web/__tests__/group-settings.test.ts create mode 100644 apps/web/__tests__/groups.test.ts create mode 100644 apps/web/__tests__/invite-links.test.ts create mode 100644 apps/web/__tests__/recurring-entries.test.ts create mode 100644 apps/web/__tests__/run-tests.cjs create mode 100644 apps/web/__tests__/server-only-loader.mjs create mode 100644 apps/web/__tests__/server-only-stub.js create mode 100644 apps/web/__tests__/server-only.cjs create mode 100644 apps/web/__tests__/spendings.test.ts create mode 100644 apps/web/__tests__/tags.test.ts create mode 100644 apps/web/__tests__/test-helpers.ts create mode 100644 apps/web/app/api/auth/login/route.ts create mode 100644 apps/web/app/api/auth/logout/route.ts create mode 100644 apps/web/app/api/auth/me/route.ts create mode 100644 apps/web/app/api/auth/register/route.ts create mode 100644 apps/web/app/api/buckets/[id]/route.ts create mode 100644 apps/web/app/api/buckets/route.ts create mode 100644 apps/web/app/api/entries/[id]/route.ts create mode 100644 apps/web/app/api/entries/route.ts create mode 100644 apps/web/app/api/groups/active/route.ts create mode 100644 apps/web/app/api/groups/audit/route.ts create mode 100644 apps/web/app/api/groups/delete/route.ts create mode 100644 apps/web/app/api/groups/invites/delete/route.ts create mode 100644 apps/web/app/api/groups/invites/revive/route.ts create mode 100644 apps/web/app/api/groups/invites/revoke/route.ts create mode 100644 apps/web/app/api/groups/invites/route.ts create mode 100644 apps/web/app/api/groups/join/route.ts create mode 100644 apps/web/app/api/groups/members/approve/route.ts create mode 100644 apps/web/app/api/groups/members/demote/route.ts create mode 100644 apps/web/app/api/groups/members/deny/route.ts create mode 100644 apps/web/app/api/groups/members/kick/route.ts create mode 100644 apps/web/app/api/groups/members/leave/route.ts create mode 100644 apps/web/app/api/groups/members/promote/route.ts create mode 100644 apps/web/app/api/groups/members/route.ts create mode 100644 apps/web/app/api/groups/members/transfer-owner/route.ts create mode 100644 apps/web/app/api/groups/rename/route.ts create mode 100644 apps/web/app/api/groups/route.ts create mode 100644 apps/web/app/api/groups/settings/route.ts create mode 100644 apps/web/app/api/invite-links/[token]/route.ts create mode 100644 apps/web/app/api/recurring-entries/[id]/route.ts create mode 100644 apps/web/app/api/recurring-entries/route.ts create mode 100644 apps/web/app/api/tags/[name]/route.ts create mode 100644 apps/web/app/api/tags/route.ts create mode 100644 apps/web/app/favicon.ico create mode 100644 apps/web/app/globals.css create mode 100644 apps/web/app/groups/[id]/settings/page.tsx create mode 100644 apps/web/app/groups/settings/page.tsx create mode 100644 apps/web/app/invite/[token]/page.tsx create mode 100644 apps/web/app/layout.tsx create mode 100644 apps/web/app/login/page.tsx create mode 100644 apps/web/app/page.tsx create mode 100644 apps/web/app/register/page.tsx create mode 100644 apps/web/components/app-frame.tsx create mode 100644 apps/web/components/app-providers.tsx create mode 100644 apps/web/components/bucket-card.tsx create mode 100644 apps/web/components/buckets-panel.tsx create mode 100644 apps/web/components/confirm-slide-modal.tsx create mode 100644 apps/web/components/dashboard-content.tsx create mode 100644 apps/web/components/entries-panel.tsx create mode 100644 apps/web/components/entry-details-modal.tsx create mode 100644 apps/web/components/group-dropdown.tsx create mode 100644 apps/web/components/group-settings-content.tsx create mode 100644 apps/web/components/navbar.tsx create mode 100644 apps/web/components/new-bucket-modal.tsx create mode 100644 apps/web/components/new-entry-modal.tsx create mode 100644 apps/web/components/notifications-toaster.tsx create mode 100644 apps/web/components/recurring-entries-panel.tsx create mode 100644 apps/web/components/tag-input.tsx create mode 100644 apps/web/e2e/auth.spec.ts create mode 100644 apps/web/e2e/groups.spec.ts create mode 100644 apps/web/e2e/smoke.spec.ts create mode 100644 apps/web/e2e/spendings.spec.ts create mode 100644 apps/web/e2e/test-helpers.ts create mode 100644 apps/web/hooks/auth-context.tsx create mode 100644 apps/web/hooks/entry-mutation-context.tsx create mode 100644 apps/web/hooks/groups-context.tsx create mode 100644 apps/web/hooks/notifications-context.tsx create mode 100644 apps/web/hooks/use-auth.ts create mode 100644 apps/web/hooks/use-buckets.ts create mode 100644 apps/web/hooks/use-entries.ts create mode 100644 apps/web/hooks/use-group-audit.ts create mode 100644 apps/web/hooks/use-group-invites.ts create mode 100644 apps/web/hooks/use-group-members.ts create mode 100644 apps/web/hooks/use-group-settings.ts create mode 100644 apps/web/hooks/use-groups.ts create mode 100644 apps/web/hooks/use-invite-link.ts create mode 100644 apps/web/hooks/use-recurring-entries.ts create mode 100644 apps/web/hooks/use-tags.ts create mode 100644 apps/web/lib/auth.ts create mode 100644 apps/web/lib/client/auth.ts create mode 100644 apps/web/lib/client/buckets.ts create mode 100644 apps/web/lib/client/entries.ts create mode 100644 apps/web/lib/client/entry-mutation-events.ts create mode 100644 apps/web/lib/client/fetch-json.ts create mode 100644 apps/web/lib/client/group-audit.ts create mode 100644 apps/web/lib/client/group-invites.ts create mode 100644 apps/web/lib/client/group-members.ts create mode 100644 apps/web/lib/client/group-settings.ts create mode 100644 apps/web/lib/client/groups.ts create mode 100644 apps/web/lib/client/invite-links.ts create mode 100644 apps/web/lib/client/recurring-entries.ts create mode 100644 apps/web/lib/client/tags.ts create mode 100644 apps/web/lib/db.ts create mode 100644 apps/web/lib/groups.ts create mode 100644 apps/web/lib/server/auth-service.ts create mode 100644 apps/web/lib/server/auth.ts create mode 100644 apps/web/lib/server/buckets.ts create mode 100644 apps/web/lib/server/db.ts create mode 100644 apps/web/lib/server/entries.ts create mode 100644 apps/web/lib/server/errors.ts create mode 100644 apps/web/lib/server/group-access.ts create mode 100644 apps/web/lib/server/group-audit.ts create mode 100644 apps/web/lib/server/group-invites.ts create mode 100644 apps/web/lib/server/group-members.ts create mode 100644 apps/web/lib/server/group-settings.ts create mode 100644 apps/web/lib/server/groups.ts create mode 100644 apps/web/lib/server/recurring-entries.ts create mode 100644 apps/web/lib/server/request.ts create mode 100644 apps/web/lib/server/session.ts create mode 100644 apps/web/lib/server/tags.ts create mode 100644 apps/web/lib/session.ts create mode 100644 apps/web/lib/shared/bucket-icons.ts create mode 100644 apps/web/lib/shared/bucket-usage.ts create mode 100644 apps/web/lib/shared/types.ts create mode 100644 apps/web/lib/spendings.ts create mode 100644 apps/web/next-env.d.ts create mode 100644 apps/web/next.config.mjs create mode 100644 apps/web/package.json create mode 100644 apps/web/playwright.config.ts create mode 100644 apps/web/postcss.config.js create mode 100644 apps/web/public/icons/icon-48.png create mode 100644 apps/web/public/icons/navbar-settings.png create mode 100644 apps/web/scripts/check-no-group-id-routes.cjs create mode 100644 apps/web/tailwind.config.ts create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/types/pg.d.ts create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.yml create mode 100644 docker/Dockerfile create mode 100644 docker/Dockerfile.dev create mode 100644 docs/01_inspect_then_lifecycle_restructure.md create mode 100644 docs/02_PLAN.md create mode 100644 docs/ENTRIES_PIVOT_CHANGELOG_AND_IMPLEMENTATION_NOTES.md create mode 100644 docs/POSTGRES_RATELIMITTING_REFERENCE.md create mode 100644 docs/POSTGRES_TO_REDIS_RATELIMITTING_MIGRATION_REFERENCE.md create mode 100644 docs/SEED_DATA.md create mode 100644 docs/SPENDINGS_TO_ENTRIES_SCHEMA_MIGRATION_RECORD.md create mode 100644 docs/app_dev_plan/01_auth.md create mode 100644 docs/app_dev_plan/02_login_ux_enhancements.md create mode 100644 docs/app_dev_plan/03_dashboard_dev_server.md create mode 100644 docs/app_dev_plan/04_groups_switcher.md create mode 100644 docs/app_dev_plan/05_invite_code_modal.md create mode 100644 docs/app_dev_plan/06_groups_ui_alignment.md create mode 100644 docs/app_dev_plan/07_invite_modal_root.md create mode 100644 docs/app_dev_plan/08_spendings_crud.md create mode 100644 docs/app_dev_plan/09_hooks_api_calls.md create mode 100644 docs/bucket-usage-audit.md create mode 100644 docs/seed-ui.sql create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 packages/db/migrations/001_init.sql create mode 100644 packages/db/migrations/002_amount_dollars.sql create mode 100644 packages/db/migrations/002_tags.sql create mode 100644 packages/db/migrations/003_group_settings_owner_invites.sql create mode 100644 packages/db/migrations/004_entries_pivot.sql create mode 100644 packages/db/migrations/005_buckets.sql create mode 100644 packages/db/migrations/006_bucket_usage.sql create mode 100644 packages/db/package.json create mode 100644 packages/db/src/migrate.js create mode 100644 packages/db/src/selftest.js create mode 100644 packages/db/test-ui-seed/index.js create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/index.ts create mode 100644 scripts/dev-rebuild.sh diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..e0a87d5 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,69 @@ +name: Build & Deploy Fiddy + +on: + push: + branches: [ "main" ] + +env: + REGISTRY: git.nicosaya.com/nalalangan/fiddy + IMAGE_TAG: main + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: 20 + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test --if-present + + - name: Docker login + run: | + echo "${{ secrets.REGISTRY_PASS }}" | docker login $REGISTRY -u "${{ secrets.REGISTRY_USER }}" --password-stdin + + - name: Build Web Image + run: | + docker build -t $REGISTRY/web:${{ github.sha }} -t $REGISTRY/web:${{ env.IMAGE_TAG }} -f docker/Dockerfile . + + - name: Push Web Image + run: | + docker push $REGISTRY/web:${{ github.sha }} + docker push $REGISTRY/web:${{ env.IMAGE_TAG }} + + deploy: + needs: build + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v3 + + - name: Install SSH key + run: | + mkdir -p ~/.ssh + echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts + + - name: Upload docker-compose.yml + run: | + ssh ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} "mkdir -p /opt/fiddy" + scp docker-compose.yml ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:/opt/fiddy/docker-compose.yml + + - name: Deploy via SSH + run: | + ssh ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} << 'EOF' + cd /opt/fiddy + export IMAGE_TAG=main + docker compose pull + docker compose up -d --remove-orphans + docker image prune -f + EOF diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..0efb000 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,123 @@ +# Copilot Instructions — Fiddy (External DB) + +## Source of truth +- Always consult PROJECT_INSTRUCTIONS.md at the repo root. +- If any guidance conflicts, PROJECT_INSTRUCTIONS.md takes precedence. +- Keep this file focused on architecture/stack; keep checklists and project workflow rules in PROJECT_INSTRUCTIONS.md. +- When asked to fix bugs, follow DEBUGGING_INSTRUCTIONS.md at the repo root. + +## Stack +- Monorepo (npm workspaces) +- Next.js (App Router) + TypeScript + Tailwind +- External Postgres (on-prem server) via node-postgres (pg). No ORM. +- Docker Compose dev/prod +- Gitea + act-runner CI/CD + +## Environment +- Dev and Prod must use the same schema/migrations (`packages/db/migrations`). +- `DATABASE_URL` points to the external DB server (NOT a container). + +## Auth +- Custom email/password auth. +- Use HttpOnly session cookies backed by DB table `sessions`. +- NEVER trust client-side RBAC checks. + +## Receipts +- Store receipt images in Postgres `bytea` table `receipts`. +- Entries list endpoints must not return image bytes. +- Image bytes only fetched by separate endpoint when inspecting a single item. + +## UI +- Dark mode, minimal, mobile-first. +- Dodger Blue accent (#1E90FF). +- Top navbar: left nav dropdown, middle group selector, right user menu. + +## Code Rules +- Small files, minimal comments. +- Prefer single-line `if` without braces when only one line follows. +- Heavy logic lives in components/hooks/services, not page files. +- Prefer API calls via exported hooks/services for reusability and cleanliness (avoid raw fetch in components when possible). +- Add/update unit tests with changes (TDD). +- Heavy focus on code readability and maintainability; prioritize clean code over clever code. + - ie. Separating different concerns into different files, and adding helper functions to avoid nested logic and improve readability, is preferred over clever one-liners or consolidating logic into fewer files. + - ie. Separate groups of related codes by adding 3 line breaks between them + +## Data Model +- Users (system_role USER|SYS_ADMIN) +- Groups + membership (group_role MEMBER|GROUP_ADMIN) +- Entries (group-scoped) + optional receipt_id +- User settings (jsonb) +- Reports for system admins + +## Architecture Contract (Backend ↔ Client ↔ Hooks ↔ UI) + +### No-Assumptions Rule (Required) +- Before making structural changes, first scan the repo and identify: + - the web app root (where `app/`, `components/`, `hooks/`, `lib/` live) + - existing API routes and helpers + - existing patterns already in use +- Do not invent files, endpoints, or conventions. If something is missing, add it minimally and consistently. + +### Layering (Hard Boundaries) +For every domain (auth, groups, entries, receipts, etc.), keep a consistent 4-layer flow: + +1) **API Route Handlers** (`app/api/.../route.ts`) +- Thin: parse input, call a server service, return JSON. +- No direct DB queries inside route files unless there is no existing server service. +- Must enforce auth & membership checks on server. + +2) **Server Services (DB + authorization)** (`lib/server/*`) +- Own all DB access and authorization helpers (sessions, requireGroupMember, etc.). +- Server-only modules must include `import "server-only";` +- Prefer small domain modules: `lib/server/auth.ts`, `lib/server/groups.ts`, `lib/server/entries.ts`, `lib/server/session.ts`. + +3) **Client API Wrappers** (`lib/client/*`) +- Typed fetch helpers only (no React state). +- Centralize `fetchJson()` / error normalization. +- Always send credentials (cookies) and never trust client-side RBAC. + +4) **Hooks (UI-facing API layer)** (`hooks/use-*.ts`) +- Hooks are the primary interface for components/pages to call APIs. +- Components should not call `fetch()` directly unless there’s a strong reason. + +### Domain Blueprint (Consistency Rule) +For any new feature/domain, prefer: +- `app/api//...` +- `lib/server/.ts` +- `lib/client/.ts` +- `hooks/use-.ts` +- `components//*` +- `__tests__/.test.ts` + +### API Conventions +- Prefer consistent JSON response shape for errors: + - `{ error: { code: string, message: string } }` +- Validate inputs at the route boundary (basic shape/type), and validate authorization in server services. +- When adding endpoints, mirror existing REST style used in the project. + +### Non-Regression Contracts (Do Not Break) +- Entries list endpoints must **never** include receipt image bytes; image bytes are fetched via a separate endpoint only. +- Auth is DB-backed HttpOnly sessions; all auth checks are server-side. +- Groups require server-side membership checks; active group persists per user. +- Group invite codes: + - shown once immediately after group creation + - modal renders outside navbar/header so it overlays the viewport correctly + - avoid re-exposing invite code elsewhere without explicit “group settings” work + +### UI Structure +- Page files stay thin; heavy logic stays in hooks/services/components. +- Dark mode, minimal, mobile-first. +- Dodger Blue accent (#1E90FF). +- Navbar: left nav dropdown, middle group selector, right user menu. + +### Environment +- Dev and Prod must use the same schema/migrations (`packages/db/migrations`). +- `DATABASE_URL` points to external Postgres (NOT a container). +- `DATABASE_URL` format must be a full connection string; URL-encode special chars in passwords. + +### Tests (Required) +- Add/update tests for API behavior changes: + - auth + - groups + - entries (group scoping) +- Tests must include negative cases: unauthorized, not-a-member, invalid inputs. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..63632e1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,101 @@ +node_modules +.next +dist +.env +db.env +*.log +.DS_Store +.vscode/* +!.vscode/settings.json + +# ========================= +# Dependencies +# ========================= +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +.pnpm-store/ + +# ========================= +# Next.js build output +# ========================= +.next/ +out/ + +# ========================= +# Production build output (common) +# ========================= +dist/ +build/ + +# ========================= +# Logs +# ========================= +logs/ +*.log + +# ========================= +# Runtime / PID files +# ========================= +pids/ +*.pid +*.seed +*.pid.lock + +# ========================= +# Env files (secrets) +# ========================= +.env +.env.* +!.env.example +!.env.local.example + +# ========================= +# Local config / caches +# ========================= +.cache/ +.tmp/ +temp/ +tmp/ + +# ========================= +# Testing +# ========================= +coverage/ +.nyc_output/ +playwright-report/ +test-results/ +.cypress/ +*.lcov + +# ========================= +# TypeScript +# ========================= +*.tsbuildinfo + +# ========================= +# Lint / format caches +# ========================= +.eslintcache +.stylelintcache + +# ========================= +# Vercel / Netlify / Deploy +# ========================= +.vercel/ +.netlify/ + +# ========================= +# OS / Editor +# ========================= +.DS_Store +Thumbs.db +ehthumbs.db +Desktop.ini + +# VS Code +.vscode/* +!.vscode/extensions.json +!.vscode/settings.js diff --git a/DEBUGGING_INSTRUCTIONS.md b/DEBUGGING_INSTRUCTIONS.md new file mode 100644 index 0000000..d75ffb7 --- /dev/null +++ b/DEBUGGING_INSTRUCTIONS.md @@ -0,0 +1,137 @@ +# Debug Protocol (Repo) + +> You are debugging an issue in this repo. Do **not** implement features unless required to fix the bug. + +## 0) Non-negotiables + +- **Source of truth:** `PROJECT_INSTRUCTIONS.md` (repo root). If anything conflicts, follow it. +- **No assumptions:** First scan the repo for existing patterns, locations, scripts, helpers, and conventions. Don’t invent missing files/endpoints unless truly necessary—and if needed, add minimally and consistently. +- **External DB:** `DATABASE_URL` points to on-prem Postgres (**not** a container). Dev/Prod share schema via migrations in `packages/db/migrations`. +- **Security:** Never log secrets, full invite codes, or receipt bytes. Invite codes in logs/audit: **last4 only**. +- **No cron/worker jobs:** Any fix must work without background tasks. +- **Server-side RBAC only:** Client checks are UX only. + +--- + +## 1) Restate the bug precisely + +Start by writing a 4–6 line **Bug Definition**: + +- **Expected behavior** +- **Actual behavior** +- **Where it happens** (page / route / service) +- **Impact** (blocker? regression? edge case?) + +If any of these are missing, ask for them explicitly. + +--- + +## 2) Collect an evidence bundle before changing code + +Before editing anything, request/locate: + +### A) Runtime signals +- Exact error text + stack trace (server + browser console) +- Network capture: request URL, method, status, response body +- Any `request_id` returned by the API for failing calls + +### B) Repo + environment +- Current branch/commit +- How app is started (`docker-compose` + which file) +- Node version + package manager used +- Relevant env vars (sanitized): `DATABASE_URL` **host/port/dbname only** (no password) + +### C) Involved code paths +Identify actual files by searching the repo: +- The page/component triggering the request +- The hook used (`hooks/use-*.ts`) +- The client wrapper (`lib/client/*`) +- The API route (`app/api/**/route.ts`) +- The server service (`lib/server/*.ts`) + +**Do not guess file names. Use repo search.** + +--- + +## 3) Determine failure class (choose ONE primary) + +Pick the most likely bucket and focus there first: + +- DB connectivity / docker networking +- Migrations/schema mismatch +- Auth/session cookie flow (HttpOnly cookies, session table, logout) +- RBAC / group membership / active group +- Request validation / parsing (route boundary) +- Client fetch wrapper / hook state +- UI behavior / event handling / mobile UX +- Playwright test failure (timing, selectors, baseURL, auth state) + +Write a **3–5 bullet** hypothesis list ordered by likelihood. + +--- + +## 4) Reproduce locally with the smallest surface area + +Prefer reproducing via: +1) A single API call (`curl` / minimal `fetch`) before full UI if possible +2) Then UI repro +3) Then Playwright repro (if it’s a test failure) + +If scripts are needed, inspect `package.json` for actual script names (**don’t assume**). Common ones may exist (`lint`, `test`, `db:migrate`) but confirm. + +--- + +## 5) Add targeted observability (minimal + removable) + +If evidence is insufficient, add temporary, minimal logs in **server services** (not in UI): + +- Always include `request_id` +- Log only safe metadata (`user_id`, `group_id`, route name, timing) +- Never log secrets/full invite code/receipt bytes/passwords/tokens +- If touching invite logic: log **last4 only** + +Prefer: +- One log line at service entry (inputs summary) +- One log line at decision points (auth fail / membership fail / db row missing) +- One log line at service exit (success + timing) + +Remove or downgrade noisy logs before final PR unless clearly valuable. + +--- + +## 6) Fix strategy rules + +- Make the smallest change that resolves the bug. +- Respect layering: + - **Route:** parse + validate shape + - **Server service:** DB + authz + business rules + - **Client wrapper:** typed fetch + error normalization + - **Hook:** UI-facing API layer +- Don’t introduce new dependencies unless absolutely necessary. +- Keep touched files free of TS warnings and lint errors. + +--- + +## 7) Verification checklist (must do) + +After the fix: +- Re-run the minimal repro (API and/or UI) +- Run relevant tests (unit + Playwright if applicable) +- Add/adjust tests for the bug: + - At least **1 positive** + **2 negatives** (unauthorized, not-a-member, invalid input) +- Confirm contracts: + - Spendings list never includes receipt bytes + - Sessions are HttpOnly + DB-backed + - Audit logs include `request_id` and never store full invite code + +--- + +## 8) Next.js route params checklist (required) + +For `app/api/**/[param]/route.ts`: +- Treat `context.params` as **async** and `await` it before reading properties. +- If you see errors about sync dynamic APIs, update handlers to unwrap params: + +Example: +```ts +const { id } = await context.params; diff --git a/PROJECT_INSTRUCTIONS.md b/PROJECT_INSTRUCTIONS.md new file mode 100644 index 0000000..1ec9d2c --- /dev/null +++ b/PROJECT_INSTRUCTIONS.md @@ -0,0 +1,50 @@ +# Project Instructions — Fiddy (External DB) + +## Core expectation +This project connects to an external Postgres instance (on-prem server). Dev and Prod must share the same schema through migrations. + +## Decisions / constraints (Group Settings) +- Add `GROUP_OWNER` role to group roles; migrate existing groups so the first admin becomes owner. +- Join policy default is `NOT_ACCEPTING`. Policies: `NOT_ACCEPTING`, `AUTO_ACCEPT`, `APPROVAL_REQUIRED`. +- Both owner and admins can approve join requests and manage invite links. +- Invite links: + - TTL limited to 1–7 days. + - Settings are immutable after creation (policy, single-use, etc.). + - Single-use does not override approval-required. + - Expired links are retained and can be revived. + - Single-use links are deleted after successful use. + - Revive resets `used_at` and `revoked_at`, refreshes `expires_at`, and creates a new audit event. +- No cron/worker jobs for now (auto ownership transfer and invite rotation are paused). +- API must generate `request_id` and return it in responses; audit logs must include it. +- Audit logs must never store full invite codes (store last4 only). + +## Do first (vertical slice) +1) DB migrate command + schema +2) Register/Login/Logout (custom sessions) +3) Protected dashboard page +4) Group create/join + group switcher (approval-based joins + optional join disable) +5) Entries CRUD (no receipt bytes in list) +6) Receipt upload/download endpoints +7) Settings + Reports + +## Definition of done +- Works via docker-compose.dev.yml with external DB +- Migrations applied via `npm run db:migrate` +- Tests + lint pass +- RBAC enforced server-side +- No large files +- No TypeScript warnings or lint errors in touched files +- No new cron/worker dependencies unless explicitly approved + +## Desktop + mobile UX checklist (required) +- Touch: long-press affordance for item-level actions when no visible button. +- Mouse: hover affordance on interactive rows/cards. +- Tap targets remain >= 40px on mobile. +- Modal overlays must close on outside click/tap. +- Use bubble notifications for main actions (create/update/delete/join). +- Add Playwright UI tests for new UI features and critical flows. +- Group role icons must be consistent: 👑 owner, 🛡️ admin, 👤 member. + +## PR review checklist +- Desktop + mobile UX checklist satisfied (hover + long-press where applicable). +- No TypeScript warnings or lint errors introduced. diff --git a/README.md b/README.md new file mode 100644 index 0000000..551c883 --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +# Fiddy (External DB) + +Monorepo scaffold for a budgeting/collaboration app that connects to an external on-prem Postgres server. + +## Quick start (dev) +1. Copy env template: + - `.env.example` -> `apps/web/.env` +2. Edit `apps/web/.env`: + - Set `DATABASE_URL` to your DB server hostname/IP (NOT `db`). + - Set `ALLOWED_DB_NAMES` to a comma-separated allowlist (case-insensitive). +3. Start web: + - `docker compose -f docker-compose.dev.yml up --build` +4. Apply migrations: + - `npm run db:migrate` (ensure `DATABASE_URL` points to the DB you want) + +## Prod +- Deploy folder: `/opt/fiddy` +- Put `.env` in `/opt/fiddy/apps/web/.env` with the prod DB connection string and `ALLOWED_DB_NAMES`. +- `docker compose up -d` will run only the web container; DB is external. + +## Copilot +- See `copilot-instructions.md` and `PROJECT_INSTRUCTIONS.md` diff --git a/apps/web/__tests__/auth.test.ts b/apps/web/__tests__/auth.test.ts new file mode 100644 index 0000000..8cbafc9 --- /dev/null +++ b/apps/web/__tests__/auth.test.ts @@ -0,0 +1,130 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import dotenv from "dotenv"; +import { createSession, hashPassword, verifyPassword } from "../lib/server/auth"; +import { loginUser, registerUser } from "../lib/server/auth-service"; +import getPool from "../lib/server/db"; +import { cleanupTestData, cleanupTestDataFromPool } from "./test-helpers"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const envLoaded = dotenv.config({ path: path.resolve(__dirname, "../../../.env") }); + +const hasDb = Boolean(process.env.DATABASE_URL); + +test("password hashing works", async () => { + const hash = await hashPassword("test-password"); + assert.ok(await verifyPassword("test-password", hash)); + assert.ok(!(await verifyPassword("wrong", hash))); +}); + +test("createSession inserts row", async t => { + if (!hasDb) { + t.skip("DATABASE_URL not set"); + return; + } + if (envLoaded.error) + t.diagnostic(String(envLoaded.error)); + + const pool = getPool(); + const client = await pool.connect(); + const email = `test_${Date.now()}@example.com`; + try { + const { rows } = await client.query( + "insert into users(email, password_hash) values($1,$2) returning id", + [email, await hashPassword("test-password")] + ); + const userId = rows[0].id as number; + const session = await createSession(userId); + const { rows: sessionRows } = await client.query( + "select id from sessions where user_id=$1 and expires_at > now()", + [userId] + ); + assert.ok(session.token.length > 10); + assert.ok(sessionRows.length === 1); + } finally { + await cleanupTestData(client, { emails: [email] }); + client.release(); + } +}); + +test("createSession supports ttl override", async t => { + if (!hasDb) { + t.skip("DATABASE_URL not set"); + return; + } + if (envLoaded.error) + t.diagnostic(String(envLoaded.error)); + + const pool = getPool(); + const client = await pool.connect(); + const email = `test_${Date.now()}_ttl@example.com`; + const ttlMs = 60 * 60 * 1000; + const nowMs = Date.now(); + try { + const { rows } = await client.query( + "insert into users(email, password_hash) values($1,$2) returning id", + [email, await hashPassword("test-password")] + ); + const userId = rows[0].id as number; + const session = await createSession(userId, { ttlMs }); + const { rows: sessionRows } = await client.query( + "select expires_at from sessions where user_id=$1 order by created_at desc limit 1", + [userId] + ); + const dbExpiresAt = new Date(sessionRows[0].expires_at).getTime(); + const expectedMin = nowMs + ttlMs - 5000; + const expectedMax = nowMs + ttlMs + 5000; + assert.ok(session.expiresAt.getTime() >= expectedMin); + assert.ok(session.expiresAt.getTime() <= expectedMax); + assert.ok(dbExpiresAt >= expectedMin); + assert.ok(dbExpiresAt <= expectedMax); + } finally { + await cleanupTestData(client, { emails: [email] }); + client.release(); + } +}); + +test("loginUser respects remember flag", async t => { + if (!hasDb) { + t.skip("DATABASE_URL not set"); + return; + } + if (envLoaded.error) t.diagnostic(String(envLoaded.error)); + + const email = `remember_${Date.now()}@example.com`; + const password = "test-password"; + const result = await registerUser({ email, password, displayName: "" }); + try { + const rememberTrue = await loginUser({ email, password, remember: true }); + const rememberFalse = await loginUser({ email, password, remember: false }); + assert.ok(rememberTrue.session.ttlMs > rememberFalse.session.ttlMs); + } finally { + const pool = getPool(); + await cleanupTestDataFromPool(pool, { userIds: [result.user.id] }); + } +}); + +test("registerUser rejects duplicate email", async t => { + if (!hasDb) { + t.skip("DATABASE_URL not set"); + return; + } + if (envLoaded.error) t.diagnostic(String(envLoaded.error)); + + const pool = getPool(); + const email = `dup_${Date.now()}@example.com`; + const password = "test-password"; + const mixedCase = email.toUpperCase(); + const result = await registerUser({ email, password, displayName: "" }); + try { + await assert.rejects( + () => registerUser({ email: mixedCase, password, displayName: "" }), + { message: "EMAIL_EXISTS" } + ); + } finally { + await cleanupTestDataFromPool(pool, { userIds: [result.user.id] }); + } +}); diff --git a/apps/web/__tests__/bucket-usage.test.ts b/apps/web/__tests__/bucket-usage.test.ts new file mode 100644 index 0000000..a6db352 --- /dev/null +++ b/apps/web/__tests__/bucket-usage.test.ts @@ -0,0 +1,65 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { calculateBucketUsage } from "../lib/shared/bucket-usage"; + +const today = "2026-02-11"; + +test("calculateBucketUsage matches tag subset", () => { + const bucket = { tags: ["groceries", "weekly"], necessity: "BOTH", windowDays: 30 }; + const entries = [ + { amountDollars: 20, occurredAt: "2026-02-10", necessity: "NECESSARY", tags: ["groceries", "weekly", "extra"], isRecurring: false, entryType: "SPENDING" as const }, + { amountDollars: 15, occurredAt: "2026-02-10", necessity: "NECESSARY", tags: ["groceries"], isRecurring: false, entryType: "SPENDING" as const } + ]; + + const result = calculateBucketUsage(bucket, entries, today); + assert.equal(result.totalUsage, 20); + assert.equal(result.matchedCount, 1); +}); + +test("calculateBucketUsage excludes recurring entries", () => { + const bucket = { tags: ["rent"], necessity: "BOTH", windowDays: 30 }; + const entries = [ + { amountDollars: 500, occurredAt: "2026-02-10", necessity: "NECESSARY", tags: ["rent"], isRecurring: true, entryType: "SPENDING" as const } + ]; + + const result = calculateBucketUsage(bucket, entries, today); + assert.equal(result.totalUsage, 0); + assert.equal(result.matchedCount, 0); +}); + +test("calculateBucketUsage applies windowDays filtering", () => { + const bucket = { tags: [], necessity: "BOTH", windowDays: 3 }; + const entries = [ + { amountDollars: 10, occurredAt: "2026-02-11", necessity: "NECESSARY", tags: [], isRecurring: false, entryType: "SPENDING" as const }, + { amountDollars: 10, occurredAt: "2026-02-09", necessity: "NECESSARY", tags: [], isRecurring: false, entryType: "SPENDING" as const }, + { amountDollars: 10, occurredAt: "2026-02-08", necessity: "NECESSARY", tags: [], isRecurring: false, entryType: "SPENDING" as const } + ]; + + const result = calculateBucketUsage(bucket, entries, today); + assert.equal(result.totalUsage, 20); + assert.equal(result.matchedCount, 2); +}); + +test("calculateBucketUsage accepts all when bucket necessity is BOTH", () => { + const bucket = { tags: [], necessity: "BOTH", windowDays: 30 }; + const entries = [ + { amountDollars: 12, occurredAt: "2026-02-10", necessity: "NECESSARY", tags: [], isRecurring: false, entryType: "SPENDING" as const }, + { amountDollars: 8, occurredAt: "2026-02-10", necessity: "UNNECESSARY", tags: [], isRecurring: false, entryType: "SPENDING" as const } + ]; + + const result = calculateBucketUsage(bucket, entries, today); + assert.equal(result.totalUsage, 20); + assert.equal(result.matchedCount, 2); +}); + +test("calculateBucketUsage halves BOTH entries when bucket not BOTH", () => { + const bucket = { tags: [], necessity: "NECESSARY", windowDays: 30 }; + const entries = [ + { amountDollars: 10, occurredAt: "2026-02-10", necessity: "BOTH", tags: [], isRecurring: false, entryType: "SPENDING" as const }, + { amountDollars: 10, occurredAt: "2026-02-10", necessity: "UNNECESSARY", tags: [], isRecurring: false, entryType: "SPENDING" as const } + ]; + + const result = calculateBucketUsage(bucket, entries, today); + assert.equal(result.totalUsage, 5); + assert.equal(result.matchedCount, 1); +}); diff --git a/apps/web/__tests__/buckets.test.ts b/apps/web/__tests__/buckets.test.ts new file mode 100644 index 0000000..843ee74 --- /dev/null +++ b/apps/web/__tests__/buckets.test.ts @@ -0,0 +1,85 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import dotenv from "dotenv"; +import getPool from "../lib/server/db"; +import { createBucket, deleteBucket, listBuckets, updateBucket } from "../lib/server/buckets"; +import { ensureTagsForGroup } from "../lib/server/tags"; +import { cleanupTestData, uniqueInviteCode } from "./test-helpers"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const envLoaded = dotenv.config({ path: path.resolve(__dirname, "../../../.env") }); + +const hasDb = Boolean(process.env.DATABASE_URL); + +test("buckets CRUD", async t => { + if (!hasDb) { + t.skip("DATABASE_URL not set"); + return; + } + + if (envLoaded.error) t.diagnostic(String(envLoaded.error)); + + const pool = getPool(); + const client = await pool.connect(); + let userId: number | null = null; + let groupId: number | null = null; + try { + const userRes = await client.query( + "insert into users(email, password_hash) values($1,$2) returning id", + [`bucket_${Date.now()}@example.com`, "hash"] + ); + userId = userRes.rows[0].id as number; + + const groupRes = await client.query( + "insert into groups(name, invite_code, created_by) values($1,$2,$3) returning id", + ["Buckets Test", uniqueInviteCode("B"), userId] + ); + groupId = groupRes.rows[0].id as number; + + await client.query( + "insert into group_members(group_id, user_id, role) values($1,$2,'GROUP_ADMIN')", + [groupId, userId] + ); + + await ensureTagsForGroup({ userId, groupId, tags: ["groceries", "weekly"] }); + + const bucket = await createBucket({ + groupId, + userId, + name: "Groceries", + description: "Weekly groceries", + iconKey: "food", + budgetLimitDollars: 200, + tags: ["groceries", "weekly"] + }); + + const list = await listBuckets(groupId); + assert.equal(list.length, 1); + assert.equal(list[0].id, bucket.id); + assert.deepEqual(list[0].tags.sort(), ["groceries", "weekly"]); + + const updated = await updateBucket({ + id: bucket.id, + groupId, + userId, + name: "Groceries+", + description: "Updated", + iconKey: "food", + budgetLimitDollars: 250, + tags: ["groceries"] + }); + assert.ok(updated); + assert.equal(updated?.name, "Groceries+"); + assert.deepEqual(updated?.tags.sort(), ["groceries"]); + + await deleteBucket({ id: bucket.id, groupId }); + const listAfter = await listBuckets(groupId); + assert.equal(listAfter.length, 0); + } finally { + await cleanupTestData(client, { userIds: [userId], groupId }); + client.release(); + } +}); diff --git a/apps/web/__tests__/entries.test.ts b/apps/web/__tests__/entries.test.ts new file mode 100644 index 0000000..cec3086 --- /dev/null +++ b/apps/web/__tests__/entries.test.ts @@ -0,0 +1,5 @@ +import { test } from "node:test"; + +test("entries CRUD (covered by legacy test file)", async t => { + t.skip("Covered by spendings.test.ts after pivot"); +}); diff --git a/apps/web/__tests__/group-ownership.test.ts b/apps/web/__tests__/group-ownership.test.ts new file mode 100644 index 0000000..cdd1c97 --- /dev/null +++ b/apps/web/__tests__/group-ownership.test.ts @@ -0,0 +1,149 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import dotenv from "dotenv"; +import getPool from "../lib/server/db"; +import { joinGroup, requireActiveGroup } from "../lib/server/groups"; +import { setGroupSettings } from "../lib/server/group-settings"; +import { transferOwnership } from "../lib/server/group-members"; +import { cleanupTestData, uniqueInviteCode } from "./test-helpers"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const envLoaded = dotenv.config({ path: path.resolve(__dirname, "../../../.env") }); + +const hasDb = Boolean(process.env.DATABASE_URL); + +test("join policy enforcement and join requests", async t => { + if (!hasDb) { + t.skip("DATABASE_URL not set"); + return; + } + + if (envLoaded.error) t.diagnostic(String(envLoaded.error)); + + const pool = getPool(); + const client = await pool.connect(); + let ownerId: number | null = null; + let memberId: number | null = null; + let groupId: number | null = null; + const inviteCode = uniqueInviteCode("J"); + try { + const ownerRes = await client.query( + "insert into users(email, password_hash) values($1,$2) returning id", + [`owner_${Date.now()}@example.com`, "hash"] + ); + ownerId = Number(ownerRes.rows[0].id); + + const memberRes = await client.query( + "insert into users(email, password_hash) values($1,$2) returning id", + [`member_${Date.now()}@example.com`, "hash"] + ); + memberId = Number(memberRes.rows[0].id); + + const groupRes = await client.query( + "insert into groups(name, invite_code, created_by) values($1,$2,$3) returning id", + ["Join Policy Group", inviteCode, ownerId] + ); + groupId = Number(groupRes.rows[0].id); + + await client.query( + "insert into group_members(group_id, user_id, role) values($1,$2,'GROUP_OWNER')", + [groupId, ownerId] + ); + + await setGroupSettings({ userId: ownerId, groupId, allowMemberTagManage: false, joinPolicy: "NOT_ACCEPTING" }); + await assert.rejects( + () => joinGroup(memberId!, inviteCode), + { message: "JOIN_NOT_ACCEPTING" } + ); + + await setGroupSettings({ userId: ownerId, groupId, allowMemberTagManage: false, joinPolicy: "APPROVAL_REQUIRED" }); + await assert.rejects( + () => joinGroup(memberId!, inviteCode), + { message: "JOIN_PENDING" } + ); + const pendingRes = await client.query( + "select status from group_join_requests where group_id=$1 and user_id=$2", + [groupId, memberId] + ); + assert.equal(pendingRes.rows[0]?.status, "PENDING"); + + await client.query("delete from group_join_requests where group_id=$1 and user_id=$2", [groupId, memberId]); + + await setGroupSettings({ userId: ownerId, groupId, allowMemberTagManage: false, joinPolicy: "AUTO_ACCEPT" }); + const group = await joinGroup(memberId!, inviteCode); + assert.equal(Number(group.id), groupId); + + const memberRows = await client.query( + "select role from group_members where group_id=$1 and user_id=$2", + [groupId, memberId] + ); + assert.equal(memberRows.rows[0]?.role, "MEMBER"); + } finally { + await cleanupTestData(client, { userIds: [ownerId, memberId], groupId }); + client.release(); + } +}); + +test("ownership transfer updates roles", async t => { + if (!hasDb) { + t.skip("DATABASE_URL not set"); + return; + } + + if (envLoaded.error) t.diagnostic(String(envLoaded.error)); + + const pool = getPool(); + const client = await pool.connect(); + let ownerId: number | null = null; + let memberId: number | null = null; + let groupId: number | null = null; + try { + const ownerRes = await client.query( + "insert into users(email, password_hash) values($1,$2) returning id", + [`owner2_${Date.now()}@example.com`, "hash"] + ); + ownerId = Number(ownerRes.rows[0].id); + + const memberRes = await client.query( + "insert into users(email, password_hash) values($1,$2) returning id", + [`member2_${Date.now()}@example.com`, "hash"] + ); + memberId = Number(memberRes.rows[0].id); + + const groupRes = await client.query( + "insert into groups(name, invite_code, created_by) values($1,$2,$3) returning id", + ["Ownership Group", uniqueInviteCode("O"), ownerId] + ); + groupId = Number(groupRes.rows[0].id); + + await client.query( + "insert into group_members(group_id, user_id, role) values($1,$2,'GROUP_OWNER')", + [groupId, ownerId] + ); + await client.query( + "insert into group_members(group_id, user_id, role) values($1,$2,'MEMBER')", + [groupId, memberId] + ); + + await transferOwnership({ + actorUserId: ownerId, + groupId, + newOwnerUserId: memberId, + requestId: `req_${Date.now()}` + }); + + const roles = await client.query( + "select user_id, role from group_members where group_id=$1", + [groupId] + ); + const roleMap = new Map(roles.rows.map(row => [Number(row.user_id), row.role])); + assert.equal(roleMap.get(ownerId), "GROUP_ADMIN"); + assert.equal(roleMap.get(memberId), "GROUP_OWNER"); + } finally { + await cleanupTestData(client, { userIds: [ownerId, memberId], groupId }); + client.release(); + } +}); diff --git a/apps/web/__tests__/group-settings.test.ts b/apps/web/__tests__/group-settings.test.ts new file mode 100644 index 0000000..16489dd --- /dev/null +++ b/apps/web/__tests__/group-settings.test.ts @@ -0,0 +1,118 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import dotenv from "dotenv"; +import getPool from "../lib/server/db"; +import { getGroupSettings, setGroupSettings } from "../lib/server/group-settings"; +import { cleanupTestData, uniqueInviteCode } from "./test-helpers"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const envLoaded = dotenv.config({ path: path.resolve(__dirname, "../../../.env") }); + +const hasDb = Boolean(process.env.DATABASE_URL); + +test("group settings update and read", async t => { + if (!hasDb) { + t.skip("DATABASE_URL not set"); + return; + } + + if (envLoaded.error) t.diagnostic(String(envLoaded.error)); + + const pool = getPool(); + const client = await pool.connect(); + let ownerId: number | null = null; + let groupId: number | null = null; + try { + const ownerRes = await client.query( + "insert into users(email, password_hash) values($1,$2) returning id", + [`settings_owner_${Date.now()}@example.com`, "hash"] + ); + ownerId = Number(ownerRes.rows[0].id); + + const groupRes = await client.query( + "insert into groups(name, invite_code, created_by) values($1,$2,$3) returning id", + ["Settings Group", uniqueInviteCode("S"), ownerId] + ); + groupId = Number(groupRes.rows[0].id); + + await client.query( + "insert into group_members(group_id, user_id, role) values($1,$2,'GROUP_OWNER')", + [groupId, ownerId] + ); + + const initial = await getGroupSettings(groupId); + assert.equal(initial.allowMemberTagManage, false); + assert.equal(initial.joinPolicy, "NOT_ACCEPTING"); + + await setGroupSettings({ userId: ownerId, groupId, allowMemberTagManage: true, joinPolicy: "AUTO_ACCEPT" }); + const updated = await getGroupSettings(groupId); + assert.equal(updated.allowMemberTagManage, true); + assert.equal(updated.joinPolicy, "AUTO_ACCEPT"); + + await setGroupSettings({ userId: ownerId, groupId, allowMemberTagManage: false, joinPolicy: "APPROVAL_REQUIRED" }); + const updatedAgain = await getGroupSettings(groupId); + assert.equal(updatedAgain.allowMemberTagManage, false); + assert.equal(updatedAgain.joinPolicy, "APPROVAL_REQUIRED"); + } finally { + await cleanupTestData(client, { userIds: [ownerId], groupId }); + client.release(); + } +}); + +test("group settings require admin", async t => { + if (!hasDb) { + t.skip("DATABASE_URL not set"); + return; + } + + if (envLoaded.error) t.diagnostic(String(envLoaded.error)); + + const pool = getPool(); + const client = await pool.connect(); + let ownerId: number | null = null; + let memberId: number | null = null; + let groupId: number | null = null; + try { + const ownerRes = await client.query( + "insert into users(email, password_hash) values($1,$2) returning id", + [`settings_owner_${Date.now()}@example.com`, "hash"] + ); + ownerId = Number(ownerRes.rows[0].id); + + const memberRes = await client.query( + "insert into users(email, password_hash) values($1,$2) returning id", + [`settings_member_${Date.now()}@example.com`, "hash"] + ); + memberId = Number(memberRes.rows[0].id); + + const groupRes = await client.query( + "insert into groups(name, invite_code, created_by) values($1,$2,$3) returning id", + ["Settings Perms", uniqueInviteCode("P"), ownerId] + ); + groupId = Number(groupRes.rows[0].id); + + await client.query( + "insert into group_members(group_id, user_id, role) values($1,$2,'GROUP_OWNER')", + [groupId, ownerId] + ); + await client.query( + "insert into group_members(group_id, user_id, role) values($1,$2,'MEMBER')", + [groupId, memberId] + ); + + await assert.rejects( + () => setGroupSettings({ userId: memberId!, groupId, allowMemberTagManage: true, joinPolicy: "AUTO_ACCEPT" }), + { message: "FORBIDDEN" } + ); + + const settings = await getGroupSettings(groupId); + assert.equal(settings.allowMemberTagManage, false); + assert.equal(settings.joinPolicy, "NOT_ACCEPTING"); + } finally { + await cleanupTestData(client, { userIds: [ownerId, memberId], groupId }); + client.release(); + } +}); diff --git a/apps/web/__tests__/groups.test.ts b/apps/web/__tests__/groups.test.ts new file mode 100644 index 0000000..bfe819a --- /dev/null +++ b/apps/web/__tests__/groups.test.ts @@ -0,0 +1,109 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import dotenv from "dotenv"; +import getPool from "../lib/server/db"; +import { setActiveGroupForUser } from "../lib/server/groups"; +import { cleanupTestData, uniqueInviteCode } from "./test-helpers"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const envLoaded = dotenv.config({ path: path.resolve(__dirname, "../../../.env") }); + +const hasDb = Boolean(process.env.DATABASE_URL); + +test("create group inserts membership", async t => { + if (!hasDb) { + t.skip("DATABASE_URL not set"); + return; + } + + if (envLoaded.error) t.diagnostic(String(envLoaded.error)); + + const pool = getPool(); + const client = await pool.connect(); + let userId: number | null = null; + let groupId: number | null = null; + try { + const userRes = await client.query( + "insert into users(email, password_hash) values($1,$2) returning id", + [`group_${Date.now()}@example.com`, "hash"] + ); + userId = Number(userRes.rows[0].id); + + const groupRes = await client.query( + "insert into groups(name, invite_code, created_by) values($1,$2,$3) returning id", + ["Test Group", uniqueInviteCode("G"), userId] + ); + groupId = groupRes.rows[0].id as number; + + await client.query( + "insert into group_members(group_id, user_id, role) values($1,$2,'GROUP_OWNER')", + [groupId, userId] + ); + + const { rows } = await client.query( + "select role from group_members where group_id=$1 and user_id=$2", + [groupId, userId] + ); + assert.equal(rows[0].role, "GROUP_OWNER"); + } finally { + await cleanupTestData(client, { userIds: [userId], groupId }); + client.release(); + } +}); + +test("setActiveGroupForUser stores active group", async t => { + if (!hasDb) { + t.skip("DATABASE_URL not set"); + return; + } + + if (envLoaded.error) t.diagnostic(String(envLoaded.error)); + + const pool = getPool(); + const client = await pool.connect(); + let userId: number | null = null; + let groupId: number | null = null; + let otherUserId: number | null = null; + try { + const userRes = await client.query( + "insert into users(email, password_hash) values($1,$2) returning id", + [`active_${Date.now()}@example.com`, "hash"] + ); + userId = userRes.rows[0].id as number; + + const otherRes = await client.query( + "insert into users(email, password_hash) values($1,$2) returning id", + [`active_other_${Date.now()}@example.com`, "hash"] + ); + otherUserId = Number(otherRes.rows[0].id); + + const groupRes = await client.query( + "insert into groups(name, invite_code, created_by) values($1,$2,$3) returning id", + ["Active Group", uniqueInviteCode("A"), userId] + ); + groupId = Number(groupRes.rows[0].id); + + await client.query( + "insert into group_members(group_id, user_id, role) values($1,$2,'GROUP_ADMIN')", + [groupId, userId] + ); + + await setActiveGroupForUser(userId, groupId); + const { rows } = await client.query( + "select data->>'activeGroupId' as active_group_id from user_settings where user_id=$1", + [userId] + ); + assert.equal(Number(rows[0]?.active_group_id || 0), groupId); + + await assert.rejects( + () => setActiveGroupForUser(otherUserId!, groupId!), + { message: "FORBIDDEN" } + ); + } finally { + await cleanupTestData(client, { userIds: [otherUserId, userId], groupId }); + client.release(); + } +}); diff --git a/apps/web/__tests__/invite-links.test.ts b/apps/web/__tests__/invite-links.test.ts new file mode 100644 index 0000000..7a8cda4 --- /dev/null +++ b/apps/web/__tests__/invite-links.test.ts @@ -0,0 +1,128 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import dotenv from "dotenv"; +import getPool from "../lib/server/db"; +import { acceptInviteLink, createInviteLink } from "../lib/server/group-invites"; +import { cleanupTestData, uniqueInviteCode } from "./test-helpers"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const envLoaded = dotenv.config({ path: path.resolve(__dirname, "../../../.env") }); + +const hasDb = Boolean(process.env.DATABASE_URL); + +test("invite link auto-accept adds member", async t => { + if (!hasDb) { + t.skip("DATABASE_URL not set"); + return; + } + if (envLoaded.error) t.diagnostic(String(envLoaded.error)); + + const pool = getPool(); + const client = await pool.connect(); + let ownerId: number | null = null; + let memberId: number | null = null; + let groupId: number | null = null; + try { + const ownerRes = await client.query( + "insert into users(email, password_hash) values($1,$2) returning id", + [`invite_owner_${Date.now()}@example.com`, "hash"] + ); + ownerId = Number(ownerRes.rows[0].id); + + const memberRes = await client.query( + "insert into users(email, password_hash) values($1,$2) returning id", + [`invite_member_${Date.now()}@example.com`, "hash"] + ); + memberId = Number(memberRes.rows[0].id); + + const groupRes = await client.query( + "insert into groups(name, invite_code, created_by) values($1,$2,$3) returning id", + ["Invite Group", uniqueInviteCode("I"), ownerId] + ); + groupId = Number(groupRes.rows[0].id); + + await client.query( + "insert into group_members(group_id, user_id, role) values($1,$2,'GROUP_OWNER')", + [groupId, ownerId] + ); + + const link = await createInviteLink({ + userId: ownerId, + groupId, + policy: "AUTO_ACCEPT", + singleUse: false, + expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), + requestId: "test-request" + }); + + const result = await acceptInviteLink({ userId: memberId!, token: link.token, requestId: "test-accept" }); + assert.equal(result.status, "JOINED"); + + const { rowCount } = await client.query( + "select 1 from group_members where group_id=$1 and user_id=$2", + [groupId, memberId] + ); + assert.equal(rowCount, 1); + } finally { + await cleanupTestData(client, { userIds: [ownerId, memberId], groupId }); + client.release(); + } +}); + +test("invite link rejects expired link", async t => { + if (!hasDb) { + t.skip("DATABASE_URL not set"); + return; + } + if (envLoaded.error) t.diagnostic(String(envLoaded.error)); + + const pool = getPool(); + const client = await pool.connect(); + let ownerId: number | null = null; + let memberId: number | null = null; + let groupId: number | null = null; + try { + const ownerRes = await client.query( + "insert into users(email, password_hash) values($1,$2) returning id", + [`invite_owner_${Date.now()}@example.com`, "hash"] + ); + ownerId = Number(ownerRes.rows[0].id); + + const memberRes = await client.query( + "insert into users(email, password_hash) values($1,$2) returning id", + [`invite_member_${Date.now()}@example.com`, "hash"] + ); + memberId = Number(memberRes.rows[0].id); + + const groupRes = await client.query( + "insert into groups(name, invite_code, created_by) values($1,$2,$3) returning id", + ["Invite Group", uniqueInviteCode("E"), ownerId] + ); + groupId = Number(groupRes.rows[0].id); + + await client.query( + "insert into group_members(group_id, user_id, role) values($1,$2,'GROUP_OWNER')", + [groupId, ownerId] + ); + + const link = await createInviteLink({ + userId: ownerId, + groupId, + policy: "AUTO_ACCEPT", + singleUse: false, + expiresAt: new Date(Date.now() - 60 * 1000), + requestId: "test-request" + }); + + await assert.rejects( + () => acceptInviteLink({ userId: memberId!, token: link.token, requestId: "test-accept" }), + { message: "INVITE_EXPIRED" } + ); + } finally { + await cleanupTestData(client, { userIds: [ownerId, memberId], groupId }); + client.release(); + } +}); diff --git a/apps/web/__tests__/recurring-entries.test.ts b/apps/web/__tests__/recurring-entries.test.ts new file mode 100644 index 0000000..4e725fa --- /dev/null +++ b/apps/web/__tests__/recurring-entries.test.ts @@ -0,0 +1,77 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import dotenv from "dotenv"; +import getPool from "../lib/server/db"; +import { createRecurringEntry, deleteRecurringEntry, listRecurringEntries } from "../lib/server/recurring-entries"; +import { ensureTagsForGroup } from "../lib/server/tags"; +import { cleanupTestData, uniqueInviteCode } from "./test-helpers"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const envLoaded = dotenv.config({ path: path.resolve(__dirname, "../../../.env") }); + +const hasDb = Boolean(process.env.DATABASE_URL); + +test("recurring entries list", async t => { + if (!hasDb) { + t.skip("DATABASE_URL not set"); + return; + } + + if (envLoaded.error) t.diagnostic(String(envLoaded.error)); + + const pool = getPool(); + const client = await pool.connect(); + let userId: number | null = null; + let groupId: number | null = null; + try { + const userRes = await client.query( + "insert into users(email, password_hash) values($1,$2) returning id", + [`recurring_${Date.now()}@example.com`, "hash"] + ); + userId = userRes.rows[0].id as number; + + const groupRes = await client.query( + "insert into groups(name, invite_code, created_by) values($1,$2,$3) returning id", + ["Recurring Test", uniqueInviteCode("R"), userId] + ); + groupId = groupRes.rows[0].id as number; + + await client.query( + "insert into group_members(group_id, user_id, role) values($1,$2,'GROUP_ADMIN')", + [groupId, userId] + ); + + await ensureTagsForGroup({ userId, groupId, tags: ["rent"] }); + + await createRecurringEntry({ + groupId, + userId, + entryType: "SPENDING", + amountDollars: 900, + occurredAt: "2026-02-01", + necessity: "NECESSARY", + purchaseType: "Rent", + notes: "Monthly rent", + tags: ["rent"], + isRecurring: true, + frequency: "MONTHLY", + intervalCount: 1, + endCondition: "NEVER", + nextRunAt: "2026-02-01" + }); + + const list = await listRecurringEntries(groupId); + assert.equal(list.length, 1); + assert.equal(list[0].isRecurring, true); + } finally { + if (groupId) { + const list = await listRecurringEntries(groupId); + for (const entry of list) await deleteRecurringEntry({ id: entry.id, groupId }); + } + await cleanupTestData(client, { userIds: [userId], groupId }); + client.release(); + } +}); diff --git a/apps/web/__tests__/run-tests.cjs b/apps/web/__tests__/run-tests.cjs new file mode 100644 index 0000000..ba62e57 --- /dev/null +++ b/apps/web/__tests__/run-tests.cjs @@ -0,0 +1,67 @@ +// __tests__/run-tests.cjs +const fs = require("node:fs"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); +const dotenv = require("dotenv"); + +function walk(dir) { + const out = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) out.push(...walk(full)); + else if (entry.isFile() && entry.name.endsWith(".test.ts")) out.push(full); + } + return out; +} + +const cwd = process.cwd(); +dotenv.config({ path: path.join(cwd, ".env") }); +const testsDir = path.join(cwd, "__tests__"); +const testFiles = fs.existsSync(testsDir) ? walk(testsDir) : []; + +const inferredAllowedDbNames = (() => { + if (process.env.ALLOWED_DB_NAMES) return process.env.ALLOWED_DB_NAMES; + if (!process.env.DATABASE_URL) return undefined; + try { + const url = new URL(process.env.DATABASE_URL); + const name = url.pathname.replace(/^\/+/, ""); + return name ? decodeURIComponent(name) : undefined; + } catch { + return undefined; + } +})(); + +if (testFiles.length === 0) { + console.error("No .test.ts files found under __tests__/"); + process.exit(1); +} + +// Run Node's test runner, with tsx registered so TS can execute. +// Node supports registering tsx via --import=tsx. :contentReference[oaicite:2]{index=2} +const nodeArgs = [ + "--require", + "./__tests__/server-only.cjs", + "--import", + "tsx", + "--test-concurrency=1", + "--test", + // Optional nicer output: + // "--test-reporter=spec", + ...testFiles, +]; + +const env = { + ...process.env, + NODE_ENV: "test", + NODE_PATH: path.join(cwd, "__tests__", "node_modules"), + ...(inferredAllowedDbNames ? { ALLOWED_DB_NAMES: inferredAllowedDbNames } : {}), +}; + +const r = spawnSync(process.execPath, nodeArgs, { + stdio: "inherit", + cwd, + env, + shell: false, +}); + +process.exit(r.status ?? 1); diff --git a/apps/web/__tests__/server-only-loader.mjs b/apps/web/__tests__/server-only-loader.mjs new file mode 100644 index 0000000..c84a4f4 --- /dev/null +++ b/apps/web/__tests__/server-only-loader.mjs @@ -0,0 +1,18 @@ +export async function resolve(specifier, context, defaultResolve) { + if (specifier === "server-only") + return { + url: "data:text/javascript,export default {}", + shortCircuit: true, + }; + return defaultResolve(specifier, context, defaultResolve); +} + +export async function load(url, context, defaultLoad) { + if (url.startsWith("data:text/javascript")) + return { + format: "module", + source: "export default {}", + shortCircuit: true, + }; + return defaultLoad(url, context, defaultLoad); +} diff --git a/apps/web/__tests__/server-only-stub.js b/apps/web/__tests__/server-only-stub.js new file mode 100644 index 0000000..f053ebf --- /dev/null +++ b/apps/web/__tests__/server-only-stub.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/apps/web/__tests__/server-only.cjs b/apps/web/__tests__/server-only.cjs new file mode 100644 index 0000000..1fee304 --- /dev/null +++ b/apps/web/__tests__/server-only.cjs @@ -0,0 +1,17 @@ +const Module = require("module"); +const path = require("path"); +const originalLoad = Module._load; +const originalResolve = Module._resolveFilename; +const stubPath = path.join(__dirname, "server-only-stub.js"); + +Module._resolveFilename = function (request, parent, isMain, options) { + if (request === "server-only" || request.startsWith("server-only/")) + return stubPath; + return originalResolve.call(this, request, parent, isMain, options); +}; + +Module._load = function (request, parent, isMain) { + if (request === "server-only" || request.startsWith("server-only/")) + return {}; + return originalLoad(request, parent, isMain); +}; diff --git a/apps/web/__tests__/spendings.test.ts b/apps/web/__tests__/spendings.test.ts new file mode 100644 index 0000000..890e684 --- /dev/null +++ b/apps/web/__tests__/spendings.test.ts @@ -0,0 +1,98 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import dotenv from "dotenv"; +import getPool from "../lib/server/db"; +import { createEntry, deleteEntry, listEntries, updateEntry } from "../lib/server/entries"; +import { ensureTagsForGroup } from "../lib/server/tags"; +import { cleanupTestData, uniqueInviteCode } from "./test-helpers"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const envLoaded = dotenv.config({ path: path.resolve(__dirname, "../../../.env") }); + +const hasDb = Boolean(process.env.DATABASE_URL); + +test("entries CRUD", async t => { + if (!hasDb) { + t.skip("DATABASE_URL not set"); + return; + } + + if (envLoaded.error) t.diagnostic(String(envLoaded.error)); + + const pool = getPool(); + const client = await pool.connect(); + let userId: number | null = null; + let groupId: number | null = null; + try { + const userRes = await client.query( + "insert into users(email, password_hash) values($1,$2) returning id", + [`entry_${Date.now()}@example.com`, "hash"] + ); + userId = userRes.rows[0].id as number; + + const groupRes = await client.query( + "insert into groups(name, invite_code, created_by) values($1,$2,$3) returning id", + ["Entries Test", uniqueInviteCode("E"), userId] + ); + groupId = groupRes.rows[0].id as number; + + await client.query( + "insert into group_members(group_id, user_id, role) values($1,$2,'GROUP_ADMIN')", + [groupId, userId] + ); + + await ensureTagsForGroup({ userId, groupId, tags: ["groceries", "weekly"] }); + + const entry = await createEntry({ + groupId, + userId, + entryType: "SPENDING", + amountDollars: 12.34, + occurredAt: "2026-02-02", + necessity: "NECESSARY", + purchaseType: "Groceries", + notes: "Test", + tags: ["groceries", "weekly"], + isRecurring: false, + intervalCount: 1 + }); + + const list = await listEntries(groupId); + assert.equal(list.length, 1); + assert.equal(list[0].id, entry.id); + assert.deepEqual(list[0].tags.sort(), ["groceries", "weekly"]); + assert.equal(list[0].entryType, "SPENDING"); + + const updated = await updateEntry({ + id: entry.id, + groupId, + userId, + entryType: "INCOME", + amountDollars: 15, + occurredAt: "2026-02-02", + necessity: "BOTH", + purchaseType: "Groceries", + notes: "Updated", + tags: ["groceries"], + isRecurring: true, + frequency: "MONTHLY", + intervalCount: 1, + endCondition: "NEVER", + nextRunAt: "2026-02-02" + }); + assert.ok(updated); + assert.equal(updated?.amountDollars, 15); + assert.equal(updated?.entryType, "INCOME"); + assert.deepEqual(updated?.tags.sort(), ["groceries"]); + + await deleteEntry({ id: entry.id, groupId }); + const listAfter = await listEntries(groupId); + assert.equal(listAfter.length, 0); + } finally { + await cleanupTestData(client, { userIds: [userId], groupId }); + client.release(); + } +}); diff --git a/apps/web/__tests__/tags.test.ts b/apps/web/__tests__/tags.test.ts new file mode 100644 index 0000000..2b1cf74 --- /dev/null +++ b/apps/web/__tests__/tags.test.ts @@ -0,0 +1,81 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import dotenv from "dotenv"; +import getPool from "../lib/server/db"; +import { deleteTagForGroup, ensureTagsForGroup, listGroupTags } from "../lib/server/tags"; +import { getGroupSettings, setGroupSettings } from "../lib/server/group-settings"; +import { cleanupTestData, uniqueInviteCode } from "./test-helpers"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const envLoaded = dotenv.config({ path: path.resolve(__dirname, "../../../.env") }); + +const hasDb = Boolean(process.env.DATABASE_URL); + +test("group tag permissions and management", async t => { + if (!hasDb) { + t.skip("DATABASE_URL not set"); + return; + } + + if (envLoaded.error) t.diagnostic(String(envLoaded.error)); + + const pool = getPool(); + const client = await pool.connect(); + let adminId: number | null = null; + let memberId: number | null = null; + let groupId: number | null = null; + try { + const adminRes = await client.query( + "insert into users(email, password_hash) values($1,$2) returning id", + [`tags_admin_${Date.now()}@example.com`, "hash"] + ); + adminId = Number(adminRes.rows[0].id); + + const memberRes = await client.query( + "insert into users(email, password_hash) values($1,$2) returning id", + [`tags_member_${Date.now()}@example.com`, "hash"] + ); + memberId = Number(memberRes.rows[0].id); + + const groupRes = await client.query( + "insert into groups(name, invite_code, created_by) values($1,$2,$3) returning id", + ["Tags Group", uniqueInviteCode("T"), adminId] + ); + groupId = Number(groupRes.rows[0].id); + + await client.query( + "insert into group_members(group_id, user_id, role) values($1,$2,'GROUP_OWNER')", + [groupId, adminId] + ); + await client.query( + "insert into group_members(group_id, user_id, role) values($1,$2,'MEMBER')", + [groupId, memberId] + ); + + const settings = await getGroupSettings(groupId); + assert.equal(settings.allowMemberTagManage, false); + + await assert.rejects( + () => ensureTagsForGroup({ userId: memberId!, groupId: groupId!, tags: ["food"] }), + { message: "FORBIDDEN" } + ); + + await setGroupSettings({ userId: adminId, groupId, allowMemberTagManage: true }); + const settingsAfter = await getGroupSettings(groupId); + assert.equal(settingsAfter.allowMemberTagManage, true); + + await ensureTagsForGroup({ userId: memberId!, groupId: groupId!, tags: ["food", "dining"] }); + const tags = await listGroupTags(groupId); + assert.deepEqual(tags, ["dining", "food"]); + + await deleteTagForGroup({ userId: memberId!, groupId: groupId!, name: "food" }); + const afterDelete = await listGroupTags(groupId); + assert.deepEqual(afterDelete, ["dining"]); + } finally { + await cleanupTestData(client, { userIds: [adminId, memberId], groupId }); + client.release(); + } +}); diff --git a/apps/web/__tests__/test-helpers.ts b/apps/web/__tests__/test-helpers.ts new file mode 100644 index 0000000..cf2cce8 --- /dev/null +++ b/apps/web/__tests__/test-helpers.ts @@ -0,0 +1,67 @@ +import type { Pool, PoolClient } from "pg"; + +type CleanupArgs = { + groupId?: number | null; + userIds?: Array; + emails?: Array; +}; + +export function uniqueInviteCode(prefix = "T"): string { + const raw = `${prefix}${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`; + const cleaned = raw.toUpperCase().replace(/[^A-Z0-9]/g, ""); + if (cleaned.length >= 8) return cleaned.slice(0, 8); + return cleaned.padEnd(8, "X"); +} + +export async function cleanupTestData(client: PoolClient, args: CleanupArgs) { + const groupId = args.groupId ?? null; + const userIds = (args.userIds || []).filter((id): id is number => Boolean(id)); + const emails = (args.emails || []).filter((email): email is string => Boolean(email)); + + const safeQuery = async (text: string, params: Array) => { + try { + await client.query(text, params); + } catch (error) { + if (typeof error === "object" && error && "code" in error && error.code === "42P01") return; + throw error; + } + }; + + if (groupId) { + await safeQuery( + "delete from entry_tags where entry_id in (select id from entries where group_id=$1)", + [groupId] + ); + await safeQuery("delete from bucket_tags where bucket_id in (select id from buckets where group_id=$1)", [groupId]); + await safeQuery("delete from buckets where group_id=$1", [groupId]); + await safeQuery("delete from entries where group_id=$1", [groupId]); + await safeQuery("delete from tags where group_id=$1", [groupId]); + await safeQuery("delete from group_audit_log where group_id=$1", [groupId]); + await safeQuery("delete from group_invite_links where group_id=$1", [groupId]); + await safeQuery("delete from group_join_requests where group_id=$1", [groupId]); + await safeQuery("delete from group_settings where group_id=$1", [groupId]); + await safeQuery("delete from group_members where group_id=$1", [groupId]); + await safeQuery("delete from groups where id=$1", [groupId]); + } + + for (const userId of userIds) { + await safeQuery("delete from user_settings where user_id=$1", [userId]); + await safeQuery("delete from sessions where user_id=$1", [userId]); + await safeQuery("delete from users where id=$1", [userId]); + } + + for (const email of emails) { + await safeQuery("delete from sessions where user_id in (select id from users where email=$1)", [email]); + await safeQuery("delete from users where email=$1", [email]); + } +} + +export async function cleanupTestDataFromPool(pool: Pool, args: CleanupArgs) { + const client = await pool.connect(); + try { + await cleanupTestData(client, args); + } finally { + client.release(); + } +} + diff --git a/apps/web/app/api/auth/login/route.ts b/apps/web/app/api/auth/login/route.ts new file mode 100644 index 0000000..03b4895 --- /dev/null +++ b/apps/web/app/api/auth/login/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { getSessionCookieName } from "@/lib/server/auth"; +import { loginUser } from "@/lib/server/auth-service"; +import { toErrorResponse } from "@/lib/server/errors"; + +export async function POST(req: Request) { + const body = await req.json().catch(() => null); + const email = String(body?.email || "").trim().toLowerCase(); + const password = String(body?.password || ""); + const remember = Boolean(body?.remember ?? true); + + if (!email || !password) + return NextResponse.json({ error: { code: "MISSING_CREDENTIALS", message: "Missing credentials" } }, { status: 400 }); + + let user; + let session; + try { + const result = await loginUser({ email, password, remember }); + user = result.user; + session = result.session; + } catch (e) { + const { status, body } = toErrorResponse(e, "POST /api/auth/login"); + return NextResponse.json(body, { status }); + } + const cookieStore = await cookies(); + cookieStore.set(getSessionCookieName(), session.token, { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + maxAge: Math.floor(session.ttlMs / 1000), + path: "/" + }); + + return NextResponse.json({ user }); +} diff --git a/apps/web/app/api/auth/logout/route.ts b/apps/web/app/api/auth/logout/route.ts new file mode 100644 index 0000000..a99475e --- /dev/null +++ b/apps/web/app/api/auth/logout/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { getSessionCookieName } from "@/lib/server/auth"; +import { logoutUser } from "@/lib/server/auth-service"; + +export async function POST() { + const cookieStore = await cookies(); + const token = cookieStore.get(getSessionCookieName())?.value; + if (token) + await logoutUser(token); + cookieStore.set(getSessionCookieName(), "", { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + maxAge: 0, + path: "/" + }); + + return NextResponse.json({ ok: true }); +} diff --git a/apps/web/app/api/auth/me/route.ts b/apps/web/app/api/auth/me/route.ts new file mode 100644 index 0000000..84a6f2e --- /dev/null +++ b/apps/web/app/api/auth/me/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from "next/server"; +import { getSessionUser } from "@/lib/server/session"; + +export async function GET() { + const user = await getSessionUser(); + if (!user) + return NextResponse.json({ error: { code: "UNAUTHORIZED", message: "Unauthorized" } }, { status: 401 }); + return NextResponse.json({ user }); +} diff --git a/apps/web/app/api/auth/register/route.ts b/apps/web/app/api/auth/register/route.ts new file mode 100644 index 0000000..1fd8089 --- /dev/null +++ b/apps/web/app/api/auth/register/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { getSessionCookieName, getSessionTtlMs } from "@/lib/server/auth"; +import { registerUser } from "@/lib/server/auth-service"; +import { toErrorResponse } from "@/lib/server/errors"; + +export async function POST(req: Request) { + const body = await req.json().catch(() => null); + const email = String(body?.email || "").trim().toLowerCase(); + const password = String(body?.password || ""); + const displayName = String(body?.displayName || "").trim(); + + if (!email || !email.includes("@")) + return NextResponse.json({ error: { code: "INVALID_EMAIL", message: "Invalid email" } }, { status: 400 }); + if (password.length < 8) + return NextResponse.json({ error: { code: "PASSWORD_TOO_SHORT", message: "Password too short" } }, { status: 400 }); + + let user; + let session; + try { + const result = await registerUser({ email, password, displayName }); + user = result.user; + session = result.session; + } catch (e) { + const { status, body } = toErrorResponse(e, "POST /api/auth/register"); + return NextResponse.json(body, { status }); + } + const cookieStore = await cookies(); + cookieStore.set(getSessionCookieName(), session.token, { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + maxAge: Math.floor(getSessionTtlMs() / 1000), + path: "/" + }); + + return NextResponse.json({ user }); +} diff --git a/apps/web/app/api/buckets/[id]/route.ts b/apps/web/app/api/buckets/[id]/route.ts new file mode 100644 index 0000000..bd9cb82 --- /dev/null +++ b/apps/web/app/api/buckets/[id]/route.ts @@ -0,0 +1,78 @@ +import { NextResponse } from "next/server"; +import { requireSessionUser } from "@/lib/server/session"; +import { deleteBucket, requireActiveGroup, updateBucket } from "@/lib/server/buckets"; +import { toErrorResponse } from "@/lib/server/errors"; +import { getRequestMeta } from "@/lib/server/request"; + +function parseTags(value: unknown) { + if (Array.isArray(value)) return value.map(tag => String(tag)); + if (typeof value === "string") return value.split(","); + return [] as string[]; +} + +export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) { + const { requestId } = await getRequestMeta(); + try { + const user = await requireSessionUser(); + const groupId = await requireActiveGroup(user.id); + const { id: idParam } = await params; + const id = Number(idParam || 0); + if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 }); + + const body = await req.json().catch(() => null); + const name = String(body?.name || "").trim(); + const description = String(body?.description || "").trim(); + const iconKey = body?.iconKey ? String(body.iconKey) : null; + const budgetLimitDollars = body?.budgetLimitDollars != null ? Number(body.budgetLimitDollars) : null; + const position = body?.position != null ? Number(body.position) : 0; + const tags = parseTags(body?.tags); + const necessity = String(body?.necessity || "BOTH").toUpperCase(); + const windowDays = body?.windowDays != null ? Number(body.windowDays) : 30; + + if (!name) + return NextResponse.json({ requestId, error: { code: "MISSING_NAME", message: "name is required" } }, { status: 400 }); + if (budgetLimitDollars != null && (!Number.isFinite(budgetLimitDollars) || budgetLimitDollars < 0)) + return NextResponse.json({ requestId, error: { code: "INVALID_BUDGET", message: "Invalid budgetLimitDollars" } }, { status: 400 }); + if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity)) + return NextResponse.json({ requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 }); + if (!Number.isFinite(windowDays) || windowDays < 1 || windowDays > 365) + return NextResponse.json({ requestId, error: { code: "INVALID_WINDOW_DAYS", message: "Invalid windowDays" } }, { status: 400 }); + + const bucket = await updateBucket({ + id, + groupId, + userId: user.id, + name, + description: description || undefined, + iconKey, + budgetLimitDollars, + position, + tags, + necessity: necessity as "NECESSARY" | "BOTH" | "UNNECESSARY", + windowDays + }); + if (!bucket) return NextResponse.json({ requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 }); + + return NextResponse.json({ requestId, bucket }); + } catch (e) { + const { status, body } = toErrorResponse(e, "PATCH /api/buckets/[id]", requestId); + return NextResponse.json(body, { status }); + } +} + +export async function DELETE(_: Request, { params }: { params: Promise<{ id: string }> }) { + const { requestId } = await getRequestMeta(); + try { + const user = await requireSessionUser(); + const groupId = await requireActiveGroup(user.id); + const { id: idParam } = await params; + const id = Number(idParam || 0); + if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 }); + + await deleteBucket({ id, groupId }); + return NextResponse.json({ requestId, ok: true }); + } catch (e) { + const { status, body } = toErrorResponse(e, "DELETE /api/buckets/[id]", requestId); + return NextResponse.json(body, { status }); + } +} diff --git a/apps/web/app/api/buckets/route.ts b/apps/web/app/api/buckets/route.ts new file mode 100644 index 0000000..ba25c8c --- /dev/null +++ b/apps/web/app/api/buckets/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from "next/server"; +import { requireSessionUser } from "@/lib/server/session"; +import { createBucket, listBuckets, requireActiveGroup } from "@/lib/server/buckets"; +import { toErrorResponse } from "@/lib/server/errors"; +import { getRequestMeta } from "@/lib/server/request"; + +function parseTags(value: unknown) { + if (Array.isArray(value)) return value.map(tag => String(tag)); + if (typeof value === "string") return value.split(","); + return [] as string[]; +} + +export async function GET() { + const { requestId } = await getRequestMeta(); + try { + const user = await requireSessionUser(); + const groupId = await requireActiveGroup(user.id); + const buckets = await listBuckets(groupId); + return NextResponse.json({ requestId, buckets }); + } catch (e) { + const { status, body } = toErrorResponse(e, "GET /api/buckets", requestId); + return NextResponse.json(body, { status }); + } +} + +export async function POST(req: Request) { + const { requestId } = await getRequestMeta(); + try { + const user = await requireSessionUser(); + const groupId = await requireActiveGroup(user.id); + const body = await req.json().catch(() => null); + const name = String(body?.name || "").trim(); + const description = String(body?.description || "").trim(); + const iconKey = body?.iconKey ? String(body.iconKey) : null; + const budgetLimitDollars = body?.budgetLimitDollars != null ? Number(body.budgetLimitDollars) : null; + const position = body?.position != null ? Number(body.position) : 0; + const tags = parseTags(body?.tags); + const necessity = String(body?.necessity || "BOTH").toUpperCase(); + const windowDays = body?.windowDays != null ? Number(body.windowDays) : 30; + + if (!name) return NextResponse.json({ requestId, error: { code: "MISSING_NAME", message: "name is required" } }, { status: 400 }); + if (budgetLimitDollars != null && (!Number.isFinite(budgetLimitDollars) || budgetLimitDollars < 0)) + return NextResponse.json({ requestId, error: { code: "INVALID_BUDGET", message: "Invalid budgetLimitDollars" } }, { status: 400 }); + if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity)) + return NextResponse.json({ requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 }); + if (!Number.isFinite(windowDays) || windowDays < 1 || windowDays > 365) + return NextResponse.json({ requestId, error: { code: "INVALID_WINDOW_DAYS", message: "Invalid windowDays" } }, { status: 400 }); + + const bucket = await createBucket({ + groupId, + userId: user.id, + name, + description: description || undefined, + iconKey, + budgetLimitDollars, + position, + tags, + necessity: necessity as "NECESSARY" | "BOTH" | "UNNECESSARY", + windowDays + }); + + return NextResponse.json({ requestId, bucket }); + } catch (e) { + const { status, body } = toErrorResponse(e, "POST /api/buckets", requestId); + return NextResponse.json(body, { status }); + } +} diff --git a/apps/web/app/api/entries/[id]/route.ts b/apps/web/app/api/entries/[id]/route.ts new file mode 100644 index 0000000..99f623f --- /dev/null +++ b/apps/web/app/api/entries/[id]/route.ts @@ -0,0 +1,99 @@ +import { NextResponse } from "next/server"; +import { requireSessionUser } from "@/lib/server/session"; +import { requireActiveGroup, updateEntry, deleteEntry } from "@/lib/server/entries"; +import { toErrorResponse } from "@/lib/server/errors"; +import { getRequestMeta } from "@/lib/server/request"; + +function parseTags(value: unknown) { + if (Array.isArray(value)) return value.map(tag => String(tag)); + if (typeof value === "string") return value.split(","); + return [] as string[]; +} + +export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) { + const { requestId } = await getRequestMeta(); + try { + const user = await requireSessionUser(); + const groupId = await requireActiveGroup(user.id); + const { id: idParam } = await params; + const id = Number(idParam || 0); + if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 }); + + const body = await req.json().catch(() => null); + const amountDollars = Number(body?.amountDollars || 0); + const occurredAt = String(body?.occurredAt || ""); + const necessity = String(body?.necessity || ""); + const tags = parseTags(body?.tags); + const purchaseType = String(body?.purchaseType || tags[0] || "General").trim(); + const notes = String(body?.notes || "").trim(); + const entryType = String(body?.entryType || "SPENDING").toUpperCase(); + const isRecurring = Boolean(body?.isRecurring); + const frequency = body?.frequency ? String(body.frequency).toUpperCase() : null; + const intervalCount = Number(body?.intervalCount || 1); + const endCondition = body?.endCondition ? String(body.endCondition).toUpperCase() : null; + const endCount = body?.endCount != null ? Number(body.endCount) : null; + const endDate = body?.endDate ? String(body.endDate) : null; + const nextRunAt = body?.nextRunAt ? String(body.nextRunAt) : (isRecurring ? occurredAt : null); + const bucketId = body?.bucketId != null ? Number(body.bucketId) : null; + + if (!Number.isFinite(amountDollars) || amountDollars <= 0) + return NextResponse.json({ requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 }); + if (!occurredAt) return NextResponse.json({ requestId, error: { code: "MISSING_OCCURRED_AT", message: "occurredAt is required" } }, { status: 400 }); + if (!purchaseType) return NextResponse.json({ requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 }); + if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity)) + return NextResponse.json({ requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 }); + if (!['SPENDING', 'INCOME'].includes(entryType)) + return NextResponse.json({ requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 }); + if (!Number.isFinite(intervalCount) || intervalCount <= 0) + return NextResponse.json({ requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 }); + if (frequency && !['DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY', 'QUARTERLY', 'YEARLY'].includes(frequency)) + return NextResponse.json({ requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 }); + if (endCondition && !['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition)) + return NextResponse.json({ requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 }); + + const entry = await updateEntry({ + id, + groupId, + userId: user.id, + entryType: entryType as "SPENDING" | "INCOME", + amountDollars, + occurredAt, + necessity: necessity as "NECESSARY" | "BOTH" | "UNNECESSARY", + purchaseType, + notes: notes || undefined, + tags, + isRecurring, + frequency: frequency as "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null, + intervalCount, + endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE" | null, + endCount, + endDate, + nextRunAt, + bucketId + }); + + if (!entry) return NextResponse.json({ requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 }); + + return NextResponse.json({ requestId, entry }); + } catch (e) { + const { status, body } = toErrorResponse(e, "PATCH /api/entries/[id]", requestId); + return NextResponse.json(body, { status }); + } +} + +export async function DELETE(_: Request, { params }: { params: Promise<{ id: string }> }) { + const { requestId } = await getRequestMeta(); + try { + const user = await requireSessionUser(); + const groupId = await requireActiveGroup(user.id); + const { id: idParam } = await params; + const id = Number(idParam || 0); + if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 }); + + await deleteEntry({ id, groupId }); + return NextResponse.json({ requestId, ok: true }); + } catch (e) { + const { status, body } = toErrorResponse(e, "DELETE /api/entries/[id]", requestId); + return NextResponse.json(body, { status }); + } +} diff --git a/apps/web/app/api/entries/route.ts b/apps/web/app/api/entries/route.ts new file mode 100644 index 0000000..fbc9efe --- /dev/null +++ b/apps/web/app/api/entries/route.ts @@ -0,0 +1,88 @@ +import { NextResponse } from "next/server"; +import { requireSessionUser } from "@/lib/server/session"; +import { createEntry, listEntries, requireActiveGroup } from "@/lib/server/entries"; +import { toErrorResponse } from "@/lib/server/errors"; +import { getRequestMeta } from "@/lib/server/request"; + +function parseTags(value: unknown) { + if (Array.isArray(value)) return value.map(tag => String(tag)); + if (typeof value === "string") return value.split(","); + return [] as string[]; +} + +export async function GET() { + const { requestId } = await getRequestMeta(); + try { + const user = await requireSessionUser(); + const groupId = await requireActiveGroup(user.id); + const entries = await listEntries(groupId); + return NextResponse.json({ requestId, entries }); + } catch (e) { + const { status, body } = toErrorResponse(e, "GET /api/entries", requestId); + return NextResponse.json(body, { status }); + } +} + +export async function POST(req: Request) { + const { requestId } = await getRequestMeta(); + try { + const user = await requireSessionUser(); + const groupId = await requireActiveGroup(user.id); + const body = await req.json().catch(() => null); + const amountDollars = Number(body?.amountDollars || 0); + const occurredAt = String(body?.occurredAt || ""); + const necessity = String(body?.necessity || ""); + const tags = parseTags(body?.tags); + const purchaseType = String(body?.purchaseType || tags[0] || "General").trim(); + const notes = String(body?.notes || "").trim(); + const entryType = String(body?.entryType || "SPENDING").toUpperCase(); + const isRecurring = Boolean(body?.isRecurring); + const frequency = body?.frequency ? String(body.frequency).toUpperCase() : null; + const intervalCount = Number(body?.intervalCount || 1); + const endCondition = body?.endCondition ? String(body.endCondition).toUpperCase() : null; + const endCount = body?.endCount != null ? Number(body.endCount) : null; + const endDate = body?.endDate ? String(body.endDate) : null; + const nextRunAt = body?.nextRunAt ? String(body.nextRunAt) : (isRecurring ? occurredAt : null); + const bucketId = body?.bucketId != null ? Number(body.bucketId) : null; + + if (!Number.isFinite(amountDollars) || amountDollars <= 0) + return NextResponse.json({ requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 }); + if (!occurredAt) return NextResponse.json({ requestId, error: { code: "MISSING_OCCURRED_AT", message: "occurredAt is required" } }, { status: 400 }); + if (!purchaseType) return NextResponse.json({ requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 }); + if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity)) + return NextResponse.json({ requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 }); + if (!['SPENDING', 'INCOME'].includes(entryType)) + return NextResponse.json({ requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 }); + if (!Number.isFinite(intervalCount) || intervalCount <= 0) + return NextResponse.json({ requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 }); + if (frequency && !['DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY', 'QUARTERLY', 'YEARLY'].includes(frequency)) + return NextResponse.json({ requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 }); + if (endCondition && !['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition)) + return NextResponse.json({ requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 }); + + const entry = await createEntry({ + groupId, + userId: user.id, + entryType: entryType as "SPENDING" | "INCOME", + amountDollars, + occurredAt, + necessity: necessity as "NECESSARY" | "BOTH" | "UNNECESSARY", + purchaseType, + notes: notes || undefined, + tags, + isRecurring, + frequency: frequency as "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null, + intervalCount, + endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE" | null, + endCount, + endDate, + nextRunAt, + bucketId + }); + + return NextResponse.json({ requestId, entry }); + } catch (e) { + const { status, body } = toErrorResponse(e, "POST /api/entries", requestId); + return NextResponse.json(body, { status }); + } +} diff --git a/apps/web/app/api/groups/active/route.ts b/apps/web/app/api/groups/active/route.ts new file mode 100644 index 0000000..6bc9fb9 --- /dev/null +++ b/apps/web/app/api/groups/active/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server"; +import { requireSessionUser } from "@/lib/server/session"; +import { getActiveGroupId, listGroups, setActiveGroupForUser } from "@/lib/server/groups"; +import { toErrorResponse } from "@/lib/server/errors"; +import { getRequestMeta } from "@/lib/server/request"; + +export async function GET() { + const { requestId } = await getRequestMeta(); + try { + const user = await requireSessionUser(); + const groups = await listGroups(user.id); + const activeGroupId = await getActiveGroupId(user.id); + const active = groups.find(group => Number(group.id) === activeGroupId) || null; + return NextResponse.json({ requestId, active }); + } catch (e) { + const { status, body } = toErrorResponse(e, "GET /api/groups/active", requestId); + return NextResponse.json(body, { status }); + } +} + +export async function POST(req: Request) { + const { requestId } = await getRequestMeta(); + try { + const user = await requireSessionUser(); + const body = await req.json().catch(() => null); + const groupId = Number(body?.groupId || 0); + if (!groupId) return NextResponse.json({ requestId, error: { code: "MISSING_GROUP_ID", message: "groupId is required" } }, { status: 400 }); + + await setActiveGroupForUser(user.id, groupId); + return NextResponse.json({ requestId, ok: true }); + } catch (e) { + const { status, body } = toErrorResponse(e, "POST /api/groups/active", requestId); + return NextResponse.json(body, { status }); + } +} diff --git a/apps/web/app/api/groups/audit/route.ts b/apps/web/app/api/groups/audit/route.ts new file mode 100644 index 0000000..371356b --- /dev/null +++ b/apps/web/app/api/groups/audit/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; +import { requireSessionUser } from "@/lib/server/session"; +import { requireActiveGroup } from "@/lib/server/groups"; +import { listGroupAudit } from "@/lib/server/group-audit"; +import { toErrorResponse } from "@/lib/server/errors"; +import { getRequestMeta } from "@/lib/server/request"; + +export async function GET() { + const { requestId } = await getRequestMeta(); + try { + const user = await requireSessionUser(); + const groupId = await requireActiveGroup(user.id); + const events = await listGroupAudit({ userId: user.id, groupId }); + return NextResponse.json({ requestId, events }); + } catch (e) { + const { status, body } = toErrorResponse(e, "GET /api/groups/audit", requestId); + return NextResponse.json(body, { status }); + } +} diff --git a/apps/web/app/api/groups/delete/route.ts b/apps/web/app/api/groups/delete/route.ts new file mode 100644 index 0000000..42252e0 --- /dev/null +++ b/apps/web/app/api/groups/delete/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { requireSessionUser } from "@/lib/server/session"; +import { requireActiveGroup, deleteGroup } from "@/lib/server/groups"; +import { toErrorResponse } from "@/lib/server/errors"; +import { getRequestMeta } from "@/lib/server/request"; + +export async function POST() { + const { requestId, ip, userAgent } = await getRequestMeta(); + try { + const user = await requireSessionUser(); + const groupId = await requireActiveGroup(user.id); + await deleteGroup({ userId: user.id, groupId, requestId, ip, userAgent }); + return NextResponse.json({ requestId, ok: true }); + } catch (e) { + const { status, body } = toErrorResponse(e, "POST /api/groups/delete", requestId); + return NextResponse.json(body, { status }); + } +} diff --git a/apps/web/app/api/groups/invites/delete/route.ts b/apps/web/app/api/groups/invites/delete/route.ts new file mode 100644 index 0000000..3c4cc88 --- /dev/null +++ b/apps/web/app/api/groups/invites/delete/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; +import { requireSessionUser } from "@/lib/server/session"; +import { requireActiveGroup } from "@/lib/server/groups"; +import { deleteInviteLink } from "@/lib/server/group-invites"; +import { toErrorResponse } from "@/lib/server/errors"; +import { getRequestMeta } from "@/lib/server/request"; + +export async function POST(req: Request) { + const { requestId, ip, userAgent } = await getRequestMeta(); + try { + const user = await requireSessionUser(); + const groupId = await requireActiveGroup(user.id); + const body = await req.json().catch(() => null); + const linkId = Number(body?.linkId || 0); + if (!linkId) + return NextResponse.json({ requestId, error: { code: "MISSING_LINK_ID", message: "linkId is required" } }, { status: 400 }); + await deleteInviteLink({ linkId, userId: user.id, groupId, requestId, ip, userAgent }); + return NextResponse.json({ requestId, ok: true }); + } catch (e) { + const { status, body } = toErrorResponse(e, "POST /api/groups/invites/delete", requestId); + return NextResponse.json(body, { status }); + } +} diff --git a/apps/web/app/api/groups/invites/revive/route.ts b/apps/web/app/api/groups/invites/revive/route.ts new file mode 100644 index 0000000..0797a44 --- /dev/null +++ b/apps/web/app/api/groups/invites/revive/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server"; +import { requireSessionUser } from "@/lib/server/session"; +import { requireActiveGroup } from "@/lib/server/groups"; +import { reviveInviteLink } from "@/lib/server/group-invites"; +import { toErrorResponse } from "@/lib/server/errors"; +import { getRequestMeta } from "@/lib/server/request"; + +export async function POST(req: Request) { + const { requestId, ip, userAgent } = await getRequestMeta(); + try { + const user = await requireSessionUser(); + const groupId = await requireActiveGroup(user.id); + const body = await req.json().catch(() => null); + const linkId = Number(body?.linkId || 0); + const ttlDays = Math.min(7, Math.max(1, Number(body?.ttlDays || 0))); + if (!linkId) + return NextResponse.json({ requestId, error: { code: "MISSING_LINK_ID", message: "linkId is required" } }, { status: 400 }); + if (!ttlDays) + return NextResponse.json({ requestId, error: { code: "MISSING_TTL", message: "ttlDays is required" } }, { status: 400 }); + const expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000); + await reviveInviteLink({ userId: user.id, groupId, linkId, expiresAt, requestId, ip, userAgent }); + return NextResponse.json({ requestId, ok: true }); + } catch (e) { + const { status, body } = toErrorResponse(e, "POST /api/groups/invites/revive", requestId); + return NextResponse.json(body, { status }); + } +} diff --git a/apps/web/app/api/groups/invites/revoke/route.ts b/apps/web/app/api/groups/invites/revoke/route.ts new file mode 100644 index 0000000..7abe454 --- /dev/null +++ b/apps/web/app/api/groups/invites/revoke/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; +import { requireSessionUser } from "@/lib/server/session"; +import { requireActiveGroup } from "@/lib/server/groups"; +import { revokeInviteLink } from "@/lib/server/group-invites"; +import { toErrorResponse } from "@/lib/server/errors"; +import { getRequestMeta } from "@/lib/server/request"; + +export async function POST(req: Request) { + const { requestId, ip, userAgent } = await getRequestMeta(); + try { + const user = await requireSessionUser(); + const groupId = await requireActiveGroup(user.id); + const body = await req.json().catch(() => null); + const linkId = Number(body?.linkId || 0); + if (!linkId) + return NextResponse.json({ requestId, error: { code: "MISSING_LINK_ID", message: "linkId is required" } }, { status: 400 }); + await revokeInviteLink({ userId: user.id, groupId, linkId, requestId, ip, userAgent }); + return NextResponse.json({ requestId, ok: true }); + } catch (e) { + const { status, body } = toErrorResponse(e, "POST /api/groups/invites/revoke", requestId); + return NextResponse.json(body, { status }); + } +} diff --git a/apps/web/app/api/groups/invites/route.ts b/apps/web/app/api/groups/invites/route.ts new file mode 100644 index 0000000..cafecd0 --- /dev/null +++ b/apps/web/app/api/groups/invites/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from "next/server"; +import { requireSessionUser } from "@/lib/server/session"; +import { requireActiveGroup } from "@/lib/server/groups"; +import { createInviteLink, listInviteLinks } from "@/lib/server/group-invites"; +import { toErrorResponse } from "@/lib/server/errors"; +import { getRequestMeta } from "@/lib/server/request"; + +export async function GET() { + const { requestId } = await getRequestMeta(); + try { + const user = await requireSessionUser(); + const groupId = await requireActiveGroup(user.id); + const links = await listInviteLinks({ userId: user.id, groupId }); + return NextResponse.json({ requestId, links }); + } catch (e) { + const { status, body } = toErrorResponse(e, "GET /api/groups/invites", requestId); + return NextResponse.json(body, { status }); + } +} + +export async function POST(req: Request) { + const { requestId, ip, userAgent } = await getRequestMeta(); + try { + const user = await requireSessionUser(); + const groupId = await requireActiveGroup(user.id); + const body = await req.json().catch(() => null); + const policy = ["NOT_ACCEPTING", "AUTO_ACCEPT", "APPROVAL_REQUIRED"].includes(String(body?.policy)) + ? (String(body?.policy) as "NOT_ACCEPTING" | "AUTO_ACCEPT" | "APPROVAL_REQUIRED") + : "NOT_ACCEPTING"; + const singleUse = Boolean(body?.singleUse); + const ttlDays = Math.min(7, Math.max(1, Number(body?.ttlDays || 0))); + if (!ttlDays) + return NextResponse.json({ requestId, error: { code: "MISSING_TTL", message: "ttlDays is required" } }, { status: 400 }); + const expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000); + const link = await createInviteLink({ userId: user.id, groupId, policy, singleUse, expiresAt, requestId, ip, userAgent }); + return NextResponse.json({ requestId, link }); + } catch (e) { + const { status, body } = toErrorResponse(e, "POST /api/groups/invites", requestId); + return NextResponse.json(body, { status }); + } +} diff --git a/apps/web/app/api/groups/join/route.ts b/apps/web/app/api/groups/join/route.ts new file mode 100644 index 0000000..736957c --- /dev/null +++ b/apps/web/app/api/groups/join/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; +import { requireSessionUser } from "@/lib/server/session"; +import { joinGroup } from "@/lib/server/groups"; +import { toErrorResponse } from "@/lib/server/errors"; +import { getRequestMeta } from "@/lib/server/request"; + +export async function POST(req: Request) { + const { requestId } = await getRequestMeta(); + try { + const user = await requireSessionUser(); + const body = await req.json().catch(() => null); + const inviteCode = String(body?.inviteCode || "").trim().toUpperCase(); + if (!inviteCode) + return NextResponse.json({ requestId, error: { code: "MISSING_INVITE_CODE", message: "Invite code is required" } }, { status: 400 }); + + const group = await joinGroup(user.id, inviteCode); + return NextResponse.json({ requestId, group }); + } catch (e) { + const { status, body } = toErrorResponse(e, "POST /api/groups/join", requestId); + return NextResponse.json(body, { status }); + } +} diff --git a/apps/web/app/api/groups/members/approve/route.ts b/apps/web/app/api/groups/members/approve/route.ts new file mode 100644 index 0000000..00afe00 --- /dev/null +++ b/apps/web/app/api/groups/members/approve/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server"; +import { requireSessionUser } from "@/lib/server/session"; +import { requireActiveGroup } from "@/lib/server/groups"; +import { approveJoinRequest } from "@/lib/server/group-members"; +import { toErrorResponse } from "@/lib/server/errors"; +import { getRequestMeta } from "@/lib/server/request"; + +export async function POST(req: Request) { + const { requestId, ip, userAgent } = await getRequestMeta(); + try { + const user = await requireSessionUser(); + const groupId = await requireActiveGroup(user.id); + const body = await req.json().catch(() => null); + const userId = Number(body?.userId || 0); + const joinRequestId = Number(body?.requestId || 0); + if (!userId) + return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 }); + await approveJoinRequest({ actorUserId: user.id, groupId, userId, requestId, ip, userAgent, requestRowId: joinRequestId || undefined }); + return NextResponse.json({ requestId, ok: true }); + } catch (e) { + const { status, body } = toErrorResponse(e, "POST /api/groups/members/approve", requestId); + return NextResponse.json(body, { status }); + } +} diff --git a/apps/web/app/api/groups/members/demote/route.ts b/apps/web/app/api/groups/members/demote/route.ts new file mode 100644 index 0000000..2b03bf2 --- /dev/null +++ b/apps/web/app/api/groups/members/demote/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; +import { requireSessionUser } from "@/lib/server/session"; +import { requireActiveGroup } from "@/lib/server/groups"; +import { demoteAdmin } from "@/lib/server/group-members"; +import { toErrorResponse } from "@/lib/server/errors"; +import { getRequestMeta } from "@/lib/server/request"; + +export async function POST(req: Request) { + const { requestId, ip, userAgent } = await getRequestMeta(); + try { + const user = await requireSessionUser(); + const groupId = await requireActiveGroup(user.id); + const body = await req.json().catch(() => null); + const userId = Number(body?.userId || 0); + if (!userId) + return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 }); + await demoteAdmin({ actorUserId: user.id, groupId, targetUserId: userId, requestId, ip, userAgent }); + return NextResponse.json({ requestId, ok: true }); + } catch (e) { + const { status, body } = toErrorResponse(e, "POST /api/groups/members/demote", requestId); + return NextResponse.json(body, { status }); + } +} diff --git a/apps/web/app/api/groups/members/deny/route.ts b/apps/web/app/api/groups/members/deny/route.ts new file mode 100644 index 0000000..145a781 --- /dev/null +++ b/apps/web/app/api/groups/members/deny/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; +import { requireSessionUser } from "@/lib/server/session"; +import { requireActiveGroup } from "@/lib/server/groups"; +import { denyJoinRequest } from "@/lib/server/group-members"; +import { toErrorResponse } from "@/lib/server/errors"; +import { getRequestMeta } from "@/lib/server/request"; + +export async function POST(req: Request) { + const { requestId, ip, userAgent } = await getRequestMeta(); + try { + const user = await requireSessionUser(); + const groupId = await requireActiveGroup(user.id); + const body = await req.json().catch(() => null); + const userId = Number(body?.userId || 0); + if (!userId) + return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 }); + await denyJoinRequest({ actorUserId: user.id, groupId, userId, requestId, ip, userAgent }); + return NextResponse.json({ requestId, ok: true }); + } catch (e) { + const { status, body } = toErrorResponse(e, "POST /api/groups/members/deny", requestId); + return NextResponse.json(body, { status }); + } +} diff --git a/apps/web/app/api/groups/members/kick/route.ts b/apps/web/app/api/groups/members/kick/route.ts new file mode 100644 index 0000000..3d60c5d --- /dev/null +++ b/apps/web/app/api/groups/members/kick/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; +import { requireSessionUser } from "@/lib/server/session"; +import { requireActiveGroup } from "@/lib/server/groups"; +import { kickMember } from "@/lib/server/group-members"; +import { toErrorResponse } from "@/lib/server/errors"; +import { getRequestMeta } from "@/lib/server/request"; + +export async function POST(req: Request) { + const { requestId, ip, userAgent } = await getRequestMeta(); + try { + const user = await requireSessionUser(); + const groupId = await requireActiveGroup(user.id); + const body = await req.json().catch(() => null); + const userId = Number(body?.userId || 0); + if (!userId) + return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 }); + await kickMember({ actorUserId: user.id, groupId, targetUserId: userId, requestId, ip, userAgent }); + return NextResponse.json({ requestId, ok: true }); + } catch (e) { + const { status, body } = toErrorResponse(e, "POST /api/groups/members/kick", requestId); + return NextResponse.json(body, { status }); + } +} diff --git a/apps/web/app/api/groups/members/leave/route.ts b/apps/web/app/api/groups/members/leave/route.ts new file mode 100644 index 0000000..99a0cc4 --- /dev/null +++ b/apps/web/app/api/groups/members/leave/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; +import { requireSessionUser } from "@/lib/server/session"; +import { requireActiveGroup } from "@/lib/server/groups"; +import { leaveGroup } from "@/lib/server/group-members"; +import { toErrorResponse } from "@/lib/server/errors"; +import { getRequestMeta } from "@/lib/server/request"; + +export async function POST() { + const { requestId, ip, userAgent } = await getRequestMeta(); + try { + const user = await requireSessionUser(); + const groupId = await requireActiveGroup(user.id); + await leaveGroup({ userId: user.id, groupId, requestId, ip, userAgent }); + return NextResponse.json({ requestId, ok: true }); + } catch (e) { + const { status, body } = toErrorResponse(e, "POST /api/groups/members/leave", requestId); + return NextResponse.json(body, { status }); + } +} diff --git a/apps/web/app/api/groups/members/promote/route.ts b/apps/web/app/api/groups/members/promote/route.ts new file mode 100644 index 0000000..937b638 --- /dev/null +++ b/apps/web/app/api/groups/members/promote/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; +import { requireSessionUser } from "@/lib/server/session"; +import { requireActiveGroup } from "@/lib/server/groups"; +import { promoteToAdmin } from "@/lib/server/group-members"; +import { toErrorResponse } from "@/lib/server/errors"; +import { getRequestMeta } from "@/lib/server/request"; + +export async function POST(req: Request) { + const { requestId, ip, userAgent } = await getRequestMeta(); + try { + const user = await requireSessionUser(); + const groupId = await requireActiveGroup(user.id); + const body = await req.json().catch(() => null); + const userId = Number(body?.userId || 0); + if (!userId) + return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 }); + await promoteToAdmin({ actorUserId: user.id, groupId, targetUserId: userId, requestId, ip, userAgent }); + return NextResponse.json({ requestId, ok: true }); + } catch (e) { + const { status, body } = toErrorResponse(e, "POST /api/groups/members/promote", requestId); + return NextResponse.json(body, { status }); + } +} diff --git a/apps/web/app/api/groups/members/route.ts b/apps/web/app/api/groups/members/route.ts new file mode 100644 index 0000000..65ad203 --- /dev/null +++ b/apps/web/app/api/groups/members/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; +import { requireSessionUser } from "@/lib/server/session"; +import { requireActiveGroup } from "@/lib/server/groups"; +import { listGroupMembers, listJoinRequests } from "@/lib/server/group-members"; +import { toErrorResponse } from "@/lib/server/errors"; +import { getRequestMeta } from "@/lib/server/request"; + +export async function GET() { + const { requestId } = await getRequestMeta(); + try { + const user = await requireSessionUser(); + const groupId = await requireActiveGroup(user.id); + const members = await listGroupMembers(groupId); + const requests = await listJoinRequests({ userId: user.id, groupId }); + return NextResponse.json({ requestId, members, requests, currentUserId: user.id }); + } catch (e) { + const { status, body } = toErrorResponse(e, "GET /api/groups/members", requestId); + return NextResponse.json(body, { status }); + } +} diff --git a/apps/web/app/api/groups/members/transfer-owner/route.ts b/apps/web/app/api/groups/members/transfer-owner/route.ts new file mode 100644 index 0000000..eee725e --- /dev/null +++ b/apps/web/app/api/groups/members/transfer-owner/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; +import { requireSessionUser } from "@/lib/server/session"; +import { requireActiveGroup } from "@/lib/server/groups"; +import { transferOwnership } from "@/lib/server/group-members"; +import { toErrorResponse } from "@/lib/server/errors"; +import { getRequestMeta } from "@/lib/server/request"; + +export async function POST(req: Request) { + const { requestId, ip, userAgent } = await getRequestMeta(); + try { + const user = await requireSessionUser(); + const groupId = await requireActiveGroup(user.id); + const body = await req.json().catch(() => null); + const userId = Number(body?.userId || 0); + if (!userId) + return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 }); + await transferOwnership({ actorUserId: user.id, groupId, newOwnerUserId: userId, requestId, ip, userAgent }); + return NextResponse.json({ requestId, ok: true }); + } catch (e) { + const { status, body } = toErrorResponse(e, "POST /api/groups/members/transfer-owner", requestId); + return NextResponse.json(body, { status }); + } +} diff --git a/apps/web/app/api/groups/rename/route.ts b/apps/web/app/api/groups/rename/route.ts new file mode 100644 index 0000000..010e47a --- /dev/null +++ b/apps/web/app/api/groups/rename/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; +import { requireSessionUser } from "@/lib/server/session"; +import { requireActiveGroup, renameGroup } from "@/lib/server/groups"; +import { toErrorResponse } from "@/lib/server/errors"; +import { getRequestMeta } from "@/lib/server/request"; + +export async function POST(req: Request) { + const { requestId, ip, userAgent } = await getRequestMeta(); + try { + const user = await requireSessionUser(); + const groupId = await requireActiveGroup(user.id); + const body = await req.json().catch(() => null); + const name = String(body?.name || "").trim(); + if (!name) + return NextResponse.json({ requestId, error: { code: "MISSING_NAME", message: "Name is required" } }, { status: 400 }); + await renameGroup({ userId: user.id, groupId, name, requestId, ip, userAgent }); + return NextResponse.json({ requestId, ok: true }); + } catch (e) { + const { status, body } = toErrorResponse(e, "POST /api/groups/rename", requestId); + return NextResponse.json(body, { status }); + } +} diff --git a/apps/web/app/api/groups/route.ts b/apps/web/app/api/groups/route.ts new file mode 100644 index 0000000..19d33cb --- /dev/null +++ b/apps/web/app/api/groups/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import { requireSessionUser } from "@/lib/server/session"; +import { createGroup, listGroups } from "@/lib/server/groups"; +import { toErrorResponse } from "@/lib/server/errors"; +import { getRequestMeta } from "@/lib/server/request"; + +export async function GET() { + const { requestId } = await getRequestMeta(); + try { + const user = await requireSessionUser(); + const groups = await listGroups(user.id); + return NextResponse.json({ requestId, groups }); + } catch (e) { + const { status, body } = toErrorResponse(e, "GET /api/groups", requestId); + return NextResponse.json(body, { status }); + } +} + +export async function POST(req: Request) { + const { requestId } = await getRequestMeta(); + try { + const user = await requireSessionUser(); + const body = await req.json().catch(() => null); + const name = String(body?.name || "").trim(); + if (!name) return NextResponse.json({ requestId, error: { code: "MISSING_NAME", message: "Name is required" } }, { status: 400 }); + + const group = await createGroup(user.id, name); + return NextResponse.json({ requestId, group }); + } catch (e) { + const { status, body } = toErrorResponse(e, "POST /api/groups", requestId); + return NextResponse.json(body, { status }); + } +} diff --git a/apps/web/app/api/groups/settings/route.ts b/apps/web/app/api/groups/settings/route.ts new file mode 100644 index 0000000..1287f0c --- /dev/null +++ b/apps/web/app/api/groups/settings/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; +import { requireSessionUser } from "@/lib/server/session"; +import { requireActiveGroup } from "@/lib/server/groups"; +import { getGroupSettings, setGroupSettings } from "@/lib/server/group-settings"; +import { toErrorResponse } from "@/lib/server/errors"; +import { getRequestMeta } from "@/lib/server/request"; + +export async function GET() { + const { requestId } = await getRequestMeta(); + try { + const user = await requireSessionUser(); + const groupId = await requireActiveGroup(user.id); + const settings = await getGroupSettings(groupId); + return NextResponse.json({ requestId, settings }); + } catch (e) { + const { status, body } = toErrorResponse(e, "GET /api/groups/settings", requestId); + return NextResponse.json(body, { status }); + } +} + +export async function POST(req: Request) { + const { requestId } = await getRequestMeta(); + try { + const user = await requireSessionUser(); + const groupId = await requireActiveGroup(user.id); + const body = await req.json().catch(() => null); + const allowMemberTagManage = Boolean(body?.allowMemberTagManage); + const joinPolicy = ["NOT_ACCEPTING", "AUTO_ACCEPT", "APPROVAL_REQUIRED"].includes(String(body?.joinPolicy)) + ? (String(body?.joinPolicy) as "NOT_ACCEPTING" | "AUTO_ACCEPT" | "APPROVAL_REQUIRED") + : "NOT_ACCEPTING"; + await setGroupSettings({ userId: user.id, groupId, allowMemberTagManage, joinPolicy }); + const settings = await getGroupSettings(groupId); + return NextResponse.json({ requestId, settings }); + } catch (e) { + const { status, body } = toErrorResponse(e, "POST /api/groups/settings", requestId); + return NextResponse.json(body, { status }); + } +} diff --git a/apps/web/app/api/invite-links/[token]/route.ts b/apps/web/app/api/invite-links/[token]/route.ts new file mode 100644 index 0000000..c585b30 --- /dev/null +++ b/apps/web/app/api/invite-links/[token]/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from "next/server"; +import { getSessionUser, requireSessionUser } from "@/lib/server/session"; +import { apiError, toErrorResponse } from "@/lib/server/errors"; +import { getRequestMeta } from "@/lib/server/request"; +import { acceptInviteLink, getInviteLinkSummaryByToken, getInviteViewerStatus } from "@/lib/server/group-invites"; + +export async function GET(_: Request, context: { params: Promise<{ token: string }> }) { + const { requestId } = await getRequestMeta(); + try { + const { token } = await context.params; + const normalized = String(token || "").trim(); + if (!normalized) apiError("INVITE_NOT_FOUND"); + const link = await getInviteLinkSummaryByToken(normalized); + if (!link) apiError("INVITE_NOT_FOUND", { tokenLast4: normalized.slice(-4) }); + const user = await getSessionUser(); + if (user) { + const viewerStatus = await getInviteViewerStatus({ userId: user.id, groupId: link.groupId }); + if (viewerStatus) + return NextResponse.json({ requestId, link: { ...link, viewerStatus } }); + } + return NextResponse.json({ requestId, link }); + } catch (e) { + const { status, body } = toErrorResponse(e, "GET /api/invite-links/[token]", requestId); + return NextResponse.json(body, { status }); + } +} + +export async function POST(_: Request, context: { params: Promise<{ token: string }> }) { + const { requestId, ip, userAgent } = await getRequestMeta(); + try { + const user = await requireSessionUser(); + const { token } = await context.params; + const normalized = String(token || "").trim(); + if (!normalized) apiError("INVITE_NOT_FOUND"); + const result = await acceptInviteLink({ userId: user.id, token: normalized, requestId, ip, userAgent }); + return NextResponse.json({ requestId, result }); + } catch (e) { + const { status, body } = toErrorResponse(e, "POST /api/invite-links/[token]", requestId); + return NextResponse.json(body, { status }); + } +} diff --git a/apps/web/app/api/recurring-entries/[id]/route.ts b/apps/web/app/api/recurring-entries/[id]/route.ts new file mode 100644 index 0000000..a9905ab --- /dev/null +++ b/apps/web/app/api/recurring-entries/[id]/route.ts @@ -0,0 +1,98 @@ +import { NextResponse } from "next/server"; +import { requireSessionUser } from "@/lib/server/session"; +import { deleteRecurringEntry, requireActiveGroup, updateRecurringEntry } from "@/lib/server/recurring-entries"; +import { toErrorResponse } from "@/lib/server/errors"; +import { getRequestMeta } from "@/lib/server/request"; + +function parseTags(value: unknown) { + if (Array.isArray(value)) return value.map(tag => String(tag)); + if (typeof value === "string") return value.split(","); + return [] as string[]; +} + +export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) { + const { requestId } = await getRequestMeta(); + try { + const user = await requireSessionUser(); + const groupId = await requireActiveGroup(user.id); + const { id: idParam } = await params; + const id = Number(idParam || 0); + if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 }); + + const body = await req.json().catch(() => null); + const amountDollars = Number(body?.amountDollars || 0); + const occurredAt = String(body?.occurredAt || ""); + const necessity = String(body?.necessity || ""); + const tags = parseTags(body?.tags); + const purchaseType = String(body?.purchaseType || tags[0] || "General").trim(); + const notes = String(body?.notes || "").trim(); + const entryType = String(body?.entryType || "SPENDING").toUpperCase(); + const frequency = body?.frequency ? String(body.frequency).toUpperCase() : null; + const intervalCount = Number(body?.intervalCount || 1); + const endCondition = body?.endCondition ? String(body.endCondition).toUpperCase() : null; + const endCount = body?.endCount != null ? Number(body.endCount) : null; + const endDate = body?.endDate ? String(body.endDate) : null; + const nextRunAt = body?.nextRunAt ? String(body.nextRunAt) : occurredAt; + const bucketId = body?.bucketId != null ? Number(body.bucketId) : null; + + if (!Number.isFinite(amountDollars) || amountDollars <= 0) + return NextResponse.json({ requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 }); + if (!occurredAt) return NextResponse.json({ requestId, error: { code: "MISSING_OCCURRED_AT", message: "occurredAt is required" } }, { status: 400 }); + if (!purchaseType) return NextResponse.json({ requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 }); + if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity)) + return NextResponse.json({ requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 }); + if (!['SPENDING', 'INCOME'].includes(entryType)) + return NextResponse.json({ requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 }); + if (!Number.isFinite(intervalCount) || intervalCount <= 0) + return NextResponse.json({ requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 }); + if (frequency && !['DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY', 'QUARTERLY', 'YEARLY'].includes(frequency)) + return NextResponse.json({ requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 }); + if (endCondition && !['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition)) + return NextResponse.json({ requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 }); + + const entry = await updateRecurringEntry({ + id, + groupId, + userId: user.id, + entryType: entryType as "SPENDING" | "INCOME", + amountDollars, + occurredAt, + necessity: necessity as "NECESSARY" | "BOTH" | "UNNECESSARY", + purchaseType, + notes: notes || undefined, + tags, + isRecurring: true, + frequency: frequency as "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null, + intervalCount, + endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE" | null, + endCount, + endDate, + nextRunAt, + bucketId + }); + + if (!entry) return NextResponse.json({ requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 }); + + return NextResponse.json({ requestId, entry }); + } catch (e) { + const { status, body } = toErrorResponse(e, "PATCH /api/recurring-entries/[id]", requestId); + return NextResponse.json(body, { status }); + } +} + +export async function DELETE(_: Request, { params }: { params: Promise<{ id: string }> }) { + const { requestId } = await getRequestMeta(); + try { + const user = await requireSessionUser(); + const groupId = await requireActiveGroup(user.id); + const { id: idParam } = await params; + const id = Number(idParam || 0); + if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 }); + + await deleteRecurringEntry({ id, groupId }); + return NextResponse.json({ requestId, ok: true }); + } catch (e) { + const { status, body } = toErrorResponse(e, "DELETE /api/recurring-entries/[id]", requestId); + return NextResponse.json(body, { status }); + } +} diff --git a/apps/web/app/api/recurring-entries/route.ts b/apps/web/app/api/recurring-entries/route.ts new file mode 100644 index 0000000..80fff1d --- /dev/null +++ b/apps/web/app/api/recurring-entries/route.ts @@ -0,0 +1,87 @@ +import { NextResponse } from "next/server"; +import { requireSessionUser } from "@/lib/server/session"; +import { createRecurringEntry, listRecurringEntries, requireActiveGroup } from "@/lib/server/recurring-entries"; +import { toErrorResponse } from "@/lib/server/errors"; +import { getRequestMeta } from "@/lib/server/request"; + +function parseTags(value: unknown) { + if (Array.isArray(value)) return value.map(tag => String(tag)); + if (typeof value === "string") return value.split(","); + return [] as string[]; +} + +export async function GET() { + const { requestId } = await getRequestMeta(); + try { + const user = await requireSessionUser(); + const groupId = await requireActiveGroup(user.id); + const entries = await listRecurringEntries(groupId); + return NextResponse.json({ requestId, entries }); + } catch (e) { + const { status, body } = toErrorResponse(e, "GET /api/recurring-entries", requestId); + return NextResponse.json(body, { status }); + } +} + +export async function POST(req: Request) { + const { requestId } = await getRequestMeta(); + try { + const user = await requireSessionUser(); + const groupId = await requireActiveGroup(user.id); + const body = await req.json().catch(() => null); + const amountDollars = Number(body?.amountDollars || 0); + const occurredAt = String(body?.occurredAt || ""); + const necessity = String(body?.necessity || ""); + const tags = parseTags(body?.tags); + const purchaseType = String(body?.purchaseType || tags[0] || "General").trim(); + const notes = String(body?.notes || "").trim(); + const entryType = String(body?.entryType || "SPENDING").toUpperCase(); + const frequency = body?.frequency ? String(body.frequency).toUpperCase() : null; + const intervalCount = Number(body?.intervalCount || 1); + const endCondition = body?.endCondition ? String(body.endCondition).toUpperCase() : null; + const endCount = body?.endCount != null ? Number(body.endCount) : null; + const endDate = body?.endDate ? String(body.endDate) : null; + const nextRunAt = body?.nextRunAt ? String(body.nextRunAt) : occurredAt; + const bucketId = body?.bucketId != null ? Number(body.bucketId) : null; + + if (!Number.isFinite(amountDollars) || amountDollars <= 0) + return NextResponse.json({ requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 }); + if (!occurredAt) return NextResponse.json({ requestId, error: { code: "MISSING_OCCURRED_AT", message: "occurredAt is required" } }, { status: 400 }); + if (!purchaseType) return NextResponse.json({ requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 }); + if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity)) + return NextResponse.json({ requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 }); + if (!['SPENDING', 'INCOME'].includes(entryType)) + return NextResponse.json({ requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 }); + if (!Number.isFinite(intervalCount) || intervalCount <= 0) + return NextResponse.json({ requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 }); + if (frequency && !['DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY', 'QUARTERLY', 'YEARLY'].includes(frequency)) + return NextResponse.json({ requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 }); + if (endCondition && !['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition)) + return NextResponse.json({ requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 }); + + const entry = await createRecurringEntry({ + groupId, + userId: user.id, + entryType: entryType as "SPENDING" | "INCOME", + amountDollars, + occurredAt, + necessity: necessity as "NECESSARY" | "BOTH" | "UNNECESSARY", + purchaseType, + notes: notes || undefined, + tags, + isRecurring: true, + frequency: frequency as "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null, + intervalCount, + endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE" | null, + endCount, + endDate, + nextRunAt, + bucketId + }); + + return NextResponse.json({ requestId, entry }); + } catch (e) { + const { status, body } = toErrorResponse(e, "POST /api/recurring-entries", requestId); + return NextResponse.json(body, { status }); + } +} diff --git a/apps/web/app/api/tags/[name]/route.ts b/apps/web/app/api/tags/[name]/route.ts new file mode 100644 index 0000000..6076c41 --- /dev/null +++ b/apps/web/app/api/tags/[name]/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; +import { requireSessionUser } from "@/lib/server/session"; +import { requireActiveGroup } from "@/lib/server/entries"; +import { deleteTagForGroup } from "@/lib/server/tags"; +import { toErrorResponse } from "@/lib/server/errors"; +import { getRequestMeta } from "@/lib/server/request"; + +export async function DELETE(_: Request, { params }: { params: Promise<{ name: string }> }) { + const { requestId } = await getRequestMeta(); + try { + const user = await requireSessionUser(); + const groupId = await requireActiveGroup(user.id); + const { name } = await params; + await deleteTagForGroup({ userId: user.id, groupId, name: decodeURIComponent(name) }); + return NextResponse.json({ requestId, ok: true }); + } catch (e) { + const { status, body } = toErrorResponse(e, "DELETE /api/tags/[name]", requestId); + return NextResponse.json(body, { status }); + } +} diff --git a/apps/web/app/api/tags/route.ts b/apps/web/app/api/tags/route.ts new file mode 100644 index 0000000..a67c667 --- /dev/null +++ b/apps/web/app/api/tags/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server"; +import { requireSessionUser } from "@/lib/server/session"; +import { requireActiveGroup } from "@/lib/server/entries"; +import { ensureTagsForGroup, listGroupTags } from "@/lib/server/tags"; +import { toErrorResponse } from "@/lib/server/errors"; +import { getRequestMeta } from "@/lib/server/request"; + +export async function GET() { + const { requestId } = await getRequestMeta(); + try { + const user = await requireSessionUser(); + const groupId = await requireActiveGroup(user.id); + const tags = await listGroupTags(groupId); + return NextResponse.json({ requestId, tags }); + } catch (e) { + const { status, body } = toErrorResponse(e, "GET /api/tags", requestId); + return NextResponse.json(body, { status }); + } +} + +export async function POST(req: Request) { + const { requestId } = await getRequestMeta(); + try { + const user = await requireSessionUser(); + const groupId = await requireActiveGroup(user.id); + const body = await req.json().catch(() => null); + const tags = Array.isArray(body?.tags) ? body.tags.map((tag: unknown) => String(tag)) : []; + await ensureTagsForGroup({ userId: user.id, groupId, tags }); + const list = await listGroupTags(groupId); + return NextResponse.json({ requestId, tags: list }); + } catch (e) { + const { status, body } = toErrorResponse(e, "POST /api/tags", requestId); + return NextResponse.json(body, { status }); + } +} diff --git a/apps/web/app/favicon.ico b/apps/web/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..a3f73952cae5ad7a4c7dcc93da1e1db5f78f69c4 GIT binary patch literal 901 zcmV;01A6=b0096201yxW0000W0B-{T02TlM0EtjeM-2)Z3IG5A4M|8uQUCw|5C8xG z5C{eU001BJ|6u?C12#!SK~#90O_NJ#oMjY-pY#9UWG2ou_crOpwp1m>Qlwadf*Q11 ziV7|y-bzsvtrl+!MM_Z+Q3My_MscB(Ac74DT2K_RDWUS92TwZ@l&cs( z{=Ikay0QJ}D=*%FdXRws^UO=HuMK?miQV&s-n;Gn#cDQ}Po3wo0VHFL7qDp(Cn@M; zDe>8i31X}K^y6tQmo5=k=dQcywg+B2_vG9Cdk2qoEe|aQ*`<#80PwAC-31Mr4%TW> zyK}G>`=C^g<>L=OlIG}};Lt5Y!SPp<4JY4gdiTg9r%nzZ>bw5d-j31V-;~6(wzf8J z@80}<&-y(*QIy~uH0H7locM};+lM)@zXah!ki>fJ>QVQ>Q#)=R87rUodjHO20`CON zRlj%NoxMFH#iG49F~#MXW&RwSW$xl2w{JQ_t$3EGRHItW$?CY6V0PF(d`DK-wQqh} z!3bgY?&n{4`MZ{;buvErmvn91M3g#`$~^6v63#l*Mx^;}{MszWSNznn4}LDrTFP z@Low$OXr48+SZmx;u)M*yt96~dL^it{ED#$dVifcKQXXYi~7 }) { + const user = await getSessionUser(); + if (!user) redirect("/login"); + + const { id } = await params; + const groupId = Number(id || 0); + if (!groupId) redirect("/"); + + return ; +} diff --git a/apps/web/app/groups/settings/page.tsx b/apps/web/app/groups/settings/page.tsx new file mode 100644 index 0000000..3c67326 --- /dev/null +++ b/apps/web/app/groups/settings/page.tsx @@ -0,0 +1,16 @@ +import { redirect } from "next/navigation"; +import { getSessionUser } from "@/lib/server/session"; +import { requireActiveGroup } from "@/lib/server/groups"; +import GroupSettingsContent from "@/components/group-settings-content"; + +export default async function GroupSettingsPage() { + const user = await getSessionUser(); + if (!user) redirect("/login"); + + try { + const groupId = await requireActiveGroup(user.id); + return ; + } catch { + redirect("/"); + } +} diff --git a/apps/web/app/invite/[token]/page.tsx b/apps/web/app/invite/[token]/page.tsx new file mode 100644 index 0000000..62ff1c1 --- /dev/null +++ b/apps/web/app/invite/[token]/page.tsx @@ -0,0 +1,282 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { useAuthContext } from "@/hooks/auth-context"; +import useInviteLink from "@/hooks/use-invite-link"; + +export default function InvitePage() { + const params = useParams(); + const router = useRouter(); + const { checkSession } = useAuthContext(); + const token = useMemo(() => { + const raw = params?.token; + if (typeof raw === "string") return raw; + if (Array.isArray(raw)) return raw[0] || ""; + return ""; + }, [params]); + const { link, loading, accepting, error, result, accept } = useInviteLink(token || null); + const [checkingSession, setCheckingSession] = useState(true); + const [hasSession, setHasSession] = useState(false); + const [redirectOpen, setRedirectOpen] = useState(false); + const [redirectMessage, setRedirectMessage] = useState(""); + const [secondsLeft, setSecondsLeft] = useState(3); + const viewerStatus = link?.viewerStatus || ""; + const isAlreadyMember = viewerStatus === "ALREADY_MEMBER"; + const isPendingViewer = viewerStatus === "PENDING"; + const groupPolicy = link?.groupJoinPolicy || link?.policy || "NOT_ACCEPTING"; + const isDisabled = groupPolicy === "NOT_ACCEPTING"; + const isManual = groupPolicy === "APPROVAL_REQUIRED"; + const isRevoked = Boolean(link?.revokedAt); + const isExpired = link?.expiresAt ? new Date(link.expiresAt).getTime() < Date.now() : false; + const isUsed = Boolean(link?.singleUse && link?.usedAt); + const joinBlockedMessage = isAlreadyMember + ? "You are already a member of this group." + : isPendingViewer + ? "You currently have a pending join request." + : isDisabled + ? "Invites are disabled for this group." + : isRevoked + ? "This invite link has been revoked." + : isExpired + ? "This invite link has expired." + : isUsed + ? "This invite link has already been used." + : ""; + + useEffect(() => { + let active = true; + async function runCheck() { + const user = await checkSession(); + if (!active) return; + setHasSession(Boolean(user)); + setCheckingSession(false); + } + runCheck(); + return () => { + active = false; + }; + }, [checkSession]); + + useEffect(() => { + if (!link || loading) return; + if (isAlreadyMember) { + setRedirectMessage("You are already a member of this group."); + setRedirectOpen(true); + return; + } + if (isPendingViewer) { + setRedirectMessage("Your join request is pending approval."); + setRedirectOpen(true); + return; + } + if (isRevoked) { + setRedirectMessage("This invite link has been revoked."); + setRedirectOpen(true); + } else if (isExpired) { + setRedirectMessage("This invite link has expired."); + setRedirectOpen(true); + } else if (isUsed) { + setRedirectMessage("This invite link has already been used."); + setRedirectOpen(true); + } + }, [link, loading, isAlreadyMember, isPendingViewer, isRevoked, isExpired, isUsed]); + + useEffect(() => { + if (!error) return; + const lower = error.toLowerCase(); + if (lower.includes("revoked")) { + setRedirectMessage("This invite link has been revoked."); + setRedirectOpen(true); + return; + } + if (lower.includes("expired")) { + setRedirectMessage("This invite link has expired."); + setRedirectOpen(true); + return; + } + if (lower.includes("used")) { + setRedirectMessage("This invite link has already been used."); + setRedirectOpen(true); + return; + } + if (lower.includes("not found") || lower.includes("invalid invite")) { + setRedirectMessage("This invite link no longer exists."); + setRedirectOpen(true); + } + }, [error]); + + useEffect(() => { + if (!result) return; + if (result.status === "JOINED") setRedirectMessage("You have joined the group."); + if (result.status === "PENDING") setRedirectMessage("Your join request is pending approval."); + if (result.status === "ALREADY_MEMBER") setRedirectMessage("You are already a member of this group."); + setRedirectOpen(true); + }, [result]); + + useEffect(() => { + if (!redirectOpen) return; + setSecondsLeft(3); + const timer = window.setInterval(() => { + setSecondsLeft(prev => { + if (prev <= 1) { + window.clearInterval(timer); + router.push("/"); + return 0; + } + return prev - 1; + }); + }, 1000); + return () => window.clearInterval(timer); + }, [redirectOpen, router]); + + const actionLabel = result?.status === "JOINED" + ? "Joined" + : result?.status === "PENDING" + ? "Request sent" + : result?.status === "ALREADY_MEMBER" + ? "Already a member" + : "Join group"; + + return ( +
+
+ +

Group invite

+
+ +
+
+
Invite details
+
+ {loading ? ( +
Loading invite…
+ ) : error ? ( +
{error}
+ ) : link ? ( +
+
+
Group
+
{link.groupName}
+
+ {isAlreadyMember ? ( +
+ You are already a member of this group. +
+ ) : isPendingViewer ? ( +
+ Your join request is pending approval. +
+ ) : isDisabled ? ( +
+ Invites are disabled for this group. +
+ ) : isRevoked ? ( +
+ This invite link has been revoked. +
+ ) : isExpired ? ( +
+ This invite link has expired. +
+ ) : isUsed ? ( +
+ This invite link has already been used. +
+ ) : isManual ? ( +
+ Requests to join require approval. +
+ ) : null} +
+ ) : ( +
Invite not found.
+ )} +
+ +
+
+
Join this group
+
+ {checkingSession ? ( +
Checking session…
+ ) : !hasSession ? ( +
+
Sign in to accept this invite.
+
+ + +
+
+ ) : ( +
+ {result ? ( +
+ {result.status === "JOINED" && "You joined the group."} + {result.status === "ALREADY_MEMBER" && "You are already in this group."} + {result.status === "PENDING" && "Join request sent for approval."} +
+ ) : joinBlockedMessage ? ( +
+ {joinBlockedMessage} +
+ ) : null} + {!joinBlockedMessage ? ( + + ) : null} + +
+ )} +
+ + {redirectOpen ? ( +
+
+
Redirecting
+

{redirectMessage}

+

Taking you to entries in {secondsLeft}s.

+
+ +
+
+
+ ) : null} + +
+ ); +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx new file mode 100644 index 0000000..b50af62 --- /dev/null +++ b/apps/web/app/layout.tsx @@ -0,0 +1,21 @@ +import "./globals.css"; +import type { Metadata } from "next"; +import AppProviders from "@/components/app-providers"; +import AppFrame from "@/components/app-frame"; + +export const metadata: Metadata = { + title: "Fiddy", + description: "Budgeting & collaboration" +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + + + ); +} diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx new file mode 100644 index 0000000..b786d27 --- /dev/null +++ b/apps/web/app/login/page.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { useAuthContext } from "@/hooks/auth-context"; + +export default function LoginPage() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [remember, setRemember] = useState(true); + const [error, setError] = useState(""); + const { checkSession, login, loading } = useAuthContext(); + const [checking, setChecking] = useState(true); + + useEffect(() => { + let active = true; + async function runCheckSession() { + try { + const user = await checkSession(); + if (!active) return; + + if (user) { + router.replace("/"); + return; + } + } finally { + if (active) setChecking(false); + } + } + runCheckSession(); + return () => { + active = false; + }; + }, [router, checkSession]); + + function validate() { + if (!email.trim()) return "Email is required"; + if (!email.includes("@")) return "Enter a valid email"; + if (!password) return "Password is required"; + return ""; + } + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + const validationError = validate(); + if (validationError) { + setError(validationError); + return; + } + setError(""); + const result = await login({ email, password, remember }); + + if (!result.ok) { + setError(result.error || "Login failed"); + return; + } + + router.replace("/"); + } + + return ( +
+
+ +

Login

+
+ {checking ? ( +
+ Checking session... +
+ ) : null} +
+ + +
+ + e.preventDefault()} + className="text-[color:var(--color-accent)]" + > + Forgot password? + +
+ {error ?
{error}
: null} + +
+ No account?{" "} + + Register + +
+
+
+ ); +} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx new file mode 100644 index 0000000..e648504 --- /dev/null +++ b/apps/web/app/page.tsx @@ -0,0 +1,10 @@ +import { redirect } from "next/navigation"; +import { getSessionUser } from "@/lib/server/session"; +import DashboardContent from "@/components/dashboard-content"; + +export default async function Page() { + const user = await getSessionUser(); + if (!user) redirect("/login"); + + return ; +} diff --git a/apps/web/app/register/page.tsx b/apps/web/app/register/page.tsx new file mode 100644 index 0000000..f7447fd --- /dev/null +++ b/apps/web/app/register/page.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { useAuthContext } from "@/hooks/auth-context"; + +export default function RegisterPage() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [displayName, setDisplayName] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const { register, loading } = useAuthContext(); + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + const result = await register({ email, password, displayName }); + if (!result.ok) { + setError(result.error || "Registration failed"); + return; + } + router.replace("/"); + } + + return ( +
+
+ +

Register

+
+
+ + + + {error ?
{error}
: null} + +
+ Already have an account?{" "} + + Login + +
+
+
+ ); +} diff --git a/apps/web/components/app-frame.tsx b/apps/web/components/app-frame.tsx new file mode 100644 index 0000000..34b6e12 --- /dev/null +++ b/apps/web/components/app-frame.tsx @@ -0,0 +1,26 @@ +"use client"; + +import type React from "react"; +import { usePathname } from "next/navigation"; +import Navbar from "@/components/navbar"; + +const NO_NAVBAR_PATHS = new Set(["/login", "/register"]); +const NO_NAVBAR_PREFIXES = ["/invite"]; + +type AppFrameProps = { + children: React.ReactNode; +}; + +export default function AppFrame({ children }: AppFrameProps) { + const pathname = usePathname(); + const hideNavbar = pathname + ? NO_NAVBAR_PATHS.has(pathname) || NO_NAVBAR_PREFIXES.some(prefix => pathname.startsWith(prefix)) + : false; + + return ( + <> + {hideNavbar ? null : } +
{children}
+ + ); +} diff --git a/apps/web/components/app-providers.tsx b/apps/web/components/app-providers.tsx new file mode 100644 index 0000000..099019e --- /dev/null +++ b/apps/web/components/app-providers.tsx @@ -0,0 +1,20 @@ +"use client"; + +import type React from "react"; +import { AuthProvider } from "@/hooks/auth-context"; +import { GroupsProvider } from "@/hooks/groups-context"; +import { NotificationsProvider } from "@/hooks/notifications-context"; + +type AppProvidersProps = { + children: React.ReactNode; +}; + +export default function AppProviders({ children }: AppProvidersProps) { + return ( + + + {children} + + + ); +} diff --git a/apps/web/components/bucket-card.tsx b/apps/web/components/bucket-card.tsx new file mode 100644 index 0000000..6b13b2a --- /dev/null +++ b/apps/web/components/bucket-card.tsx @@ -0,0 +1,151 @@ +import { Bucket } from "@/lib/server/buckets"; +import React from "react"; + + +type BucketCardProps = { + bucket: Bucket; + + icon?: string | null; + + isExpanded: boolean; + toggleExpanded: (bucketId: number) => void; + + isMenuOpen: boolean; + setMenuOpenId: React.Dispatch>; + + setConfirmDeleteId: (bucketId: number) => void; + openEdit: (bucketId: number) => void; + + limit: number; + + usageLabel: string; + renderUsageBar: (bucket: Bucket) => React.ReactNode; +}; + +export function BucketCard({ + bucket, + icon, + isExpanded, + toggleExpanded, + isMenuOpen, + setMenuOpenId, + setConfirmDeleteId, + openEdit, + limit, + usageLabel, + renderUsageBar, +}: BucketCardProps) { + return ( +
toggleExpanded(bucket.id)} + > +
+
+
+ {icon || "🚫"} +
+ +
+
{bucket.name}
+ {bucket.description ? ( +
+ {bucket.description} +
+ ) : null} +
+
+ +
+ + + {isMenuOpen ? ( +
+ + + +
+ ) : null} +
+
+ + {limit > 0 ? ( + <> + {renderUsageBar(bucket)} + {isExpanded ? ( +
+
{usageLabel}
+
+ {bucket.tags?.length ? ( + bucket.tags.map((tag) => ( + + #{tag} + + )) + ) : ( + No tags + )} +
+
+ ) : null} + + ) : isExpanded ? ( +
+ {bucket.tags?.length ? ( + bucket.tags.map((tag) => ( + + #{tag} + + )) + ) : ( + No tags + )} +
+ ) : null} +
+ ); +} + +export default React.memo(BucketCard, (prev, next) => ( + prev.bucket === next.bucket + && prev.icon === next.icon + && prev.isExpanded === next.isExpanded + && prev.isMenuOpen === next.isMenuOpen + && prev.limit === next.limit + && prev.usageLabel === next.usageLabel +)); diff --git a/apps/web/components/buckets-panel.tsx b/apps/web/components/buckets-panel.tsx new file mode 100644 index 0000000..303c571 --- /dev/null +++ b/apps/web/components/buckets-panel.tsx @@ -0,0 +1,230 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useGroupsContext } from "@/hooks/groups-context"; +import useBuckets from "@/hooks/use-buckets"; +import useTags from "@/hooks/use-tags"; +import NewBucketModal from "@/components/new-bucket-modal"; +import ConfirmSlideModal from "@/components/confirm-slide-modal"; +import { bucketIcons } from "@/lib/shared/bucket-icons"; +import BucketCard from "./bucket-card"; +import { useEntryMutation } from "@/hooks/entry-mutation-context"; + +export default function BucketsPanel() { + const { activeGroupId } = useGroupsContext(); + const { buckets, loading, error, createBucket, updateBucket, deleteBucket, reload } = useBuckets(activeGroupId); + const { mutationVersion } = useEntryMutation(); + const { tags: tagSuggestions } = useTags(activeGroupId); + const [modalOpen, setModalOpen] = useState(false); + const [editId, setEditId] = useState(null); + const [confirmDeleteId, setConfirmDeleteId] = useState(null); + const [menuOpenId, setMenuOpenId] = useState(null); + const [expandedIds, setExpandedIds] = useState([]); + const [form, setForm] = useState({ + name: "", + description: "", + iconKey: "none", + budgetLimitDollars: "", + tags: [] as string[], + necessity: "BOTH", + windowDays: "30" + }); + + const iconMap = useMemo(() => new Map(bucketIcons.map(item => [item.key, item.icon])), []); + const orderedBuckets = useMemo(() => [...buckets].sort((a, b) => a.position - b.position || a.name.localeCompare(b.name)), [buckets]); + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (!menuOpenId) return; + const target = event.target as HTMLElement | null; + if (!target) return; + if (target.closest("[data-bucket-menu]") || target.closest("[data-bucket-menu-button]")) return; + setMenuOpenId(null); + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [menuOpenId]); + + useEffect(() => { + if (!activeGroupId) return; + if (mutationVersion === 0) return; + reload(); + }, [mutationVersion, activeGroupId, reload]); + + function resetForm() { + setForm({ name: "", description: "", iconKey: "none", budgetLimitDollars: "", tags: [], necessity: "BOTH", windowDays: "30" }); + setEditId(null); + } + + function openCreate() { + resetForm(); + setModalOpen(true); + } + + function openEdit(bucketId: number) { + const bucket = buckets.find(item => item.id === bucketId); + if (!bucket) return; + setEditId(bucketId); + setForm({ + name: bucket.name, + description: bucket.description || "", + iconKey: bucket.iconKey || "none", + budgetLimitDollars: bucket.budgetLimitDollars != null ? String(bucket.budgetLimitDollars) : "", + tags: bucket.tags || [], + necessity: bucket.necessity, + windowDays: bucket.windowDays ? String(bucket.windowDays) : "30" + }); + setModalOpen(true); + setMenuOpenId(null); + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const budget = form.budgetLimitDollars ? Number(form.budgetLimitDollars) : null; + const windowDays = form.windowDays ? Number(form.windowDays) : 30; + if (!form.name.trim()) return; + const iconKey = form.iconKey && form.iconKey !== "none" ? form.iconKey : null; + if (!Number.isFinite(windowDays) || windowDays < 1 || windowDays > 365) return; + + if (editId) { + const ok = await updateBucket({ + id: editId, + name: form.name.trim(), + description: form.description.trim() || undefined, + iconKey, + budgetLimitDollars: budget, + tags: form.tags, + necessity: form.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY", + windowDays + }); + if (ok) setModalOpen(false); + } else { + const ok = await createBucket({ + name: form.name.trim(), + description: form.description.trim() || undefined, + iconKey, + budgetLimitDollars: budget, + tags: form.tags, + necessity: form.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY", + windowDays + }); + if (ok) setModalOpen(false); + } + } + + function toggleExpanded(bucketId: number) { + setExpandedIds(prev => prev.includes(bucketId) + ? prev.filter(id => id !== bucketId) + : [...prev, bucketId]); + } + + function budgetUsage(bucket: typeof buckets[number]) { + const limit = bucket.budgetLimitDollars || 0; + const spent = bucket.totalUsage || 0; + const pct = limit > 0 ? (spent / limit) * 100 : 0; + return { limit, spent, pct }; + } + + function renderUsageBar(bucket: typeof buckets[number]) { + const { limit, spent, pct } = budgetUsage(bucket); + if (!limit) return null; + const clamped = Math.max(0, pct); + const overage = Math.max(0, clamped - 100); + const overColor = clamped > 200 ? "bg-red-500" : "bg-yellow-400"; + const tone = overage > 0 ? overColor : clamped >= 80 ? "bg-yellow-400" : "bg-green-400"; + return ( +
+
+
+
+
+ ); + } + + return ( + <> +
+
+

Buckets

+ +
+
+ + + {!activeGroupId ? ( +
Select a group to view buckets.
+ ) : loading ? ( +
+ {[0, 1].map(row => ( +
+
+
+
+
+
+ ))} +
+ + + ) : orderedBuckets.length ? ( + orderedBuckets.map(bucket => { + const icon = bucket.iconKey ? iconMap.get(bucket.iconKey) : null; + const { limit, spent } = budgetUsage(bucket); + const isExpanded = expandedIds.includes(bucket.id); + const usageLabel = limit ? `$${spent.toFixed(2)} / $${limit.toFixed(2)}` : ""; + return + }) + ) : ( +
No buckets yet.
+ )} +
+
+ setModalOpen(false)} + onSubmit={handleSubmit} + onChange={next => setForm(prev => ({ ...prev, ...next }))} + tagSuggestions={tagSuggestions} + /> + setConfirmDeleteId(null)} + onConfirm={async () => { + if (!confirmDeleteId) return; + const ok = await deleteBucket(confirmDeleteId); + if (ok) setConfirmDeleteId(null); + }} + /> + + ); +} diff --git a/apps/web/components/confirm-slide-modal.tsx b/apps/web/components/confirm-slide-modal.tsx new file mode 100644 index 0000000..4bcbbcf --- /dev/null +++ b/apps/web/components/confirm-slide-modal.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +type ConfirmSlideModalProps = { + isOpen: boolean; + title: string; + description?: string; + confirmLabel?: string; + onClose: () => void; + onConfirm: () => void; +}; + +export default function ConfirmSlideModal({ + isOpen, + title, + description, + confirmLabel = "Confirm", + onClose, + onConfirm +}: ConfirmSlideModalProps) { + const trackRef = useRef(null); + const [dragX, setDragX] = useState(0); + const [dragging, setDragging] = useState(false); + const handleSize = 44; + + function handlePointerDown(event: React.PointerEvent) { + event.preventDefault(); + setDragging(true); + (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); + } + + function handlePointerMove(event: React.PointerEvent) { + if (!dragging) return; + const track = trackRef.current; + if (!track) return; + const rect = track.getBoundingClientRect(); + const next = Math.min(Math.max(0, event.clientX - rect.left - handleSize / 2), rect.width - handleSize); + setDragX(next); + } + + function handlePointerUp(event: React.PointerEvent) { + if (!dragging) return; + setDragging(false); + (event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId); + const track = trackRef.current; + if (!track) return; + const threshold = (track.clientWidth - handleSize) * 0.8; + if (dragX >= threshold) { + setDragX(0); + onConfirm(); + } else { + setDragX(0); + } + } + useEffect(() => { + if (!isOpen) return; + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") onClose(); + } + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + return ( +
+
event.stopPropagation()}> +
{title}
+ {description ?

{description}

: null} +
+
Slide to confirm
+
+
+ +
+
+
+
{confirmLabel}
+ +
+
+
+ ); +} diff --git a/apps/web/components/dashboard-content.tsx b/apps/web/components/dashboard-content.tsx new file mode 100644 index 0000000..4b3264d --- /dev/null +++ b/apps/web/components/dashboard-content.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useGroupsContext } from "@/hooks/groups-context"; +import EntriesPanel from "@/components/entries-panel"; +import BucketsPanel from "@/components/buckets-panel"; +import { EntryMutationProvider } from "@/hooks/entry-mutation-context"; + + +export default function DashboardContent() { + const { groups, activeGroupId, loading } = useGroupsContext(); + const activeGroup = groups.find((group) => group.id === activeGroupId) ?? null; + + if (loading) { + return ( +
+
+
+
+
+
+
+
+
+ ); + } + + if (!activeGroup) { + return ( +
+
+ Create or join a group to add entries. +
+
+ ); + } + + return ( + +
+ + +
+
+ ); +} diff --git a/apps/web/components/entries-panel.tsx b/apps/web/components/entries-panel.tsx new file mode 100644 index 0000000..9cc45de --- /dev/null +++ b/apps/web/components/entries-panel.tsx @@ -0,0 +1,785 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import useEntries from "@/hooks/use-entries"; +import { useGroupsContext } from "@/hooks/groups-context"; +import NewEntryModal from "@/components/new-entry-modal"; +import EntryDetailsModal from "@/components/entry-details-modal"; +import { useNotificationsContext } from "@/hooks/notifications-context"; +import useTags from "@/hooks/use-tags"; +import ConfirmSlideModal from "@/components/confirm-slide-modal"; +import useGroupSettings from "@/hooks/use-group-settings"; +import TagInput from "@/components/tag-input"; +import { emitEntryMutated } from "@/lib/client/entry-mutation-events"; + +export default function EntriesPanel() { + const today = new Date().toISOString().slice(0, 10); + const { groups, activeGroupId } = useGroupsContext(); + const router = useRouter(); + const { entries, loading, error, createEntry, updateEntry, deleteEntry } = useEntries(activeGroupId); + const { notify } = useNotificationsContext(); + const { tags: tagSuggestions } = useTags(activeGroupId); + const { settings } = useGroupSettings(activeGroupId); + const activeGroup = groups.find(group => group.id === activeGroupId) || null; + const canManageTags = Boolean(activeGroup && (activeGroup.role !== "MEMBER" || settings.allowMemberTagManage)); + const emptyTagActionLabel = canManageTags + ? "No Tags Assigned Yet - Click To Assign Tags" + : "No Tags Assigned Yet - Contact Your Group Admin"; + const [form, setForm] = useState({ + amountDollars: "", + occurredAt: today, + necessity: "NECESSARY", + notes: "", + tags: [] as string[], + entryType: "SPENDING" as "SPENDING" | "INCOME", + isRecurring: false, + frequency: "MONTHLY" as "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY", + intervalCount: 1, + endCondition: "NEVER" as "NEVER" | "AFTER_COUNT" | "BY_DATE", + endCount: "", + endDate: "" + }); + const [isModalOpen, setIsModalOpen] = useState(false); + const [detailsOpen, setDetailsOpen] = useState(false); + const [selectedId, setSelectedId] = useState(null); + const [selectedIndex, setSelectedIndex] = useState(null); + const [detailsForm, setDetailsForm] = useState({ + amountDollars: "", + occurredAt: today, + necessity: "NECESSARY", + notes: "", + tags: [] as string[], + entryType: "SPENDING" as "SPENDING" | "INCOME", + isRecurring: false, + frequency: "MONTHLY" as "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY", + intervalCount: 1, + endCondition: "NEVER" as "NEVER" | "AFTER_COUNT" | "BY_DATE", + endCount: "", + endDate: "" + }); + const [detailsOriginal, setDetailsOriginal] = useState(null); + const [removedTags, setRemovedTags] = useState([]); + const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); + const [discardOpen, setDiscardOpen] = useState(false); + const [filterOpen, setFilterOpen] = useState(false); + const [entryTab, setEntryTab] = useState<"SINGLE" | "RECURRING">("SINGLE"); + const emptyFilters = { + amountMin: "", + amountMax: "", + dateFrom: "", + dateTo: "", + necessity: "ANY", + notesQuery: "", + tags: [] as string[], + tagsMode: "ANY" as "ANY" | "ALL" + }; + const [filters, setFilters] = useState(emptyFilters); + const amountInputRef = useRef(null); + const tagsInputRef = useRef(null); + const pendingDiscardRef = useRef< + | { type: "close" } + | { type: "prev" } + | { type: "next" } + | { type: "open"; entry: typeof entries[number]; index: number } + | null + >(null); + const filteredEntries = useMemo(() => { + if (!entries.length) return entries; + const min = filters.amountMin ? Number(filters.amountMin) : null; + const max = filters.amountMax ? Number(filters.amountMax) : null; + const from = filters.dateFrom ? new Date(filters.dateFrom).getTime() : null; + const to = filters.dateTo ? new Date(filters.dateTo).getTime() : null; + const query = filters.notesQuery.trim().toLowerCase(); + const tagsFilter = filters.tags.map(tag => tag.toLowerCase()); + return entries.filter(entry => { + if (min != null && entry.amountDollars < min) return false; + if (max != null && entry.amountDollars > max) return false; + if (from != null) { + const time = new Date(entry.occurredAt).getTime(); + if (!Number.isNaN(from) && time < from) return false; + } + if (to != null) { + const time = new Date(entry.occurredAt).getTime(); + if (!Number.isNaN(to) && time > to + 24 * 60 * 60 * 1000 - 1) return false; + } + if (filters.necessity !== "ANY" && entry.necessity !== filters.necessity) return false; + if (query) { + const notes = (entry.notes || "").toLowerCase(); + if (!notes.includes(query)) return false; + } + if (tagsFilter.length) { + const entryTags = (entry.tags || []).map(tag => tag.toLowerCase()); + if (filters.tagsMode === "ALL") { + if (!tagsFilter.every(tag => entryTags.includes(tag))) return false; + } else if (!tagsFilter.some(tag => entryTags.includes(tag))) return false; + } + return true; + }); + }, [entries, filters]); + const visibleEntries = useMemo(() => filteredEntries.filter(entry => entry.isRecurring === (entryTab === "RECURRING")), [filteredEntries, entryTab]); + const totalEntries = visibleEntries.length; + const activeFilterCount = useMemo(() => { + let count = 0; + if (filters.amountMin) count += 1; + if (filters.amountMax) count += 1; + if (filters.dateFrom) count += 1; + if (filters.dateTo) count += 1; + if (filters.necessity !== "ANY") count += 1; + if (filters.notesQuery.trim()) count += 1; + if (filters.tags.length) count += 1; + return count; + }, [filters]); + + function handleEmptyTagAction() { + if (!activeGroupId || !canManageTags) return; + router.push("/groups/settings"); + } + + useEffect(() => { + if (tagsInputRef.current) + tagsInputRef.current.setCustomValidity(form.tags.length ? "" : "Please fill out this field"); + }, [form.tags.length]); + + useEffect(() => { + if (!filterOpen && !discardOpen) return; + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + if (discardOpen) handleCancelDiscard(); + if (filterOpen) setFilterOpen(false); + } + } + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [discardOpen, filterOpen]); + + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + if (tagsInputRef.current) + tagsInputRef.current.setCustomValidity(form.tags.length ? "" : "Please fill out this field"); + if (!e.currentTarget.reportValidity()) { + if (!form.amountDollars) amountInputRef.current?.focus(); + else if (!form.tags.length) tagsInputRef.current?.focus(); + return; + } + const amountDollars = Number(form.amountDollars || 0); + if (!Number.isFinite(amountDollars) || amountDollars <= 0) { + return; + } + if (!form.occurredAt) { + return; + } + if (!form.tags.length) { + return; + } + + const purchaseType = form.tags.join(", ") || "General"; + + const nextRunAt = form.isRecurring ? form.occurredAt : null; + const createdEntry = await createEntry({ + entryType: form.entryType, + amountDollars, + occurredAt: form.occurredAt, + necessity: form.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY", + purchaseType, + notes: form.notes.trim() || undefined, + tags: form.tags, + isRecurring: form.isRecurring, + frequency: form.isRecurring ? form.frequency : null, + intervalCount: form.intervalCount, + endCondition: form.isRecurring ? form.endCondition : null, + endCount: form.endCondition === "AFTER_COUNT" ? Number(form.endCount || 0) || null : null, + endDate: form.endCondition === "BY_DATE" ? form.endDate || null : null, + nextRunAt + }); + if (createdEntry) { + setForm({ + amountDollars: "", + occurredAt: today, + necessity: "NECESSARY", + notes: "", + tags: [], + entryType: "SPENDING", + isRecurring: false, + frequency: "MONTHLY", + intervalCount: 1, + endCondition: "NEVER", + endCount: "", + endDate: "" + }); + setIsModalOpen(false); + notify({ + title: "Entry added", + message: `${form.tags.join(", ")} · $${amountDollars.toFixed(2)}`, + tone: "success" + }); + emitEntryMutated({ before: null, after: createdEntry }); + } + } + + function setDetailsFromEntry(entry: typeof entries[number], index: number) { + const nextId = Number(entry.id); + if (!Number.isFinite(nextId) || nextId <= 0) { + alert("Invalid entry id"); + return; + } + const nextForm = { + amountDollars: String(entry.amountDollars), + occurredAt: new Date(entry.occurredAt).toISOString().slice(0, 10), + necessity: entry.necessity, + notes: entry.notes || "", + tags: entry.tags || [], + entryType: entry.entryType, + isRecurring: entry.isRecurring, + frequency: entry.frequency || "MONTHLY", + intervalCount: entry.intervalCount || 1, + endCondition: entry.endCondition || "NEVER", + endCount: entry.endCount ? String(entry.endCount) : "", + endDate: entry.endDate || "" + }; + setSelectedId(nextId); + setSelectedIndex(index); + setRemovedTags([]); + setDetailsForm(nextForm); + setDetailsOriginal(nextForm); + setDetailsOpen(true); + } + + function handleOpenDetails(entry: typeof entries[number], index: number) { + requestDiscard({ type: "open", entry, index }); + } + + function normalizeTags(tags: string[]) { + return tags.map(tag => tag.toLowerCase()).sort(); + } + + function handleFilterAddTag(tag: string) { + setFilters(prev => (prev.tags.includes(tag) ? prev : { ...prev, tags: [...prev.tags, tag] })); + } + + function handleFilterToggleTag(tag: string) { + setFilters(prev => ({ ...prev, tags: prev.tags.filter(item => item !== tag) })); + } + + function handleClearFilters() { + setFilters(emptyFilters); + } + function hasDetailsChanges() { + if (!detailsOriginal) return false; + const currentTags = detailsForm.tags.filter(tag => !removedTags.includes(tag)); + const currentTagsKey = normalizeTags(currentTags).join("|"); + const originalTagsKey = normalizeTags(detailsOriginal.tags).join("|"); + return ( + detailsForm.amountDollars !== detailsOriginal.amountDollars || + detailsForm.occurredAt !== detailsOriginal.occurredAt || + detailsForm.necessity !== detailsOriginal.necessity || + detailsForm.notes !== detailsOriginal.notes || + detailsForm.entryType !== detailsOriginal.entryType || + detailsForm.isRecurring !== detailsOriginal.isRecurring || + detailsForm.frequency !== detailsOriginal.frequency || + detailsForm.intervalCount !== detailsOriginal.intervalCount || + detailsForm.endCondition !== detailsOriginal.endCondition || + detailsForm.endCount !== detailsOriginal.endCount || + detailsForm.endDate !== detailsOriginal.endDate || + currentTagsKey !== originalTagsKey + ); + } + + function requestDiscard(action: NonNullable) { + if (!detailsOpen || !hasDetailsChanges()) { + runDiscardAction(action); + return; + } + pendingDiscardRef.current = action; + setDiscardOpen(true); + } + + function runDiscardAction(action: NonNullable) { + if (action.type === "close") { + setDetailsOpen(false); + setDetailsOriginal(null); + setRemovedTags([]); + return; + } + if (action.type === "open") { + setDetailsFromEntry(action.entry, action.index); + return; + } + if (!totalEntries) return; + const current = selectedIndex ?? 0; + if (action.type === "prev") { + const nextIndex = current === 0 ? totalEntries - 1 : current - 1; + setDetailsFromEntry(visibleEntries[nextIndex], nextIndex); + } + if (action.type === "next") { + const nextIndex = current === totalEntries - 1 ? 0 : current + 1; + setDetailsFromEntry(visibleEntries[nextIndex], nextIndex); + } + } + + async function handleUpdate(e: React.FormEvent) { + e.preventDefault(); + if (!selectedId) return; + if (!hasDetailsChanges()) return; + + const amountDollars = Number(detailsForm.amountDollars || 0); + if (!Number.isFinite(amountDollars) || amountDollars <= 0) { + alert("Enter a valid amount"); + return; + } + if (!detailsForm.occurredAt) { + alert("Select a date"); + return; + } + const nextTags = detailsForm.tags.filter(tag => !removedTags.includes(tag)); + if (!nextTags.length) { + alert("Add at least one tag"); + return; + } + + const purchaseType = nextTags.join(", ") || "General"; + + const nextRunAt = detailsForm.isRecurring ? detailsForm.occurredAt : null; + const beforeEntry = entries.find(entry => Number(entry.id) === Number(selectedId)) || null; + const updatedEntry = await updateEntry({ + id: selectedId, + entryType: detailsForm.entryType, + amountDollars, + occurredAt: detailsForm.occurredAt, + necessity: detailsForm.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY", + purchaseType, + notes: detailsForm.notes.trim() || undefined, + tags: nextTags, + isRecurring: detailsForm.isRecurring, + frequency: detailsForm.isRecurring ? detailsForm.frequency : null, + intervalCount: detailsForm.intervalCount, + endCondition: detailsForm.isRecurring ? detailsForm.endCondition : null, + endCount: detailsForm.endCondition === "AFTER_COUNT" ? Number(detailsForm.endCount || 0) || null : null, + endDate: detailsForm.endCondition === "BY_DATE" ? detailsForm.endDate || null : null, + nextRunAt + }); + if (updatedEntry) { + setDetailsOpen(false); + setDetailsOriginal(null); + setRemovedTags([]); + notify({ + title: "Entry updated", + message: `${nextTags.join(", ")} · $${amountDollars.toFixed(2)}` + }); + emitEntryMutated({ before: beforeEntry, after: updatedEntry }); + } + } + + async function handleDelete() { + if (!selectedId || !Number.isFinite(selectedId)) return; + const beforeEntry = entries.find(entry => Number(entry.id) === Number(selectedId)) || null; + const deletedEntry = await deleteEntry(selectedId); + if (deletedEntry || beforeEntry) { + setDetailsOpen(false); + setDetailsOriginal(null); + setRemovedTags([]); + notify({ + title: "Entry deleted", + message: detailsForm.tags.join(", ") || "Entry removed", + tone: "danger" + }); + emitEntryMutated({ before: deletedEntry || beforeEntry, after: null }); + } + } + + function handleToggleTag(tag: string) { + setRemovedTags(prev => prev.includes(tag) ? prev.filter(item => item !== tag) : [...prev, tag]); + } + + function handleAddTag(tag: string) { + setDetailsForm(prev => ({ ...prev, tags: prev.tags.includes(tag) ? prev.tags : [...prev.tags, tag] })); + setRemovedTags(prev => prev.filter(item => item !== tag)); + } + + function handlePrev() { + if (!totalEntries) return; + requestDiscard({ type: "prev" }); + } + + function handleNext() { + if (!totalEntries) return; + requestDiscard({ type: "next" }); + } + + function handleCloseDetails() { + requestDiscard({ type: "close" }); + } + + function handleConfirmDiscard() { + const action = pendingDiscardRef.current; + pendingDiscardRef.current = null; + setDiscardOpen(false); + if (action) runDiscardAction(action); + } + + function handleCancelDiscard() { + pendingDiscardRef.current = null; + setDiscardOpen(false); + } + + function handleRevertDetails() { + if (!detailsOriginal) return; + setDetailsForm(detailsOriginal); + setRemovedTags([]); + } + + return ( + <> +
+
+
+
+

Entries

+
+ + +
+
+
+ + +
+
+
+ {!activeGroupId ? ( +
Select a group to view entries.
+ ) : loading ? ( +
+ {[0, 1, 2].map(row => ( +
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ ) : entries.length ? ( + visibleEntries.length ? ( + visibleEntries.map((entry, index) => { + const tags = entry.tags ?? []; + const mobileTagLimit = 2; + const mobileTags = tags.slice(0, mobileTagLimit); + const extraTagCount = Math.max(tags.length - mobileTagLimit, 0); + + return ( +
handleOpenDetails(entry, index)} + > +
+
${entry.amountDollars.toFixed(2)}
+
+ {new Date(entry.occurredAt).toISOString().slice(0, 10)} · {entry.necessity} +
+
+ {tags.length ? ( + <> +
+ {mobileTags.map(tag => ( + + #{tag} + + ))} + {extraTagCount ? ( + + {extraTagCount} more... + + ) : null} +
+
+ {tags.map(tag => ( + + #{tag} + + ))} +
+ + ) : ( + No tags + )} +
+ ); + }) + ) : ( +
+
No matching entries.
+ {activeFilterCount ? ( + + ) : null} +
+ ) + ) : ( +
No entries yet.
+ )} +
+
+
+ setIsModalOpen(false)} + onSubmit={handleCreate} + onChange={next => setForm(prev => ({ ...prev, ...next }))} + tagSuggestions={tagSuggestions} + emptyTagActionLabel={emptyTagActionLabel} + emptyTagActionDisabled={!canManageTags} + onEmptyTagAction={handleEmptyTagAction} + amountInputRef={amountInputRef} + tagsInputRef={tagsInputRef} + /> + setConfirmDeleteOpen(true)} + onRevert={handleRevertDetails} + onChange={next => setDetailsForm(prev => ({ ...prev, ...next }))} + onAddTag={handleAddTag} + onToggleTag={handleToggleTag} + removedTags={removedTags} + tagSuggestions={tagSuggestions} + emptyTagActionLabel={emptyTagActionLabel} + emptyTagActionDisabled={!canManageTags} + onEmptyTagAction={handleEmptyTagAction} + onPrev={handlePrev} + onNext={handleNext} + loopHintPrev={selectedIndex === 0 && totalEntries > 1 ? "Loop" : ""} + loopHintNext={selectedIndex === totalEntries - 1 && totalEntries > 1 ? "Loop" : ""} + canNavigate={totalEntries > 1} + /> + {filterOpen ? ( +
setFilterOpen(false)}> +
event.stopPropagation()} + onKeyDown={event => { + if (event.key === "Escape") setFilterOpen(false); + }} + role="dialog" + tabIndex={-1} + > +
+

Filter Entries

+
+ +
+
+
+ + +
+
+ {([ + { value: "ANY", label: "Any" }, + { value: "NECESSARY", label: "Necessary" }, + { value: "BOTH", label: "Both" }, + { value: "UNNECESSARY", label: "Unnecessary" } + ] as const).map(option => ( + + ))} +
+
+ +
+
+ + + +
+ } + tags={filters.tags} + suggestions={tagSuggestions} + allowCustom={false} + onToggleTag={handleFilterToggleTag} + onAddTag={handleFilterAddTag} + emptySuggestionLabel={emptyTagActionLabel} + emptySuggestionDisabled={!canManageTags} + onEmptySuggestionClick={handleEmptyTagAction} + /> +
+
+
+ {activeFilterCount ? `${activeFilterCount} filter${activeFilterCount === 1 ? "" : "s"} applied` : "No filters applied"} +
+
+ + +
+
+
+
+ ) : null} + {discardOpen ? ( +
+
{ + if (event.key === "Escape") handleCancelDiscard(); + }} + role="dialog" + tabIndex={-1} + > +
Discard changes?
+

You have unsaved changes. Do you want to discard them?

+
+ + +
+
+
+ ) : null} + setConfirmDeleteOpen(false)} + onConfirm={() => { + setConfirmDeleteOpen(false); + handleDelete(); + }} + /> + + ); +} diff --git a/apps/web/components/entry-details-modal.tsx b/apps/web/components/entry-details-modal.tsx new file mode 100644 index 0000000..feb3a36 --- /dev/null +++ b/apps/web/components/entry-details-modal.tsx @@ -0,0 +1,387 @@ +"use client"; + +import type React from "react"; +import { useEffect, useRef } from "react"; +import TagInput from "@/components/tag-input"; + +export type EntryDetailsForm = { + amountDollars: string; + occurredAt: string; + necessity: string; + notes: string; + tags: string[]; + entryType: "SPENDING" | "INCOME"; + isRecurring: boolean; + frequency: "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY"; + intervalCount: number; + endCondition: "NEVER" | "AFTER_COUNT" | "BY_DATE"; + endCount: string; + endDate: string; +}; + +type EntryDetailsModalProps = { + isOpen: boolean; + form: EntryDetailsForm; + originalForm: EntryDetailsForm | null; + isDirty: boolean; + error: string; + onClose: () => void; + onSubmit: (event: React.FormEvent) => void; + onRequestDelete: () => void; + onRevert: () => void; + onChange: (next: Partial) => void; + onAddTag: (tag: string) => void; + onToggleTag: (tag: string) => void; + removedTags: string[]; + tagSuggestions: string[]; + emptyTagActionLabel?: string; + emptyTagActionDisabled?: boolean; + onEmptyTagAction?: () => void; + onPrev: () => void; + onNext: () => void; + loopHintPrev: string; + loopHintNext: string; + canNavigate: boolean; +}; + +export default function EntryDetailsModal({ + isOpen, + form, + originalForm, + isDirty, + error, + onClose, + onSubmit, + onRequestDelete, + onRevert, + onChange, + onAddTag, + onToggleTag, + removedTags, + tagSuggestions, + emptyTagActionLabel, + emptyTagActionDisabled = false, + onEmptyTagAction, + onPrev, + onNext, + loopHintPrev, + loopHintNext, + canNavigate +}: EntryDetailsModalProps) { + const baseline = originalForm ?? form; + const removedSet = new Set(removedTags.map(tag => tag.toLowerCase())); + const currentTags = form.tags.filter(tag => !removedSet.has(tag.toLowerCase())); + const normalizeTags = (tags: string[]) => tags.map(tag => tag.toLowerCase()).sort().join("|"); + const baselineTags = baseline.tags || []; + const tagsChanged = normalizeTags(currentTags) !== normalizeTags(baselineTags); + const addedTags = currentTags.filter(tag => !baselineTags.some(base => base.toLowerCase() === tag.toLowerCase())); + const amountChanged = form.amountDollars !== baseline.amountDollars; + const dateChanged = form.occurredAt !== baseline.occurredAt; + const necessityChanged = form.necessity !== baseline.necessity; + const notesChanged = form.notes !== baseline.notes; + const changedInputClass = "border-2 border-[color:var(--color-accent)]"; + const formRef = useRef(null); + + const touchStartX = useRef(null); + const touchDeltaX = useRef(0); + + function handleTouchStart(event: React.TouchEvent) { + touchStartX.current = event.touches[0]?.clientX ?? null; + touchDeltaX.current = 0; + } + + function handleTouchMove(event: React.TouchEvent) { + if (touchStartX.current === null) return; + touchDeltaX.current = event.touches[0]?.clientX - touchStartX.current; + } + + function handleTouchEnd() { + if (!canNavigate) return; + const delta = touchDeltaX.current; + touchStartX.current = null; + touchDeltaX.current = 0; + if (Math.abs(delta) < 60) return; + if (delta > 0) onPrev(); + else onNext(); + } + + useEffect(() => { + if (!isOpen) return; + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") onClose(); + } + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + return ( +
+
event.stopPropagation()} + onTouchStart={handleTouchStart} + onTouchMove={handleTouchMove} + onTouchEnd={handleTouchEnd} + > +
+ +

Entry details

+ +
+
{ + if (event.key !== "Enter" || event.defaultPrevented || event.shiftKey) return; + const target = event.target as HTMLElement; + if (target?.tagName === "TEXTAREA") return; + event.preventDefault(); + formRef.current?.requestSubmit(); + }} + className="mt-3 grid gap-3 md:grid-cols-2" + > +
+ +
+ + +
+
+ +
+ onChange({ occurredAt: e.target.value })} + required + /> +
+
+
+ {([ + { value: "NECESSARY", label: "Necessary" }, + { value: "BOTH", label: "Both" }, + { value: "UNNECESSARY", label: "Unnecessary" } + ] as const).map(option => ( + + ))} +
+
+ + {form.isRecurring ? ( +
+
Frequency Conditions
+
+ onChange({ intervalCount: Number(e.target.value || 1) })} + /> + +
+ {([ + { value: "NEVER", label: "Forever" }, + { value: "BY_DATE", label: "Until" }, + { value: "AFTER_COUNT", label: "After" } + ] as const).map(option => ( + + ))} +
+ {form.endCondition === "AFTER_COUNT" ? ( + onChange({ endCount: e.target.value })} + /> + ) : null} + {form.endCondition === "BY_DATE" ? ( +
+ + onChange({ endDate: e.target.value })} + /> + +
+ ) : null} +
+
+ ) : null} +