Skip to content

Commit 50b128e

Browse files
committed
✨ Authors and admins can now update/remove translations
1 parent 9ceed95 commit 50b128e

File tree

6 files changed

+353
-3
lines changed

6 files changed

+353
-3
lines changed

app/actions/remove-translation.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"use server";
2+
3+
import { MUTATIONS } from "@/lib/supabase/repository";
4+
import { createClient } from "@/lib/supabase/server";
5+
6+
export type RemoveTranslationState =
7+
| { ok: false; error: string }
8+
| { ok: true; error: null };
9+
10+
export type RemoveTranslationAction = (
11+
prevState: RemoveTranslationState,
12+
formData: FormData,
13+
) => Promise<RemoveTranslationState>;
14+
15+
export async function removeTranslation(
16+
translationId: string,
17+
_prevState: RemoveTranslationState,
18+
_formData: FormData,
19+
): Promise<RemoveTranslationState> {
20+
const supabase = await createClient();
21+
const { error } = await MUTATIONS.removeTranslation(supabase, translationId);
22+
23+
if (error) {
24+
switch (error.code) {
25+
case "28000":
26+
return { ok: false, error: "로그인이 필요해요" };
27+
case "22023":
28+
return { ok: false, error: "잘못된 요청이에요" };
29+
case "NO_TRANSLATION":
30+
return { ok: false, error: "번역을 찾을 수 없어요" };
31+
case "42501":
32+
return { ok: false, error: "이 번역을 지울 권한이 없어요" };
33+
default:
34+
return { ok: false, error: "번역을 지우는 중 문제가 생겼어요" };
35+
}
36+
}
37+
38+
return { ok: true, error: null };
39+
}

app/actions/update-translation.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"use server";
2+
3+
import { MUTATIONS } from "@/lib/supabase/repository";
4+
import { createClient } from "@/lib/supabase/server";
5+
6+
export type UpdateTranslationState =
7+
| { ok: false; error: string }
8+
| { ok: true; error: null };
9+
10+
export type UpdateTranslationAction = (
11+
prevState: UpdateTranslationState,
12+
formData: FormData,
13+
) => Promise<UpdateTranslationState>;
14+
15+
export async function updateTranslation(
16+
translationId: string,
17+
_prevState: UpdateTranslationState,
18+
formData: FormData,
19+
): Promise<UpdateTranslationState> {
20+
const name = (formData.get("name") as string | null)?.trim();
21+
if (!name) return { ok: false, error: "번역을 입력해주세요" };
22+
23+
const supabase = await createClient();
24+
const { error } = await MUTATIONS.updateTranslation(
25+
supabase,
26+
translationId,
27+
name,
28+
);
29+
30+
if (error) {
31+
switch (error.code) {
32+
case "28000":
33+
return { ok: false, error: "로그인이 필요해요" };
34+
case "22023":
35+
return { ok: false, error: "올바른 값을 입력해주세요" };
36+
case "NO_TRANSLATION":
37+
return { ok: false, error: "번역을 찾을 수 없어요" };
38+
case "23505":
39+
return { ok: false, error: "이미 존재하는 번역이에요" };
40+
case "42501":
41+
return { ok: false, error: "이 번역을 고칠 권한이 없어요" };
42+
default:
43+
return { ok: false, error: "번역을 고치는 중 문제가 생겼어요" };
44+
}
45+
}
46+
47+
return { ok: true, error: null };
48+
}

app/jargon/[slug]/page.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import SuggestTranslationDialog from "@/components/dialogs/suggest-translation-d
66
import ShareButton from "@/components/share-button";
77
import { Comment } from "@/types/comment";
88
import JargonActions from "@/components/jargon/jargon-actions";
9+
import TranslationActions from "@/components/jargon/translation-actions";
910

