Skip to content

Commit ba4b7a8

Browse files
committed
Implement output types for Article
1 parent e35359c commit ba4b7a8

9 files changed

Lines changed: 176 additions & 110 deletions

File tree

apps/rpc/src/invariant.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,16 @@ export function parseOrReport<T extends z.ZodSchema>(schema: T, value: z.infer<T
1818
}
1919
return result.data
2020
}
21+
22+
export function parseOutputType<T extends z.ZodSchema>(schema: T, value: z.infer<T> | unknown): z.infer<T> {
23+
const result = schema.safeParse(value)
24+
if (!result.success) {
25+
logger.error(
26+
"Procedure handler failed to parse value into schema: %s emitted for object %o",
27+
result.error.message,
28+
value
29+
)
30+
throw new Error("Procedure handler returned value that does not conform to schema")
31+
}
32+
return result.data
33+
}

apps/rpc/src/modules/article/article-repository.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,15 @@
11
import type { DBHandle } from "@dotkomonline/db"
22
import {
3-
type Article,
4-
type ArticleFilterQuery,
3+
Article,
54
type ArticleId,
6-
ArticleSchema,
75
type ArticleSlug,
8-
type ArticleTag,
6+
ArticleTag,
97
type ArticleTagName,
10-
ArticleTagSchema,
118
type ArticleWrite,
12-
} from "@dotkomonline/types"
9+
} from "./article-types"
1310
import { parseOrReport } from "../../invariant"
1411
import { type Pageable, pageQuery } from "../../query"
12+
import { ArticleFilterQuery } from "./article-service"
1513

1614
export interface ArticleRepository {
1715
create(handle: DBHandle, data: ArticleWrite): Promise<Article>
@@ -122,7 +120,7 @@ export function getArticleRepository(): ArticleRepository {
122120
})
123121

