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 });
|
||||
}
|
||||
152
apps/web/src/app/projects/sase/users/[id]/_notes.tsx
Normal file
152
apps/web/src/app/projects/sase/users/[id]/_notes.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
type Note = {
|
||||
id: string;
|
||||
saseUserId: string;
|
||||
authorUserId: string;
|
||||
body: string;
|
||||
pinned: boolean;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export function NotesTab({ userId }: { userId: string }) {
|
||||
const [notes, setNotes] = useState<Note[] | null>(null);
|
||||
const [text, setText] = useState("");
|
||||
const [pinned, setPinned] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
async function load() {
|
||||
const res = await fetch(`/api/sase/users/${userId}/notes`, { cache: "no-store" });
|
||||
const data = (await res.json().catch(() => ({}))) as { notes?: Note[] };
|
||||
setNotes(data.notes ?? []);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [userId]);
|
||||
|
||||
function submit() {
|
||||
setError(null);
|
||||
if (text.trim().length < 2) {
|
||||
setError("Not en az 2 karakter olmalı.");
|
||||
return;
|
||||
}
|
||||
startTransition(async () => {
|
||||
const res = await fetch(`/api/sase/users/${userId}/notes`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ body: text.trim(), pinned }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
setError(data.error ?? `HTTP ${res.status}`);
|
||||
return;
|
||||
}
|
||||
setText("");
|
||||
setPinned(false);
|
||||
await load();
|
||||
});
|
||||
}
|
||||
|
||||
function togglePin(noteId: string, current: boolean) {
|
||||
startTransition(async () => {
|
||||
await fetch(`/api/sase/notes/${noteId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ pinned: !current }),
|
||||
});
|
||||
await load();
|
||||
});
|
||||
}
|
||||
|
||||
function remove(noteId: string) {
|
||||
if (!confirm("Bu notu sil?")) return;
|
||||
startTransition(async () => {
|
||||
await fetch(`/api/sase/notes/${noteId}`, { method: "DELETE" });
|
||||
await load();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-md border p-3 space-y-2">
|
||||
<Textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Founder notu… (örn. 'Telefonla görüştük, Q3'te enterprise'a geçecek')"
|
||||
rows={3}
|
||||
maxLength={4000}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={pinned}
|
||||
onCheckedChange={(v) => setPinned(v === true)}
|
||||
/>
|
||||
Üste sabitle (pinned)
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
{error && <span className="text-xs text-destructive">{error}</span>}
|
||||
<span className="text-xs text-muted-foreground">{text.length}/4000</span>
|
||||
<Button onClick={submit} disabled={pending} size="sm">
|
||||
{pending ? "..." : "Not ekle"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notes === null ? (
|
||||
<p className="text-sm text-muted-foreground">Yükleniyor…</p>
|
||||
) : notes.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Henüz not yok.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{notes.map((n) => (
|
||||
<li key={n.id} className="rounded-md border p-3 space-y-2">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{new Date(n.createdAt).toLocaleString("tr-TR", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
{n.pinned && <Badge variant="default">pinned</Badge>}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => togglePin(n.id, n.pinned)}
|
||||
disabled={pending}
|
||||
>
|
||||
{n.pinned ? "Sabitle. kaldır" : "Sabitle"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => remove(n.id)}
|
||||
disabled={pending}
|
||||
>
|
||||
Sil
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap text-sm">{n.body}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from "@/lib/sase/user-detail";
|
||||
import { EmailReveal } from "./_email-reveal";
|
||||
import { ImpersonateButton } from "./_impersonate-button";
|
||||
import { NotesTab } from "./_notes";
|
||||
import { saseAdminWired } from "@/lib/admin-sdk/sase";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -113,6 +114,7 @@ export default async function SaseUserDetailPage({
|
||||
<TabsTrigger value="billing">Subscription & Billing</TabsTrigger>
|
||||
<TabsTrigger value="usage">Kullanım</TabsTrigger>
|
||||
<TabsTrigger value="timeline">Aktivite</TabsTrigger>
|
||||
<TabsTrigger value="notes">Notlar</TabsTrigger>
|
||||
<TabsTrigger value="audit">Audit</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -281,6 +283,10 @@ export default async function SaseUserDetailPage({
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="notes" className="pt-3">
|
||||
<NotesTab userId={user.id} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="audit" className="pt-3">
|
||||
{audit.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
||||
Reference in New Issue
Block a user