Skip to content

Commit

Permalink
Stories and comments (demo)
Browse files Browse the repository at this point in the history
  • Loading branch information
koistya committed Jan 24, 2021
1 parent bfcec48 commit e64d80d
Show file tree
Hide file tree
Showing 12 changed files with 603 additions and 2 deletions.
107 changes: 105 additions & 2 deletions api/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@

import DataLoader from "dataloader";
import { Request } from "express";
import type { User, Identity } from "db";
import type { User, Identity, Story, Comment } from "db";

import db from "./db";
import { mapTo, mapToMany } from "./utils";
import { mapTo, mapToMany, mapToValues } from "./utils";
import { UnauthorizedError, ForbiddenError } from "./error";

export class Context {
Expand Down Expand Up @@ -90,4 +90,107 @@ export class Context {
.select()
.then((rows) => mapToMany(rows, keys, (x) => x.user_id)),
);

storyById = new DataLoader<string, Story | null>((keys) =>
db
.table<Story>("stories")
.whereIn("id", keys)
.select()
.then((rows) => {
rows.forEach((x) => this.storyBySlug.prime(x.slug, x));
return rows;
})
.then((rows) => mapTo(rows, keys, (x) => x.id)),
);

storyBySlug = new DataLoader<string, Story | null>((keys) =>
db
.table<Story>("stories")
.whereIn("slug", keys)
.select()
.then((rows) => {
rows.forEach((x) => this.storyById.prime(x.id, x));
return rows;
})
.then((rows) => mapTo(rows, keys, (x) => x.slug)),
);

storyCommentsCount = new DataLoader<string, number>((keys) =>
db
.table<Comment>("comments")
.whereIn("story_id", keys)
.groupBy("story_id")
.select<{ story_id: string; count: string }[]>(
"story_id",
db.raw("count(story_id)"),
)
.then((rows) =>
mapToValues(
rows,
keys,
(x) => x.story_id,
(x) => (x ? Number(x.count) : 0),
),
),
);

storyPointsCount = new DataLoader<string, number>((keys) =>
db
.table("stories")
.leftJoin("story_points", "story_points.story_id", "stories.id")
.whereIn("stories.id", keys)
.groupBy("stories.id")
.select("stories.id", db.raw("count(story_points.user_id)::int"))
.then((rows) =>
mapToValues(
rows,
keys,
(x) => x.id,
(x) => (x ? parseInt(x.count, 10) : 0),
),
),
);

storyPointGiven = new DataLoader<string, boolean>((keys) => {
const currentUser = this.user;
const userId = currentUser ? currentUser.id : "";

return db
.table("stories")
.leftJoin("story_points", function join() {
this.on("story_points.story_id", "stories.id").andOn(
"story_points.user_id",
db.raw("?", [userId]),
);
})
.whereIn("stories.id", keys)
.select<{ id: string; given: boolean }[]>(
"stories.id",
db.raw("(story_points.user_id IS NOT NULL) AS given"),
)
.then((rows) =>
mapToValues(
rows,
keys,
(x) => x.id,
(x) => x?.given || false,
),
);
});

commentById = new DataLoader<string, Comment | null>((keys) =>
db
.table<Comment>("comments")
.whereIn("id", keys)
.select()
.then((rows) => mapTo(rows, keys, (x) => x.id)),
);

commentsByStoryId = new DataLoader<string, Comment[]>((keys) =>
db
.table<Comment>("comments")
.whereIn("story_id", keys)
.select()
.then((rows) => mapToMany(rows, keys, (x) => x.story_id)),
);
}
1 change: 1 addition & 0 deletions api/mutations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@

export * from "./auth";
export * from "./user";
export * from "./story";
160 changes: 160 additions & 0 deletions api/mutations/story.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/**
* GraphQL API mutations related to stories.
*
* @copyright 2016-present Kriasoft (https://git.io/vMINh)
*/

import slugify from "slugify";
import validator from "validator";
import { v4 as uuid } from "uuid";
import { mutationWithClientMutationId } from "graphql-relay";
import {
GraphQLNonNull,
GraphQLID,
GraphQLString,
GraphQLBoolean,
GraphQLList,
} from "graphql";

import db, { Story } from "../db";
import { Context } from "../context";
import { StoryType } from "../types";
import { fromGlobalId, validate } from "../utils";

function slug(text: string) {
return slugify(text, { lower: true });
}

export const upsertStory = mutationWithClientMutationId({
name: "UpsertStory",
description: "Creates or updates a story.",

inputFields: {
id: { type: GraphQLID },
title: { type: GraphQLString },
text: { type: GraphQLString },
approved: { type: GraphQLBoolean },
validateOnly: { type: GraphQLBoolean },
},

outputFields: {
story: { type: StoryType },
errors: {
// TODO: Extract into a custom type.
type: new GraphQLList(
new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(GraphQLString))),
),
},
},

