feat: implement admin panel functionality in Mini App

- Added new API endpoints for admin features: `GET /api/admin/me`, `GET /api/admin/users`, and `PATCH /api/admin/duties/:id` to manage user duties.
- Introduced `UserForAdmin` and `AdminDutyReassignBody` schemas for handling admin-related data.
- Updated documentation to include Mini App design guidelines and admin panel functionalities.
- Enhanced tests for admin API to ensure proper access control and functionality.
- Improved error handling and localization for admin actions.
This commit is contained in:
2026-03-06 09:57:26 +03:00
parent 68b1884b73
commit c390a4dd6e
28 changed files with 2045 additions and 15 deletions

View File

@@ -4,7 +4,14 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { fetchDuties, fetchCalendarEvents, AccessDeniedError } from "./api";
import {
fetchDuties,
fetchCalendarEvents,
fetchAdminMe,
fetchAdminUsers,
patchAdminDuty,
AccessDeniedError,
} from "./api";
describe("fetchDuties", () => {
const originalFetch = globalThis.fetch;
@@ -174,3 +181,160 @@ describe("fetchCalendarEvents", () => {
).rejects.toMatchObject({ name: "AbortError" });
});
});
describe("fetchAdminMe", () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ is_admin: true }),
} as Response);
});
afterEach(() => {
vi.stubGlobal("fetch", originalFetch);
});
it("returns is_admin true on 200", async () => {
const result = await fetchAdminMe("init-data", "en");
expect(result).toEqual({ is_admin: true });
});
it("returns is_admin false on 403", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: false,
status: 403,
} as Response);
const result = await fetchAdminMe("init-data", "en");
expect(result).toEqual({ is_admin: false });
});
it("returns is_admin false on non-200 and non-403", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: false,
status: 500,
} as Response);
const result = await fetchAdminMe("init-data", "en");
expect(result).toEqual({ is_admin: false });
});
it("returns is_admin false when response is not boolean", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ is_admin: "yes" }),
} as Response);
const result = await fetchAdminMe("init-data", "en");
expect(result).toEqual({ is_admin: false });
});
});
describe("fetchAdminUsers", () => {
const originalFetch = globalThis.fetch;
const validUsers = [
{ id: 1, full_name: "Alice", username: "alice", role_id: 1 },
{ id: 2, full_name: "Bob", username: null, role_id: 2 },
];
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve(validUsers),
} as Response);
});
afterEach(() => {
vi.stubGlobal("fetch", originalFetch);
});
it("returns users array on 200", async () => {
const result = await fetchAdminUsers("init-data", "en");
expect(result).toEqual(validUsers);
});
it("throws AccessDeniedError on 403", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: false,
status: 403,
json: () => Promise.resolve({ detail: "Admin only" }),
} as Response);
await expect(fetchAdminUsers("init-data", "en")).rejects.toThrow(AccessDeniedError);
await expect(fetchAdminUsers("init-data", "en")).rejects.toMatchObject({
message: "ACCESS_DENIED",
serverDetail: "Admin only",
});
});
it("filters invalid items from response", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
json: () =>
Promise.resolve([
validUsers[0],
{ id: 2, full_name: "Bob", username: 123, role_id: 2 },
{ id: 3 },
]),
} as Response);
const result = await fetchAdminUsers("init-data", "en");
expect(result).toEqual([validUsers[0]]);
});
});
describe("patchAdminDuty", () => {
const originalFetch = globalThis.fetch;
const updatedDuty = {
id: 5,
user_id: 2,
start_at: "2025-03-01T09:00:00Z",
end_at: "2025-03-01T18:00:00Z",
};
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve(updatedDuty),
} as Response);
});
afterEach(() => {
vi.stubGlobal("fetch", originalFetch);
});
it("sends PATCH with user_id and returns updated duty", async () => {
const result = await patchAdminDuty(5, 2, "init-data", "en");
expect(result).toEqual(updatedDuty);
const fetchCall = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(fetchCall[1]?.method).toBe("PATCH");
expect(fetchCall[1]?.body).toBe(JSON.stringify({ user_id: 2 }));
});
it("throws AccessDeniedError on 403", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: false,
status: 403,
json: () => Promise.resolve({ detail: "Admin only" }),
} as Response);
await expect(
patchAdminDuty(5, 2, "init-data", "en")
).rejects.toThrow(AccessDeniedError);
});
it("throws with server detail on 400", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: false,
status: 400,
json: () => Promise.resolve({ detail: "User not found" }),
} as Response);
await expect(
patchAdminDuty(5, 999, "init-data", "en")
).rejects.toThrow("User not found");
});
});

View File

