Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

User stories and comments (demo) #242

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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