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:
60
apps/web/src/app/api/sase/notes/[noteId]/route.ts
Normal file
60
apps/web/src/app/api/sase/notes/[noteId]/route.ts
Normal 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 });
|
||||
}
|
||||
62
apps/web/src/app/api/sase/users/[id]/notes/route.ts
Normal file
62
apps/web/src/app/api/sase/users/[id]/notes/route.ts
Normal 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 });
|
||||
}
|
||||
Reference in New Issue
Block a user