@@ -11,6 +11,22 @@ import { translate } from "@/i18n/messages";
type ApiLang = "ru" | "en";
/** User summary for admin dropdown (GET /api/admin/users). */
export interface UserForAdmin {
id: number;
full_name: string;
username: string | null;
role_id: number | null;
}
/** Minimal duty shape returned by PATCH /api/admin/duties/:id. */
export interface DutyReassignResponse {
id: number;
user_id: number;
start_at: string;
end_at: string;
}
/** Minimal runtime check for a single duty item (required fields). */
function isDutyWithUser(x: unknown): x is DutyWithUser {
if (!x || typeof x !== "object") return false;
@@ -207,3 +223,153 @@ export async function fetchCalendarEvents(
opts.cleanup();
}
}
/**
* Fetch admin status for the current user (GET /api/admin/me).
* Returns { is_admin: true } or { is_admin: false }. On 403, treat as not admin.
*/
export async function fetchAdminMe(
initData: string,
acceptLang: ApiLang
): Promise<{ is_admin: boolean }> {
const base =
typeof window !== "undefined" ? window.location.origin : "";
const url = `${base}/api/admin/me`;
const opts = buildFetchOptions(initData, acceptLang);
try {
logger.debug("API request", "/api/admin/me");
const res = await fetch(url, { headers: opts.headers, signal: opts.signal });
if (res.status === 403) {
return { is_admin: false };
}
if (!res.ok) {
return { is_admin: false };
}
const data = (await res.json()) as { is_admin?: boolean };
return {
is_admin: typeof data?.is_admin === "boolean" ? data.is_admin : false,
};
} catch (e) {
logger.warn("API request failed", "/api/admin/me", e);
return { is_admin: false };
} finally {
opts.cleanup();
}
}
/**
* Fetch users for admin dropdown (GET /api/admin/users). Admin only; throws AccessDeniedError on 403.
*/
export async function fetchAdminUsers(
initData: string,
acceptLang: ApiLang,
signal?: AbortSignal | null
): Promise<UserForAdmin[]> {
const base =
typeof window !== "undefined" ? window.location.origin : "";
const url = `${base}/api/admin/users`;
const opts = buildFetchOptions(initData, acceptLang, signal);
try {
logger.debug("API request", "/api/admin/users");
const res = await fetch(url, { headers: opts.headers, signal: opts.signal });
if (res.status === 403) {
let detail = translate(acceptLang, "admin.access_denied");
try {
const body = await res.json();
if (body && (body as { detail?: string }).detail !== undefined) {
const d = (body as { detail: string | { msg?: string } }).detail;
detail =
typeof d === "string" ? d : (d.msg ?? JSON.stringify(d));
}
} catch {
/* ignore */
}
throw new AccessDeniedError(API_ACCESS_DENIED, detail);
}
if (!res.ok) {
throw new Error(translate(acceptLang, "error_load_failed"));
}
const data = await res.json();
if (!Array.isArray(data)) return [];
return data.filter(
(x: unknown): x is UserForAdmin =>
x != null &&
typeof (x as UserForAdmin).id === "number" &&
typeof (x as UserForAdmin).full_name === "string" &&
((x as UserForAdmin).username === null ||
typeof (x as UserForAdmin).username === "string") &&
((x as UserForAdmin).role_id === null ||
typeof (x as UserForAdmin).role_id === "number")
);
} catch (e) {
if ((e as Error).name === "AbortError" || e instanceof AccessDeniedError) {
throw e;
}
logger.error("API request failed", "/api/admin/users", e);
throw e;
} finally {
opts.cleanup();
}
}
/**
* Reassign a duty to another user (PATCH /api/admin/duties/:id). Admin only.
* Returns updated duty (id, user_id, start_at, end_at). Throws on 403/404/400.
*/
export async function patchAdminDuty(
dutyId: number,
userId: number,
initData: string,
acceptLang: ApiLang
): Promise<DutyReassignResponse> {
const base =
typeof window !== "undefined" ? window.location.origin : "";
const url = `${base}/api/admin/duties/${dutyId}`;
const opts = buildFetchOptions(initData, acceptLang);
try {
logger.debug("API request", "PATCH", url, { user_id: userId });
const res = await fetch(url, {
method: "PATCH",
headers: {
...opts.headers,
"Content-Type": "application/json",
},
body: JSON.stringify({ user_id: userId }),
signal: opts.signal,
});
if (res.status === 403) {
let detail = translate(acceptLang, "admin.access_denied");
try {
const body = await res.json();
if (body && (body as { detail?: string }).detail !== undefined) {
const d = (body as { detail: string | { msg?: string } }).detail;
detail =
typeof d === "string" ? d : (d.msg ?? JSON.stringify(d));
}
} catch {
/* ignore */
}
throw new AccessDeniedError(API_ACCESS_DENIED, detail);
}
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const msg =
data && typeof (data as { detail?: string }).detail === "string"
? (data as { detail: string }).detail
: translate(acceptLang, "error_generic");
throw new Error(msg);
}
const out = data as DutyReassignResponse;
if (
typeof out?.id !== "number" ||
typeof out?.user_id !== "number" ||
typeof out?.start_at !== "string" ||
typeof out?.end_at !== "string"
) {
throw new Error(translate(acceptLang, "error_load_failed"));
}
return out;
} finally {
opts.cleanup();
}
}