Skip to content

Commit e64d80d

Browse files
committed
Stories and comments (demo)
1 parent bfcec48 commit e64d80d

File tree

12 files changed

+603
-2
lines changed

12 files changed

+603
-2
lines changed

api/context.ts

Lines changed: 105 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@
66

77
import DataLoader from "dataloader";
88
import { Request } from "express";
9-
import type { User, Identity } from "db";
9+
import type { User, Identity, Story, Comment } from "db";
1010

1111
import db from "./db";
12-
import { mapTo, mapToMany } from "./utils";
12+
import { mapTo, mapToMany, mapToValues } from "./utils";
1313
import { UnauthorizedError, ForbiddenError } from "./error";
1414

1515
export class Context {
@@ -90,4 +90,107 @@ export class Context {
9090
.select()
9191
.then((rows) => mapToMany(rows, keys, (x) => x.user_id)),
9292
);
93+
94+
storyById = new DataLoader<string, Story | null>((keys) =>
95+
db
96+
.table<Story>("stories")
97+
.whereIn("id", keys)
98+
.select()
99+
.then((rows) => {
100+
rows.forEach((x) => this.storyBySlug.prime(x.slug, x));
101+
return rows;
102+
})
103+
.then((rows) => mapTo(rows, keys, (x) => x.id)),
104+
);
105+
106+
storyBySlug = new DataLoader<string, Story | null>((keys) =>
107+
db
108+
.table<Story>("stories")
109+
.whereIn("slug", keys)
110+
.select()
111+
.then((rows) => {
112+
rows.forEach((x) => this.storyById.prime(x.id, x));
113+
return rows;
114+
})
115+
.then((rows) => mapTo(rows, keys, (x) => x.slug)),
116+
);
117+
118+
storyCommentsCount = new DataLoader<string, number>((keys) =>
119+
db
120+
.table<Comment>("comments")
121+
.whereIn("story_id", keys)
122+
.groupBy("story_id")
123+
.select<{ story_id: string; count: string }[]>(
124+
"story_id",
125+
db.raw("count(story_id)"),
126+
)
127+
.then((rows) =>
128+
mapToValues(
129+
rows,
130+
keys,
131+
(x) => x.story_id,
132+
(x) => (x ? Number(x.count) : 0),
133+
),
134+
),
135+
);
136+
137+
storyPointsCount = new DataLoader<string, number>((keys) =>
138+
db
139+
.table("stories")
140+
.leftJoin("story_points", "story_points.story_id", "stories.id")
141+
.whereIn("stories.id", keys)
142+
.groupBy("stories.id")
143+
.select("stories.id", db.raw("count(story_points.user_id)::int"))
144+
.then((rows) =>
145+
mapToValues(
146+
rows,
147+
keys,
148+
(x) => x.id,
149+
(x) => (x ? parseInt(x.count, 10) : 0),
150+
),
151+
),
152+
);
153+
154+
storyPointGiven = new DataLoader<string, boolean>((keys) => {
155+
const currentUser = this.user;
156+
const userId = currentUser ? currentUser.id : "";
157+
158+
return db
159+
.table("stories")
160+
.leftJoin("story_points", function join() {
161+
this.on("story_points.story_id", "stories.id").andOn(
162+
"story_points.user_id",
163+
db.raw("?", [userId]),
164+
);
165+
})
166+
.whereIn("stories.id", keys)
167+
.select<{ id: string; given: boolean }[]>(
168+
"stories.id",
169+
db.raw("(story_points.user_id IS NOT NULL) AS given"),
170+
)
171+
.then((rows) =>
172+
mapToValues(
173+
rows,
174+
keys,
175+
(x) => x.id,
176+
(x) => x?.given || false,
177+
),
178+
);
179+
});
180+
181+
commentById = new DataLoader<string, Comment | null>((keys) =>
182+
db
183+
.table<Comment>("comments")
184+
.whereIn("id", keys)
185+
.select()
186+
.then((rows) => mapTo(rows, keys, (x) => x.id)),
187+
);
188+
189+
commentsByStoryId = new DataLoader<string, Comment[]>((keys) =>
190+
db
191+
.table<Comment>("comments")
192+
.whereIn("story_id", keys)
193+
.select()
194+
.then((rows) => mapToMany(rows, keys, (x) => x.story_id)),
195+
);
93196
}

