harden public launch api contracts and ops baseline
This commit is contained in:
parent
4873449e16
commit
b1c8a4ae6c
56
.gitea/workflows/deploy-dokploy.yml
Normal file
56
.gitea/workflows/deploy-dokploy.yml
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
name: Build & Deploy Fiddy (Dokploy)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "main" ]
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.nicosaya.com/nalalangan/fiddy
|
||||||
|
|
||||||
|
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:main -f docker/Dockerfile .
|
||||||
|
|
||||||
|
- name: Push Web Image
|
||||||
|
run: |
|
||||||
|
docker push $REGISTRY/web:${{ github.sha }}
|
||||||
|
docker push $REGISTRY/web:main
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Trigger Dokploy Deploy
|
||||||
|
env:
|
||||||
|
DOKPLOY_DEPLOY_HOOK: ${{ secrets.DOKPLOY_DEPLOY_HOOK }}
|
||||||
|
IMAGE_TAG: ${{ github.sha }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$DOKPLOY_DEPLOY_HOOK" ]; then
|
||||||
|
echo "Missing DOKPLOY_DEPLOY_HOOK secret"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
curl -fsS -X POST "$DOKPLOY_DEPLOY_HOOK" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"imageTag\":\"$IMAGE_TAG\"}"
|
||||||
@ -75,7 +75,7 @@ test("buckets CRUD", async t => {
|
|||||||
assert.equal(updated?.name, "Groceries+");
|
assert.equal(updated?.name, "Groceries+");
|
||||||
assert.deepEqual(updated?.tags.sort(), ["groceries"]);
|
assert.deepEqual(updated?.tags.sort(), ["groceries"]);
|
||||||
|
|
||||||
await deleteBucket({ id: bucket.id, groupId });
|
await deleteBucket({ id: bucket.id, groupId, userId });
|
||||||
const listAfter = await listBuckets(groupId);
|
const listAfter = await listBuckets(groupId);
|
||||||
assert.equal(listAfter.length, 0);
|
assert.equal(listAfter.length, 0);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
14
apps/web/__tests__/errors-response.test.ts
Normal file
14
apps/web/__tests__/errors-response.test.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { ApiError, toErrorResponse } from "../lib/server/errors";
|
||||||
|
|
||||||
|
test("toErrorResponse includes request_id alias", async () => {
|
||||||
|
const prevDebug = process.env.DEBUG_API;
|
||||||
|
process.env.DEBUG_API = "0";
|
||||||
|
const { status, body } = toErrorResponse(new ApiError("UNAUTHORIZED"), "GET /api/example", "req_test_1");
|
||||||
|
assert.equal(status, 401);
|
||||||
|
assert.equal(body.requestId, "req_test_1");
|
||||||
|
assert.equal(body.request_id, "req_test_1");
|
||||||
|
assert.equal(body.error.code, "UNAUTHORIZED");
|
||||||
|
process.env.DEBUG_API = prevDebug;
|
||||||
|
});
|
||||||
101
apps/web/__tests__/rate-limit.test.ts
Normal file
101
apps/web/__tests__/rate-limit.test.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
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 { ApiError } from "../lib/server/errors";
|
||||||
|
import { enforceAuthRateLimit, enforceUserWriteRateLimit } from "../lib/server/rate-limit";
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
async function ensureRateLimitTable() {
|
||||||
|
const pool = getPool();
|
||||||
|
await pool.query(`
|
||||||
|
create table if not exists rate_limits(
|
||||||
|
key text primary key,
|
||||||
|
window_start timestamptz not null,
|
||||||
|
count integer not null default 0,
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("auth rate limit blocks when threshold is exceeded", async t => {
|
||||||
|
if (!hasDb) {
|
||||||
|
t.skip("DATABASE_URL not set");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (envLoaded.error) t.diagnostic(String(envLoaded.error));
|
||||||
|
|
||||||
|
await ensureRateLimitTable();
|
||||||
|
const pool = getPool();
|
||||||
|
const marker = Date.now();
|
||||||
|
const ip = `test-ip-${marker}`;
|
||||||
|
const identifier = `rate_limit_${marker}@example.com`;
|
||||||
|
try {
|
||||||
|
await enforceAuthRateLimit({
|
||||||
|
route: "login",
|
||||||
|
ip,
|
||||||
|
identifier,
|
||||||
|
ipLimit: 1,
|
||||||
|
identifierLimit: 1,
|
||||||
|
windowMs: 60_000
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => enforceAuthRateLimit({
|
||||||
|
route: "login",
|
||||||
|
ip,
|
||||||
|
identifier,
|
||||||
|
ipLimit: 1,
|
||||||
|
identifierLimit: 1,
|
||||||
|
windowMs: 60_000
|
||||||
|
}),
|
||||||
|
(error: unknown) => error instanceof ApiError && error.code === "RATE_LIMITED"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await pool.query("delete from rate_limits where key like $1 or key like $2", [
|
||||||
|
`auth:login:ip:${ip}%`,
|
||||||
|
`auth:login:identifier:${identifier}%`
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("user write rate limit blocks when threshold is exceeded", async t => {
|
||||||
|
if (!hasDb) {
|
||||||
|
t.skip("DATABASE_URL not set");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (envLoaded.error) t.diagnostic(String(envLoaded.error));
|
||||||
|
|
||||||
|
await ensureRateLimitTable();
|
||||||
|
const pool = getPool();
|
||||||
|
const userId = 987654;
|
||||||
|
const scope = `test_scope_${Date.now()}`;
|
||||||
|
try {
|
||||||
|
await enforceUserWriteRateLimit({
|
||||||
|
userId,
|
||||||
|
scope,
|
||||||
|
limit: 1,
|
||||||
|
windowMs: 60_000
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => enforceUserWriteRateLimit({
|
||||||
|
userId,
|
||||||
|
scope,
|
||||||
|
limit: 1,
|
||||||
|
windowMs: 60_000
|
||||||
|
}),
|
||||||
|
(error: unknown) => error instanceof ApiError && error.code === "RATE_LIMITED"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await pool.query("delete from rate_limits where key = $1", [
|
||||||
|
`write:user:${userId}:scope:${scope}`
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -69,7 +69,7 @@ test("recurring entries list", async t => {
|
|||||||
} finally {
|
} finally {
|
||||||
if (groupId) {
|
if (groupId) {
|
||||||
const list = await listRecurringEntries(groupId);
|
const list = await listRecurringEntries(groupId);
|
||||||
for (const entry of list) await deleteRecurringEntry({ id: entry.id, groupId });
|
for (const entry of list) await deleteRecurringEntry({ id: entry.id, groupId, userId: userId! });
|
||||||
}
|
}
|
||||||
await cleanupTestData(client, { userIds: [userId], groupId });
|
await cleanupTestData(client, { userIds: [userId], groupId });
|
||||||
client.release();
|
client.release();
|
||||||
|
|||||||
@ -88,7 +88,7 @@ test("entries CRUD", async t => {
|
|||||||
assert.equal(updated?.entryType, "INCOME");
|
assert.equal(updated?.entryType, "INCOME");
|
||||||
assert.deepEqual(updated?.tags.sort(), ["groceries"]);
|
assert.deepEqual(updated?.tags.sort(), ["groceries"]);
|
||||||
|
|
||||||
await deleteEntry({ id: entry.id, groupId });
|
await deleteEntry({ id: entry.id, groupId, userId });
|
||||||
const listAfter = await listEntries(groupId);
|
const listAfter = await listEntries(groupId);
|
||||||
assert.equal(listAfter.length, 0);
|
assert.equal(listAfter.length, 0);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@ -2,25 +2,28 @@ import { NextResponse } from "next/server";
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { getSessionCookieName } from "@/lib/server/auth";
|
import { getSessionCookieName } from "@/lib/server/auth";
|
||||||
import { loginUser } from "@/lib/server/auth-service";
|
import { loginUser } from "@/lib/server/auth-service";
|
||||||
|
import { enforceAuthRateLimit } from "@/lib/server/rate-limit";
|
||||||
import { toErrorResponse } from "@/lib/server/errors";
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
|
const { requestId, ip } = await getRequestMeta();
|
||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const email = String(body?.email || "").trim().toLowerCase();
|
const email = String(body?.email || "").trim().toLowerCase();
|
||||||
const password = String(body?.password || "");
|
const password = String(body?.password || "");
|
||||||
const remember = Boolean(body?.remember ?? true);
|
const remember = Boolean(body?.remember ?? true);
|
||||||
|
|
||||||
if (!email || !password)
|
|
||||||
return NextResponse.json({ error: { code: "MISSING_CREDENTIALS", message: "Missing credentials" } }, { status: 400 });
|
|
||||||
|
|
||||||
let user;
|
let user;
|
||||||
let session;
|
let session;
|
||||||
try {
|
try {
|
||||||
|
await enforceAuthRateLimit({ route: "login", ip, identifier: email });
|
||||||
|
if (!email || !password)
|
||||||
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_CREDENTIALS", message: "Missing credentials" } }, { status: 400 });
|
||||||
const result = await loginUser({ email, password, remember });
|
const result = await loginUser({ email, password, remember });
|
||||||
user = result.user;
|
user = result.user;
|
||||||
session = result.session;
|
session = result.session;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/auth/login");
|
const { status, body } = toErrorResponse(e, "POST /api/auth/login", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
@ -32,5 +35,5 @@ export async function POST(req: Request) {
|
|||||||
path: "/"
|
path: "/"
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ user });
|
return NextResponse.json({ requestId, request_id: requestId, user });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,8 +2,12 @@ import { NextResponse } from "next/server";
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { getSessionCookieName } from "@/lib/server/auth";
|
import { getSessionCookieName } from "@/lib/server/auth";
|
||||||
import { logoutUser } from "@/lib/server/auth-service";
|
import { logoutUser } from "@/lib/server/auth-service";
|
||||||
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
|
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
|
const { requestId } = await getRequestMeta();
|
||||||
|
try {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const token = cookieStore.get(getSessionCookieName())?.value;
|
const token = cookieStore.get(getSessionCookieName())?.value;
|
||||||
if (token)
|
if (token)
|
||||||
@ -16,5 +20,9 @@ export async function POST() {
|
|||||||
path: "/"
|
path: "/"
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "POST /api/auth/logout", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,17 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getSessionUser } from "@/lib/server/session";
|
import { getSessionUser } from "@/lib/server/session";
|
||||||
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
|
const { requestId } = await getRequestMeta();
|
||||||
|
try {
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
if (!user)
|
if (!user)
|
||||||
return NextResponse.json({ error: { code: "UNAUTHORIZED", message: "Unauthorized" } }, { status: 401 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "UNAUTHORIZED", message: "Unauthorized" } }, { status: 401 });
|
||||||
return NextResponse.json({ user });
|
return NextResponse.json({ requestId, request_id: requestId, user });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "GET /api/auth/me", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,27 +2,30 @@ import { NextResponse } from "next/server";
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { getSessionCookieName, getSessionTtlMs } from "@/lib/server/auth";
|
import { getSessionCookieName, getSessionTtlMs } from "@/lib/server/auth";
|
||||||
import { registerUser } from "@/lib/server/auth-service";
|
import { registerUser } from "@/lib/server/auth-service";
|
||||||
|
import { enforceAuthRateLimit } from "@/lib/server/rate-limit";
|
||||||
import { toErrorResponse } from "@/lib/server/errors";
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
|
const { requestId, ip } = await getRequestMeta();
|
||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const email = String(body?.email || "").trim().toLowerCase();
|
const email = String(body?.email || "").trim().toLowerCase();
|
||||||
const password = String(body?.password || "");
|
const password = String(body?.password || "");
|
||||||
const displayName = String(body?.displayName || "").trim();
|
const displayName = String(body?.displayName || "").trim();
|
||||||
|
|
||||||
if (!email || !email.includes("@"))
|
|
||||||
return NextResponse.json({ error: { code: "INVALID_EMAIL", message: "Invalid email" } }, { status: 400 });
|
|
||||||
if (password.length < 8)
|
|
||||||
return NextResponse.json({ error: { code: "PASSWORD_TOO_SHORT", message: "Password too short" } }, { status: 400 });
|
|
||||||
|
|
||||||
let user;
|
let user;
|
||||||
let session;
|
let session;
|
||||||
try {
|
try {
|
||||||
|
await enforceAuthRateLimit({ route: "register", ip, identifier: email });
|
||||||
|
if (!email || !email.includes("@"))
|
||||||
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_EMAIL", message: "Invalid email" } }, { status: 400 });
|
||||||
|
if (password.length < 8)
|
||||||
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "PASSWORD_TOO_SHORT", message: "Password too short" } }, { status: 400 });
|
||||||
const result = await registerUser({ email, password, displayName });
|
const result = await registerUser({ email, password, displayName });
|
||||||
user = result.user;
|
user = result.user;
|
||||||
session = result.session;
|
session = result.session;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/auth/register");
|
const { status, body } = toErrorResponse(e, "POST /api/auth/register", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
@ -34,5 +37,5 @@ export async function POST(req: Request) {
|
|||||||
path: "/"
|
path: "/"
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ user });
|
return NextResponse.json({ requestId, request_id: requestId, user });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,7 +17,7 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
|||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const { id: idParam } = await params;
|
const { id: idParam } = await params;
|
||||||
const id = Number(idParam || 0);
|
const id = Number(idParam || 0);
|
||||||
if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
|
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 body = await req.json().catch(() => null);
|
||||||
const name = String(body?.name || "").trim();
|
const name = String(body?.name || "").trim();
|
||||||
@ -30,13 +30,13 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
|||||||
const windowDays = body?.windowDays != null ? Number(body.windowDays) : 30;
|
const windowDays = body?.windowDays != null ? Number(body.windowDays) : 30;
|
||||||
|
|
||||||
if (!name)
|
if (!name)
|
||||||
return NextResponse.json({ requestId, error: { code: "MISSING_NAME", message: "name is required" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_NAME", message: "name is required" } }, { status: 400 });
|
||||||
if (budgetLimitDollars != null && (!Number.isFinite(budgetLimitDollars) || budgetLimitDollars < 0))
|
if (budgetLimitDollars != null && (!Number.isFinite(budgetLimitDollars) || budgetLimitDollars < 0))
|
||||||
return NextResponse.json({ requestId, error: { code: "INVALID_BUDGET", message: "Invalid budgetLimitDollars" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_BUDGET", message: "Invalid budgetLimitDollars" } }, { status: 400 });
|
||||||
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
||||||
return NextResponse.json({ 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 (!Number.isFinite(windowDays) || windowDays < 1 || windowDays > 365)
|
if (!Number.isFinite(windowDays) || windowDays < 1 || windowDays > 365)
|
||||||
return NextResponse.json({ requestId, error: { code: "INVALID_WINDOW_DAYS", message: "Invalid windowDays" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_WINDOW_DAYS", message: "Invalid windowDays" } }, { status: 400 });
|
||||||
|
|
||||||
const bucket = await updateBucket({
|
const bucket = await updateBucket({
|
||||||
id,
|
id,
|
||||||
@ -51,9 +51,9 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
|||||||
necessity: necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
|
necessity: necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
|
||||||
windowDays
|
windowDays
|
||||||
});
|
});
|
||||||
if (!bucket) return NextResponse.json({ requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 });
|
if (!bucket) return NextResponse.json({ requestId, request_id: requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 });
|
||||||
|
|
||||||
return NextResponse.json({ requestId, bucket });
|
return NextResponse.json({ requestId, request_id: requestId, bucket });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "PATCH /api/buckets/[id]", requestId);
|
const { status, body } = toErrorResponse(e, "PATCH /api/buckets/[id]", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
@ -67,10 +67,10 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str
|
|||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const { id: idParam } = await params;
|
const { id: idParam } = await params;
|
||||||
const id = Number(idParam || 0);
|
const id = Number(idParam || 0);
|
||||||
if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
|
if (!id) return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
|
||||||
|
|
||||||
await deleteBucket({ id, groupId });
|
await deleteBucket({ id, groupId, userId: user.id });
|
||||||
return NextResponse.json({ requestId, ok: true });
|
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "DELETE /api/buckets/[id]", requestId);
|
const { status, body } = toErrorResponse(e, "DELETE /api/buckets/[id]", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
|
|||||||
@ -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 { createBucket, listBuckets, requireActiveGroup } from "@/lib/server/buckets";
|
import { createBucket, listBuckets, requireActiveGroup } from "@/lib/server/buckets";
|
||||||
import { toErrorResponse } from "@/lib/server/errors";
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
@ -16,7 +16,7 @@ export async function GET() {
|
|||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const buckets = await listBuckets(groupId);
|
const buckets = await listBuckets(groupId);
|
||||||
return NextResponse.json({ requestId, buckets });
|
return NextResponse.json({ requestId, request_id: requestId, buckets });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "GET /api/buckets", requestId);
|
const { status, body } = toErrorResponse(e, "GET /api/buckets", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
@ -38,13 +38,13 @@ export async function POST(req: Request) {
|
|||||||
const necessity = String(body?.necessity || "BOTH").toUpperCase();
|
const necessity = String(body?.necessity || "BOTH").toUpperCase();
|
||||||
const windowDays = body?.windowDays != null ? Number(body.windowDays) : 30;
|
const windowDays = body?.windowDays != null ? Number(body.windowDays) : 30;
|
||||||
|
|
||||||
if (!name) return NextResponse.json({ requestId, error: { code: "MISSING_NAME", message: "name is required" } }, { status: 400 });
|
if (!name) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_NAME", message: "name is required" } }, { status: 400 });
|
||||||
if (budgetLimitDollars != null && (!Number.isFinite(budgetLimitDollars) || budgetLimitDollars < 0))
|
if (budgetLimitDollars != null && (!Number.isFinite(budgetLimitDollars) || budgetLimitDollars < 0))
|
||||||
return NextResponse.json({ requestId, error: { code: "INVALID_BUDGET", message: "Invalid budgetLimitDollars" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_BUDGET", message: "Invalid budgetLimitDollars" } }, { status: 400 });
|
||||||
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
||||||
return NextResponse.json({ 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 (!Number.isFinite(windowDays) || windowDays < 1 || windowDays > 365)
|
if (!Number.isFinite(windowDays) || windowDays < 1 || windowDays > 365)
|
||||||
return NextResponse.json({ requestId, error: { code: "INVALID_WINDOW_DAYS", message: "Invalid windowDays" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_WINDOW_DAYS", message: "Invalid windowDays" } }, { status: 400 });
|
||||||
|
|
||||||
const bucket = await createBucket({
|
const bucket = await createBucket({
|
||||||
groupId,
|
groupId,
|
||||||
@ -59,9 +59,10 @@ export async function POST(req: Request) {
|
|||||||
windowDays
|
windowDays
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ requestId, bucket });
|
return NextResponse.json({ requestId, request_id: requestId, bucket });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/buckets", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/buckets", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,7 +17,7 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
|||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const { id: idParam } = await params;
|
const { id: idParam } = await params;
|
||||||
const id = Number(idParam || 0);
|
const id = Number(idParam || 0);
|
||||||
if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
|
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 body = await req.json().catch(() => null);
|
||||||
const amountDollars = Number(body?.amountDollars || 0);
|
const amountDollars = Number(body?.amountDollars || 0);
|
||||||
@ -37,19 +37,19 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
|||||||
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)
|
||||||
return NextResponse.json({ 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 });
|
||||||
if (!occurredAt) return NextResponse.json({ requestId, error: { code: "MISSING_OCCURRED_AT", message: "occurredAt is required" } }, { status: 400 });
|
if (!occurredAt) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_OCCURRED_AT", message: "occurredAt is required" } }, { status: 400 });
|
||||||
if (!purchaseType) return NextResponse.json({ requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 });
|
if (!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))
|
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
||||||
return NextResponse.json({ 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, 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, 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', 'BIWEEKLY', 'MONTHLY', 'QUARTERLY', 'YEARLY'].includes(frequency))
|
||||||
return NextResponse.json({ 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, 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 });
|
||||||
|
|
||||||
const entry = await updateEntry({
|
const entry = await updateEntry({
|
||||||
id,
|
id,
|
||||||
@ -72,9 +72,9 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
|||||||
bucketId
|
bucketId
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!entry) return NextResponse.json({ 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 });
|
||||||
|
|
||||||
return NextResponse.json({ requestId, entry });
|
return NextResponse.json({ requestId, request_id: requestId, entry });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "PATCH /api/entries/[id]", requestId);
|
const { status, body } = toErrorResponse(e, "PATCH /api/entries/[id]", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
@ -88,10 +88,10 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str
|
|||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const { id: idParam } = await params;
|
const { id: idParam } = await params;
|
||||||
const id = Number(idParam || 0);
|
const id = Number(idParam || 0);
|
||||||
if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
|
if (!id) return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
|
||||||
|
|
||||||
await deleteEntry({ id, groupId });
|
await deleteEntry({ id, groupId, userId: user.id });
|
||||||
return NextResponse.json({ requestId, ok: true });
|
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "DELETE /api/entries/[id]", requestId);
|
const { status, body } = toErrorResponse(e, "DELETE /api/entries/[id]", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
|
|||||||
@ -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 { createEntry, listEntries, requireActiveGroup } from "@/lib/server/entries";
|
import { createEntry, listEntries, requireActiveGroup } from "@/lib/server/entries";
|
||||||
import { toErrorResponse } from "@/lib/server/errors";
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
@ -16,7 +16,7 @@ export async function GET() {
|
|||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const entries = await listEntries(groupId);
|
const entries = await listEntries(groupId);
|
||||||
return NextResponse.json({ requestId, entries });
|
return NextResponse.json({ requestId, request_id: requestId, entries });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "GET /api/entries", requestId);
|
const { status, body } = toErrorResponse(e, "GET /api/entries", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
@ -46,19 +46,19 @@ export async function POST(req: Request) {
|
|||||||
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)
|
||||||
return NextResponse.json({ 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 });
|
||||||
if (!occurredAt) return NextResponse.json({ requestId, error: { code: "MISSING_OCCURRED_AT", message: "occurredAt is required" } }, { status: 400 });
|
if (!occurredAt) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_OCCURRED_AT", message: "occurredAt is required" } }, { status: 400 });
|
||||||
if (!purchaseType) return NextResponse.json({ requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 });
|
if (!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))
|
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
||||||
return NextResponse.json({ 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, 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, 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', 'BIWEEKLY', 'MONTHLY', 'QUARTERLY', 'YEARLY'].includes(frequency))
|
||||||
return NextResponse.json({ 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, 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 });
|
||||||
|
|
||||||
const entry = await createEntry({
|
const entry = await createEntry({
|
||||||
groupId,
|
groupId,
|
||||||
@ -80,9 +80,10 @@ export async function POST(req: Request) {
|
|||||||
bucketId
|
bucketId
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ requestId, entry });
|
return NextResponse.json({ requestId, request_id: requestId, entry });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/entries", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/entries", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 { getActiveGroupId, listGroups, setActiveGroupForUser } from "@/lib/server/groups";
|
import { getActiveGroupId, listGroups, setActiveGroupForUser } from "@/lib/server/groups";
|
||||||
import { toErrorResponse } from "@/lib/server/errors";
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
@ -11,7 +11,7 @@ export async function GET() {
|
|||||||
const groups = await listGroups(user.id);
|
const groups = await listGroups(user.id);
|
||||||
const activeGroupId = await getActiveGroupId(user.id);
|
const activeGroupId = await getActiveGroupId(user.id);
|
||||||
const active = groups.find(group => Number(group.id) === activeGroupId) || null;
|
const active = groups.find(group => Number(group.id) === activeGroupId) || null;
|
||||||
return NextResponse.json({ requestId, active });
|
return NextResponse.json({ requestId, request_id: requestId, active });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "GET /api/groups/active", requestId);
|
const { status, body } = toErrorResponse(e, "GET /api/groups/active", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
@ -24,12 +24,13 @@ export async function POST(req: Request) {
|
|||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const groupId = Number(body?.groupId || 0);
|
const groupId = Number(body?.groupId || 0);
|
||||||
if (!groupId) return NextResponse.json({ requestId, error: { code: "MISSING_GROUP_ID", message: "groupId is required" } }, { status: 400 });
|
if (!groupId) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_GROUP_ID", message: "groupId is required" } }, { status: 400 });
|
||||||
|
|
||||||
await setActiveGroupForUser(user.id, groupId);
|
await setActiveGroupForUser(user.id, groupId);
|
||||||
return NextResponse.json({ requestId, ok: true });
|
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/active", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/active", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { listGroupAudit } from "@/lib/server/group-audit";
|
import { listGroupAudit } from "@/lib/server/group-audit";
|
||||||
@ -11,9 +11,10 @@ export async function GET() {
|
|||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const events = await listGroupAudit({ userId: user.id, groupId });
|
const events = await listGroupAudit({ userId: user.id, groupId });
|
||||||
return NextResponse.json({ requestId, events });
|
return NextResponse.json({ requestId, request_id: requestId, events });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "GET /api/groups/audit", requestId);
|
const { status, body } = toErrorResponse(e, "GET /api/groups/audit", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 { requireActiveGroup, deleteGroup } from "@/lib/server/groups";
|
import { requireActiveGroup, deleteGroup } from "@/lib/server/groups";
|
||||||
import { toErrorResponse } from "@/lib/server/errors";
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
@ -10,9 +10,10 @@ export async function POST() {
|
|||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
await deleteGroup({ userId: user.id, groupId, requestId, ip, userAgent });
|
await deleteGroup({ userId: user.id, groupId, requestId, ip, userAgent });
|
||||||
return NextResponse.json({ requestId, ok: true });
|
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/delete", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/delete", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { deleteInviteLink } from "@/lib/server/group-invites";
|
import { deleteInviteLink } from "@/lib/server/group-invites";
|
||||||
@ -13,11 +13,12 @@ export async function POST(req: Request) {
|
|||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const linkId = Number(body?.linkId || 0);
|
const linkId = Number(body?.linkId || 0);
|
||||||
if (!linkId)
|
if (!linkId)
|
||||||
return NextResponse.json({ requestId, error: { code: "MISSING_LINK_ID", message: "linkId is required" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_LINK_ID", message: "linkId is required" } }, { status: 400 });
|
||||||
await deleteInviteLink({ linkId, userId: user.id, groupId, requestId, ip, userAgent });
|
await deleteInviteLink({ linkId, userId: user.id, groupId, requestId, ip, userAgent });
|
||||||
return NextResponse.json({ requestId, ok: true });
|
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/invites/delete", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/invites/delete", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { reviveInviteLink } from "@/lib/server/group-invites";
|
import { reviveInviteLink } from "@/lib/server/group-invites";
|
||||||
@ -14,14 +14,15 @@ export async function POST(req: Request) {
|
|||||||
const linkId = Number(body?.linkId || 0);
|
const linkId = Number(body?.linkId || 0);
|
||||||
const ttlDays = Math.min(7, Math.max(1, Number(body?.ttlDays || 0)));
|
const ttlDays = Math.min(7, Math.max(1, Number(body?.ttlDays || 0)));
|
||||||
if (!linkId)
|
if (!linkId)
|
||||||
return NextResponse.json({ requestId, error: { code: "MISSING_LINK_ID", message: "linkId is required" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_LINK_ID", message: "linkId is required" } }, { status: 400 });
|
||||||
if (!ttlDays)
|
if (!ttlDays)
|
||||||
return NextResponse.json({ requestId, error: { code: "MISSING_TTL", message: "ttlDays is required" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_TTL", message: "ttlDays is required" } }, { status: 400 });
|
||||||
const expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000);
|
const expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000);
|
||||||
await reviveInviteLink({ userId: user.id, groupId, linkId, expiresAt, requestId, ip, userAgent });
|
await reviveInviteLink({ userId: user.id, groupId, linkId, expiresAt, requestId, ip, userAgent });
|
||||||
return NextResponse.json({ requestId, ok: true });
|
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/invites/revive", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/invites/revive", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { revokeInviteLink } from "@/lib/server/group-invites";
|
import { revokeInviteLink } from "@/lib/server/group-invites";
|
||||||
@ -13,11 +13,12 @@ export async function POST(req: Request) {
|
|||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const linkId = Number(body?.linkId || 0);
|
const linkId = Number(body?.linkId || 0);
|
||||||
if (!linkId)
|
if (!linkId)
|
||||||
return NextResponse.json({ requestId, error: { code: "MISSING_LINK_ID", message: "linkId is required" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_LINK_ID", message: "linkId is required" } }, { status: 400 });
|
||||||
await revokeInviteLink({ userId: user.id, groupId, linkId, requestId, ip, userAgent });
|
await revokeInviteLink({ userId: user.id, groupId, linkId, requestId, ip, userAgent });
|
||||||
return NextResponse.json({ requestId, ok: true });
|
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/invites/revoke", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/invites/revoke", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { createInviteLink, listInviteLinks } from "@/lib/server/group-invites";
|
import { createInviteLink, listInviteLinks } from "@/lib/server/group-invites";
|
||||||
@ -11,7 +11,7 @@ export async function GET() {
|
|||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const links = await listInviteLinks({ userId: user.id, groupId });
|
const links = await listInviteLinks({ userId: user.id, groupId });
|
||||||
return NextResponse.json({ requestId, links });
|
return NextResponse.json({ requestId, request_id: requestId, links });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "GET /api/groups/invites", requestId);
|
const { status, body } = toErrorResponse(e, "GET /api/groups/invites", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
@ -30,12 +30,13 @@ export async function POST(req: Request) {
|
|||||||
const singleUse = Boolean(body?.singleUse);
|
const singleUse = Boolean(body?.singleUse);
|
||||||
const ttlDays = Math.min(7, Math.max(1, Number(body?.ttlDays || 0)));
|
const ttlDays = Math.min(7, Math.max(1, Number(body?.ttlDays || 0)));
|
||||||
if (!ttlDays)
|
if (!ttlDays)
|
||||||
return NextResponse.json({ requestId, error: { code: "MISSING_TTL", message: "ttlDays is required" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_TTL", message: "ttlDays is required" } }, { status: 400 });
|
||||||
const expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000);
|
const expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000);
|
||||||
const link = await createInviteLink({ userId: user.id, groupId, policy, singleUse, expiresAt, requestId, ip, userAgent });
|
const link = await createInviteLink({ userId: user.id, groupId, policy, singleUse, expiresAt, requestId, ip, userAgent });
|
||||||
return NextResponse.json({ requestId, link });
|
return NextResponse.json({ requestId, request_id: requestId, link });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/invites", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/invites", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 { joinGroup } from "@/lib/server/groups";
|
import { joinGroup } from "@/lib/server/groups";
|
||||||
import { toErrorResponse } from "@/lib/server/errors";
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
@ -11,12 +11,13 @@ export async function POST(req: Request) {
|
|||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const inviteCode = String(body?.inviteCode || "").trim().toUpperCase();
|
const inviteCode = String(body?.inviteCode || "").trim().toUpperCase();
|
||||||
if (!inviteCode)
|
if (!inviteCode)
|
||||||
return NextResponse.json({ requestId, error: { code: "MISSING_INVITE_CODE", message: "Invite code is required" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_INVITE_CODE", message: "Invite code is required" } }, { status: 400 });
|
||||||
|
|
||||||
const group = await joinGroup(user.id, inviteCode);
|
const group = await joinGroup(user.id, inviteCode);
|
||||||
return NextResponse.json({ requestId, group });
|
return NextResponse.json({ requestId, request_id: requestId, group });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/join", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/join", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { approveJoinRequest } from "@/lib/server/group-members";
|
import { approveJoinRequest } from "@/lib/server/group-members";
|
||||||
@ -14,11 +14,12 @@ export async function POST(req: Request) {
|
|||||||
const userId = Number(body?.userId || 0);
|
const userId = Number(body?.userId || 0);
|
||||||
const joinRequestId = Number(body?.requestId || 0);
|
const joinRequestId = Number(body?.requestId || 0);
|
||||||
if (!userId)
|
if (!userId)
|
||||||
return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
|
||||||
await approveJoinRequest({ actorUserId: user.id, groupId, userId, requestId, ip, userAgent, requestRowId: joinRequestId || undefined });
|
await approveJoinRequest({ actorUserId: user.id, groupId, userId, requestId, ip, userAgent, requestRowId: joinRequestId || undefined });
|
||||||
return NextResponse.json({ requestId, ok: true });
|
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/members/approve", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/members/approve", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { demoteAdmin } from "@/lib/server/group-members";
|
import { demoteAdmin } from "@/lib/server/group-members";
|
||||||
@ -13,11 +13,12 @@ export async function POST(req: Request) {
|
|||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const userId = Number(body?.userId || 0);
|
const userId = Number(body?.userId || 0);
|
||||||
if (!userId)
|
if (!userId)
|
||||||
return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
|
||||||
await demoteAdmin({ actorUserId: user.id, groupId, targetUserId: userId, requestId, ip, userAgent });
|
await demoteAdmin({ actorUserId: user.id, groupId, targetUserId: userId, requestId, ip, userAgent });
|
||||||
return NextResponse.json({ requestId, ok: true });
|
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/members/demote", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/members/demote", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { denyJoinRequest } from "@/lib/server/group-members";
|
import { denyJoinRequest } from "@/lib/server/group-members";
|
||||||
@ -13,11 +13,12 @@ export async function POST(req: Request) {
|
|||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const userId = Number(body?.userId || 0);
|
const userId = Number(body?.userId || 0);
|
||||||
if (!userId)
|
if (!userId)
|
||||||
return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
|
||||||
await denyJoinRequest({ actorUserId: user.id, groupId, userId, requestId, ip, userAgent });
|
await denyJoinRequest({ actorUserId: user.id, groupId, userId, requestId, ip, userAgent });
|
||||||
return NextResponse.json({ requestId, ok: true });
|
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/members/deny", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/members/deny", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { kickMember } from "@/lib/server/group-members";
|
import { kickMember } from "@/lib/server/group-members";
|
||||||
@ -13,11 +13,12 @@ export async function POST(req: Request) {
|
|||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const userId = Number(body?.userId || 0);
|
const userId = Number(body?.userId || 0);
|
||||||
if (!userId)
|
if (!userId)
|
||||||
return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
|
||||||
await kickMember({ actorUserId: user.id, groupId, targetUserId: userId, requestId, ip, userAgent });
|
await kickMember({ actorUserId: user.id, groupId, targetUserId: userId, requestId, ip, userAgent });
|
||||||
return NextResponse.json({ requestId, ok: true });
|
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/members/kick", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/members/kick", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { leaveGroup } from "@/lib/server/group-members";
|
import { leaveGroup } from "@/lib/server/group-members";
|
||||||
@ -11,9 +11,10 @@ export async function POST() {
|
|||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
await leaveGroup({ userId: user.id, groupId, requestId, ip, userAgent });
|
await leaveGroup({ userId: user.id, groupId, requestId, ip, userAgent });
|
||||||
return NextResponse.json({ requestId, ok: true });
|
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/members/leave", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/members/leave", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { promoteToAdmin } from "@/lib/server/group-members";
|
import { promoteToAdmin } from "@/lib/server/group-members";
|
||||||
@ -13,11 +13,12 @@ export async function POST(req: Request) {
|
|||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const userId = Number(body?.userId || 0);
|
const userId = Number(body?.userId || 0);
|
||||||
if (!userId)
|
if (!userId)
|
||||||
return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
|
||||||
await promoteToAdmin({ actorUserId: user.id, groupId, targetUserId: userId, requestId, ip, userAgent });
|
await promoteToAdmin({ actorUserId: user.id, groupId, targetUserId: userId, requestId, ip, userAgent });
|
||||||
return NextResponse.json({ requestId, ok: true });
|
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/members/promote", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/members/promote", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { listGroupMembers, listJoinRequests } from "@/lib/server/group-members";
|
import { listGroupMembers, listJoinRequests } from "@/lib/server/group-members";
|
||||||
@ -12,9 +12,10 @@ export async function GET() {
|
|||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const members = await listGroupMembers(groupId);
|
const members = await listGroupMembers(groupId);
|
||||||
const requests = await listJoinRequests({ userId: user.id, groupId });
|
const requests = await listJoinRequests({ userId: user.id, groupId });
|
||||||
return NextResponse.json({ requestId, members, requests, currentUserId: user.id });
|
return NextResponse.json({ requestId, request_id: requestId, members, requests, currentUserId: user.id });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "GET /api/groups/members", requestId);
|
const { status, body } = toErrorResponse(e, "GET /api/groups/members", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { transferOwnership } from "@/lib/server/group-members";
|
import { transferOwnership } from "@/lib/server/group-members";
|
||||||
@ -13,11 +13,12 @@ export async function POST(req: Request) {
|
|||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const userId = Number(body?.userId || 0);
|
const userId = Number(body?.userId || 0);
|
||||||
if (!userId)
|
if (!userId)
|
||||||
return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
|
||||||
await transferOwnership({ actorUserId: user.id, groupId, newOwnerUserId: userId, requestId, ip, userAgent });
|
await transferOwnership({ actorUserId: user.id, groupId, newOwnerUserId: userId, requestId, ip, userAgent });
|
||||||
return NextResponse.json({ requestId, ok: true });
|
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/members/transfer-owner", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/members/transfer-owner", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 { requireActiveGroup, renameGroup } from "@/lib/server/groups";
|
import { requireActiveGroup, renameGroup } from "@/lib/server/groups";
|
||||||
import { toErrorResponse } from "@/lib/server/errors";
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
@ -12,11 +12,12 @@ export async function POST(req: Request) {
|
|||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const name = String(body?.name || "").trim();
|
const name = String(body?.name || "").trim();
|
||||||
if (!name)
|
if (!name)
|
||||||
return NextResponse.json({ requestId, error: { code: "MISSING_NAME", message: "Name is required" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_NAME", message: "Name is required" } }, { status: 400 });
|
||||||
await renameGroup({ userId: user.id, groupId, name, requestId, ip, userAgent });
|
await renameGroup({ userId: user.id, groupId, name, requestId, ip, userAgent });
|
||||||
return NextResponse.json({ requestId, ok: true });
|
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/rename", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/rename", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 { createGroup, listGroups } from "@/lib/server/groups";
|
import { createGroup, listGroups } from "@/lib/server/groups";
|
||||||
import { toErrorResponse } from "@/lib/server/errors";
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
@ -9,7 +9,7 @@ export async function GET() {
|
|||||||
try {
|
try {
|
||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
const groups = await listGroups(user.id);
|
const groups = await listGroups(user.id);
|
||||||
return NextResponse.json({ requestId, groups });
|
return NextResponse.json({ requestId, request_id: requestId, groups });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "GET /api/groups", requestId);
|
const { status, body } = toErrorResponse(e, "GET /api/groups", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
@ -22,12 +22,13 @@ export async function POST(req: Request) {
|
|||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const name = String(body?.name || "").trim();
|
const name = String(body?.name || "").trim();
|
||||||
if (!name) return NextResponse.json({ requestId, error: { code: "MISSING_NAME", message: "Name is required" } }, { status: 400 });
|
if (!name) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_NAME", message: "Name is required" } }, { status: 400 });
|
||||||
|
|
||||||
const group = await createGroup(user.id, name);
|
const group = await createGroup(user.id, name);
|
||||||
return NextResponse.json({ requestId, group });
|
return NextResponse.json({ requestId, request_id: requestId, group });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { getGroupSettings, setGroupSettings } from "@/lib/server/group-settings";
|
import { getGroupSettings, setGroupSettings } from "@/lib/server/group-settings";
|
||||||
@ -11,7 +11,7 @@ export async function GET() {
|
|||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const settings = await getGroupSettings(groupId);
|
const settings = await getGroupSettings(groupId);
|
||||||
return NextResponse.json({ requestId, settings });
|
return NextResponse.json({ requestId, request_id: requestId, settings });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "GET /api/groups/settings", requestId);
|
const { status, body } = toErrorResponse(e, "GET /api/groups/settings", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
@ -30,9 +30,10 @@ export async function POST(req: Request) {
|
|||||||
: "NOT_ACCEPTING";
|
: "NOT_ACCEPTING";
|
||||||
await setGroupSettings({ userId: user.id, groupId, allowMemberTagManage, joinPolicy });
|
await setGroupSettings({ userId: user.id, groupId, allowMemberTagManage, joinPolicy });
|
||||||
const settings = await getGroupSettings(groupId);
|
const settings = await getGroupSettings(groupId);
|
||||||
return NextResponse.json({ requestId, settings });
|
return NextResponse.json({ requestId, request_id: requestId, settings });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/settings", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/settings", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
12
apps/web/app/api/health/live/route.ts
Normal file
12
apps/web/app/api/health/live/route.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const { requestId } = await getRequestMeta();
|
||||||
|
return NextResponse.json({
|
||||||
|
requestId,
|
||||||
|
request_id: requestId,
|
||||||
|
ok: true,
|
||||||
|
status: "live"
|
||||||
|
});
|
||||||
|
}
|
||||||
25
apps/web/app/api/health/ready/route.ts
Normal file
25
apps/web/app/api/health/ready/route.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import getPool from "@/lib/server/db";
|
||||||
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const { requestId } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const pool = getPool();
|
||||||
|
await pool.query("select 1");
|
||||||
|
return NextResponse.json({
|
||||||
|
requestId,
|
||||||
|
request_id: requestId,
|
||||||
|
ok: true,
|
||||||
|
status: "ready"
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "GET /api/health/ready", requestId);
|
||||||
|
return NextResponse.json({
|
||||||
|
...body,
|
||||||
|
ok: false,
|
||||||
|
status: "not_ready"
|
||||||
|
}, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -16,9 +16,9 @@ export async function GET(_: Request, context: { params: Promise<{ token: string
|
|||||||
if (user) {
|
if (user) {
|
||||||
const viewerStatus = await getInviteViewerStatus({ userId: user.id, groupId: link.groupId });
|
const viewerStatus = await getInviteViewerStatus({ userId: user.id, groupId: link.groupId });
|
||||||
if (viewerStatus)
|
if (viewerStatus)
|
||||||
return NextResponse.json({ requestId, link: { ...link, viewerStatus } });
|
return NextResponse.json({ requestId, request_id: requestId, link: { ...link, viewerStatus } });
|
||||||
}
|
}
|
||||||
return NextResponse.json({ requestId, link });
|
return NextResponse.json({ requestId, request_id: requestId, link });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "GET /api/invite-links/[token]", requestId);
|
const { status, body } = toErrorResponse(e, "GET /api/invite-links/[token]", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
@ -33,7 +33,7 @@ export async function POST(_: Request, context: { params: Promise<{ token: strin
|
|||||||
const normalized = String(token || "").trim();
|
const normalized = String(token || "").trim();
|
||||||
if (!normalized) apiError("INVITE_NOT_FOUND");
|
if (!normalized) apiError("INVITE_NOT_FOUND");
|
||||||
const result = await acceptInviteLink({ userId: user.id, token: normalized, requestId, ip, userAgent });
|
const result = await acceptInviteLink({ userId: user.id, token: normalized, requestId, ip, userAgent });
|
||||||
return NextResponse.json({ requestId, result });
|
return NextResponse.json({ requestId, request_id: requestId, result });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/invite-links/[token]", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/invite-links/[token]", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
|
|||||||
@ -17,7 +17,7 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
|||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const { id: idParam } = await params;
|
const { id: idParam } = await params;
|
||||||
const id = Number(idParam || 0);
|
const id = Number(idParam || 0);
|
||||||
if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
|
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 body = await req.json().catch(() => null);
|
||||||
const amountDollars = Number(body?.amountDollars || 0);
|
const amountDollars = Number(body?.amountDollars || 0);
|
||||||
@ -36,19 +36,19 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
|||||||
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)
|
||||||
return NextResponse.json({ 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 });
|
||||||
if (!occurredAt) return NextResponse.json({ requestId, error: { code: "MISSING_OCCURRED_AT", message: "occurredAt is required" } }, { status: 400 });
|
if (!occurredAt) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_OCCURRED_AT", message: "occurredAt is required" } }, { status: 400 });
|
||||||
if (!purchaseType) return NextResponse.json({ requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 });
|
if (!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))
|
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
||||||
return NextResponse.json({ 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, 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, 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', 'BIWEEKLY', 'MONTHLY', 'QUARTERLY', 'YEARLY'].includes(frequency))
|
||||||
return NextResponse.json({ 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, 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 });
|
||||||
|
|
||||||
const entry = await updateRecurringEntry({
|
const entry = await updateRecurringEntry({
|
||||||
id,
|
id,
|
||||||
@ -71,9 +71,9 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
|||||||
bucketId
|
bucketId
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!entry) return NextResponse.json({ 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 });
|
||||||
|
|
||||||
return NextResponse.json({ requestId, entry });
|
return NextResponse.json({ requestId, request_id: requestId, entry });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "PATCH /api/recurring-entries/[id]", requestId);
|
const { status, body } = toErrorResponse(e, "PATCH /api/recurring-entries/[id]", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
@ -87,10 +87,10 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str
|
|||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const { id: idParam } = await params;
|
const { id: idParam } = await params;
|
||||||
const id = Number(idParam || 0);
|
const id = Number(idParam || 0);
|
||||||
if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
|
if (!id) return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
|
||||||
|
|
||||||
await deleteRecurringEntry({ id, groupId });
|
await deleteRecurringEntry({ id, groupId, userId: user.id });
|
||||||
return NextResponse.json({ requestId, ok: true });
|
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "DELETE /api/recurring-entries/[id]", requestId);
|
const { status, body } = toErrorResponse(e, "DELETE /api/recurring-entries/[id]", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
|
|||||||
@ -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";
|
||||||
@ -16,7 +16,7 @@ export async function GET() {
|
|||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const entries = await listRecurringEntries(groupId);
|
const entries = await listRecurringEntries(groupId);
|
||||||
return NextResponse.json({ requestId, entries });
|
return NextResponse.json({ requestId, request_id: requestId, entries });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "GET /api/recurring-entries", requestId);
|
const { status, body } = toErrorResponse(e, "GET /api/recurring-entries", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
@ -45,19 +45,19 @@ export async function POST(req: Request) {
|
|||||||
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)
|
||||||
return NextResponse.json({ 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 });
|
||||||
if (!occurredAt) return NextResponse.json({ requestId, error: { code: "MISSING_OCCURRED_AT", message: "occurredAt is required" } }, { status: 400 });
|
if (!occurredAt) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_OCCURRED_AT", message: "occurredAt is required" } }, { status: 400 });
|
||||||
if (!purchaseType) return NextResponse.json({ requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 });
|
if (!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))
|
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
||||||
return NextResponse.json({ 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, 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, 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', 'BIWEEKLY', 'MONTHLY', 'QUARTERLY', 'YEARLY'].includes(frequency))
|
||||||
return NextResponse.json({ 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, 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 });
|
||||||
|
|
||||||
const entry = await createRecurringEntry({
|
const entry = await createRecurringEntry({
|
||||||
groupId,
|
groupId,
|
||||||
@ -79,9 +79,10 @@ export async function POST(req: Request) {
|
|||||||
bucketId
|
bucketId
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ requestId, entry });
|
return NextResponse.json({ requestId, request_id: requestId, entry });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/recurring-entries", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/recurring-entries", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,7 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ name: s
|
|||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const { name } = await params;
|
const { name } = await params;
|
||||||
await deleteTagForGroup({ userId: user.id, groupId, name: decodeURIComponent(name) });
|
await deleteTagForGroup({ userId: user.id, groupId, name: decodeURIComponent(name) });
|
||||||
return NextResponse.json({ requestId, ok: true });
|
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "DELETE /api/tags/[name]", requestId);
|
const { status, body } = toErrorResponse(e, "DELETE /api/tags/[name]", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
|
|||||||
@ -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 { requireActiveGroup } from "@/lib/server/entries";
|
import { requireActiveGroup } from "@/lib/server/entries";
|
||||||
import { ensureTagsForGroup, listGroupTags } from "@/lib/server/tags";
|
import { ensureTagsForGroup, listGroupTags } from "@/lib/server/tags";
|
||||||
@ -11,7 +11,7 @@ export async function GET() {
|
|||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const tags = await listGroupTags(groupId);
|
const tags = await listGroupTags(groupId);
|
||||||
return NextResponse.json({ requestId, tags });
|
return NextResponse.json({ requestId, request_id: requestId, tags });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "GET /api/tags", requestId);
|
const { status, body } = toErrorResponse(e, "GET /api/tags", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
@ -27,9 +27,10 @@ export async function POST(req: Request) {
|
|||||||
const tags = Array.isArray(body?.tags) ? body.tags.map((tag: unknown) => String(tag)) : [];
|
const tags = Array.isArray(body?.tags) ? body.tags.map((tag: unknown) => String(tag)) : [];
|
||||||
await ensureTagsForGroup({ userId: user.id, groupId, tags });
|
await ensureTagsForGroup({ userId: user.id, groupId, tags });
|
||||||
const list = await listGroupTags(groupId);
|
const list = await listGroupTags(groupId);
|
||||||
return NextResponse.json({ requestId, tags: list });
|
return NextResponse.json({ requestId, request_id: requestId, tags: list });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/tags", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/tags", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,7 @@ export async function registerUser(input: { email: string; password: string; dis
|
|||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
const email = input.email.trim().toLowerCase();
|
const email = input.email.trim().toLowerCase();
|
||||||
const existing = await pool.query("select id from users where email=$1", [email]);
|
const existing = await pool.query("select id from users where email=$1", [email]);
|
||||||
if (existing.rowCount)
|
if (existing.rows.length > 0)
|
||||||
apiError("EMAIL_EXISTS", { email });
|
apiError("EMAIL_EXISTS", { email });
|
||||||
|
|
||||||
const passwordHash = await hashPassword(input.password);
|
const passwordHash = await hashPassword(input.password);
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import getPool from "@/lib/server/db";
|
|||||||
import { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { ensureTagsForGroup, listTagsForBuckets, normalizeTags, setBucketTags } from "@/lib/server/tags";
|
import { ensureTagsForGroup, listTagsForBuckets, normalizeTags, setBucketTags } from "@/lib/server/tags";
|
||||||
import { calculateBucketUsage } from "@/lib/shared/bucket-usage";
|
import { calculateBucketUsage } from "@/lib/shared/bucket-usage";
|
||||||
|
import { enforceUserWriteRateLimit } from "@/lib/server/rate-limit";
|
||||||
|
|
||||||
export { requireActiveGroup };
|
export { requireActiveGroup };
|
||||||
|
|
||||||
@ -108,6 +109,7 @@ export async function createBucket(input: {
|
|||||||
necessity?: "NECESSARY" | "BOTH" | "UNNECESSARY";
|
necessity?: "NECESSARY" | "BOTH" | "UNNECESSARY";
|
||||||
windowDays?: number;
|
windowDays?: number;
|
||||||
}) {
|
}) {
|
||||||
|
await enforceUserWriteRateLimit({ userId: input.userId, scope: "buckets:create" });
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
const rows = (await pool.query<BucketRow>(
|
const rows = (await pool.query<BucketRow>(
|
||||||
`insert into buckets(group_id, created_by, name, description, icon_key, budget_limit_dollars, position, necessity, window_days)
|
`insert into buckets(group_id, created_by, name, description, icon_key, budget_limit_dollars, position, necessity, window_days)
|
||||||
@ -157,6 +159,7 @@ export async function updateBucket(input: {
|
|||||||
necessity?: "NECESSARY" | "BOTH" | "UNNECESSARY";
|
necessity?: "NECESSARY" | "BOTH" | "UNNECESSARY";
|
||||||
windowDays?: number;
|
windowDays?: number;
|
||||||
}) {
|
}) {
|
||||||
|
await enforceUserWriteRateLimit({ userId: input.userId, scope: "buckets:update" });
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
const rows = (await pool.query<BucketRow>(
|
const rows = (await pool.query<BucketRow>(
|
||||||
`update buckets
|
`update buckets
|
||||||
@ -200,7 +203,8 @@ export async function updateBucket(input: {
|
|||||||
} as Bucket;
|
} as Bucket;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteBucket(input: { id: number; groupId: number }) {
|
export async function deleteBucket(input: { id: number; groupId: number; userId: number }) {
|
||||||
|
await enforceUserWriteRateLimit({ userId: input.userId, scope: "buckets:delete" });
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
await pool.query("delete from buckets where id=$1 and group_id=$2", [input.id, input.groupId]);
|
await pool.query("delete from buckets where id=$1 and group_id=$2", [input.id, input.groupId]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
if (process.env.NODE_ENV !== "test")
|
if (process.env.NODE_ENV !== "test")
|
||||||
require("server-only");
|
require("server-only");
|
||||||
import pg from "pg";
|
import pg, { type Pool as PgPool } from "pg";
|
||||||
|
|
||||||
const { Pool } = pg;
|
const { Pool } = pg;
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
// eslint-disable-next-line no-var
|
// eslint-disable-next-line no-var
|
||||||
var __fiddyPool: pg.Pool | undefined;
|
var __fiddyPool: PgPool | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function getPool() {
|
export default function getPool() {
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import getPool from "@/lib/server/db";
|
|||||||
import { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { listTagsForEntries, normalizeTags, requireExistingTagsForGroup, setEntryTags } from "@/lib/server/tags";
|
import { listTagsForEntries, normalizeTags, requireExistingTagsForGroup, setEntryTags } from "@/lib/server/tags";
|
||||||
import { listBucketTags } from "@/lib/server/buckets";
|
import { listBucketTags } from "@/lib/server/buckets";
|
||||||
|
import { enforceUserWriteRateLimit } from "@/lib/server/rate-limit";
|
||||||
import type { Entry } from "@/lib/shared/types";
|
import type { Entry } from "@/lib/shared/types";
|
||||||
|
|
||||||
type EntryRow = {
|
type EntryRow = {
|
||||||
@ -48,6 +49,7 @@ export async function listEntries(groupId: number): Promise<Entry[]> {
|
|||||||
purchaseType: row.purchase_type,
|
purchaseType: row.purchase_type,
|
||||||
notes: row.notes,
|
notes: row.notes,
|
||||||
receiptId: row.receipt_id,
|
receiptId: row.receipt_id,
|
||||||
|
bucketId: null,
|
||||||
tags: tagsMap.get(Number(row.id)) || [],
|
tags: tagsMap.get(Number(row.id)) || [],
|
||||||
isRecurring: row.is_recurring,
|
isRecurring: row.is_recurring,
|
||||||
frequency: row.frequency,
|
frequency: row.frequency,
|
||||||
@ -79,6 +81,7 @@ export async function createEntry(input: {
|
|||||||
nextRunAt?: string | null;
|
nextRunAt?: string | null;
|
||||||
bucketId?: number | null;
|
bucketId?: number | null;
|
||||||
}) {
|
}) {
|
||||||
|
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,
|
||||||
@ -118,6 +121,7 @@ export async function createEntry(input: {
|
|||||||
purchaseType: row.purchase_type,
|
purchaseType: row.purchase_type,
|
||||||
notes: row.notes,
|
notes: row.notes,
|
||||||
receiptId: row.receipt_id,
|
receiptId: row.receipt_id,
|
||||||
|
bucketId: null,
|
||||||
tags,
|
tags,
|
||||||
isRecurring: row.is_recurring,
|
isRecurring: row.is_recurring,
|
||||||
frequency: row.frequency,
|
frequency: row.frequency,
|
||||||
@ -150,6 +154,7 @@ export async function updateEntry(input: {
|
|||||||
nextRunAt?: string | null;
|
nextRunAt?: string | null;
|
||||||
bucketId?: number | null;
|
bucketId?: number | null;
|
||||||
}) {
|
}) {
|
||||||
|
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
|
||||||
@ -190,6 +195,7 @@ export async function updateEntry(input: {
|
|||||||
purchaseType: rows[0].purchase_type,
|
purchaseType: rows[0].purchase_type,
|
||||||
notes: rows[0].notes,
|
notes: rows[0].notes,
|
||||||
receiptId: rows[0].receipt_id,
|
receiptId: rows[0].receipt_id,
|
||||||
|
bucketId: null,
|
||||||
tags,
|
tags,
|
||||||
isRecurring: rows[0].is_recurring,
|
isRecurring: rows[0].is_recurring,
|
||||||
frequency: rows[0].frequency,
|
frequency: rows[0].frequency,
|
||||||
@ -202,7 +208,8 @@ export async function updateEntry(input: {
|
|||||||
} as Entry;
|
} as Entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteEntry(input: { id: number; groupId: number }) {
|
export async function deleteEntry(input: { id: number; groupId: number; userId: number }) {
|
||||||
|
await enforceUserWriteRateLimit({ userId: input.userId, scope: "entries:delete" });
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
await pool.query("delete from entries where id=$1 and group_id=$2", [input.id, input.groupId]);
|
await pool.query("delete from entries where id=$1 and group_id=$2", [input.id, input.groupId]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,8 @@ type ErrorPayload = {
|
|||||||
const sensitiveKeys = new Set([
|
const sensitiveKeys = new Set([
|
||||||
"password",
|
"password",
|
||||||
"token",
|
"token",
|
||||||
|
"invitecode",
|
||||||
|
"invite_code",
|
||||||
"authorization",
|
"authorization",
|
||||||
"cookie",
|
"cookie",
|
||||||
"session",
|
"session",
|
||||||
@ -37,7 +39,8 @@ const statusMap: Record<string, { status: number; message: string }> = {
|
|||||||
OWNER_MUST_TRANSFER: { status: 403, message: "Owner must transfer ownership before leaving" },
|
OWNER_MUST_TRANSFER: { status: 403, message: "Owner must transfer ownership before leaving" },
|
||||||
CANNOT_LEAVE_LAST_MEMBER: { status: 403, message: "Cannot leave last remaining member" },
|
CANNOT_LEAVE_LAST_MEMBER: { status: 403, message: "Cannot leave last remaining member" },
|
||||||
EMAIL_EXISTS: { status: 409, message: "Email already registered" },
|
EMAIL_EXISTS: { status: 409, message: "Email already registered" },
|
||||||
INVALID_CREDENTIALS: { status: 401, message: "Invalid credentials" }
|
INVALID_CREDENTIALS: { status: 401, message: "Invalid credentials" },
|
||||||
|
RATE_LIMITED: { status: 429, message: "Too many requests" }
|
||||||
};
|
};
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
@ -76,7 +79,6 @@ function logApiError(payload: ErrorPayload) {
|
|||||||
|
|
||||||
export function toErrorResponse(e: unknown, route: string, requestId?: string) {
|
export function toErrorResponse(e: unknown, route: string, requestId?: string) {
|
||||||
const debugEnabled = process.env.DEBUG_API === "1";
|
const debugEnabled = process.env.DEBUG_API === "1";
|
||||||
console.log()
|
|
||||||
let payload: ErrorPayload = { code: "SERVER_ERROR", message: "Server error" };
|
let payload: ErrorPayload = { code: "SERVER_ERROR", message: "Server error" };
|
||||||
let status = 500;
|
let status = 500;
|
||||||
|
|
||||||
@ -100,6 +102,7 @@ export function toErrorResponse(e: unknown, route: string, requestId?: string) {
|
|||||||
status,
|
status,
|
||||||
body: {
|
body: {
|
||||||
requestId,
|
requestId,
|
||||||
|
request_id: requestId,
|
||||||
error: { code: payload.code, message: payload.message },
|
error: { code: payload.code, message: payload.message },
|
||||||
...(debugEnabled ? { debug: payload.context } : {})
|
...(debugEnabled ? { debug: payload.context } : {})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import getPool from "@/lib/server/db";
|
|||||||
import { apiError } from "@/lib/server/errors";
|
import { apiError } from "@/lib/server/errors";
|
||||||
import { requireGroupAdmin } from "@/lib/server/group-access";
|
import { requireGroupAdmin } from "@/lib/server/group-access";
|
||||||
import { recordGroupAudit } from "@/lib/server/group-audit";
|
import { recordGroupAudit } from "@/lib/server/group-audit";
|
||||||
|
import { enforceUserWriteRateLimit } from "@/lib/server/rate-limit";
|
||||||
|
|
||||||
export type JoinPolicy = "NOT_ACCEPTING" | "AUTO_ACCEPT" | "APPROVAL_REQUIRED";
|
export type JoinPolicy = "NOT_ACCEPTING" | "AUTO_ACCEPT" | "APPROVAL_REQUIRED";
|
||||||
|
|
||||||
@ -41,12 +42,12 @@ export async function getInviteViewerStatus(input: { userId: number; groupId: nu
|
|||||||
"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",
|
||||||
[input.groupId, input.userId]
|
[input.groupId, input.userId]
|
||||||
);
|
);
|
||||||
if (existing.rowCount) return "ALREADY_MEMBER" as const;
|
if (existing.rows.length) return "ALREADY_MEMBER" as const;
|
||||||
const pending = await pool.query(
|
const pending = await pool.query(
|
||||||
"select 1 from group_join_requests where group_id=$1 and user_id=$2 and status='PENDING'",
|
"select 1 from group_join_requests where group_id=$1 and user_id=$2 and status='PENDING'",
|
||||||
[input.groupId, input.userId]
|
[input.groupId, input.userId]
|
||||||
);
|
);
|
||||||
if (pending.rowCount) return "PENDING" as const;
|
if (pending.rows.length) return "PENDING" as const;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,6 +81,7 @@ export async function createInviteLink(input: {
|
|||||||
ip?: string | null;
|
ip?: string | null;
|
||||||
userAgent?: string | null;
|
userAgent?: string | null;
|
||||||
}) {
|
}) {
|
||||||
|
await enforceUserWriteRateLimit({ userId: input.userId, scope: "groups:invites:create" });
|
||||||
const role = await requireGroupAdmin(input.userId, input.groupId);
|
const role = await requireGroupAdmin(input.userId, input.groupId);
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
const token = createToken();
|
const token = createToken();
|
||||||
@ -111,6 +113,7 @@ export async function createInviteLink(input: {
|
|||||||
token: String(row.token),
|
token: String(row.token),
|
||||||
policy: row.policy as JoinPolicy,
|
policy: row.policy as JoinPolicy,
|
||||||
singleUse: Boolean(row.single_use),
|
singleUse: Boolean(row.single_use),
|
||||||
|
groupJoinPolicy: row.policy as JoinPolicy,
|
||||||
expiresAt: row.expires_at,
|
expiresAt: row.expires_at,
|
||||||
usedAt: row.used_at,
|
usedAt: row.used_at,
|
||||||
revokedAt: row.revoked_at,
|
revokedAt: row.revoked_at,
|
||||||
@ -134,6 +137,7 @@ export async function listInviteLinks(input: { userId: number; groupId: number }
|
|||||||
token: String(row.token),
|
token: String(row.token),
|
||||||
policy: row.policy as JoinPolicy,
|
policy: row.policy as JoinPolicy,
|
||||||
singleUse: Boolean(row.single_use),
|
singleUse: Boolean(row.single_use),
|
||||||
|
groupJoinPolicy: row.policy as JoinPolicy,
|
||||||
expiresAt: row.expires_at,
|
expiresAt: row.expires_at,
|
||||||
usedAt: row.used_at,
|
usedAt: row.used_at,
|
||||||
revokedAt: row.revoked_at,
|
revokedAt: row.revoked_at,
|
||||||
@ -142,6 +146,7 @@ export async function listInviteLinks(input: { userId: number; groupId: number }
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function revokeInviteLink(input: { userId: number; groupId: number; linkId: number; requestId: string; ip?: string | null; userAgent?: string | null }) {
|
export async function revokeInviteLink(input: { userId: number; groupId: number; linkId: number; requestId: string; ip?: string | null; userAgent?: string | null }) {
|
||||||
|
await enforceUserWriteRateLimit({ userId: input.userId, scope: "groups:invites:revoke" });
|
||||||
const role = await requireGroupAdmin(input.userId, input.groupId);
|
const role = await requireGroupAdmin(input.userId, input.groupId);
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
@ -165,6 +170,7 @@ export async function revokeInviteLink(input: { userId: number; groupId: number;
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function reviveInviteLink(input: { userId: number; groupId: number; linkId: number; expiresAt: Date; requestId: string; ip?: string | null; userAgent?: string | null }) {
|
export async function reviveInviteLink(input: { userId: number; groupId: number; linkId: number; expiresAt: Date; requestId: string; ip?: string | null; userAgent?: string | null }) {
|
||||||
|
await enforceUserWriteRateLimit({ userId: input.userId, scope: "groups:invites:revive" });
|
||||||
const role = await requireGroupAdmin(input.userId, input.groupId);
|
const role = await requireGroupAdmin(input.userId, input.groupId);
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
@ -208,6 +214,7 @@ export async function getInviteLinkByToken(token: string) {
|
|||||||
token: String(row.token),
|
token: String(row.token),
|
||||||
policy: row.policy as JoinPolicy,
|
policy: row.policy as JoinPolicy,
|
||||||
singleUse: Boolean(row.single_use),
|
singleUse: Boolean(row.single_use),
|
||||||
|
groupJoinPolicy: row.policy as JoinPolicy,
|
||||||
expiresAt: row.expires_at,
|
expiresAt: row.expires_at,
|
||||||
usedAt: row.used_at,
|
usedAt: row.used_at,
|
||||||
revokedAt: row.revoked_at,
|
revokedAt: row.revoked_at,
|
||||||
@ -243,6 +250,7 @@ export async function getInviteLinkSummaryByToken(token: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function acceptInviteLink(input: { userId: number; token: string; requestId: string; ip?: string | null; userAgent?: string | null }) {
|
export async function acceptInviteLink(input: { userId: number; token: string; requestId: string; ip?: string | null; userAgent?: string | null }) {
|
||||||
|
await enforceUserWriteRateLimit({ userId: input.userId, scope: "groups:invites:accept" });
|
||||||
const summary = await getInviteLinkSummaryByToken(input.token);
|
const summary = await getInviteLinkSummaryByToken(input.token);
|
||||||
if (!summary) apiError("INVITE_NOT_FOUND", { tokenLast4: last4(input.token) });
|
if (!summary) apiError("INVITE_NOT_FOUND", { tokenLast4: last4(input.token) });
|
||||||
|
|
||||||
@ -251,7 +259,7 @@ export async function acceptInviteLink(input: { userId: number; token: string; r
|
|||||||
"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",
|
||||||
[summary.groupId, input.userId]
|
[summary.groupId, input.userId]
|
||||||
);
|
);
|
||||||
if (existing.rowCount) {
|
if (existing.rows.length) {
|
||||||
return { status: "ALREADY_MEMBER" as const, group: { id: summary.groupId, name: summary.groupName } };
|
return { status: "ALREADY_MEMBER" as const, group: { id: summary.groupId, name: summary.groupName } };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -259,7 +267,7 @@ export async function acceptInviteLink(input: { userId: number; token: string; r
|
|||||||
"select 1 from group_join_requests where group_id=$1 and user_id=$2 and status='PENDING'",
|
"select 1 from group_join_requests where group_id=$1 and user_id=$2 and status='PENDING'",
|
||||||
[summary.groupId, input.userId]
|
[summary.groupId, input.userId]
|
||||||
);
|
);
|
||||||
if (pending.rowCount) {
|
if (pending.rows.length) {
|
||||||
return { status: "PENDING" as const, group: { id: summary.groupId, name: summary.groupName } };
|
return { status: "PENDING" as const, group: { id: summary.groupId, name: summary.groupName } };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -333,6 +341,7 @@ export async function markInviteLinkUsed(input: { linkId: number }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteInviteLink(input: { userId: number; groupId: number; linkId: number; requestId: string; ip?: string | null; userAgent?: string | null }) {
|
export async function deleteInviteLink(input: { userId: number; groupId: number; linkId: number; requestId: string; ip?: string | null; userAgent?: string | null }) {
|
||||||
|
await enforceUserWriteRateLimit({ userId: input.userId, scope: "groups:invites:delete" });
|
||||||
const role = await requireGroupAdmin(input.userId, input.groupId);
|
const role = await requireGroupAdmin(input.userId, input.groupId);
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import getPool from "@/lib/server/db";
|
|||||||
import { apiError } from "@/lib/server/errors";
|
import { apiError } from "@/lib/server/errors";
|
||||||
import { getGroupRole, isAdminRole, requireGroupAdmin, requireGroupOwner } from "@/lib/server/group-access";
|
import { getGroupRole, isAdminRole, requireGroupAdmin, requireGroupOwner } from "@/lib/server/group-access";
|
||||||
import { recordGroupAudit } from "@/lib/server/group-audit";
|
import { recordGroupAudit } from "@/lib/server/group-audit";
|
||||||
|
import { enforceUserWriteRateLimit } from "@/lib/server/rate-limit";
|
||||||
|
|
||||||
export type GroupRole = "MEMBER" | "GROUP_ADMIN" | "GROUP_OWNER";
|
export type GroupRole = "MEMBER" | "GROUP_ADMIN" | "GROUP_OWNER";
|
||||||
|
|
||||||
@ -104,6 +105,7 @@ export async function createJoinRequest(input: { userId: number; groupId: number
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function approveJoinRequest(input: { actorUserId: number; groupId: number; userId: number; requestId: string; ip?: string | null; userAgent?: string | null; requestRowId?: number }) {
|
export async function approveJoinRequest(input: { actorUserId: number; groupId: number; userId: number; requestId: string; ip?: string | null; userAgent?: string | null; requestRowId?: number }) {
|
||||||
|
await enforceUserWriteRateLimit({ userId: input.actorUserId, scope: "groups:members:approve" });
|
||||||
const role = await requireGroupAdmin(input.actorUserId, input.groupId);
|
const role = await requireGroupAdmin(input.actorUserId, input.groupId);
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
@ -161,15 +163,16 @@ export async function approveJoinRequest(input: { actorUserId: number; groupId:
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function denyJoinRequest(input: { actorUserId: number; groupId: number; userId: number; requestId: string; ip?: string | null; userAgent?: string | null }) {
|
export async function denyJoinRequest(input: { actorUserId: number; groupId: number; userId: number; requestId: string; ip?: string | null; userAgent?: string | null }) {
|
||||||
|
await enforceUserWriteRateLimit({ userId: input.actorUserId, scope: "groups:members:deny" });
|
||||||
const role = await requireGroupAdmin(input.actorUserId, input.groupId);
|
const role = await requireGroupAdmin(input.actorUserId, input.groupId);
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
const { rowCount } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`update group_join_requests
|
`update group_join_requests
|
||||||
set status='DENIED', decided_by=$3, decided_at=now(), updated_at=now()
|
set status='DENIED', decided_by=$3, decided_at=now(), updated_at=now()
|
||||||
where group_id=$1 and user_id=$2 and status='PENDING'`,
|
where group_id=$1 and user_id=$2 and status='PENDING'`,
|
||||||
[input.groupId, input.userId, input.actorUserId]
|
[input.groupId, input.userId, input.actorUserId]
|
||||||
);
|
);
|
||||||
if (!rowCount) apiError("JOIN_REQUEST_NOT_FOUND", { groupId: input.groupId, userId: input.userId });
|
if (!rows.length) apiError("JOIN_REQUEST_NOT_FOUND", { groupId: input.groupId, userId: input.userId });
|
||||||
|
|
||||||
await recordGroupAudit({
|
await recordGroupAudit({
|
||||||
groupId: input.groupId,
|
groupId: input.groupId,
|
||||||
@ -184,6 +187,7 @@ export async function denyJoinRequest(input: { actorUserId: number; groupId: num
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function kickMember(input: { actorUserId: number; groupId: number; targetUserId: number; requestId: string; ip?: string | null; userAgent?: string | null }) {
|
export async function kickMember(input: { actorUserId: number; groupId: number; targetUserId: number; requestId: string; ip?: string | null; userAgent?: string | null }) {
|
||||||
|
await enforceUserWriteRateLimit({ userId: input.actorUserId, scope: "groups:members:kick" });
|
||||||
const role = await requireGroupAdmin(input.actorUserId, input.groupId);
|
const role = await requireGroupAdmin(input.actorUserId, input.groupId);
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
@ -229,6 +233,7 @@ export async function kickMember(input: { actorUserId: number; groupId: number;
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function leaveGroup(input: { userId: number; groupId: number; requestId: string; ip?: string | null; userAgent?: string | null }) {
|
export async function leaveGroup(input: { userId: number; groupId: number; requestId: string; ip?: string | null; userAgent?: string | null }) {
|
||||||
|
await enforceUserWriteRateLimit({ userId: input.userId, scope: "groups:members:leave" });
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
let role: GroupRole | null = null;
|
let role: GroupRole | null = null;
|
||||||
@ -273,6 +278,7 @@ export async function leaveGroup(input: { userId: number; groupId: number; reque
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function promoteToAdmin(input: { actorUserId: number; groupId: number; targetUserId: number; requestId: string; ip?: string | null; userAgent?: string | null }) {
|
export async function promoteToAdmin(input: { actorUserId: number; groupId: number; targetUserId: number; requestId: string; ip?: string | null; userAgent?: string | null }) {
|
||||||
|
await enforceUserWriteRateLimit({ userId: input.actorUserId, scope: "groups:members:promote" });
|
||||||
const role = await requireGroupAdmin(input.actorUserId, input.groupId);
|
const role = await requireGroupAdmin(input.actorUserId, input.groupId);
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
@ -301,6 +307,7 @@ export async function promoteToAdmin(input: { actorUserId: number; groupId: numb
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function demoteAdmin(input: { actorUserId: number; groupId: number; targetUserId: number; requestId: string; ip?: string | null; userAgent?: string | null }) {
|
export async function demoteAdmin(input: { actorUserId: number; groupId: number; targetUserId: number; requestId: string; ip?: string | null; userAgent?: string | null }) {
|
||||||
|
await enforceUserWriteRateLimit({ userId: input.actorUserId, scope: "groups:members:demote" });
|
||||||
const role = await requireGroupAdmin(input.actorUserId, input.groupId);
|
const role = await requireGroupAdmin(input.actorUserId, input.groupId);
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
@ -330,6 +337,7 @@ export async function demoteAdmin(input: { actorUserId: number; groupId: number;
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function transferOwnership(input: { actorUserId: number; groupId: number; newOwnerUserId: number; requestId: string; ip?: string | null; userAgent?: string | null }) {
|
export async function transferOwnership(input: { actorUserId: number; groupId: number; newOwnerUserId: number; requestId: string; ip?: string | null; userAgent?: string | null }) {
|
||||||
|
await enforceUserWriteRateLimit({ userId: input.actorUserId, scope: "groups:members:transfer-owner" });
|
||||||
await requireGroupOwner(input.actorUserId, input.groupId);
|
await requireGroupOwner(input.actorUserId, input.groupId);
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
|
|||||||
@ -3,6 +3,7 @@ if (process.env.NODE_ENV !== "test")
|
|||||||
import getPool from "@/lib/server/db";
|
import getPool from "@/lib/server/db";
|
||||||
import { apiError } from "@/lib/server/errors";
|
import { apiError } from "@/lib/server/errors";
|
||||||
import { requireGroupAdmin } from "@/lib/server/group-access";
|
import { requireGroupAdmin } from "@/lib/server/group-access";
|
||||||
|
import { enforceUserWriteRateLimit } from "@/lib/server/rate-limit";
|
||||||
|
|
||||||
export async function getGroupSettings(groupId: number) {
|
export async function getGroupSettings(groupId: number) {
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
@ -17,6 +18,7 @@ export async function getGroupSettings(groupId: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function setGroupSettings(input: { userId: number; groupId: number; allowMemberTagManage: boolean; joinPolicy?: "NOT_ACCEPTING" | "AUTO_ACCEPT" | "APPROVAL_REQUIRED" }) {
|
export async function setGroupSettings(input: { userId: number; groupId: number; allowMemberTagManage: boolean; joinPolicy?: "NOT_ACCEPTING" | "AUTO_ACCEPT" | "APPROVAL_REQUIRED" }) {
|
||||||
|
await enforceUserWriteRateLimit({ userId: input.userId, scope: "groups:settings:update" });
|
||||||
await requireGroupAdmin(input.userId, input.groupId);
|
await requireGroupAdmin(input.userId, input.groupId);
|
||||||
|
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
|
|||||||
@ -6,11 +6,16 @@ import { apiError } from "@/lib/server/errors";
|
|||||||
import type { Group } from "@/lib/shared/types";
|
import type { Group } from "@/lib/shared/types";
|
||||||
import { requireGroupAdmin, requireGroupOwner } from "@/lib/server/group-access";
|
import { requireGroupAdmin, requireGroupOwner } from "@/lib/server/group-access";
|
||||||
import { recordGroupAudit } from "@/lib/server/group-audit";
|
import { recordGroupAudit } from "@/lib/server/group-audit";
|
||||||
|
import { enforceUserWriteRateLimit } from "@/lib/server/rate-limit";
|
||||||
|
|
||||||
function createInviteCode() {
|
function createInviteCode() {
|
||||||
return crypto.randomBytes(4).toString("hex").toUpperCase();
|
return crypto.randomBytes(4).toString("hex").toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function last4(value: string) {
|
||||||
|
return value.slice(-4);
|
||||||
|
}
|
||||||
|
|
||||||
type GroupRow = {
|
type GroupRow = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@ -56,16 +61,18 @@ export async function setActiveGroupId(userId: number, groupId: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function setActiveGroupForUser(userId: number, groupId: number) {
|
export async function setActiveGroupForUser(userId: number, groupId: number) {
|
||||||
|
await enforceUserWriteRateLimit({ userId, scope: "groups:active:set" });
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
const { rowCount } = await pool.query(
|
const { rows } = await pool.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, userId]
|
[groupId, userId]
|
||||||
);
|
);
|
||||||
if (!rowCount) apiError("FORBIDDEN", { userId, groupId });
|
if (!rows.length) apiError("FORBIDDEN", { userId, groupId });
|
||||||
await setActiveGroupId(userId, groupId);
|
await setActiveGroupId(userId, groupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createGroup(userId: number, name: string) {
|
export async function createGroup(userId: number, name: string) {
|
||||||
|
await enforceUserWriteRateLimit({ userId, scope: "groups:create" });
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
const inviteCode = createInviteCode();
|
const inviteCode = createInviteCode();
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
@ -85,6 +92,7 @@ export async function createGroup(userId: number, name: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function renameGroup(input: { userId: number; groupId: number; name: string; requestId: string; ip?: string | null; userAgent?: string | null }) {
|
export async function renameGroup(input: { userId: number; groupId: number; name: string; requestId: string; ip?: string | null; userAgent?: string | null }) {
|
||||||
|
await enforceUserWriteRateLimit({ userId: input.userId, scope: "groups:rename" });
|
||||||
const role = await requireGroupAdmin(input.userId, input.groupId);
|
const role = await requireGroupAdmin(input.userId, input.groupId);
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
@ -105,6 +113,7 @@ export async function renameGroup(input: { userId: number; groupId: number; name
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteGroup(input: { userId: number; groupId: number; requestId: string; ip?: string | null; userAgent?: string | null }) {
|
export async function deleteGroup(input: { userId: number; groupId: number; requestId: string; ip?: string | null; userAgent?: string | null }) {
|
||||||
|
await enforceUserWriteRateLimit({ userId: input.userId, scope: "groups:delete" });
|
||||||
await requireGroupOwner(input.userId, input.groupId);
|
await requireGroupOwner(input.userId, input.groupId);
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
await pool.query(
|
await pool.query(
|
||||||
@ -123,6 +132,7 @@ export async function deleteGroup(input: { userId: number; groupId: number; requ
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function joinGroup(userId: number, inviteCode: string) {
|
export async function joinGroup(userId: number, inviteCode: string) {
|
||||||
|
await enforceUserWriteRateLimit({ userId, scope: "groups:join" });
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
"select id, name from groups where invite_code=$1",
|
"select id, name from groups where invite_code=$1",
|
||||||
@ -130,13 +140,13 @@ export async function joinGroup(userId: number, inviteCode: string) {
|
|||||||
);
|
);
|
||||||
const group = rows[0];
|
const group = rows[0];
|
||||||
if (!group)
|
if (!group)
|
||||||
apiError("INVALID_INVITE", { inviteCode });
|
apiError("INVALID_INVITE", { inviteCodeLast4: last4(inviteCode) });
|
||||||
|
|
||||||
const existing = await pool.query(
|
const existing = await pool.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",
|
||||||
[group.id, userId]
|
[group.id, userId]
|
||||||
);
|
);
|
||||||
if (existing.rowCount)
|
if (existing.rows.length)
|
||||||
return { id: group.id, name: group.name } as { id: number; name: string };
|
return { id: group.id, name: group.name } as { id: number; name: string };
|
||||||
|
|
||||||
const settings = await pool.query(
|
const settings = await pool.query(
|
||||||
|
|||||||
105
apps/web/lib/server/rate-limit.ts
Normal file
105
apps/web/lib/server/rate-limit.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
if (process.env.NODE_ENV !== "test")
|
||||||
|
require("server-only");
|
||||||
|
import getPool from "@/lib/server/db";
|
||||||
|
import { apiError } from "@/lib/server/errors";
|
||||||
|
|
||||||
|
type LimitInput = {
|
||||||
|
key: string;
|
||||||
|
limit: number;
|
||||||
|
windowMs: number;
|
||||||
|
scope: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let ensureTablePromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
async function ensureRateLimitsTable() {
|
||||||
|
if (!ensureTablePromise) {
|
||||||
|
ensureTablePromise = (async () => {
|
||||||
|
const pool = getPool();
|
||||||
|
await pool.query(`
|
||||||
|
create table if not exists rate_limits(
|
||||||
|
key text primary key,
|
||||||
|
window_start timestamptz not null,
|
||||||
|
count integer not null default 0,
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await pool.query("create index if not exists rate_limits_updated_at_idx on rate_limits(updated_at)");
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
await ensureTablePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWindowStart(nowMs: number, windowMs: number) {
|
||||||
|
const bucketStart = Math.floor(nowMs / windowMs) * windowMs;
|
||||||
|
return new Date(bucketStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function consumeRateLimit(input: LimitInput) {
|
||||||
|
await ensureRateLimitsTable();
|
||||||
|
const now = Date.now();
|
||||||
|
const windowStart = normalizeWindowStart(now, input.windowMs);
|
||||||
|
const pool = getPool();
|
||||||
|
const { rows } = await pool.query<{ count: number }>(
|
||||||
|
`insert into rate_limits(key, window_start, count, updated_at)
|
||||||
|
values($1, $2, 1, now())
|
||||||
|
on conflict (key) do update
|
||||||
|
set count = case
|
||||||
|
when rate_limits.window_start = excluded.window_start then rate_limits.count + 1
|
||||||
|
else 1
|
||||||
|
end,
|
||||||
|
window_start = case
|
||||||
|
when rate_limits.window_start = excluded.window_start then rate_limits.window_start
|
||||||
|
else excluded.window_start
|
||||||
|
end,
|
||||||
|
updated_at = now()
|
||||||
|
returning count`,
|
||||||
|
[input.key, windowStart]
|
||||||
|
);
|
||||||
|
const count = Number(rows[0]?.count || 0);
|
||||||
|
if (count > input.limit) {
|
||||||
|
apiError("RATE_LIMITED", {
|
||||||
|
scope: input.scope,
|
||||||
|
limit: input.limit,
|
||||||
|
windowMs: input.windowMs
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enforceAuthRateLimit(input: {
|
||||||
|
route: "login" | "register";
|
||||||
|
ip?: string | null;
|
||||||
|
identifier?: string | null;
|
||||||
|
ipLimit?: number;
|
||||||
|
identifierLimit?: number;
|
||||||
|
windowMs?: number;
|
||||||
|
}) {
|
||||||
|
const scope = `auth:${input.route}`;
|
||||||
|
const ip = String(input.ip || "unknown").trim().toLowerCase();
|
||||||
|
const windowMs = input.windowMs ?? (15 * 60 * 1000);
|
||||||
|
await consumeRateLimit({
|
||||||
|
key: `${scope}:ip:${ip}`,
|
||||||
|
scope,
|
||||||
|
limit: input.ipLimit ?? 20,
|
||||||
|
windowMs
|
||||||
|
});
|
||||||
|
|
||||||
|
const identifier = String(input.identifier || "").trim().toLowerCase();
|
||||||
|
if (identifier) {
|
||||||
|
await consumeRateLimit({
|
||||||
|
key: `${scope}:identifier:${identifier}`,
|
||||||
|
scope,
|
||||||
|
limit: input.identifierLimit ?? 10,
|
||||||
|
windowMs
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enforceUserWriteRateLimit(input: { userId: number; scope: string; limit?: number; windowMs?: number }) {
|
||||||
|
await consumeRateLimit({
|
||||||
|
key: `write:user:${input.userId}:scope:${input.scope}`,
|
||||||
|
scope: input.scope,
|
||||||
|
limit: input.limit ?? 120,
|
||||||
|
windowMs: input.windowMs ?? (15 * 60 * 1000)
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -18,6 +18,6 @@ export async function updateRecurringEntry(input: Parameters<typeof updateEntry>
|
|||||||
return updateEntry({ ...input, isRecurring: true });
|
return updateEntry({ ...input, isRecurring: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteRecurringEntry(input: { id: number; groupId: number }) {
|
export async function deleteRecurringEntry(input: { id: number; groupId: number; userId: number }) {
|
||||||
return deleteEntry(input);
|
return deleteEntry(input);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ if (process.env.NODE_ENV !== "test")
|
|||||||
import getPool from "@/lib/server/db";
|
import getPool from "@/lib/server/db";
|
||||||
import { apiError } from "@/lib/server/errors";
|
import { apiError } from "@/lib/server/errors";
|
||||||
import { getGroupRole, isAdminRole } from "@/lib/server/group-access";
|
import { getGroupRole, isAdminRole } from "@/lib/server/group-access";
|
||||||
|
import { enforceUserWriteRateLimit } from "@/lib/server/rate-limit";
|
||||||
|
|
||||||
type TagRow = { id: number; name: string };
|
type TagRow = { id: number; name: string };
|
||||||
type TagListRow = { name: string };
|
type TagListRow = { name: string };
|
||||||
@ -44,6 +45,7 @@ export async function canManageTags(userId: number, groupId: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteTagForGroup(input: { userId: number; groupId: number; name: string }) {
|
export async function deleteTagForGroup(input: { userId: number; groupId: number; name: string }) {
|
||||||
|
await enforceUserWriteRateLimit({ userId: input.userId, scope: "tags:delete" });
|
||||||
const allowed = await canManageTags(input.userId, input.groupId);
|
const allowed = await canManageTags(input.userId, input.groupId);
|
||||||
if (!allowed) apiError("FORBIDDEN", { userId: input.userId, groupId: input.groupId });
|
if (!allowed) apiError("FORBIDDEN", { userId: input.userId, groupId: input.groupId });
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
@ -67,6 +69,7 @@ export async function ensureTagsForGroup(input: { userId: number; groupId: numbe
|
|||||||
|
|
||||||
const missing = tags.filter(tag => !existing.has(tag));
|
const missing = tags.filter(tag => !existing.has(tag));
|
||||||
if (missing.length) {
|
if (missing.length) {
|
||||||
|
await enforceUserWriteRateLimit({ userId: input.userId, scope: "tags:create" });
|
||||||
const allowed = await canManageTags(input.userId, input.groupId);
|
const allowed = await canManageTags(input.userId, input.groupId);
|
||||||
if (!allowed) apiError("FORBIDDEN", { userId: input.userId, groupId: input.groupId, missing });
|
if (!allowed) apiError("FORBIDDEN", { userId: input.userId, groupId: input.groupId, missing });
|
||||||
|
|
||||||
|
|||||||
@ -1 +1,21 @@
|
|||||||
export default {};
|
const securityHeaders = [
|
||||||
|
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||||
|
{ key: "X-Frame-Options", value: "DENY" },
|
||||||
|
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||||
|
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
|
||||||
|
{ key: "Content-Security-Policy", value: "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/:path*",
|
||||||
|
headers: securityHeaders
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
|
|||||||
59
docker/nginx/fiddy.conf
Normal file
59
docker/nginx/fiddy.conf
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
upstream fiddy_web {
|
||||||
|
server 127.0.0.1:3000;
|
||||||
|
keepalive 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name fiddy.example.com;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
listen [::]:443 ssl http2;
|
||||||
|
server_name fiddy.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/fiddy.example.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/fiddy.example.com/privkey.pem;
|
||||||
|
ssl_session_timeout 1d;
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_prefer_server_ciphers off;
|
||||||
|
|
||||||
|
client_max_body_size 10m;
|
||||||
|
client_body_timeout 15s;
|
||||||
|
client_header_timeout 15s;
|
||||||
|
keepalive_timeout 30s;
|
||||||
|
send_timeout 30s;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
location /api/auth/login {
|
||||||
|
limit_req zone=fiddy_auth burst=15 nodelay;
|
||||||
|
include /etc/nginx/includes/fiddy-proxy.conf;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/auth/register {
|
||||||
|
limit_req zone=fiddy_auth burst=15 nodelay;
|
||||||
|
include /etc/nginx/includes/fiddy-proxy.conf;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/api/(entries|buckets|groups|tags|recurring-entries) {
|
||||||
|
if ($request_method ~* "(POST|PATCH|PUT|DELETE)") {
|
||||||
|
limit_req zone=fiddy_write burst=40 nodelay;
|
||||||
|
}
|
||||||
|
include /etc/nginx/includes/fiddy-proxy.conf;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
include /etc/nginx/includes/fiddy-proxy.conf;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
docker/nginx/includes/fiddy-proxy.conf
Normal file
10
docker/nginx/includes/fiddy-proxy.conf
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
proxy_send_timeout 60s;
|
||||||
|
proxy_connect_timeout 5s;
|
||||||
|
proxy_pass http://fiddy_web;
|
||||||
53
docker/observability/docker-compose.observability.yml
Normal file
53
docker/observability/docker-compose.observability.yml
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
services:
|
||||||
|
loki:
|
||||||
|
image: grafana/loki:3.2.0
|
||||||
|
command: -config.file=/etc/loki/local-config.yml
|
||||||
|
volumes:
|
||||||
|
- ./loki-config.yml:/etc/loki/local-config.yml:ro
|
||||||
|
- loki_data:/loki
|
||||||
|
ports:
|
||||||
|
- "3100:3100"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
promtail:
|
||||||
|
image: grafana/promtail:3.2.0
|
||||||
|
command: -config.file=/etc/promtail/config.yml
|
||||||
|
volumes:
|
||||||
|
- ./promtail-config.yml:/etc/promtail/config.yml:ro
|
||||||
|
- /var/log:/var/log:ro
|
||||||
|
- /var/lib/docker/containers:/var/lib/docker/containers:ro
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- loki
|
||||||
|
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana:11.3.0
|
||||||
|
environment:
|
||||||
|
- GF_SECURITY_ADMIN_USER=admin
|
||||||
|
- GF_SECURITY_ADMIN_PASSWORD=change-me
|
||||||
|
ports:
|
||||||
|
- "3001:3000"
|
||||||
|
volumes:
|
||||||
|
- grafana_data:/var/lib/grafana
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- loki
|
||||||
|
|
||||||
|
uptime-kuma:
|
||||||
|
image: louislam/uptime-kuma:1.23.16
|
||||||
|
ports:
|
||||||
|
- "3002:3001"
|
||||||
|
volumes:
|
||||||
|
- uptime_kuma_data:/app/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
node-exporter:
|
||||||
|
image: prom/node-exporter:v1.8.2
|
||||||
|
ports:
|
||||||
|
- "9100:9100"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
loki_data:
|
||||||
|
grafana_data:
|
||||||
|
uptime_kuma_data:
|
||||||
31
docker/observability/loki-config.yml
Normal file
31
docker/observability/loki-config.yml
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
auth_enabled: false
|
||||||
|
|
||||||
|
server:
|
||||||
|
http_listen_port: 3100
|
||||||
|
|
||||||
|
common:
|
||||||
|
path_prefix: /loki
|
||||||
|
replication_factor: 1
|
||||||
|
ring:
|
||||||
|
kvstore:
|
||||||
|
store: inmemory
|
||||||
|
storage:
|
||||||
|
filesystem:
|
||||||
|
chunks_directory: /loki/chunks
|
||||||
|
rules_directory: /loki/rules
|
||||||
|
|
||||||
|
schema_config:
|
||||||
|
configs:
|
||||||
|
- from: 2024-01-01
|
||||||
|
store: tsdb
|
||||||
|
object_store: filesystem
|
||||||
|
schema: v13
|
||||||
|
index:
|
||||||
|
prefix: index_
|
||||||
|
period: 24h
|
||||||
|
|
||||||
|
limits_config:
|
||||||
|
retention_period: 168h
|
||||||
|
|
||||||
|
ruler:
|
||||||
|
alertmanager_url: http://localhost:9093
|
||||||
26
docker/observability/promtail-config.yml
Normal file
26
docker/observability/promtail-config.yml
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
server:
|
||||||
|
http_listen_port: 9080
|
||||||
|
grpc_listen_port: 0
|
||||||
|
|
||||||
|
positions:
|
||||||
|
filename: /tmp/positions.yml
|
||||||
|
|
||||||
|
clients:
|
||||||
|
- url: http://loki:3100/loki/api/v1/push
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: system
|
||||||
|
static_configs:
|
||||||
|
- targets:
|
||||||
|
- localhost
|
||||||
|
labels:
|
||||||
|
job: varlogs
|
||||||
|
__path__: /var/log/*log
|
||||||
|
|
||||||
|
- job_name: docker
|
||||||
|
static_configs:
|
||||||
|
- targets:
|
||||||
|
- localhost
|
||||||
|
labels:
|
||||||
|
job: docker
|
||||||
|
__path__: /var/lib/docker/containers/*/*-json.log
|
||||||
68
docs/05_REFACTOR_2.md
Normal file
68
docs/05_REFACTOR_2.md
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
# Refactor 2: Public Launch Hardening
|
||||||
|
|
||||||
|
## Purpose Overview
|
||||||
|
This refactor prepares Fiddy for public exposure without changing the core stack (Next.js + external Postgres). The goal is to harden API contracts, improve abuse resistance, tighten security posture, and make deployment/operations repeatable for self-hosted production.
|
||||||
|
|
||||||
|
Primary outcomes:
|
||||||
|
- Keep architecture stable and avoid risky stack rewrite.
|
||||||
|
- Standardize API response metadata (`request_id`) for traceability.
|
||||||
|
- Add layered rate limiting (app + proxy plan) for auth and write paths.
|
||||||
|
- Remove sensitive logging risk (no full invite codes, no secrets).
|
||||||
|
- Add health probes and deployment/ops reference artifacts.
|
||||||
|
- Document rollout, rollback, backup, and monitoring runbooks.
|
||||||
|
|
||||||
|
## Scope Checklist
|
||||||
|
- [x] Phase 1: App security + API contract hardening
|
||||||
|
- [x] Phase 2: Dokploy deployment workflow artifacts
|
||||||
|
- [x] Phase 3: Nginx + host hardening artifacts
|
||||||
|
- [x] Phase 4: Observability stack references/configs
|
||||||
|
- [x] Phase 5: Backup + restore process and scripts/docs
|
||||||
|
- [x] Verification pass (tests/build/lint where possible)
|
||||||
|
|
||||||
|
## Running Implementation Log
|
||||||
|
|
||||||
|
### 2026-02-14
|
||||||
|
- Started `Refactor 2` execution from current in-progress workspace baseline.
|
||||||
|
- Confirmed this repo already contains broad uncommitted changes unrelated to this phase; implementation will be done via targeted edits only.
|
||||||
|
- Established this document as the source log/checklist for all noteworthy decisions, tradeoffs, blockers, and completed steps.
|
||||||
|
- Added migration `packages/db/migrations/007_rate_limits.sql` for server-side rate limit state.
|
||||||
|
- Added server limiter `apps/web/lib/server/rate-limit.ts` and wired write-path guardrails in server services (`groups`, `group-members`, `group-invites`, `entries`, `buckets`, `tags`, `group-settings`).
|
||||||
|
- Hardened API error pipeline in `apps/web/lib/server/errors.ts`:
|
||||||
|
- Added `RATE_LIMITED` mapping (`429`).
|
||||||
|
- Added `request_id` alias in structured error body.
|
||||||
|
- Extended sensitive-key redaction for invite code keys.
|
||||||
|
- Removed stray debug `console.log()` call.
|
||||||
|
- Fixed invite-code leak risk in `apps/web/lib/server/groups.ts` by sending only `inviteCodeLast4` in error context.
|
||||||
|
- Standardized API response envelope updates in auth and route handlers so responses include both `requestId` and `request_id` while preserving backward compatibility.
|
||||||
|
- Added health probe routes:
|
||||||
|
- `apps/web/app/api/health/live/route.ts`
|
||||||
|
- `apps/web/app/api/health/ready/route.ts`
|
||||||
|
- Added security header baseline in `apps/web/next.config.mjs` (CSP, frame/referrer/content-type hardening).
|
||||||
|
- Added CI/CD workflow for Dokploy-triggered deploys:
|
||||||
|
- `.gitea/workflows/deploy-dokploy.yml`
|
||||||
|
- Added self-host edge + observability + backup artifacts:
|
||||||
|
- `docker/nginx/fiddy.conf`
|
||||||
|
- `docker/nginx/includes/fiddy-proxy.conf`
|
||||||
|
- `docker/observability/docker-compose.observability.yml`
|
||||||
|
- `docker/observability/loki-config.yml`
|
||||||
|
- `docker/observability/promtail-config.yml`
|
||||||
|
- `scripts/backup-postgres.sh`
|
||||||
|
- `scripts/restore-postgres.sh`
|
||||||
|
- `docs/public-launch-runbook.md`
|
||||||
|
- Added regression tests for request-id contract and limiter behavior:
|
||||||
|
- `apps/web/__tests__/errors-response.test.ts`
|
||||||
|
- `apps/web/__tests__/rate-limit.test.ts`
|
||||||
|
- Verification results:
|
||||||
|
- `npm test`: pass (`25 passed`, `1 skipped`).
|
||||||
|
- `npm run build`: pass.
|
||||||
|
- `npm run lint`: still fails due existing workspace lint script invocation issue (`next lint` resolves `apps/web/lint` path).
|
||||||
|
- Post-verification fixups:
|
||||||
|
- Added table auto-bootstrap fallback in `apps/web/lib/server/rate-limit.ts` to avoid failures in environments where migration `007_rate_limits.sql` has not been applied yet.
|
||||||
|
- Corrected `Entry` mapping consistency in `apps/web/lib/server/entries.ts` (`bucketId` included in all return shapes).
|
||||||
|
- Replaced `rowCount` checks with `rows.length` in typed query paths to satisfy current TypeScript/`pg` typings.
|
||||||
|
- Implementation correction note:
|
||||||
|
- A batch replacement briefly introduced invalid destructuring (`request_id` in `getRequestMeta` destructure). This was corrected in all affected routes before final verification.
|
||||||
|
|
||||||
|
### Risks / Notes to Revisit
|
||||||
|
- Workspace is intentionally dirty; commits must be path-scoped to avoid mixing unrelated changes.
|
||||||
|
- `npm run lint` currently fails due `next lint` invocation behavior in this environment; lint verification needs explicit follow-up task.
|
||||||
65
docs/public-launch-runbook.md
Normal file
65
docs/public-launch-runbook.md
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
# Public Launch Runbook (Self-Hosted + Dokploy)
|
||||||
|
|
||||||
|
## 1) Goals
|
||||||
|
- Deploy Fiddy publicly without stack rewrite.
|
||||||
|
- Keep Postgres self-hosted.
|
||||||
|
- Enable fast rollback and basic operational visibility.
|
||||||
|
- Keep security baseline enforceable for direct home-IP exposure.
|
||||||
|
|
||||||
|
## 2) Deploy Control Plane (Dokploy)
|
||||||
|
1. Install Dokploy on your Proxmox Docker host.
|
||||||
|
2. Add project in Dokploy and connect Gitea repository.
|
||||||
|
3. Configure image source: `git.nicosaya.com/nalalangan/fiddy/web`.
|
||||||
|
4. Deploy by immutable tag (`github.sha`) and keep `main` as convenience tag.
|
||||||
|
5. Configure health check endpoint: `/api/health/ready`.
|
||||||
|
6. Keep previous releases for rollback and verify rollback button path.
|
||||||
|
|
||||||
|
### Required secrets/variables
|
||||||
|
- `DATABASE_URL`
|
||||||
|
- `DATABASE_SSL`
|
||||||
|
- `ALLOWED_DB_NAMES`
|
||||||
|
- `SESSION_COOKIE_NAME`
|
||||||
|
- `SESSION_TTL_DAYS`
|
||||||
|
- `DEBUG_API=0`
|
||||||
|
|
||||||
|
## 3) CI/CD (Gitea Actions)
|
||||||
|
- Use `.gitea/workflows/deploy-dokploy.yml`.
|
||||||
|
- Required secrets:
|
||||||
|
- `REGISTRY_USER`
|
||||||
|
- `REGISTRY_PASS`
|
||||||
|
- `DOKPLOY_DEPLOY_HOOK`
|
||||||
|
|
||||||
|
## 4) Reverse Proxy + Network Hardening
|
||||||
|
- Use `docker/nginx/fiddy.conf` as baseline.
|
||||||
|
- Install certificate with Let’s Encrypt.
|
||||||
|
- Route 443 -> app container only.
|
||||||
|
- Keep Postgres private; never expose 5432 publicly.
|
||||||
|
- Restrict SSH to allowlist/VPN.
|
||||||
|
- Add host firewall rules:
|
||||||
|
- Allow inbound `80/443`.
|
||||||
|
- Deny all other inbound by default.
|
||||||
|
|
||||||
|
## 5) Observability
|
||||||
|
- Bring up monitoring stack:
|
||||||
|
- `docker compose -f docker/observability/docker-compose.observability.yml up -d`
|
||||||
|
- Configure Grafana datasource to Loki (`http://loki:3100`).
|
||||||
|
- Add Uptime Kuma monitors:
|
||||||
|
- `/api/health/live`
|
||||||
|
- `/api/health/ready`
|
||||||
|
- home page (`/`)
|
||||||
|
|
||||||
|
## 6) Backup + Restore
|
||||||
|
- Daily backup command:
|
||||||
|
- `scripts/backup-postgres.sh`
|
||||||
|
- Retention:
|
||||||
|
- default 7 days (`RETENTION_DAYS=7`)
|
||||||
|
- Restore drill:
|
||||||
|
- `scripts/restore-postgres.sh backups/postgres/<file>.dump <target_database_url>`
|
||||||
|
- Run restore drill on non-prod DB before public launch.
|
||||||
|
|
||||||
|
## 7) Incident Response Quick Flow
|
||||||
|
1. Identify failing request and `request_id`.
|
||||||
|
2. Correlate application logs (Loki) by `request_id`.
|
||||||
|
3. Check `/api/health/ready` status and DB connectivity.
|
||||||
|
4. Roll back to previous known-good Dokploy release if needed.
|
||||||
|
5. Capture root cause and update this runbook/checklist.
|
||||||
8
packages/db/migrations/007_rate_limits.sql
Normal file
8
packages/db/migrations/007_rate_limits.sql
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
create table if not exists rate_limits(
|
||||||
|
key text primary key,
|
||||||
|
window_start timestamptz not null,
|
||||||
|
count integer not null default 0,
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists rate_limits_updated_at_idx on rate_limits(updated_at);
|
||||||
24
scripts/backup-postgres.sh
Normal file
24
scripts/backup-postgres.sh
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ -z "${DATABASE_URL:-}" ]]; then
|
||||||
|
echo "DATABASE_URL is required"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
BACKUP_DIR="${BACKUP_DIR:-./backups/postgres}"
|
||||||
|
RETENTION_DAYS="${RETENTION_DAYS:-7}"
|
||||||
|
TIMESTAMP="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||||
|
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
|
echo "Creating logical backup..."
|
||||||
|
pg_dump "$DATABASE_URL" --format=custom --file="${BACKUP_DIR}/fiddy_${TIMESTAMP}.dump"
|
||||||
|
|
||||||
|
echo "Creating globals backup..."
|
||||||
|
pg_dumpall "$DATABASE_URL" --globals-only > "${BACKUP_DIR}/fiddy_globals_${TIMESTAMP}.sql"
|
||||||
|
|
||||||
|
echo "Pruning backups older than ${RETENTION_DAYS} days..."
|
||||||
|
find "$BACKUP_DIR" -type f -name "fiddy_*" -mtime "+${RETENTION_DAYS}" -delete
|
||||||
|
|
||||||
|
echo "Backup complete: ${BACKUP_DIR}"
|
||||||
19
scripts/restore-postgres.sh
Normal file
19
scripts/restore-postgres.sh
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ $# -lt 2 ]]; then
|
||||||
|
echo "Usage: $0 <backup.dump> <target_database_url>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
BACKUP_FILE="$1"
|
||||||
|
TARGET_DATABASE_URL="$2"
|
||||||
|
|
||||||
|
if [[ ! -f "$BACKUP_FILE" ]]; then
|
||||||
|
echo "Backup file not found: $BACKUP_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Restoring backup ${BACKUP_FILE} into target database..."
|
||||||
|
pg_restore --clean --if-exists --no-owner --no-privileges --dbname="$TARGET_DATABASE_URL" "$BACKUP_FILE"
|
||||||
|
echo "Restore complete."
|
||||||
Loading…
Reference in New Issue
Block a user