1011
export default async function JargonDetailPage({
1112
params,
@@ -67,8 +68,16 @@ export default async function JargonDetailPage({
6768
<div className="text-foreground text-base">
6869
<ul className="list-disc pl-5">
6970
{jargon.translations.map((tran) => (
70-
<li key={tran.name} className="text-foreground">
71-
{tran.name}
71+
<li
72+
key={tran.id}
73+
className="text-foreground flex items-center gap-2"
74+
>
75+
<span>{tran.name}</span>
76+
<TranslationActions
77+
id={tran.id}
78+
authorId={tran.author_id}
79+
name={tran.name}
80+
/>
7281
</li>
7382
))}
7483
</ul>
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"use client";
2+
3+
import { useActionState, useEffect } from "react";
4+
import Form from "next/form";
5+
import { useQueryClient } from "@tanstack/react-query";
6+
import { useUserQuery } from "@/hooks/use-user-query";
7+
import { Button } from "@/components/ui/button";
8+
import {
9+
Dialog,
10+
DialogClose,
11+
DialogContent,
12+
DialogDescription,
13+
DialogFooter,
14+
DialogHeader,
15+
DialogTitle,
16+
DialogTrigger,
17+
} from "@/components/ui/dialog";
18+
import {
19+
AlertDialog,
20+
AlertDialogAction,
21+
AlertDialogCancel,
22+
AlertDialogContent,
23+
AlertDialogDescription,
24+
AlertDialogFooter,
25+
AlertDialogHeader,
26+
AlertDialogTitle,
27+
AlertDialogTrigger,
28+
} from "@/components/ui/alert-dialog";
29+
import { Input } from "@/components/ui/input";
30+
import {
31+
updateTranslation,
32+
type UpdateTranslationAction,
33+
} from "@/app/actions/update-translation";
34+
import {
35+
removeTranslation,
36+
type RemoveTranslationAction,
37+
} from "@/app/actions/remove-translation";
38+
39+
export default function TranslationActions({
40+
id,
41+
authorId,
42+
name,
43+
}: {
44+
id: string;
45+
authorId: string;
46+
name: string;
47+
}) {
48+
const queryClient = useQueryClient();
49+
50+
const { data: user } = useUserQuery();
51+
const isAdmin = user?.app_metadata?.userrole === "admin";
52+
const canManage = !!user && (user.id === authorId || isAdmin);
53+
54+
const [updateState, updateAction] = useActionState(
55+
updateTranslation.bind(null, id) satisfies UpdateTranslationAction,
56+
{ ok: false, error: "" },
57+
);
58+
const [removeState, removeAction] = useActionState(
59+
removeTranslation.bind(null, id) satisfies RemoveTranslationAction,
60+
{ ok: false, error: "" },
61+
);
62+
63+
useEffect(() => {
64+
if (updateState.ok || removeState.ok) {
65+
queryClient.invalidateQueries({ queryKey: ["translations"] });
66+
window.location.reload();
67+
}
68+
}, [updateState.ok, removeState.ok, queryClient]);
69+
70+
if (!canManage) return null;
71+
72+
return (
73+
<div className="flex items-center gap-1">
74+
<Dialog>
75+
<DialogTrigger asChild>
76+
<Button type="button" variant="ghost" className="h-6 px-2.5 text-xs">
77+
고치기
78+
</Button>
79+
</DialogTrigger>
80+
<DialogContent className="sm:max-w-[420px]">
81+
<DialogHeader>
82+
<DialogTitle>번역 고치기</DialogTitle>
83+
<DialogDescription>새로운 번역을 입력해 주세요.</DialogDescription>
84+
</DialogHeader>
85+
<Form action={updateAction} className="grid gap-3">
86+
<Input name="name" defaultValue={name} autoFocus />
87+
{updateState?.error && (
88+
<span className="text-xs text-red-600">{updateState.error}</span>
89+
)}
90+
<DialogFooter>
91+
<DialogClose asChild>
92+
<Button type="button" variant="outline">
93+
닫기
94+
</Button>
95+
</DialogClose>
96+
<Button type="submit">저장</Button>
97+
</DialogFooter>
98+
</Form>
99+
</DialogContent>
100+
</Dialog>
101+
102+
<AlertDialog>
103+
<AlertDialogTrigger asChild>
104+
<Button
105+
type="button"
106+
variant="ghost"
107+
className="h-6 px-2.5 text-xs text-red-600 hover:text-red-700"
108+
>
109+
지우기
110+
</Button>
111+
</AlertDialogTrigger>
112+
<AlertDialogContent>
113+
<AlertDialogHeader>
114+
<AlertDialogTitle>정말로 번역을 지울까요?</AlertDialogTitle>
115+
<AlertDialogDescription>
116+
이 작업은 되돌릴 수 없어요. 연결된 댓글의 표기도 함께 사라져요.
117+
</AlertDialogDescription>
118+
</AlertDialogHeader>
119+
{removeState?.error && (
120+
<span className="text-xs text-red-600">{removeState.error}</span>
121+
)}
122+
<AlertDialogFooter>
123+
<AlertDialogCancel>닫기</AlertDialogCancel>
124+
<Form action={removeAction}>
125+
<AlertDialogAction
126+
type="submit"
127+
className="bg-red-600 hover:bg-red-700"
128+
>
129+
지우기
130+
</AlertDialogAction>
131+
</Form>
132+
</AlertDialogFooter>
133+
</AlertDialogContent>
134+
</AlertDialog>
135+
</div>
136+
);
137+
}

lib/supabase/repository.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export const QUERIES = {
3535
return supabase
3636
.from("jargon")
3737
.select(
38-
"id, name, slug, created_at, author_id, translations:translation(name), categories:jargon_category(category:category(acronym))",
38+
"id, name, slug, created_at, author_id, translations:translation(id, name, author_id), categories:jargon_category(category:category(acronym))",
3939
)
4040
.eq("slug", slug)
4141
.limit(1)
@@ -227,4 +227,24 @@ export const MUTATIONS = {
227227
) {
228228
return supabase.rpc("remove_jargon", { p_jargon_id: jargonId });
229229
},
230+
231+
updateTranslation: function (
232+
supabase: SupabaseClient<Database>,
233+
translationId: string,
234+
name: string,
235+
) {
236+
return (supabase as any).rpc("update_translation", {
237+
p_translation_id: translationId,
238+
p_name: name,
239+
});
240+
},
241+
242+
removeTranslation: function (
243+
supabase: SupabaseClient<Database>,
244+
translationId: string,
245+
) {
246+
return (supabase as any).rpc("remove_translation", {
247+
p_translation_id: translationId,
248+
});
249+
},
230250
};
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
CREATE OR REPLACE FUNCTION public.update_translation(
2+
p_translation_id uuid,
3+
p_name text
4+
) RETURNS boolean
5+
LANGUAGE plpgsql SECURITY DEFINER
6+
SET search_path TO 'public'
7+
AS $$
8+
declare
9+
v_actor_id uuid := auth.uid();
10+
v_author_id uuid;
11+
v_is_admin boolean := coalesce(get_my_claim('userrole')::text, '') = '"admin"';
12+
begin
13+
-- Auth check
14+
if v_actor_id is null then
15+
raise exception 'Not authenticated' using errcode = '28000';
16+
end if;
17+
18+
-- Input validation
19+
if p_translation_id is null then
20+
raise exception 'Translation ID is required' using errcode = '22023';
21+
end if;
22+
23+
if p_name is null or trim(p_name) = '' then
24+
raise exception 'Name is required' using errcode = '22023';
25+
end if;
26+
27+
-- Load author
28+
select author_id into v_author_id from public.translation where id = p_translation_id;
29+
if not found then
30+
raise exception 'Translation not found' using errcode = 'NO_TRANSLATION';
31+
end if;
32+
33+
-- Authorization: author or admin
34+
if v_actor_id <> v_author_id and not v_is_admin then
35+
raise exception 'Not authorized to update this translation' using errcode = '42501';
36+
end if;
37+
38+
begin
39+
update public.translation
40+
set name = trim(p_name),
41+
updated_at = now()
42+
where id = p_translation_id;
43+
exception
44+
when unique_violation then
45+
-- Surface a consistent error code for duplicates
46+
raise exception using errcode = '23505', message = 'Translation already exists';
47+
end;
48+
49+
return true;
50+
end;
51+
$$;
52+
53+
CREATE OR REPLACE FUNCTION public.remove_translation(p_translation_id uuid) RETURNS boolean
54+
LANGUAGE plpgsql SECURITY DEFINER
55+
SET search_path TO 'public'
56+
AS $$
57+
declare
58+
v_actor_id uuid := auth.uid();
59+
v_author_id uuid;
60+
v_is_admin boolean := coalesce(get_my_claim('userrole')::text, '') = '"admin"';
61+
begin
62+
-- Auth check
63+
if v_actor_id is null then
64+
raise exception 'Not authenticated' using errcode = '28000';
65+
end if;
66+
67+
-- Input validation
68+
if p_translation_id is null then
69+
raise exception 'Translation ID is required' using errcode = '22023';
70+
end if;
71+
72+
-- Load author
73+
select author_id into v_author_id from public.translation where id = p_translation_id;
74+
if not found then
75+
raise exception 'Translation not found' using errcode = 'NO_TRANSLATION';
76+
end if;
77+
78+
-- Authorization: author or admin
79+
if v_actor_id <> v_author_id and not v_is_admin then
80+
raise exception 'Not authorized to remove this translation' using errcode = '42501';
81+
end if;
82+
83+
delete from public.translation where id = p_translation_id;
84+
85+
return true;
86+
end;
87+
$$;
88+
89+
GRANT ALL ON FUNCTION public.update_translation(p_translation_id uuid, p_name text) TO anon;
90+
GRANT ALL ON FUNCTION public.update_translation(p_translation_id uuid, p_name text) TO authenticated;
91+
GRANT ALL ON FUNCTION public.update_translation(p_translation_id uuid, p_name text) TO service_role;
92+
93+
GRANT ALL ON FUNCTION public.remove_translation(p_translation_id uuid) TO anon;
94+
GRANT ALL ON FUNCTION public.remove_translation(p_translation_id uuid) TO authenticated;
95+
GRANT ALL ON FUNCTION public.remove_translation(p_translation_id uuid) TO service_role;
96+
97+
NOTIFY pgrst, 'reload schema';

0 commit comments

Comments
 (0)