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:
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)
+);
+