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:
@@ -5,11 +5,12 @@
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
"build": "prisma generate && prisma generate --schema=./prisma/sase/schema.prisma && next build",
|
||||
"start": "next start -p 3000 -H 0.0.0.0",
|
||||
"start": "prisma db push --skip-generate && next start -p 3000 -H 0.0.0.0",
|
||||
"lint": "next lint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prisma:generate": "prisma generate && prisma generate --schema=./prisma/sase/schema.prisma",
|
||||
"prisma:migrate:deploy": "prisma migrate deploy",
|
||||
"prisma:db:push": "prisma db push --skip-generate",
|
||||
"prisma:seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"prisma": {
|
||||
|
||||
@@ -363,3 +363,20 @@ model EvalRun {
|
||||
@@index([promptTag, promptVersion])
|
||||
@@map("eval_runs")
|
||||
}
|
||||
|
||||
// ---------- Phase 7a: Sase user-management ----------
|
||||
|
||||
// Founder-only notes pinned to a Sase user. Stored panel-side (KVKK minimize:
|
||||
// the spoke is never written to). Append-only from the UI — delete is allowed
|
||||
// while we're early; can tighten to audit-only soft-delete later.
|
||||
model SaseUserNote {
|
||||
id String @id @default(cuid())
|
||||
saseUserId String @map("sase_user_id")
|
||||
authorUserId String @map("author_user_id") // panel User.id
|
||||
body String
|
||||
pinned Boolean @default(false)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([saseUserId, pinned, createdAt])
|
||||
@@map("sase_user_notes")
|
||||
}
|
||||
|
||||
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">
|
||||
|
||||
@@ -27,6 +27,22 @@ PRD'de "founder müdahale frekansı en yüksek" varsayımı EFT idi; Stripe sonr
|
||||
|
||||
- `apps/api/src/common/guards/internal-token.guard.ts` — X-Internal-Token doğrulama, panel→Sase.tr tüm `/internal/admin/*` çağrıları için temel. Henüz hiçbir endpoint kullanmıyor; ilk mutation eklendiğinde bağlanır.
|
||||
|
||||
### Sase RO connection — `super_panel_reader` rolü oluştur (geçici çözüm)
|
||||
|
||||
2026-05-18 itibarıyla panel `DATABASE_URL_SASE_RO` env'i Sase.tr'nin kendi `sase-postgres` (Coolify standalone DB, `v48gwwo48w8gg0ko0wg0ocko:5432`) üzerinde **`sase` superuser** ile çalışıyor. Bu hızlı bir fix — eskiden `coolify-db`'deki `sase` schema'sında `super_panel_reader` (SELECT-only) rolü vardı; Sase.tr ayrı bir DB'ye geçince o rol kayboldu.
|
||||
|
||||
Yapılacak:
|
||||
```sql
|
||||
CREATE ROLE super_panel_reader LOGIN PASSWORD '<rand>';
|
||||
GRANT CONNECT ON DATABASE sase TO super_panel_reader;
|
||||
GRANT USAGE ON SCHEMA public TO super_panel_reader;
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA public TO super_panel_reader;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO super_panel_reader;
|
||||
```
|
||||
Sonra panel-web `DATABASE_URL_SASE_RO` env'ini bu role çevir.
|
||||
|
||||
**Neden ertelenebilir:** Panel sadece RO query yapıyor; superuser olsa da panel-side write yok. Riski sınırlı ama defense-in-depth için temizlenmeli.
|
||||
|
||||
---
|
||||
|
||||
## insight
|
||||
|
||||
Reference in New Issue
Block a user