refactor: improve type safety by replacing any types with specific interfaces across API services and components.

This commit is contained in:
taDuc
2026-05-14 21:54:44 +07:00
parent dca3ca67ad
commit b220798978
20 changed files with 249 additions and 71 deletions
+4 -2
View File
@@ -497,9 +497,11 @@ export default function WikiBySlugClient({ slug }: { slug: string }) {
createdAt: sample?.created_at || 'Unknown date', createdAt: sample?.created_at || 'Unknown date',
title: `Phiên bản lúc ${formatDate(sample?.created_at)}` title: `Phiên bản lúc ${formatDate(sample?.created_at)}`
}; };
if (sample?.isCurrent) { if (sample && "content" in sample && (sample as any).isCurrent) {
return { ...versionInfo, content: sample.content || '' }; return { ...versionInfo, content: (sample as any).content || "" };
} }
const contentResp = await getContentByVersionWikiId(versionId); const contentResp = await getContentByVersionWikiId(versionId);
return { ...versionInfo, content: contentResp?.data?.content || "" }; return { ...versionInfo, content: contentResp?.data?.content || "" };
}); });
+10 -1
View File
@@ -73,7 +73,16 @@ const Calendar: React.FC = () => {
const handleEventClick = (clickInfo: EventClickArg) => { const handleEventClick = (clickInfo: EventClickArg) => {
const event = clickInfo.event; const event = clickInfo.event;
setSelectedEvent(event as unknown as CalendarEvent); setSelectedEvent({
id: event.id,
title: event.title,
start: event.startStr,
end: event.endStr,
extendedProps: {
calendar: event.extendedProps.calendar,
},
} as CalendarEvent);
setEventTitle(event.title); setEventTitle(event.title);
setEventStartDate(event.start?.toISOString().split("T")[0] || ""); setEventStartDate(event.start?.toISOString().split("T")[0] || "");
setEventEndDate(event.end?.toISOString().split("T")[0] || ""); setEventEndDate(event.end?.toISOString().split("T")[0] || "");
+2 -1
View File
@@ -7,9 +7,10 @@ import "react-quill-new/dist/quill.snow.css";
const ReactQuillEditor = dynamic( const ReactQuillEditor = dynamic(
async () => { async () => {
const { default: RQ } = await import("react-quill-new"); const { default: RQ } = await import("react-quill-new");
return ({ forwardedRef, ...props }: any) => ( return ({ forwardedRef, ...props }: { forwardedRef: React.Ref<any>; [key: string]: any }) => (
<RQ ref={forwardedRef} {...props} /> <RQ ref={forwardedRef} {...props} />
); );
}, },
{ {
ssr: false, ssr: false,
@@ -15,10 +15,13 @@ import { IsolatedContent } from "@/components/ui/IsolatedContent";
import { apiDeleteHistorianCV } from "@/service/historianService"; import { apiDeleteHistorianCV } from "@/service/historianService";
import { statusConfig } from "@/service/handler"; import { statusConfig } from "@/service/handler";
import { Application } from "@/interface/historian";
import { MediaItem } from "@/components/tables/MediaTable";
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
application: any; application: Application | null;
onRefresh: () => void; onRefresh: () => void;
} }
@@ -42,7 +45,7 @@ export default function ApplicationDetailModal({
} }
}, [isOpen, application]); }, [isOpen, application]);
const isImageFile = (file: any) => { const isImageFile = (file: MediaItem) => {
const isImageMime = file.mime_type?.startsWith("image/"); const isImageMime = file.mime_type?.startsWith("image/");
const isImageExt = /\.(jpg|jpeg|png|webp|gif)$/i.test(file.storage_key); const isImageExt = /\.(jpg|jpeg|png|webp|gif)$/i.test(file.storage_key);
return isImageMime || isImageExt; return isImageMime || isImageExt;
@@ -51,17 +54,17 @@ export default function ApplicationDetailModal({
const mediaList = application?.media || []; const mediaList = application?.media || [];
const imageMediaOnly = mediaList.filter(isImageFile); const imageMediaOnly = mediaList.filter(isImageFile);
const imageSlides = imageMediaOnly.map((item: any) => ({ const imageSlides = imageMediaOnly.map((item: MediaItem) => ({
src: `${URL_MEDIA}${item.storage_key}`, src: `${URL_MEDIA}${item.storage_key}`,
title: item.original_name, title: item.original_name,
description: `Dung lượng: ${(item.size / 1024).toFixed(2)} KB`, description: `Dung lượng: ${(item.size / 1024).toFixed(2)} KB`,
})); }));
const handleMediaClick = (item: any) => { const handleMediaClick = (item: MediaItem) => {
const fileUrl = `${URL_MEDIA}${item.storage_key}`; const fileUrl = `${URL_MEDIA}${item.storage_key}`;
if (isImageFile(item)) { if (isImageFile(item)) {
const photoIndex = imageMediaOnly.findIndex( const photoIndex = imageMediaOnly.findIndex(
(img: any) => img.id === item.id, (img: MediaItem) => img.id === item.id,
); );
setIndex(photoIndex); setIndex(photoIndex);
} else { } else {
@@ -93,7 +96,9 @@ export default function ApplicationDetailModal({
}; };
const handleDeleteApplication = async () => { const handleDeleteApplication = async () => {
if (!application) return;
await apiDeleteHistorianCV(application.id); await apiDeleteHistorianCV(application.id);
Swal.fire("Thành công!", "Hồ sơ đã được xóa.", "success"); Swal.fire("Thành công!", "Hồ sơ đã được xóa.", "success");
onRefresh(); onRefresh();
onClose(); onClose();
@@ -186,7 +191,7 @@ export default function ApplicationDetailModal({
</h4> </h4>
{mediaList.length > 0 ? ( {mediaList.length > 0 ? (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4"> <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
{mediaList.map((media: any, idx: number) => { {mediaList.map((media: MediaItem, idx: number) => {
const isImg = isImageFile(media); const isImg = isImageFile(media);
return ( return (
<div <div
+2 -1
View File
@@ -23,7 +23,8 @@ export interface MediaItem {
original_name: string; original_name: string;
mime_type: string; mime_type: string;
size: number; size: number;
file_metadata: any; file_metadata: Record<string, unknown> | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
+16 -3
View File
@@ -2,6 +2,8 @@
import { Modal } from "../ui/modal"; import { Modal } from "../ui/modal";
import UserMetaCard from "@/components/user-profile/UserMetaCard"; import UserMetaCard from "@/components/user-profile/UserMetaCard";
import UserInfoCard from "@/components/user-profile/UserInfoCard"; import UserInfoCard from "@/components/user-profile/UserInfoCard";
import { UserMetaCardProps } from "@/interface/user";
import { fullDataUser } from "@/interface/admin"; import { fullDataUser } from "@/interface/admin";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { MediaDto } from "@/interface/media"; import { MediaDto } from "@/interface/media";
@@ -28,7 +30,17 @@ export default function UserDetailModal({
const [mediaData, setMediaData] = useState<MediaDto | null>(null); const [mediaData, setMediaData] = useState<MediaDto | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const formattedData = { data: user }; const formattedData: UserMetaCardProps = {
data: user
? {
id: user.id,
email: user.email,
profile: user.profile,
roles: user.roles as any, // UserRole and Role are slightly different, need a better fix or small cast
}
: undefined,
};
useEffect(() => { useEffect(() => {
if (user?.id && isOpen) { if (user?.id && isOpen) {
@@ -68,8 +80,9 @@ export default function UserDetailModal({
</div> </div>
<div className="space-y-6 custom-scrollbar max-h-[65vh] overflow-y-auto pr-2"> <div className="space-y-6 custom-scrollbar max-h-[65vh] overflow-y-auto pr-2">
<UserMetaCard data={formattedData as any} /> <UserMetaCard data={formattedData} />
<UserInfoCard data={formattedData as any} /> <UserInfoCard data={formattedData} />
<div className="min-h-[150px] relative"> <div className="min-h-[150px] relative">
{loading ? ( {loading ? (
+6 -2
View File
@@ -3,6 +3,8 @@
import React, { useState, useRef, useEffect } from "react"; import React, { useState, useRef, useEffect } from "react";
import { ChatbotPayload } from "@/interface/chatbot"; import { ChatbotPayload } from "@/interface/chatbot";
import { apiChatbot } from "@/service/chatbotService"; import { apiChatbot } from "@/service/chatbotService";
import { AxiosError } from "axios";
type Message = { type Message = {
id: string; id: string;
@@ -67,16 +69,18 @@ export default function ChatbotWidget({
}; };
setMessages((prev) => [...prev, botMessage]); setMessages((prev) => [...prev, botMessage]);
} catch (error: any) { } catch (error) {
const axiosError = error as AxiosError<{ message: string }>;
const errorMessage: Message = { const errorMessage: Message = {
id: (Date.now() + 1).toString(), id: (Date.now() + 1).toString(),
sender: "bot", sender: "bot",
text: text:
error?.response?.data?.message || axiosError.response?.data?.message ||
"Có lỗi xảy ra khi kết nối. Vui lòng thử lại sau.", "Có lỗi xảy ra khi kết nối. Vui lòng thử lại sau.",
}; };
setMessages((prev) => [...prev, errorMessage]); setMessages((prev) => [...prev, errorMessage]);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
@@ -20,7 +20,10 @@ const formatFullDateTime = (dateString: string) => {
return `${time} ${day}`; return `${time} ${day}`;
}; };
const processMedia = (mediaArray: any[]) => { import { Application } from "@/interface/historian";
import { MediaItem } from "../tables/MediaTable";
const processMedia = (mediaArray: MediaItem[]) => {
if (!mediaArray || mediaArray.length === 0) return { type: "empty" }; if (!mediaArray || mediaArray.length === 0) return { type: "empty" };
const imageFiles = mediaArray.filter((file) => { const imageFiles = mediaArray.filter((file) => {
const isImageMime = file.mime_type?.startsWith("image/"); const isImageMime = file.mime_type?.startsWith("image/");
@@ -49,16 +52,17 @@ const processMedia = (mediaArray: any[]) => {
export default function ApplicationList({ export default function ApplicationList({
applications, applications,
}: { }: {
applications: any[]; applications: Application[];
}) { }) {
const router = useRouter(); const router = useRouter();
const dispatch = useDispatch(); const dispatch = useDispatch();
const handleViewDetail = (app: any) => { const handleViewDetail = (app: Application) => {
dispatch(setSelectedApplication(app)); dispatch(setSelectedApplication(app));
router.push(`/user/account/applications`); router.push(`/user/account/applications`);
}; };
const StatusIcons: Record<string, React.ReactNode> = { const StatusIcons: Record<string, React.ReactNode> = {
APPROVED: ( APPROVED: (
<svg <svg
+6 -3
View File
@@ -11,6 +11,8 @@ import { URL_MEDIA } from "../../../api";
import { deleteMedia } from "@/service/mediaService"; import { deleteMedia } from "@/service/mediaService";
import { INITIAL_LIMIT } from "../../../constant"; import { INITIAL_LIMIT } from "../../../constant";
import { MediaItem } from "../tables/MediaTable";
export default function MediaLibrary({ export default function MediaLibrary({
data, data,
@@ -32,7 +34,7 @@ export default function MediaLibrary({
setLocalMedia(data?.data || []); setLocalMedia(data?.data || []);
}, [data]); }, [data]);
const isImageFile = (file: any) => { const isImageFile = (file: MediaItem) => {
const isImageMime = file.mime_type?.startsWith("image/"); const isImageMime = file.mime_type?.startsWith("image/");
const isImageExt = /\.(jpg|jpeg|png|webp|gif)$/i.test(file.storage_key); const isImageExt = /\.(jpg|jpeg|png|webp|gif)$/i.test(file.storage_key);
return isImageMime || isImageExt; return isImageMime || isImageExt;
@@ -77,7 +79,7 @@ export default function MediaLibrary({
} }
}; };
const handleItemClick = (item: any, idx: number, isImage: boolean) => { const handleItemClick = (item: MediaItem, idx: number, isImage: boolean) => {
if (isSelectionMode) { if (isSelectionMode) {
toggleItemSelection(item.id); toggleItemSelection(item.id);
} else { } else {
@@ -166,7 +168,7 @@ export default function MediaLibrary({
} }
}; };
const renderItemCard = (item: any, isImage: boolean, idx: number) => { const renderItemCard = (item: MediaItem, isImage: boolean, idx: number) => {
const isSelected = selectedIds.includes(item.id); const isSelected = selectedIds.includes(item.id);
return ( return (
@@ -254,6 +256,7 @@ export default function MediaLibrary({
); );
}; };
return ( return (
<div className="rounded-2xl border border-gray-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-900/50"> <div className="rounded-2xl border border-gray-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-900/50">
<div className="mb-6 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center"> <div className="mb-6 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
+7 -3
View File
@@ -10,6 +10,8 @@ import { Profile, UserMetaCardProps } from "@/interface/user";
import { apiUpdateUser } from "@/service/userService"; import { apiUpdateUser } from "@/service/userService";
import { toast } from "sonner"; import { toast } from "sonner";
import Link from "next/link"; import Link from "next/link";
import { AxiosError } from "axios";
export default function UserInfoCard({ data }: { data: UserMetaCardProps }) { export default function UserInfoCard({ data }: { data: UserMetaCardProps }) {
const router = useRouter(); const router = useRouter();
@@ -62,8 +64,9 @@ export default function UserInfoCard({ data }: { data: UserMetaCardProps }) {
setTimeout(() => { setTimeout(() => {
window.location.reload(); window.location.reload();
}, 1000); }, 1000);
} catch (error: any) { } catch (error) {
const serverResponse = error.response?.data; const axiosError = error as AxiosError<{ status: boolean; message: string }>;
const serverResponse = axiosError.response?.data;
if (serverResponse && serverResponse.status === false) { if (serverResponse && serverResponse.status === false) {
const msg = serverResponse.message || ""; const msg = serverResponse.message || "";
@@ -77,8 +80,9 @@ export default function UserInfoCard({ data }: { data: UserMetaCardProps }) {
toast.error("Không thể kết nối đến máy chủ hoặc lỗi hệ thống."); toast.error("Không thể kết nối đến máy chủ hoặc lỗi hệ thống.");
} }
console.error("Lỗi chi tiết:", error); console.error("Lỗi chi tiết:", axiosError);
} }
}; };
return ( return (
+13 -2
View File
@@ -9,7 +9,17 @@ export const apiGetListUser = async (payload: getUserDto) => {
return response?.data; return response?.data;
}; };
export const apiChangeRole = async (id: string, payload: any) => { export interface ChangeRolePayload {
role_ids: string[];
user_id: string;
}
export interface UpdateApplicationStatusPayload {
status: "APPROVED" | "REJECTED";
review_note: string;
}
export const apiChangeRole = async (id: string, payload: ChangeRolePayload) => {
const response = await api.patch(API.Admin.CHANGE_ROLE(id), payload); const response = await api.patch(API.Admin.CHANGE_ROLE(id), payload);
return response?.data; return response?.data;
}; };
@@ -32,11 +42,12 @@ export const apiGetUserMedia = async (id: string) => {
return response?.data; return response?.data;
}; };
export const apiUpdateApplicationStatus = async (id: string, payload: any) => { export const apiUpdateApplicationStatus = async (id: string, payload: UpdateApplicationStatusPayload) => {
const response = await api.put(API.Admin.UPDATE_APPLICATION_STATUS(id), payload); const response = await api.put(API.Admin.UPDATE_APPLICATION_STATUS(id), payload);
return response?.data; return response?.data;
}; };
export const apiGetUserById = async (userId: string) => { export const apiGetUserById = async (userId: string) => {
const response = await api.get(API.Admin.GET_USER_BY_ID(userId)); const response = await api.get(API.Admin.GET_USER_BY_ID(userId));
return response?.data; return response?.data;
+28 -4
View File
@@ -2,6 +2,29 @@ import api from "@/config/config";
import { API } from "../../api"; import { API } from "../../api";
import { clearStoredTokens, extractTokensFromResponsePayload, setStoredTokens } from "@/auth/tokenStore"; import { clearStoredTokens, extractTokensFromResponsePayload, setStoredTokens } from "@/auth/tokenStore";
export interface SignUpPayload {
display_name: string;
email: string;
password: string;
token_id: string;
}
export interface SignInPayload {
email: string;
password: string;
}
export interface ResetPasswordPayload {
email: string;
new_password: string;
token_id: string;
}
export interface ChangePasswordPayload {
old_password: string;
new_password: string;
}
export const apiCreateOTP = async (email: string, token_type: number = 2) => { export const apiCreateOTP = async (email: string, token_type: number = 2) => {
const response = await api.post(API.Auth.CREATEOTP, { const response = await api.post(API.Auth.CREATEOTP, {
email, email,
@@ -16,7 +39,7 @@ export const apiVerifyOTP = async (email: string, token: string, token_type: num
return response.data; return response.data;
}; };
export const apiSignUp = async (payload: any) => { export const apiSignUp = async (payload: SignUpPayload) => {
const response = await api.post(API.Auth.SIGNUP, payload); const response = await api.post(API.Auth.SIGNUP, payload);
return response.data; return response.data;
}; };
@@ -27,14 +50,14 @@ export const apiLogout = async () => {
return response.data; return response.data;
}; };
export const apiSignIn = async (payload: any) => { export const apiSignIn = async (payload: SignInPayload) => {
const response = await api.post(API.Auth.SIGNIN, payload); const response = await api.post(API.Auth.SIGNIN, payload);
const tokens = extractTokensFromResponsePayload(response?.data); const tokens = extractTokensFromResponsePayload(response?.data);
if (tokens) setStoredTokens(tokens); if (tokens) setStoredTokens(tokens);
return response.data; return response.data;
}; };
export const apiResetPassword = async (payload: any) => { export const apiResetPassword = async (payload: ResetPasswordPayload) => {
const response = await api.post(API.Auth.FORGOT_PASSWORD, payload); const response = await api.post(API.Auth.FORGOT_PASSWORD, payload);
return response.data; return response.data;
}; };
@@ -44,7 +67,8 @@ export const apiGetCurrentUser = async () => {
return response?.data; return response?.data;
}; };
export const apiChangePassword = async (payload: any) => { export const apiChangePassword = async (payload: ChangePasswordPayload) => {
const response = await api.patch(API.User.CHANGE_PASSWORD, payload); const response = await api.patch(API.User.CHANGE_PASSWORD, payload);
return response?.data; return response?.data;
}; };
+16 -2
View File
@@ -1,16 +1,30 @@
import api from "@/config/config"; import api from "@/config/config";
import { API } from "../../api"; import { API } from "../../api";
export const createHistorianCV = async (payload: any) => { export interface CreateHistorianCVPayload {
content: string;
media_ids: (string | number)[];
verify_type: string;
}
export interface GetUserApplicationsPayload {
page?: number;
limit?: number;
status?: string;
user_id?: string;
}
export const createHistorianCV = async (payload: CreateHistorianCVPayload) => {
const response = await api.post(API.Historian.CREATE_CV, payload); const response = await api.post(API.Historian.CREATE_CV, payload);
return response?.data; return response?.data;
}; };
export const apiGetUserApplications = async (payload :any) => { export const apiGetUserApplications = async (payload : GetUserApplicationsPayload) => {
const response = await api.get(API.Historian.APPLICATION, { params: payload }); const response = await api.get(API.Historian.APPLICATION, { params: payload });
return response?.data; return response?.data;
}; };
export const apiDeleteHistorianCV = async (id: number | string) => { export const apiDeleteHistorianCV = async (id: number | string) => {
const response = await api.delete(API.Historian.DELETE_CV(id)); const response = await api.delete(API.Historian.DELETE_CV(id));
return response?.data; return response?.data;
+10 -1
View File
@@ -121,7 +121,16 @@ export const deleteMediaById = async (mediaId: string) => {
return response?.data; return response?.data;
} }
export const getMedia = async (payload: any) => { export interface GetMediaPayload {
page?: number;
limit?: number;
search?: string;
sortBy?: string;
sortOrder?: "asc" | "desc";
user_id?: string;
}
export const getMedia = async (payload: GetMediaPayload) => {
const response = await api.get(API.Media.GET_MEDIA, { const response = await api.get(API.Media.GET_MEDIA, {
params: payload, params: payload,
}); });
+6 -4
View File
@@ -9,8 +9,9 @@ export type { GeometriesBBoxQuery } from "@/uhm/types/api";
export type EntityGeometrySearchGeo = { export type EntityGeometrySearchGeo = {
id: string; id: string;
type: string | null; type: string | null;
draw_geometry: unknown; draw_geometry: Geometry;
binding?: unknown; binding?: string[];
time_start?: number | null; time_start?: number | null;
time_end?: number | null; time_end?: number | null;
}; };
@@ -106,8 +107,9 @@ export async function searchGeometriesByEntityName(
type GeometryRow = { type GeometryRow = {
id: string; id: string;
geo_type: number; geo_type: number;
draw_geometry: unknown; draw_geometry: Geometry;
binding?: unknown; binding?: string[];
time_start?: number; time_start?: number;
time_end?: number; time_end?: number;
bbox?: { bbox?: {
+5 -4
View File
@@ -4,7 +4,7 @@ import { ApiError, requestJson } from "@/uhm/api/http";
export type Wiki = { export type Wiki = {
id: string; id: string;
project_id?: string; project_id: string;
title?: string; title?: string;
slug?: string | null; slug?: string | null;
content?: string; content?: string;
@@ -12,12 +12,13 @@ export type Wiki = {
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
content_sample?: { content_sample?: {
created_at?: string; id: string;
content?: string; title: string;
id?: string; created_at: string;
}[]; }[];
}; };
export async function searchWikisByTitle(title: string, options?: { limit?: number; cursor?: string; entityId?: string }): Promise<Wiki[]> { export async function searchWikisByTitle(title: string, options?: { limit?: number; cursor?: string; entityId?: string }): Promise<Wiki[]> {
const keyword = title.trim(); const keyword = title.trim();
const params = new URLSearchParams({ title: keyword }); const params = new URLSearchParams({ title: keyword });
@@ -41,7 +41,9 @@ export function persistBackgroundLayerVisibility(visibility: BackgroundLayerVisi
function normalizeBackgroundLayerVisibility(raw: unknown): BackgroundLayerVisibility | null { function normalizeBackgroundLayerVisibility(raw: unknown): BackgroundLayerVisibility | null {
if (!raw || typeof raw !== "object") return null; if (!raw || typeof raw !== "object") return null;
const source = raw as Record<string, unknown>; const source = raw as Partial<Record<string, boolean>>;
const next: BackgroundLayerVisibility = { const next: BackgroundLayerVisibility = {
...DEFAULT_BACKGROUND_LAYER_VISIBILITY, ...DEFAULT_BACKGROUND_LAYER_VISIBILITY,
}; };
+80 -24
View File
@@ -3,7 +3,8 @@ import { normalizeGeoTypeKey, typeKeyToGeoTypeCode } from "@/uhm/lib/map/geo/geo
import type { Change } from "@/uhm/lib/editor/draft/editorTypes"; import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
import type { EntitySnapshot } from "@/uhm/types/entities"; import type { EntitySnapshot } from "@/uhm/types/entities";
import type { EntitySnapshotOperation } from "@/uhm/types/entities"; import type { EntitySnapshotOperation } from "@/uhm/types/entities";
import type { Feature, FeatureCollection, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo"; import type { Feature, FeatureCollection, Geometry, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo";
import type { EditorSnapshot, Project } from "@/uhm/types/projects"; import type { EditorSnapshot, Project } from "@/uhm/types/projects";
import type { WikiSnapshot } from "@/uhm/types/wiki"; import type { WikiSnapshot } from "@/uhm/types/wiki";
import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects"; import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects";
@@ -14,6 +15,56 @@ function isRecord(value: unknown): value is UnknownRecord {
return !!value && typeof value === "object" && !Array.isArray(value); return !!value && typeof value === "object" && !Array.isArray(value);
} }
interface RawEntityRow extends UnknownRecord {
id?: string | number;
operation?: string;
source?: string;
ref?: { id?: string };
name?: string;
description?: string;
status?: number;
}
interface RawGeometryRow extends UnknownRecord {
id?: string | number;
operation?: string;
source?: string;
ref?: { id?: string };
type?: string | number;
geo_type?: string | number;
draw_geometry?: Geometry;
geometry?: Geometry;
binding?: string[];
time_start?: number;
time_end?: number;
}
interface RawWikiRow extends UnknownRecord {
id?: string;
operation?: string;
source?: string;
ref?: { id?: string };
title?: string;
slug?: string;
doc?: string;
updated_at?: string | number;
}
interface RawGeometryEntityRow extends UnknownRecord {
geometry_id?: string | number;
entity_id?: string | number;
operation?: string;
base_links_hash?: string;
}
interface RawEntityWikiRow extends UnknownRecord {
entity_id?: string;
wiki_id?: string;
operation?: string;
is_deleted?: boolean | number;
}
function sanitizeEntitySnapshotOperation(op: unknown): EntitySnapshotOperation { function sanitizeEntitySnapshotOperation(op: unknown): EntitySnapshotOperation {
if (typeof op !== "string") return "reference"; if (typeof op !== "string") return "reference";
const v = op.trim(); const v = op.trim();
@@ -132,10 +183,11 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
? geometryEntityRaw ? geometryEntityRaw
.filter(isRecord) .filter(isRecord)
.map((r) => { .map((r) => {
const geometry_id = getStringId(r.geometry_id); const row = r as RawGeometryEntityRow;
const entity_id = typeof r.entity_id === "string" ? r.entity_id : ""; const geometry_id = getStringId(row.geometry_id);
const entity_id = typeof row.entity_id === "string" ? row.entity_id : "";
return { return {
...(r as unknown as Omit<GeometryEntitySnapshot, "geometry_id" | "entity_id">), ...(row as unknown as Omit<GeometryEntitySnapshot, "geometry_id" | "entity_id">),
geometry_id, geometry_id,
entity_id, entity_id,
}; };
@@ -191,16 +243,17 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
const links = geometryEntity || migratedGeometryEntity || []; const links = geometryEntity || migratedGeometryEntity || [];
const byGeom = new Map<string, string[]>(); const byGeom = new Map<string, string[]>();
for (const row of links) { for (const row of links) {
if ((row as any)?.operation === "delete") continue; if ((row as RawGeometryEntityRow).operation === "delete") continue;
const list = byGeom.get(row.geometry_id) || []; const list = byGeom.get(row.geometry_id) || [];
list.push(row.entity_id); list.push(row.entity_id);
byGeom.set(row.geometry_id, list); byGeom.set(row.geometry_id, list);
} }
const entityNameById = new Map<string, string>(); const entityNameById = new Map<string, string>();
for (const row of entities || []) { for (const r of entities || []) {
const row = r as RawEntityRow;
const id = typeof row?.id === "string" ? row.id : ""; const id = typeof row?.id === "string" ? row.id : "";
if (!id) continue; if (!id) continue;
const name = typeof (row as any)?.name === "string" ? String((row as any).name).trim() : ""; const name = typeof row.name === "string" ? String(row.name).trim() : "";
if (name) entityNameById.set(id, name); if (name) entityNameById.set(id, name);
} }
const geometryById = new Map<string, GeometrySnapshot>(); const geometryById = new Map<string, GeometrySnapshot>();
@@ -327,7 +380,7 @@ export function buildEditorSnapshot(options: {
? cloned.name.trim() ? cloned.name.trim()
: id; : id;
const source: "inline" | "ref" = cloned.source === "inline" ? "inline" : "ref"; const source: "inline" | "ref" = cloned.source === "inline" ? "inline" : "ref";
const opRaw = sanitizeEntitySnapshotOperation((cloned as any).operation); const opRaw = sanitizeEntitySnapshotOperation((cloned as RawEntityRow).operation);
// Editor state should delete objects by removing them from the list. // Editor state should delete objects by removing them from the list.
// Keep this defensive guard to avoid emitting delete markers unexpectedly. // Keep this defensive guard to avoid emitting delete markers unexpectedly.
if (opRaw === "delete") continue; if (opRaw === "delete") continue;
@@ -417,13 +470,14 @@ export function buildEditorSnapshot(options: {
} }
const baselineGeometryEntity = new globalThis.Map<string, string | undefined>(); const baselineGeometryEntity = new globalThis.Map<string, string | undefined>();
for (const row of options.previousSnapshot?.geometry_entity || []) { for (const r of options.previousSnapshot?.geometry_entity || []) {
const row = r as RawGeometryEntityRow;
if (!row) continue; if (!row) continue;
if ((row as any).operation === "delete") continue; if (row.operation === "delete") continue;
const geometry_id = typeof row.geometry_id === "string" || typeof row.geometry_id === "number" ? String(row.geometry_id).trim() : ""; const geometry_id = typeof row.geometry_id === "string" || typeof row.geometry_id === "number" ? String(row.geometry_id).trim() : "";
const entity_id = typeof row.entity_id === "string" || typeof row.entity_id === "number" ? String(row.entity_id).trim() : ""; const entity_id = typeof row.entity_id === "string" || typeof row.entity_id === "number" ? String(row.entity_id).trim() : "";
if (!geometry_id || !entity_id) continue; if (!geometry_id || !entity_id) continue;
baselineGeometryEntity.set(`${geometry_id}::${entity_id}`, (row as any).base_links_hash); baselineGeometryEntity.set(`${geometry_id}::${entity_id}`, row.base_links_hash);
} }
const currentGeometryEntityRows: GeometryEntitySnapshot[] = []; const currentGeometryEntityRows: GeometryEntitySnapshot[] = [];
@@ -474,7 +528,7 @@ export function buildEditorSnapshot(options: {
const previousWikis = new globalThis.Map<string, WikiSnapshot>(); const previousWikis = new globalThis.Map<string, WikiSnapshot>();
for (const item of options.previousSnapshot?.wikis || []) { for (const item of options.previousSnapshot?.wikis || []) {
if (!item || typeof item !== "object") continue; if (!item || typeof item !== "object") continue;
if ((item as any).operation === "delete") continue; if ((item as RawWikiRow).operation === "delete") continue;
const id = (item as WikiSnapshot).id; const id = (item as WikiSnapshot).id;
if (typeof id === "string" && id.length > 0) previousWikis.set(id, item as WikiSnapshot); if (typeof id === "string" && id.length > 0) previousWikis.set(id, item as WikiSnapshot);
} }
@@ -537,24 +591,26 @@ export function buildEditorSnapshot(options: {
for (const prev of previousWikis.values()) { for (const prev of previousWikis.values()) {
if (!prev?.id) continue; if (!prev?.id) continue;
if (currentWikiIds.has(prev.id)) continue; if (currentWikiIds.has(prev.id)) continue;
const row = prev as RawWikiRow;
deletedWikis.push({ deletedWikis.push({
id: prev.id, id: prev.id,
source: prev.source === "inline" ? "inline" : "ref", source: prev.source === "inline" ? "inline" : "ref",
operation: "delete", operation: "delete",
title: typeof prev.title === "string" ? prev.title : "Untitled wiki", title: typeof prev.title === "string" ? prev.title : "Untitled wiki",
slug: (prev as any).slug ?? null, slug: row.slug ?? null,
doc: (prev as any).doc ?? null, doc: row.doc ?? null,
updated_at: (prev as any).updated_at ?? undefined, updated_at: row.updated_at ?? undefined,
} as WikiSnapshot); } as WikiSnapshot);
} }
const wikis = [...wikisCurrent, ...deletedWikis]; const wikis = [...wikisCurrent, ...deletedWikis];
const baselineEntityWiki = new Set<string>(); const baselineEntityWiki = new Set<string>();
for (const row of options.previousSnapshot?.entity_wiki || []) { for (const r of options.previousSnapshot?.entity_wiki || []) {
if (!row || typeof (row as any).entity_id !== "string" || typeof (row as any).wiki_id !== "string") continue; const row = r as RawEntityWikiRow;
if ((row as any).operation === "delete") continue; if (!row || typeof row.entity_id !== "string" || typeof row.wiki_id !== "string") continue;
const entity_id = (row as any).entity_id.trim(); if (row.operation === "delete") continue;
const wiki_id = (row as any).wiki_id.trim(); const entity_id = row.entity_id.trim();
const wiki_id = row.wiki_id.trim();
if (!entity_id || !wiki_id) continue; if (!entity_id || !wiki_id) continue;
baselineEntityWiki.add(`${entity_id}::${wiki_id}`); baselineEntityWiki.add(`${entity_id}::${wiki_id}`);
} }
@@ -591,7 +647,7 @@ export function buildEditorSnapshot(options: {
source: e.source, source: e.source,
operation: e.operation, operation: e.operation,
name: typeof e.name === "string" ? e.name : undefined, name: typeof e.name === "string" ? e.name : undefined,
description: typeof (e as any).description === "string" ? (e as any).description : (e as any).description ?? null, description: typeof (e as RawEntityRow).description === "string" ? (e as RawEntityRow).description : (e as RawEntityRow).description ?? null,
})) }))
.sort((a, b) => String(a.id).localeCompare(String(b.id))), .sort((a, b) => String(a.id).localeCompare(String(b.id))),
geometries: geometries.slice().sort((a, b) => String(a.id).localeCompare(String(b.id))), geometries: geometries.slice().sort((a, b) => String(a.id).localeCompare(String(b.id))),
@@ -602,8 +658,8 @@ export function buildEditorSnapshot(options: {
source: w.source, source: w.source,
operation: w.operation, operation: w.operation,
title: w.title, title: w.title,
slug: (w as any).slug ?? null, slug: (w as RawWikiRow).slug ?? null,
doc: (w as any).doc ?? null, doc: (w as RawWikiRow).doc ?? null,
})) }))
.sort((a, b) => a.id.localeCompare(b.id)), .sort((a, b) => a.id.localeCompare(b.id)),
entity_wiki: entityWikis, entity_wiki: entityWikis,
@@ -640,7 +696,7 @@ function dedupeAndSortGeometryEntity(rows: GeometryEntitySnapshot[]): GeometryEn
const geometry_id = typeof row.geometry_id === "string" ? row.geometry_id : ""; const geometry_id = typeof row.geometry_id === "string" ? row.geometry_id : "";
const entity_id = typeof row.entity_id === "string" ? row.entity_id : ""; const entity_id = typeof row.entity_id === "string" ? row.entity_id : "";
if (!geometry_id || !entity_id) continue; if (!geometry_id || !entity_id) continue;
const opRaw = (row as any).operation; const opRaw = (row as RawGeometryEntityRow).operation;
const operation: GeometryEntitySnapshot["operation"] = const operation: GeometryEntitySnapshot["operation"] =
opRaw === "delete" opRaw === "delete"
? "delete" ? "delete"
+8 -2
View File
@@ -4,8 +4,14 @@ export type ApiEnvelope<T> = {
status: boolean | "success" | "error" | string; status: boolean | "success" | "error" | string;
data?: T; data?: T;
message?: string; message?: string;
errors?: unknown; errors?: string | Record<string, string[]> | null;
pagination?: unknown; pagination?: {
current_page: number;
page_size: number;
total_records: number;
total_pages: number;
} | null;
}; };
export type GeometriesBBoxQuery = { export type GeometriesBBoxQuery = {
+8 -1
View File
@@ -2,15 +2,22 @@
// FE stores Tiptap JSON as a JSON-stringified payload. // FE stores Tiptap JSON as a JSON-stringified payload.
export type WikiDoc = string | null; export type WikiDoc = string | null;
export type WikiContentSample = {
id: string;
title: string;
created_at: string;
};
export type WikiSnapshotOperation = "create" | "update" | "delete" | "reference"; export type WikiSnapshotOperation = "create" | "update" | "delete" | "reference";
export type WikiSnapshot = { export type WikiSnapshot = {
id: string; id: string;
source: "inline" | "ref"; source: "inline" | "ref";
// Optional for backwards-compat with older commits. New commits should include it.
operation?: WikiSnapshotOperation; operation?: WikiSnapshotOperation;
title: string; title: string;
slug?: string | null; slug?: string | null;
doc: WikiDoc; doc: WikiDoc;
content_sample?: WikiContentSample[];
updated_at?: string; updated_at?: string;
}; };