Add a retargetable, human-readable vanity link namespace /l/<alias> that sits alongside the untouched /share/... routes. - New share_aliases table (workspace-scoped, UNIQUE(workspace_id, alias), page_id nullable ON DELETE SET NULL so the address outlives its target). - ShareAliasRepo + ShareAliasService (create / no-op / 409 reassign guard / availability / request-time readable-target resolution through the single existing share boundary). - Public ShareAliasRedirectController (GET /l/:alias) issues a 302 (never 301, the target is mutable) to the canonical /share/:key/p/:slug page; unknown / dangling / no-longer-readable aliases serve the SPA index with no leak. 'l/:alias' excluded from the global /api prefix. - Authenticated ShareAliasController (set/remove/availability/for-page). - Shared ASCII-only normalize/validate util (server + client copies). - Client: Custom address block in the share modal (live normalize + debounced availability + copy + reassign confirmation dialog). - Unit tests: util, repo SQL-shape, service semantics, migration/entity sanity (server jest) + client alias util (vitest). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
255 lines
6.7 KiB
TypeScript
255 lines
6.7 KiB
TypeScript
import {
|
|
keepPreviousData,
|
|
useMutation,
|
|
useQuery,
|
|
useQueryClient,
|
|
UseQueryResult,
|
|
} from "@tanstack/react-query";
|
|
import { notifications } from "@mantine/notifications";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
ICreateShare,
|
|
IShare,
|
|
IShareAlias,
|
|
ISetShareAlias,
|
|
ISharedItem,
|
|
ISharedPage,
|
|
ISharedPageTree,
|
|
IShareForPage,
|
|
IShareInfoInput,
|
|
IUpdateShare,
|
|
} from "@/features/share/types/share.types.ts";
|
|
import {
|
|
createShare,
|
|
deleteShare,
|
|
getShareAliasForPage,
|
|
getSharedPageTree,
|
|
getShareForPage,
|
|
getShareInfo,
|
|
getSharePageInfo,
|
|
getShares,
|
|
removeShareAlias,
|
|
setShareAlias,
|
|
updateShare,
|
|
} from "@/features/share/services/share-service.ts";
|
|
import { IPagination, QueryParams } from "@/lib/types.ts";
|
|
|
|
export function useGetSharesQuery(
|
|
params?: QueryParams,
|
|
): UseQueryResult<IPagination<ISharedItem>, Error> {
|
|
return useQuery({
|
|
queryKey: ["share-list", params],
|
|
queryFn: () => getShares(params),
|
|
placeholderData: keepPreviousData,
|
|
});
|
|
}
|
|
|
|
export function useGetShareByIdQuery(
|
|
shareId: string,
|
|
): UseQueryResult<IShare, Error> {
|
|
const query = useQuery({
|
|
queryKey: ["share-by-id", shareId],
|
|
queryFn: () => getShareInfo(shareId),
|
|
enabled: !!shareId,
|
|
});
|
|
|
|
return query;
|
|
}
|
|
|
|
export function useSharePageQuery(
|
|
shareInput: Partial<IShareInfoInput>,
|
|
): UseQueryResult<ISharedPage, Error> {
|
|
const query = useQuery({
|
|
queryKey: ["shares", shareInput],
|
|
queryFn: () => getSharePageInfo(shareInput),
|
|
enabled: !!shareInput.pageId,
|
|
});
|
|
|
|
return query;
|
|
}
|
|
|
|
export function useShareForPageQuery(
|
|
pageId: string,
|
|
): UseQueryResult<IShareForPage | null, Error> {
|
|
const query = useQuery({
|
|
queryKey: ["share-for-page", pageId],
|
|
// React Query forbids `undefined` as resolved data ("Query data cannot be
|
|
// undefined"). When no share exists for the page the endpoint resolves to
|
|
// undefined, so normalize the absence to `null`.
|
|
queryFn: async () => (await getShareForPage(pageId)) ?? null,
|
|
enabled: !!pageId,
|
|
staleTime: 60 * 1000,
|
|
retry: false,
|
|
});
|
|
|
|
return query;
|
|
}
|
|
|
|
export function useCreateShareMutation() {
|
|
const { t } = useTranslation();
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation<any, Error, ICreateShare>({
|
|
mutationFn: (data) => createShare(data),
|
|
onSuccess: (data) => {
|
|
queryClient.invalidateQueries({
|
|
predicate: (item) =>
|
|
["share-for-page", "share-list"].includes(item.queryKey[0] as string),
|
|
});
|
|
},
|
|
onError: (error) => {
|
|
notifications.show({
|
|
message: error?.["response"]?.data?.message || t("Failed to share page"),
|
|
color: "red",
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useUpdateShareMutation() {
|
|
const { t } = useTranslation();
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation<any, Error, IUpdateShare>({
|
|
mutationFn: (data) => updateShare(data),
|
|
onSuccess: (data) => {
|
|
queryClient.invalidateQueries({
|
|
predicate: (item) =>
|
|
["share-for-page", "share-list"].includes(item.queryKey[0] as string),
|
|
});
|
|
},
|
|
onError: (error, params) => {
|
|
if (error?.["status"] === 404) {
|
|
queryClient.removeQueries({
|
|
predicate: (item) =>
|
|
["share-for-page"].includes(item.queryKey[0] as string),
|
|
});
|
|
|
|
notifications.show({
|
|
message: t("Share not found"),
|
|
color: "red",
|
|
});
|
|
return;
|
|
}
|
|
|
|
notifications.show({
|
|
message: error?.["response"]?.data?.message || "Share not found",
|
|
color: "red",
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useDeleteShareMutation() {
|
|
const { t } = useTranslation();
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: (shareId: string) => deleteShare(shareId),
|
|
onSuccess: (data) => {
|
|
queryClient.removeQueries({
|
|
predicate: (item) =>
|
|
["share-for-page"].includes(item.queryKey[0] as string),
|
|
});
|
|
|
|
queryClient.invalidateQueries({
|
|
predicate: (item) =>
|
|
["share-list"].includes(item.queryKey[0] as string),
|
|
});
|
|
|
|
notifications.show({ message: t("Share deleted successfully") });
|
|
},
|
|
onError: (error) => {
|
|
if (error?.["status"] === 404) {
|
|
queryClient.removeQueries({
|
|
predicate: (item) =>
|
|
["share-for-page"].includes(item.queryKey[0] as string),
|
|
});
|
|
}
|
|
|
|
notifications.show({
|
|
message: error?.["response"]?.data?.message || "Failed to delete share",
|
|
color: "red",
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useShareAliasForPageQuery(
|
|
pageId: string,
|
|
): UseQueryResult<IShareAlias | null, Error> {
|
|
return useQuery({
|
|
// The endpoint resolves to null when the page has no alias; normalize the
|
|
// absence so React Query never sees `undefined`.
|
|
queryKey: ["share-alias-for-page", pageId],
|
|
queryFn: async () => (await getShareAliasForPage(pageId)) ?? null,
|
|
enabled: !!pageId,
|
|
staleTime: 60 * 1000,
|
|
retry: false,
|
|
});
|
|
}
|
|
|
|
export function useSetShareAliasMutation() {
|
|
const { t } = useTranslation();
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation<IShareAlias, Error, ISetShareAlias>({
|
|
mutationFn: (data) => setShareAlias(data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({
|
|
predicate: (item) =>
|
|
["share-alias-for-page", "share-list"].includes(
|
|
item.queryKey[0] as string,
|
|
),
|
|
});
|
|
},
|
|
onError: (error) => {
|
|
// A 409 reassign-required is handled inline by the modal (it shows the
|
|
// "move address here?" confirmation), so don't surface a generic toast.
|
|
if (error?.["status"] === 409) return;
|
|
notifications.show({
|
|
message:
|
|
error?.["response"]?.data?.message || t("Failed to set custom address"),
|
|
color: "red",
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useRemoveShareAliasMutation() {
|
|
const { t } = useTranslation();
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation<void, Error, string>({
|
|
mutationFn: (aliasId) => removeShareAlias(aliasId),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({
|
|
predicate: (item) =>
|
|
["share-alias-for-page", "share-list"].includes(
|
|
item.queryKey[0] as string,
|
|
),
|
|
});
|
|
},
|
|
onError: (error) => {
|
|
notifications.show({
|
|
message:
|
|
error?.["response"]?.data?.message ||
|
|
t("Failed to remove custom address"),
|
|
color: "red",
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useGetSharedPageTreeQuery(
|
|
shareId: string,
|
|
): UseQueryResult<ISharedPageTree, Error> {
|
|
return useQuery({
|
|
queryKey: ["shared-page-tree", shareId],
|
|
queryFn: () => getSharedPageTree(shareId),
|
|
enabled: !!shareId,
|
|
placeholderData: keepPreviousData,
|
|
staleTime: 60 * 60 * 1000,
|
|
});
|
|
}
|