async mutateAndGetPayload(input, ctx: Context) {
const id = input.id ? fromGlobalId(input.id, "Story") : null;
const newId = uuid();

let story: Story | undefined;

if (id) {
story = await db.table<Story>("stories").where({ id }).first();

if (!story) {
throw new Error(`Cannot find the story # ${id}.`);
}

// Only the author of the story or admins can edit it
ctx.ensureAuthorized(
(user) => story?.author_id === user.id || user.admin,
);
} else {
ctx.ensureAuthorized();
}

// Validate and sanitize user input
const { data, errors } = validate(input, (x) =>
x
.field("title", { trim: true })
.isRequired()
.isLength({ min: 5, max: 80 })

.field("text", { alias: "URL or text", trim: true })
.isRequired()
.isLength({ min: 10, max: 1000 })

.field("text", {
trim: true,
as: "is_url",
transform: (x) =>
validator.isURL(x, { protocols: ["http", "https"] }),
})

.field("approved")
.is(() => Boolean(ctx.user?.admin), "Only admins can approve a story."),
);

if (errors.length > 0) {
return { errors };
}

if (data.title) {
data.slug = `${slug(data.title)}-${(id || newId).substr(29)}`;
}

if (id && Object.keys(data).length) {
[story] = await db
.table<Story>("stories")
.where({ id })
.update({
...(data as Partial<Story>),
updated_at: db.fn.now(),
})
.returning("*");
} else {
[story] = await db
.table<Story>("stories")
.insert({
id: newId,
...(data as Partial<Story>),
author_id: ctx.user?.id,
approved: ctx.user?.admin ? true : false,
})
.returning("*");
}

return { story };
},
});

export const likeStory = mutationWithClientMutationId({
name: "LikeStory",
description: 'Marks the story as "liked".',

inputFields: {
id: { type: new GraphQLNonNull(GraphQLID) },
},

outputFields: {
story: { type: StoryType },
},

async mutateAndGetPayload(input, ctx: Context) {
// Check permissions
ctx.ensureAuthorized();

const id = fromGlobalId(input.id, "Story");
const keys = { story_id: id, user_id: ctx.user.id };

const points = await db
.table("story_points")
.where(keys)
.select(db.raw("1"));

if (points.length) {
await db.table("story_points").where(keys).del();
} else {
await db.table("story_points").insert(keys);
}

const story = db.table("stories").where({ id }).first();

return { story };
},
});
8 changes: 8 additions & 0 deletions api/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ export const { nodeInterface, nodeField, nodesField } = nodeDefinitions(
switch (type) {
case "User":
return context.userById.load(id).then(assignType("User"));
case "Story":
return context.storyById.load(id).then(assignType("Story"));
case "Comment":
return context.commentById.load(id).then(assignType("Comment"));
default:
return null;
}
Expand All @@ -25,6 +29,10 @@ export const { nodeInterface, nodeField, nodesField } = nodeDefinitions(
switch (getType(obj)) {
case "User":
return require("./types").UserType;
case "Story":
return require("./types").StoryType;
case "Comment":
return require("./types").CommentType;
default:
return null;
}
Expand Down
1 change: 1 addition & 0 deletions api/queries/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
*/

export * from "./user";
export * from "./story";
55 changes: 55 additions & 0 deletions api/queries/story.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* The top-level GraphQL API query fields related to stories.
*
* @copyright 2016-present Kriasoft (https://git.io/vMINh)
*/

import {
GraphQLList,
GraphQLNonNull,
GraphQLString,
GraphQLFieldConfig,
} from "graphql";

import db from "../db";
import { Context } from "../context";
import { StoryType } from "../types";

export const story: GraphQLFieldConfig<unknown, Context> = {
type: StoryType,

args: {
slug: { type: new GraphQLNonNull(GraphQLString) },
},

async resolve(root, { slug }) {
let story = await db.table("stories").where({ slug }).first();

// Attempts to find a story by partial ID contained in the slug.
if (!story) {
const match = slug.match(/[a-f0-9]{7}$/);
if (match) {
story = await db
.table("stories")
.whereRaw(`id::text LIKE '%${match[0]}'`)
.first();
}
}

return story;
},
};

export const stories: GraphQLFieldConfig<unknown, Context> = {
type: new GraphQLList(StoryType),

resolve(self, args, ctx) {
return db
.table("stories")
.where({ approved: true })
.orWhere({ approved: false, author_id: ctx.user ? ctx.user.id : null })
.orderBy("created_at", "desc")
.limit(100)
.select();
},
};
Loading

0 comments on commit e64d80d

Please sign in to comment.