feat(learner): owner admin API for roles, groups, course access and library curation
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
668ae379bf
commit
a8a8e32bcb
@@ -0,0 +1,150 @@
|
||||
import type { Feature, Role, ServerContext } from "@stc/contracts";
|
||||
import { libraryRepo, type Collection } from "@stc/db";
|
||||
import { ensureGroup } from "./auth/groups";
|
||||
import { badRequest, body, json, notFound, ownerOnly, param } from "./http";
|
||||
|
||||
const ROLES: Role[] = ["owner", "author", "learner"];
|
||||
|
||||
/** Owner-only administration: users and roles, groups, course access lists and library curation. */
|
||||
export function adminRoutes(ctx: ServerContext, invalidate: (userId?: string) => void): Feature["routes"] {
|
||||
const { db } = ctx;
|
||||
const lib = libraryRepo(db);
|
||||
|
||||
return {
|
||||
"/api/admin/users": {
|
||||
GET: ownerOnly(async () => {
|
||||
const users = await db.sql`SELECT id, email, name, created_at FROM auth_user ORDER BY created_at, email`;
|
||||
const roles = await db.sql`SELECT user_id, role FROM user_roles`;
|
||||
const members = await db.sql`SELECT user_id, group_id FROM group_members`;
|
||||
return json(users.map((u: { id: string; email: string; name: string; created_at: string }) => ({
|
||||
id: u.id, email: u.email, name: u.name, createdAt: u.created_at,
|
||||
roles: roles.filter((r: { user_id: string }) => r.user_id === u.id).map((r: { role: string }) => r.role).sort(),
|
||||
groups: members.filter((m: { user_id: string }) => m.user_id === u.id).map((m: { group_id: string }) => m.group_id).sort(),
|
||||
})));
|
||||
}),
|
||||
},
|
||||
"/api/admin/users/:id/roles": {
|
||||
PUT: ownerOnly(async (req, rc) => {
|
||||
const id = param(rc, "id");
|
||||
const b = await body<{ roles: Role[] }>(req);
|
||||
if (!b || !Array.isArray(b.roles) || b.roles.some((r) => !ROLES.includes(r))) return badRequest("roles must be a list of owner, author, learner");
|
||||
const exists = await db.sql`SELECT 1 AS x FROM auth_user WHERE id = ${id}`;
|
||||
if (!exists.length) return notFound();
|
||||
const roles = [...new Set(b.roles)];
|
||||
if (!roles.includes("owner")) {
|
||||
const owners = await db.sql`SELECT user_id FROM user_roles WHERE role = 'owner'`;
|
||||
const others = owners.filter((o: { user_id: string }) => o.user_id !== id);
|
||||
if (others.length === 0) return json({ error: "the last owner cannot lose the owner role" }, 409);
|
||||
}
|
||||
await db.sql.begin(async (tx) => {
|
||||
await tx`DELETE FROM user_roles WHERE user_id = ${id}`;
|
||||
for (const r of roles) await tx`INSERT INTO user_roles (user_id, role) VALUES (${id}, ${r})`;
|
||||
});
|
||||
invalidate(id);
|
||||
return json({ id, roles: roles.sort() });
|
||||
}),
|
||||
},
|
||||
"/api/admin/groups": {
|
||||
GET: ownerOnly(async () => {
|
||||
const groups = await db.sql`SELECT id, name FROM groups ORDER BY name`;
|
||||
const members = await db.sql`SELECT group_id, user_id FROM group_members ORDER BY user_id`;
|
||||
return json(groups.map((g: { id: string; name: string }) => ({
|
||||
id: g.id, name: g.name,
|
||||
members: members.filter((m: { group_id: string }) => m.group_id === g.id).map((m: { user_id: string }) => m.user_id),
|
||||
})));
|
||||
}),
|
||||
POST: ownerOnly(async (req) => {
|
||||
const b = await body<{ name: string }>(req);
|
||||
const name = typeof b?.name === "string" ? b.name.trim() : "";
|
||||
if (!name) return badRequest("name is required");
|
||||
return json({ id: await ensureGroup(db, name), name }, 201);
|
||||
}),
|
||||
},
|
||||
"/api/admin/groups/:id": {
|
||||
DELETE: ownerOnly(async (_req, rc) => {
|
||||
const id = param(rc, "id");
|
||||
await db.sql.begin(async (tx) => {
|
||||
await tx`DELETE FROM group_members WHERE group_id = ${id}`;
|
||||
await tx`DELETE FROM learner_group_claims WHERE group_id = ${id}`;
|
||||
await tx`DELETE FROM course_acl WHERE principal_type = 'group' AND principal_id = ${id}`;
|
||||
await tx`DELETE FROM groups WHERE id = ${id}`;
|
||||
});
|
||||
invalidate();
|
||||
return json({ ok: true });
|
||||
}),
|
||||
},
|
||||
"/api/admin/groups/:id/members": {
|
||||
PUT: ownerOnly(async (req, rc) => {
|
||||
const id = param(rc, "id");
|
||||
const b = await body<{ userIds: string[] }>(req);
|
||||
if (!b || !Array.isArray(b.userIds)) return badRequest("userIds must be a list");
|
||||
const g = await db.sql`SELECT 1 AS x FROM groups WHERE id = ${id}`;
|
||||
if (!g.length) return notFound();
|
||||
await db.sql.begin(async (tx) => {
|
||||
await tx`DELETE FROM group_members WHERE group_id = ${id}`;
|
||||
await tx`DELETE FROM learner_group_claims WHERE group_id = ${id}`;
|
||||
for (const u of new Set(b.userIds.map(String))) await tx`INSERT INTO group_members (group_id, user_id) VALUES (${id}, ${u})`;
|
||||
});
|
||||
invalidate();
|
||||
return json({ id, members: [...new Set(b.userIds)].sort() });
|
||||
}),
|
||||
},
|
||||
"/api/admin/courses/:id/access": {
|
||||
GET: ownerOnly(async (_req, rc) => {
|
||||
const id = param(rc, "id");
|
||||
const course = ctx.courses.get(id);
|
||||
if (!course) return notFound();
|
||||
const acl = await db.sql`SELECT principal_type, principal_id FROM course_acl WHERE course_id = ${id} ORDER BY principal_type, principal_id`;
|
||||
const authors = await db.sql`SELECT user_id FROM course_authors WHERE course_id = ${id} ORDER BY user_id`;
|
||||
return json({
|
||||
visibility: course.manifest?.visibility ?? "restricted",
|
||||
acl: acl.map((a: { principal_type: string; principal_id: string }) => ({ principal_type: a.principal_type, principal_id: a.principal_id })),
|
||||
authors: authors.map((a: { user_id: string }) => a.user_id),
|
||||
});
|
||||
}),
|
||||
PUT: ownerOnly(async (req, rc) => {
|
||||
const id = param(rc, "id");
|
||||
if (!ctx.courses.get(id)) return notFound();
|
||||
const b = await body<{ acl?: { principal_type: string; principal_id: string }[]; authors?: string[] }>(req);
|
||||
if (!b) return badRequest("body required");
|
||||
if (b.acl && b.acl.some((a) => (a.principal_type !== "user" && a.principal_type !== "group") || !a.principal_id)) return badRequest("acl entries need principal_type user|group and principal_id");
|
||||
await db.sql.begin(async (tx) => {
|
||||
if (b.acl) {
|
||||
await tx`DELETE FROM course_acl WHERE course_id = ${id}`;
|
||||
const seen = new Set<string>();
|
||||
for (const a of b.acl) {
|
||||
const k = `${a.principal_type}\0${a.principal_id}`;
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
await tx`INSERT INTO course_acl (course_id, principal_type, principal_id) VALUES (${id}, ${a.principal_type}, ${a.principal_id})`;
|
||||
}
|
||||
}
|
||||
if (b.authors) {
|
||||
await tx`DELETE FROM course_authors WHERE course_id = ${id}`;
|
||||
for (const u of new Set(b.authors)) await tx`INSERT INTO course_authors (course_id, user_id) VALUES (${id}, ${u})`;
|
||||
}
|
||||
});
|
||||
return json({ ok: true });
|
||||
}),
|
||||
},
|
||||
"/api/admin/library": {
|
||||
GET: ownerOnly(async () => json({ featured: await lib.getFeatured(), collections: await lib.listCollections() })),
|
||||
PUT: ownerOnly(async (req) => {
|
||||
const b = await body<{ featured?: string | null; collections?: Collection[] }>(req);
|
||||
if (!b) return badRequest("body required");
|
||||
if (b.featured !== undefined) await lib.setFeatured(b.featured || null);
|
||||
if (b.collections) {
|
||||
const keep = new Set<string>();
|
||||
for (const [i, c] of b.collections.entries()) {
|
||||
if (!c.title?.trim()) return badRequest("every collection needs a title");
|
||||
const cid = c.id || c.title.trim().toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-|-$/g, "");
|
||||
keep.add(cid);
|
||||
await lib.saveCollection({ id: cid, title: c.title.trim(), sort: c.sort ?? i, courseIds: c.courseIds ?? [] });
|
||||
}
|
||||
for (const c of await lib.listCollections()) if (!keep.has(c.id)) await lib.deleteCollection(c.id);
|
||||
}
|
||||
return json({ featured: await lib.getFeatured(), collections: await lib.listCollections() });
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { AuthService, Feature, ServerContext } from "@stc/contracts";
|
||||
import { createAuth } from "./auth";
|
||||
import { runMigrations } from "./migrate";
|
||||
import { json } from "./http";
|
||||
import { adminRoutes } from "./admin";
|
||||
|
||||
/**
|
||||
* The learner feature: auth, library, reader, notes, progress and exercises.
|
||||
@@ -20,6 +21,7 @@ export function learnerFeature(ctx: ServerContext): Feature & { auth: AuthServic
|
||||
"/api/learner/providers": {
|
||||
GET: async () => json({ emailPassword: true, providers: auth.providers(), sso: await auth.hasSso() }),
|
||||
},
|
||||
...adminRoutes(ctx, auth.service.invalidate),
|
||||
};
|
||||
return { name: "learner", routes, auth: auth.service };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user