feat(platform): ship AI fallback stack and entity-scoped course planning

Unifies the new LangGraph-driven course-plan/media flow with robust provider fallbacks, admin AI provider settings, editable book-style materials, and strict entity isolation across LMS/course-plan APIs. Adds admin-only entity membership management in the Entities UI so users can switch linked entities directly from the platform.

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-26 02:34:52 +04:00
parent 096b042daf
commit 35ccc0dfb2
51 changed files with 5487 additions and 538 deletions

View File

@@ -6,6 +6,7 @@ import { toast } from "sonner";
import {
FileText,
Image as ImageIcon,
Library,
Link2,
Mic,
Music,
@@ -28,9 +29,10 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { LibraryPickerDialog } from "@/components/coursePlan/LibraryPickerDialog";
import { coursePlanService } from "@/services/coursePlan.service";
import { describeApiError } from "@/lib/api-client";
import type { CoursePlanGenerateBrief } from "@/types";
import type { CoursePlanGenerateBrief, Resource } from "@/types";
/**
* AI course-plan generation wizard.
@@ -48,7 +50,7 @@ import type { CoursePlanGenerateBrief } from "@/types";
* materials immediately.
*/
type DraftSourceKind = "file" | "url" | "text";
type DraftSourceKind = "file" | "url" | "text" | "resource";
interface DraftSource {
/** Stable client-side id; not persisted. */
@@ -61,6 +63,10 @@ interface DraftSource {
url?: string;
/** Only set when kind === "text". */
text?: string;
/** Only set when kind === "resource" — pointer into the central library. */
resourceId?: number;
/** Display-only metadata so we can render the row without a refetch. */
resourceType?: string;
}
interface MediaToggleState {
@@ -383,6 +389,26 @@ export default function CoursePlanWizard() {
});
const planId = resp?.data?.id;
if (planId && state.sources.length) {
// Library picks go through the dedicated bulk endpoint — one
// round-trip for the whole set, with server-side dedupe — so we
// collect their ids first and post them together. The remaining
// file/url/text drafts are posted individually as before.
const resourceIds = state.sources
.filter((s) => s.kind === "resource" && s.resourceId)
.map((s) => s.resourceId as number);
if (resourceIds.length) {
try {
await coursePlanService.attachResources(planId, resourceIds);
} catch (err) {
toast.error(
describeApiError(
err,
t("coursePlan.sources.libraryAttachFailed"),
),
);
}
}
// Best-effort upload — we keep going even if a single one fails so
// the user lands on the detail page with whatever did make it in.
for (const src of state.sources) {
@@ -404,6 +430,8 @@ export default function CoursePlanWizard() {
name: src.name || t("coursePlan.sourceKind.text"),
});
}
// src.kind === "resource" was already handled in the bulk
// attach above.
} catch (err) {
toast.error(
describeApiError(err, t("coursePlan.sources.uploadFailed", {
@@ -433,6 +461,15 @@ function SourcesStep({
const fileRef = useRef<HTMLInputElement>(null);
const [draftUrl, setDraftUrl] = useState("");
const [draftText, setDraftText] = useState("");
const [libraryOpen, setLibraryOpen] = useState(false);
// Resource ids already queued in this wizard session; passed to the
// picker so the same resource can't be added twice.
const queuedResourceIds = new Set(
sources
.filter((s) => s.kind === "resource" && s.resourceId)
.map((s) => s.resourceId as number),
);
const addFiles = (files: FileList | null) => {
if (!files || !files.length) return;
@@ -470,38 +507,73 @@ function SourcesStep({
setDraftText("");
};
const addLibraryResources = (picked: Resource[]) => {
if (!picked.length) return;
const additions: DraftSource[] = picked.map((r) => ({
uid: genUid(),
kind: "resource",
name: r.name,
resourceId: r.id,
resourceType: r.resource_type || r.type || "resource",
}));
onChange([...sources, ...additions]);
setLibraryOpen(false);
};
const remove = (uid: string) => onChange(sources.filter((s) => s.uid !== uid));
return (
<div className="space-y-5">
<div className="rounded-md border-dashed border-2 px-4 py-6 text-center bg-muted/20">
<FileText className="mx-auto h-6 w-6 text-muted-foreground" />
<p className="mt-2 text-sm font-medium">
{t("coursePlan.sources.dropTitle")}
</p>
<p className="text-xs text-muted-foreground">
{t("coursePlan.sources.dropHint")}
</p>
<div className="mt-3">
<input
ref={fileRef}
type="file"
multiple
className="hidden"
accept=".pdf,.doc,.docx,.txt,.md,.html,.htm,.rtf"
onChange={(e) => {
addFiles(e.currentTarget.files);
if (fileRef.current) fileRef.current.value = "";
}}
/>
<Button
variant="outline"
size="sm"
onClick={() => fileRef.current?.click()}
>
<Upload className="mr-1 h-4 w-4" />
{t("coursePlan.sources.uploadFiles")}
</Button>
<div className="grid gap-3 md:grid-cols-2">
<div className="rounded-md border-dashed border-2 px-4 py-6 text-center bg-muted/20">
<FileText className="mx-auto h-6 w-6 text-muted-foreground" />
<p className="mt-2 text-sm font-medium">
{t("coursePlan.sources.dropTitle")}
</p>
<p className="text-xs text-muted-foreground">
{t("coursePlan.sources.dropHint")}
</p>
<div className="mt-3">
<input
ref={fileRef}
type="file"
multiple
className="hidden"
accept=".pdf,.doc,.docx,.txt,.md,.html,.htm,.rtf"
onChange={(e) => {
addFiles(e.currentTarget.files);
if (fileRef.current) fileRef.current.value = "";
}}
/>
<Button
variant="outline"
size="sm"
onClick={() => fileRef.current?.click()}
>
<Upload className="mr-1 h-4 w-4" />
{t("coursePlan.sources.uploadFiles")}
</Button>
</div>
</div>
<div className="rounded-md border-dashed border-2 px-4 py-6 text-center bg-muted/20">
<Library className="mx-auto h-6 w-6 text-muted-foreground" />
<p className="mt-2 text-sm font-medium">
{t("coursePlan.sources.libraryPickTitle")}
</p>
<p className="text-xs text-muted-foreground">
{t("coursePlan.sources.libraryPickHint")}
</p>
<div className="mt-3">
<Button
variant="outline"
size="sm"
onClick={() => setLibraryOpen(true)}
>
<Library className="mr-1 h-4 w-4" />
{t("coursePlan.sources.libraryPickButton")}
</Button>
</div>
</div>
</div>
@@ -566,8 +638,19 @@ function SourcesStep({
className="flex items-center gap-2 px-3 py-2 text-sm"
>
<Badge variant="outline" className="capitalize text-[10px]">
{s.kind}
{s.kind === "resource"
? s.resourceType || "library"
: s.kind}
</Badge>
{s.kind === "resource" && (
<Badge
variant="secondary"
className="gap-1 text-[10px] flex items-center"
>
<Library className="h-3 w-3" />
{t("coursePlan.sources.fromLibrary")}
</Badge>
)}
<span className="flex-1 truncate">{s.name}</span>
<Button
variant="ghost"
@@ -583,6 +666,13 @@ function SourcesStep({
</ul>
</div>
)}
<LibraryPickerDialog
open={libraryOpen}
onOpenChange={setLibraryOpen}
alreadyLinkedIds={queuedResourceIds}
onConfirm={addLibraryResources}
/>
</div>
);
}