Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,13 @@ model AudioUpload {
uploadError String?
moderationStatus String? // "Pending" | "Reviewing" | "Approved" | "Rejected"
moderationReason String?
// Discord ID of the signed-in owner. Existing rows without an owner are
// deliberately not shown to anyone, preventing legacy shared history.
ownerId String?
accountId String?
account RobloxAccount? @relation(fields: [accountId], references: [id])

@@index([ownerId, createdAt])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
53 changes: 39 additions & 14 deletions src/app/api/history/route.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { getAuthenticatedUserId } from "@/lib/auth";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

/** GET /api/history — list all conversion/upload history (newest first) */
async function requireUser() {
const ownerId = await getAuthenticatedUserId();
if (!ownerId) {
return { error: NextResponse.json({ ok: false, error: "Silakan login dengan Discord terlebih dahulu." }, { status: 401 }) };
}
return { ownerId };
}

/** GET /api/history — only the current Discord user's history, newest first. */
export async function GET() {
const auth = await requireUser();
if ("error" in auth) return auth.error;

try {
const items = await db.audioUpload.findMany({
where: { ownerId: auth.ownerId },
orderBy: { createdAt: "desc" },
take: 100,
include: { account: true },
});
return NextResponse.json({ ok: true, items });
} catch (e) {
return NextResponse.json({ ok: false, error: (e as Error).message, items: [] }, { status: 500 });
}
}

/** POST /api/history — save a conversion/upload record */
/** POST /api/history — create a record owned by the current Discord user. */
export async function POST(req: NextRequest) {
const auth = await requireUser();
if ("error" in auth) return auth.error;

try {
const body = await req.json();
const created = await db.audioUpload.create({
data: {
ownerId: auth.ownerId,
sourceType: body.sourceType || "file",
sourceUrl: body.sourceUrl || null,
sourceTitle: body.sourceTitle || null,
Expand All @@ -44,7 +60,8 @@ export async function POST(req: NextRequest) {
uploadError: body.uploadError || null,
moderationStatus: body.moderationStatus || null,
moderationReason: body.moderationReason || null,
accountId: body.accountId || null,
// Never accept ownerId from the browser. accountId is intentionally
// omitted: Roblox accounts are browser-session credentials, not shared DB rows.
},
});
return NextResponse.json({ ok: true, item: created });
Expand All @@ -53,35 +70,43 @@ export async function POST(req: NextRequest) {
}
}

/** PATCH /api/history?id=xxx — update moderation status of a record */
/** PATCH /api/history?id=xxx — update only a record owned by this user. */
export async function PATCH(req: NextRequest) {
const auth = await requireUser();
if ("error" in auth) return auth.error;

try {
const url = new URL(req.url);
const id = url.searchParams.get("id");
const id = new URL(req.url).searchParams.get("id");
if (!id) return NextResponse.json({ ok: false, error: "id wajib diisi" }, { status: 400 });
const body = await req.json();
const updated = await db.audioUpload.update({
where: { id },
const result = await db.audioUpload.updateMany({
where: { id, ownerId: auth.ownerId },
data: {
moderationStatus: body.moderationStatus || null,
moderationReason: body.moderationReason || null,
uploadStatus: body.uploadStatus || undefined,
uploadError: body.uploadError || undefined,
robloxAssetId: body.robloxAssetId || undefined,
},
});
return NextResponse.json({ ok: true, item: updated });
if (!result.count) return NextResponse.json({ ok: false, error: "Riwayat tidak ditemukan." }, { status: 404 });
const item = await db.audioUpload.findFirst({ where: { id, ownerId: auth.ownerId } });
return NextResponse.json({ ok: true, item });
} catch (e) {
return NextResponse.json({ ok: false, error: (e as Error).message }, { status: 500 });
}
}

/** DELETE /api/history?id=xxx — delete a record */
/** DELETE /api/history?id=xxx — delete only a record owned by this user. */
export async function DELETE(req: NextRequest) {
const auth = await requireUser();
if ("error" in auth) return auth.error;

try {
const url = new URL(req.url);
const id = url.searchParams.get("id");
const id = new URL(req.url).searchParams.get("id");
if (!id) return NextResponse.json({ ok: false, error: "id wajib diisi" }, { status: 400 });
await db.audioUpload.delete({ where: { id } });
const result = await db.audioUpload.deleteMany({ where: { id, ownerId: auth.ownerId } });
if (!result.count) return NextResponse.json({ ok: false, error: "Riwayat tidak ditemukan." }, { status: 404 });
return NextResponse.json({ ok: true });
} catch (e) {
return NextResponse.json({ ok: false, error: (e as Error).message }, { status: 500 });
Expand Down
2 changes: 2 additions & 0 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { HistoryList } from "@/components/history-list";
import { PreviewList } from "@/components/preview-list";
import { ThemeToggle } from "@/components/theme-toggle";
import { UserMenu } from "@/components/user-menu";
import { SessionWorkspace } from "@/components/session-workspace";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
Expand All @@ -21,6 +22,7 @@ export default function Home() {

return (
<div className="flex min-h-screen flex-col bg-background bg-grid">
<SessionWorkspace />
{/* Header */}
<header className="sticky top-0 z-50 border-b bg-background/80 backdrop-blur-xl">
<div className="mx-auto flex h-14 max-w-7xl items-center gap-3 px-4">
Expand Down
8 changes: 5 additions & 3 deletions src/components/history-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,8 +200,8 @@ export function HistoryList() {
<div className="flex flex-wrap items-center gap-1.5">
<p className="truncate text-xs font-semibold">{item.assetName}</p>
{isUploaded && !item.moderationStatus && (
<Badge variant="outline" className="h-4 gap-0.5 px-1 text-[9px] text-emerald-500">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" /> Sukses
<Badge variant="outline" className="h-4 gap-0.5 px-1 text-[9px] text-slate-500">
<Clock className="h-2.5 w-2.5" /> Status moderasi belum tersedia
</Badge>
)}
{isUploaded && item.moderationStatus && (
Expand Down Expand Up @@ -276,6 +276,8 @@ function getModBadge(status: string | null): { icon: React.ElementType; label: s
case "Pending":
return { icon: Clock, label: "Menunggu", className: "text-amber-500 border-amber-500/30" };
default:
return { icon: CheckCircle2, label: "Sukses", className: "text-emerald-500" };
// "uploaded" only confirms the file reached Roblox, not that the
// asset passed Roblox's independent moderation.
return { icon: Clock, label: "Status belum tersedia", className: "text-slate-500 border-slate-500/30" };
}
}
11 changes: 8 additions & 3 deletions src/components/roblox-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -282,8 +282,12 @@ export function RobloxPanel() {
setUploading(false);
refreshHistory();
if (success > 0) {
toast.success(`${success} audio terupload`, {
description: failed > 0 ? `${failed} gagal` : "Menunggu moderasi Roblox...",
toast.success(`${success} audio dikirim ke Roblox`, {
// Creating an asset is not the same as being allowed to use it.
// Roblox makes the final moderation decision asynchronously.
description: failed > 0
? `${failed} gagal. Audio yang dikirim tetap menunggu moderasi Roblox.`
: "Upload selesai, tetapi belum disetujui. Menunggu moderasi Roblox...",
});
}
};
Expand Down Expand Up @@ -549,7 +553,8 @@ function getModConfig(state: ModerationState | null) {
case "Pending":
return { icon: Clock, label: "Menunggu", color: "#f59e0b" };
default:
return { icon: CheckCircle2, label: "Sukses", color: "#10b981" };
// Never call an asset successful before Roblox explicitly approves it.
return { icon: Clock, label: "Status belum tersedia", color: "#94a3b8" };
}
}

Expand Down
25 changes: 25 additions & 0 deletions src/components/session-workspace.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"use client";

import { useEffect } from "react";
import { useSession } from "next-auth/react";
import { useConverter } from "@/lib/store";

/**
* Keeps client-side persisted state scoped to the Discord account. The server
* remains the authority for history; this additionally protects shared devices
* from displaying the previous user's Roblox workspace during a session switch.
*/
export function SessionWorkspace() {
const { data: session, status } = useSession();
const setOwnerId = useConverter((state) => state.setOwnerId);

useEffect(() => {
if (status === "authenticated" && session.user?.id) {
setOwnerId(session.user.id);
} else if (status === "unauthenticated") {
setOwnerId(null);
}
}, [session?.user?.id, setOwnerId, status]);

return null;
}
10 changes: 8 additions & 2 deletions src/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import NextAuth, { type NextAuthOptions } from "next-auth";
import NextAuth, { getServerSession, type NextAuthOptions } from "next-auth";
import DiscordProvider from "next-auth/providers/discord";
import type { Session } from "next-auth";

Expand Down Expand Up @@ -59,7 +59,7 @@ async function notifyLogin(user: { name?: string | null; email?: string | null;
// `trustHost` is a v4 runtime option (read from env via NEXTAUTH_URL or
// AUTH_TRUST_HOST) — passing it as a config key was never typed. We work
// around the type with `as NextAuthOptions`.
const authOptions: NextAuthOptions = {
export const authOptions: NextAuthOptions = {
providers: [
DiscordProvider({
clientId: process.env.AUTH_DISCORD_ID!,
Expand All @@ -85,6 +85,12 @@ const authOptions: NextAuthOptions = {
},
};

/** The Discord user ID is the tenant boundary for all user-owned data. */
export async function getAuthenticatedUserId(): Promise<string | null> {
const session = await getServerSession(authOptions);
return session?.user?.id ?? null;
}

const handler = NextAuth(authOptions);

export const GET = handler;
Expand Down
20 changes: 20 additions & 0 deletions src/lib/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ const DEFAULT_SETTINGS: ProcessSettings = {
};

interface ConverterState {
// Discord user ID that owns this browser-persisted workspace.
ownerId: string | null;

// Sources queue (multi)
sources: SourceItem[];
sourceLoading: boolean;
Expand Down Expand Up @@ -158,6 +161,10 @@ interface ConverterState {
// History
historyVersion: number;

// Switch workspace when the Discord session changes. This prevents a user
// on a shared browser from seeing another user's Roblox identity or queue.
setOwnerId: (ownerId: string | null) => void;

// Actions - Sources
addSource: (s: Omit<SourceItem, "id" | "selected">) => void;
removeSource: (id: string) => void;
Expand Down Expand Up @@ -228,6 +235,7 @@ if (typeof window !== "undefined") {
export const useConverter = create<ConverterState>()(
persist(
(set, get) => ({
ownerId: null,
sources: [],
sourceLoading: false,
sourceError: null,
Expand All @@ -250,6 +258,17 @@ export const useConverter = create<ConverterState>()(

historyVersion: 0,

setOwnerId: (ownerId) => set((state) => {
if (state.ownerId === ownerId) return { ownerId };
// Do not carry any persisted browser state across Discord accounts.
return {
ownerId,
sources: [], cookies: "", settings: { ...DEFAULT_SETTINGS }, activePresetId: "none",
processedMap: {}, uploadMap: {}, account: null,
sourceError: null, processError: null, verifyError: null,
};
}),

// Sources
addSource: (s) =>
set((state) => ({
Expand Down Expand Up @@ -369,6 +388,7 @@ export const useConverter = create<ConverterState>()(
// the verified identity across reloads, but the user has to re-verify
// (paste key + click Verify) before any upload can resume.
partialize: (state) => ({
ownerId: state.ownerId,
account: state.account
? { ...state.account, apiKey: "" }
: null,
Expand Down