dh_demo

DreamHanks demo project
git clone git://git.lair.cx/dh_demo
Log | Files | Refs | README

commit 16a82ae2a84f72372b54515472ce2ed0db9413e1
parent fb8f67d8efdaf5ade0806a1ef0bbbb3c3914e116
Author: Yongbin Kim <iam@yongbin.kim>
Date:   Thu, 26 Jan 2023 09:45:00 +0900

feat: thread 테이블 및 API 추가

Signed-off-by: Yongbin Kim <iam@yongbin.kim>

Diffstat:
Alib/models/thread.ts | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlib/models/wiki_page.ts | 29+++++++++++++++++++++++++++++
Alib/models/wiki_talk.ts | 48++++++++++++++++++++++++++++++++++++++++++++++++
Mlib/security/acl.ts | 2++
Apages/api/talks/[slug]/[...path].ts | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apages/talk/[slug]/[...path].tsx | 148+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpages/wiki/[slug]/[...path].tsx | 1-
Msql/0001_base.sql | 27+++++++++++++++++++--------
8 files changed, 434 insertions(+), 9 deletions(-)

diff --git a/lib/models/thread.ts b/lib/models/thread.ts @@ -0,0 +1,105 @@ +import { modelBehaviour } from '@/lib/model_helpers' +import { OkPacket, RowDataPacket } from 'mysql2' + +export interface Thread { + id: number + authorId: number + authorName: string + title: string + createdAt: Date +} + +const SQL_LIST_THREADS = ` + select t.id, t.author_id, u.nickname, t.title, t.created_at + from threads t + left join user_profiles u on u.login_id = t.author_id + order by t.created_at desc + limit ? offset ? +` + +export const listThreads = modelBehaviour< + [limit: number, offset: number], + Thread[] +>(async conn => { + const [rows] = await conn.query<RowDataPacket[]>({ + sql: SQL_LIST_THREADS, + }) + + return rows.map(row => ({ + id: row[0], + authorId: row[1], + authorName: row[2], + title: row[3], + createdAt: row[4], + })) +}) + +const SQL_CREATE_THREAD = ` + insert into threads (author_id, title) + values (?, ?) +` + +export const createThread = modelBehaviour< + [authorId: number, title: string], + number +>(async (conn, args) => { + const [result] = await conn.query<OkPacket>({ + sql: SQL_CREATE_THREAD, + }, args) + + return result.insertId +}) + +export interface ThreadComment { + id: number + threadId: number + authorId: number + authorName: string + content: string + createdAt: Date + updatedAt?: Date +} + +const SQL_LIST_THREAD_COMMENTS = ` + select c.id, c.thread_id, c.author_id, u.nickname, c.content, c.created_at, c.updated_at + from thread_comments c + left join user_profiles u on u.login_id = c.author_id + where c.thread_id = ? + order by c.created_at desc + limit ? offset ? +` + +export const listThreadComments = modelBehaviour< + [threadId: number, limit: number, offset: number], + ThreadComment[] +>(async (conn, args) => { + const [rows] = await conn.query<RowDataPacket[]>({ + sql: SQL_LIST_THREAD_COMMENTS, + }, args) + + return rows.map(row => ({ + id: row[0], + threadId: row[1], + authorId: row[2], + authorName: row[3], + content: row[4], + createdAt: row[5], + updatedAt: row[6], + })) +}) + +const SQL_CREATE_THREAD_COMMENT = ` + insert into thread_comments (thread_id, author_id, content) + values (?, ?, ?) +` + +export const createThreadComment = modelBehaviour< + [threadId: number, authorId: number, content: string], + number +>(async (conn, args) => { + const [result] = await conn.query<OkPacket>({ + sql: SQL_CREATE_THREAD_COMMENT, + }, args) + + return result.insertId +}) diff --git a/lib/models/wiki_page.ts b/lib/models/wiki_page.ts @@ -64,3 +64,32 @@ export const getWikiPage = modelBehaviour< updatedAt: row[6], } }) + +const SQL_GET_WIKI_AND_PAGE_ACL = ` + select w.id as wiki_id, w.acl_data as wiki_acl, p.id as page_id, p.acl_data as page_acl + from wikis w + inner join wiki_pages p on p.wiki_id = w.id + where w.slug = ? + and p.path = ? +` + +export const getWikiAndPageACLViaPath = modelBehaviour< + [slug: string, path: string], + { wikiId: number, wiki: ACL, pageId: number, page: ACL } | null +>(async (conn, args) => { + const [rows] = await conn.query<RowDataPacket[]>({ + sql: SQL_GET_WIKI_AND_PAGE_ACL, + }, args) + + if (rows.length === 0) { + return null + } + + const row = rows[0] + return { + wikiId: row[0], + wiki: row[1], + pageId: row[2], + page: row[3], + } +}) diff --git a/lib/models/wiki_talk.ts b/lib/models/wiki_talk.ts @@ -0,0 +1,48 @@ +import { modelBehaviour } from '@/lib/model_helpers' +import { createThread, Thread } from '@/lib/models/thread' +import { OkPacket, RowDataPacket } from 'mysql2' + +const SQL_LIST_WIKI_TALKS = ` + select t.id, t.author_id, u.nickname, t.title, t.created_at + from threads t + inner join wiki_talks wt on t.id = wt.thread_id + left join user_profiles u on u.login_id = t.author_id + where wt.page_id = ? + order by t.created_at desc + limit ? offset ? +` + +export const listWikiTalks = modelBehaviour< + [pageId: number, limit: number, offset: number], + Thread[] +>(async (conn, args) => { + const [rows] = await conn.query<RowDataPacket[]>({ + sql: SQL_LIST_WIKI_TALKS, + }, args) + + return rows.map(row => ({ + id: row[0], + authorId: row[1], + authorName: row[2], + title: row[3], + createdAt: row[4], + })) +}) + +const SQL_CREATE_WIKI_TALK = ` + insert into wiki_talks (page_id, thread_id) + values (?, ?) +` + +export const createWikiTalk = modelBehaviour< + [pageId: number, authorId: number, title: string], + number +>(async (conn, args) => { + const threadId = await createThread(conn, [args[1], args[2]]) + + await conn.query<OkPacket>({ + sql: SQL_CREATE_WIKI_TALK, + }, [args[0], threadId]) + + return threadId +}) diff --git a/lib/security/acl.ts b/lib/security/acl.ts @@ -14,6 +14,8 @@ export const ACL_ACTION_WRITE = 'write' export const ACL_ACTION_DELETE = 'delete' export const ACL_ACTION_MOVE = 'move' export const ACL_ACTION_MANAGE = 'manage' +export const ACL_ACTION_TALK = 'talk' +export const ACL_ACTION_CREATE_THREAD = 'create_thread' export function resolveACL (token: AccessTokenPayload | null, acl: ACL | null | undefined, action: string): boolean { const items = acl?.[action] diff --git a/pages/api/talks/[slug]/[...path].ts b/pages/api/talks/[slug]/[...path].ts @@ -0,0 +1,83 @@ +import { + ApiError, + ERR_FORBIDDEN, + ERR_INVALID_REQUEST, + ERR_METHOD_NOT_ALLOWED, + ERR_NOT_FOUND, + ERR_UNAUTHORIZED, +} from '@/lib/apierror' +import { withConnection } from '@/lib/model_helpers' +import { createThreadComment } from '@/lib/models/thread' +import { getWikiAndPageACLViaPath } from '@/lib/models/wiki_page' +import { createWikiTalk } from '@/lib/models/wiki_talk' +import { ACL_ACTION_CREATE_THREAD, resolveACL } from '@/lib/security/acl' +import { authenticationFromCookies } from '@/lib/security/token' +import { NextApiRequest, NextApiResponse } from 'next' + +export interface CreateTalkRequest { + title?: string + content?: string +} + +export interface CreateTalkResponse { + thread: number +} + +export default function handler ( + req: NextApiRequest, + res: NextApiResponse, +) { + switch (req.method) { + case 'POST': + return handlePost(req, res) + default: + res.status(405).json(ERR_METHOD_NOT_ALLOWED) + } +} + +async function handlePost ( + req: NextApiRequest, + res: NextApiResponse<CreateTalkResponse | ApiError>, +) { + const token = await authenticationFromCookies(req.cookies) + const uid = token?.uid + if (uid == null) { + res.status(401).json(ERR_UNAUTHORIZED) + return + } + + const { slug, path: paths } = req.query + if (typeof slug !== 'string' || !Array.isArray(paths)) { + res.status(400).json(ERR_INVALID_REQUEST) + return + } + + const path = paths.join('/') + + const { title, content } = req.body as CreateTalkRequest + if (typeof title !== 'string' || typeof content !== 'string') { + res.status(400).json(ERR_INVALID_REQUEST) + return + } + + await withConnection(async conn => { + // ACL Check + const aclInfo = await getWikiAndPageACLViaPath(conn, [slug, path]) + if (aclInfo == null) { + res.status(404).json(ERR_NOT_FOUND) + return + } + + if (!resolveACL(token, aclInfo.wiki, ACL_ACTION_CREATE_THREAD) || + !resolveACL(token, aclInfo.page, ACL_ACTION_CREATE_THREAD)) { + res.status(403).json(ERR_FORBIDDEN) + return + } + + // 새 토론과 첫 댓글 만듦 + const threadId = await createWikiTalk(conn, [aclInfo.pageId, uid, title]) + await createThreadComment(conn, [threadId, uid, content]) + + res.status(200).json({ thread: threadId }) + }) +} diff --git a/pages/talk/[slug]/[...path].tsx b/pages/talk/[slug]/[...path].tsx @@ -0,0 +1,148 @@ +import { SubmitButton } from '@/components/elements/Button' +import Title from '@/components/elements/Title' +import Field from '@/components/form/Field' +import Fields from '@/components/form/Fields' +import Form from '@/components/form/Form' +import Container from '@/components/layout/Container' +import Divider from '@/components/layout/Divider' +import Hero from '@/components/layout/Hero' +import Section from '@/components/layout/Section' +import { ApiError } from '@/lib/apierror' +import { Thread } from '@/lib/models/thread' +import { getWikiAndPageACLViaPath } from '@/lib/models/wiki_page' +import { listWikiTalks } from '@/lib/models/wiki_talk' +import { CreateTalkResponse } from '@/pages/api/talks/[slug]/[...path]' +import { GetServerSideProps } from 'next' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { useState } from 'react' + +export interface TestPageProps { + wikiSlug: string + pageId: number + pagePath: string + talks: Thread[] +} + +export const getServerSideProps: GetServerSideProps<TestPageProps> = async (context) => { + const { slug, path: paths } = context.query + if (typeof slug !== 'string' || !Array.isArray(paths)) { + return { + notFound: true, + } + } + + const path = paths.join('/') + + const pageACLInfo = await getWikiAndPageACLViaPath([slug, path]) + if (pageACLInfo === null) { + return { + notFound: true, + } + } + + const talks = await listWikiTalks([pageACLInfo.pageId, 10, 0]) + + return { + props: { + wikiSlug: slug, + pageId: pageACLInfo.pageId, + pagePath: path, + talks: talks, + }, + } +} + +export default function TestPage (props: TestPageProps) { + return ( + <Container> + <Hero> + <Title kind="headline" size="large">{props.pagePath}</Title> + </Hero> + + <Section> + {props.talks.length === 0 ? ( + <div>No talks</div> + ) : ( + <div>asdf</div> + )} + </Section> + + <Divider/> + + <Section> + <Title>새 주제 만들기</Title> + <NewTalk {...props} /> + </Section> + </Container> + ) +} + +function NewTalk (props: TestPageProps) { + const router = useRouter() + + const [title, setTitle] = useState('') + const [content, setContent] = useState('') + + const [isLoading, setLoading] = useState(false) + const [submitError, setSubmitError] = useState<ApiError | null>(null) + + const handleSubmit = () => { + setLoading(true) + + !(async () => { + const res = await fetch(`/api/talks/${props.wikiSlug}/${props.pagePath}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + title: title, + content: content, + }), + }) + + const data = await res.json() + + if (!res.ok) { + setLoading(false) + setSubmitError(data) + return + } + + await router.push(`/threads/${(data as CreateTalkResponse).thread}`) + })().catch((err) => { + setLoading(false) + console.error(err) + }) + } + + return ( + <> + <Form onSubmit={handleSubmit}> + <Fields> + <Field + type="text" + placeholder="제목" + value={title} + onValueChange={setTitle} + disabled={isLoading} + color={submitError ? 'error' : undefined} + /> + + <Field + type="textarea" + placeholder="내용" + value={content} + onValueChange={setContent} + disabled={isLoading} + color={submitError ? 'error' : undefined} + message={submitError ? submitError.message : undefined} + /> + + <SubmitButton value="만들기"/> + </Fields> + </Form> + </> + ) +} diff --git a/pages/wiki/[slug]/[...path].tsx b/pages/wiki/[slug]/[...path].tsx @@ -1,5 +1,4 @@ import WikiArticle from '@/components/wiki/WikiArticle' -import WikiToolbar from '@/components/wiki/WikiToolbar' import { withConnection } from '@/lib/model_helpers' import { getWikiPage } from '@/pages/api/wiki/[slug]/[...path]' import { GetServerSideProps } from 'next' diff --git a/sql/0001_base.sql b/sql/0001_base.sql @@ -73,19 +73,19 @@ create table wiki_changes created_at datetime not null default current_timestamp ); -create table wiki_talks +create table threads ( - id int not null auto_increment primary key, - page_id int not null references wiki_pages (id) - on delete cascade on update cascade, - title varchar(255) not null check ( title <> '' ), - created_at datetime not null default current_timestamp + id int not null auto_increment primary key, + author_id int not null references logins (id) + on delete set null on update cascade, + title varchar(255) not null, + created_at datetime not null default current_timestamp ); -create table wiki_talk_comments +create table thread_comments ( id int not null auto_increment primary key, - talk_id int not null references wiki_talks (id) + thread_id int not null references threads (id) on delete cascade on update cascade, author_id int not null references logins (id) on delete set null on update cascade, @@ -93,3 +93,14 @@ create table wiki_talk_comments created_at datetime not null default current_timestamp, updated_at datetime null on update current_timestamp ); + +create table wiki_talks +( + page_id int not null references wiki_pages (id) + on delete cascade on update cascade, + thread_id int not null references threads (id) + on delete cascade on update cascade, + + primary key (page_id, thread_id) +); +