feat(sase): founder notes — Phase A of user mutations

Panel-side only — first writable feature on the Sase user detail page.
Notes are stored in the panel database (sase_user_notes); the spoke is
never touched (KVKK minimize).

Model
- SaseUserNote { id, saseUserId, authorUserId, body, pinned, createdAt }
- Index on (saseUserId, pinned, createdAt) for the default render order

API
- GET  /api/sase/users/[id]/notes — list (pinned first, then newest)
- POST /api/sase/users/[id]/notes — { body, pinned } create (audit'li)
- PATCH/DELETE /api/sase/notes/[noteId] — toggle pin / hard delete

UI
- New "Notlar" tab on user detail. Textarea + pinned checkbox + submit;
  list shows TR-localized timestamps, per-row pin/unpin and delete.

Schema sync
- package.json `start` now runs `prisma db push --skip-generate` before
  `next start`. Panel uses db-push style (no migrations dir); this lets
  the new table land on next deploy without a separate manual step.
  Future destructive changes will require a smarter migration approach.

teknikborc.md updated: super_panel_reader role still needs to be created
on the new sase-postgres (current panel uses sase superuser).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Semih
2026-05-18 09:32:48 +03:00
parent d8c2bdd22f
commit 5b26d10485
7 changed files with 315 additions and 1 deletions

View File

@@ -0,0 +1,60 @@
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { writeAudit } from "@/lib/audit";
export const dynamic = "force-dynamic";
export async function PATCH(
req: Request,
ctx: { params: Promise<{ noteId: string }> },
) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return NextResponse.json({ ok: false, error: "unauthenticated" }, { status: 401 });
const { noteId } = await ctx.params;
const body = (await req.json().catch(() => ({}))) as { pinned?: boolean };
const existing = await prisma.saseUserNote.findUnique({ where: { id: noteId } });
if (!existing) return NextResponse.json({ ok: false, error: "not_found" }, { status: 404 });
const note = await prisma.saseUserNote.update({
where: { id: noteId },
data: { pinned: !!body.pinned },
});
await writeAudit({
projectKey: "sase",
endpoint: `/api/sase/users/${existing.saseUserId}/notes/${noteId}`,
method: "PATCH",
requestPayload: { pinned: note.pinned },
responseStatus: 200,
});
return NextResponse.json({ ok: true, note });
}
export async function DELETE(
_req: Request,
ctx: { params: Promise<{ noteId: string }> },
) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return NextResponse.json({ ok: false, error: "unauthenticated" }, { status: 401 });
const { noteId } = await ctx.params;
const existing = await prisma.saseUserNote.findUnique({ where: { id: noteId } });
if (!existing) return NextResponse.json({ ok: false, error: "not_found" }, { status: 404 });
await prisma.saseUserNote.delete({ where: { id: noteId } });
await writeAudit({
projectKey: "sase",
endpoint: `/api/sase/users/${existing.saseUserId}/notes/${noteId}`,
method: "DELETE",
requestPayload: { noteId },
responseStatus: 200,
});
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,62 @@
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { writeAudit } from "@/lib/audit";
export const dynamic = "force-dynamic";
const MAX_BODY = 4000;
export async function GET(
_req: Request,
ctx: { params: Promise<{ id: string }> },
) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return NextResponse.json({ ok: false, error: "unauthenticated" }, { status: 401 });
const { id } = await ctx.params;
const notes = await prisma.saseUserNote.findMany({
where: { saseUserId: id },
orderBy: [{ pinned: "desc" }, { createdAt: "desc" }],
take: 200,
});
return NextResponse.json({ ok: true, notes });
}
export async function POST(
req: Request,
ctx: { params: Promise<{ id: string }> },
) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return NextResponse.json({ ok: false, error: "unauthenticated" }, { status: 401 });
const { id } = await ctx.params;
const body = (await req.json().catch(() => ({}))) as { body?: string; pinned?: boolean };
const text = (body.body ?? "").trim();
if (text.length < 2) {
return NextResponse.json({ ok: false, error: "body_too_short" }, { status: 400 });
}
if (text.length > MAX_BODY) {
return NextResponse.json({ ok: false, error: "body_too_long" }, { status: 400 });
}
const note = await prisma.saseUserNote.create({
data: {
saseUserId: id,
authorUserId: session.user.id,
body: text,
pinned: !!body.pinned,
},
});
await writeAudit({
projectKey: "sase",
endpoint: `/api/sase/users/${id}/notes`,
method: "POST",
requestPayload: { noteId: note.id, pinned: note.pinned, bodyLen: text.length },
responseStatus: 201,
});
return NextResponse.json({ ok: true, note }, { status: 201 });
}