feat: implement schedules pivot, scheduler service, and dokploy deploy flow
This commit is contained in:
parent
19ee02ac6c
commit
f8e426542d
@ -33,11 +33,20 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
docker build -t $REGISTRY/web:${{ github.sha }} -t $REGISTRY/web:main -f docker/Dockerfile .
|
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
|
- name: Push Web Image
|
||||||
run: |
|
run: |
|
||||||
docker push $REGISTRY/web:${{ github.sha }}
|
docker push $REGISTRY/web:${{ github.sha }}
|
||||||
docker push $REGISTRY/web:main
|
docker push $REGISTRY/web:main
|
||||||
|
|
||||||
|
- name: Push Scheduler Image
|
||||||
|
run: |
|
||||||
|
docker push $REGISTRY/scheduler:${{ github.sha }}
|
||||||
|
docker push $REGISTRY/scheduler:main
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
needs: build
|
needs: build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@ -58,6 +67,19 @@ jobs:
|
|||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{\"imageTag\":\"$IMAGE_TAG\"}"
|
-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
|
- name: Wait for Ready Health Check
|
||||||
env:
|
env:
|
||||||
HEALTH_URL: ${{ secrets.DOKPLOY_HEALTHCHECK_URL }}
|
HEALTH_URL: ${{ secrets.DOKPLOY_HEALTHCHECK_URL }}
|
||||||
|
|||||||
@ -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
|
|
||||||
@ -42,3 +42,12 @@
|
|||||||
- Keep touched files free of TS warnings and lint errors.
|
- Keep touched files free of TS warnings and lint errors.
|
||||||
- Add/update tests when API behavior changes (include negative cases).
|
- Add/update tests when API behavior changes (include negative cases).
|
||||||
- Keep text encoding clean (no mojibake).
|
- 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
|
||||||
|
|||||||
@ -17,6 +17,7 @@ If anything conflicts, follow **this** doc.
|
|||||||
### External DB + migrations
|
### External DB + migrations
|
||||||
- `DATABASE_URL` points to **on-prem Postgres** (**NOT** a container).
|
- `DATABASE_URL` points to **on-prem Postgres** (**NOT** a container).
|
||||||
- Dev/Prod share schema via migrations in: `packages/db/migrations`.
|
- 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 background jobs
|
||||||
- **No cron/worker jobs**. Any fix must work without background tasks.
|
- **No cron/worker jobs**. Any fix must work without background tasks.
|
||||||
@ -164,3 +165,25 @@ For `app/api/**/[param]/route.ts`:
|
|||||||
- unauthorized
|
- unauthorized
|
||||||
- not-a-member
|
- not-a-member
|
||||||
- invalid input
|
- 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.
|
||||||
|
|||||||
13
apps/scheduler/package.json
Normal file
13
apps/scheduler/package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
168
apps/scheduler/src/index.ts
Normal file
168
apps/scheduler/src/index.ts
Normal file
@ -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<ScheduleRow>(
|
||||||
|
`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);
|
||||||
|
});
|
||||||
11
apps/scheduler/tsconfig.json
Normal file
11
apps/scheduler/tsconfig.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
@ -5,10 +5,10 @@ import { calculateBucketUsage } from "../lib/shared/bucket-usage";
|
|||||||
const today = "2026-02-11";
|
const today = "2026-02-11";
|
||||||
|
|
||||||
test("calculateBucketUsage matches tag subset", () => {
|
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 = [
|
const entries = [
|
||||||
{ amountDollars: 20, occurredAt: "2026-02-10", necessity: "NECESSARY", tags: ["groceries", "weekly", "extra"], 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", tags: ["groceries"], isRecurring: false, 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);
|
const result = calculateBucketUsage(bucket, entries, today);
|
||||||
@ -16,10 +16,10 @@ test("calculateBucketUsage matches tag subset", () => {
|
|||||||
assert.equal(result.matchedCount, 1);
|
assert.equal(result.matchedCount, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("calculateBucketUsage excludes recurring entries", () => {
|
test("calculateBucketUsage ignores non-spending entries", () => {
|
||||||
const bucket = { tags: ["rent"], necessity: "BOTH", windowDays: 30 };
|
const bucket = { tags: ["rent"], necessity: "BOTH" as const, windowDays: 30 };
|
||||||
const entries = [
|
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);
|
const result = calculateBucketUsage(bucket, entries, today);
|
||||||
@ -28,11 +28,11 @@ test("calculateBucketUsage excludes recurring entries", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("calculateBucketUsage applies windowDays filtering", () => {
|
test("calculateBucketUsage applies windowDays filtering", () => {
|
||||||
const bucket = { tags: [], necessity: "BOTH", windowDays: 3 };
|
const bucket = { tags: [], necessity: "BOTH" as const, windowDays: 3 };
|
||||||
const entries = [
|
const entries = [
|
||||||
{ amountDollars: 10, occurredAt: "2026-02-11", 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", tags: [], isRecurring: false, 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", tags: [], isRecurring: false, 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);
|
const result = calculateBucketUsage(bucket, entries, today);
|
||||||
@ -41,10 +41,10 @@ test("calculateBucketUsage applies windowDays filtering", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("calculateBucketUsage accepts all when bucket necessity is BOTH", () => {
|
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 = [
|
const entries = [
|
||||||
{ amountDollars: 12, occurredAt: "2026-02-10", necessity: "NECESSARY", 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", tags: [], isRecurring: false, 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);
|
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", () => {
|
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 = [
|
const entries = [
|
||||||
{ amountDollars: 10, occurredAt: "2026-02-10", necessity: "BOTH", 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", tags: [], isRecurring: false, 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);
|
const result = calculateBucketUsage(bucket, entries, today);
|
||||||
|
|||||||
@ -104,7 +104,7 @@ test("group settings require admin", async t => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() => setGroupSettings({ userId: memberId!, groupId, allowMemberTagManage: true, joinPolicy: "AUTO_ACCEPT" }),
|
() => setGroupSettings({ userId: memberId!, groupId: groupId!, allowMemberTagManage: true, joinPolicy: "AUTO_ACCEPT" }),
|
||||||
{ message: "FORBIDDEN" }
|
{ message: "FORBIDDEN" }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -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" });
|
const result = await acceptInviteLink({ userId: memberId!, token: link.token, requestId: "test-accept" });
|
||||||
assert.equal(result.status, "JOINED");
|
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",
|
"select 1 from group_members where group_id=$1 and user_id=$2",
|
||||||
[groupId, memberId]
|
[groupId, memberId]
|
||||||
);
|
);
|
||||||
assert.equal(rowCount, 1);
|
assert.equal(queryResult.rows.length, 1);
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupTestData(client, { userIds: [ownerId, memberId], groupId });
|
await cleanupTestData(client, { userIds: [ownerId, memberId], groupId });
|
||||||
client.release();
|
client.release();
|
||||||
|
|||||||
@ -56,11 +56,9 @@ test("recurring entries list", async t => {
|
|||||||
purchaseType: "Rent",
|
purchaseType: "Rent",
|
||||||
notes: "Monthly rent",
|
notes: "Monthly rent",
|
||||||
tags: ["rent"],
|
tags: ["rent"],
|
||||||
isRecurring: true,
|
|
||||||
frequency: "MONTHLY",
|
frequency: "MONTHLY",
|
||||||
intervalCount: 1,
|
intervalCount: 1,
|
||||||
endCondition: "NEVER",
|
endCondition: "NEVER"
|
||||||
nextRunAt: "2026-02-01"
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const list = await listRecurringEntries(groupId);
|
const list = await listRecurringEntries(groupId);
|
||||||
|
|||||||
100
apps/web/__tests__/schedules.test.ts
Normal file
100
apps/web/__tests__/schedules.test.ts
Normal file
@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -55,9 +55,7 @@ test("entries CRUD", async t => {
|
|||||||
necessity: "NECESSARY",
|
necessity: "NECESSARY",
|
||||||
purchaseType: "Groceries",
|
purchaseType: "Groceries",
|
||||||
notes: "Test",
|
notes: "Test",
|
||||||
tags: ["groceries", "weekly"],
|
tags: ["groceries", "weekly"]
|
||||||
isRecurring: false,
|
|
||||||
intervalCount: 1
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const list = await listEntries(groupId);
|
const list = await listEntries(groupId);
|
||||||
@ -76,12 +74,7 @@ test("entries CRUD", async t => {
|
|||||||
necessity: "BOTH",
|
necessity: "BOTH",
|
||||||
purchaseType: "Groceries",
|
purchaseType: "Groceries",
|
||||||
notes: "Updated",
|
notes: "Updated",
|
||||||
tags: ["groceries"],
|
tags: ["groceries"]
|
||||||
isRecurring: true,
|
|
||||||
frequency: "MONTHLY",
|
|
||||||
intervalCount: 1,
|
|
||||||
endCondition: "NEVER",
|
|
||||||
nextRunAt: "2026-02-02"
|
|
||||||
});
|
});
|
||||||
assert.ok(updated);
|
assert.ok(updated);
|
||||||
assert.equal(updated?.amountDollars, 15);
|
assert.equal(updated?.amountDollars, 15);
|
||||||
|
|||||||
@ -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)",
|
"delete from entry_tags where entry_id in (select id from entries where group_id=$1)",
|
||||||
[groupId]
|
[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 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 buckets where group_id=$1", [groupId]);
|
||||||
await safeQuery("delete from entries 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();
|
client.release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
45
apps/web/__tests__/user-settings.test.ts
Normal file
45
apps/web/__tests__/user-settings.test.ts
Normal file
@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -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 purchaseType = String(body?.purchaseType || tags[0] || "General").trim();
|
||||||
const notes = String(body?.notes || "").trim();
|
const notes = String(body?.notes || "").trim();
|
||||||
const entryType = String(body?.entryType || "SPENDING").toUpperCase();
|
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;
|
const bucketId = body?.bucketId != null ? Number(body.bucketId) : null;
|
||||||
|
|
||||||
if (!Number.isFinite(amountDollars) || amountDollars <= 0)
|
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 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
|
||||||
if (!['SPENDING', 'INCOME'].includes(entryType))
|
if (!['SPENDING', 'INCOME'].includes(entryType))
|
||||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 });
|
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({
|
const entry = await updateEntry({
|
||||||
id,
|
id,
|
||||||
groupId,
|
groupId,
|
||||||
@ -62,13 +48,6 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
|||||||
purchaseType,
|
purchaseType,
|
||||||
notes: notes || undefined,
|
notes: notes || undefined,
|
||||||
tags,
|
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
|
bucketId
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -36,13 +36,6 @@ export async function POST(req: Request) {
|
|||||||
const purchaseType = String(body?.purchaseType || tags[0] || "General").trim();
|
const purchaseType = String(body?.purchaseType || tags[0] || "General").trim();
|
||||||
const notes = String(body?.notes || "").trim();
|
const notes = String(body?.notes || "").trim();
|
||||||
const entryType = String(body?.entryType || "SPENDING").toUpperCase();
|
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;
|
const bucketId = body?.bucketId != null ? Number(body.bucketId) : null;
|
||||||
|
|
||||||
if (!Number.isFinite(amountDollars) || amountDollars <= 0)
|
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 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
|
||||||
if (!['SPENDING', 'INCOME'].includes(entryType))
|
if (!['SPENDING', 'INCOME'].includes(entryType))
|
||||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 });
|
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({
|
const entry = await createEntry({
|
||||||
groupId,
|
groupId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@ -70,13 +56,6 @@ export async function POST(req: Request) {
|
|||||||
purchaseType,
|
purchaseType,
|
||||||
notes: notes || undefined,
|
notes: notes || undefined,
|
||||||
tags,
|
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
|
bucketId
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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 endCount = body?.endCount != null ? Number(body.endCount) : null;
|
||||||
const endDate = body?.endDate ? String(body.endDate) : null;
|
const endDate = body?.endDate ? String(body.endDate) : null;
|
||||||
const nextRunAt = body?.nextRunAt ? String(body.nextRunAt) : occurredAt;
|
const nextRunAt = body?.nextRunAt ? String(body.nextRunAt) : occurredAt;
|
||||||
const bucketId = body?.bucketId != null ? Number(body.bucketId) : null;
|
|
||||||
|
|
||||||
if (!Number.isFinite(amountDollars) || amountDollars <= 0)
|
if (!Number.isFinite(amountDollars) || amountDollars <= 0)
|
||||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 });
|
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 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 });
|
||||||
if (!Number.isFinite(intervalCount) || intervalCount <= 0)
|
if (!Number.isFinite(intervalCount) || intervalCount <= 0)
|
||||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 });
|
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 });
|
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))
|
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 });
|
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,
|
purchaseType,
|
||||||
notes: notes || undefined,
|
notes: notes || undefined,
|
||||||
tags,
|
tags,
|
||||||
isRecurring: true,
|
frequency: frequency as "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY" | null,
|
||||||
frequency: frequency as "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null,
|
|
||||||
intervalCount,
|
intervalCount,
|
||||||
endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE" | null,
|
endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE" | null,
|
||||||
endCount,
|
endCount,
|
||||||
endDate,
|
endDate,
|
||||||
nextRunAt,
|
nextRunAt
|
||||||
bucketId
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!entry) return NextResponse.json({ requestId, request_id: requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 });
|
if (!entry) return NextResponse.json({ requestId, request_id: requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 });
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { createRecurringEntry, listRecurringEntries, requireActiveGroup } from "@/lib/server/recurring-entries";
|
import { createRecurringEntry, listRecurringEntries, requireActiveGroup } from "@/lib/server/recurring-entries";
|
||||||
import { toErrorResponse } from "@/lib/server/errors";
|
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 endCount = body?.endCount != null ? Number(body.endCount) : null;
|
||||||
const endDate = body?.endDate ? String(body.endDate) : null;
|
const endDate = body?.endDate ? String(body.endDate) : null;
|
||||||
const nextRunAt = body?.nextRunAt ? String(body.nextRunAt) : occurredAt;
|
const nextRunAt = body?.nextRunAt ? String(body.nextRunAt) : occurredAt;
|
||||||
const bucketId = body?.bucketId != null ? Number(body.bucketId) : null;
|
|
||||||
|
|
||||||
if (!Number.isFinite(amountDollars) || amountDollars <= 0)
|
if (!Number.isFinite(amountDollars) || amountDollars <= 0)
|
||||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 });
|
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 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 });
|
||||||
if (!Number.isFinite(intervalCount) || intervalCount <= 0)
|
if (!Number.isFinite(intervalCount) || intervalCount <= 0)
|
||||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 });
|
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 });
|
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))
|
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 });
|
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,
|
purchaseType,
|
||||||
notes: notes || undefined,
|
notes: notes || undefined,
|
||||||
tags,
|
tags,
|
||||||
isRecurring: true,
|
frequency: frequency as "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY" | null,
|
||||||
frequency: frequency as "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null,
|
|
||||||
intervalCount,
|
intervalCount,
|
||||||
endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE" | null,
|
endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE" | null,
|
||||||
endCount,
|
endCount,
|
||||||
endDate,
|
endDate
|
||||||
nextRunAt,
|
|
||||||
bucketId
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ requestId, request_id: requestId, entry });
|
return NextResponse.json({ requestId, request_id: requestId, entry });
|
||||||
|
|||||||
96
apps/web/app/api/schedules/[id]/route.ts
Normal file
96
apps/web/app/api/schedules/[id]/route.ts
Normal file
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
84
apps/web/app/api/schedules/route.ts
Normal file
84
apps/web/app/api/schedules/route.ts
Normal file
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
33
apps/web/app/api/user/settings/route.ts
Normal file
33
apps/web/app/api/user/settings/route.ts
Normal file
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/web/app/settings/page.tsx
Normal file
9
apps/web/app/settings/page.tsx
Normal file
@ -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 <SettingsContent />;
|
||||||
|
}
|
||||||
@ -20,23 +20,60 @@ export default function ConfirmSlideModal({
|
|||||||
onConfirm
|
onConfirm
|
||||||
}: ConfirmSlideModalProps) {
|
}: ConfirmSlideModalProps) {
|
||||||
const trackRef = useRef<HTMLDivElement | null>(null);
|
const trackRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const endFlashTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const reachedEndRef = useRef(false);
|
||||||
const [dragX, setDragX] = useState(0);
|
const [dragX, setDragX] = useState(0);
|
||||||
const [dragging, setDragging] = useState(false);
|
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<HTMLButtonElement>) {
|
function handlePointerDown(event: React.PointerEvent<HTMLButtonElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setDragging(true);
|
setDragging(true);
|
||||||
|
reachedEndRef.current = false;
|
||||||
|
setIsAtEnd(false);
|
||||||
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePointerMove(event: React.PointerEvent<HTMLButtonElement>) {
|
function handlePointerMove(event: React.PointerEvent<HTMLButtonElement>) {
|
||||||
if (!dragging) return;
|
if (!dragging) return;
|
||||||
const track = trackRef.current;
|
const next = getDragPositionFromClientX(event.clientX);
|
||||||
if (!track) return;
|
const nextAtEnd = isEndPosition(next);
|
||||||
const rect = track.getBoundingClientRect();
|
|
||||||
const next = Math.min(Math.max(0, event.clientX - rect.left - handleSize / 2), rect.width - handleSize);
|
|
||||||
setDragX(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<HTMLButtonElement>) {
|
function handlePointerUp(event: React.PointerEvent<HTMLButtonElement>) {
|
||||||
@ -45,14 +82,35 @@ export default function ConfirmSlideModal({
|
|||||||
(event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId);
|
(event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId);
|
||||||
const track = trackRef.current;
|
const track = trackRef.current;
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
const threshold = (track.clientWidth - handleSize) * 0.8;
|
const releaseX = getDragPositionFromClientX(event.clientX);
|
||||||
if (dragX >= threshold) {
|
const releaseAtEnd = isEndPosition(releaseX);
|
||||||
|
|
||||||
|
setIsAtEnd(prev => (prev ? false : prev));
|
||||||
|
|
||||||
|
if (releaseAtEnd && !reachedEndRef.current) {
|
||||||
|
triggerEndFeedback();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (releaseAtEnd) {
|
||||||
setDragX(0);
|
setDragX(0);
|
||||||
onConfirm();
|
onConfirm();
|
||||||
} else {
|
} else {
|
||||||
setDragX(0);
|
setDragX(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handlePointerCancel(event: React.PointerEvent<HTMLButtonElement>) {
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
function handleKeyDown(event: KeyboardEvent) {
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
@ -73,20 +131,26 @@ export default function ConfirmSlideModal({
|
|||||||
<div className="text-xs text-soft">Slide to confirm</div>
|
<div className="text-xs text-soft">Slide to confirm</div>
|
||||||
<div
|
<div
|
||||||
ref={trackRef}
|
ref={trackRef}
|
||||||
className="mt-2 h-11 rounded-full border border-accent-weak bg-surface relative overflow-hidden touch-none select-none"
|
className={`relative mx-auto mt-2 h-10 w-4/5 overflow-hidden rounded-full border touch-none select-none transition-colors ${isAtEnd || endFlash ? "border-accent bg-accent-soft" : "border-accent-weak bg-surface"}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="absolute inset-y-0 left-0 bg-accent-soft rounded-full"
|
className="absolute inset-y-0 left-0 rounded-full bg-accent-soft"
|
||||||
style={{ width: dragX + handleSize }}
|
style={{ width: dragX + handleSize }}
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
className={`pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs font-semibold text-[color:var(--color-accent)] transition-all ${isAtEnd || endFlash ? "scale-100 opacity-100" : "scale-90 opacity-0"}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
click
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="absolute top-0 left-0 h-11 w-11 rounded-full border border-accent bg-panel text-xl font-semibold text-[color:var(--color-text)] touch-none select-none leading-none"
|
className={`absolute left-0 top-0 h-10 w-10 rounded-full border bg-panel text-lg font-semibold leading-none text-[color:var(--color-text)] touch-none select-none will-change-transform transition-[border-color,box-shadow] duration-100 ${isAtEnd || endFlash ? "border-accent-strong shadow-[0_0_0_2px_var(--color-accent-focus)]" : "border-accent"}`}
|
||||||
style={{ transform: `translateX(${dragX}px)` }}
|
style={{ transform: `translateX(${dragX}px)` }}
|
||||||
onPointerDown={handlePointerDown}
|
onPointerDown={handlePointerDown}
|
||||||
onPointerMove={handlePointerMove}
|
onPointerMove={handlePointerMove}
|
||||||
onPointerUp={handlePointerUp}
|
onPointerUp={handlePointerUp}
|
||||||
onPointerCancel={handlePointerUp}
|
onPointerCancel={handlePointerCancel}
|
||||||
aria-label="Slide to confirm"
|
aria-label="Slide to confirm"
|
||||||
>
|
>
|
||||||
▸
|
▸
|
||||||
|
|||||||
@ -13,12 +13,6 @@ export type EntryDetailsForm = {
|
|||||||
notes: string;
|
notes: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
entryType: "SPENDING" | "INCOME";
|
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 = {
|
type EntryDetailsModalProps = {
|
||||||
@ -83,7 +77,6 @@ export default function EntryDetailsModal({
|
|||||||
const notesChanged = form.notes !== baseline.notes;
|
const notesChanged = form.notes !== baseline.notes;
|
||||||
const changedInputClass = "border-2 border-[color:var(--color-accent)]";
|
const changedInputClass = "border-2 border-[color:var(--color-accent)]";
|
||||||
const formRef = useRef<HTMLFormElement | null>(null);
|
const formRef = useRef<HTMLFormElement | null>(null);
|
||||||
|
|
||||||
const touchStartX = useRef<number | null>(null);
|
const touchStartX = useRef<number | null>(null);
|
||||||
const touchDeltaX = useRef(0);
|
const touchDeltaX = useRef(0);
|
||||||
|
|
||||||
@ -119,10 +112,7 @@ export default function EntryDetailsModal({
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 !mt-0" onClick={onClose}>
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 !mt-0"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className="relative w-full max-w-xl rounded-xl border border-accent-weak bg-panel p-4"
|
className="relative w-full max-w-xl rounded-xl border border-accent-weak bg-panel p-4"
|
||||||
onClick={event => event.stopPropagation()}
|
onClick={event => event.stopPropagation()}
|
||||||
@ -131,26 +121,14 @@ export default function EntryDetailsModal({
|
|||||||
onTouchEnd={handleTouchEnd}
|
onTouchEnd={handleTouchEnd}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-3 items-center gap-3">
|
<div className="grid grid-cols-3 items-center gap-3">
|
||||||
<button
|
<button type="button" onClick={onPrev} className="flex w-20 items-center justify-between rounded-lg border border-accent-weak bg-panel px-2.5 py-1 text-sm font-semibold text-muted hover:border-accent disabled:opacity-50" disabled={!canNavigate} aria-label="Previous entry">
|
||||||
type="button"
|
<span aria-hidden>{loopHintPrev ? "o" : "<"}</span>
|
||||||
onClick={onPrev}
|
|
||||||
className="flex w-20 items-center justify-between rounded-lg border border-accent-weak bg-panel px-2.5 py-1 text-sm font-semibold text-muted hover:border-accent disabled:opacity-50"
|
|
||||||
disabled={!canNavigate}
|
|
||||||
aria-label="Previous entry"
|
|
||||||
>
|
|
||||||
<span aria-hidden>{loopHintPrev ? "↺" : "←"}</span>
|
|
||||||
<span className="text-xs text-soft">{loopHintPrev ? "To Last" : "Prev"}</span>
|
<span className="text-xs text-soft">{loopHintPrev ? "To Last" : "Prev"}</span>
|
||||||
</button>
|
</button>
|
||||||
<h2 className="text-center text-lg font-semibold">Entry Details</h2>
|
<h2 className="text-center text-lg font-semibold">Entry Details</h2>
|
||||||
<button
|
<button type="button" onClick={onNext} className="ml-auto flex w-20 items-center justify-between rounded-lg border border-accent-weak bg-panel px-2.5 py-1 text-sm font-semibold text-muted hover:border-accent disabled:opacity-50" disabled={!canNavigate} aria-label="Next entry">
|
||||||
type="button"
|
|
||||||
onClick={onNext}
|
|
||||||
className="ml-auto flex w-20 items-center justify-between rounded-lg border border-accent-weak bg-panel px-2.5 py-1 text-sm font-semibold text-muted hover:border-accent disabled:opacity-50"
|
|
||||||
disabled={!canNavigate}
|
|
||||||
aria-label="Next entry"
|
|
||||||
>
|
|
||||||
<span className="text-xs text-soft">{loopHintNext ? "To First" : "Next"}</span>
|
<span className="text-xs text-soft">{loopHintNext ? "To First" : "Next"}</span>
|
||||||
<span aria-hidden>{loopHintNext ? "↻" : "→"}</span>
|
<span aria-hidden>{loopHintNext ? "o" : ">"}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<form
|
<form
|
||||||
@ -165,8 +143,6 @@ export default function EntryDetailsModal({
|
|||||||
}}
|
}}
|
||||||
className="mt-3 grid gap-3 md:grid-cols-2"
|
className="mt-3 grid gap-3 md:grid-cols-2"
|
||||||
>
|
>
|
||||||
|
|
||||||
|
|
||||||
<div className="md:col-span-2 flex flex-wrap items-center gap-2">
|
<div className="md:col-span-2 flex flex-wrap items-center gap-2">
|
||||||
<ToggleButtonGroup
|
<ToggleButtonGroup
|
||||||
value={form.entryType}
|
value={form.entryType}
|
||||||
@ -178,21 +154,7 @@ export default function EntryDetailsModal({
|
|||||||
{ value: "INCOME", label: "Income" }
|
{ value: "INCOME", label: "Income" }
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`flex h-9 w-9 items-center justify-center rounded-full border text-lg ${form.isRecurring ? "border-accent bg-accent-soft" : "border-accent-weak bg-panel"}`}
|
|
||||||
onClick={() => onChange({ isRecurring: !form.isRecurring })}
|
|
||||||
title="Toggle Recurring Entry"
|
|
||||||
aria-label={form.isRecurring ? "Recurring entry" : "One-time entry"}
|
|
||||||
>
|
|
||||||
<span aria-hidden className="font-bold text-[25px]"
|
|
||||||
style={{ transform: `translateY(-2px)` }}
|
|
||||||
>∞</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<label className="text-sm text-muted">
|
<label className="text-sm text-muted">
|
||||||
Amount ($)
|
Amount ($)
|
||||||
<input
|
<input
|
||||||
@ -244,62 +206,6 @@ export default function EntryDetailsModal({
|
|||||||
onEmptySuggestionClick={onEmptyTagAction}
|
onEmptySuggestionClick={onEmptyTagAction}
|
||||||
invalid={!currentTags.length}
|
invalid={!currentTags.length}
|
||||||
/>
|
/>
|
||||||
{form.isRecurring ? (
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<div className="text-sm text-muted">Frequency Conditions</div>
|
|
||||||
<div className="mt-2 flex flex-wrap items-center justify-center gap-2">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
className="w-20 input-base px-3 py-2 text-center text-sm"
|
|
||||||
value={form.intervalCount}
|
|
||||||
onChange={e => onChange({ intervalCount: Number(e.target.value || 1) })}
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
className="min-w-[120px] input-base px-3 py-2 text-center text-sm"
|
|
||||||
value={form.frequency}
|
|
||||||
onChange={e => onChange({ frequency: e.target.value as EntryDetailsForm["frequency"] })}
|
|
||||||
>
|
|
||||||
<option value="DAILY">day(s)</option>
|
|
||||||
<option value="WEEKLY">week(s)</option>
|
|
||||||
<option value="BIWEEKLY">biweekly</option>
|
|
||||||
<option value="MONTHLY">month(s)</option>
|
|
||||||
<option value="QUARTERLY">quarter(s)</option>
|
|
||||||
<option value="YEARLY">year(s)</option>
|
|
||||||
</select>
|
|
||||||
<ToggleButtonGroup
|
|
||||||
value={form.endCondition}
|
|
||||||
onChange={endCondition => onChange({ endCondition })}
|
|
||||||
ariaLabel="End condition"
|
|
||||||
className="flex items-center gap-1 rounded-full border border-accent-weak bg-panel"
|
|
||||||
sizeClassName="px-3 py-2 text-xs font-semibold"
|
|
||||||
options={[
|
|
||||||
{ value: "NEVER", label: "Forever" },
|
|
||||||
{ value: "BY_DATE", label: "Until" },
|
|
||||||
{ value: "AFTER_COUNT", label: "After" }
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
{form.endCondition === "AFTER_COUNT" ? (
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
className="w-24 input-base px-3 py-2 text-center text-sm"
|
|
||||||
value={form.endCount}
|
|
||||||
placeholder="Count"
|
|
||||||
onChange={e => onChange({ endCount: e.target.value })}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{form.endCondition === "BY_DATE" ? (
|
|
||||||
<DatePicker
|
|
||||||
value={form.endDate}
|
|
||||||
onChange={endDate => onChange({ endDate })}
|
|
||||||
showWeekButtons={false}
|
|
||||||
centerInput
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<label className="text-sm text-muted md:col-span-2">
|
<label className="text-sm text-muted md:col-span-2">
|
||||||
Notes
|
Notes
|
||||||
<textarea
|
<textarea
|
||||||
@ -320,42 +226,16 @@ export default function EntryDetailsModal({
|
|||||||
aria-label="Revert changes"
|
aria-label="Revert changes"
|
||||||
className="flex h-9 w-9 items-center justify-center rounded-lg border border-accent-weak bg-panel text-sm hover:border-accent disabled:opacity-40 disabled:cursor-not-allowed"
|
className="flex h-9 w-9 items-center justify-center rounded-lg border border-accent-weak bg-panel text-sm hover:border-accent disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<svg
|
R
|
||||||
aria-hidden="true"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
className="h-4 w-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M3 12a9 9 0 1 0 3-6.7" />
|
|
||||||
<path d="M3 4v4h4" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="submit" className="rounded-lg btn-accent px-4 py-2 text-sm font-semibold disabled:opacity-35 disabled:cursor-not-allowed" disabled={!isDirty}>
|
||||||
type="submit"
|
|
||||||
className="rounded-lg btn-accent px-4 py-2 text-sm font-semibold disabled:opacity-35 disabled:cursor-not-allowed disabled:grayscale disabled:shadow-none"
|
|
||||||
disabled={!isDirty}
|
|
||||||
>
|
|
||||||
Save changes
|
Save changes
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" className="rounded-lg btn-outline-accent px-4 py-2 text-sm font-semibold" onClick={onRequestDelete}>
|
||||||
type="button"
|
|
||||||
className="rounded-lg btn-outline-accent px-4 py-2 text-sm font-semibold"
|
|
||||||
onClick={onRequestDelete}
|
|
||||||
>
|
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex-1 w-full" />
|
<div className="flex-1 w-full" />
|
||||||
<button
|
<button type="button" onClick={onClose} className="rounded-lg btn-outline-accent px-4 py-2 text-sm font-semibold" aria-label="Close">
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="rounded-lg btn-outline-accent px-4 py-2 text-sm font-semibold"
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -127,8 +127,7 @@ export default function Navbar() {
|
|||||||
className="w-full rounded-lg btn-outline-accent px-3 py-2 text-xs font-semibold"
|
className="w-full rounded-lg btn-outline-accent px-3 py-2 text-xs font-semibold"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setUserMenuOpen(false);
|
setUserMenuOpen(false);
|
||||||
if (activeGroupId) router.push("/groups/settings");
|
router.push("/settings");
|
||||||
else router.push("/");
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Settings
|
Settings
|
||||||
|
|||||||
@ -25,9 +25,11 @@ type NewBucketModalProps = {
|
|||||||
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
|
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
|
||||||
onChange: (next: Partial<BucketForm>) => void;
|
onChange: (next: Partial<BucketForm>) => void;
|
||||||
tagSuggestions: string[];
|
tagSuggestions: string[];
|
||||||
|
canDelete?: boolean;
|
||||||
|
onDelete?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function NewBucketModal({ isOpen, title, form, error, onClose, onSubmit, onChange, tagSuggestions }: NewBucketModalProps) {
|
export default function NewBucketModal({ isOpen, title, form, error, onClose, onSubmit, onChange, tagSuggestions, canDelete = false, onDelete }: NewBucketModalProps) {
|
||||||
const [iconModalOpen, setIconModalOpen] = useState(false);
|
const [iconModalOpen, setIconModalOpen] = useState(false);
|
||||||
const [iconSearch, setIconSearch] = useState("");
|
const [iconSearch, setIconSearch] = useState("");
|
||||||
const formRef = useRef<HTMLFormElement | null>(null);
|
const formRef = useRef<HTMLFormElement | null>(null);
|
||||||
@ -157,11 +159,22 @@ export default function NewBucketModal({ isOpen, title, form, error, onClose, on
|
|||||||
onToggleTag={tag => onChange({ tags: form.tags.filter(item => item !== tag) })}
|
onToggleTag={tag => onChange({ tags: form.tags.filter(item => item !== tag) })}
|
||||||
onAddTag={tag => onChange({ tags: [...form.tags, tag] })}
|
onAddTag={tag => onChange({ tags: [...form.tags, tag] })}
|
||||||
/>
|
/>
|
||||||
<div className="md:col-span-2 flex items-center justify-between">
|
<div className="md:col-span-2 flex items-center justify-between gap-3">
|
||||||
|
{error ? <div className="text-sm text-red-400">{error}</div> : null}
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
<button type="submit" className="rounded-lg btn-accent px-4 py-2 text-sm font-semibold">
|
<button type="submit" className="rounded-lg btn-accent px-4 py-2 text-sm font-semibold">
|
||||||
Save bucket
|
Save bucket
|
||||||
</button>
|
</button>
|
||||||
{error ? <div className="text-sm text-red-400">{error}</div> : null}
|
{canDelete ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-lg border border-red-400/70 bg-red-500/10 px-4 py-2 text-sm font-semibold text-red-200 hover:bg-red-500/15"
|
||||||
|
onClick={onDelete}
|
||||||
|
>
|
||||||
|
Delete bucket
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -13,12 +13,6 @@ type NewEntryForm = {
|
|||||||
notes: string;
|
notes: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
entryType: "SPENDING" | "INCOME";
|
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 NewEntryModalProps = {
|
type NewEntryModalProps = {
|
||||||
@ -37,7 +31,6 @@ type NewEntryModalProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit, onChange, tagSuggestions, emptyTagActionLabel, emptyTagActionDisabled = false, onEmptyTagAction, amountInputRef, tagsInputRef }: NewEntryModalProps) {
|
export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit, onChange, tagSuggestions, emptyTagActionLabel, emptyTagActionDisabled = false, onEmptyTagAction, amountInputRef, tagsInputRef }: NewEntryModalProps) {
|
||||||
const recurrenceLabel = form.isRecurring ? "Recurring" : "One-Time";
|
|
||||||
const typeLabel = form.entryType === "INCOME" ? "Income" : "Expense";
|
const typeLabel = form.entryType === "INCOME" ? "Income" : "Expense";
|
||||||
const formRef = useRef<HTMLFormElement | null>(null);
|
const formRef = useRef<HTMLFormElement | null>(null);
|
||||||
|
|
||||||
@ -49,12 +42,12 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
|
|||||||
|
|
||||||
if (!form.occurredAt) {
|
if (!form.occurredAt) {
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
onChange({ occurredAt: today, endDate: form.endDate || today });
|
onChange({ occurredAt: today });
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [form.endDate, form.occurredAt, isOpen, onChange, onClose]);
|
}, [form.occurredAt, isOpen, onChange, onClose]);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
@ -68,14 +61,14 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
|
|||||||
onClick={event => event.stopPropagation()}
|
onClick={event => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold">New {recurrenceLabel} {typeLabel} Entry</h2>
|
<h2 className="text-lg font-semibold">New {typeLabel} Entry</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="rounded-lg btn-outline-accent px-2 py-1 text-sm"
|
className="rounded-lg btn-outline-accent px-2 py-1 text-sm"
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
✕
|
x
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
@ -90,18 +83,6 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
|
|||||||
{ value: "INCOME", label: "Income" }
|
{ value: "INCOME", label: "Income" }
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`flex h-9 w-9 items-center justify-center rounded-full border text-lg ${form.isRecurring ? "border-accent bg-accent-soft" : "border-accent-weak bg-panel"}`}
|
|
||||||
onClick={() => onChange({ isRecurring: !form.isRecurring })}
|
|
||||||
title="Toggle Recurring Entry"
|
|
||||||
aria-label={form.isRecurring ? "Recurring entry" : "One-time entry"}
|
|
||||||
>
|
|
||||||
<span aria-hidden className="font-bold text-[25px]"
|
|
||||||
style={{ transform: `translateY(-2px)` }}
|
|
||||||
>∞</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<form
|
<form
|
||||||
ref={formRef}
|
ref={formRef}
|
||||||
@ -116,10 +97,9 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
|
|||||||
className="mt-3 grid gap-3 md:grid-cols-2"
|
className="mt-3 grid gap-3 md:grid-cols-2"
|
||||||
>
|
>
|
||||||
<label className="text-sm text-muted">
|
<label className="text-sm text-muted">
|
||||||
{/* Amount ($) */}
|
|
||||||
<div className="relative mt-1">
|
<div className="relative mt-1">
|
||||||
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-soft">
|
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-soft">
|
||||||
{form.entryType === "INCOME" ? "🤑 $" : "😭 $"}
|
{form.entryType === "INCOME" ? "$" : "$"}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
ref={amountInputRef}
|
ref={amountInputRef}
|
||||||
@ -157,8 +137,6 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* TAGS */}
|
|
||||||
<TagInput
|
<TagInput
|
||||||
label="Tags"
|
label="Tags"
|
||||||
tags={form.tags}
|
tags={form.tags}
|
||||||
@ -172,62 +150,6 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
|
|||||||
invalid={!form.tags.length}
|
invalid={!form.tags.length}
|
||||||
inputRef={tagsInputRef}
|
inputRef={tagsInputRef}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* RECURRING OPTIONS */}
|
|
||||||
{form.isRecurring ? (
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<div className="mt-2 flex flex-wrap items-center justify-center gap-y-2">
|
|
||||||
<div className="text-sm text-muted mr-2">Every</div>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
className="mr-1 w-12 input-base px-3 py-2 text-center text-sm"
|
|
||||||
value={form.intervalCount}
|
|
||||||
onChange={e => onChange({ intervalCount: Number(e.target.value || 1) })}
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
className="min-w-[100px] input-base px-3 py-2 text-center text-sm"
|
|
||||||
value={form.frequency}
|
|
||||||
onChange={e => onChange({ frequency: e.target.value as NewEntryForm["frequency"] })}
|
|
||||||
>
|
|
||||||
<option value="DAILY">day(s)</option>
|
|
||||||
<option value="WEEKLY">week(s)</option>
|
|
||||||
<option value="MONTHLY">month(s)</option>
|
|
||||||
<option value="YEARLY">year(s)</option>
|
|
||||||
</select>
|
|
||||||
<ToggleButtonGroup
|
|
||||||
value={form.endCondition}
|
|
||||||
onChange={endCondition => onChange({ endCondition })}
|
|
||||||
ariaLabel="End condition"
|
|
||||||
className="flex items-center gap-1 rounded-full border border-accent-weak bg-panel"
|
|
||||||
sizeClassName="px-3 py-3 text-xs font-semibold"
|
|
||||||
options={[
|
|
||||||
{ value: "NEVER", label: "Forever" },
|
|
||||||
{ value: "BY_DATE", label: "Until" },
|
|
||||||
{ value: "AFTER_COUNT", label: "After" }
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
{form.endCondition === "AFTER_COUNT" ? (
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
className="w-24 input-base px-3 py-2 text-center text-sm"
|
|
||||||
value={form.endCount}
|
|
||||||
placeholder="Count"
|
|
||||||
onChange={e => onChange({ endCount: e.target.value })}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{form.endCondition === "BY_DATE" ? (
|
|
||||||
<DatePicker
|
|
||||||
value={form.endDate}
|
|
||||||
onChange={endDate => onChange({ endDate })}
|
|
||||||
showWeekButtons={false}
|
|
||||||
centerInput
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<label className="text-sm text-muted md:col-span-2">
|
<label className="text-sm text-muted md:col-span-2">
|
||||||
Notes
|
Notes
|
||||||
<textarea
|
<textarea
|
||||||
@ -244,7 +166,7 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
|
|||||||
type="submit"
|
type="submit"
|
||||||
className="rounded-lg btn-accent px-4 py-2 text-sm font-semibold"
|
className="rounded-lg btn-accent px-4 py-2 text-sm font-semibold"
|
||||||
>
|
>
|
||||||
{form.isRecurring ? "Set schedule and add entry" : "Add entry"}
|
Add entry
|
||||||
</button>
|
</button>
|
||||||
{error ? <div className="text-sm text-red-400">{error}</div> : null}
|
{error ? <div className="text-sm text-red-400">{error}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
226
apps/web/components/new-schedule-modal.tsx
Normal file
226
apps/web/components/new-schedule-modal.tsx
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type React from "react";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import DatePicker from "@/components/date-picker";
|
||||||
|
import TagInput from "@/components/tag-input";
|
||||||
|
import ToggleButtonGroup from "@/components/toggle-button-group";
|
||||||
|
import type { ScheduleEndCondition, ScheduleFrequency } from "@/lib/shared/types";
|
||||||
|
|
||||||
|
export type NewScheduleForm = {
|
||||||
|
amountDollars: string;
|
||||||
|
startsOn: string;
|
||||||
|
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
|
||||||
|
notes: string;
|
||||||
|
tags: string[];
|
||||||
|
entryType: "SPENDING" | "INCOME";
|
||||||
|
frequency: ScheduleFrequency;
|
||||||
|
intervalCount: number;
|
||||||
|
endCondition: ScheduleEndCondition;
|
||||||
|
endCount: string;
|
||||||
|
endDate: string;
|
||||||
|
createEntryNow: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NewScheduleModalProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
form: NewScheduleForm;
|
||||||
|
error: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
|
||||||
|
onChange: (next: Partial<NewScheduleForm>) => void;
|
||||||
|
tagSuggestions: string[];
|
||||||
|
emptyTagActionLabel?: string;
|
||||||
|
emptyTagActionDisabled?: boolean;
|
||||||
|
onEmptyTagAction?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function NewScheduleModal({
|
||||||
|
isOpen,
|
||||||
|
form,
|
||||||
|
error,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
onChange,
|
||||||
|
tagSuggestions,
|
||||||
|
emptyTagActionLabel,
|
||||||
|
emptyTagActionDisabled = false,
|
||||||
|
onEmptyTagAction
|
||||||
|
}: NewScheduleModalProps) {
|
||||||
|
const formRef = useRef<HTMLFormElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") onClose();
|
||||||
|
}
|
||||||
|
if (!form.startsOn) {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
onChange({ startsOn: today });
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [form.startsOn, isOpen, onChange, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 !mt-0" onClick={onClose}>
|
||||||
|
<div className="w-full max-w-xl rounded-xl border border-accent-weak bg-panel p-4" onClick={event => event.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">New Schedule</h2>
|
||||||
|
<button type="button" onClick={onClose} className="rounded-lg btn-outline-accent px-2 py-1 text-sm" aria-label="Close">
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={form.entryType}
|
||||||
|
onChange={entryType => onChange({ entryType })}
|
||||||
|
ariaLabel="Entry type"
|
||||||
|
className="flex items-center gap-[-50px] rounded-full border border-accent-weak bg-panel"
|
||||||
|
sizeClassName="px-4 py-2.5 text-xs font-semibold"
|
||||||
|
options={[
|
||||||
|
{ value: "SPENDING", label: "Spending", className: "mr-[-10px]" },
|
||||||
|
{ value: "INCOME", label: "Income" }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={form.createEntryNow ? "NOW" : "NEXT"}
|
||||||
|
onChange={value => onChange({ createEntryNow: value === "NOW" })}
|
||||||
|
ariaLabel="Create behavior"
|
||||||
|
sizeClassName="px-3 py-2 text-xs font-semibold"
|
||||||
|
options={[
|
||||||
|
{ value: "NOW", label: "Create Entry Now" },
|
||||||
|
{ value: "NEXT", label: "Start Next Schedule" }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
ref={formRef}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
onKeyDown={event => {
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<label className="text-sm text-muted">
|
||||||
|
Amount ($)
|
||||||
|
<input
|
||||||
|
name="amountDollars"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="0.01"
|
||||||
|
className={`mt-1 w-full input-base px-3 py-2 text-sm ${form.amountDollars ? "" : "border-red-400/70"}`}
|
||||||
|
value={form.amountDollars}
|
||||||
|
onChange={e => onChange({ amountDollars: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="text-sm text-muted">
|
||||||
|
<DatePicker name="startsOn" value={form.startsOn} onChange={startsOn => onChange({ startsOn })} required className="mt-1" />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted">
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={form.necessity}
|
||||||
|
onChange={necessity => onChange({ necessity })}
|
||||||
|
ariaLabel="Necessity"
|
||||||
|
className="mt-1 flex items-center gap-2 rounded-full border border-accent-weak bg-panel"
|
||||||
|
sizeClassName="px-3 py-2.5 text-xs font-semibold"
|
||||||
|
options={[
|
||||||
|
{ value: "NECESSARY", label: "Necessary", className: "flex-1" },
|
||||||
|
{ value: "BOTH", label: "Both", className: "flex-1" },
|
||||||
|
{ value: "UNNECESSARY", label: "Unnecessary", className: "flex-1" }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<TagInput
|
||||||
|
label="Tags"
|
||||||
|
tags={form.tags}
|
||||||
|
suggestions={tagSuggestions}
|
||||||
|
allowCustom={false}
|
||||||
|
onToggleTag={tag => onChange({ tags: form.tags.filter(item => item !== tag) })}
|
||||||
|
onAddTag={tag => onChange({ tags: [...form.tags, tag] })}
|
||||||
|
emptySuggestionLabel={emptyTagActionLabel}
|
||||||
|
emptySuggestionDisabled={emptyTagActionDisabled}
|
||||||
|
onEmptySuggestionClick={onEmptyTagAction}
|
||||||
|
invalid={!form.tags.length}
|
||||||
|
/>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<div className="mt-2 flex flex-wrap items-center justify-center gap-y-2">
|
||||||
|
<div className="text-sm text-muted mr-2">Every</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="mr-1 w-12 input-base px-3 py-2 text-center text-sm"
|
||||||
|
value={form.intervalCount}
|
||||||
|
onChange={e => onChange({ intervalCount: Number(e.target.value || 1) })}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="min-w-[100px] input-base px-3 py-2 text-center text-sm"
|
||||||
|
value={form.frequency}
|
||||||
|
onChange={e => onChange({ frequency: e.target.value as ScheduleFrequency })}
|
||||||
|
>
|
||||||
|
<option value="DAILY">daily</option>
|
||||||
|
<option value="WEEKLY">weakly</option>
|
||||||
|
<option value="MONTHLY">monthly</option>
|
||||||
|
<option value="YEARLY">yearly</option>
|
||||||
|
</select>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={form.endCondition}
|
||||||
|
onChange={endCondition => onChange({ endCondition })}
|
||||||
|
ariaLabel="End condition"
|
||||||
|
className="flex items-center gap-1 rounded-full border border-accent-weak bg-panel"
|
||||||
|
sizeClassName="px-3 py-3 text-xs font-semibold"
|
||||||
|
options={[
|
||||||
|
{ value: "NEVER", label: "Forever" },
|
||||||
|
{ value: "BY_DATE", label: "Until" },
|
||||||
|
{ value: "AFTER_COUNT", label: "After" }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{form.endCondition === "AFTER_COUNT" ? (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="w-24 input-base px-3 py-2 text-center text-sm"
|
||||||
|
value={form.endCount}
|
||||||
|
placeholder="Count"
|
||||||
|
onChange={e => onChange({ endCount: e.target.value })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{form.endCondition === "BY_DATE" ? (
|
||||||
|
<DatePicker
|
||||||
|
value={form.endDate}
|
||||||
|
onChange={endDate => onChange({ endDate })}
|
||||||
|
showWeekButtons={false}
|
||||||
|
centerInput
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="text-sm text-muted md:col-span-2">
|
||||||
|
Notes
|
||||||
|
<textarea
|
||||||
|
name="notes"
|
||||||
|
className="mt-1 w-full input-base px-3 py-2 text-sm"
|
||||||
|
rows={3}
|
||||||
|
value={form.notes}
|
||||||
|
onChange={e => onChange({ notes: e.target.value })}
|
||||||
|
placeholder="Optional"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="md:col-span-2 flex items-center justify-between">
|
||||||
|
<button type="submit" className="rounded-lg btn-accent px-4 py-2 text-sm font-semibold">
|
||||||
|
Save schedule
|
||||||
|
</button>
|
||||||
|
{error ? <div className="text-sm text-red-400">{error}</div> : null}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
261
apps/web/components/schedule-details-modal.tsx
Normal file
261
apps/web/components/schedule-details-modal.tsx
Normal file
@ -0,0 +1,261 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type React from "react";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import DatePicker from "@/components/date-picker";
|
||||||
|
import TagInput from "@/components/tag-input";
|
||||||
|
import ToggleButtonGroup from "@/components/toggle-button-group";
|
||||||
|
import type { ScheduleEndCondition, ScheduleFrequency } from "@/lib/shared/types";
|
||||||
|
|
||||||
|
export type ScheduleDetailsForm = {
|
||||||
|
amountDollars: string;
|
||||||
|
startsOn: string;
|
||||||
|
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
|
||||||
|
notes: string;
|
||||||
|
tags: string[];
|
||||||
|
entryType: "SPENDING" | "INCOME";
|
||||||
|
frequency: ScheduleFrequency;
|
||||||
|
intervalCount: number;
|
||||||
|
endCondition: ScheduleEndCondition;
|
||||||
|
endCount: string;
|
||||||
|
endDate: string;
|
||||||
|
nextRunOn: string;
|
||||||
|
isActive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ScheduleDetailsModalProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
form: ScheduleDetailsForm;
|
||||||
|
originalForm: ScheduleDetailsForm | null;
|
||||||
|
isDirty: boolean;
|
||||||
|
error: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
|
||||||
|
onRequestDelete: () => void;
|
||||||
|
onRevert: () => void;
|
||||||
|
onChange: (next: Partial<ScheduleDetailsForm>) => void;
|
||||||
|
onAddTag: (tag: string) => void;
|
||||||
|
onToggleTag: (tag: string) => void;
|
||||||
|
removedTags: string[];
|
||||||
|
tagSuggestions: string[];
|
||||||
|
emptyTagActionLabel?: string;
|
||||||
|
emptyTagActionDisabled?: boolean;
|
||||||
|
onEmptyTagAction?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ScheduleDetailsModal({
|
||||||
|
isOpen,
|
||||||
|
form,
|
||||||
|
originalForm,
|
||||||
|
isDirty,
|
||||||
|
error,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
onRequestDelete,
|
||||||
|
onRevert,
|
||||||
|
onChange,
|
||||||
|
onAddTag,
|
||||||
|
onToggleTag,
|
||||||
|
removedTags,
|
||||||
|
tagSuggestions,
|
||||||
|
emptyTagActionLabel,
|
||||||
|
emptyTagActionDisabled = false,
|
||||||
|
onEmptyTagAction
|
||||||
|
}: ScheduleDetailsModalProps) {
|
||||||
|
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 addedTags = currentTags.filter(tag => !baselineTags.some(base => base.toLowerCase() === tag.toLowerCase()));
|
||||||
|
const formRef = useRef<HTMLFormElement | null>(null);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 !mt-0" onClick={onClose}>
|
||||||
|
<div className="w-full max-w-xl rounded-xl border border-accent-weak bg-panel p-4" onClick={event => event.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">Schedule Details</h2>
|
||||||
|
<button type="button" onClick={onClose} className="rounded-lg btn-outline-accent px-2 py-1 text-sm" aria-label="Close">
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
ref={formRef}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
onKeyDown={event => {
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<div className="md:col-span-2 flex items-center gap-2">
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={form.entryType}
|
||||||
|
onChange={entryType => onChange({ entryType })}
|
||||||
|
ariaLabel="Entry type"
|
||||||
|
sizeClassName="px-4 py-2.5 text-xs font-semibold"
|
||||||
|
options={[
|
||||||
|
{ value: "SPENDING", label: "Spending" },
|
||||||
|
{ value: "INCOME", label: "Income" }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={form.isActive ? "ACTIVE" : "PAUSED"}
|
||||||
|
onChange={value => onChange({ isActive: value === "ACTIVE" })}
|
||||||
|
ariaLabel="Schedule active status"
|
||||||
|
sizeClassName="px-3 py-2 text-xs font-semibold"
|
||||||
|
options={[
|
||||||
|
{ value: "ACTIVE", label: "Active" },
|
||||||
|
{ value: "PAUSED", label: "Paused" }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="text-sm text-muted">
|
||||||
|
Amount ($)
|
||||||
|
<input
|
||||||
|
name="amountDollars"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="0.01"
|
||||||
|
className="mt-1 w-full input-base px-3 py-2 text-sm"
|
||||||
|
value={form.amountDollars}
|
||||||
|
onChange={e => onChange({ amountDollars: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="text-sm text-muted">
|
||||||
|
<DatePicker name="startsOn" value={form.startsOn} onChange={startsOn => onChange({ startsOn })} required className="mt-1" />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted">
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={form.necessity}
|
||||||
|
onChange={necessity => onChange({ necessity })}
|
||||||
|
ariaLabel="Necessity"
|
||||||
|
className="mt-1 flex items-center gap-2 rounded-full border border-accent-weak bg-panel"
|
||||||
|
sizeClassName="px-3 py-2.5 text-xs font-semibold"
|
||||||
|
options={[
|
||||||
|
{ value: "NECESSARY", label: "Necessary", className: "flex-1" },
|
||||||
|
{ value: "BOTH", label: "Both", className: "flex-1" },
|
||||||
|
{ value: "UNNECESSARY", label: "Unnecessary", className: "flex-1" }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<TagInput
|
||||||
|
label="Tags"
|
||||||
|
tags={form.tags}
|
||||||
|
removedTags={removedTags}
|
||||||
|
highlightTags={addedTags}
|
||||||
|
suggestions={tagSuggestions}
|
||||||
|
allowCustom={false}
|
||||||
|
chipsBelow
|
||||||
|
onToggleTag={onToggleTag}
|
||||||
|
onAddTag={onAddTag}
|
||||||
|
emptySuggestionLabel={emptyTagActionLabel}
|
||||||
|
emptySuggestionDisabled={emptyTagActionDisabled}
|
||||||
|
onEmptySuggestionClick={onEmptyTagAction}
|
||||||
|
invalid={!currentTags.length}
|
||||||
|
/>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<div className="mt-2 flex flex-wrap items-center justify-center gap-y-2">
|
||||||
|
<div className="text-sm text-muted mr-2">Every</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="mr-1 w-12 input-base px-3 py-2 text-center text-sm"
|
||||||
|
value={form.intervalCount}
|
||||||
|
onChange={e => onChange({ intervalCount: Number(e.target.value || 1) })}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="min-w-[100px] input-base px-3 py-2 text-center text-sm"
|
||||||
|
value={form.frequency}
|
||||||
|
onChange={e => onChange({ frequency: e.target.value as ScheduleFrequency })}
|
||||||
|
>
|
||||||
|
<option value="DAILY">daily</option>
|
||||||
|
<option value="WEEKLY">weakly</option>
|
||||||
|
<option value="MONTHLY">monthly</option>
|
||||||
|
<option value="YEARLY">yearly</option>
|
||||||
|
</select>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={form.endCondition}
|
||||||
|
onChange={endCondition => onChange({ endCondition })}
|
||||||
|
ariaLabel="End condition"
|
||||||
|
className="flex items-center gap-1 rounded-full border border-accent-weak bg-panel"
|
||||||
|
sizeClassName="px-3 py-3 text-xs font-semibold"
|
||||||
|
options={[
|
||||||
|
{ value: "NEVER", label: "Forever" },
|
||||||
|
{ value: "BY_DATE", label: "Until" },
|
||||||
|
{ value: "AFTER_COUNT", label: "After" }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{form.endCondition === "AFTER_COUNT" ? (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="w-24 input-base px-3 py-2 text-center text-sm"
|
||||||
|
value={form.endCount}
|
||||||
|
placeholder="Count"
|
||||||
|
onChange={e => onChange({ endCount: e.target.value })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{form.endCondition === "BY_DATE" ? (
|
||||||
|
<DatePicker
|
||||||
|
value={form.endDate}
|
||||||
|
onChange={endDate => onChange({ endDate })}
|
||||||
|
showWeekButtons={false}
|
||||||
|
centerInput
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted">
|
||||||
|
Next run
|
||||||
|
<DatePicker name="nextRunOn" value={form.nextRunOn} onChange={nextRunOn => onChange({ nextRunOn })} required className="mt-1" />
|
||||||
|
</div>
|
||||||
|
<label className="text-sm text-muted md:col-span-2">
|
||||||
|
Notes
|
||||||
|
<textarea
|
||||||
|
name="notes"
|
||||||
|
className="mt-1 w-full input-base px-3 py-2 text-sm"
|
||||||
|
rows={3}
|
||||||
|
value={form.notes}
|
||||||
|
onChange={e => onChange({ notes: e.target.value })}
|
||||||
|
placeholder="Optional"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="md:col-span-2 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 w-full">
|
||||||
|
<button type="button" onClick={onRevert} disabled={!isDirty} className="rounded-lg btn-outline-accent px-3 py-2 text-sm font-semibold disabled:opacity-40">
|
||||||
|
Revert
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="rounded-lg btn-accent px-4 py-2 text-sm font-semibold disabled:opacity-35" disabled={!isDirty}>
|
||||||
|
Save changes
|
||||||
|
</button>
|
||||||
|
<button type="button" className="rounded-lg btn-outline-accent px-4 py-2 text-sm font-semibold" onClick={onRequestDelete}>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<div className="flex-1 w-full" />
|
||||||
|
<button type="button" onClick={onClose} className="rounded-lg btn-outline-accent px-4 py-2 text-sm font-semibold">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error ? <div className="text-sm text-red-400">{error}</div> : null}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
apps/web/components/settings-content.tsx
Normal file
58
apps/web/components/settings-content.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import useUserSettings from "@/hooks/use-user-settings";
|
||||||
|
import { useNotificationsContext } from "@/hooks/notifications-context";
|
||||||
|
|
||||||
|
export default function SettingsContent() {
|
||||||
|
const { settings, loading, error, updateSettings } = useUserSettings();
|
||||||
|
const { notify } = useNotificationsContext();
|
||||||
|
const [entryPanelPageSize, setEntryPanelPageSize] = useState("10");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEntryPanelPageSize(String(settings.entryPanelPageSize || 10));
|
||||||
|
}, [settings.entryPanelPageSize]);
|
||||||
|
|
||||||
|
async function handleSave(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
const nextSize = Number(entryPanelPageSize);
|
||||||
|
if (!Number.isFinite(nextSize) || nextSize <= 0) return;
|
||||||
|
const ok = await updateSettings({ entryPanelPageSize: nextSize });
|
||||||
|
if (!ok) return;
|
||||||
|
notify({ title: "Settings saved", message: `Entry panel page size: ${nextSize}`, tone: "success" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="panel panel-accent p-4">
|
||||||
|
<div className="text-sm text-muted">Loading settings...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="panel panel-accent p-4">
|
||||||
|
<h1 className="text-xl font-semibold">User settings</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted">These settings apply to your account across groups.</p>
|
||||||
|
<form className="mt-4 space-y-3" onSubmit={handleSave}>
|
||||||
|
<label className="block text-sm text-muted">
|
||||||
|
Entry/Schedule cards per page
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
value={entryPanelPageSize}
|
||||||
|
onChange={event => setEntryPanelPageSize(event.target.value)}
|
||||||
|
className="mt-1 w-40 input-base px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="rounded-lg btn-accent px-4 py-2 text-sm font-semibold">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
{error ? <div className="text-sm text-red-400">{error}</div> : null}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -11,7 +11,7 @@ test("login and register hide navbar", async ({ page }) => {
|
|||||||
test("login shows entries for seeded owner", async ({ page }) => {
|
test("login shows entries for seeded owner", async ({ page }) => {
|
||||||
await login(page, "owner1@fiddy.dev", "FiddyDev123!");
|
await login(page, "owner1@fiddy.dev", "FiddyDev123!");
|
||||||
await expect(page).toHaveURL("/");
|
await expect(page).toHaveURL("/");
|
||||||
await expect(page.getByRole("heading", { name: "Entries" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Entries" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("no-group user sees empty state", async ({ page }) => {
|
test("no-group user sees empty state", async ({ page }) => {
|
||||||
|
|||||||
@ -5,7 +5,7 @@ test("seeded entries render with tags and no-tag state", async ({ page }) => {
|
|||||||
await login(page, "owner1@fiddy.dev", "FiddyDev123!");
|
await login(page, "owner1@fiddy.dev", "FiddyDev123!");
|
||||||
await expect(page).toHaveURL("/");
|
await expect(page).toHaveURL("/");
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "Entries" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Entries" })).toBeVisible();
|
||||||
await expect(page.getByText("$12.50").first()).toBeVisible();
|
await expect(page.getByText("$12.50").first()).toBeVisible();
|
||||||
await expect(page.locator("span:visible", { hasText: "#Food" }).first()).toBeVisible();
|
await expect(page.locator("span:visible", { hasText: "#Food" }).first()).toBeVisible();
|
||||||
await expect(page.locator("span:visible", { hasText: "#Travel" }).first()).toBeVisible();
|
await expect(page.locator("span:visible", { hasText: "#Travel" }).first()).toBeVisible();
|
||||||
|
|||||||
@ -1,27 +1,122 @@
|
|||||||
import React from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import type { Bucket } from "@/lib/client/buckets";
|
import type { Bucket } from "@/lib/client/buckets";
|
||||||
|
|
||||||
type BucketCardProps = {
|
type BucketCardProps = {
|
||||||
bucket: Bucket;
|
bucket: Bucket;
|
||||||
icon?: string | null;
|
icon?: string | null;
|
||||||
isExpanded: boolean;
|
|
||||||
toggleExpanded: (bucketId: number) => void;
|
|
||||||
isMenuOpen: boolean;
|
|
||||||
setMenuOpenId: React.Dispatch<React.SetStateAction<number | null>>;
|
|
||||||
setConfirmDeleteId: (bucketId: number) => void;
|
|
||||||
openEdit: (bucketId: number) => void;
|
openEdit: (bucketId: number) => void;
|
||||||
limit: number;
|
limit: number;
|
||||||
usageLabel: string;
|
usageLabel: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TAG_GAP_PX = 8;
|
||||||
|
const TAG_CLASS = "shrink-0 rounded-full border border-accent-weak bg-accent-soft px-2 py-0.5 text-[11px]";
|
||||||
|
const TAG_MORE_CLASS = "shrink-0 rounded-full border border-accent-weak bg-panel px-2 py-0.5 text-[11px] text-soft";
|
||||||
|
|
||||||
|
function BucketTagsRow({ tags }: { tags: string[] }) {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const moreRef = useRef<HTMLSpanElement | null>(null);
|
||||||
|
const tagRefs = useRef<(HTMLSpanElement | null)[]>([]);
|
||||||
|
const [visibleCount, setVisibleCount] = useState(tags.length);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
tagRefs.current = tagRefs.current.slice(0, tags.length);
|
||||||
|
setVisibleCount(tags.length);
|
||||||
|
}, [tags]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tags.length) return;
|
||||||
|
|
||||||
|
function recomputeVisibleCount() {
|
||||||
|
const containerWidth = containerRef.current?.clientWidth ?? 0;
|
||||||
|
const widths = tags.map((_, index) => tagRefs.current[index]?.offsetWidth ?? 0);
|
||||||
|
const moreProbe = moreRef.current;
|
||||||
|
|
||||||
|
if (!containerWidth || !moreProbe || widths.some(width => !width)) {
|
||||||
|
setVisibleCount(tags.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalTagsWidth = widths.reduce((sum, width) => sum + width, 0) + TAG_GAP_PX * Math.max(widths.length - 1, 0);
|
||||||
|
if (totalTagsWidth <= containerWidth) {
|
||||||
|
setVisibleCount(tags.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextVisibleCount = 0;
|
||||||
|
let usedWidth = 0;
|
||||||
|
|
||||||
|
for (let index = 0; index < widths.length; index += 1) {
|
||||||
|
usedWidth += index === 0 ? widths[index] : TAG_GAP_PX + widths[index];
|
||||||
|
const remaining = widths.length - (index + 1);
|
||||||
|
if (remaining <= 0) {
|
||||||
|
nextVisibleCount = widths.length;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
moreProbe.textContent = `${remaining} more...`;
|
||||||
|
const moreWidth = moreProbe.offsetWidth;
|
||||||
|
const totalWithMore = usedWidth + TAG_GAP_PX + moreWidth;
|
||||||
|
if (totalWithMore <= containerWidth) nextVisibleCount = index + 1;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVisibleCount(nextVisibleCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
recomputeVisibleCount();
|
||||||
|
|
||||||
|
if (typeof ResizeObserver !== "undefined") {
|
||||||
|
const observer = new ResizeObserver(recomputeVisibleCount);
|
||||||
|
if (containerRef.current) observer.observe(containerRef.current);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("resize", recomputeVisibleCount);
|
||||||
|
return () => window.removeEventListener("resize", recomputeVisibleCount);
|
||||||
|
}, [tags]);
|
||||||
|
|
||||||
|
if (!tags.length) {
|
||||||
|
return <span className="text-[11px] text-soft">No tags</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleTags = tags.slice(0, visibleCount);
|
||||||
|
const hasOverflow = visibleCount < tags.length;
|
||||||
|
const remainingCount = tags.length - visibleCount;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full">
|
||||||
|
<div ref={containerRef} className="flex min-w-0 items-center gap-2 overflow-hidden whitespace-nowrap">
|
||||||
|
{visibleTags.map((tag, index) => (
|
||||||
|
<span key={`${tag}-${index}`} className={TAG_CLASS}>
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{hasOverflow ? <span className={TAG_MORE_CLASS}>{remainingCount} more...</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className="pointer-events-none absolute left-0 top-0 -z-10 opacity-0" aria-hidden="true">
|
||||||
|
{tags.map((tag, index) => (
|
||||||
|
<span
|
||||||
|
key={`${tag}-${index}`}
|
||||||
|
ref={element => {
|
||||||
|
tagRefs.current[index] = element;
|
||||||
|
}}
|
||||||
|
className={`${TAG_CLASS} inline-block`}
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<span ref={moreRef} className={`${TAG_MORE_CLASS} inline-block`}>
|
||||||
|
{tags.length} more...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function BucketCard({
|
export function BucketCard({
|
||||||
bucket,
|
bucket,
|
||||||
icon,
|
icon,
|
||||||
isExpanded,
|
|
||||||
toggleExpanded,
|
|
||||||
isMenuOpen,
|
|
||||||
setMenuOpenId,
|
|
||||||
setConfirmDeleteId,
|
|
||||||
openEdit,
|
openEdit,
|
||||||
limit,
|
limit,
|
||||||
usageLabel,
|
usageLabel,
|
||||||
@ -34,8 +129,8 @@ export function BucketCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="w-full max-w-[360px] rounded-lg border border-accent-weak bg-panel px-3 py-3 transition hover:border-accent"
|
className="w-full cursor-pointer rounded-lg border border-accent-weak bg-panel px-3 py-3 transition hover:border-accent"
|
||||||
onClick={() => toggleExpanded(bucket.id)}
|
onClick={() => openEdit(bucket.id)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex min-w-0 items-start gap-3">
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
@ -60,93 +155,20 @@ export function BucketCard({
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-sm font-semibold">{bucket.name}</div>
|
<div className="truncate text-sm font-semibold">{bucket.name}</div>
|
||||||
{bucket.description ? (
|
{bucket.description ? (
|
||||||
<div className={`text-xs text-soft ${isExpanded ? "" : "truncate"}`}>
|
<div className="text-xs text-soft">
|
||||||
{bucket.description}
|
{bucket.description}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative" data-bucket-menu>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-lg btn-outline-accent px-2 py-1 text-xs"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
setMenuOpenId((prev) => (prev === bucket.id ? null : bucket.id));
|
|
||||||
}}
|
|
||||||
aria-label="Bucket actions"
|
|
||||||
data-bucket-menu-button
|
|
||||||
>
|
|
||||||
...
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isMenuOpen ? (
|
|
||||||
<div className="absolute right-0 mt-2 w-40 rounded-lg border border-accent-weak bg-panel p-1 text-xs shadow-lg">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="w-full rounded-md px-2 py-1 text-left hover:bg-accent-soft"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
openEdit(bucket.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="w-full rounded-md px-2 py-1 text-left text-red-200 hover:bg-red-500/10"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
setConfirmDeleteId(bucket.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{limit > 0 ? (
|
|
||||||
<>
|
|
||||||
{isExpanded ? (
|
|
||||||
<div className="mt-2 space-y-2 text-xs text-soft">
|
<div className="mt-2 space-y-2 text-xs text-soft">
|
||||||
<div>{usageLabel}</div>
|
{limit > 0 ? <div>{usageLabel}</div> : null}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex min-w-0 items-center">
|
||||||
{bucket.tags?.length ? (
|
<BucketTagsRow tags={bucket.tags || []} />
|
||||||
bucket.tags.map((tag) => (
|
|
||||||
<span
|
|
||||||
key={tag}
|
|
||||||
className="rounded-full border border-accent-weak bg-accent-soft px-2 py-0.5 text-[11px]"
|
|
||||||
>
|
|
||||||
#{tag}
|
|
||||||
</span>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<span className="text-[11px] text-soft">No tags</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
) : isExpanded ? (
|
|
||||||
<div className="mt-2 flex flex-wrap gap-2 text-xs text-soft">
|
|
||||||
{bucket.tags?.length ? (
|
|
||||||
bucket.tags.map((tag) => (
|
|
||||||
<span
|
|
||||||
key={tag}
|
|
||||||
className="rounded-full border border-accent-weak bg-accent-soft px-2 py-0.5 text-[11px]"
|
|
||||||
>
|
|
||||||
#{tag}
|
|
||||||
</span>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<span className="text-[11px] text-soft">No tags</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -154,8 +176,6 @@ export function BucketCard({
|
|||||||
export default React.memo(BucketCard, (prev, next) => (
|
export default React.memo(BucketCard, (prev, next) => (
|
||||||
prev.bucket === next.bucket
|
prev.bucket === next.bucket
|
||||||
&& prev.icon === next.icon
|
&& prev.icon === next.icon
|
||||||
&& prev.isExpanded === next.isExpanded
|
|
||||||
&& prev.isMenuOpen === next.isMenuOpen
|
|
||||||
&& prev.limit === next.limit
|
&& prev.limit === next.limit
|
||||||
&& prev.usageLabel === next.usageLabel
|
&& prev.usageLabel === next.usageLabel
|
||||||
));
|
));
|
||||||
|
|||||||
@ -9,17 +9,17 @@ import ConfirmSlideModal from "@/components/confirm-slide-modal";
|
|||||||
import { bucketIcons } from "@/lib/shared/bucket-icons";
|
import { bucketIcons } from "@/lib/shared/bucket-icons";
|
||||||
import BucketCard from "./bucket-card";
|
import BucketCard from "./bucket-card";
|
||||||
import { useEntryMutation } from "@/hooks/entry-mutation-context";
|
import { useEntryMutation } from "@/hooks/entry-mutation-context";
|
||||||
|
import { useNotificationsContext } from "@/hooks/notifications-context";
|
||||||
|
|
||||||
export default function BucketsPanel() {
|
export default function BucketsPanel() {
|
||||||
const { activeGroupId } = useGroupsContext();
|
const { activeGroupId } = useGroupsContext();
|
||||||
const { buckets, loading, error, createBucket, updateBucket, deleteBucket, reload } = useBuckets(activeGroupId);
|
const { buckets, loading, error, createBucket, updateBucket, deleteBucket, reload } = useBuckets(activeGroupId);
|
||||||
const { mutationVersion } = useEntryMutation();
|
const { mutationVersion } = useEntryMutation();
|
||||||
|
const { notify } = useNotificationsContext();
|
||||||
const { tags: tagSuggestions } = useTags(activeGroupId);
|
const { tags: tagSuggestions } = useTags(activeGroupId);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [editId, setEditId] = useState<number | null>(null);
|
const [editId, setEditId] = useState<number | null>(null);
|
||||||
const [confirmDeleteId, setConfirmDeleteId] = useState<number | null>(null);
|
const [confirmDeleteId, setConfirmDeleteId] = useState<number | null>(null);
|
||||||
const [menuOpenId, setMenuOpenId] = useState<number | null>(null);
|
|
||||||
const [expandedIds, setExpandedIds] = useState<number[]>([]);
|
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
description: "",
|
description: "",
|
||||||
@ -33,18 +33,6 @@ export default function BucketsPanel() {
|
|||||||
const iconMap = useMemo(() => new Map(bucketIcons.map(item => [item.key, item.icon])), []);
|
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]);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!activeGroupId) return;
|
if (!activeGroupId) return;
|
||||||
if (mutationVersion === 0) return;
|
if (mutationVersion === 0) return;
|
||||||
@ -75,7 +63,6 @@ export default function BucketsPanel() {
|
|||||||
windowDays: bucket.windowDays ? String(bucket.windowDays) : "30"
|
windowDays: bucket.windowDays ? String(bucket.windowDays) : "30"
|
||||||
});
|
});
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
setMenuOpenId(null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
@ -97,7 +84,10 @@ export default function BucketsPanel() {
|
|||||||
necessity: form.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
|
necessity: form.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
|
||||||
windowDays
|
windowDays
|
||||||
});
|
});
|
||||||
if (ok) setModalOpen(false);
|
if (ok) {
|
||||||
|
notify({ title: "Bucket updated", message: form.name.trim(), tone: "success" });
|
||||||
|
setModalOpen(false);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const ok = await createBucket({
|
const ok = await createBucket({
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
@ -108,14 +98,11 @@ export default function BucketsPanel() {
|
|||||||
necessity: form.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
|
necessity: form.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
|
||||||
windowDays
|
windowDays
|
||||||
});
|
});
|
||||||
if (ok) setModalOpen(false);
|
if (ok) {
|
||||||
|
notify({ title: "Bucket created", message: form.name.trim(), tone: "success" });
|
||||||
|
setModalOpen(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleExpanded(bucketId: number) {
|
|
||||||
setExpandedIds(prev => prev.includes(bucketId)
|
|
||||||
? prev.filter(id => id !== bucketId)
|
|
||||||
: [...prev, bucketId]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function budgetUsage(bucket: typeof buckets[number]) {
|
function budgetUsage(bucket: typeof buckets[number]) {
|
||||||
@ -139,7 +126,7 @@ export default function BucketsPanel() {
|
|||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
<div className="mt-3 grid gap-3 [grid-template-columns:repeat(auto-fit,minmax(260px,1fr))]">
|
||||||
|
|
||||||
|
|
||||||
{!activeGroupId ? (
|
{!activeGroupId ? (
|
||||||
@ -161,17 +148,11 @@ export default function BucketsPanel() {
|
|||||||
orderedBuckets.map(bucket => {
|
orderedBuckets.map(bucket => {
|
||||||
const icon = bucket.iconKey ? iconMap.get(bucket.iconKey) : null;
|
const icon = bucket.iconKey ? iconMap.get(bucket.iconKey) : null;
|
||||||
const { limit, spent } = budgetUsage(bucket);
|
const { limit, spent } = budgetUsage(bucket);
|
||||||
const isExpanded = expandedIds.includes(bucket.id);
|
|
||||||
const usageLabel = limit ? `$${spent.toFixed(2)} / $${limit.toFixed(2)}` : "";
|
const usageLabel = limit ? `$${spent.toFixed(2)} / $${limit.toFixed(2)}` : "";
|
||||||
return <BucketCard
|
return <BucketCard
|
||||||
key={bucket.id}
|
key={bucket.id}
|
||||||
bucket={bucket}
|
bucket={bucket}
|
||||||
icon={icon}
|
icon={icon}
|
||||||
isExpanded={isExpanded}
|
|
||||||
toggleExpanded={toggleExpanded}
|
|
||||||
isMenuOpen={menuOpenId === bucket.id}
|
|
||||||
setMenuOpenId={setMenuOpenId}
|
|
||||||
setConfirmDeleteId={setConfirmDeleteId}
|
|
||||||
openEdit={openEdit}
|
openEdit={openEdit}
|
||||||
limit={limit}
|
limit={limit}
|
||||||
usageLabel={usageLabel}
|
usageLabel={usageLabel}
|
||||||
@ -191,6 +172,11 @@ export default function BucketsPanel() {
|
|||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onChange={next => setForm(prev => ({ ...prev, ...next }))}
|
onChange={next => setForm(prev => ({ ...prev, ...next }))}
|
||||||
tagSuggestions={tagSuggestions}
|
tagSuggestions={tagSuggestions}
|
||||||
|
canDelete={Boolean(editId)}
|
||||||
|
onDelete={() => {
|
||||||
|
if (!editId) return;
|
||||||
|
setConfirmDeleteId(editId);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<ConfirmSlideModal
|
<ConfirmSlideModal
|
||||||
isOpen={Boolean(confirmDeleteId)}
|
isOpen={Boolean(confirmDeleteId)}
|
||||||
@ -200,8 +186,18 @@ export default function BucketsPanel() {
|
|||||||
onClose={() => setConfirmDeleteId(null)}
|
onClose={() => setConfirmDeleteId(null)}
|
||||||
onConfirm={async () => {
|
onConfirm={async () => {
|
||||||
if (!confirmDeleteId) return;
|
if (!confirmDeleteId) return;
|
||||||
|
const deletedBucket = buckets.find(bucket => bucket.id === confirmDeleteId) || null;
|
||||||
const ok = await deleteBucket(confirmDeleteId);
|
const ok = await deleteBucket(confirmDeleteId);
|
||||||
if (ok) setConfirmDeleteId(null);
|
if (ok) {
|
||||||
|
notify({
|
||||||
|
title: "Bucket deleted",
|
||||||
|
message: deletedBucket?.name || "Bucket removed",
|
||||||
|
tone: "danger"
|
||||||
|
});
|
||||||
|
setConfirmDeleteId(null);
|
||||||
|
setModalOpen(false);
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
import type { Entry } from "@/lib/shared/types";
|
import type { Entry } from "@/lib/shared/types";
|
||||||
|
|
||||||
type EntriesListProps = {
|
type EntriesListProps = {
|
||||||
@ -12,6 +13,156 @@ type EntriesListProps = {
|
|||||||
onClearFilters: () => void;
|
onClearFilters: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TAG_GAP_PX = 8;
|
||||||
|
const TAG_CLASS = "shrink-0 rounded-full border border-accent-weak bg-accent-soft px-2 py-0.5 text-xs";
|
||||||
|
const TAG_MORE_CLASS = "shrink-0 rounded-full border border-accent-weak bg-panel px-2 py-0.5 text-xs text-soft";
|
||||||
|
|
||||||
|
function NecessityIcon({ necessity }: { necessity: Entry["necessity"] }) {
|
||||||
|
if (necessity === "NECESSARY") {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="flex h-5 w-5 items-center justify-center rounded-full border border-accent-weak bg-accent-soft text-[color:var(--color-accent)]"
|
||||||
|
title="Necessary"
|
||||||
|
aria-label="Necessary"
|
||||||
|
>
|
||||||
|
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M20 6 9 17l-5-5" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (necessity === "UNNECESSARY") {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="flex h-5 w-5 items-center justify-center rounded-full border border-red-400/60 bg-red-500/10 text-red-200"
|
||||||
|
title="Unnecessary"
|
||||||
|
aria-label="Unnecessary"
|
||||||
|
>
|
||||||
|
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="m18 6-12 12" />
|
||||||
|
<path d="m6 6 12 12" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="flex h-5 w-5 items-center justify-center rounded-full border border-amber-400/60 bg-amber-500/10 text-amber-200"
|
||||||
|
title="Both"
|
||||||
|
aria-label="Both"
|
||||||
|
>
|
||||||
|
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M9 9h6" />
|
||||||
|
<path d="M9 15h6" />
|
||||||
|
<path d="M12 6v6" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EntryTagsRow({ tags }: { tags: string[] }) {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const moreRef = useRef<HTMLSpanElement | null>(null);
|
||||||
|
const tagRefs = useRef<(HTMLSpanElement | null)[]>([]);
|
||||||
|
const [visibleCount, setVisibleCount] = useState(tags.length);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
tagRefs.current = tagRefs.current.slice(0, tags.length);
|
||||||
|
setVisibleCount(tags.length);
|
||||||
|
}, [tags]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tags.length) return;
|
||||||
|
|
||||||
|
function recomputeVisibleCount() {
|
||||||
|
const containerWidth = containerRef.current?.clientWidth ?? 0;
|
||||||
|
const widths = tags.map((_, index) => tagRefs.current[index]?.offsetWidth ?? 0);
|
||||||
|
const moreProbe = moreRef.current;
|
||||||
|
|
||||||
|
if (!containerWidth || !moreProbe || widths.some(width => !width)) {
|
||||||
|
setVisibleCount(tags.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalTagsWidth = widths.reduce((sum, width) => sum + width, 0) + TAG_GAP_PX * Math.max(widths.length - 1, 0);
|
||||||
|
if (totalTagsWidth <= containerWidth) {
|
||||||
|
setVisibleCount(tags.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextVisibleCount = 0;
|
||||||
|
let usedWidth = 0;
|
||||||
|
|
||||||
|
for (let index = 0; index < widths.length; index += 1) {
|
||||||
|
usedWidth += index === 0 ? widths[index] : TAG_GAP_PX + widths[index];
|
||||||
|
const remaining = widths.length - (index + 1);
|
||||||
|
if (remaining <= 0) {
|
||||||
|
nextVisibleCount = widths.length;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
moreProbe.textContent = `${remaining} more...`;
|
||||||
|
const moreWidth = moreProbe.offsetWidth;
|
||||||
|
const totalWithMore = usedWidth + TAG_GAP_PX + moreWidth;
|
||||||
|
if (totalWithMore <= containerWidth) nextVisibleCount = index + 1;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVisibleCount(nextVisibleCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
recomputeVisibleCount();
|
||||||
|
|
||||||
|
if (typeof ResizeObserver !== "undefined") {
|
||||||
|
const observer = new ResizeObserver(recomputeVisibleCount);
|
||||||
|
if (containerRef.current) observer.observe(containerRef.current);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("resize", recomputeVisibleCount);
|
||||||
|
return () => window.removeEventListener("resize", recomputeVisibleCount);
|
||||||
|
}, [tags]);
|
||||||
|
|
||||||
|
if (!tags.length) {
|
||||||
|
return <span className="rounded-full border border-accent-weak px-2 py-0.5 text-xs text-soft">No tags</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleTags = tags.slice(0, visibleCount);
|
||||||
|
const hasOverflow = visibleCount < tags.length;
|
||||||
|
const remainingCount = tags.length - visibleCount;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full">
|
||||||
|
<div ref={containerRef} className="flex min-w-0 items-center gap-2 overflow-hidden whitespace-nowrap">
|
||||||
|
{visibleTags.map((tag, index) => (
|
||||||
|
<span key={`${tag}-${index}`} className={TAG_CLASS}>
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{hasOverflow ? <span className={TAG_MORE_CLASS}>{remainingCount} more...</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className="pointer-events-none absolute left-0 top-0 -z-10 opacity-0" aria-hidden="true">
|
||||||
|
{tags.map((tag, index) => (
|
||||||
|
<span
|
||||||
|
key={`${tag}-${index}`}
|
||||||
|
ref={element => {
|
||||||
|
tagRefs.current[index] = element;
|
||||||
|
}}
|
||||||
|
className={`${TAG_CLASS} inline-block`}
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<span ref={moreRef} className={`${TAG_MORE_CLASS} inline-block`}>
|
||||||
|
{tags.length} more...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function EntriesList({
|
export default function EntriesList({
|
||||||
activeGroupId,
|
activeGroupId,
|
||||||
loading,
|
loading,
|
||||||
@ -45,47 +196,21 @@ export default function EntriesList({
|
|||||||
visibleEntries.length ? (
|
visibleEntries.length ? (
|
||||||
visibleEntries.map((entry, index) => {
|
visibleEntries.map((entry, index) => {
|
||||||
const tags = entry.tags ?? [];
|
const tags = entry.tags ?? [];
|
||||||
const mobileTagLimit = 2;
|
|
||||||
const mobileTags = tags.slice(0, mobileTagLimit);
|
|
||||||
const extraTagCount = Math.max(tags.length - mobileTagLimit, 0);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
className="flex items-center justify-between rounded-lg border border-accent-weak bg-panel px-3 py-2 text-sm transition hover:border-accent hover:bg-accent-soft"
|
className="flex min-h-[84px] cursor-pointer flex-col justify-between gap-2 rounded-lg border border-accent-weak bg-panel px-3 py-2 text-sm transition hover:border-accent hover:bg-accent-soft"
|
||||||
onClick={() => onOpenDetails(entry, index)}
|
onClick={() => onOpenDetails(entry, index)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<NecessityIcon necessity={entry.necessity} />
|
||||||
<div className="text-base font-semibold">${entry.amountDollars.toFixed(2)}</div>
|
<div className="text-base font-semibold">${entry.amountDollars.toFixed(2)}</div>
|
||||||
<div className="text-xs text-muted">
|
|
||||||
{new Date(entry.occurredAt).toISOString().slice(0, 10)} - {entry.necessity}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-xs text-muted">{new Date(entry.occurredAt).toISOString().slice(0, 10)}</div>
|
||||||
</div>
|
</div>
|
||||||
{tags.length ? (
|
<EntryTagsRow tags={tags} />
|
||||||
<>
|
|
||||||
<div className="flex flex-wrap justify-end gap-2 md:hidden">
|
|
||||||
{mobileTags.map(tag => (
|
|
||||||
<span key={tag} className="rounded-full border border-accent-weak bg-accent-soft px-2 py-0.5 text-xs">
|
|
||||||
#{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{extraTagCount ? (
|
|
||||||
<span className="rounded-full border border-accent-weak bg-panel px-2 py-0.5 text-xs text-soft">
|
|
||||||
{extraTagCount} more...
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="hidden flex-wrap justify-end gap-2 md:flex">
|
|
||||||
{tags.map(tag => (
|
|
||||||
<span key={tag} className="rounded-full border border-accent-weak bg-accent-soft px-2 py-0.5 text-xs">
|
|
||||||
#{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="rounded-full border border-accent-weak px-2 py-0.5 text-xs text-soft">No tags</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
85
apps/web/features/entries/components/schedules-list.tsx
Normal file
85
apps/web/features/entries/components/schedules-list.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { Schedule } from "@/lib/shared/types";
|
||||||
|
|
||||||
|
type SchedulesListProps = {
|
||||||
|
activeGroupId: number | null;
|
||||||
|
loading: boolean;
|
||||||
|
schedules: Schedule[];
|
||||||
|
visibleSchedules: Schedule[];
|
||||||
|
activeFilterCount: number;
|
||||||
|
onOpenDetails: (schedule: Schedule, index: number) => void;
|
||||||
|
onClearFilters: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SchedulesList({
|
||||||
|
activeGroupId,
|
||||||
|
loading,
|
||||||
|
schedules,
|
||||||
|
visibleSchedules,
|
||||||
|
activeFilterCount,
|
||||||
|
onOpenDetails,
|
||||||
|
onClearFilters
|
||||||
|
}: SchedulesListProps) {
|
||||||
|
return (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{!activeGroupId ? (
|
||||||
|
<div className="text-sm text-muted">Select a group to view schedules.</div>
|
||||||
|
) : loading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[0, 1, 2].map(row => (
|
||||||
|
<div key={row} className="rounded-lg border border-accent-weak bg-panel px-3 py-3">
|
||||||
|
<div className="animate-pulse space-y-2">
|
||||||
|
<div className="h-4 w-28 rounded bg-surface" />
|
||||||
|
<div className="h-3 w-40 rounded bg-surface" />
|
||||||
|
<div className="h-3 w-36 rounded bg-surface" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : schedules.length ? (
|
||||||
|
visibleSchedules.length ? (
|
||||||
|
visibleSchedules.map((schedule, index) => (
|
||||||
|
<div
|
||||||
|
key={schedule.id}
|
||||||
|
className="flex min-h-[84px] cursor-pointer flex-col justify-between gap-2 rounded-lg border border-accent-weak bg-panel px-3 py-2 text-sm transition hover:border-accent hover:bg-accent-soft"
|
||||||
|
onClick={() => onOpenDetails(schedule, index)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="text-base font-semibold">${schedule.amountDollars.toFixed(2)}</div>
|
||||||
|
<div className={`rounded-full border px-2 py-0.5 text-[10px] ${schedule.isActive ? "border-green-400/60 bg-green-500/10 text-green-200" : "border-amber-400/60 bg-amber-500/10 text-amber-200"}`}>
|
||||||
|
{schedule.isActive ? "Active" : "Paused"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted">Next: {schedule.nextRunOn}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
{(schedule.tags || []).length ? (
|
||||||
|
schedule.tags.map(tag => (
|
||||||
|
<span key={`${schedule.id}-${tag}`} className="rounded-full border border-accent-weak bg-accent-soft px-2 py-0.5 text-xs">
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="rounded-full border border-accent-weak px-2 py-0.5 text-xs text-soft">No tags</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 text-sm text-muted">
|
||||||
|
<div>No matching schedules.</div>
|
||||||
|
{activeFilterCount ? (
|
||||||
|
<button type="button" className="rounded-lg btn-outline-accent px-3 py-1 text-xs" onClick={onClearFilters}>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted">No schedules yet.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -12,13 +12,6 @@ type CreateEntryInput = {
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
bucketId?: number | null;
|
bucketId?: number | null;
|
||||||
isRecurring?: boolean;
|
|
||||||
frequency?: "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null;
|
|
||||||
intervalCount?: number;
|
|
||||||
endCondition?: "NEVER" | "AFTER_COUNT" | "BY_DATE" | null;
|
|
||||||
endCount?: number | null;
|
|
||||||
endDate?: string | null;
|
|
||||||
nextRunAt?: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type UpdateEntryInput = CreateEntryInput & { id: number };
|
type UpdateEntryInput = CreateEntryInput & { id: number };
|
||||||
@ -63,7 +56,7 @@ export default function useEntries(activeGroupId?: number | null) {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [activeGroupId]);
|
}, [activeGroupId]);
|
||||||
|
|
||||||
const createEntry = useCallback(async (input: CreateEntryInput) => {
|
const createEntry = useCallback(async (input: CreateEntryInput): Promise<Entry | null> => {
|
||||||
setError("");
|
setError("");
|
||||||
const result = await entriesCreate(input);
|
const result = await entriesCreate(input);
|
||||||
if (isError(result)) {
|
if (isError(result)) {
|
||||||
@ -75,7 +68,7 @@ export default function useEntries(activeGroupId?: number | null) {
|
|||||||
return created;
|
return created;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateEntry = useCallback(async (input: UpdateEntryInput) => {
|
const updateEntry = useCallback(async (input: UpdateEntryInput): Promise<Entry | null> => {
|
||||||
setError("");
|
setError("");
|
||||||
const result = await entriesUpdate(input);
|
const result = await entriesUpdate(input);
|
||||||
if (isError(result)) {
|
if (isError(result)) {
|
||||||
@ -89,7 +82,7 @@ export default function useEntries(activeGroupId?: number | null) {
|
|||||||
return updated;
|
return updated;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const deleteEntry = useCallback(async (id: number | string) => {
|
const deleteEntry = useCallback(async (id: number | string): Promise<Entry | null> => {
|
||||||
setError("");
|
setError("");
|
||||||
const numericId = Number(id);
|
const numericId = Number(id);
|
||||||
if (!Number.isFinite(numericId) || numericId <= 0) return null;
|
if (!Number.isFinite(numericId) || numericId <= 0) return null;
|
||||||
|
|||||||
119
apps/web/features/entries/hooks/use-schedules.ts
Normal file
119
apps/web/features/entries/hooks/use-schedules.ts
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import type { Schedule, ScheduleEndCondition, ScheduleFrequency } from "@/lib/shared/types";
|
||||||
|
import { schedulesCreate, schedulesDelete, schedulesList, schedulesUpdate } from "@/lib/client/schedules";
|
||||||
|
import type { ApiResult } from "@/lib/client/fetch-json";
|
||||||
|
|
||||||
|
type ScheduleInput = {
|
||||||
|
entryType: "SPENDING" | "INCOME";
|
||||||
|
amountDollars: number;
|
||||||
|
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
|
||||||
|
purchaseType: string;
|
||||||
|
notes?: string;
|
||||||
|
tags?: string[];
|
||||||
|
startsOn: string;
|
||||||
|
frequency: ScheduleFrequency;
|
||||||
|
intervalCount?: number;
|
||||||
|
endCondition?: ScheduleEndCondition;
|
||||||
|
endCount?: number | null;
|
||||||
|
endDate?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isError<T>(result: ApiResult<T>): result is { error: { code: string; message: string } } {
|
||||||
|
return "error" in result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareSchedulesAsc(a: Schedule, b: Schedule) {
|
||||||
|
if (a.nextRunOn === b.nextRunOn) return Number(a.id) - Number(b.id);
|
||||||
|
return a.nextRunOn > b.nextRunOn ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertScheduleSorted(schedules: Schedule[], next: Schedule) {
|
||||||
|
const without = schedules.filter(item => Number(item.id) !== Number(next.id));
|
||||||
|
const merged = [next, ...without];
|
||||||
|
merged.sort(compareSchedulesAsc);
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useSchedules(activeGroupId?: number | null) {
|
||||||
|
const [schedules, setSchedules] = useState<Schedule[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!activeGroupId) {
|
||||||
|
setError("");
|
||||||
|
setSchedules([]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
const result = await schedulesList();
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
setSchedules([]);
|
||||||
|
} else {
|
||||||
|
const next = [...(result.data.schedules || [])];
|
||||||
|
next.sort(compareSchedulesAsc);
|
||||||
|
setSchedules(next);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, [activeGroupId]);
|
||||||
|
|
||||||
|
const createSchedule = useCallback(async (input: ScheduleInput & { createEntryNow?: boolean }): Promise<Schedule | null> => {
|
||||||
|
setError("");
|
||||||
|
const result = await schedulesCreate(input);
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const created = result.data.schedule;
|
||||||
|
setSchedules(prev => upsertScheduleSorted(prev, created));
|
||||||
|
return created;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateSchedule = useCallback(async (input: ScheduleInput & { id: number; nextRunOn?: string; isActive?: boolean }): Promise<Schedule | null> => {
|
||||||
|
setError("");
|
||||||
|
const result = await schedulesUpdate(input);
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const updated = result.data.schedule;
|
||||||
|
setSchedules(prev => upsertScheduleSorted(prev, updated));
|
||||||
|
return updated;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const deleteSchedule = useCallback(async (id: number | string): Promise<Schedule | null> => {
|
||||||
|
setError("");
|
||||||
|
const numericId = Number(id);
|
||||||
|
if (!Number.isFinite(numericId) || numericId <= 0) return null;
|
||||||
|
let removed: Schedule | null = null;
|
||||||
|
const result = await schedulesDelete({ id });
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
setSchedules(prev => {
|
||||||
|
const index = prev.findIndex(item => Number(item.id) === numericId);
|
||||||
|
if (index < 0) return prev;
|
||||||
|
removed = prev[index];
|
||||||
|
return [...prev.slice(0, index), ...prev.slice(index + 1)];
|
||||||
|
});
|
||||||
|
return removed;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
schedules,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
createSchedule,
|
||||||
|
updateSchedule,
|
||||||
|
deleteSchedule,
|
||||||
|
reload: load
|
||||||
|
};
|
||||||
|
}
|
||||||
41
apps/web/hooks/use-user-settings.ts
Normal file
41
apps/web/hooks/use-user-settings.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { userSettingsGet, userSettingsUpdate, type UserSettings } from "@/lib/client/user-settings";
|
||||||
|
import type { ApiResult } from "@/lib/client/fetch-json";
|
||||||
|
|
||||||
|
function isError<T>(result: ApiResult<T>): result is { error: { code: string; message: string } } {
|
||||||
|
return "error" in result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultSettings: UserSettings = { entryPanelPageSize: 10 };
|
||||||
|
|
||||||
|
export default function useUserSettings() {
|
||||||
|
const [settings, setSettings] = useState<UserSettings>(defaultSettings);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
const result = await userSettingsGet();
|
||||||
|
if (isError(result)) setError(result.error.message || "");
|
||||||
|
else setSettings(result.data.settings || defaultSettings);
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateSettings = useCallback(async (next: UserSettings) => {
|
||||||
|
setError("");
|
||||||
|
const result = await userSettingsUpdate(next);
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setSettings(result.data.settings);
|
||||||
|
return true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return { settings, loading, error, updateSettings, reload: load };
|
||||||
|
}
|
||||||
@ -14,13 +14,6 @@ export async function entriesCreate(input: {
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
bucketId?: number | null;
|
bucketId?: number | null;
|
||||||
isRecurring?: boolean;
|
|
||||||
frequency?: "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null;
|
|
||||||
intervalCount?: number;
|
|
||||||
endCondition?: "NEVER" | "AFTER_COUNT" | "BY_DATE" | null;
|
|
||||||
endCount?: number | null;
|
|
||||||
endDate?: string | null;
|
|
||||||
nextRunAt?: string | null;
|
|
||||||
}) {
|
}) {
|
||||||
return fetchJson<{ entry: Entry }>("/api/entries", {
|
return fetchJson<{ entry: Entry }>("/api/entries", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@ -38,13 +31,6 @@ export async function entriesUpdate(input: {
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
bucketId?: number | null;
|
bucketId?: number | null;
|
||||||
isRecurring?: boolean;
|
|
||||||
frequency?: "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null;
|
|
||||||
intervalCount?: number;
|
|
||||||
endCondition?: "NEVER" | "AFTER_COUNT" | "BY_DATE" | null;
|
|
||||||
endCount?: number | null;
|
|
||||||
endDate?: string | null;
|
|
||||||
nextRunAt?: string | null;
|
|
||||||
}) {
|
}) {
|
||||||
return fetchJson<{ entry: Entry }>(`/api/entries/${input.id}`, {
|
return fetchJson<{ entry: Entry }>(`/api/entries/${input.id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
@ -56,14 +42,7 @@ export async function entriesUpdate(input: {
|
|||||||
purchaseType: input.purchaseType,
|
purchaseType: input.purchaseType,
|
||||||
notes: input.notes,
|
notes: input.notes,
|
||||||
tags: input.tags,
|
tags: input.tags,
|
||||||
bucketId: input.bucketId,
|
bucketId: input.bucketId
|
||||||
isRecurring: input.isRecurring,
|
|
||||||
frequency: input.frequency,
|
|
||||||
intervalCount: input.intervalCount,
|
|
||||||
endCondition: input.endCondition,
|
|
||||||
endCount: input.endCount,
|
|
||||||
endDate: input.endDate,
|
|
||||||
nextRunAt: input.nextRunAt
|
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,28 @@
|
|||||||
import { fetchJson } from "@/lib/client/fetch-json";
|
import { fetchJson } from "@/lib/client/fetch-json";
|
||||||
import type { Entry } from "@/lib/shared/types";
|
|
||||||
|
export type RecurringEntryCompat = {
|
||||||
|
id: number;
|
||||||
|
entryType: "SPENDING" | "INCOME";
|
||||||
|
amountDollars: number;
|
||||||
|
occurredAt: string;
|
||||||
|
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
|
||||||
|
purchaseType: string;
|
||||||
|
notes: string | null;
|
||||||
|
receiptId: number | null;
|
||||||
|
bucketId: number | null;
|
||||||
|
tags: string[];
|
||||||
|
isRecurring: true;
|
||||||
|
frequency: "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY";
|
||||||
|
intervalCount: number;
|
||||||
|
endCondition: "NEVER" | "AFTER_COUNT" | "BY_DATE";
|
||||||
|
endCount: number | null;
|
||||||
|
endDate: string | null;
|
||||||
|
nextRunAt: string | null;
|
||||||
|
lastExecutedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export async function recurringEntriesList() {
|
export async function recurringEntriesList() {
|
||||||
return fetchJson<{ entries: Entry[] }>("/api/recurring-entries", { method: "GET" });
|
return fetchJson<{ entries: RecurringEntryCompat[] }>("/api/recurring-entries", { method: "GET" });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function recurringEntriesCreate(input: {
|
export async function recurringEntriesCreate(input: {
|
||||||
@ -14,14 +34,14 @@ export async function recurringEntriesCreate(input: {
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
bucketId?: number | null;
|
bucketId?: number | null;
|
||||||
frequency?: "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null;
|
frequency?: "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY" | null;
|
||||||
intervalCount?: number;
|
intervalCount?: number;
|
||||||
endCondition?: "NEVER" | "AFTER_COUNT" | "BY_DATE" | null;
|
endCondition?: "NEVER" | "AFTER_COUNT" | "BY_DATE" | null;
|
||||||
endCount?: number | null;
|
endCount?: number | null;
|
||||||
endDate?: string | null;
|
endDate?: string | null;
|
||||||
nextRunAt?: string | null;
|
nextRunAt?: string | null;
|
||||||
}) {
|
}) {
|
||||||
return fetchJson<{ entry: Entry }>("/api/recurring-entries", {
|
return fetchJson<{ entry: RecurringEntryCompat }>("/api/recurring-entries", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(input)
|
body: JSON.stringify(input)
|
||||||
});
|
});
|
||||||
@ -37,14 +57,14 @@ export async function recurringEntriesUpdate(input: {
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
bucketId?: number | null;
|
bucketId?: number | null;
|
||||||
frequency?: "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null;
|
frequency?: "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY" | null;
|
||||||
intervalCount?: number;
|
intervalCount?: number;
|
||||||
endCondition?: "NEVER" | "AFTER_COUNT" | "BY_DATE" | null;
|
endCondition?: "NEVER" | "AFTER_COUNT" | "BY_DATE" | null;
|
||||||
endCount?: number | null;
|
endCount?: number | null;
|
||||||
endDate?: string | null;
|
endDate?: string | null;
|
||||||
nextRunAt?: string | null;
|
nextRunAt?: string | null;
|
||||||
}) {
|
}) {
|
||||||
return fetchJson<{ entry: Entry }>(`/api/recurring-entries/${input.id}`, {
|
return fetchJson<{ entry: RecurringEntryCompat }>(`/api/recurring-entries/${input.id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
entryType: input.entryType,
|
entryType: input.entryType,
|
||||||
|
|||||||
42
apps/web/lib/client/schedules.ts
Normal file
42
apps/web/lib/client/schedules.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { fetchJson } from "@/lib/client/fetch-json";
|
||||||
|
import type { Schedule, ScheduleEndCondition, ScheduleFrequency } from "@/lib/shared/types";
|
||||||
|
|
||||||
|
type ScheduleInput = {
|
||||||
|
entryType: "SPENDING" | "INCOME";
|
||||||
|
amountDollars: number;
|
||||||
|
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
|
||||||
|
purchaseType: string;
|
||||||
|
notes?: string;
|
||||||
|
tags?: string[];
|
||||||
|
startsOn: string;
|
||||||
|
frequency: ScheduleFrequency;
|
||||||
|
intervalCount?: number;
|
||||||
|
endCondition?: ScheduleEndCondition;
|
||||||
|
endCount?: number | null;
|
||||||
|
endDate?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function schedulesList() {
|
||||||
|
return fetchJson<{ schedules: Schedule[] }>("/api/schedules", { method: "GET" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function schedulesCreate(input: ScheduleInput & { createEntryNow?: boolean }) {
|
||||||
|
return fetchJson<{ schedule: Schedule }>("/api/schedules", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function schedulesUpdate(input: ScheduleInput & { id: number; nextRunOn?: string; isActive?: boolean }) {
|
||||||
|
return fetchJson<{ schedule: Schedule }>(`/api/schedules/${input.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function schedulesDelete(input: { id: number | string }) {
|
||||||
|
const numericId = Number(input.id);
|
||||||
|
if (!Number.isFinite(numericId) || numericId <= 0)
|
||||||
|
return { error: { code: "INVALID_ID", message: "Invalid id" } } as const;
|
||||||
|
return fetchJson<{ ok: true }>(`/api/schedules/${numericId}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
16
apps/web/lib/client/user-settings.ts
Normal file
16
apps/web/lib/client/user-settings.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { fetchJson } from "@/lib/client/fetch-json";
|
||||||
|
|
||||||
|
export type UserSettings = {
|
||||||
|
entryPanelPageSize: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function userSettingsGet() {
|
||||||
|
return fetchJson<{ settings: UserSettings }>("/api/user/settings", { method: "GET" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function userSettingsUpdate(input: UserSettings) {
|
||||||
|
return fetchJson<{ settings: UserSettings }>("/api/user/settings", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -26,7 +26,6 @@ type UsageEntryRow = {
|
|||||||
amount_dollars: string | number;
|
amount_dollars: string | number;
|
||||||
occurred_at: string;
|
occurred_at: string;
|
||||||
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
|
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
|
||||||
is_recurring: boolean;
|
|
||||||
tags: string[] | null;
|
tags: string[] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -47,20 +46,19 @@ export type Bucket = {
|
|||||||
async function listUsageEntries(groupId: number) {
|
async function listUsageEntries(groupId: number) {
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
const rows = (await pool.query<UsageEntryRow>(
|
const rows = (await pool.query<UsageEntryRow>(
|
||||||
`select e.id, e.amount_dollars, e.occurred_at, e.necessity, e.is_recurring,
|
`select e.id, e.amount_dollars, e.occurred_at, e.necessity,
|
||||||
array_remove(array_agg(distinct t.name), null) as tags
|
array_remove(array_agg(distinct t.name), null) as tags
|
||||||
from entries e
|
from entries e
|
||||||
left join entry_tags et on et.entry_id = e.id
|
left join entry_tags et on et.entry_id = e.id
|
||||||
left join tags t on t.id = et.tag_id and t.group_id = $1
|
left join tags t on t.id = et.tag_id and t.group_id = $1
|
||||||
where e.group_id = $1 and e.entry_type = 'SPENDING'
|
where e.group_id = $1 and e.entry_type = 'SPENDING'
|
||||||
group by e.id, e.amount_dollars, e.occurred_at, e.necessity, e.is_recurring`,
|
group by e.id, e.amount_dollars, e.occurred_at, e.necessity`,
|
||||||
[groupId]
|
[groupId]
|
||||||
)).rows;
|
)).rows;
|
||||||
return rows.map(row => ({
|
return rows.map(row => ({
|
||||||
amountDollars: Number(row.amount_dollars),
|
amountDollars: Number(row.amount_dollars),
|
||||||
occurredAt: row.occurred_at,
|
occurredAt: row.occurred_at,
|
||||||
necessity: row.necessity,
|
necessity: row.necessity,
|
||||||
isRecurring: row.is_recurring,
|
|
||||||
tags: row.tags || [],
|
tags: row.tags || [],
|
||||||
entryType: "SPENDING" as const
|
entryType: "SPENDING" as const
|
||||||
}));
|
}));
|
||||||
|
|||||||
@ -16,14 +16,7 @@ type EntryRow = {
|
|||||||
purchase_type: string;
|
purchase_type: string;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
receipt_id: number | null;
|
receipt_id: number | null;
|
||||||
is_recurring: boolean;
|
source_schedule_id: number | null;
|
||||||
frequency: "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null;
|
|
||||||
interval_count: number;
|
|
||||||
end_condition: "NEVER" | "AFTER_COUNT" | "BY_DATE" | null;
|
|
||||||
end_count: number | null;
|
|
||||||
end_date: string | null;
|
|
||||||
next_run_at: string | null;
|
|
||||||
last_executed_at: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export { requireActiveGroup };
|
export { requireActiveGroup };
|
||||||
@ -32,7 +25,7 @@ export async function listEntries(groupId: number): Promise<Entry[]> {
|
|||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
const rows = (await pool.query<EntryRow>(
|
const rows = (await pool.query<EntryRow>(
|
||||||
`select id, entry_type, amount_dollars, occurred_at, necessity, purchase_type, notes, receipt_id,
|
`select id, entry_type, amount_dollars, occurred_at, necessity, purchase_type, notes, receipt_id,
|
||||||
is_recurring, frequency, interval_count, end_condition, end_count, end_date, next_run_at, last_executed_at
|
source_schedule_id
|
||||||
from entries
|
from entries
|
||||||
where group_id = $1
|
where group_id = $1
|
||||||
order by occurred_at desc, id desc`,
|
order by occurred_at desc, id desc`,
|
||||||
@ -51,14 +44,7 @@ export async function listEntries(groupId: number): Promise<Entry[]> {
|
|||||||
receiptId: row.receipt_id,
|
receiptId: row.receipt_id,
|
||||||
bucketId: null,
|
bucketId: null,
|
||||||
tags: tagsMap.get(Number(row.id)) || [],
|
tags: tagsMap.get(Number(row.id)) || [],
|
||||||
isRecurring: row.is_recurring,
|
sourceScheduleId: row.source_schedule_id == null ? null : Number(row.source_schedule_id)
|
||||||
frequency: row.frequency,
|
|
||||||
intervalCount: Number(row.interval_count || 1),
|
|
||||||
endCondition: row.end_condition,
|
|
||||||
endCount: row.end_count,
|
|
||||||
endDate: row.end_date,
|
|
||||||
nextRunAt: row.next_run_at,
|
|
||||||
lastExecutedAt: row.last_executed_at
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -72,23 +58,17 @@ export async function createEntry(input: {
|
|||||||
purchaseType: string;
|
purchaseType: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
isRecurring?: boolean;
|
|
||||||
frequency?: "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null;
|
|
||||||
intervalCount?: number;
|
|
||||||
endCondition?: "NEVER" | "AFTER_COUNT" | "BY_DATE" | null;
|
|
||||||
endCount?: number | null;
|
|
||||||
endDate?: string | null;
|
|
||||||
nextRunAt?: string | null;
|
|
||||||
bucketId?: number | null;
|
bucketId?: number | null;
|
||||||
|
sourceScheduleId?: number | null;
|
||||||
}) {
|
}) {
|
||||||
await enforceUserWriteRateLimit({ userId: input.userId, scope: "entries:create" });
|
await enforceUserWriteRateLimit({ userId: input.userId, scope: "entries:create" });
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
const rows = (await pool.query<EntryRow>(
|
const rows = (await pool.query<EntryRow>(
|
||||||
`insert into entries(group_id, created_by, entry_type, amount_dollars, occurred_at, necessity, purchase_type, notes,
|
`insert into entries(group_id, created_by, entry_type, amount_dollars, occurred_at, necessity, purchase_type, notes,
|
||||||
is_recurring, frequency, interval_count, end_condition, end_count, end_date, next_run_at)
|
source_schedule_id)
|
||||||
values($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
|
values($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||||
returning id, entry_type, amount_dollars, occurred_at, necessity, purchase_type, notes, receipt_id,
|
returning id, entry_type, amount_dollars, occurred_at, necessity, purchase_type, notes, receipt_id,
|
||||||
is_recurring, frequency, interval_count, end_condition, end_count, end_date, next_run_at, last_executed_at`,
|
source_schedule_id`,
|
||||||
[
|
[
|
||||||
input.groupId,
|
input.groupId,
|
||||||
input.userId,
|
input.userId,
|
||||||
@ -98,13 +78,7 @@ export async function createEntry(input: {
|
|||||||
input.necessity,
|
input.necessity,
|
||||||
input.purchaseType,
|
input.purchaseType,
|
||||||
input.notes || null,
|
input.notes || null,
|
||||||
Boolean(input.isRecurring),
|
input.sourceScheduleId ?? null
|
||||||
input.frequency || null,
|
|
||||||
input.intervalCount || 1,
|
|
||||||
input.endCondition || null,
|
|
||||||
input.endCount ?? null,
|
|
||||||
input.endDate || null,
|
|
||||||
input.nextRunAt || null
|
|
||||||
]
|
]
|
||||||
)).rows;
|
)).rows;
|
||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
@ -123,14 +97,7 @@ export async function createEntry(input: {
|
|||||||
receiptId: row.receipt_id,
|
receiptId: row.receipt_id,
|
||||||
bucketId: null,
|
bucketId: null,
|
||||||
tags,
|
tags,
|
||||||
isRecurring: row.is_recurring,
|
sourceScheduleId: row.source_schedule_id == null ? null : Number(row.source_schedule_id)
|
||||||
frequency: row.frequency,
|
|
||||||
intervalCount: Number(row.interval_count || 1),
|
|
||||||
endCondition: row.end_condition,
|
|
||||||
endCount: row.end_count,
|
|
||||||
endDate: row.end_date,
|
|
||||||
nextRunAt: row.next_run_at,
|
|
||||||
lastExecutedAt: row.last_executed_at
|
|
||||||
} as Entry;
|
} as Entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -145,24 +112,16 @@ export async function updateEntry(input: {
|
|||||||
purchaseType: string;
|
purchaseType: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
isRecurring?: boolean;
|
|
||||||
frequency?: "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null;
|
|
||||||
intervalCount?: number;
|
|
||||||
endCondition?: "NEVER" | "AFTER_COUNT" | "BY_DATE" | null;
|
|
||||||
endCount?: number | null;
|
|
||||||
endDate?: string | null;
|
|
||||||
nextRunAt?: string | null;
|
|
||||||
bucketId?: number | null;
|
bucketId?: number | null;
|
||||||
}) {
|
}) {
|
||||||
await enforceUserWriteRateLimit({ userId: input.userId, scope: "entries:update" });
|
await enforceUserWriteRateLimit({ userId: input.userId, scope: "entries:update" });
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
const rows = (await pool.query<EntryRow>(
|
const rows = (await pool.query<EntryRow>(
|
||||||
`update entries
|
`update entries
|
||||||
set entry_type=$1, amount_dollars=$2, occurred_at=$3, necessity=$4, purchase_type=$5, notes=$6,
|
set entry_type=$1, amount_dollars=$2, occurred_at=$3, necessity=$4, purchase_type=$5, notes=$6
|
||||||
is_recurring=$7, frequency=$8, interval_count=$9, end_condition=$10, end_count=$11, end_date=$12, next_run_at=$13
|
where id=$7 and group_id=$8
|
||||||
where id=$14 and group_id=$15
|
|
||||||
returning id, entry_type, amount_dollars, occurred_at, necessity, purchase_type, notes, receipt_id,
|
returning id, entry_type, amount_dollars, occurred_at, necessity, purchase_type, notes, receipt_id,
|
||||||
is_recurring, frequency, interval_count, end_condition, end_count, end_date, next_run_at, last_executed_at`,
|
source_schedule_id`,
|
||||||
[
|
[
|
||||||
input.entryType,
|
input.entryType,
|
||||||
input.amountDollars,
|
input.amountDollars,
|
||||||
@ -170,13 +129,6 @@ export async function updateEntry(input: {
|
|||||||
input.necessity,
|
input.necessity,
|
||||||
input.purchaseType,
|
input.purchaseType,
|
||||||
input.notes || null,
|
input.notes || null,
|
||||||
Boolean(input.isRecurring),
|
|
||||||
input.frequency || null,
|
|
||||||
input.intervalCount || 1,
|
|
||||||
input.endCondition || null,
|
|
||||||
input.endCount ?? null,
|
|
||||||
input.endDate || null,
|
|
||||||
input.nextRunAt || null,
|
|
||||||
input.id,
|
input.id,
|
||||||
input.groupId
|
input.groupId
|
||||||
]
|
]
|
||||||
@ -197,14 +149,7 @@ export async function updateEntry(input: {
|
|||||||
receiptId: rows[0].receipt_id,
|
receiptId: rows[0].receipt_id,
|
||||||
bucketId: null,
|
bucketId: null,
|
||||||
tags,
|
tags,
|
||||||
isRecurring: rows[0].is_recurring,
|
sourceScheduleId: rows[0].source_schedule_id == null ? null : Number(rows[0].source_schedule_id)
|
||||||
frequency: rows[0].frequency,
|
|
||||||
intervalCount: Number(rows[0].interval_count || 1),
|
|
||||||
endCondition: rows[0].end_condition,
|
|
||||||
endCount: rows[0].end_count,
|
|
||||||
endDate: rows[0].end_date,
|
|
||||||
nextRunAt: rows[0].next_run_at,
|
|
||||||
lastExecutedAt: rows[0].last_executed_at
|
|
||||||
} as Entry;
|
} as Entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,23 +1,134 @@
|
|||||||
if (process.env.NODE_ENV !== "test")
|
if (process.env.NODE_ENV !== "test")
|
||||||
require("server-only");
|
require("server-only");
|
||||||
import { createEntry, deleteEntry, listEntries, updateEntry, requireActiveGroup } from "@/lib/server/entries";
|
import { createSchedule, deleteSchedule, listSchedules, requireActiveGroup, updateSchedule } from "@/lib/server/schedules";
|
||||||
import type { Entry } from "@/lib/shared/types";
|
import type { Schedule, ScheduleEndCondition, ScheduleFrequency } from "@/lib/shared/types";
|
||||||
|
|
||||||
export { requireActiveGroup };
|
export { requireActiveGroup };
|
||||||
|
|
||||||
export async function listRecurringEntries(groupId: number): Promise<Entry[]> {
|
type RecurringEntryCompat = {
|
||||||
const entries = await listEntries(groupId);
|
id: number;
|
||||||
return entries.filter(entry => entry.isRecurring);
|
entryType: "SPENDING" | "INCOME";
|
||||||
|
amountDollars: number;
|
||||||
|
occurredAt: string;
|
||||||
|
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
|
||||||
|
purchaseType: string;
|
||||||
|
notes: string | null;
|
||||||
|
receiptId: number | null;
|
||||||
|
bucketId: number | null;
|
||||||
|
tags: string[];
|
||||||
|
isRecurring: true;
|
||||||
|
frequency: ScheduleFrequency;
|
||||||
|
intervalCount: number;
|
||||||
|
endCondition: ScheduleEndCondition;
|
||||||
|
endCount: number | null;
|
||||||
|
endDate: string | null;
|
||||||
|
nextRunAt: string | null;
|
||||||
|
lastExecutedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toRecurringCompat(schedule: Schedule): RecurringEntryCompat {
|
||||||
|
return {
|
||||||
|
id: schedule.id,
|
||||||
|
entryType: schedule.entryType,
|
||||||
|
amountDollars: schedule.amountDollars,
|
||||||
|
occurredAt: schedule.startsOn,
|
||||||
|
necessity: schedule.necessity,
|
||||||
|
purchaseType: schedule.purchaseType,
|
||||||
|
notes: schedule.notes,
|
||||||
|
receiptId: null,
|
||||||
|
bucketId: null,
|
||||||
|
tags: schedule.tags,
|
||||||
|
isRecurring: true,
|
||||||
|
frequency: schedule.frequency,
|
||||||
|
intervalCount: schedule.intervalCount,
|
||||||
|
endCondition: schedule.endCondition,
|
||||||
|
endCount: schedule.endCount,
|
||||||
|
endDate: schedule.endDate,
|
||||||
|
nextRunAt: schedule.nextRunOn,
|
||||||
|
lastExecutedAt: schedule.lastRunOn
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createRecurringEntry(input: Parameters<typeof createEntry>[0]) {
|
export async function listRecurringEntries(groupId: number): Promise<RecurringEntryCompat[]> {
|
||||||
return createEntry({ ...input, isRecurring: true });
|
const schedules = await listSchedules(groupId);
|
||||||
|
return schedules.map(toRecurringCompat);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateRecurringEntry(input: Parameters<typeof updateEntry>[0]) {
|
export async function createRecurringEntry(input: {
|
||||||
return updateEntry({ ...input, isRecurring: true });
|
groupId: number;
|
||||||
|
userId: number;
|
||||||
|
entryType: "SPENDING" | "INCOME";
|
||||||
|
amountDollars: number;
|
||||||
|
occurredAt: string;
|
||||||
|
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
|
||||||
|
purchaseType: string;
|
||||||
|
notes?: string;
|
||||||
|
tags?: string[];
|
||||||
|
frequency?: ScheduleFrequency | null;
|
||||||
|
intervalCount?: number;
|
||||||
|
endCondition?: ScheduleEndCondition | null;
|
||||||
|
endCount?: number | null;
|
||||||
|
endDate?: string | null;
|
||||||
|
}) {
|
||||||
|
const schedule = await createSchedule({
|
||||||
|
groupId: input.groupId,
|
||||||
|
userId: input.userId,
|
||||||
|
entryType: input.entryType,
|
||||||
|
amountDollars: input.amountDollars,
|
||||||
|
necessity: input.necessity,
|
||||||
|
purchaseType: input.purchaseType,
|
||||||
|
notes: input.notes,
|
||||||
|
tags: input.tags,
|
||||||
|
startsOn: input.occurredAt,
|
||||||
|
frequency: input.frequency || "MONTHLY",
|
||||||
|
intervalCount: input.intervalCount || 1,
|
||||||
|
endCondition: input.endCondition || "NEVER",
|
||||||
|
endCount: input.endCount ?? null,
|
||||||
|
endDate: input.endDate || null,
|
||||||
|
createEntryNow: true
|
||||||
|
});
|
||||||
|
return toRecurringCompat(schedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRecurringEntry(input: {
|
||||||
|
id: number;
|
||||||
|
groupId: number;
|
||||||
|
userId: number;
|
||||||
|
entryType: "SPENDING" | "INCOME";
|
||||||
|
amountDollars: number;
|
||||||
|
occurredAt: string;
|
||||||
|
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
|
||||||
|
purchaseType: string;
|
||||||
|
notes?: string;
|
||||||
|
tags?: string[];
|
||||||
|
frequency?: ScheduleFrequency | null;
|
||||||
|
intervalCount?: number;
|
||||||
|
endCondition?: ScheduleEndCondition | null;
|
||||||
|
endCount?: number | null;
|
||||||
|
endDate?: string | null;
|
||||||
|
nextRunAt?: string | null;
|
||||||
|
}) {
|
||||||
|
const schedule = await updateSchedule({
|
||||||
|
id: input.id,
|
||||||
|
groupId: input.groupId,
|
||||||
|
userId: input.userId,
|
||||||
|
entryType: input.entryType,
|
||||||
|
amountDollars: input.amountDollars,
|
||||||
|
necessity: input.necessity,
|
||||||
|
purchaseType: input.purchaseType,
|
||||||
|
notes: input.notes,
|
||||||
|
tags: input.tags,
|
||||||
|
startsOn: input.occurredAt,
|
||||||
|
frequency: input.frequency || "MONTHLY",
|
||||||
|
intervalCount: input.intervalCount || 1,
|
||||||
|
endCondition: input.endCondition || "NEVER",
|
||||||
|
endCount: input.endCount ?? null,
|
||||||
|
endDate: input.endDate || null,
|
||||||
|
nextRunOn: input.nextRunAt || input.occurredAt
|
||||||
|
});
|
||||||
|
return schedule ? toRecurringCompat(schedule) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteRecurringEntry(input: { id: number; groupId: number; userId: number }) {
|
export async function deleteRecurringEntry(input: { id: number; groupId: number; userId: number }) {
|
||||||
return deleteEntry(input);
|
return deleteSchedule(input);
|
||||||
}
|
}
|
||||||
|
|||||||
233
apps/web/lib/server/schedules.ts
Normal file
233
apps/web/lib/server/schedules.ts
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
if (process.env.NODE_ENV !== "test")
|
||||||
|
require("server-only");
|
||||||
|
import getPool from "@/lib/server/db";
|
||||||
|
import { createEntry, requireActiveGroup } from "@/lib/server/entries";
|
||||||
|
import { enforceUserWriteRateLimit } from "@/lib/server/rate-limit";
|
||||||
|
import { listTagsForSchedules, normalizeTags, requireExistingTagsForGroup, setScheduleTags } from "@/lib/server/tags";
|
||||||
|
import type { Schedule, ScheduleEndCondition, ScheduleFrequency } from "@/lib/shared/types";
|
||||||
|
|
||||||
|
type ScheduleRow = {
|
||||||
|
id: number;
|
||||||
|
entry_type: "SPENDING" | "INCOME";
|
||||||
|
amount_dollars: string | number;
|
||||||
|
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
|
||||||
|
purchase_type: string;
|
||||||
|
notes: string | null;
|
||||||
|
starts_on: string;
|
||||||
|
frequency: ScheduleFrequency;
|
||||||
|
interval_count: number;
|
||||||
|
end_condition: ScheduleEndCondition;
|
||||||
|
end_count: number | null;
|
||||||
|
end_date: string | null;
|
||||||
|
next_run_on: string;
|
||||||
|
last_run_on: string | null;
|
||||||
|
run_count: number;
|
||||||
|
is_active: boolean;
|
||||||
|
legacy_source_entry_id: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreateScheduleInput = {
|
||||||
|
groupId: number;
|
||||||
|
userId: number;
|
||||||
|
entryType: "SPENDING" | "INCOME";
|
||||||
|
amountDollars: number;
|
||||||
|
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
|
||||||
|
purchaseType: string;
|
||||||
|
notes?: string;
|
||||||
|
tags?: string[];
|
||||||
|
startsOn: string;
|
||||||
|
frequency: ScheduleFrequency;
|
||||||
|
intervalCount?: number;
|
||||||
|
endCondition?: ScheduleEndCondition;
|
||||||
|
endCount?: number | null;
|
||||||
|
endDate?: string | null;
|
||||||
|
createEntryNow?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UpdateScheduleInput = {
|
||||||
|
id: number;
|
||||||
|
groupId: number;
|
||||||
|
userId: number;
|
||||||
|
entryType: "SPENDING" | "INCOME";
|
||||||
|
amountDollars: number;
|
||||||
|
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
|
||||||
|
purchaseType: string;
|
||||||
|
notes?: string;
|
||||||
|
tags?: string[];
|
||||||
|
startsOn: string;
|
||||||
|
frequency: ScheduleFrequency;
|
||||||
|
intervalCount?: number;
|
||||||
|
endCondition?: ScheduleEndCondition;
|
||||||
|
endCount?: number | null;
|
||||||
|
endDate?: string | null;
|
||||||
|
nextRunOn?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { requireActiveGroup };
|
||||||
|
|
||||||
|
function addInterval(dateIso: string, frequency: ScheduleFrequency, intervalCount: number) {
|
||||||
|
const safeInterval = Math.max(1, Number(intervalCount || 1));
|
||||||
|
const date = new Date(`${dateIso}T00:00:00Z`);
|
||||||
|
if (Number.isNaN(date.getTime())) return dateIso;
|
||||||
|
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 toSchedule(row: ScheduleRow, tags: string[]): Schedule {
|
||||||
|
return {
|
||||||
|
id: Number(row.id),
|
||||||
|
entryType: row.entry_type,
|
||||||
|
amountDollars: Number(row.amount_dollars),
|
||||||
|
necessity: row.necessity,
|
||||||
|
purchaseType: row.purchase_type,
|
||||||
|
notes: row.notes,
|
||||||
|
startsOn: row.starts_on,
|
||||||
|
frequency: row.frequency,
|
||||||
|
intervalCount: Number(row.interval_count || 1),
|
||||||
|
endCondition: row.end_condition,
|
||||||
|
endCount: row.end_count,
|
||||||
|
endDate: row.end_date,
|
||||||
|
nextRunOn: row.next_run_on,
|
||||||
|
lastRunOn: row.last_run_on,
|
||||||
|
runCount: Number(row.run_count || 0),
|
||||||
|
isActive: Boolean(row.is_active),
|
||||||
|
legacySourceEntryId: row.legacy_source_entry_id == null ? null : Number(row.legacy_source_entry_id),
|
||||||
|
tags
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSchedules(groupId: number): Promise<Schedule[]> {
|
||||||
|
const pool = getPool();
|
||||||
|
const rows = (await pool.query<ScheduleRow>(
|
||||||
|
`select id, entry_type, amount_dollars, necessity, purchase_type, notes, starts_on, frequency, interval_count,
|
||||||
|
end_condition, end_count, end_date, next_run_on, last_run_on, run_count, is_active, legacy_source_entry_id
|
||||||
|
from schedules
|
||||||
|
where group_id=$1
|
||||||
|
order by next_run_on asc, id asc`,
|
||||||
|
[groupId]
|
||||||
|
)).rows;
|
||||||
|
const scheduleIds = rows.map(row => Number(row.id));
|
||||||
|
const tagsMap = await listTagsForSchedules({ groupId, scheduleIds });
|
||||||
|
return rows.map(row => toSchedule(row, tagsMap.get(Number(row.id)) || []));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSchedule(input: CreateScheduleInput): Promise<Schedule> {
|
||||||
|
await enforceUserWriteRateLimit({ userId: input.userId, scope: "schedules:create" });
|
||||||
|
const pool = getPool();
|
||||||
|
const safeIntervalCount = Math.max(1, Number(input.intervalCount || 1));
|
||||||
|
const endCondition = input.endCondition || "NEVER";
|
||||||
|
const rows = (await pool.query<ScheduleRow>(
|
||||||
|
`insert into schedules(group_id, created_by, entry_type, amount_dollars, necessity, purchase_type, notes, starts_on,
|
||||||
|
frequency, interval_count, end_condition, end_count, end_date, next_run_on, is_active)
|
||||||
|
values($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,true)
|
||||||
|
returning id, entry_type, amount_dollars, necessity, purchase_type, notes, starts_on, frequency, interval_count,
|
||||||
|
end_condition, end_count, end_date, next_run_on, last_run_on, run_count, is_active, legacy_source_entry_id`,
|
||||||
|
[
|
||||||
|
input.groupId,
|
||||||
|
input.userId,
|
||||||
|
input.entryType,
|
||||||
|
input.amountDollars,
|
||||||
|
input.necessity,
|
||||||
|
input.purchaseType,
|
||||||
|
input.notes || null,
|
||||||
|
input.startsOn,
|
||||||
|
input.frequency,
|
||||||
|
safeIntervalCount,
|
||||||
|
endCondition,
|
||||||
|
input.endCount ?? null,
|
||||||
|
input.endDate || null,
|
||||||
|
input.startsOn
|
||||||
|
]
|
||||||
|
)).rows;
|
||||||
|
const created = rows[0];
|
||||||
|
const tags = normalizeTags(input.tags || []);
|
||||||
|
const tagIds = await requireExistingTagsForGroup({ groupId: input.groupId, tags });
|
||||||
|
await setScheduleTags({ scheduleId: Number(created.id), tagIds });
|
||||||
|
|
||||||
|
if (input.createEntryNow) {
|
||||||
|
const nextRunOn = addInterval(input.startsOn, input.frequency, safeIntervalCount);
|
||||||
|
await createEntry({
|
||||||
|
groupId: input.groupId,
|
||||||
|
userId: input.userId,
|
||||||
|
entryType: input.entryType,
|
||||||
|
amountDollars: input.amountDollars,
|
||||||
|
occurredAt: input.startsOn,
|
||||||
|
necessity: input.necessity,
|
||||||
|
purchaseType: input.purchaseType,
|
||||||
|
notes: input.notes,
|
||||||
|
tags,
|
||||||
|
sourceScheduleId: Number(created.id)
|
||||||
|
});
|
||||||
|
const updatedRows = (await pool.query<ScheduleRow>(
|
||||||
|
`update schedules
|
||||||
|
set next_run_on=$1, last_run_on=$2, run_count=1, updated_at=now()
|
||||||
|
where id=$3 and group_id=$4
|
||||||
|
returning id, entry_type, amount_dollars, necessity, purchase_type, notes, starts_on, frequency, interval_count,
|
||||||
|
end_condition, end_count, end_date, next_run_on, last_run_on, run_count, is_active, legacy_source_entry_id`,
|
||||||
|
[nextRunOn, input.startsOn, Number(created.id), input.groupId]
|
||||||
|
)).rows;
|
||||||
|
return toSchedule(updatedRows[0], tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
return toSchedule(created, tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSchedule(input: UpdateScheduleInput): Promise<Schedule | null> {
|
||||||
|
await enforceUserWriteRateLimit({ userId: input.userId, scope: "schedules:update" });
|
||||||
|
const pool = getPool();
|
||||||
|
const safeIntervalCount = Math.max(1, Number(input.intervalCount || 1));
|
||||||
|
const endCondition = input.endCondition || "NEVER";
|
||||||
|
const nextRunOn = input.nextRunOn || input.startsOn;
|
||||||
|
const rows = (await pool.query<ScheduleRow>(
|
||||||
|
`update schedules
|
||||||
|
set entry_type=$1,
|
||||||
|
amount_dollars=$2,
|
||||||
|
necessity=$3,
|
||||||
|
purchase_type=$4,
|
||||||
|
notes=$5,
|
||||||
|
starts_on=$6,
|
||||||
|
frequency=$7,
|
||||||
|
interval_count=$8,
|
||||||
|
end_condition=$9,
|
||||||
|
end_count=$10,
|
||||||
|
end_date=$11,
|
||||||
|
next_run_on=$12,
|
||||||
|
is_active=$13,
|
||||||
|
updated_at=now()
|
||||||
|
where id=$14 and group_id=$15
|
||||||
|
returning id, entry_type, amount_dollars, necessity, purchase_type, notes, starts_on, frequency, interval_count,
|
||||||
|
end_condition, end_count, end_date, next_run_on, last_run_on, run_count, is_active, legacy_source_entry_id`,
|
||||||
|
[
|
||||||
|
input.entryType,
|
||||||
|
input.amountDollars,
|
||||||
|
input.necessity,
|
||||||
|
input.purchaseType,
|
||||||
|
input.notes || null,
|
||||||
|
input.startsOn,
|
||||||
|
input.frequency,
|
||||||
|
safeIntervalCount,
|
||||||
|
endCondition,
|
||||||
|
input.endCount ?? null,
|
||||||
|
input.endDate || null,
|
||||||
|
nextRunOn,
|
||||||
|
input.isActive ?? true,
|
||||||
|
input.id,
|
||||||
|
input.groupId
|
||||||
|
]
|
||||||
|
)).rows;
|
||||||
|
if (!rows[0]) return null;
|
||||||
|
const tags = normalizeTags(input.tags || []);
|
||||||
|
const tagIds = await requireExistingTagsForGroup({ groupId: input.groupId, tags });
|
||||||
|
await setScheduleTags({ scheduleId: Number(input.id), tagIds });
|
||||||
|
return toSchedule(rows[0], tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSchedule(input: { id: number; groupId: number; userId: number }) {
|
||||||
|
await enforceUserWriteRateLimit({ userId: input.userId, scope: "schedules:delete" });
|
||||||
|
const pool = getPool();
|
||||||
|
await pool.query("delete from schedules where id=$1 and group_id=$2", [input.id, input.groupId]);
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ type TagRow = { id: number; name: string };
|
|||||||
type TagListRow = { name: string };
|
type TagListRow = { name: string };
|
||||||
type EntryTagsRow = { entry_id: number; tags: string[] };
|
type EntryTagsRow = { entry_id: number; tags: string[] };
|
||||||
type BucketTagsRow = { bucket_id: number; tags: string[] };
|
type BucketTagsRow = { bucket_id: number; tags: string[] };
|
||||||
|
type ScheduleTagsRow = { schedule_id: number; tags: string[] };
|
||||||
|
|
||||||
function normalizeTag(tag: string) {
|
function normalizeTag(tag: string) {
|
||||||
return tag
|
return tag
|
||||||
@ -128,6 +129,32 @@ export async function listTagsForEntries(input: { groupId: number; entryIds: num
|
|||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setScheduleTags(input: { scheduleId: number; tagIds: number[] }) {
|
||||||
|
const pool = getPool();
|
||||||
|
await pool.query("delete from schedule_tags where schedule_id=$1", [input.scheduleId]);
|
||||||
|
if (!input.tagIds.length) return;
|
||||||
|
|
||||||
|
const values = input.tagIds.map((_, idx) => `($1,$${idx + 2})`).join(",");
|
||||||
|
const params = [input.scheduleId, ...input.tagIds];
|
||||||
|
await pool.query(`insert into schedule_tags(schedule_id, tag_id) values ${values}`, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listTagsForSchedules(input: { groupId: number; scheduleIds: number[] }) {
|
||||||
|
if (!input.scheduleIds.length) return new Map<number, string[]>();
|
||||||
|
const pool = getPool();
|
||||||
|
const rows = (await pool.query<ScheduleTagsRow>(
|
||||||
|
`select st.schedule_id, array_agg(t.name order by t.name asc) as tags
|
||||||
|
from schedule_tags st
|
||||||
|
join tags t on t.id = st.tag_id
|
||||||
|
where t.group_id=$1 and st.schedule_id = any($2::bigint[])
|
||||||
|
group by st.schedule_id`,
|
||||||
|
[input.groupId, input.scheduleIds]
|
||||||
|
)).rows;
|
||||||
|
const map = new Map<number, string[]>();
|
||||||
|
rows.forEach((row: ScheduleTagsRow) => map.set(Number(row.schedule_id), (row.tags || []).map((tag: string) => String(tag))));
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
export async function setBucketTags(input: { bucketId: number; tagIds: number[] }) {
|
export async function setBucketTags(input: { bucketId: number; tagIds: number[] }) {
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
await pool.query("delete from bucket_tags where bucket_id=$1", [input.bucketId]);
|
await pool.query("delete from bucket_tags where bucket_id=$1", [input.bucketId]);
|
||||||
|
|||||||
50
apps/web/lib/server/user-settings.ts
Normal file
50
apps/web/lib/server/user-settings.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
if (process.env.NODE_ENV !== "test")
|
||||||
|
require("server-only");
|
||||||
|
import getPool from "@/lib/server/db";
|
||||||
|
import { enforceUserWriteRateLimit } from "@/lib/server/rate-limit";
|
||||||
|
|
||||||
|
const DEFAULT_ENTRY_PANEL_PAGE_SIZE = 10;
|
||||||
|
const MIN_ENTRY_PANEL_PAGE_SIZE = 1;
|
||||||
|
const MAX_ENTRY_PANEL_PAGE_SIZE = 100;
|
||||||
|
|
||||||
|
export type UserSettings = {
|
||||||
|
entryPanelPageSize: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeEntryPanelPageSize(value: unknown) {
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (!Number.isFinite(numeric))
|
||||||
|
return DEFAULT_ENTRY_PANEL_PAGE_SIZE;
|
||||||
|
return Math.min(MAX_ENTRY_PANEL_PAGE_SIZE, Math.max(MIN_ENTRY_PANEL_PAGE_SIZE, Math.floor(numeric)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultUserSettings(): UserSettings {
|
||||||
|
return { entryPanelPageSize: DEFAULT_ENTRY_PANEL_PAGE_SIZE };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserSettings(userId: number): Promise<UserSettings> {
|
||||||
|
const pool = getPool();
|
||||||
|
const { rows } = await pool.query<{ data: Record<string, unknown> }>(
|
||||||
|
"select data from user_settings where user_id=$1",
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
const data = rows[0]?.data || {};
|
||||||
|
return {
|
||||||
|
entryPanelPageSize: normalizeEntryPanelPageSize(data.entryPanelPageSize)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setUserSettings(input: { userId: number; entryPanelPageSize: number }) {
|
||||||
|
await enforceUserWriteRateLimit({ userId: input.userId, scope: "user-settings:update" });
|
||||||
|
const pool = getPool();
|
||||||
|
const entryPanelPageSize = normalizeEntryPanelPageSize(input.entryPanelPageSize);
|
||||||
|
await pool.query(
|
||||||
|
`insert into user_settings(user_id, data)
|
||||||
|
values($1, jsonb_build_object('entryPanelPageSize', $2::int))
|
||||||
|
on conflict (user_id) do update
|
||||||
|
set data = user_settings.data || jsonb_build_object('entryPanelPageSize', $2::int),
|
||||||
|
updated_at = now()`,
|
||||||
|
[input.userId, entryPanelPageSize]
|
||||||
|
);
|
||||||
|
return { entryPanelPageSize };
|
||||||
|
}
|
||||||
@ -9,7 +9,6 @@ export type BucketUsageEntry = {
|
|||||||
occurredAt: string | Date;
|
occurredAt: string | Date;
|
||||||
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
|
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
|
||||||
tags: string[];
|
tags: string[];
|
||||||
isRecurring: boolean;
|
|
||||||
entryType?: "SPENDING" | "INCOME";
|
entryType?: "SPENDING" | "INCOME";
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -50,7 +49,6 @@ export function calculateBucketUsage(bucket: BucketUsageBucket, entries: BucketU
|
|||||||
|
|
||||||
entries.forEach(entry => {
|
entries.forEach(entry => {
|
||||||
if (entry.entryType && entry.entryType !== "SPENDING") return;
|
if (entry.entryType && entry.entryType !== "SPENDING") return;
|
||||||
if (entry.isRecurring) return;
|
|
||||||
if (!hasAllTags(bucket.tags || [], entry.tags || [])) return;
|
if (!hasAllTags(bucket.tags || [], entry.tags || [])) return;
|
||||||
|
|
||||||
const occurred = toDateOnly(entry.occurredAt);
|
const occurred = toDateOnly(entry.occurredAt);
|
||||||
|
|||||||
@ -21,12 +21,29 @@ export type Entry = {
|
|||||||
receiptId: number | null;
|
receiptId: number | null;
|
||||||
bucketId: number | null;
|
bucketId: number | null;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
isRecurring: boolean;
|
sourceScheduleId: number | null;
|
||||||
frequency: "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null;
|
};
|
||||||
|
|
||||||
|
export type ScheduleFrequency = "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY";
|
||||||
|
export type ScheduleEndCondition = "NEVER" | "AFTER_COUNT" | "BY_DATE";
|
||||||
|
|
||||||
|
export type Schedule = {
|
||||||
|
id: number;
|
||||||
|
entryType: "SPENDING" | "INCOME";
|
||||||
|
amountDollars: number;
|
||||||
|
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
|
||||||
|
purchaseType: string;
|
||||||
|
notes: string | null;
|
||||||
|
startsOn: string;
|
||||||
|
frequency: ScheduleFrequency;
|
||||||
intervalCount: number;
|
intervalCount: number;
|
||||||
endCondition: "NEVER" | "AFTER_COUNT" | "BY_DATE" | null;
|
endCondition: ScheduleEndCondition;
|
||||||
endCount: number | null;
|
endCount: number | null;
|
||||||
endDate: string | null;
|
endDate: string | null;
|
||||||
nextRunAt: string | null;
|
nextRunOn: string;
|
||||||
lastExecutedAt: string | null;
|
lastRunOn: string | null;
|
||||||
|
runCount: number;
|
||||||
|
isActive: boolean;
|
||||||
|
legacySourceEntryId: number | null;
|
||||||
|
tags: string[];
|
||||||
};
|
};
|
||||||
|
|||||||
2
apps/web/next-env.d.ts
vendored
2
apps/web/next-env.d.ts
vendored
@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
13
docker/Dockerfile.scheduler
Normal file
13
docker/Dockerfile.scheduler
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
FROM node:20-slim
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
COPY apps/scheduler/package*.json apps/scheduler/
|
||||||
|
COPY apps/web/package*.json apps/web/
|
||||||
|
COPY packages/db/package*.json packages/db/
|
||||||
|
COPY packages/shared/package*.json packages/shared/
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY apps/scheduler ./apps/scheduler
|
||||||
|
|
||||||
|
CMD ["npm", "run", "start", "--workspace", "apps/scheduler"]
|
||||||
23
docker/nginx/npm/http_top.conf.example
Normal file
23
docker/nginx/npm/http_top.conf.example
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Place in NPM global custom config loaded in `http` context
|
||||||
|
# (commonly `/data/nginx/custom/http_top.conf`).
|
||||||
|
|
||||||
|
limit_req_zone $binary_remote_addr zone=fiddy_auth:10m rate=10r/m;
|
||||||
|
limit_req_zone $binary_remote_addr zone=fiddy_write:10m rate=60r/m;
|
||||||
|
limit_conn_zone $binary_remote_addr zone=fiddy_conn:10m;
|
||||||
|
|
||||||
|
log_format fiddy_json escape=json
|
||||||
|
'{'
|
||||||
|
'"time":"$time_iso8601",'
|
||||||
|
'"remote_addr":"$remote_addr",'
|
||||||
|
'"request_id":"$request_id",'
|
||||||
|
'"request_method":"$request_method",'
|
||||||
|
'"uri":"$request_uri",'
|
||||||
|
'"status":$status,'
|
||||||
|
'"bytes_sent":$body_bytes_sent,'
|
||||||
|
'"request_time":$request_time,'
|
||||||
|
'"upstream_addr":"$upstream_addr",'
|
||||||
|
'"upstream_status":"$upstream_status",'
|
||||||
|
'"upstream_response_time":"$upstream_response_time",'
|
||||||
|
'"http_referer":"$http_referer",'
|
||||||
|
'"http_user_agent":"$http_user_agent"'
|
||||||
|
'}';
|
||||||
12
docker/nginx/npm/location-auth-advanced.conf.example
Normal file
12
docker/nginx/npm/location-auth-advanced.conf.example
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
# Paste into NPM Custom Location advanced config for:
|
||||||
|
# - /api/auth/login
|
||||||
|
# - /api/auth/register
|
||||||
|
#
|
||||||
|
# Requires `limit_req_zone fiddy_auth` in http context.
|
||||||
|
|
||||||
|
# Keep request-id forwarding/header here too because this location is
|
||||||
|
# more specific than `/` and can bypass root location directives.
|
||||||
|
add_header X-Request-Id $request_id always;
|
||||||
|
proxy_set_header X-Request-Id $request_id;
|
||||||
|
|
||||||
|
limit_req zone=fiddy_auth burst=15 nodelay;
|
||||||
15
docker/nginx/npm/location-root-advanced.conf.example
Normal file
15
docker/nginx/npm/location-root-advanced.conf.example
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# Paste into NPM Custom Location advanced config for:
|
||||||
|
# - /
|
||||||
|
#
|
||||||
|
# This is where NPM reliably applies add_header/proxy_set_header directives.
|
||||||
|
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header X-Request-Id $request_id always;
|
||||||
|
|
||||||
|
proxy_set_header X-Request-Id $request_id;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
proxy_send_timeout 60s;
|
||||||
|
proxy_connect_timeout 5s;
|
||||||
18
docker/nginx/npm/location-write-advanced.conf.example
Normal file
18
docker/nginx/npm/location-write-advanced.conf.example
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Paste into NPM Custom Location advanced config for write-heavy API paths,
|
||||||
|
# for example:
|
||||||
|
# - /api/entries
|
||||||
|
# - /api/buckets
|
||||||
|
# - /api/groups
|
||||||
|
# - /api/tags
|
||||||
|
# - /api/recurring-entries
|
||||||
|
#
|
||||||
|
# Requires `limit_req_zone fiddy_write` in http context.
|
||||||
|
|
||||||
|
# Keep request-id forwarding/header here too because this location is
|
||||||
|
# more specific than `/` and can bypass root location directives.
|
||||||
|
add_header X-Request-Id $request_id always;
|
||||||
|
proxy_set_header X-Request-Id $request_id;
|
||||||
|
|
||||||
|
if ($request_method ~* "(POST|PATCH|PUT|DELETE)") {
|
||||||
|
limit_req zone=fiddy_write burst=40 nodelay;
|
||||||
|
}
|
||||||
21
docker/nginx/npm/proxy-host-advanced.conf.example
Normal file
21
docker/nginx/npm/proxy-host-advanced.conf.example
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# Paste into Nginx Proxy Manager -> Proxy Host -> Advanced
|
||||||
|
# (server block context for this host).
|
||||||
|
#
|
||||||
|
# IMPORTANT:
|
||||||
|
# In NPM, `add_header` and `proxy_set_header` do not reliably apply from this section.
|
||||||
|
# Put those directives in Custom Location advanced config (especially location `/`).
|
||||||
|
|
||||||
|
server_tokens off;
|
||||||
|
|
||||||
|
client_max_body_size 10m;
|
||||||
|
client_body_timeout 15s;
|
||||||
|
client_header_timeout 15s;
|
||||||
|
keepalive_timeout 30s;
|
||||||
|
send_timeout 30s;
|
||||||
|
|
||||||
|
# Requires `limit_conn_zone` defined globally in http context.
|
||||||
|
limit_conn fiddy_conn 50;
|
||||||
|
|
||||||
|
# Prefer dedicated per-host log path if available on your NPM host.
|
||||||
|
access_log /var/log/nginx/fiddy-access.log fiddy_json;
|
||||||
|
error_log /var/log/nginx/fiddy-error.log warn;
|
||||||
@ -133,6 +133,21 @@ Primary outcomes:
|
|||||||
- `scripts/log-restore-drill.sh` to append timestamped restore outcomes and measured RTO.
|
- `scripts/log-restore-drill.sh` to append timestamped restore outcomes and measured RTO.
|
||||||
- Added consolidated execution checklist:
|
- Added consolidated execution checklist:
|
||||||
- `docs/07_PUBLIC_LAUNCH_CHECKLIST.md` for go-live gating across infra, deploy, security, observability, DR, and rollback.
|
- `docs/07_PUBLIC_LAUNCH_CHECKLIST.md` for go-live gating across infra, deploy, security, observability, DR, and rollback.
|
||||||
|
- Clarified deployment assumption:
|
||||||
|
- use existing external Nginx edge; `docker/nginx/*` is now documented as template/reference config, not a required new Nginx runtime.
|
||||||
|
- Added Nginx Proxy Manager setup pack:
|
||||||
|
- `docs/08_NGINX_PROXY_MANAGER_SETUP.md` (UI + SSH fallback instructions).
|
||||||
|
- `docker/nginx/npm/*.example` snippets for host advanced config, location limits, and http-level zones.
|
||||||
|
- Adjusted NPM guidance based on real NPM behavior:
|
||||||
|
- moved header directives to custom location snippets (especially `/`) because Proxy Host Advanced does not reliably apply `add_header`/`proxy_set_header`.
|
||||||
|
- Added staged operator playbook:
|
||||||
|
- `docs/09_DEPLOYMENT_EXECUTION_PLAYBOOK.md` to separate no-touch prep from hands-on infra steps.
|
||||||
|
- Deferred (tracked) for later session:
|
||||||
|
- host-specific NPM tailoring for exact domain/upstream/custom-location layout.
|
||||||
|
- Added NPM execution runsheet:
|
||||||
|
- `docs/10_NPM_HANDS_ON_RUNSHEET.md` with strict run order and verification checkpoints.
|
||||||
|
- Added first-time Dokploy onboarding guide:
|
||||||
|
- `docs/11_DOKPLOY_FIRST_TIME_WALKTHROUGH.md` with install, app wiring, webhook, and rollback test flow.
|
||||||
|
|
||||||
### Risks / Notes to Revisit
|
### Risks / Notes to Revisit
|
||||||
- Workspace is intentionally dirty; commits must be path-scoped to avoid mixing unrelated changes.
|
- Workspace is intentionally dirty; commits must be path-scoped to avoid mixing unrelated changes.
|
||||||
|
|||||||
@ -8,9 +8,9 @@ This document tracks launch-critical security findings for app, data, users, and
|
|||||||
1. Direct home-IP exposure increases scanning and DDoS risk.
|
1. Direct home-IP exposure increases scanning and DDoS risk.
|
||||||
- Status: Partially mitigated.
|
- Status: Partially mitigated.
|
||||||
- Mitigations in repo:
|
- Mitigations in repo:
|
||||||
- TLS + HTTPS redirect (`docker/nginx/fiddy.conf`)
|
- TLS + HTTPS redirect (apply via existing Nginx using `docker/nginx/fiddy.conf` template)
|
||||||
- request rate limits (`docker/nginx/fiddy.conf`)
|
- request rate limits (existing Nginx with `docker/nginx/fiddy.conf` template)
|
||||||
- connection cap (`docker/nginx/fiddy.conf`)
|
- connection cap (existing Nginx with `docker/nginx/fiddy.conf` template)
|
||||||
- Required ops actions:
|
- Required ops actions:
|
||||||
- enforce host firewall allowlist rules
|
- enforce host firewall allowlist rules
|
||||||
- restrict SSH to VPN or fixed allowlist
|
- restrict SSH to VPN or fixed allowlist
|
||||||
@ -68,7 +68,6 @@ This document tracks launch-critical security findings for app, data, users, and
|
|||||||
- [x] `npm run build` passes.
|
- [x] `npm run build` passes.
|
||||||
- [ ] Production host firewall rules verified (`scripts/harden-host-ufw.sh` + `scripts/check-host-security.sh`).
|
- [ ] Production host firewall rules verified (`scripts/harden-host-ufw.sh` + `scripts/check-host-security.sh`).
|
||||||
- [ ] SSH restricted to VPN/allowlist.
|
- [ ] SSH restricted to VPN/allowlist.
|
||||||
- [ ] Backup restore drill logged for current week (`scripts/restore-drill-postgres.sh`).
|
|
||||||
- [ ] Backup restore drill logged for current week (`scripts/restore-drill-postgres.sh` + `scripts/log-restore-drill.sh`).
|
- [ ] Backup restore drill logged for current week (`scripts/restore-drill-postgres.sh` + `scripts/log-restore-drill.sh`).
|
||||||
- [ ] Base backup job configured and validated (`scripts/basebackup-postgres.sh`).
|
- [ ] Base backup job configured and validated (`scripts/basebackup-postgres.sh`).
|
||||||
- [ ] Auto-ban tooling configured (fail2ban or crowdsec) from `docker/security/`.
|
- [ ] Auto-ban tooling configured (fail2ban or crowdsec) from `docker/security/`.
|
||||||
|
|||||||
@ -17,13 +17,17 @@
|
|||||||
- [ ] `SESSION_TTL_DAYS`
|
- [ ] `SESSION_TTL_DAYS`
|
||||||
- [ ] `DEBUG_API=0`
|
- [ ] `DEBUG_API=0`
|
||||||
- [ ] `DOKPLOY_DEPLOY_HOOK`
|
- [ ] `DOKPLOY_DEPLOY_HOOK`
|
||||||
|
- [ ] `DOKPLOY_SCHEDULER_DEPLOY_HOOK`
|
||||||
- [ ] `DOKPLOY_HEALTHCHECK_URL`
|
- [ ] `DOKPLOY_HEALTHCHECK_URL`
|
||||||
- [ ] Deploy workflow passes build/test/push/deploy.
|
- [ ] Deploy workflow passes build/test/push/deploy.
|
||||||
|
- [ ] Scheduler deploy workflow step passes.
|
||||||
- [ ] Post-deploy health gate passes (`scripts/wait-for-health.sh`).
|
- [ ] Post-deploy health gate passes (`scripts/wait-for-health.sh`).
|
||||||
- [ ] Manual smoke passes (`scripts/smoke-public-launch.sh`).
|
- [ ] Manual smoke passes (`scripts/smoke-public-launch.sh`).
|
||||||
|
|
||||||
## C) Security Controls
|
## C) Security Controls
|
||||||
- [ ] Nginx TLS/headers/rate limits enabled (`docker/nginx/fiddy.conf`).
|
- [ ] Existing Nginx TLS/headers/rate limits enabled (using `docker/nginx/fiddy.conf` template).
|
||||||
|
- [ ] If using NPM, `docs/08_NGINX_PROXY_MANAGER_SETUP.md` completed.
|
||||||
|
- [ ] If using NPM, Custom Location `/` includes header/request-id snippet.
|
||||||
- [ ] Request-id propagation enabled (`X-Request-Id` in responses).
|
- [ ] Request-id propagation enabled (`X-Request-Id` in responses).
|
||||||
- [ ] Server-side rate limits active (auth/write/ip limiters).
|
- [ ] Server-side rate limits active (auth/write/ip limiters).
|
||||||
- [ ] Fail2ban or CrowdSec configured from `docker/security/`.
|
- [ ] Fail2ban or CrowdSec configured from `docker/security/`.
|
||||||
|
|||||||
115
docs/08_NGINX_PROXY_MANAGER_SETUP.md
Normal file
115
docs/08_NGINX_PROXY_MANAGER_SETUP.md
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
# Nginx Proxy Manager Setup (Existing Edge)
|
||||||
|
|
||||||
|
This guide assumes you already run Nginx Proxy Manager (NPM) as your shared reverse proxy and want to route Fiddy through it.
|
||||||
|
|
||||||
|
## 1) Proxy Host in NPM UI
|
||||||
|
1. Create a Proxy Host for your Fiddy domain.
|
||||||
|
2. Forward Hostname/IP: your app host/internal IP.
|
||||||
|
3. Forward Port: your app port (for example `3000`).
|
||||||
|
4. Enable:
|
||||||
|
- Block Common Exploits
|
||||||
|
- Websockets Support
|
||||||
|
- SSL certificate
|
||||||
|
- Force SSL
|
||||||
|
- HTTP/2 support
|
||||||
|
|
||||||
|
## 2) Host Advanced Config (NPM UI)
|
||||||
|
In Proxy Host -> Advanced, paste from:
|
||||||
|
- `docker/nginx/npm/proxy-host-advanced.conf.example`
|
||||||
|
|
||||||
|
This adds:
|
||||||
|
- timeout/body limits
|
||||||
|
- connection cap
|
||||||
|
- structured access/error logs
|
||||||
|
|
||||||
|
## 3) Required Root Custom Location `/` (NPM UI)
|
||||||
|
Create a Custom Location for:
|
||||||
|
- `/`
|
||||||
|
|
||||||
|
In that location Advanced field, paste:
|
||||||
|
- `docker/nginx/npm/location-root-advanced.conf.example`
|
||||||
|
|
||||||
|
This handles:
|
||||||
|
- security headers
|
||||||
|
- request-id propagation/response header
|
||||||
|
- upstream proxy timeouts
|
||||||
|
|
||||||
|
## 4) Per-Location Rate Limits (NPM UI)
|
||||||
|
Create Custom Locations in NPM for:
|
||||||
|
- `/api/auth/login`
|
||||||
|
- `/api/auth/register`
|
||||||
|
- `/api/entries`
|
||||||
|
- `/api/buckets`
|
||||||
|
- `/api/groups`
|
||||||
|
- `/api/tags`
|
||||||
|
- `/api/schedules` (canonical)
|
||||||
|
- `/api/recurring-entries` (compatibility, deprecated)
|
||||||
|
|
||||||
|
Then use:
|
||||||
|
- `docker/nginx/npm/location-auth-advanced.conf.example` for auth locations
|
||||||
|
- `docker/nginx/npm/location-write-advanced.conf.example` for write API locations
|
||||||
|
|
||||||
|
Note:
|
||||||
|
- because these are more specific locations than `/`, keep request-id directives in these location snippets too.
|
||||||
|
|
||||||
|
## 5) Global NPM Config Needed for Rate Limit Zones
|
||||||
|
`limit_req_zone`, `limit_conn_zone`, and `log_format` must exist in Nginx `http` context.
|
||||||
|
|
||||||
|
Use template:
|
||||||
|
- `docker/nginx/npm/http_top.conf.example`
|
||||||
|
|
||||||
|
Typical NPM path:
|
||||||
|
- `/data/nginx/custom/http_top.conf`
|
||||||
|
|
||||||
|
## 6) SSH Method (If UI Is Not Enough)
|
||||||
|
If your NPM UI does not expose everything you need:
|
||||||
|
|
||||||
|
1. Enter the container:
|
||||||
|
```bash
|
||||||
|
docker exec -it <npm_container_name> sh
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Verify active config and custom includes:
|
||||||
|
```bash
|
||||||
|
nginx -T | grep -n "include .*custom"
|
||||||
|
nginx -T | grep -n "http_top.conf"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Write global HTTP custom file (path may vary by image/version):
|
||||||
|
```bash
|
||||||
|
mkdir -p /data/nginx/custom
|
||||||
|
cat >/data/nginx/custom/http_top.conf <<'EOF'
|
||||||
|
# paste docker/nginx/npm/http_top.conf.example content
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Reload Nginx:
|
||||||
|
```bash
|
||||||
|
nginx -t
|
||||||
|
nginx -s reload
|
||||||
|
```
|
||||||
|
|
||||||
|
5. In NPM UI, apply:
|
||||||
|
- host advanced snippet
|
||||||
|
- location `/` snippet
|
||||||
|
- auth/write location snippets
|
||||||
|
|
||||||
|
## 7) Log Path Alignment
|
||||||
|
If your NPM uses a different log path than `/var/log/nginx`:
|
||||||
|
- update `access_log` / `error_log` lines in your host advanced config
|
||||||
|
- update:
|
||||||
|
- `docker/observability/promtail-config.yml`
|
||||||
|
- `docker/security/fail2ban/jail.d/fiddy-nginx.conf`
|
||||||
|
- `docker/security/crowdsec/acquis.yaml`
|
||||||
|
|
||||||
|
## 8) Validate
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
scripts/smoke-public-launch.sh https://your-domain
|
||||||
|
```
|
||||||
|
|
||||||
|
Then confirm:
|
||||||
|
- `X-Request-Id` response header exists
|
||||||
|
- response JSON includes `request_id`
|
||||||
|
- nginx access logs receive entries for the Fiddy host
|
||||||
|
- auth and write endpoint bursts are rate limited
|
||||||
126
docs/09_DEPLOYMENT_EXECUTION_PLAYBOOK.md
Normal file
126
docs/09_DEPLOYMENT_EXECUTION_PLAYBOOK.md
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
# Deployment Execution Playbook (Hands-On Checkpoints)
|
||||||
|
|
||||||
|
Purpose: keep implementation work prepared in-repo, and call for operator actions only when local infrastructure access is required.
|
||||||
|
|
||||||
|
## Status Icon Legend
|
||||||
|
Use these in execution updates for fast scanning:
|
||||||
|
- `🔄` 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
|
||||||
|
|
||||||
|
## Phase 0: Preflight (No Infra Changes)
|
||||||
|
- [ ] `npm run lint`
|
||||||
|
- [ ] `npm test`
|
||||||
|
- [ ] `npm run build`
|
||||||
|
- [ ] Confirm docs are up to date:
|
||||||
|
- [ ] `docs/public-launch-runbook.md`
|
||||||
|
- [ ] `docs/07_PUBLIC_LAUNCH_CHECKLIST.md`
|
||||||
|
- [ ] `docs/08_NGINX_PROXY_MANAGER_SETUP.md`
|
||||||
|
- [ ] `docs/06_SECURITY_REVIEW.md`
|
||||||
|
|
||||||
|
## Phase 1: Registry + Dokploy Wiring (Operator Needed)
|
||||||
|
First-time reference: `docs/11_DOKPLOY_FIRST_TIME_WALKTHROUGH.md`.
|
||||||
|
|
||||||
|
Hands-on checkpoints:
|
||||||
|
1. Create/verify secrets in Gitea:
|
||||||
|
- `REGISTRY_USER`
|
||||||
|
- `REGISTRY_PASS`
|
||||||
|
- `DOKPLOY_DEPLOY_HOOK`
|
||||||
|
- `DOKPLOY_SCHEDULER_DEPLOY_HOOK`
|
||||||
|
- `DOKPLOY_HEALTHCHECK_URL`
|
||||||
|
2. In Dokploy app settings (Web):
|
||||||
|
- image source points to `git.nicosaya.com/nalalangan/fiddy/web`
|
||||||
|
- health endpoint is `/api/health/ready`
|
||||||
|
- release history retention is enabled
|
||||||
|
3. In Dokploy app settings (Scheduler):
|
||||||
|
- image source points to `git.nicosaya.com/nalalangan/fiddy/scheduler`
|
||||||
|
- no public port exposed
|
||||||
|
- env vars set: `DATABASE_URL`, `DATABASE_SSL`, `ALLOWED_DB_NAMES`
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- [ ] Push-to-main triggers `.gitea/workflows/deploy-dokploy.yml`
|
||||||
|
- [ ] Web and Scheduler deploy hooks fire successfully
|
||||||
|
- [ ] Health gate completes via `scripts/wait-for-health.sh`
|
||||||
|
|
||||||
|
## Phase 2: NPM Edge Setup (Operator Needed)
|
||||||
|
Use `docs/08_NGINX_PROXY_MANAGER_SETUP.md`.
|
||||||
|
Execution order helper: `docs/10_NPM_HANDS_ON_RUNSHEET.md`.
|
||||||
|
|
||||||
|
Hands-on checkpoints:
|
||||||
|
1. Proxy Host for Fiddy domain configured to internal app IP:port.
|
||||||
|
2. Proxy Host Advanced:
|
||||||
|
- `docker/nginx/npm/proxy-host-advanced.conf.example`
|
||||||
|
3. Custom Location `/`:
|
||||||
|
- `docker/nginx/npm/location-root-advanced.conf.example`
|
||||||
|
4. Custom auth/write locations:
|
||||||
|
- `docker/nginx/npm/location-auth-advanced.conf.example`
|
||||||
|
- `docker/nginx/npm/location-write-advanced.conf.example`
|
||||||
|
5. Global NPM `http` config includes:
|
||||||
|
- `docker/nginx/npm/http_top.conf.example`
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- [ ] `scripts/smoke-public-launch.sh https://<domain>` passes
|
||||||
|
- [ ] Response header `X-Request-Id` present
|
||||||
|
- [ ] Response body includes `request_id`
|
||||||
|
- [ ] Rate limits are active under burst tests
|
||||||
|
|
||||||
|
## Phase 3: Host Security Baseline (Operator Needed)
|
||||||
|
Hands-on checkpoints:
|
||||||
|
1. Firewall baseline:
|
||||||
|
- dry run: `SSH_ALLOW_CIDR=<cidr> DRY_RUN=1 scripts/harden-host-ufw.sh`
|
||||||
|
- apply: `SSH_ALLOW_CIDR=<cidr> DRY_RUN=0 sudo scripts/harden-host-ufw.sh`
|
||||||
|
2. Security snapshot:
|
||||||
|
- `scripts/check-host-security.sh`
|
||||||
|
3. Auto-ban tooling:
|
||||||
|
- fail2ban and/or crowdsec using `docker/security/*`
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- [ ] Only expected public ports exposed (`80/443`)
|
||||||
|
- [ ] SSH restricted by allowlist/VPN
|
||||||
|
- [ ] Ban tooling sees nginx logs and can ban test offender
|
||||||
|
|
||||||
|
## Phase 4: Observability + Alerts (Operator Needed)
|
||||||
|
Hands-on checkpoints:
|
||||||
|
1. Start stack:
|
||||||
|
- `docker compose -f docker/observability/docker-compose.observability.yml up -d`
|
||||||
|
2. Grafana datasource:
|
||||||
|
- Loki `http://loki:3100`
|
||||||
|
3. Uptime Kuma monitors:
|
||||||
|
- `/api/health/live`
|
||||||
|
- `/api/health/ready`
|
||||||
|
- `/`
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- [ ] nginx logs appear in Loki (`job="nginx"`)
|
||||||
|
- [ ] alert rules configured (5xx/auth spikes/DB failures/resource pressure)
|
||||||
|
|
||||||
|
## Phase 5: Backup + DR (Operator Needed)
|
||||||
|
Hands-on checkpoints:
|
||||||
|
1. Schedule logical backups:
|
||||||
|
- `scripts/backup-postgres.sh`
|
||||||
|
2. Schedule periodic base backups:
|
||||||
|
- `PRIMARY_DATABASE_URL=<replication-url> scripts/basebackup-postgres.sh`
|
||||||
|
3. Run restore drill:
|
||||||
|
- `scripts/restore-drill-postgres.sh <dump> <target_db_url>`
|
||||||
|
4. Log drill:
|
||||||
|
- `scripts/log-restore-drill.sh <env> <dump> <target> <status> <rto_min> <notes>`
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- [ ] latest drill entry in `docs/restore-drill-log.csv`
|
||||||
|
- [ ] measured RTO acceptable
|
||||||
|
|
||||||
|
## Phase 6: Launch Gate
|
||||||
|
Run final checklist:
|
||||||
|
- `docs/07_PUBLIC_LAUNCH_CHECKLIST.md`
|
||||||
|
|
||||||
|
Go-live only after all required boxes are checked.
|
||||||
|
|
||||||
|
## Deferred Item (Intentional)
|
||||||
|
- NPM host-specific tailoring (domain/upstream/custom locations) is intentionally deferred and tracked for a later hands-on session.
|
||||||
101
docs/10_NPM_HANDS_ON_RUNSHEET.md
Normal file
101
docs/10_NPM_HANDS_ON_RUNSHEET.md
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
# NPM Hands-On Runsheet
|
||||||
|
|
||||||
|
Use this when you are ready to actively configure Nginx Proxy Manager for Fiddy.
|
||||||
|
|
||||||
|
## Inputs To Decide First
|
||||||
|
- `DOMAIN`: Fiddy public domain (example: `fiddy.example.com`)
|
||||||
|
- `UPSTREAM_HOST`: internal app host/IP (example: `192.168.1.50`)
|
||||||
|
- `UPSTREAM_PORT`: app port (default `3000`)
|
||||||
|
- `NPM_CONTAINER`: your NPM container name (for SSH fallback)
|
||||||
|
- `NPM_LOG_PATH`: log path if different from `/var/log/nginx`
|
||||||
|
|
||||||
|
## Run 1: Proxy Host Baseline (NPM UI)
|
||||||
|
1. Proxy Hosts -> Add Proxy Host.
|
||||||
|
2. Domain Names: `DOMAIN`.
|
||||||
|
3. Scheme: `http`.
|
||||||
|
4. Forward Hostname/IP: `UPSTREAM_HOST`.
|
||||||
|
5. Forward Port: `UPSTREAM_PORT`.
|
||||||
|
6. Enable:
|
||||||
|
- Block Common Exploits
|
||||||
|
- Websockets Support
|
||||||
|
7. SSL tab:
|
||||||
|
- Request/choose cert
|
||||||
|
- Force SSL
|
||||||
|
- HTTP/2
|
||||||
|
|
||||||
|
Stop and verify:
|
||||||
|
- opening `https://DOMAIN` reaches app homepage.
|
||||||
|
|
||||||
|
## Run 2: Proxy Host Advanced (NPM UI)
|
||||||
|
Paste:
|
||||||
|
- `docker/nginx/npm/proxy-host-advanced.conf.example`
|
||||||
|
|
||||||
|
Stop and verify:
|
||||||
|
- save succeeds with no Nginx validation errors.
|
||||||
|
|
||||||
|
## Run 3: Root Location `/` (NPM UI)
|
||||||
|
1. In that Proxy Host, add Custom Location path `/`.
|
||||||
|
2. Paste:
|
||||||
|
- `docker/nginx/npm/location-root-advanced.conf.example`
|
||||||
|
|
||||||
|
Stop and verify:
|
||||||
|
- `curl -I https://DOMAIN` includes `X-Request-Id`.
|
||||||
|
|
||||||
|
## Run 4: API Location Controls (NPM UI)
|
||||||
|
Add custom locations and advanced snippets:
|
||||||
|
- `/api/auth/login` -> `docker/nginx/npm/location-auth-advanced.conf.example`
|
||||||
|
- `/api/auth/register` -> `docker/nginx/npm/location-auth-advanced.conf.example`
|
||||||
|
- `/api/entries` -> `docker/nginx/npm/location-write-advanced.conf.example`
|
||||||
|
- `/api/buckets` -> `docker/nginx/npm/location-write-advanced.conf.example`
|
||||||
|
- `/api/groups` -> `docker/nginx/npm/location-write-advanced.conf.example`
|
||||||
|
- `/api/tags` -> `docker/nginx/npm/location-write-advanced.conf.example`
|
||||||
|
- `/api/schedules` (canonical) -> `docker/nginx/npm/location-write-advanced.conf.example`
|
||||||
|
- `/api/recurring-entries` (compatibility, deprecated) -> `docker/nginx/npm/location-write-advanced.conf.example`
|
||||||
|
|
||||||
|
Stop and verify:
|
||||||
|
- auth/login bad-password bursts eventually return `429`.
|
||||||
|
|
||||||
|
## Run 5: Global Nginx `http` Snippet (SSH fallback if needed)
|
||||||
|
If NPM UI does not expose global `http` context:
|
||||||
|
1. `docker exec -it NPM_CONTAINER sh`
|
||||||
|
2. Ensure custom path exists:
|
||||||
|
```bash
|
||||||
|
mkdir -p /data/nginx/custom
|
||||||
|
```
|
||||||
|
3. Write:
|
||||||
|
```bash
|
||||||
|
cat >/data/nginx/custom/http_top.conf <<'EOF'
|
||||||
|
# paste docker/nginx/npm/http_top.conf.example
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
4. Reload:
|
||||||
|
```bash
|
||||||
|
nginx -t && nginx -s reload
|
||||||
|
```
|
||||||
|
|
||||||
|
Stop and verify:
|
||||||
|
- no reload errors
|
||||||
|
- rate limit zones are recognized
|
||||||
|
|
||||||
|
## Run 6: Final Functional Validation
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
scripts/smoke-public-launch.sh https://DOMAIN
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- `/api/health/live` and `/api/health/ready` are `200`
|
||||||
|
- `X-Request-Id` header present
|
||||||
|
- JSON response contains `request_id`
|
||||||
|
|
||||||
|
## Run 7: Log Path Alignment (if needed)
|
||||||
|
If NPM logs are not in `/var/log/nginx`:
|
||||||
|
- update:
|
||||||
|
- `docker/observability/promtail-config.yml`
|
||||||
|
- `docker/security/fail2ban/jail.d/fiddy-nginx.conf`
|
||||||
|
- `docker/security/crowdsec/acquis.yaml`
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- All Run 1-6 checks pass.
|
||||||
|
- NPM config persists across restart.
|
||||||
|
- Smoke check passes after NPM restart.
|
||||||
110
docs/11_DOKPLOY_FIRST_TIME_WALKTHROUGH.md
Normal file
110
docs/11_DOKPLOY_FIRST_TIME_WALKTHROUGH.md
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
# Dokploy First-Time Walkthrough
|
||||||
|
|
||||||
|
This is a first-use setup guide for Dokploy in your environment.
|
||||||
|
|
||||||
|
## 0) Important Port Constraint
|
||||||
|
Dokploy installation expects ports `80`, `443`, and `3000` available on the host where Dokploy is installed.
|
||||||
|
|
||||||
|
If your Nginx Proxy Manager already uses `80/443` on that same machine, install Dokploy on a separate VM/host (recommended).
|
||||||
|
|
||||||
|
## 1) Install Dokploy (Operator Step)
|
||||||
|
On the target Dokploy server (Linux, root):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sSL https://dokploy.com/install.sh | sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open:
|
||||||
|
- `http://<dokploy-host>:3000`
|
||||||
|
|
||||||
|
Create your admin account.
|
||||||
|
|
||||||
|
## 2) Create the Web App in Dokploy (Image-Based)
|
||||||
|
Create an `Application` service for Fiddy Web and configure:
|
||||||
|
- Source type: Docker image
|
||||||
|
- Image: `git.nicosaya.com/nalalangan/fiddy/web:main`
|
||||||
|
- Internal app port: `3000`
|
||||||
|
- Health endpoint: `/api/health/ready`
|
||||||
|
|
||||||
|
Set environment variables:
|
||||||
|
- `DATABASE_URL`
|
||||||
|
- `DATABASE_SSL`
|
||||||
|
- `ALLOWED_DB_NAMES`
|
||||||
|
- `SESSION_COOKIE_NAME`
|
||||||
|
- `SESSION_TTL_DAYS`
|
||||||
|
- `DEBUG_API=0`
|
||||||
|
|
||||||
|
## 3) Create the Scheduler App in Dokploy (Image-Based)
|
||||||
|
Create a second `Application` service for Scheduler and configure:
|
||||||
|
- Source type: Docker image
|
||||||
|
- Image: `git.nicosaya.com/nalalangan/fiddy/scheduler:main`
|
||||||
|
- No public port required
|
||||||
|
|
||||||
|
Set environment variables:
|
||||||
|
- `DATABASE_URL`
|
||||||
|
- `DATABASE_SSL`
|
||||||
|
- `ALLOWED_DB_NAMES`
|
||||||
|
- `SCHEDULER_POLL_MS` (optional, default `60000`)
|
||||||
|
- `SCHEDULER_BATCH_SIZE` (optional, default `100`)
|
||||||
|
|
||||||
|
## 4) Configure Registry Credentials in Dokploy
|
||||||
|
Add your registry credentials in Dokploy registry settings for:
|
||||||
|
- registry host: `git.nicosaya.com`
|
||||||
|
- username/password (or token)
|
||||||
|
|
||||||
|
Use the registry in the application configuration so image pulls succeed.
|
||||||
|
|
||||||
|
## 5) Configure Auto Deploy Hooks
|
||||||
|
In both Dokploy applications (Web + Scheduler), enable Auto Deploy and copy each Webhook URL.
|
||||||
|
|
||||||
|
Set this value in Gitea repo secrets as:
|
||||||
|
- `DOKPLOY_DEPLOY_HOOK`
|
||||||
|
- `DOKPLOY_SCHEDULER_DEPLOY_HOOK`
|
||||||
|
|
||||||
|
Set health URL secret too:
|
||||||
|
- `DOKPLOY_HEALTHCHECK_URL=https://<public-domain>/api/health/ready`
|
||||||
|
|
||||||
|
## 6) Wire Gitea Secrets
|
||||||
|
In Gitea repository secrets, add:
|
||||||
|
- `REGISTRY_USER`
|
||||||
|
- `REGISTRY_PASS`
|
||||||
|
- `DOKPLOY_DEPLOY_HOOK`
|
||||||
|
- `DOKPLOY_SCHEDULER_DEPLOY_HOOK`
|
||||||
|
- `DOKPLOY_HEALTHCHECK_URL`
|
||||||
|
|
||||||
|
The workflow in `.gitea/workflows/deploy-dokploy.yml` will:
|
||||||
|
1. build + push web and scheduler images
|
||||||
|
2. call Dokploy deploy hook(s)
|
||||||
|
3. wait for web ready health via `scripts/wait-for-health.sh`
|
||||||
|
|
||||||
|
## 7) First Deployment Test
|
||||||
|
1. Push `main`.
|
||||||
|
2. Confirm Gitea workflow succeeds.
|
||||||
|
3. In Dokploy, verify both Web and Scheduler show successful pull/start logs.
|
||||||
|
4. Run smoke check:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/smoke-public-launch.sh https://<domain>
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- `200` for `/api/health/live` and `/api/health/ready`
|
||||||
|
- `X-Request-Id` present
|
||||||
|
- `request_id` present in response JSON
|
||||||
|
|
||||||
|
## 8) Rollback Test (Required Before Public Launch)
|
||||||
|
In Dokploy, trigger rollback for Web and Scheduler to previous successful releases, then run smoke check again.
|
||||||
|
|
||||||
|
Record outcome and reason in your runbook/checklist.
|
||||||
|
|
||||||
|
## 9) Troubleshooting Fast Path
|
||||||
|
- Hook fires but no deploy:
|
||||||
|
- verify Auto Deploy enabled in Dokploy app
|
||||||
|
- verify hook URL in Gitea secret
|
||||||
|
- Image pull fails:
|
||||||
|
- verify Dokploy registry credentials
|
||||||
|
- verify image path/tag exists
|
||||||
|
- Health gate fails:
|
||||||
|
- check app logs in Dokploy
|
||||||
|
- verify DB connectivity/env vars
|
||||||
|
- verify NPM route and TLS
|
||||||
40
docs/DB_MIGRATION_WORKFLOW.md
Normal file
40
docs/DB_MIGRATION_WORKFLOW.md
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# DB Migration Workflow (Active Set)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
- Run only approved migration files.
|
||||||
|
- Keep a clear record of what is in scope and what is applied.
|
||||||
|
|
||||||
|
## Active Migration Manifest
|
||||||
|
- Source of truth: `packages/db/migrations/active-migrations.json`
|
||||||
|
- Current active set:
|
||||||
|
- `007_rate_limits.sql`
|
||||||
|
- `008_schedules_pivot.sql`
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
- Show active-scope status:
|
||||||
|
- `npm run db:migrate:status`
|
||||||
|
- Apply active-scope migrations:
|
||||||
|
- `npm run db:migrate`
|
||||||
|
- Show full migration status:
|
||||||
|
- `npm run db:migrate:status:all`
|
||||||
|
- Apply all migration files (escape hatch):
|
||||||
|
- `npm run db:migrate:all`
|
||||||
|
|
||||||
|
## Tracking
|
||||||
|
- Applied migrations are tracked in `_migrations`.
|
||||||
|
- `db:migrate:status` reports:
|
||||||
|
- `APPLIED`
|
||||||
|
- `PENDING`
|
||||||
|
- `HASH_MISMATCH`
|
||||||
|
- applied files outside active scope
|
||||||
|
|
||||||
|
## Rule For New Migrations
|
||||||
|
1. Add the new SQL file under `packages/db/migrations/`.
|
||||||
|
2. Append the filename to `packages/db/migrations/active-migrations.json`.
|
||||||
|
3. Run `npm run db:migrate:status`.
|
||||||
|
4. Run `npm run db:migrate`.
|
||||||
|
5. Commit migration + manifest + docs update together.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- If hash mismatch appears for stale files outside active scope, it does not block active migration runs.
|
||||||
|
- If hash mismatch appears for a file inside active scope, fix it before applying.
|
||||||
@ -137,7 +137,8 @@ Add filtering modal with:
|
|||||||
### API
|
### API
|
||||||
- New `/api/entries` endpoints (CRUD)
|
- New `/api/entries` endpoints (CRUD)
|
||||||
- New `/api/buckets` endpoints (CRUD)
|
- New `/api/buckets` endpoints (CRUD)
|
||||||
- New `/api/recurring-entries` endpoints (CRUD)
|
- New `/api/schedules` endpoints (CRUD, canonical)
|
||||||
|
- `/api/recurring-entries` retained as compatibility alias (deprecated)
|
||||||
|
|
||||||
### Server
|
### Server
|
||||||
- `lib/server/entries.ts`, `buckets.ts`, `recurring-entries.ts`
|
- `lib/server/entries.ts`, `buckets.ts`, `recurring-entries.ts`
|
||||||
|
|||||||
45
docs/SCHEDULES_PIVOT_GUARDRAILS.md
Normal file
45
docs/SCHEDULES_PIVOT_GUARDRAILS.md
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
# Schedules Pivot Guardrails
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
- Replace "recurring entries" with first-class "schedules".
|
||||||
|
- Keep entry creation separate from schedule templates.
|
||||||
|
- Preserve compatibility during migration.
|
||||||
|
|
||||||
|
## Terminology
|
||||||
|
- Use `Schedule` and `Schedules` in new UI and API surfaces.
|
||||||
|
- Legacy `/api/recurring-entries` remains compatibility-only and is deprecated.
|
||||||
|
|
||||||
|
## Data Model Rules
|
||||||
|
- `entries` are real posted records.
|
||||||
|
- `schedules` are templates that can materialize future entries.
|
||||||
|
- `entries.source_schedule_id` links posted entries created by schedule execution.
|
||||||
|
- Legacy recurring flags on `entries` are normalized to non-recurring in migration.
|
||||||
|
|
||||||
|
## Compatibility Window
|
||||||
|
- Canonical routes:
|
||||||
|
- `GET/POST /api/schedules`
|
||||||
|
- `PATCH/DELETE /api/schedules/[id]`
|
||||||
|
- Compatibility routes (deprecated):
|
||||||
|
- `GET/POST /api/recurring-entries`
|
||||||
|
- `PATCH/DELETE /api/recurring-entries/[id]`
|
||||||
|
|
||||||
|
## Frequency Contract
|
||||||
|
- Allowed frequencies:
|
||||||
|
- `DAILY`
|
||||||
|
- `WEEKLY`
|
||||||
|
- `MONTHLY`
|
||||||
|
- `YEARLY`
|
||||||
|
- Legacy `BIWEEKLY` and `QUARTERLY` values are mapped during migration:
|
||||||
|
- `BIWEEKLY` -> `WEEKLY` with interval doubled
|
||||||
|
- `QUARTERLY` -> `MONTHLY` with interval tripled
|
||||||
|
|
||||||
|
## Scheduler Service
|
||||||
|
- Runs as separate workspace app: `apps/scheduler`.
|
||||||
|
- Uses UTC schedule evaluation.
|
||||||
|
- Uses `FOR UPDATE SKIP LOCKED` and unique index on `(source_schedule_id, occurred_at)` for idempotency.
|
||||||
|
- Must not log secrets or sensitive payloads.
|
||||||
|
|
||||||
|
## User Settings
|
||||||
|
- User-level page size stored in `user_settings.data.entryPanelPageSize`.
|
||||||
|
- Default is `10`.
|
||||||
|
- Applied to Entries and Schedules tab "Show more" increments.
|
||||||
@ -9,7 +9,8 @@
|
|||||||
## 2) Deploy Control Plane (Dokploy)
|
## 2) Deploy Control Plane (Dokploy)
|
||||||
1. Install Dokploy on your Proxmox Docker host.
|
1. Install Dokploy on your Proxmox Docker host.
|
||||||
2. Add project in Dokploy and connect Gitea repository.
|
2. Add project in Dokploy and connect Gitea repository.
|
||||||
3. Configure image source: `git.nicosaya.com/nalalangan/fiddy/web`.
|
3. Configure web image source: `git.nicosaya.com/nalalangan/fiddy/web`.
|
||||||
|
4. Configure scheduler image source: `git.nicosaya.com/nalalangan/fiddy/scheduler`.
|
||||||
4. Deploy by immutable tag (`github.sha`) and keep `main` as convenience tag.
|
4. Deploy by immutable tag (`github.sha`) and keep `main` as convenience tag.
|
||||||
5. Configure health check endpoint: `/api/health/ready`.
|
5. Configure health check endpoint: `/api/health/ready`.
|
||||||
6. Keep previous releases for rollback and verify rollback button path.
|
6. Keep previous releases for rollback and verify rollback button path.
|
||||||
@ -21,6 +22,8 @@
|
|||||||
- `SESSION_COOKIE_NAME`
|
- `SESSION_COOKIE_NAME`
|
||||||
- `SESSION_TTL_DAYS`
|
- `SESSION_TTL_DAYS`
|
||||||
- `DEBUG_API=0`
|
- `DEBUG_API=0`
|
||||||
|
- `SCHEDULER_POLL_MS` (scheduler app, optional)
|
||||||
|
- `SCHEDULER_BATCH_SIZE` (scheduler app, optional)
|
||||||
|
|
||||||
## 3) CI/CD (Gitea Actions)
|
## 3) CI/CD (Gitea Actions)
|
||||||
- Use `.gitea/workflows/deploy-dokploy.yml`.
|
- Use `.gitea/workflows/deploy-dokploy.yml`.
|
||||||
@ -28,13 +31,17 @@
|
|||||||
- `REGISTRY_USER`
|
- `REGISTRY_USER`
|
||||||
- `REGISTRY_PASS`
|
- `REGISTRY_PASS`
|
||||||
- `DOKPLOY_DEPLOY_HOOK`
|
- `DOKPLOY_DEPLOY_HOOK`
|
||||||
|
- `DOKPLOY_SCHEDULER_DEPLOY_HOOK`
|
||||||
- `DOKPLOY_HEALTHCHECK_URL`
|
- `DOKPLOY_HEALTHCHECK_URL`
|
||||||
- Health gate:
|
- Health gate:
|
||||||
- workflow calls `scripts/wait-for-health.sh` against `DOKPLOY_HEALTHCHECK_URL`
|
- workflow calls `scripts/wait-for-health.sh` against `DOKPLOY_HEALTHCHECK_URL`
|
||||||
- default retry window: 5 minutes (30 attempts x 10s)
|
- default retry window: 5 minutes (30 attempts x 10s)
|
||||||
|
|
||||||
## 4) Reverse Proxy + Network Hardening
|
## 4) Reverse Proxy + Network Hardening
|
||||||
- Use `docker/nginx/fiddy.conf` as baseline.
|
- Use your existing Nginx reverse proxy/vhost.
|
||||||
|
- Apply the required Fiddy directives using `docker/nginx/fiddy.conf` and `docker/nginx/includes/fiddy-proxy.conf` as templates.
|
||||||
|
- For Nginx Proxy Manager-specific setup, follow `docs/08_NGINX_PROXY_MANAGER_SETUP.md`.
|
||||||
|
- NPM note: apply `add_header`/`proxy_set_header` in Custom Location `/` (and specific API locations), not only Proxy Host Advanced.
|
||||||
- Install certificate with Let's Encrypt.
|
- Install certificate with Let's Encrypt.
|
||||||
- Route 443 -> app container only.
|
- Route 443 -> app container only.
|
||||||
- Keep Postgres private; never expose 5432 publicly.
|
- Keep Postgres private; never expose 5432 publicly.
|
||||||
@ -45,6 +52,10 @@
|
|||||||
- Confirm Nginx writes JSON logs:
|
- Confirm Nginx writes JSON logs:
|
||||||
- `/var/log/nginx/fiddy-access.log`
|
- `/var/log/nginx/fiddy-access.log`
|
||||||
- `/var/log/nginx/fiddy-error.log`
|
- `/var/log/nginx/fiddy-error.log`
|
||||||
|
- If your log paths differ, update:
|
||||||
|
- `docker/observability/promtail-config.yml`
|
||||||
|
- `docker/security/fail2ban/jail.d/fiddy-nginx.conf`
|
||||||
|
- `docker/security/crowdsec/acquis.yaml`
|
||||||
- Apply/verify host baseline using scripts:
|
- Apply/verify host baseline using scripts:
|
||||||
- dry-run firewall apply: `SSH_ALLOW_CIDR=<your-cidr> DRY_RUN=1 scripts/harden-host-ufw.sh`
|
- dry-run firewall apply: `SSH_ALLOW_CIDR=<your-cidr> DRY_RUN=1 scripts/harden-host-ufw.sh`
|
||||||
- real firewall apply: `SSH_ALLOW_CIDR=<your-cidr> DRY_RUN=0 sudo scripts/harden-host-ufw.sh`
|
- real firewall apply: `SSH_ALLOW_CIDR=<your-cidr> DRY_RUN=0 sudo scripts/harden-host-ufw.sh`
|
||||||
|
|||||||
13
package-lock.json
generated
13
package-lock.json
generated
@ -10,6 +10,14 @@
|
|||||||
"packages/*"
|
"packages/*"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"apps/scheduler": {
|
||||||
|
"name": "@fiddy/scheduler",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"pg": "^8.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"apps/web": {
|
"apps/web": {
|
||||||
"name": "@fiddy/web",
|
"name": "@fiddy/web",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
@ -980,6 +988,10 @@
|
|||||||
"resolved": "packages/db",
|
"resolved": "packages/db",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@fiddy/scheduler": {
|
||||||
|
"resolved": "apps/scheduler",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@fiddy/shared": {
|
"node_modules/@fiddy/shared": {
|
||||||
"resolved": "packages/shared",
|
"resolved": "packages/shared",
|
||||||
"link": true
|
"link": true
|
||||||
@ -7110,6 +7122,7 @@
|
|||||||
"name": "@fiddy/db",
|
"name": "@fiddy/db",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"pg": "^8.13.0"
|
"pg": "^8.13.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,9 +10,13 @@
|
|||||||
"dev:web": "npm --workspace apps/web run dev",
|
"dev:web": "npm --workspace apps/web run dev",
|
||||||
"build": "npm --workspace apps/web run build",
|
"build": "npm --workspace apps/web run build",
|
||||||
"start": "npm --workspace apps/web run start",
|
"start": "npm --workspace apps/web run start",
|
||||||
|
"start:scheduler": "npm --workspace apps/scheduler run start",
|
||||||
"lint": "npm --workspace apps/web run lint",
|
"lint": "npm --workspace apps/web run lint",
|
||||||
"test": "npm --workspace apps/web run test",
|
"test": "npm --workspace apps/web run test",
|
||||||
"test:ui": "npm --workspace apps/web run test:ui",
|
"test:ui": "npm --workspace apps/web run test:ui",
|
||||||
"db:migrate": "npm --workspace packages/db run migrate"
|
"db:migrate": "npm --workspace packages/db run migrate",
|
||||||
|
"db:migrate:all": "npm --workspace packages/db run migrate:all",
|
||||||
|
"db:migrate:status": "npm --workspace packages/db run status",
|
||||||
|
"db:migrate:status:all": "npm --workspace packages/db run status:all"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
143
packages/db/migrations/008_schedules_pivot.sql
Normal file
143
packages/db/migrations/008_schedules_pivot.sql
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
create type schedule_frequency as enum ('DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY');
|
||||||
|
create type schedule_end_condition as enum ('NEVER', 'AFTER_COUNT', 'BY_DATE');
|
||||||
|
|
||||||
|
create table schedules(
|
||||||
|
id bigserial primary key,
|
||||||
|
group_id bigint not null references groups(id) on delete cascade,
|
||||||
|
created_by bigint not null references users(id),
|
||||||
|
entry_type entry_type not null default 'SPENDING',
|
||||||
|
amount_dollars numeric(12,2) not null check (amount_dollars >= 0),
|
||||||
|
necessity spending_necessity not null,
|
||||||
|
purchase_type text not null,
|
||||||
|
notes text,
|
||||||
|
starts_on date not null,
|
||||||
|
frequency schedule_frequency not null,
|
||||||
|
interval_count integer not null default 1 check (interval_count > 0),
|
||||||
|
end_condition schedule_end_condition not null default 'NEVER',
|
||||||
|
end_count integer,
|
||||||
|
end_date date,
|
||||||
|
next_run_on date not null,
|
||||||
|
last_run_on date,
|
||||||
|
run_count integer not null default 0,
|
||||||
|
is_active boolean not null default true,
|
||||||
|
legacy_source_entry_id bigint references entries(id) on delete set null,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create index schedules_group_next_run_idx on schedules(group_id, next_run_on);
|
||||||
|
create index schedules_created_by_idx on schedules(created_by);
|
||||||
|
|
||||||
|
create table schedule_tags(
|
||||||
|
schedule_id bigint not null references schedules(id) on delete cascade,
|
||||||
|
tag_id bigint not null references tags(id) on delete cascade,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
primary key (schedule_id, tag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index schedule_tags_schedule_idx on schedule_tags(schedule_id);
|
||||||
|
create index schedule_tags_tag_idx on schedule_tags(tag_id);
|
||||||
|
|
||||||
|
alter table entries
|
||||||
|
add column source_schedule_id bigint references schedules(id) on delete set null;
|
||||||
|
|
||||||
|
create unique index entries_schedule_occurrence_unique
|
||||||
|
on entries(source_schedule_id, occurred_at)
|
||||||
|
where source_schedule_id is not null;
|
||||||
|
|
||||||
|
with legacy_recurring as (
|
||||||
|
select
|
||||||
|
e.id,
|
||||||
|
e.group_id,
|
||||||
|
e.created_by,
|
||||||
|
e.entry_type,
|
||||||
|
e.amount_dollars,
|
||||||
|
e.necessity,
|
||||||
|
e.purchase_type,
|
||||||
|
e.notes,
|
||||||
|
e.occurred_at,
|
||||||
|
case
|
||||||
|
when e.frequency::text = 'BIWEEKLY' then 'WEEKLY'::schedule_frequency
|
||||||
|
when e.frequency::text = 'QUARTERLY' then 'MONTHLY'::schedule_frequency
|
||||||
|
when e.frequency::text in ('DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY') then e.frequency::text::schedule_frequency
|
||||||
|
else 'MONTHLY'::schedule_frequency
|
||||||
|
end as schedule_frequency,
|
||||||
|
case
|
||||||
|
when e.frequency::text = 'BIWEEKLY' then greatest(coalesce(e.interval_count, 1), 1) * 2
|
||||||
|
when e.frequency::text = 'QUARTERLY' then greatest(coalesce(e.interval_count, 1), 1) * 3
|
||||||
|
else greatest(coalesce(e.interval_count, 1), 1)
|
||||||
|
end as safe_interval_count,
|
||||||
|
coalesce(e.end_condition::text, 'NEVER')::schedule_end_condition as schedule_end_condition,
|
||||||
|
e.end_count,
|
||||||
|
e.end_date,
|
||||||
|
coalesce(e.next_run_at, e.occurred_at) as legacy_base_date
|
||||||
|
from entries e
|
||||||
|
where e.is_recurring = true
|
||||||
|
), inserted_schedules as (
|
||||||
|
insert into schedules(
|
||||||
|
group_id,
|
||||||
|
created_by,
|
||||||
|
entry_type,
|
||||||
|
amount_dollars,
|
||||||
|
necessity,
|
||||||
|
purchase_type,
|
||||||
|
notes,
|
||||||
|
starts_on,
|
||||||
|
frequency,
|
||||||
|
interval_count,
|
||||||
|
end_condition,
|
||||||
|
end_count,
|
||||||
|
end_date,
|
||||||
|
next_run_on,
|
||||||
|
last_run_on,
|
||||||
|
run_count,
|
||||||
|
is_active,
|
||||||
|
legacy_source_entry_id
|
||||||
|
)
|
||||||
|
select
|
||||||
|
lr.group_id,
|
||||||
|
lr.created_by,
|
||||||
|
lr.entry_type,
|
||||||
|
lr.amount_dollars,
|
||||||
|
lr.necessity,
|
||||||
|
lr.purchase_type,
|
||||||
|
lr.notes,
|
||||||
|
lr.legacy_base_date as starts_on,
|
||||||
|
lr.schedule_frequency,
|
||||||
|
lr.safe_interval_count,
|
||||||
|
lr.schedule_end_condition,
|
||||||
|
lr.end_count,
|
||||||
|
lr.end_date,
|
||||||
|
case
|
||||||
|
when lr.schedule_frequency = 'DAILY' then (lr.legacy_base_date + (lr.safe_interval_count::text || ' day')::interval)::date
|
||||||
|
when lr.schedule_frequency = 'WEEKLY' then (lr.legacy_base_date + (lr.safe_interval_count::text || ' week')::interval)::date
|
||||||
|
when lr.schedule_frequency = 'MONTHLY' then (lr.legacy_base_date + (lr.safe_interval_count::text || ' month')::interval)::date
|
||||||
|
else (lr.legacy_base_date + (lr.safe_interval_count::text || ' year')::interval)::date
|
||||||
|
end as next_run_on,
|
||||||
|
lr.legacy_base_date as last_run_on,
|
||||||
|
1 as run_count,
|
||||||
|
case
|
||||||
|
when lr.schedule_end_condition = 'AFTER_COUNT' and coalesce(lr.end_count, 0) <= 1 then false
|
||||||
|
when lr.schedule_end_condition = 'BY_DATE' and lr.end_date is not null and lr.end_date < lr.legacy_base_date then false
|
||||||
|
else true
|
||||||
|
end as is_active,
|
||||||
|
lr.id as legacy_source_entry_id
|
||||||
|
from legacy_recurring lr
|
||||||
|
returning id, legacy_source_entry_id
|
||||||
|
)
|
||||||
|
insert into schedule_tags(schedule_id, tag_id)
|
||||||
|
select i.id, et.tag_id
|
||||||
|
from inserted_schedules i
|
||||||
|
join entry_tags et on et.entry_id = i.legacy_source_entry_id
|
||||||
|
on conflict do nothing;
|
||||||
|
|
||||||
|
update entries
|
||||||
|
set is_recurring = false,
|
||||||
|
frequency = null,
|
||||||
|
interval_count = 1,
|
||||||
|
end_condition = null,
|
||||||
|
end_count = null,
|
||||||
|
end_date = null,
|
||||||
|
next_run_at = null,
|
||||||
|
last_executed_at = null
|
||||||
|
where is_recurring = true;
|
||||||
6
packages/db/migrations/active-migrations.json
Normal file
6
packages/db/migrations/active-migrations.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"migrations": [
|
||||||
|
"007_rate_limits.sql",
|
||||||
|
"008_schedules_pivot.sql"
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -5,6 +5,9 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"migrate": "node ./src/migrate.js",
|
"migrate": "node ./src/migrate.js",
|
||||||
|
"migrate:all": "node ./src/migrate.js --all",
|
||||||
|
"status": "node ./src/migration-status.js",
|
||||||
|
"status:all": "node ./src/migration-status.js --all",
|
||||||
"test": "node ./src/selftest.js",
|
"test": "node ./src/selftest.js",
|
||||||
"test:ui-seed": "node ./test-ui-seed/index.js"
|
"test:ui-seed": "node ./test-ui-seed/index.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -11,6 +11,8 @@ dotenv.config({ path: path.resolve(__dirname, "../../../apps/web/.env") });
|
|||||||
|
|
||||||
const { Pool } = pg;
|
const { Pool } = pg;
|
||||||
const migrationsDir = path.resolve(__dirname, "../migrations");
|
const migrationsDir = path.resolve(__dirname, "../migrations");
|
||||||
|
const activeManifestPath = path.resolve(migrationsDir, "active-migrations.json");
|
||||||
|
const runAll = process.argv.includes("--all");
|
||||||
|
|
||||||
const sha256 = s => crypto.createHash("sha256").update(s).digest("hex");
|
const sha256 = s => crypto.createHash("sha256").update(s).digest("hex");
|
||||||
|
|
||||||
@ -53,6 +55,43 @@ async function ensureMigrationsTable(client) {
|
|||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readActiveManifest() {
|
||||||
|
if (!fs.existsSync(activeManifestPath))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
const raw = fs.readFileSync(activeManifestPath, "utf8");
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
const listed = Array.isArray(parsed?.migrations) ? parsed.migrations : [];
|
||||||
|
const normalized = listed
|
||||||
|
.map(value => String(value || "").trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (!normalized.length)
|
||||||
|
throw new Error("active-migrations.json must list at least one migration filename");
|
||||||
|
|
||||||
|
for (const migration of normalized) {
|
||||||
|
if (!migration.endsWith(".sql"))
|
||||||
|
throw new Error(`Invalid migration name in active manifest: ${migration}`);
|
||||||
|
const full = path.join(migrationsDir, migration);
|
||||||
|
if (!fs.existsSync(full))
|
||||||
|
throw new Error(`Migration listed in active manifest does not exist: ${migration}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMigrationFiles() {
|
||||||
|
const allFiles = fs.readdirSync(migrationsDir).filter(f => f.endsWith(".sql")).sort();
|
||||||
|
if (runAll)
|
||||||
|
return allFiles;
|
||||||
|
|
||||||
|
const active = readActiveManifest();
|
||||||
|
if (!active)
|
||||||
|
return allFiles;
|
||||||
|
|
||||||
|
return active;
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
requireAllowedDatabase();
|
requireAllowedDatabase();
|
||||||
|
|
||||||
@ -66,7 +105,7 @@ async function main() {
|
|||||||
await client.query("begin");
|
await client.query("begin");
|
||||||
await ensureMigrationsTable(client);
|
await ensureMigrationsTable(client);
|
||||||
|
|
||||||
const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith(".sql")).sort();
|
const files = resolveMigrationFiles();
|
||||||
|
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
const id = f;
|
const id = f;
|
||||||
|
|||||||
143
packages/db/src/migration-status.js
Normal file
143
packages/db/src/migration-status.js
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import pg from "pg";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
dotenv.config({ path: path.resolve(__dirname, "../../../apps/web/.env") });
|
||||||
|
|
||||||
|
const { Pool } = pg;
|
||||||
|
const migrationsDir = path.resolve(__dirname, "../migrations");
|
||||||
|
const activeManifestPath = path.resolve(migrationsDir, "active-migrations.json");
|
||||||
|
const runAll = process.argv.includes("--all");
|
||||||
|
|
||||||
|
const sha256 = s => crypto.createHash("sha256").update(s).digest("hex");
|
||||||
|
|
||||||
|
function getDatabaseName(connectionString) {
|
||||||
|
try {
|
||||||
|
const url = new URL(connectionString);
|
||||||
|
const name = url.pathname.replace(/^\/+/, "");
|
||||||
|
return name ? decodeURIComponent(name) : "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireAllowedDatabase() {
|
||||||
|
if (!process.env.DATABASE_URL)
|
||||||
|
throw new Error("DATABASE_URL is required");
|
||||||
|
const allowedRaw = process.env.ALLOWED_DB_NAMES;
|
||||||
|
if (!allowedRaw)
|
||||||
|
throw new Error("ALLOWED_DB_NAMES is required");
|
||||||
|
const allowed = allowedRaw
|
||||||
|
.split(",")
|
||||||
|
.map(value => value.trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (!allowed.length)
|
||||||
|
throw new Error("ALLOWED_DB_NAMES must include at least one database name");
|
||||||
|
const dbName = getDatabaseName(process.env.DATABASE_URL);
|
||||||
|
if (!dbName)
|
||||||
|
throw new Error("DATABASE_URL must include a database name");
|
||||||
|
if (!allowed.includes(dbName.toLowerCase()))
|
||||||
|
throw new Error(`DATABASE_URL must target an allowed database. Found "${dbName}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureMigrationsTable(client) {
|
||||||
|
await client.query(`
|
||||||
|
create table if not exists _migrations(
|
||||||
|
id text primary key,
|
||||||
|
hash text not null,
|
||||||
|
applied_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readActiveManifest() {
|
||||||
|
if (!fs.existsSync(activeManifestPath))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
const raw = fs.readFileSync(activeManifestPath, "utf8");
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
const listed = Array.isArray(parsed?.migrations) ? parsed.migrations : [];
|
||||||
|
const normalized = listed
|
||||||
|
.map(value => String(value || "").trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (!normalized.length)
|
||||||
|
throw new Error("active-migrations.json must list at least one migration filename");
|
||||||
|
|
||||||
|
for (const migration of normalized) {
|
||||||
|
if (!migration.endsWith(".sql"))
|
||||||
|
throw new Error(`Invalid migration name in active manifest: ${migration}`);
|
||||||
|
const full = path.join(migrationsDir, migration);
|
||||||
|
if (!fs.existsSync(full))
|
||||||
|
throw new Error(`Migration listed in active manifest does not exist: ${migration}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMigrationFiles() {
|
||||||
|
const allFiles = fs.readdirSync(migrationsDir).filter(f => f.endsWith(".sql")).sort();
|
||||||
|
if (runAll)
|
||||||
|
return allFiles;
|
||||||
|
const active = readActiveManifest();
|
||||||
|
if (!active)
|
||||||
|
return allFiles;
|
||||||
|
return active;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
requireAllowedDatabase();
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
ssl: process.env.DATABASE_SSL === "false" ? false : { rejectUnauthorized: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await ensureMigrationsTable(client);
|
||||||
|
const files = resolveMigrationFiles();
|
||||||
|
const appliedRows = (await client.query(
|
||||||
|
"select id, hash, applied_at from _migrations order by applied_at asc"
|
||||||
|
)).rows;
|
||||||
|
const appliedById = new Map(appliedRows.map(row => [String(row.id), row]));
|
||||||
|
|
||||||
|
console.log(`mode=${runAll ? "all" : "active"}`);
|
||||||
|
console.log(`migrations_checked=${files.length}`);
|
||||||
|
|
||||||
|
for (const f of files) {
|
||||||
|
const sql = fs.readFileSync(path.join(migrationsDir, f), "utf8");
|
||||||
|
const hash = sha256(sql);
|
||||||
|
const applied = appliedById.get(f);
|
||||||
|
if (!applied) {
|
||||||
|
console.log(`[PENDING] ${f}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (String(applied.hash) !== hash) {
|
||||||
|
console.log(`[HASH_MISMATCH] ${f} applied_at=${applied.applied_at}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
console.log(`[APPLIED] ${f} applied_at=${applied.applied_at}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopedSet = new Set(files);
|
||||||
|
const extras = appliedRows.filter(row => !scopedSet.has(String(row.id)));
|
||||||
|
if (extras.length) {
|
||||||
|
console.log(`applied_outside_scope=${extras.length}`);
|
||||||
|
extras.forEach(row => console.log(` - ${row.id} (${row.applied_at})`));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user