api/mutations/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@
66

77
export * from "./auth";
88
export * from "./user";
9+
export * from "./story";

api/mutations/story.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/**
2+
* GraphQL API mutations related to stories.
3+
*
4+
* @copyright 2016-present Kriasoft (https://git.io/vMINh)
5+
*/
6+
7+
import slugify from "slugify";
8+
import validator from "validator";
9+
import { v4 as uuid } from "uuid";
10+
import { mutationWithClientMutationId } from "graphql-relay";
11+
import {
12+
GraphQLNonNull,
13+
GraphQLID,
14+
GraphQLString,
15+
GraphQLBoolean,
16+
GraphQLList,
17+
} from "graphql";
18+
19+
import db, { Story } from "../db";
20+
import { Context } from "../context";
21+
import { StoryType } from "../types";
22+
import { fromGlobalId, validate } from "../utils";
23+
24+
function slug(text: string) {
25+
return slugify(text, { lower: true });
26+
}
27+
28+
export const upsertStory = mutationWithClientMutationId({
29+
name: "UpsertStory",
30+
description: "Creates or updates a story.",
31+
32+
inputFields: {
33+
id: { type: GraphQLID },
34+
title: { type: GraphQLString },
35+
text: { type: GraphQLString },
36+
approved: { type: GraphQLBoolean },
37+
validateOnly: { type: GraphQLBoolean },
38+
},
39+
40+
outputFields: {
41+
story: { type: StoryType },
42+
errors: {
43+
// TODO: Extract into a custom type.
44+
type: new GraphQLList(
45+
new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(GraphQLString))),
46+
),
47+
},
48+
},
49+
50+
async mutateAndGetPayload(input, ctx: Context) {
51+
const id = input.id ? fromGlobalId(input.id, "Story") : null;
52+
const newId = uuid();
53+
54+
let story: Story | undefined;
55+
56+
if (id) {
57+
story = await db.table<Story>("stories").where({ id }).first();
58+
59+
if (!story) {
60+
throw new Error(`Cannot find the story # ${id}.`);
61+
}
62+
63+
// Only the author of the story or admins can edit it
64+
ctx.ensureAuthorized(
65+
(user) => story?.author_id === user.id || user.admin,
66+
);
67+
} else {
68+
ctx.ensureAuthorized();
69+
}
70+
71+
// Validate and sanitize user input
72+
const { data, errors } = validate(input, (x) =>
73+
x
74+
.field("title", { trim: true })
75+
.isRequired()
76+
.isLength({ min: 5, max: 80 })
77+
78+
.field("text", { alias: "URL or text", trim: true })
79+
.isRequired()
80+
.isLength({ min: 10, max: 1000 })
81+
82+
.field("text", {
83+
trim: true,
84+
as: "is_url",
85+
transform: (x) =>
86+
validator.isURL(x, { protocols: ["http", "https"] }),
87+
})
88+
89+
.field("approved")
90+
.is(() => Boolean(ctx.user?.admin), "Only admins can approve a story."),
91+
);
92+
93+
if (errors.length > 0) {
94+
return { errors };
95+
}
96+
97+
if (data.title) {
98+
data.slug = `${slug(data.title)}-${(id || newId).substr(29)}`;
99+
}
100+
101+
if (id && Object.keys(data).length) {
102+
[story] = await db
103+
.table<Story>("stories")
104+
.where({ id })
105+
.update({
106+
...(data as Partial<Story>),
107+
updated_at: db.fn.now(),
108+
})
109+
.returning("*");
110+
} else {
111+
[story] = await db
112+
.table<Story>("stories")
113+
.insert({
114+
id: newId,
115+
...(data as Partial<Story>),
116+
author_id: ctx.user?.id,
117+
approved: ctx.user?.admin ? true : false,
118+
})
119+
.returning("*");
120+
}
121+
122+
return { story };
123+
},
124+
});
125+
126+
export const likeStory = mutationWithClientMutationId({
127+
name: "LikeStory",
128+
description: 'Marks the story as "liked".',
129+
130+
inputFields: {
131+
id: { type: new GraphQLNonNull(GraphQLID) },
132+
},
133+
134+
outputFields: {
135+
story: { type: StoryType },
136+
},
137+
138+
async mutateAndGetPayload(input, ctx: Context) {
139+
// Check permissions
140+
ctx.ensureAuthorized();
141+
142+
const id = fromGlobalId(input.id, "Story");
143+
const keys = { story_id: id, user_id: ctx.user.id };
144+
145+
const points = await db
146+
.table("story_points")
147+
.where(keys)
148+
.select(db.raw("1"));
149+
150+
if (points.length) {
151+
await db.table("story_points").where(keys).del();
152+
} else {
153+
await db.table("story_points").insert(keys);
154+
}
155+
156+
const story = db.table("stories").where({ id }).first();
157+
158+
return { story };
159+
},
160+
});

