diff --git a/api/context.ts b/api/context.ts index 66a5eaca..d09a79c4 100644 --- a/api/context.ts +++ b/api/context.ts @@ -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 { @@ -90,4 +90,107 @@ export class Context { .select() .then((rows) => mapToMany(rows, keys, (x) => x.user_id)), ); + + storyById = new DataLoader((keys) => + db + .table("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((keys) => + db + .table("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((keys) => + db + .table("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((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((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((keys) => + db + .table("comments") + .whereIn("id", keys) + .select() + .then((rows) => mapTo(rows, keys, (x) => x.id)), + ); + + commentsByStoryId = new DataLoader((keys) => + db + .table("comments") + .whereIn("story_id", keys) + .select() + .then((rows) => mapToMany(rows, keys, (x) => x.story_id)), + ); } diff --git a/api/mutations/index.ts b/api/mutations/index.ts index c395319b..74a01d68 100644 --- a/api/mutations/index.ts +++ b/api/mutations/index.ts @@ -6,3 +6,4 @@ export * from "./auth"; export * from "./user"; +export * from "./story"; diff --git a/api/mutations/story.ts b/api/mutations/story.ts new file mode 100644 index 00000000..762ec527 --- /dev/null +++ b/api/mutations/story.ts @@ -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("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("stories") + .where({ id }) + .update({ + ...(data as Partial), + updated_at: db.fn.now(), + }) + .returning("*"); + } else { + [story] = await db + .table("stories") + .insert({ + id: newId, + ...(data as Partial), + 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 }; + }, +}); diff --git a/api/node.ts b/api/node.ts index 7cd963bb..36810c9e 100644 --- a/api/node.ts +++ b/api/node.ts @@ -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; } @@ -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; } diff --git a/api/queries/index.ts b/api/queries/index.ts index df63e3a3..8721dbac 100644 --- a/api/queries/index.ts +++ b/api/queries/index.ts @@ -5,3 +5,4 @@ */ export * from "./user"; +export * from "./story"; diff --git a/api/queries/story.ts b/api/queries/story.ts new file mode 100644 index 00000000..641a4d8c --- /dev/null +++ b/api/queries/story.ts @@ -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 = { + 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 = { + 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(); + }, +}; diff --git a/api/schema.graphql b/api/schema.graphql index 9d048693..f2f32c2d 100644 --- a/api/schema.graphql +++ b/api/schema.graphql @@ -19,6 +19,8 @@ type Root { me: User user(username: String!): User users(after: String, first: Int): UserConnection + story(slug: String!): Story + stories: [Story] } # An object with an ID @@ -114,6 +116,32 @@ type UserEdge { cursor: String! } +type Story implements Node { + # The ID of an object + id: ID! + author: User! + slug: String! + title: String! + text(truncate: Int): String! + isURL: Boolean! + comments: [Comment] + pointsCount: Int! + pointGiven: Boolean! + commentsCount: Int! + createdAt(format: String): String + updatedAt(format: String): String +} + +type Comment implements Node { + # The ID of an object + id: ID! + parent: Comment + author: User! + text: String + createdAt(format: String): String + updatedAt(format: String): String +} + type Mutation { # Authenticates user with an ID token or email and password. signIn(idToken: String, email: String, password: String): SignInPayload @@ -123,6 +151,12 @@ type Mutation { # Updates a user. updateUser(input: UpdateUserInput!): UpdateUserPayload + + # Creates or updates a story. + upsertStory(input: UpsertStoryInput!): UpsertStoryPayload + + # Marks the story as "liked". + likeStory(input: LikeStoryInput!): LikeStoryPayload } type SignInPayload { @@ -147,3 +181,28 @@ input UpdateUserInput { validateOnly: Boolean clientMutationId: String } + +type UpsertStoryPayload { + story: Story + errors: [[String!]!] + clientMutationId: String +} + +input UpsertStoryInput { + id: ID + title: String + text: String + approved: Boolean + validateOnly: Boolean + clientMutationId: String +} + +type LikeStoryPayload { + story: Story + clientMutationId: String +} + +input LikeStoryInput { + id: ID! + clientMutationId: String +} diff --git a/api/types/comment.ts b/api/types/comment.ts new file mode 100644 index 00000000..66ac2e31 --- /dev/null +++ b/api/types/comment.ts @@ -0,0 +1,47 @@ +/** + * The custom GraphQL type that represents a story comment. + * + * @copyright 2016-present Kriasoft (https://git.io/vMINh) + */ + +import { globalIdField } from "graphql-relay"; +import { GraphQLObjectType, GraphQLNonNull, GraphQLString } from "graphql"; + +import { Comment } from "../db"; +import { UserType } from "./user"; +import { nodeInterface } from "../node"; +import { dateField } from "../fields"; +import { Context } from "../context"; + +export const CommentType: GraphQLObjectType = new GraphQLObjectType< + Comment, + Context +>({ + name: "Comment", + interfaces: [nodeInterface], + + fields: () => ({ + id: globalIdField(), + + parent: { + type: CommentType, + resolve(self, args, ctx) { + return self.parent_id && ctx.commentById.load(self.parent_id); + }, + }, + + author: { + type: new GraphQLNonNull(UserType), + resolve(self, args, ctx) { + return ctx.userById.load(self.author_id); + }, + }, + + text: { + type: GraphQLString, + }, + + createdAt: dateField((self) => self.created_at), + updatedAt: dateField((self) => self.updated_at), + }), +}); diff --git a/api/types/index.ts b/api/types/index.ts index ea27bb30..f1a82b48 100644 --- a/api/types/index.ts +++ b/api/types/index.ts @@ -5,3 +5,5 @@ */ export * from "./user"; +export * from "./story"; +export * from "./comment"; diff --git a/api/types/story.ts b/api/types/story.ts new file mode 100644 index 00000000..20f378f9 --- /dev/null +++ b/api/types/story.ts @@ -0,0 +1,97 @@ +/** + * The custom GraphQL type that represents a story. + * + * @copyright 2016-present Kriasoft (https://git.io/vMINh) + */ + +import { truncate } from "lodash"; +import { globalIdField } from "graphql-relay"; +import { + GraphQLObjectType, + GraphQLList, + GraphQLBoolean, + GraphQLNonNull, + GraphQLInt, + GraphQLString, +} from "graphql"; + +import { Story } from "../db"; +import { UserType } from "./user"; +import { CommentType } from "./comment"; +import { nodeInterface } from "../node"; +import { dateField } from "../fields"; +import { Context } from "../context"; + +export const StoryType = new GraphQLObjectType({ + name: "Story", + interfaces: [nodeInterface], + + fields: { + id: globalIdField(), + + author: { + type: new GraphQLNonNull(UserType), + resolve(self, args, ctx) { + return ctx.userById.load(self.author_id); + }, + }, + + slug: { + type: new GraphQLNonNull(GraphQLString), + }, + + title: { + type: new GraphQLNonNull(GraphQLString), + }, + + text: { + type: new GraphQLNonNull(GraphQLString), + args: { + truncate: { type: GraphQLInt }, + }, + resolve(self, args) { + return args.truncate && self.text + ? truncate(self.text, { length: args.truncate }) + : self.text; + }, + }, + + isURL: { + type: new GraphQLNonNull(GraphQLBoolean), + resolve(self) { + return self.is_url; + }, + }, + + comments: { + type: new GraphQLList(CommentType), + resolve(self, args, ctx) { + return ctx.commentsByStoryId.load(self.id); + }, + }, + + pointsCount: { + type: new GraphQLNonNull(GraphQLInt), + resolve(self, args, ctx) { + return ctx.storyPointsCount.load(self.id); + }, + }, + + pointGiven: { + type: new GraphQLNonNull(GraphQLBoolean), + resolve(self, args, ctx) { + return ctx.user ? ctx.storyPointGiven.load(self.id) : false; + }, + }, + + commentsCount: { + type: new GraphQLNonNull(GraphQLInt), + resolve(self, args, ctx) { + return ctx.storyCommentsCount.load(self.id); + }, + }, + + createdAt: dateField((self) => self.created_at), + updatedAt: dateField((self) => self.updated_at), + }, +}); diff --git a/db/migrations/001_initial.js b/db/migrations/001_initial.js index cec85485..4cadc8d6 100644 --- a/db/migrations/001_initial.js +++ b/db/migrations/001_initial.js @@ -72,9 +72,45 @@ module.exports.up = async (/** @type {Knex} */ db) /* prettier-ignore */ => { table.timestamp("expires_at"); table.primary(["provider", "id"]); }); + + await db.schema.createTable("stories", (table) => { + table.uuid("id").notNullable().defaultTo(db.raw("uuid_generate_v4()")).primary(); + table.specificType("author_id", "user_id").notNullable().references("id").inTable("users").onDelete("CASCADE").onUpdate("CASCADE"); + table.string("slug", 120).notNullable(); + table.string("title", 120).notNullable(); + table.string("text", 2000); + table.boolean("is_url").notNullable().defaultTo(false); + table.boolean("approved").notNullable().defaultTo(false); + table.timestamps(false, true); + }); + + await db.schema.createTable("story_points", (table) => { + table.uuid("story_id").references("id").inTable("stories").onDelete("CASCADE").onUpdate("CASCADE"); + table.specificType("user_id", "user_id").notNullable().references("id").inTable("users").onDelete("CASCADE").onUpdate("CASCADE"); + table.primary(["story_id", "user_id"]); + }); + + await db.schema.createTable("comments", (table) => { + table.uuid("id").notNullable().defaultTo(db.raw("uuid_generate_v4()")).primary(); + table.uuid("story_id").notNullable().references("id").inTable("stories").onDelete("CASCADE").onUpdate("CASCADE"); + table.uuid("parent_id").references("id").inTable("comments").onDelete("CASCADE").onUpdate("CASCADE"); + table.specificType("author_id", "user_id").notNullable().references("id").inTable("users").onDelete("CASCADE").onUpdate("CASCADE"); + table.text("text"); + table.timestamps(false, true); + }); + + await db.schema.createTable("comment_points", (table) => { + table.uuid("comment_id").references("id").inTable("comments").onDelete("CASCADE").onUpdate("CASCADE"); + table.specificType("user_id", "user_id").notNullable().references("id").inTable("users").onDelete("CASCADE").onUpdate("CASCADE"); + table.primary(["comment_id", "user_id"]); + }); }; module.exports.down = async (/** @type {Knex} */ db) => { + await db.schema.dropTableIfExists("comment_points"); + await db.schema.dropTableIfExists("comments"); + await db.schema.dropTableIfExists("story_points"); + await db.schema.dropTableIfExists("stories"); await db.schema.dropTableIfExists("identities"); await db.schema.dropTableIfExists("users"); await db.raw("DROP TYPE IF EXISTS identity_provider"); diff --git a/db/types.d.ts b/db/types.d.ts index a0729f29..ffd7fe47 100644 --- a/db/types.d.ts +++ b/db/types.d.ts @@ -14,6 +14,21 @@ export enum IdentityProvider { playgames = "playgames", } +export type CommentPoint = { + comment_id: string; + user_id: string; +}; + +export type Comment = { + id: string; + story_id: string; + parent_id: string | null; + author_id: string; + text: string | null; + created_at: Date; + updated_at: Date; +}; + export type Identity = { provider: IdentityProvider; id: string; @@ -36,6 +51,23 @@ export type Identity = { expires_at: Date | null; }; +export type Story = { + id: string; + author_id: string; + slug: string; + title: string; + text: string | null; + is_url: boolean; + approved: boolean; + created_at: Date; + updated_at: Date; +}; + +export type StoryPoint = { + story_id: string; + user_id: string; +}; + export type User = { id: string; username: string;