124122
return tags.map((tag) =>
125-
parseOrReport(ArticleTagSchema, {
123+
parseOrReport(ArticleTag, {
126124
name: tag.tagName,
127125
})
128126
)
@@ -131,7 +129,7 @@ export function getArticleRepository(): ArticleRepository {
131129
}
132130

133131
function mapArticle(article: Omit<Article, "tags">, tags: { tag: ArticleTag }[]): Article {
134-
return parseOrReport(ArticleSchema, {
132+
return parseOrReport(Article, {
135133
...article,
136134
tags: tags.map((link) => link.tag),
137135
})

apps/rpc/src/modules/article/article-router.ts

Lines changed: 70 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,74 @@
11
import type { PresignedPost } from "@aws-sdk/s3-presigned-post"
2-
import { ArticleFilterQuerySchema, ArticleSchema, ArticleTagSchema, ArticleWriteSchema } from "@dotkomonline/types"
2+
import { Article, ArticleTag, ArticleWrite } from "./article-types"
33
import type { inferProcedureInput, inferProcedureOutput } from "@trpc/server"
44
import { z } from "zod"
55
import { isEditor } from "../../authorization"
66
import { withAuditLogEntry, withAuthentication, withAuthorization, withDatabaseTransaction } from "../../middlewares"
77
import { BasePaginateInputSchema, PaginateInputSchema } from "../../query"
88
import { procedure, t } from "../../trpc"
9+
import { ArticleFilterQuery } from "./article-service"
10+
import { parseOutputType } from "src/invariant"
911

10-
export type CreateArticleInput = inferProcedureInput<typeof createArticleProcedure>
11-
export type CreateArticleOutput = inferProcedureOutput<typeof createArticleProcedure>
12-
const createArticleProcedure = procedure
13-
.input(
14-
z.object({
15-
article: ArticleWriteSchema,
16-
tags: z.array(ArticleTagSchema.shape.name),
17-
})
18-
)
19-
.use(withAuthentication())
20-
.use(withAuthorization(isEditor()))
21-
.use(withDatabaseTransaction())
22-
.use(withAuditLogEntry())
23-
.mutation(async ({ input, ctx }) => {
24-
const article = await ctx.articleService.create(ctx.handle, input.article)
25-
const tags = await ctx.articleService.setTags(ctx.handle, article.id, input.tags)
26-
return {
27-
...article,
28-
tags,
29-
}
12+
export const ArticleMessage = Article.pick({
13+
id: true,
14+
slug: true,
15+
title: true,
16+
author: true,
17+
photographer: true,
18+
imageUrl: true,
19+
excerpt: true,
20+
content: true,
21+
isFeatured: true,
22+
vimeoId: true,
23+
createdAt: true,
24+
updatedAt: true,
25+
tags: true,
26+
})
27+
28+
function buildCreateArticleProcedure() {
29+
const Input = z.object({
30+
article: ArticleWrite,
31+
tags: z.array(ArticleTag.shape.name),
3032
})
33+
const Output = ArticleMessage
3134

32-
export type EditArticleInput = inferProcedureInput<typeof editArticleProcedure>
33-
export type EditArticleOutput = inferProcedureOutput<typeof editArticleProcedure>
34-
const editArticleProcedure = procedure
35-
.input(
36-
z.object({
37-
id: ArticleSchema.shape.id,
38-
input: ArticleWriteSchema.partial(),
39-
tags: z.array(ArticleTagSchema.shape.name),
35+
return procedure
36+
.input(Input)
37+
.output(Output)
38+
.use(withAuthentication())
39+
.use(withAuthorization(isEditor()))
40+
.use(withDatabaseTransaction())
41+
.use(withAuditLogEntry())
42+
.mutation(async ({ input, ctx }) => {
43+
const { id } = await ctx.articleService.create(ctx.handle, input.article)
44+
await ctx.articleService.setTags(ctx.handle, id, input.tags)
45+
const article = await ctx.articleService.getById(ctx.handle, id)
46+
return parseOutputType(Output, article)
4047
})
41-
)
42-
.use(withAuthentication())
43-
.use(withAuthorization(isEditor()))
44-
.use(withDatabaseTransaction())
45-
.use(withAuditLogEntry())
46-
.mutation(async ({ input, ctx }) => {
47-
const article = await ctx.articleService.update(ctx.handle, input.id, input.input)
48-
const tags = await ctx.articleService.setTags(ctx.handle, input.id, input.tags)
49-
return { ...article, tags }
48+
}
49+
50+
function buildEditArticleProcedure() {
51+
const Input = z.object({
52+
id: Article.shape.id,
53+
input: ArticleWrite.partial(),
54+
tags: z.array(ArticleTag.shape.name),
5055
})
56+
const Output = ArticleMessage
57+
58+
return procedure
59+
.input(Input)
60+
.output(Output)
61+
.use(withAuthentication())
62+
.use(withAuthorization(isEditor()))
63+
.use(withDatabaseTransaction())
64+
.use(withAuditLogEntry())
65+
.mutation(async ({ input, ctx }) => {
66+
const { id } = await ctx.articleService.update(ctx.handle, input.id, input.input)
67+
await ctx.articleService.setTags(ctx.handle, input.id, input.tags)
68+
const article = await ctx.articleService.getById(ctx.handle, id)
69+
return parseOutputType(Output, article)
70+
})
71+
}
5172

5273
export type AllArticlesInput = inferProcedureInput<typeof allArticlesProcedure>
5374
export type AllArticlesOutput = inferProcedureOutput<typeof allArticlesProcedure>
@@ -59,7 +80,7 @@ const allArticlesProcedure = procedure
5980
export type FindArticlesInput = inferProcedureInput<typeof findArticlesProcedure>
6081
export type FindArticlesOutput = inferProcedureOutput<typeof findArticlesProcedure>
6182
const findArticlesProcedure = procedure
62-
.input(BasePaginateInputSchema.extend({ filters: ArticleFilterQuerySchema }))
83+
.input(BasePaginateInputSchema.extend({ filters: ArticleFilterQuery }))
6384
.use(withDatabaseTransaction())
6485
.query(async ({ input, ctx }) => {
6586
const items = await ctx.articleService.findMany(ctx.handle, input.filters, input)
@@ -73,21 +94,22 @@ const findArticlesProcedure = procedure
7394
export type FindArticleInput = inferProcedureInput<typeof findArticleProcedure>
7495
export type FindArticleOutput = inferProcedureOutput<typeof findArticleProcedure>
7596
const findArticleProcedure = procedure
76-
.input(ArticleSchema.shape.id)
97+
.input(Article.shape.id)
7798
.use(withDatabaseTransaction())
7899
.query(async ({ input, ctx }) => ctx.articleService.findById(ctx.handle, input))
79100

80101
export type GetArticleInput = inferProcedureInput<typeof getArticleProcedure>
81102
export type GetArticleOutput = inferProcedureOutput<typeof getArticleProcedure>
82103
const getArticleProcedure = procedure
83-
.input(ArticleSchema.shape.id)
104+
.input(Article.shape.id)
84105
.use(withDatabaseTransaction())
85106
.query(async ({ input, ctx }) => ctx.articleService.getById(ctx.handle, input))
86107

87108
export type FindRelatedArticlesInput = inferProcedureInput<typeof findRelatedArticlesProcedure>
88109
export type FindRelatedArticlesOutput = inferProcedureOutput<typeof findRelatedArticlesProcedure>
89110
const findRelatedArticlesProcedure = procedure
90-
.input(ArticleSchema)
111+
// TODO: Accept article id here instead
112+
.input(Article)
91113
.use(withDatabaseTransaction())
92114
.query(async ({ input, ctx }) => ctx.articleService.findRelated(ctx.handle, input))
93115

@@ -118,8 +140,8 @@ export type AddArticleTagOutput = inferProcedureOutput<typeof addArticleTagProce
118140
const addArticleTagProcedure = procedure
119141
.input(
120142
z.object({
121-
id: ArticleSchema.shape.id,
122-
tag: ArticleTagSchema.shape.name,
143+
id: Article.shape.id,
144+
tag: ArticleTag.shape.name,
123145
})
124146
)
125147
.use(withAuthentication())
@@ -135,8 +157,8 @@ export type RemoveArticleTagOutput = inferProcedureOutput<typeof removeArticleTa
135157
const removeArticleTagProcedure = procedure
136158
.input(
137159
z.object({
138-
id: ArticleSchema.shape.id,
139-
tag: ArticleTagSchema.shape.name,
160+
id: Article.shape.id,
161+
tag: ArticleTag.shape.name,
140162
})
141163
)
142164
.use(withAuthentication())
@@ -164,8 +186,8 @@ const createArticleFileUploadProcedure = procedure
164186
})
165187

166188
export const articleRouter = t.router({
167-
create: createArticleProcedure,
168-
edit: editArticleProcedure,
189+
create: buildCreateArticleProcedure(),
190+
edit: buildEditArticleProcedure(),
169191
all: allArticlesProcedure,
170192
findArticles: findArticlesProcedure,
171193
find: findArticleProcedure,

apps/rpc/src/modules/article/article-service.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,27 @@
11
import type { S3Client } from "@aws-sdk/client-s3"
22
import type { PresignedPost } from "@aws-sdk/s3-presigned-post"
33
import type { DBHandle } from "@dotkomonline/db"
4-
import type {
5-
Article,
6-
ArticleFilterQuery,
7-
ArticleId,
8-
ArticleSlug,
9-
ArticleTag,
10-
ArticleTagName,
11-
ArticleWrite,
12-
UserId,
13-
} from "@dotkomonline/types"
4+
import { Article, ArticleId, ArticleSlug, ArticleTag, ArticleTagName, ArticleWrite } from "./article-types"
145
import { createS3PresignedPost, slugify } from "@dotkomonline/utils"
156
import { compareAsc, compareDesc } from "date-fns"
167
import { AlreadyExistsError, NotFoundError } from "../../error"
178
import type { Pageable } from "../../query"
189
import type { ArticleRepository } from "./article-repository"
1910
import type { ArticleTagLinkRepository } from "./article-tag-link-repository"
2011
import type { ArticleTagRepository } from "./article-tag-repository"
12+
import z from "zod"
13+
import { buildAnyOfFilter, buildSearchFilter, UserId } from "@dotkomonline/types"
14+
15+
/**
16+
* Filtering options available for Article
17+
*/
18+
export type ArticleFilterQuery = z.infer<typeof ArticleFilterQuery>
19+
export const ArticleFilterQuery = z
20+
.object({
21+
bySearchTerm: buildSearchFilter(),
22+
byTags: buildAnyOfFilter(ArticleTag.shape.name),
23+
})
24+
.partial()
2125

2226
export interface ArticleService {
2327
create(handle: DBHandle, data: ArticleWrite): Promise<Article>

apps/rpc/src/modules/article/article-tag-link-repository.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { DBHandle } from "@dotkomonline/db"
2-
import type { ArticleId, ArticleTagName } from "@dotkomonline/types"
2+
import type { ArticleId, ArticleTagName } from "./article-types"
33

44
export interface ArticleTagLinkRepository {
55
add(handle: DBHandle, articleId: ArticleId, articleTagName: ArticleTagName): Promise<void>

apps/rpc/src/modules/article/article-tag-repository.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { DBHandle } from "@dotkomonline/db"
2-
import { type ArticleId, type ArticleTag, type ArticleTagName, ArticleTagSchema } from "@dotkomonline/types"
2+
import { type ArticleId, ArticleTag, type ArticleTagName } from "./article-types"
33
import { parseOrReport } from "../../invariant"
44

55
export interface ArticleTagRepository {
@@ -19,12 +19,12 @@ export function getArticleTagRepository(): ArticleTagRepository {
1919
},
2020
})
2121

22-
return parseOrReport(ArticleTagSchema.nullable(), tag)
22+
return parseOrReport(ArticleTag.nullable(), tag)
2323
},
2424

2525
async findMany(handle) {
2626
const tags = await handle.articleTag.findMany()
27-
return parseOrReport(ArticleTagSchema.array(), tags)
27+
return parseOrReport(ArticleTag.array(), tags)
2828
},
2929

3030
async create(handle, articleTagName) {
@@ -34,7 +34,7 @@ export function getArticleTagRepository(): ArticleTagRepository {
3434
},
3535
})
3636

37-
return parseOrReport(ArticleTagSchema, tag)
37+
return parseOrReport(ArticleTag, tag)
3838
},
3939

4040
async delete(handle, articleTagName) {
@@ -44,7 +44,7 @@ export function getArticleTagRepository(): ArticleTagRepository {
4444
},
4545
})
4646

47-
return parseOrReport(ArticleTagSchema, tag)
47+
return parseOrReport(ArticleTag, tag)
4848
},
4949

5050
async findManyByArticleId(handle, articleId) {
@@ -60,7 +60,7 @@ export function getArticleTagRepository(): ArticleTagRepository {
6060
},
6161
})
6262

63-
return parseOrReport(ArticleTagSchema.array(), tags)
63+
return parseOrReport(ArticleTag.array(), tags)
6464
},
6565
}
6666
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import z from "zod"
2+
3+
/**
4+
* Articles may be tagged with one or more tags. Tags themselves only have a name, but are linked through a database
5+
* link table.
6+
*
7+
* @see {ArticleTagLink}
8+
*/
9+
export type ArticleTag = z.infer<typeof ArticleTag>
10+
export type ArticleTagName = ArticleTag["name"]
11+
export type ArticleTagWrite = z.infer<typeof ArticleTagWrite>
12+
13+
export const ArticleTag = z.object({
14+
name: z.string(),
15+
})
16+
17+
export const ArticleTagWrite = ArticleTag.pick({ name: true })
18+
19+
/**
20+
* Domain type for Article
21+
*
22+
* An article is a blog post written by an OnlineWeb member, typically (but not limited to) somebody from Prokom. Its
23+
* contents are stored as rich text content to be rendered by the client.
24+
*/
25+
export type Article = z.infer<typeof Article>
26+
export type ArticleId = Article["id"]
27+
export type ArticleSlug = Article["slug"]
28+
export type ArticleWrite = z.infer<typeof ArticleWrite>
29+
30+
export const Article = z.object({
31+
id: z.string().uuid(),
32+
slug: z.string(),
33+
title: z.string(),
34+
/**
35+
* The stylized name of the author of the post.
36+
*
37+
* TODO: This should likely be a foreign key to the users table.
38+
*/
39+
author: z.string(),
40+
/**
41+
* The stylized name of the photographer or artist of the cover image for the article.
42+
*
43+
* This is intended to be left as a string, as the photographer may be somebody who is not related to Online at all.
44+
*/
45+
photographer: z.string(),
46+
imageUrl: z.string().url(),
47+
excerpt: z.string(),
48+
content: z.string(),
49+
isFeatured: z.boolean(),
50+
vimeoId: z.string().nullable(),
51+
createdAt: z.coerce.date(),
52+
updatedAt: z.coerce.date(),
53+
tags: z.array(ArticleTag),
54+
})
55+
56+
export const ArticleWrite = Article.pick({
57+
slug: true,
58+
title: true,
59+
author: true,
60+
photographer: true,
61+
imageUrl: true,
62+
excerpt: true,
63+
content: true,
64+
isFeatured: true,
65+
vimeoId: true,
66+
})

0 commit comments

Comments
 (0)