Finalize multi-image upload backend data processing
This commit is contained in:
parent
3039076e27
commit
31396b83cc
@ -4,7 +4,7 @@ import ErrorNote from '@/components/ErrorNote';
|
|||||||
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
|
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
|
||||||
import InfoBlock from '@/components/InfoBlock';
|
import InfoBlock from '@/components/InfoBlock';
|
||||||
import LoaderButton from '@/components/primitives/LoaderButton';
|
import LoaderButton from '@/components/primitives/LoaderButton';
|
||||||
import { addAllUploads } from '@/photo/actions';
|
import { addAllUploadsAction } from '@/photo/actions';
|
||||||
import { PATH_ADMIN_PHOTOS } from '@/site/paths';
|
import { PATH_ADMIN_PHOTOS } from '@/site/paths';
|
||||||
import {
|
import {
|
||||||
TagsWithMeta,
|
TagsWithMeta,
|
||||||
@ -15,8 +15,7 @@ import {
|
|||||||
generateLocalNaivePostgresString,
|
generateLocalNaivePostgresString,
|
||||||
generateLocalPostgresString,
|
generateLocalPostgresString,
|
||||||
} from '@/utility/date';
|
} from '@/utility/date';
|
||||||
import { convertStringToArray } from '@/utility/string';
|
import { clsx } from 'clsx/lite';
|
||||||
import clsx from 'clsx';
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { BiImageAdd } from 'react-icons/bi';
|
import { BiImageAdd } from 'react-icons/bi';
|
||||||
@ -66,6 +65,7 @@ export default function AdminAddAllUploads({
|
|||||||
, 100);
|
, 100);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
readOnly={isLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@ -81,6 +81,7 @@ export default function AdminAddAllUploads({
|
|||||||
setTags(tags);
|
setTags(tags);
|
||||||
setTagErrorMessage(getValidationMessageForTags(tags) ?? '');
|
setTagErrorMessage(getValidationMessageForTags(tags) ?? '');
|
||||||
}}
|
}}
|
||||||
|
readOnly={isLoading}
|
||||||
error={tagErrorMessage}
|
error={tagErrorMessage}
|
||||||
required={false}
|
required={false}
|
||||||
hideLabel
|
hideLabel
|
||||||
@ -97,10 +98,8 @@ export default function AdminAddAllUploads({
|
|||||||
`Are you sure you want to add all ${storageUrlCount} uploads?`
|
`Are you sure you want to add all ${storageUrlCount} uploads?`
|
||||||
)) {
|
)) {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
addAllUploads({
|
addAllUploadsAction({
|
||||||
tags: showTags && tags
|
tags: showTags ? tags : undefined,
|
||||||
? convertStringToArray(tags) ?? []
|
|
||||||
: [],
|
|
||||||
takenAtLocal: generateLocalPostgresString(),
|
takenAtLocal: generateLocalPostgresString(),
|
||||||
takenAtNaiveLocal: generateLocalNaivePostgresString(),
|
takenAtNaiveLocal: generateLocalNaivePostgresString(),
|
||||||
})
|
})
|
||||||
|
|||||||
@ -149,12 +149,18 @@ export default function FieldSetWithStatus({
|
|||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
autoCapitalize={!capitalize ? 'off' : undefined}
|
autoCapitalize={!capitalize ? 'off' : undefined}
|
||||||
readOnly={readOnly || pending || loading}
|
readOnly={readOnly || pending || loading}
|
||||||
|
disabled={type === 'checkbox' && (
|
||||||
|
readOnly || pending || loading
|
||||||
|
)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
(
|
(
|
||||||
type === 'text' ||
|
type === 'text' ||
|
||||||
type === 'email' ||
|
type === 'email' ||
|
||||||
type === 'password'
|
type === 'password'
|
||||||
) && 'w-full',
|
) && 'w-full',
|
||||||
|
type === 'checkbox' && (
|
||||||
|
readOnly || pending || loading
|
||||||
|
) && 'opacity-50 cursor-not-allowed',
|
||||||
Boolean(error) && 'error',
|
Boolean(error) && 'error',
|
||||||
)}
|
)}
|
||||||
/>}
|
/>}
|
||||||
|
|||||||
@ -41,14 +41,19 @@ import { convertPhotoToPhotoDbInsert } from '.';
|
|||||||
import { runAuthenticatedAdminServerAction } from '@/auth';
|
import { runAuthenticatedAdminServerAction } from '@/auth';
|
||||||
import { AI_IMAGE_QUERIES, AiImageQuery } from './ai';
|
import { AI_IMAGE_QUERIES, AiImageQuery } from './ai';
|
||||||
import { streamOpenAiImageQuery } from '@/services/openai';
|
import { streamOpenAiImageQuery } from '@/services/openai';
|
||||||
import { AI_TEXT_GENERATION_ENABLED, BLUR_ENABLED } from '@/site/config';
|
import {
|
||||||
|
AI_TEXT_AUTO_GENERATED_FIELDS,
|
||||||
|
AI_TEXT_GENERATION_ENABLED,
|
||||||
|
BLUR_ENABLED,
|
||||||
|
} from '@/site/config';
|
||||||
import { getStorageUploadUrlsNoStore } from '@/services/storage/cache';
|
import { getStorageUploadUrlsNoStore } from '@/services/storage/cache';
|
||||||
|
import { generateAiImageQueries } from './ai/server';
|
||||||
|
|
||||||
// Private actions
|
// Private actions
|
||||||
|
|
||||||
export const createPhotoAction = async (formData: FormData) =>
|
export const createPhotoAction = async (formData: FormData) =>
|
||||||
runAuthenticatedAdminServerAction(async () => {
|
runAuthenticatedAdminServerAction(async () => {
|
||||||
const photo = convertFormDataToPhotoDbInsert(formData, true);
|
const photo = convertFormDataToPhotoDbInsert(formData);
|
||||||
|
|
||||||
const updatedUrl = await convertUploadToPhoto(photo.url);
|
const updatedUrl = await convertUploadToPhoto(photo.url);
|
||||||
|
|
||||||
@ -60,17 +65,18 @@ export const createPhotoAction = async (formData: FormData) =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const addAllUploads = async ({
|
export const addAllUploadsAction = async ({
|
||||||
tags,
|
tags,
|
||||||
takenAtLocal,
|
takenAtLocal,
|
||||||
takenAtNaiveLocal,
|
takenAtNaiveLocal,
|
||||||
}: {
|
}: {
|
||||||
tags: string[]
|
tags?: string
|
||||||
takenAtLocal: string
|
takenAtLocal: string
|
||||||
takenAtNaiveLocal: string
|
takenAtNaiveLocal: string
|
||||||
}) =>
|
}) =>
|
||||||
runAuthenticatedAdminServerAction(async () => {
|
runAuthenticatedAdminServerAction(async () => {
|
||||||
const uploadUrls = await getStorageUploadUrlsNoStore();
|
const uploadUrls = await getStorageUploadUrlsNoStore();
|
||||||
|
|
||||||
for (const { url } of uploadUrls) {
|
for (const { url } of uploadUrls) {
|
||||||
const {
|
const {
|
||||||
photoFormExif,
|
photoFormExif,
|
||||||
@ -82,23 +88,38 @@ export const addAllUploads = async ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (photoFormExif) {
|
if (photoFormExif) {
|
||||||
const form = {
|
const {
|
||||||
|
title,
|
||||||
|
caption,
|
||||||
|
tags: aiTags,
|
||||||
|
semanticDescription,
|
||||||
|
} = await generateAiImageQueries(
|
||||||
|
imageResizedBase64,
|
||||||
|
AI_TEXT_AUTO_GENERATED_FIELDS,
|
||||||
|
);
|
||||||
|
|
||||||
|
const form: Partial<PhotoFormData> = {
|
||||||
...photoFormExif,
|
...photoFormExif,
|
||||||
tags,
|
title,
|
||||||
|
caption,
|
||||||
|
tags: tags || aiTags,
|
||||||
|
semanticDescription,
|
||||||
takenAt: photoFormExif.takenAt || takenAtLocal,
|
takenAt: photoFormExif.takenAt || takenAtLocal,
|
||||||
takenAtNaive: photoFormExif.takenAtNaive || takenAtNaiveLocal,
|
takenAtNaive: photoFormExif.takenAtNaive || takenAtNaiveLocal,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updatedUrl = await convertUploadToPhoto(url);
|
||||||
|
if (updatedUrl) {
|
||||||
|
const photo = convertFormDataToPhotoDbInsert(form);
|
||||||
|
console.log(photo);
|
||||||
|
photo.url = updatedUrl;
|
||||||
|
await insertPhoto(photo);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// const updatedUrl = await convertUploadToPhoto(url);
|
|
||||||
// if (updatedUrl) {
|
|
||||||
// const photo = convertFormDataToPhotoDbInsert(new FormData(), true);
|
|
||||||
// photo.url = updatedUrl;
|
|
||||||
// await insertPhoto(photo);
|
|
||||||
// }
|
|
||||||
// const photo = convertFormDataToPhotoDbInsert(new FormData(), true);
|
|
||||||
// photo.url = url;
|
|
||||||
// await insertPhoto(photo);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
revalidateAllKeysAndPaths();
|
||||||
|
redirect(PATH_ADMIN_PHOTOS);
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updatePhotoAction = async (formData: FormData) =>
|
export const updatePhotoAction = async (formData: FormData) =>
|
||||||
|
|||||||
@ -63,3 +63,11 @@ export const parseTitleAndCaption = (text: string) => {
|
|||||||
caption: matches?.[2] ?? '',
|
caption: matches?.[2] ?? '',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const cleanUpAiTextResponse = (text?: string) => text
|
||||||
|
? text
|
||||||
|
.replaceAll('\n', ' ')
|
||||||
|
.replaceAll('"', '')
|
||||||
|
.replace(/\.$/, '')
|
||||||
|
.trim()
|
||||||
|
: undefined;
|
||||||
|
|||||||
77
src/photo/ai/server.ts
Normal file
77
src/photo/ai/server.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { generateOpenAiImageQuery } from '@/services/openai';
|
||||||
|
import {
|
||||||
|
AI_IMAGE_QUERIES,
|
||||||
|
AiAutoGeneratedField,
|
||||||
|
cleanUpAiTextResponse,
|
||||||
|
parseTitleAndCaption,
|
||||||
|
} from '.';
|
||||||
|
|
||||||
|
export const generateAiImageQueries = async (
|
||||||
|
imageBase64?: string,
|
||||||
|
textFieldsToGenerate: AiAutoGeneratedField[] = [],
|
||||||
|
): Promise<{
|
||||||
|
title?: string
|
||||||
|
caption?: string
|
||||||
|
tags?: string
|
||||||
|
semanticDescription?: string
|
||||||
|
}> => {
|
||||||
|
let title: string | undefined;
|
||||||
|
let caption: string | undefined;
|
||||||
|
let tags: string | undefined;
|
||||||
|
let semanticDescription: string | undefined;
|
||||||
|
|
||||||
|
if (imageBase64) {
|
||||||
|
if (
|
||||||
|
textFieldsToGenerate.includes('title') &&
|
||||||
|
textFieldsToGenerate.includes('caption')
|
||||||
|
) {
|
||||||
|
const titleAndCaption = await generateOpenAiImageQuery(
|
||||||
|
imageBase64,
|
||||||
|
AI_IMAGE_QUERIES['title-and-caption'],
|
||||||
|
);
|
||||||
|
if (titleAndCaption) {
|
||||||
|
const titleAndCaptionParsed = parseTitleAndCaption(titleAndCaption);
|
||||||
|
title = titleAndCaptionParsed.title;
|
||||||
|
caption = titleAndCaptionParsed.caption;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (textFieldsToGenerate.includes('title')) {
|
||||||
|
title = cleanUpAiTextResponse(
|
||||||
|
await generateOpenAiImageQuery(
|
||||||
|
imageBase64,
|
||||||
|
AI_IMAGE_QUERIES['title'],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if (textFieldsToGenerate.includes('caption')) {
|
||||||
|
caption = cleanUpAiTextResponse(
|
||||||
|
await generateOpenAiImageQuery(
|
||||||
|
imageBase64,
|
||||||
|
AI_IMAGE_QUERIES['caption'],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (textFieldsToGenerate.includes('tags')) {
|
||||||
|
tags = cleanUpAiTextResponse(
|
||||||
|
await generateOpenAiImageQuery(
|
||||||
|
imageBase64,
|
||||||
|
AI_IMAGE_QUERIES['tags'],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (textFieldsToGenerate.includes('semantic')) {
|
||||||
|
semanticDescription = cleanUpAiTextResponse(
|
||||||
|
await generateOpenAiImageQuery(
|
||||||
|
imageBase64,
|
||||||
|
AI_IMAGE_QUERIES['description-small'],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
caption,
|
||||||
|
tags,
|
||||||
|
semanticDescription,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -7,7 +7,7 @@ export type AiContent = ReturnType<typeof useAiImageQueries>;
|
|||||||
|
|
||||||
export default function useAiImageQueries(
|
export default function useAiImageQueries(
|
||||||
textFieldsToAutoGenerate: AiAutoGeneratedField[] = [],
|
textFieldsToAutoGenerate: AiAutoGeneratedField[] = [],
|
||||||
imageData?: string,
|
imageBase64?: string,
|
||||||
) {
|
) {
|
||||||
const [
|
const [
|
||||||
requestTitleCaption,
|
requestTitleCaption,
|
||||||
@ -17,33 +17,33 @@ export default function useAiImageQueries(
|
|||||||
_isLoadingCaption,
|
_isLoadingCaption,
|
||||||
resetTitle,
|
resetTitle,
|
||||||
resetCaption,
|
resetCaption,
|
||||||
] = useTitleCaptionAiImageQuery(imageData);
|
] = useTitleCaptionAiImageQuery(imageBase64);
|
||||||
|
|
||||||
const [
|
const [
|
||||||
requestTitle,
|
requestTitle,
|
||||||
titleSolo,
|
titleSolo,
|
||||||
isLoadingTitleSolo,
|
isLoadingTitleSolo,
|
||||||
resetTitleSolo,
|
resetTitleSolo,
|
||||||
] = useAiImageQuery(imageData, 'title');
|
] = useAiImageQuery(imageBase64, 'title');
|
||||||
|
|
||||||
const [
|
const [
|
||||||
requestCaption,
|
requestCaption,
|
||||||
captionSolo,
|
captionSolo,
|
||||||
isLoadingCaptionSolo,
|
isLoadingCaptionSolo,
|
||||||
resetCaptionSolo,
|
resetCaptionSolo,
|
||||||
] = useAiImageQuery(imageData, 'caption');
|
] = useAiImageQuery(imageBase64, 'caption');
|
||||||
|
|
||||||
const [
|
const [
|
||||||
requestTags,
|
requestTags,
|
||||||
tags,
|
tags,
|
||||||
isLoadingTags,
|
isLoadingTags,
|
||||||
] = useAiImageQuery(imageData, 'tags');
|
] = useAiImageQuery(imageBase64, 'tags');
|
||||||
|
|
||||||
const [
|
const [
|
||||||
requestSemantic,
|
requestSemantic,
|
||||||
semanticDescription,
|
semanticDescription,
|
||||||
isLoadingSemantic,
|
isLoadingSemantic,
|
||||||
] = useAiImageQuery(imageData, 'description-small');
|
] = useAiImageQuery(imageBase64, 'description-small');
|
||||||
|
|
||||||
const title = _title || titleSolo;
|
const title = _title || titleSolo;
|
||||||
const caption = _caption || captionSolo;
|
const caption = _caption || captionSolo;
|
||||||
@ -99,12 +99,12 @@ export default function useAiImageQueries(
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (imageData && !hasRunAllQueriesOnce.current) {
|
if (imageBase64 && !hasRunAllQueriesOnce.current) {
|
||||||
if (textFieldsToAutoGenerate.length > 0) {
|
if (textFieldsToAutoGenerate.length > 0) {
|
||||||
request(textFieldsToAutoGenerate);
|
request(textFieldsToAutoGenerate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [textFieldsToAutoGenerate, imageData, request]);
|
}, [textFieldsToAutoGenerate, imageBase64, request]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
request,
|
request,
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { streamAiImageQueryAction } from '../actions';
|
import { streamAiImageQueryAction } from '../actions';
|
||||||
import { readStreamableValue } from 'ai/rsc';
|
import { readStreamableValue } from 'ai/rsc';
|
||||||
import { AiImageQuery } from '.';
|
import { AiImageQuery, cleanUpAiTextResponse } from '.';
|
||||||
|
|
||||||
export default function useAiImageQuery(
|
export default function useAiImageQuery(
|
||||||
imageBase64: string | undefined,
|
imageBase64: string | undefined,
|
||||||
@ -21,10 +21,9 @@ export default function useAiImageQuery(
|
|||||||
query,
|
query,
|
||||||
);
|
);
|
||||||
for await (const text of readStreamableValue(textStream)) {
|
for await (const text of readStreamableValue(textStream)) {
|
||||||
setText(current => `${current}${text ?? ''}`
|
setText(current =>
|
||||||
.replaceAll('\n', ' ')
|
cleanUpAiTextResponse(`${current}${text ?? ''}`) ?? ''
|
||||||
.replaceAll('"', '')
|
);
|
||||||
.replace(/\.$/, ''));
|
|
||||||
}
|
}
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import useAiImageQuery from './useAiImageQuery';
|
|||||||
import { parseTitleAndCaption } from '.';
|
import { parseTitleAndCaption } from '.';
|
||||||
|
|
||||||
export default function useTitleCaptionAiImageQuery(
|
export default function useTitleCaptionAiImageQuery(
|
||||||
imageBase64: string | undefined,
|
imageBase64?: string,
|
||||||
) {
|
) {
|
||||||
const [
|
const [
|
||||||
request,
|
request,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import type { ExifData } from 'ts-exif-parser';
|
import type { ExifData } from 'ts-exif-parser';
|
||||||
import { Photo, PhotoDbInsert, PhotoExif } from '..';
|
import { DEFAULT_ASPECT_RATIO, Photo, PhotoDbInsert, PhotoExif } from '..';
|
||||||
import {
|
import {
|
||||||
convertTimestampToNaivePostgresString,
|
convertTimestampToNaivePostgresString,
|
||||||
convertTimestampWithOffsetToPostgresString,
|
convertTimestampWithOffsetToPostgresString,
|
||||||
@ -20,7 +20,7 @@ import { TAG_FAVS, getValidationMessageForTags } from '@/tag';
|
|||||||
|
|
||||||
type VirtualFields = 'favorite';
|
type VirtualFields = 'favorite';
|
||||||
|
|
||||||
export type PhotoFormData = Record<keyof PhotoDbInsert | VirtualFields, string>;
|
export type PhotoFormData = Record<keyof PhotoDbInsert | VirtualFields, string>
|
||||||
|
|
||||||
export type FieldSetType =
|
export type FieldSetType =
|
||||||
'text' |
|
'text' |
|
||||||
@ -217,8 +217,7 @@ export const convertExifToFormData = (
|
|||||||
// PREPARE FORM FOR DB INSERT
|
// PREPARE FORM FOR DB INSERT
|
||||||
|
|
||||||
export const convertFormDataToPhotoDbInsert = (
|
export const convertFormDataToPhotoDbInsert = (
|
||||||
formData: FormData | PhotoFormData,
|
formData: FormData | Partial<PhotoFormData>,
|
||||||
generateId?: boolean,
|
|
||||||
): PhotoDbInsert => {
|
): PhotoDbInsert => {
|
||||||
const photoForm = formData instanceof FormData
|
const photoForm = formData instanceof FormData
|
||||||
? Object.fromEntries(formData) as PhotoFormData
|
? Object.fromEntries(formData) as PhotoFormData
|
||||||
@ -245,11 +244,13 @@ export const convertFormDataToPhotoDbInsert = (
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...(photoForm as PhotoFormData & { filmSimulation?: FilmSimulation }),
|
...(photoForm as PhotoFormData & { filmSimulation?: FilmSimulation }),
|
||||||
...(generateId && !photoForm.id) && { id: generateNanoid() },
|
...!photoForm.id && { id: generateNanoid() },
|
||||||
// Convert form strings to arrays
|
// Convert form strings to arrays
|
||||||
tags: tags.length > 0 ? tags : undefined,
|
tags: tags.length > 0 ? tags : undefined,
|
||||||
// Convert form strings to numbers
|
// Convert form strings to numbers
|
||||||
aspectRatio: roundToNumber(parseFloat(photoForm.aspectRatio), 6),
|
aspectRatio: photoForm.aspectRatio
|
||||||
|
? roundToNumber(parseFloat(photoForm.aspectRatio), 6)
|
||||||
|
: DEFAULT_ASPECT_RATIO,
|
||||||
focalLength: photoForm.focalLength
|
focalLength: photoForm.focalLength
|
||||||
? parseInt(photoForm.focalLength)
|
? parseInt(photoForm.focalLength)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|||||||
@ -31,6 +31,8 @@ export const INFINITE_SCROLL_GRID_PHOTO_MULTIPLE = HIGH_DENSITY_GRID
|
|||||||
// Thumbnails below /p/[photoId]
|
// Thumbnails below /p/[photoId]
|
||||||
export const RELATED_GRID_PHOTOS_TO_SHOW = 12;
|
export const RELATED_GRID_PHOTOS_TO_SHOW = 12;
|
||||||
|
|
||||||
|
export const DEFAULT_ASPECT_RATIO = 1.5;
|
||||||
|
|
||||||
export const ACCEPTED_PHOTO_FILE_TYPES = [
|
export const ACCEPTED_PHOTO_FILE_TYPES = [
|
||||||
'image/jpg',
|
'image/jpg',
|
||||||
'image/jpeg',
|
'image/jpeg',
|
||||||
|
|||||||
@ -101,6 +101,6 @@ export const generateOpenAiImageQuery = async (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
}],
|
}],
|
||||||
});
|
}).then(({ text }) => text);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user