From f8e426542d1f7a912da4ed93ac29f1ea595e2899 Mon Sep 17 00:00:00 2001 From: Nico Date: Sun, 15 Feb 2026 17:10:58 -0800 Subject: [PATCH] feat: implement schedules pivot, scheduler service, and dokploy deploy flow --- .gitea/workflows/deploy-dokploy.yml | 22 + .gitea/workflows/deploy.yml | 69 - AGENTS.md | 9 + PROJECT_INSTRUCTIONS.md | 23 + apps/scheduler/package.json | 13 + apps/scheduler/src/index.ts | 168 +++ apps/scheduler/tsconfig.json | 11 + apps/web/__tests__/bucket-usage.test.ts | 32 +- apps/web/__tests__/group-settings.test.ts | 2 +- apps/web/__tests__/invite-links.test.ts | 4 +- apps/web/__tests__/recurring-entries.test.ts | 4 +- apps/web/__tests__/schedules.test.ts | 100 ++ apps/web/__tests__/spendings.test.ts | 11 +- apps/web/__tests__/test-helpers.ts | 10 +- apps/web/__tests__/user-settings.test.ts | 45 + apps/web/app/api/entries/[id]/route.ts | 21 - apps/web/app/api/entries/route.ts | 21 - .../app/api/recurring-entries/[id]/route.ts | 9 +- apps/web/app/api/recurring-entries/route.ts | 12 +- apps/web/app/api/schedules/[id]/route.ts | 96 ++ apps/web/app/api/schedules/route.ts | 84 ++ apps/web/app/api/user/settings/route.ts | 33 + apps/web/app/settings/page.tsx | 9 + apps/web/components/confirm-slide-modal.tsx | 86 +- apps/web/components/entry-details-modal.tsx | 138 +- apps/web/components/navbar.tsx | 3 +- apps/web/components/new-bucket-modal.tsx | 23 +- apps/web/components/new-entry-modal.tsx | 90 +- apps/web/components/new-schedule-modal.tsx | 226 ++++ .../web/components/schedule-details-modal.tsx | 261 ++++ apps/web/components/settings-content.tsx | 58 + apps/web/e2e/auth.spec.ts | 2 +- apps/web/e2e/spendings.spec.ts | 2 +- .../buckets/components/bucket-card.tsx | 208 +-- .../buckets/components/buckets-panel.tsx | 58 +- .../entries/components/entries-list.tsx | 191 ++- .../entries/components/entries-panel.tsx | 1125 ++++++++++------- .../entries/components/schedules-list.tsx | 85 ++ .../web/features/entries/hooks/use-entries.ts | 13 +- .../features/entries/hooks/use-schedules.ts | 119 ++ apps/web/hooks/use-user-settings.ts | 41 + apps/web/lib/client/entries.ts | 23 +- apps/web/lib/client/recurring-entries.ts | 32 +- apps/web/lib/client/schedules.ts | 42 + apps/web/lib/client/user-settings.ts | 16 + apps/web/lib/server/buckets.ts | 6 +- apps/web/lib/server/entries.ts | 81 +- apps/web/lib/server/recurring-entries.ts | 131 +- apps/web/lib/server/schedules.ts | 233 ++++ apps/web/lib/server/tags.ts | 27 + apps/web/lib/server/user-settings.ts | 50 + apps/web/lib/shared/bucket-usage.ts | 2 - apps/web/lib/shared/types.ts | 27 +- apps/web/next-env.d.ts | 2 +- docker/Dockerfile.scheduler | 13 + docker/nginx/npm/http_top.conf.example | 23 + .../npm/location-auth-advanced.conf.example | 12 + .../npm/location-root-advanced.conf.example | 15 + .../npm/location-write-advanced.conf.example | 18 + .../npm/proxy-host-advanced.conf.example | 21 + docs/05_REFACTOR_2.md | 15 + docs/06_SECURITY_REVIEW.md | 7 +- docs/07_PUBLIC_LAUNCH_CHECKLIST.md | 6 +- docs/08_NGINX_PROXY_MANAGER_SETUP.md | 115 ++ docs/09_DEPLOYMENT_EXECUTION_PLAYBOOK.md | 126 ++ docs/10_NPM_HANDS_ON_RUNSHEET.md | 101 ++ docs/11_DOKPLOY_FIRST_TIME_WALKTHROUGH.md | 110 ++ docs/DB_MIGRATION_WORKFLOW.md | 40 + ...IVOT_CHANGELOG_AND_IMPLEMENTATION_NOTES.md | 3 +- docs/SCHEDULES_PIVOT_GUARDRAILS.md | 45 + docs/public-launch-runbook.md | 15 +- package-lock.json | 13 + package.json | 6 +- .../db/migrations/008_schedules_pivot.sql | 143 +++ packages/db/migrations/active-migrations.json | 6 + packages/db/package.json | 3 + packages/db/src/migrate.js | 41 +- packages/db/src/migration-status.js | 143 +++ 78 files changed, 4112 insertions(+), 1137 deletions(-) delete mode 100644 .gitea/workflows/deploy.yml create mode 100644 apps/scheduler/package.json create mode 100644 apps/scheduler/src/index.ts create mode 100644 apps/scheduler/tsconfig.json create mode 100644 apps/web/__tests__/schedules.test.ts create mode 100644 apps/web/__tests__/user-settings.test.ts create mode 100644 apps/web/app/api/schedules/[id]/route.ts create mode 100644 apps/web/app/api/schedules/route.ts create mode 100644 apps/web/app/api/user/settings/route.ts create mode 100644 apps/web/app/settings/page.tsx create mode 100644 apps/web/components/new-schedule-modal.tsx create mode 100644 apps/web/components/schedule-details-modal.tsx create mode 100644 apps/web/components/settings-content.tsx create mode 100644 apps/web/features/entries/components/schedules-list.tsx create mode 100644 apps/web/features/entries/hooks/use-schedules.ts create mode 100644 apps/web/hooks/use-user-settings.ts create mode 100644 apps/web/lib/client/schedules.ts create mode 100644 apps/web/lib/client/user-settings.ts create mode 100644 apps/web/lib/server/schedules.ts create mode 100644 apps/web/lib/server/user-settings.ts create mode 100644 docker/Dockerfile.scheduler create mode 100644 docker/nginx/npm/http_top.conf.example create mode 100644 docker/nginx/npm/location-auth-advanced.conf.example create mode 100644 docker/nginx/npm/location-root-advanced.conf.example create mode 100644 docker/nginx/npm/location-write-advanced.conf.example create mode 100644 docker/nginx/npm/proxy-host-advanced.conf.example create mode 100644 docs/08_NGINX_PROXY_MANAGER_SETUP.md create mode 100644 docs/09_DEPLOYMENT_EXECUTION_PLAYBOOK.md create mode 100644 docs/10_NPM_HANDS_ON_RUNSHEET.md create mode 100644 docs/11_DOKPLOY_FIRST_TIME_WALKTHROUGH.md create mode 100644 docs/DB_MIGRATION_WORKFLOW.md create mode 100644 docs/SCHEDULES_PIVOT_GUARDRAILS.md create mode 100644 packages/db/migrations/008_schedules_pivot.sql create mode 100644 packages/db/migrations/active-migrations.json create mode 100644 packages/db/src/migration-status.js diff --git a/.gitea/workflows/deploy-dokploy.yml b/.gitea/workflows/deploy-dokploy.yml index ec90a6a..1904a8f 100644 --- a/.gitea/workflows/deploy-dokploy.yml +++ b/.gitea/workflows/deploy-dokploy.yml @@ -33,11 +33,20 @@ jobs: run: | docker build -t $REGISTRY/web:${{ github.sha }} -t $REGISTRY/web:main -f docker/Dockerfile . + - name: Build Scheduler Image + run: | + docker build -t $REGISTRY/scheduler:${{ github.sha }} -t $REGISTRY/scheduler:main -f docker/Dockerfile.scheduler . + - name: Push Web Image run: | docker push $REGISTRY/web:${{ github.sha }} docker push $REGISTRY/web:main + - name: Push Scheduler Image + run: | + docker push $REGISTRY/scheduler:${{ github.sha }} + docker push $REGISTRY/scheduler:main + deploy: needs: build runs-on: ubuntu-latest @@ -58,6 +67,19 @@ jobs: -H "Content-Type: application/json" \ -d "{\"imageTag\":\"$IMAGE_TAG\"}" + - name: Trigger Dokploy Scheduler Deploy + env: + DOKPLOY_SCHEDULER_DEPLOY_HOOK: ${{ secrets.DOKPLOY_SCHEDULER_DEPLOY_HOOK }} + IMAGE_TAG: ${{ github.sha }} + run: | + if [ -z "$DOKPLOY_SCHEDULER_DEPLOY_HOOK" ]; then + echo "DOKPLOY_SCHEDULER_DEPLOY_HOOK not set; skipping scheduler deploy trigger" + exit 0 + fi + curl -fsS -X POST "$DOKPLOY_SCHEDULER_DEPLOY_HOOK" \ + -H "Content-Type: application/json" \ + -d "{\"imageTag\":\"$IMAGE_TAG\"}" + - name: Wait for Ready Health Check env: HEALTH_URL: ${{ secrets.DOKPLOY_HEALTHCHECK_URL }} diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml deleted file mode 100644 index e0a87d5..0000000 --- a/.gitea/workflows/deploy.yml +++ /dev/null @@ -1,69 +0,0 @@ -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/AGENTS.md b/AGENTS.md index 56ce058..a2a25ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,3 +42,12 @@ - Keep touched files free of TS warnings and lint errors. - Add/update tests when API behavior changes (include negative cases). - Keep text encoding clean (no mojibake). + +## Response icon legend +Use the same status icons defined in `PROJECT_INSTRUCTIONS.md` section "Agent Response Legend (required)": +- `πŸ”„` in progress +- `βœ…` completed +- `πŸ§ͺ` verification/test result +- `⚠️` risk/blocker/manual action +- `❌` failure +- `🧭` recommendation/next step diff --git a/PROJECT_INSTRUCTIONS.md b/PROJECT_INSTRUCTIONS.md index f928afa..2e5b289 100644 --- a/PROJECT_INSTRUCTIONS.md +++ b/PROJECT_INSTRUCTIONS.md @@ -17,6 +17,7 @@ If anything conflicts, follow **this** doc. ### External DB + migrations - `DATABASE_URL` points to **on-prem Postgres** (**NOT** a container). - Dev/Prod share schema via migrations in: `packages/db/migrations`. +- Active migration runbook: `docs/DB_MIGRATION_WORKFLOW.md` (active set + status commands). ### No background jobs - **No cron/worker jobs**. Any fix must work without background tasks. @@ -164,3 +165,25 @@ For `app/api/**/[param]/route.ts`: - unauthorized - not-a-member - invalid input + +--- + +## 11) Agent Response Legend (required) +Use emoji/icons in agent progress and final responses so status is obvious at a glance. + +Legend: +- `πŸ”„` in progress +- `βœ…` completed +- `πŸ§ͺ` test/lint/verification result +- `πŸ“„` documentation update +- `πŸ—„οΈ` database or migration change +- `πŸš€` deploy/release step +- `⚠️` risk, blocker, or manual operator action needed +- `❌` failed command or unsuccessful attempt +- `ℹ️` informational context +- `🧭` recommendation or next-step option + +Usage rules: +- Include at least one status icon in each substantive agent response. +- Use one icon per bullet/line; avoid icon spam. +- Keep icon meaning consistent with this legend. diff --git a/apps/scheduler/package.json b/apps/scheduler/package.json new file mode 100644 index 0000000..ca63dfc --- /dev/null +++ b/apps/scheduler/package.json @@ -0,0 +1,13 @@ +{ + "name": "@fiddy/scheduler", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "start": "tsx src/index.ts" + }, + "dependencies": { + "dotenv": "^16.4.5", + "pg": "^8.13.0" + } +} diff --git a/apps/scheduler/src/index.ts b/apps/scheduler/src/index.ts new file mode 100644 index 0000000..4f34103 --- /dev/null +++ b/apps/scheduler/src/index.ts @@ -0,0 +1,168 @@ +import dotenv from "dotenv"; +import pg from "pg"; + +dotenv.config(); + +type ScheduleRow = { + id: number; + group_id: number; + created_by: number; + entry_type: "SPENDING" | "INCOME"; + amount_dollars: string | number; + necessity: "NECESSARY" | "BOTH" | "UNNECESSARY"; + purchase_type: string; + notes: string | null; + next_run_on: string; + frequency: "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY"; + interval_count: number; + end_condition: "NEVER" | "AFTER_COUNT" | "BY_DATE"; + end_count: number | null; + end_date: string | null; + run_count: number; +}; + +const DEFAULT_POLL_MS = 60_000; +const DEFAULT_BATCH_SIZE = 100; + +function getPollMs() { + const value = Number(process.env.SCHEDULER_POLL_MS || DEFAULT_POLL_MS); + if (!Number.isFinite(value) || value < 1_000) return DEFAULT_POLL_MS; + return Math.floor(value); +} + +function getBatchSize() { + const value = Number(process.env.SCHEDULER_BATCH_SIZE || DEFAULT_BATCH_SIZE); + if (!Number.isFinite(value) || value < 1) return DEFAULT_BATCH_SIZE; + return Math.floor(value); +} + +function addInterval(dateIso: string, frequency: ScheduleRow["frequency"], intervalCount: number) { + const safeInterval = Math.max(1, Number(intervalCount || 1)); + const date = new Date(`${dateIso}T00:00:00Z`); + if (frequency === "DAILY") date.setUTCDate(date.getUTCDate() + safeInterval); + else if (frequency === "WEEKLY") date.setUTCDate(date.getUTCDate() + safeInterval * 7); + else if (frequency === "MONTHLY") date.setUTCMonth(date.getUTCMonth() + safeInterval); + else date.setUTCFullYear(date.getUTCFullYear() + safeInterval); + return date.toISOString().slice(0, 10); +} + +function shouldDeactivate(input: { endCondition: ScheduleRow["end_condition"]; endCount: number | null; endDate: string | null; runCount: number; nextRunOn: string }) { + if (input.endCondition === "AFTER_COUNT" && input.endCount != null) { + return input.runCount >= input.endCount; + } + if (input.endCondition === "BY_DATE" && input.endDate) { + return input.nextRunOn > input.endDate; + } + return false; +} + +async function runOnce(pool: pg.Pool) { + const client = await pool.connect(); + const batchSize = getBatchSize(); + let processed = 0; + try { + await client.query("begin"); + const dueRows = (await client.query( + `select id, group_id, created_by, entry_type, amount_dollars, necessity, purchase_type, notes, + next_run_on, frequency, interval_count, end_condition, end_count, end_date, run_count + from schedules + where is_active=true and next_run_on <= (now() at time zone 'UTC')::date + order by next_run_on asc, id asc + for update skip locked + limit $1`, + [batchSize] + )).rows; + + for (const row of dueRows) { + const runOn = row.next_run_on; + const entryInsert = await client.query<{ id: number }>( + `insert into entries(group_id, created_by, entry_type, amount_dollars, occurred_at, necessity, purchase_type, notes, source_schedule_id) + values($1,$2,$3,$4,$5,$6,$7,$8,$9) + on conflict (source_schedule_id, occurred_at) do nothing + returning id`, + [ + row.group_id, + row.created_by, + row.entry_type, + Number(row.amount_dollars), + runOn, + row.necessity, + row.purchase_type, + row.notes, + row.id + ] + ); + + if (entryInsert.rows[0]?.id) { + await client.query( + `insert into entry_tags(entry_id, tag_id) + select $1, st.tag_id + from schedule_tags st + where st.schedule_id=$2 + on conflict do nothing`, + [entryInsert.rows[0].id, row.id] + ); + } + + const nextRunOn = addInterval(runOn, row.frequency, row.interval_count); + const nextRunCount = Number(row.run_count || 0) + 1; + const deactivate = shouldDeactivate({ + endCondition: row.end_condition, + endCount: row.end_count, + endDate: row.end_date, + runCount: nextRunCount, + nextRunOn + }); + + await client.query( + `update schedules + set next_run_on=$1, + last_run_on=$2, + run_count=$3, + is_active=$4, + updated_at=now() + where id=$5`, + [nextRunOn, runOn, nextRunCount, !deactivate, row.id] + ); + processed += 1; + } + + await client.query("commit"); + } catch (error) { + await client.query("rollback"); + throw error; + } finally { + client.release(); + } + return processed; +} + +async function main() { + if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is required"); + + + const pool = new pg.Pool({ + connectionString: process.env.DATABASE_URL, + ssl: process.env.DATABASE_SSL === "false" ? false : { rejectUnauthorized: false } + }); + + const pollMs = getPollMs(); + console.log(`[scheduler] started, poll_ms=${pollMs}, batch_size=${getBatchSize()}`); + + while (true) { + try { + const processed = await runOnce(pool); + if (processed > 0) console.log(`[scheduler] processed=${processed}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`[scheduler] cycle_failed=${message}`); + } + await new Promise(resolve => setTimeout(resolve, pollMs)); + } +} + +main().catch(error => { + const message = error instanceof Error ? error.message : String(error); + console.error(`[scheduler] fatal=${message}`); + process.exit(1); +}); diff --git a/apps/scheduler/tsconfig.json b/apps/scheduler/tsconfig.json new file mode 100644 index 0000000..6a4befe --- /dev/null +++ b/apps/scheduler/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/apps/web/__tests__/bucket-usage.test.ts b/apps/web/__tests__/bucket-usage.test.ts index a6db352..ebcd554 100644 --- a/apps/web/__tests__/bucket-usage.test.ts +++ b/apps/web/__tests__/bucket-usage.test.ts @@ -5,10 +5,10 @@ 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 bucket = { tags: ["groceries", "weekly"], necessity: "BOTH" as const, 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 } + { amountDollars: 20, occurredAt: "2026-02-10", necessity: "NECESSARY" as const, tags: ["groceries", "weekly", "extra"], entryType: "SPENDING" as const }, + { amountDollars: 15, occurredAt: "2026-02-10", necessity: "NECESSARY" as const, tags: ["groceries"], entryType: "SPENDING" as const } ]; const result = calculateBucketUsage(bucket, entries, today); @@ -16,10 +16,10 @@ test("calculateBucketUsage matches tag subset", () => { assert.equal(result.matchedCount, 1); }); -test("calculateBucketUsage excludes recurring entries", () => { - const bucket = { tags: ["rent"], necessity: "BOTH", windowDays: 30 }; +test("calculateBucketUsage ignores non-spending entries", () => { + const bucket = { tags: ["rent"], necessity: "BOTH" as const, windowDays: 30 }; const entries = [ - { amountDollars: 500, occurredAt: "2026-02-10", necessity: "NECESSARY", tags: ["rent"], isRecurring: true, entryType: "SPENDING" as const } + { amountDollars: 500, occurredAt: "2026-02-10", necessity: "NECESSARY" as const, tags: ["rent"], entryType: "INCOME" as const } ]; const result = calculateBucketUsage(bucket, entries, today); @@ -28,11 +28,11 @@ test("calculateBucketUsage excludes recurring entries", () => { }); test("calculateBucketUsage applies windowDays filtering", () => { - const bucket = { tags: [], necessity: "BOTH", windowDays: 3 }; + const bucket = { tags: [], necessity: "BOTH" as const, 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 } + { amountDollars: 10, occurredAt: "2026-02-11", necessity: "NECESSARY" as const, tags: [], entryType: "SPENDING" as const }, + { amountDollars: 10, occurredAt: "2026-02-09", necessity: "NECESSARY" as const, tags: [], entryType: "SPENDING" as const }, + { amountDollars: 10, occurredAt: "2026-02-08", necessity: "NECESSARY" as const, tags: [], entryType: "SPENDING" as const } ]; const result = calculateBucketUsage(bucket, entries, today); @@ -41,10 +41,10 @@ test("calculateBucketUsage applies windowDays filtering", () => { }); test("calculateBucketUsage accepts all when bucket necessity is BOTH", () => { - const bucket = { tags: [], necessity: "BOTH", windowDays: 30 }; + const bucket = { tags: [], necessity: "BOTH" as const, 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 } + { amountDollars: 12, occurredAt: "2026-02-10", necessity: "NECESSARY" as const, tags: [], entryType: "SPENDING" as const }, + { amountDollars: 8, occurredAt: "2026-02-10", necessity: "UNNECESSARY" as const, tags: [], entryType: "SPENDING" as const } ]; const result = calculateBucketUsage(bucket, entries, today); @@ -53,10 +53,10 @@ test("calculateBucketUsage accepts all when bucket necessity is BOTH", () => { }); test("calculateBucketUsage halves BOTH entries when bucket not BOTH", () => { - const bucket = { tags: [], necessity: "NECESSARY", windowDays: 30 }; + const bucket = { tags: [], necessity: "NECESSARY" as const, 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 } + { amountDollars: 10, occurredAt: "2026-02-10", necessity: "BOTH" as const, tags: [], entryType: "SPENDING" as const }, + { amountDollars: 10, occurredAt: "2026-02-10", necessity: "UNNECESSARY" as const, tags: [], entryType: "SPENDING" as const } ]; const result = calculateBucketUsage(bucket, entries, today); diff --git a/apps/web/__tests__/group-settings.test.ts b/apps/web/__tests__/group-settings.test.ts index 16489dd..a80e45f 100644 --- a/apps/web/__tests__/group-settings.test.ts +++ b/apps/web/__tests__/group-settings.test.ts @@ -104,7 +104,7 @@ test("group settings require admin", async t => { ); await assert.rejects( - () => setGroupSettings({ userId: memberId!, groupId, allowMemberTagManage: true, joinPolicy: "AUTO_ACCEPT" }), + () => setGroupSettings({ userId: memberId!, groupId: groupId!, allowMemberTagManage: true, joinPolicy: "AUTO_ACCEPT" }), { message: "FORBIDDEN" } ); diff --git a/apps/web/__tests__/invite-links.test.ts b/apps/web/__tests__/invite-links.test.ts index 7a8cda4..0b79774 100644 --- a/apps/web/__tests__/invite-links.test.ts +++ b/apps/web/__tests__/invite-links.test.ts @@ -61,11 +61,11 @@ test("invite link auto-accept adds member", async t => { const result = await acceptInviteLink({ userId: memberId!, token: link.token, requestId: "test-accept" }); assert.equal(result.status, "JOINED"); - const { rowCount } = await client.query( + const queryResult = await client.query( "select 1 from group_members where group_id=$1 and user_id=$2", [groupId, memberId] ); - assert.equal(rowCount, 1); + assert.equal(queryResult.rows.length, 1); } 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 index 23b46bc..6a3fba3 100644 --- a/apps/web/__tests__/recurring-entries.test.ts +++ b/apps/web/__tests__/recurring-entries.test.ts @@ -56,11 +56,9 @@ test("recurring entries list", async t => { purchaseType: "Rent", notes: "Monthly rent", tags: ["rent"], - isRecurring: true, frequency: "MONTHLY", intervalCount: 1, - endCondition: "NEVER", - nextRunAt: "2026-02-01" + endCondition: "NEVER" }); const list = await listRecurringEntries(groupId); diff --git a/apps/web/__tests__/schedules.test.ts b/apps/web/__tests__/schedules.test.ts new file mode 100644 index 0000000..b140e8c --- /dev/null +++ b/apps/web/__tests__/schedules.test.ts @@ -0,0 +1,100 @@ +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 { createSchedule, deleteSchedule, listSchedules, updateSchedule } from "../lib/server/schedules"; +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("schedules 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", + [`schedule_${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", + ["Schedules Test", uniqueInviteCode("Q"), 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", "home"] }); + + const created = await createSchedule({ + groupId, + userId, + entryType: "SPENDING", + amountDollars: 1200, + necessity: "NECESSARY", + purchaseType: "Rent", + notes: "Monthly rent", + tags: ["rent", "home"], + startsOn: "2026-03-01", + frequency: "MONTHLY", + intervalCount: 1, + endCondition: "NEVER", + createEntryNow: false + }); + + const listed = await listSchedules(groupId); + assert.equal(listed.length, 1); + assert.equal(listed[0].id, created.id); + assert.equal(listed[0].frequency, "MONTHLY"); + assert.deepEqual(listed[0].tags.sort(), ["home", "rent"]); + + const updated = await updateSchedule({ + id: created.id, + groupId, + userId, + entryType: "SPENDING", + amountDollars: 1300, + necessity: "NECESSARY", + purchaseType: "Rent", + notes: "Updated rent", + tags: ["rent"], + startsOn: "2026-03-01", + frequency: "MONTHLY", + intervalCount: 1, + endCondition: "AFTER_COUNT", + endCount: 3, + nextRunOn: "2026-04-01", + isActive: true + }); + assert.ok(updated); + assert.equal(updated?.amountDollars, 1300); + assert.equal(updated?.endCondition, "AFTER_COUNT"); + assert.equal(updated?.endCount, 3); + assert.deepEqual(updated?.tags, ["rent"]); + + await deleteSchedule({ id: created.id, groupId, userId }); + const afterDelete = await listSchedules(groupId); + assert.equal(afterDelete.length, 0); + } finally { + await cleanupTestData(client, { userIds: [userId], groupId }); + client.release(); + } +}); diff --git a/apps/web/__tests__/spendings.test.ts b/apps/web/__tests__/spendings.test.ts index f5220ef..1025c5f 100644 --- a/apps/web/__tests__/spendings.test.ts +++ b/apps/web/__tests__/spendings.test.ts @@ -55,9 +55,7 @@ test("entries CRUD", async t => { necessity: "NECESSARY", purchaseType: "Groceries", notes: "Test", - tags: ["groceries", "weekly"], - isRecurring: false, - intervalCount: 1 + tags: ["groceries", "weekly"] }); const list = await listEntries(groupId); @@ -76,12 +74,7 @@ test("entries CRUD", async t => { necessity: "BOTH", purchaseType: "Groceries", notes: "Updated", - tags: ["groceries"], - isRecurring: true, - frequency: "MONTHLY", - intervalCount: 1, - endCondition: "NEVER", - nextRunAt: "2026-02-02" + tags: ["groceries"] }); assert.ok(updated); assert.equal(updated?.amountDollars, 15); diff --git a/apps/web/__tests__/test-helpers.ts b/apps/web/__tests__/test-helpers.ts index cf2cce8..9ecca0c 100644 --- a/apps/web/__tests__/test-helpers.ts +++ b/apps/web/__tests__/test-helpers.ts @@ -32,6 +32,15 @@ export async function cleanupTestData(client: PoolClient, args: CleanupArgs) { "delete from entry_tags where entry_id in (select id from entries where group_id=$1)", [groupId] ); + await safeQuery( + "delete from schedule_tags where schedule_id in (select id from schedules where group_id=$1)", + [groupId] + ); + await safeQuery( + "delete from entries where source_schedule_id in (select id from schedules where group_id=$1)", + [groupId] + ); + await safeQuery("delete from schedules 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]); @@ -64,4 +73,3 @@ export async function cleanupTestDataFromPool(pool: Pool, args: CleanupArgs) { client.release(); } } - diff --git a/apps/web/__tests__/user-settings.test.ts b/apps/web/__tests__/user-settings.test.ts new file mode 100644 index 0000000..9ad9def --- /dev/null +++ b/apps/web/__tests__/user-settings.test.ts @@ -0,0 +1,45 @@ +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 { getUserSettings, setUserSettings } from "../lib/server/user-settings"; +import { cleanupTestData } 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("user settings default and update", 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; + try { + const userRes = await client.query( + "insert into users(email, password_hash) values($1,$2) returning id", + [`user_settings_${Date.now()}@example.com`, "hash"] + ); + userId = userRes.rows[0].id as number; + + const initial = await getUserSettings(userId); + assert.equal(initial.entryPanelPageSize, 10); + + const updated = await setUserSettings({ userId, entryPanelPageSize: 25 }); + assert.equal(updated.entryPanelPageSize, 25); + + const readBack = await getUserSettings(userId); + assert.equal(readBack.entryPanelPageSize, 25); + } finally { + await cleanupTestData(client, { userIds: [userId] }); + client.release(); + } +}); diff --git a/apps/web/app/api/entries/[id]/route.ts b/apps/web/app/api/entries/[id]/route.ts index 2b19a80..e72c654 100644 --- a/apps/web/app/api/entries/[id]/route.ts +++ b/apps/web/app/api/entries/[id]/route.ts @@ -27,13 +27,6 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st 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) @@ -44,13 +37,6 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 }); if (!['SPENDING', 'INCOME'].includes(entryType)) return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 }); - if (!Number.isFinite(intervalCount) || intervalCount <= 0) - return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 }); - if (frequency && !['DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY', 'QUARTERLY', 'YEARLY'].includes(frequency)) - return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 }); - if (endCondition && !['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition)) - return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 }); - const entry = await updateEntry({ id, groupId, @@ -62,13 +48,6 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st 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 }); diff --git a/apps/web/app/api/entries/route.ts b/apps/web/app/api/entries/route.ts index c8f397b..d840b24 100644 --- a/apps/web/app/api/entries/route.ts +++ b/apps/web/app/api/entries/route.ts @@ -36,13 +36,6 @@ export async function POST(req: Request) { 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) @@ -53,13 +46,6 @@ export async function POST(req: Request) { return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 }); if (!['SPENDING', 'INCOME'].includes(entryType)) return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 }); - if (!Number.isFinite(intervalCount) || intervalCount <= 0) - return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 }); - if (frequency && !['DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY', 'QUARTERLY', 'YEARLY'].includes(frequency)) - return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 }); - if (endCondition && !['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition)) - return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 }); - const entry = await createEntry({ groupId, userId: user.id, @@ -70,13 +56,6 @@ export async function POST(req: Request) { 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 }); diff --git a/apps/web/app/api/recurring-entries/[id]/route.ts b/apps/web/app/api/recurring-entries/[id]/route.ts index ae35a52..57770d8 100644 --- a/apps/web/app/api/recurring-entries/[id]/route.ts +++ b/apps/web/app/api/recurring-entries/[id]/route.ts @@ -33,7 +33,6 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st 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, request_id: requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 }); @@ -45,7 +44,7 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 }); if (!Number.isFinite(intervalCount) || intervalCount <= 0) return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 }); - if (frequency && !['DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY', 'QUARTERLY', 'YEARLY'].includes(frequency)) + if (frequency && !['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'].includes(frequency)) return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 }); if (endCondition && !['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition)) return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 }); @@ -61,14 +60,12 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st purchaseType, notes: notes || undefined, tags, - isRecurring: true, - frequency: frequency as "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null, + frequency: frequency as "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY" | null, intervalCount, endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE" | null, endCount, endDate, - nextRunAt, - bucketId + nextRunAt }); if (!entry) return NextResponse.json({ requestId, request_id: requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 }); diff --git a/apps/web/app/api/recurring-entries/route.ts b/apps/web/app/api/recurring-entries/route.ts index 0a86a6a..238077b 100644 --- a/apps/web/app/api/recurring-entries/route.ts +++ b/apps/web/app/api/recurring-entries/route.ts @@ -1,4 +1,4 @@ -ο»Ώimport { NextResponse } from "next/server"; +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"; @@ -42,7 +42,6 @@ export async function POST(req: Request) { 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, request_id: requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 }); @@ -54,7 +53,7 @@ export async function POST(req: Request) { return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 }); if (!Number.isFinite(intervalCount) || intervalCount <= 0) return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 }); - if (frequency && !['DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY', 'QUARTERLY', 'YEARLY'].includes(frequency)) + if (frequency && !['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'].includes(frequency)) return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 }); if (endCondition && !['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition)) return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 }); @@ -69,14 +68,11 @@ export async function POST(req: Request) { purchaseType, notes: notes || undefined, tags, - isRecurring: true, - frequency: frequency as "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null, + frequency: frequency as "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY" | null, intervalCount, endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE" | null, endCount, - endDate, - nextRunAt, - bucketId + endDate }); return NextResponse.json({ requestId, request_id: requestId, entry }); diff --git a/apps/web/app/api/schedules/[id]/route.ts b/apps/web/app/api/schedules/[id]/route.ts new file mode 100644 index 0000000..3b1ab2a --- /dev/null +++ b/apps/web/app/api/schedules/[id]/route.ts @@ -0,0 +1,96 @@ +import { NextResponse } from "next/server"; +import { requireSessionUser } from "@/lib/server/session"; +import { deleteSchedule, requireActiveGroup, updateSchedule } from "@/lib/server/schedules"; +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, request_id: requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 }); + + const body = await req.json().catch(() => null); + const amountDollars = Number(body?.amountDollars || 0); + const startsOn = String(body?.startsOn || ""); + 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 = String(body?.frequency || "").toUpperCase(); + const intervalCount = Number(body?.intervalCount || 1); + const endCondition = body?.endCondition ? String(body.endCondition).toUpperCase() : "NEVER"; + const endCount = body?.endCount != null ? Number(body.endCount) : null; + const endDate = body?.endDate ? String(body.endDate) : null; + const nextRunOn = body?.nextRunOn ? String(body.nextRunOn) : startsOn; + const isActive = body?.isActive != null ? Boolean(body.isActive) : true; + + if (!Number.isFinite(amountDollars) || amountDollars <= 0) + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 }); + if (!startsOn) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_STARTS_ON", message: "startsOn is required" } }, { status: 400 }); + if (!purchaseType) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 }); + if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity)) + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 }); + if (!['SPENDING', 'INCOME'].includes(entryType)) + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 }); + if (!['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'].includes(frequency)) + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 }); + if (!Number.isFinite(intervalCount) || intervalCount <= 0) + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 }); + if (!['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition)) + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 }); + + const schedule = await updateSchedule({ + id, + groupId, + userId: user.id, + entryType: entryType as "SPENDING" | "INCOME", + amountDollars, + startsOn, + necessity: necessity as "NECESSARY" | "BOTH" | "UNNECESSARY", + purchaseType, + notes: notes || undefined, + tags, + frequency: frequency as "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY", + intervalCount, + endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE", + endCount, + endDate, + nextRunOn, + isActive + }); + + if (!schedule) return NextResponse.json({ requestId, request_id: requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 }); + return NextResponse.json({ requestId, request_id: requestId, schedule }); + } catch (e) { + const { status, body } = toErrorResponse(e, "PATCH /api/schedules/[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, request_id: requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 }); + + await deleteSchedule({ id, groupId, userId: user.id }); + return NextResponse.json({ requestId, request_id: requestId, ok: true }); + } catch (e) { + const { status, body } = toErrorResponse(e, "DELETE /api/schedules/[id]", requestId); + return NextResponse.json(body, { status }); + } +} diff --git a/apps/web/app/api/schedules/route.ts b/apps/web/app/api/schedules/route.ts new file mode 100644 index 0000000..a058f54 --- /dev/null +++ b/apps/web/app/api/schedules/route.ts @@ -0,0 +1,84 @@ +import { NextResponse } from "next/server"; +import { requireSessionUser } from "@/lib/server/session"; +import { createSchedule, listSchedules, requireActiveGroup } from "@/lib/server/schedules"; +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 schedules = await listSchedules(groupId); + return NextResponse.json({ requestId, request_id: requestId, schedules }); + } catch (e) { + const { status, body } = toErrorResponse(e, "GET /api/schedules", 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 startsOn = String(body?.startsOn || ""); + 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 = String(body?.frequency || "").toUpperCase(); + const intervalCount = Number(body?.intervalCount || 1); + const endCondition = body?.endCondition ? String(body.endCondition).toUpperCase() : "NEVER"; + const endCount = body?.endCount != null ? Number(body.endCount) : null; + const endDate = body?.endDate ? String(body.endDate) : null; + const createEntryNow = Boolean(body?.createEntryNow); + + if (!Number.isFinite(amountDollars) || amountDollars <= 0) + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 }); + if (!startsOn) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_STARTS_ON", message: "startsOn is required" } }, { status: 400 }); + if (!purchaseType) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 }); + if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity)) + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 }); + if (!['SPENDING', 'INCOME'].includes(entryType)) + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 }); + if (!['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'].includes(frequency)) + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 }); + if (!Number.isFinite(intervalCount) || intervalCount <= 0) + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 }); + if (!['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition)) + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 }); + + const schedule = await createSchedule({ + groupId, + userId: user.id, + entryType: entryType as "SPENDING" | "INCOME", + amountDollars, + startsOn, + necessity: necessity as "NECESSARY" | "BOTH" | "UNNECESSARY", + purchaseType, + notes: notes || undefined, + tags, + frequency: frequency as "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY", + intervalCount, + endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE", + endCount, + endDate, + createEntryNow + }); + + return NextResponse.json({ requestId, request_id: requestId, schedule }); + } catch (e) { + const { status, body } = toErrorResponse(e, "POST /api/schedules", requestId); + return NextResponse.json(body, { status }); + } +} diff --git a/apps/web/app/api/user/settings/route.ts b/apps/web/app/api/user/settings/route.ts new file mode 100644 index 0000000..579db42 --- /dev/null +++ b/apps/web/app/api/user/settings/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import { requireSessionUser } from "@/lib/server/session"; +import { getUserSettings, setUserSettings } from "@/lib/server/user-settings"; +import { getRequestMeta } from "@/lib/server/request"; +import { toErrorResponse } from "@/lib/server/errors"; + +export async function GET() { + const { requestId } = await getRequestMeta(); + try { + const user = await requireSessionUser(); + const settings = await getUserSettings(user.id); + return NextResponse.json({ requestId, request_id: requestId, settings }); + } catch (e) { + const { status, body } = toErrorResponse(e, "GET /api/user/settings", 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 entryPanelPageSize = Number(body?.entryPanelPageSize); + if (!Number.isFinite(entryPanelPageSize) || entryPanelPageSize <= 0) + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_PAGE_SIZE", message: "entryPanelPageSize must be a positive number" } }, { status: 400 }); + const settings = await setUserSettings({ userId: user.id, entryPanelPageSize }); + return NextResponse.json({ requestId, request_id: requestId, settings }); + } catch (e) { + const { status, body } = toErrorResponse(e, "POST /api/user/settings", requestId); + return NextResponse.json(body, { status }); + } +} diff --git a/apps/web/app/settings/page.tsx b/apps/web/app/settings/page.tsx new file mode 100644 index 0000000..1defb51 --- /dev/null +++ b/apps/web/app/settings/page.tsx @@ -0,0 +1,9 @@ +import { redirect } from "next/navigation"; +import { getSessionUser } from "@/lib/server/session"; +import SettingsContent from "@/components/settings-content"; + +export default async function SettingsPage() { + const user = await getSessionUser(); + if (!user) redirect("/login"); + return ; +} diff --git a/apps/web/components/confirm-slide-modal.tsx b/apps/web/components/confirm-slide-modal.tsx index 4bcbbcf..6ec2e21 100644 --- a/apps/web/components/confirm-slide-modal.tsx +++ b/apps/web/components/confirm-slide-modal.tsx @@ -20,23 +20,60 @@ export default function ConfirmSlideModal({ onConfirm }: ConfirmSlideModalProps) { const trackRef = useRef(null); + const endFlashTimeoutRef = useRef | null>(null); + const reachedEndRef = useRef(false); const [dragX, setDragX] = useState(0); const [dragging, setDragging] = useState(false); - const handleSize = 44; + const [isAtEnd, setIsAtEnd] = useState(false); + const [endFlash, setEndFlash] = useState(false); + const handleSize = 40; + + function getDragPositionFromClientX(clientX: number) { + const track = trackRef.current; + if (!track) return 0; + const rect = track.getBoundingClientRect(); + return Math.min(Math.max(0, clientX - rect.left - handleSize / 2), rect.width - handleSize); + } + + function isEndPosition(position: number) { + const track = trackRef.current; + if (!track) return false; + const maxDrag = track.clientWidth - handleSize; + const endTolerancePx = 1; + return position >= maxDrag - endTolerancePx; + } + + function triggerEndFeedback() { + setEndFlash(true); + if (endFlashTimeoutRef.current) clearTimeout(endFlashTimeoutRef.current); + endFlashTimeoutRef.current = setTimeout(() => setEndFlash(false), 140); + + if (typeof navigator !== "undefined" && typeof navigator.vibrate === "function") { + navigator.vibrate(16); + } + } function handlePointerDown(event: React.PointerEvent) { event.preventDefault(); setDragging(true); + reachedEndRef.current = false; + setIsAtEnd(false); (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); + const next = getDragPositionFromClientX(event.clientX); + const nextAtEnd = isEndPosition(next); + setDragX(next); + setIsAtEnd(prev => (prev === nextAtEnd ? prev : nextAtEnd)); + + if (nextAtEnd && !reachedEndRef.current) { + reachedEndRef.current = true; + triggerEndFeedback(); + } + if (!nextAtEnd) reachedEndRef.current = false; } function handlePointerUp(event: React.PointerEvent) { @@ -45,14 +82,35 @@ export default function ConfirmSlideModal({ (event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId); const track = trackRef.current; if (!track) return; - const threshold = (track.clientWidth - handleSize) * 0.8; - if (dragX >= threshold) { + const releaseX = getDragPositionFromClientX(event.clientX); + const releaseAtEnd = isEndPosition(releaseX); + + setIsAtEnd(prev => (prev ? false : prev)); + + if (releaseAtEnd && !reachedEndRef.current) { + triggerEndFeedback(); + } + + if (releaseAtEnd) { setDragX(0); onConfirm(); } else { setDragX(0); } } + + function handlePointerCancel(event: React.PointerEvent) { + if (!dragging) return; + setDragging(false); + (event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId); + setIsAtEnd(prev => (prev ? false : prev)); + setDragX(0); + } + + useEffect(() => () => { + if (endFlashTimeoutRef.current) clearTimeout(endFlashTimeoutRef.current); + }, []); + useEffect(() => { if (!isOpen) return; function handleKeyDown(event: KeyboardEvent) { @@ -73,20 +131,26 @@ export default function ConfirmSlideModal({
Slide to confirm
+

Entry Details

-
- -
-
- - -