api/node.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ export const { nodeInterface, nodeField, nodesField } = nodeDefinitions(
1717
switch (type) {
1818
case "User":
1919
return context.userById.load(id).then(assignType("User"));
20+
case "Story":
21+
return context.storyById.load(id).then(assignType("Story"));
22+
case "Comment":
23+
return context.commentById.load(id).then(assignType("Comment"));
2024
default:
2125
return null;
2226
}
@@ -25,6 +29,10 @@ export const { nodeInterface, nodeField, nodesField } = nodeDefinitions(
2529
switch (getType(obj)) {
2630
case "User":
2731
return require("./types").UserType;
32+
case "Story":
33+
return require("./types").StoryType;
34+
case "Comment":
35+
return require("./types").CommentType;
2836
default:
2937
return null;
3038
}

api/queries/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@
55
*/
66

77
export * from "./user";
8+
export * from "./story";

api/queries/story.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* The top-level GraphQL API query fields related to stories.
3+
*
4+
* @copyright 2016-present Kriasoft (https://git.io/vMINh)
5+
*/
6+
7+
import {
8+
GraphQLList,
9+
GraphQLNonNull,
10+
GraphQLString,
11+
GraphQLFieldConfig,
12+
} from "graphql";
13+
14+
import db from "../db";
15+
import { Context } from "../context";
16+
import { StoryType } from "../types";
17+
18+
export const story: GraphQLFieldConfig<unknown, Context> = {
19+
type: StoryType,
20+
21+
args: {
22+
slug: { type: new GraphQLNonNull(GraphQLString) },
23+
},
24+
25+
async resolve(root, { slug }) {
26+
let story = await db.table("stories").where({ slug }).first();
27+
28+
// Attempts to find a story by partial ID contained in the slug.
29+
if (!story) {
30+
const match = slug.match(/[a-f0-9]{7}$/);
31+
if (match) {
32+
story = await db
33+
.table("stories")
34+
.whereRaw(`id::text LIKE '%${match[0]}'`)
35+
.first();
36+
}
37+
}
38+
39+
return story;
40+
},
41+
};
42+
43+
export const stories: GraphQLFieldConfig<unknown, Context> = {
44+
type: new GraphQLList(StoryType),
45+
46+
resolve(self, args, ctx) {
47+
return db
48+
.table("stories")
49+
.where({ approved: true })
50+
.orWhere({ approved: false, author_id: ctx.user ? ctx.user.id : null })
51+
.orderBy("created_at", "desc")
52+
.limit(100)
53+
.select();
54+
},
55+
};

0 commit comments

Comments
 (0)