Files
Edu/apps/portal-shell/src/lib/api/admin.ts
SpecialX 2910a90271 feat(portal-shell): extract domain API layer and migrate 31 widgets
Task 4-10 of portal-shell data abstraction plan (M1-M2).

Add 7 domain API modules under src/lib/api/ (parent/admin/teacher/
student/universal/sidebar/topbar), each exposing semantic hooks that
wrap useWidgetQuery/useWidgetMutation and return flattened domain
models. Widget code now imports from @/lib/api instead of inlining
gql literals.

- 31 widgets migrated (gql literal count in widgets: 0)
- 7 test files (85 cases, all passing)
- topbar.useNotifications renamed to useNotificationBell to avoid
  barrel export collision with universal.useNotifications
- typecheck + lint (0 errors) + test (85/85) verified
2026-07-17 13:07:24 +08:00

652 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/**
* Admin domain API语义化 Hook
*
* 为 widgets/admin/* 提供语义化查询/变更 Hook封装 GraphQL DOC 与类型映射。
* widget 通过 `import { useUsers } from "@/lib/api/admin"` 调用。
*
* 设计:
* - 查询 Hook 返回 UseQueryResult<TData>data 已归一化(剥离外层 query 字段)
* - 变更 Hook 返回 { run, loading, error }run 抛 ApiError 表示业务失败
* - 分页查询返回 UseQueryResult<PaginatedResult<T>>,保留 items + total
*
* 关联spec §2.2、§5.6
*/
import { useWidgetQuery } from "@/lib/useWidgetQuery";
import { useWidgetMutation } from "@/lib/useWidgetMutation";
import { ApiError } from "./errors";
import type { PaginatedResult, Pagination, UseQueryResult } from "./types";
import {
GET_USERS_DOC,
UPDATE_USER_STATUS_DOC,
UPDATE_USER_ROLE_DOC,
GET_ROLES_DOC,
GET_PERMISSIONS_DOC,
UPDATE_ROLE_PERMISSIONS_DOC,
GET_AUDIT_LOGS_DOC,
GET_INVITATION_CODES_DOC,
CREATE_INVITATION_CODE_DOC,
REVOKE_INVITATION_CODE_DOC,
GET_SCHOOL_DOC,
UPDATE_SCHOOL_DOC,
GET_PLUGIN_REGISTRY_DOC,
GET_ROLE_PLUGIN_MAPPING_DOC,
GET_LAYOUT_TEMPLATES_DOC,
GET_ROLE_LAYOUT_DEFAULT_DOC,
UPDATE_PLUGIN_REGISTRY_DOC,
UPDATE_ROLE_PLUGIN_MAPPING_DOC,
UPDATE_ROLE_LAYOUT_DEFAULT_DOC,
RESET_USER_LAYOUT_OVERRIDE_DOC,
} from "./operations/admin.graphql";
// ============================================================
// Types: User management
// ============================================================
export interface User {
id: string;
name: string;
email: string;
role: string;
status: string;
createdAt: string;
}
export type UserQueryVars = {
role: string | null;
limit: number;
offset: number;
};
// ============================================================
// Types: RBAC
// ============================================================
export interface Permission {
id: string;
name: string;
resource: string;
action: string;
description: string;
}
export interface RolePermission {
id: string;
name: string;
resource: string;
action: string;
}
export interface Role {
id: string;
name: string;
permissions: RolePermission[];
}
// ============================================================
// Types: Audit logs
// ============================================================
export interface AuditLog {
id: string;
userId: string;
userName: string;
action: string;
resource: string;
resourceId: string;
ip: string;
timestamp: string;
details: string;
}
export interface AuditLogFilter {
userId?: string | null;
action?: string | null;
resource?: string | null;
}
// ============================================================
// Types: Invitation codes
// ============================================================
export interface InvitationCode {
id: string;
code: string;
role: string;
status: string;
usedCount: number;
maxUses: number;
expiresAt: string;
createdAt: string;
createdBy: string;
}
export interface CreateInvitationCodeInput {
role: string;
maxUses: number;
ttlHours: number;
}
export type CreatedInvitationCode = Pick<
InvitationCode,
"id" | "code" | "role" | "maxUses" | "expiresAt"
>;
// ============================================================
// Types: School
// ============================================================
export interface School {
id: string;
name: string;
address: string;
phone: string;
email: string;
currentAcademicYear: string;
currentTerm: string;
semesterStart: string;
semesterEnd: string;
}
export interface SchoolInput {
name?: string;
address?: string;
phone?: string;
email?: string;
currentAcademicYear?: string;
currentTerm?: string;
semesterStart?: string;
semesterEnd?: string;
}
// ============================================================
// Types: Plugin manager
// ============================================================
export interface RegistryItem {
pluginId: string;
category: string;
version: string;
displayName: string;
description: string;
requiredRoles: string[];
isBuiltin: boolean;
isActive: boolean;
defaultSlot: string;
defaultSize: { colSpan: number; rowSpan: number };
defaultProps: Record<string, unknown>;
propsSchema: Record<string, unknown>;
}
export interface RoleMapping {
role: string;
pluginId: string;
slot: string;
sortOrder: number;
isEnabled: boolean;
widgetProps: Record<string, unknown>;
}
export interface RolePluginMappingInput {
pluginId: string;
slot: string;
sortOrder: number;
isEnabled: boolean;
widgetProps: Record<string, unknown>;
}
export interface LayoutTemplate {
layoutId: string;
displayName: string;
description: string;
availableSlots: string[];
}
export interface RoleLayoutDefault {
role: string;
layoutId: string;
slotOverrides: Record<string, unknown>;
}
export interface PluginRegistryInput {
isActive?: boolean;
defaultProps?: Record<string, unknown>;
}
export interface UpdatedPluginRegistry {
pluginId: string;
isActive: boolean;
defaultProps: Record<string, unknown>;
}
export interface UpdatedRoleMapping {
role: string;
pluginId: string;
isEnabled: boolean;
}
// ============================================================
// Hooks: User management
// ============================================================
export function useUsers(
vars: UserQueryVars,
): UseQueryResult<PaginatedResult<User>> {
const result = useWidgetQuery<
{ users: PaginatedResult<User> },
UserQueryVars
>(GET_USERS_DOC, vars);
return {
...result,
data: result.data?.users,
};
}
export function useUpdateUserStatus(): {
run: (id: string, status: string) => Promise<{ id: string; status: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
{ updateUserStatus: { id: string; status: string } },
{ id: string; status: string }
>(UPDATE_USER_STATUS_DOC);
const run = async (
id: string,
status: string,
): Promise<{ id: string; status: string }> => {
const data = await rawRun({ id, status });
if (!data?.updateUserStatus) {
throw new ApiError("Failed to update user status", "INTERNAL_ERROR");
}
return data.updateUserStatus;
};
return { run, loading, error };
}
export function useUpdateUserRole(): {
run: (id: string, role: string) => Promise<{ id: string; role: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
{ updateUserRole: { id: string; role: string } },
{ id: string; role: string }
>(UPDATE_USER_ROLE_DOC);
const run = async (
id: string,
role: string,
): Promise<{ id: string; role: string }> => {
const data = await rawRun({ id, role });
if (!data?.updateUserRole) {
throw new ApiError("Failed to update user role", "INTERNAL_ERROR");
}
return data.updateUserRole;
};
return { run, loading, error };
}
// ============================================================
// Hooks: RBAC
// ============================================================
export function useRoles(): UseQueryResult<Role[]> {
const result = useWidgetQuery<{ roles: Role[] }, Record<string, never>>(
GET_ROLES_DOC,
{},
);
return {
...result,
data: result.data?.roles,
};
}
export function usePermissions(): UseQueryResult<Permission[]> {
const result = useWidgetQuery<
{ permissions: Permission[] },
Record<string, never>
>(GET_PERMISSIONS_DOC, {});
return {
...result,
data: result.data?.permissions,
};
}
export function useUpdateRolePermissions(): {
run: (roleId: string, permissionIds: string[]) => Promise<{ id: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
{ updateRolePermissions: { id: string } },
{ roleId: string; permissionIds: string[] }
>(UPDATE_ROLE_PERMISSIONS_DOC);
const run = async (
roleId: string,
permissionIds: string[],
): Promise<{ id: string }> => {
const data = await rawRun({ roleId, permissionIds });
if (!data?.updateRolePermissions) {
throw new ApiError("Failed to update role permissions", "INTERNAL_ERROR");
}
return data.updateRolePermissions;
};
return { run, loading, error };
}
// ============================================================
// Hooks: Audit logs
// ============================================================
export function useAuditLogs(
filter: AuditLogFilter,
pagination: Pagination,
): UseQueryResult<PaginatedResult<AuditLog>> {
const result = useWidgetQuery<
{ auditLogs: PaginatedResult<AuditLog> },
{ filter: AuditLogFilter; limit: number; offset: number }
>(GET_AUDIT_LOGS_DOC, {
filter,
limit: pagination.limit,
offset: pagination.offset,
});
return {
...result,
data: result.data?.auditLogs,
};
}
// ============================================================
// Hooks: Invitation codes
// ============================================================
export function useInvitationCodes(
status: string | null,
): UseQueryResult<InvitationCode[]> {
const result = useWidgetQuery<
{ invitationCodes: InvitationCode[] },
{ status: string | null }
>(GET_INVITATION_CODES_DOC, { status });
return {
...result,
data: result.data?.invitationCodes,
};
}
export function useCreateInvitationCode(): {
run: (input: CreateInvitationCodeInput) => Promise<CreatedInvitationCode>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
{ createInvitationCode: CreatedInvitationCode },
{ input: CreateInvitationCodeInput }
>(CREATE_INVITATION_CODE_DOC);
const run = async (
input: CreateInvitationCodeInput,
): Promise<CreatedInvitationCode> => {
const data = await rawRun({ input });
if (!data?.createInvitationCode) {
throw new ApiError("Failed to create invitation code", "INTERNAL_ERROR");
}
return data.createInvitationCode;
};
return { run, loading, error };
}
export function useRevokeInvitationCode(): {
run: (id: string) => Promise<{ id: string; status: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
{ revokeInvitationCode: { id: string; status: string } },
{ id: string }
>(REVOKE_INVITATION_CODE_DOC);
const run = async (id: string): Promise<{ id: string; status: string }> => {
const data = await rawRun({ id });
if (!data?.revokeInvitationCode) {
throw new ApiError("Failed to revoke invitation code", "INTERNAL_ERROR");
}
return data.revokeInvitationCode;
};
return { run, loading, error };
}
// ============================================================
// Hooks: School settings
// ============================================================
export function useSchool(): UseQueryResult<School | null> {
const result = useWidgetQuery<
{ school: School | null },
Record<string, never>
>(GET_SCHOOL_DOC, {});
return {
...result,
data: result.data?.school ?? null,
};
}
export function useUpdateSchool(): {
run: (input: SchoolInput) => Promise<School>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<{ updateSchool: School }, { input: SchoolInput }>(
UPDATE_SCHOOL_DOC,
);
const run = async (input: SchoolInput): Promise<School> => {
const data = await rawRun({ input });
if (!data?.updateSchool) {
throw new ApiError("Failed to update school", "INTERNAL_ERROR");
}
return data.updateSchool;
};
return { run, loading, error };
}
// ============================================================
// Hooks: Plugin manager
// ============================================================
export function usePluginRegistry(): UseQueryResult<RegistryItem[]> {
const result = useWidgetQuery<
{ pluginRegistry: RegistryItem[] },
Record<string, never>
>(GET_PLUGIN_REGISTRY_DOC, {});
return {
...result,
data: result.data?.pluginRegistry,
};
}
export function useRolePluginMapping(
role: string,
): UseQueryResult<RoleMapping[]> {
const result = useWidgetQuery<
{ rolePluginMapping: RoleMapping[] },
{ role: string | null }
>(GET_ROLE_PLUGIN_MAPPING_DOC, { role });
return {
...result,
data: result.data?.rolePluginMapping,
};
}
export function useLayoutTemplates(): UseQueryResult<LayoutTemplate[]> {
const result = useWidgetQuery<
{ layoutTemplates: LayoutTemplate[] },
Record<string, never>
>(GET_LAYOUT_TEMPLATES_DOC, {});
return {
...result,
data: result.data?.layoutTemplates,
};
}
export function useRoleLayoutDefault(
role: string,
): UseQueryResult<RoleLayoutDefault | null> {
const result = useWidgetQuery<
{ roleLayoutDefault: RoleLayoutDefault | null },
{ role: string | null }
>(GET_ROLE_LAYOUT_DEFAULT_DOC, { role });
return {
...result,
data: result.data?.roleLayoutDefault ?? null,
};
}
export function useUpdatePluginRegistry(): {
run: (
pluginId: string,
input: PluginRegistryInput,
) => Promise<UpdatedPluginRegistry>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
{ updatePluginRegistry: UpdatedPluginRegistry },
{ pluginId: string; input: PluginRegistryInput }
>(UPDATE_PLUGIN_REGISTRY_DOC);
const run = async (
pluginId: string,
input: PluginRegistryInput,
): Promise<UpdatedPluginRegistry> => {
const data = await rawRun({ pluginId, input });
if (!data?.updatePluginRegistry) {
throw new ApiError("Failed to update plugin registry", "INTERNAL_ERROR");
}
return data.updatePluginRegistry;
};
return { run, loading, error };
}
export function useUpdateRolePluginMapping(): {
run: (
role: string,
mappings: RolePluginMappingInput[],
) => Promise<UpdatedRoleMapping[]>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
{ updateRolePluginMapping: UpdatedRoleMapping[] },
{ role: string; mappings: RolePluginMappingInput[] }
>(UPDATE_ROLE_PLUGIN_MAPPING_DOC);
const run = async (
role: string,
mappings: RolePluginMappingInput[],
): Promise<UpdatedRoleMapping[]> => {
const data = await rawRun({ role, mappings });
if (!data?.updateRolePluginMapping) {
throw new ApiError(
"Failed to update role plugin mapping",
"INTERNAL_ERROR",
);
}
return data.updateRolePluginMapping;
};
return { run, loading, error };
}
export function useUpdateRoleLayoutDefault(): {
run: (
role: string,
layoutId: string,
) => Promise<{ role: string; layoutId: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
{ updateRoleLayoutDefault: { role: string; layoutId: string } },
{ role: string; layoutId: string }
>(UPDATE_ROLE_LAYOUT_DEFAULT_DOC);
const run = async (
role: string,
layoutId: string,
): Promise<{ role: string; layoutId: string }> => {
const data = await rawRun({ role, layoutId });
if (!data?.updateRoleLayoutDefault) {
throw new ApiError(
"Failed to update role layout default",
"INTERNAL_ERROR",
);
}
return data.updateRoleLayoutDefault;
};
return { run, loading, error };
}
export function useResetUserLayoutOverride(): {
run: (userId: string) => Promise<{ userId: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
{ resetUserLayoutOverride: { userId: string } },
{ userId: string }
>(RESET_USER_LAYOUT_OVERRIDE_DOC);
const run = async (userId: string): Promise<{ userId: string }> => {
const data = await rawRun({ userId });
if (!data?.resetUserLayoutOverride) {
throw new ApiError(
"Failed to reset user layout override",
"INTERNAL_ERROR",
);
}
return data.resetUserLayoutOverride;
};
return { run, loading, error };
}