From 11b4f3439f2d2adc1d305257b8054a21fe160852 Mon Sep 17 00:00:00 2001 From: Konstantin Tarkus Date: Thu, 20 Aug 2020 11:56:01 +0300 Subject: [PATCH] Remove stories, comments (demo) --- api/schema.graphql | 72 ---------------- api/src/context.ts | 115 ++----------------------- api/src/db.ts | 32 ------- api/src/mutations/index.ts | 1 - api/src/mutations/story.ts | 160 ----------------------------------- api/src/mutations/user.ts | 34 ++------ api/src/node.ts | 8 -- api/src/queries/index.ts | 1 - api/src/queries/story.ts | 55 ------------ api/src/types/comment.ts | 47 ---------- api/src/types/index.ts | 2 - api/src/types/story.ts | 97 --------------------- db/migrations/001_initial.js | 37 +------- 13 files changed, 14 insertions(+), 647 deletions(-) delete mode 100644 api/src/mutations/story.ts delete mode 100644 api/src/queries/story.ts delete mode 100644 api/src/types/comment.ts delete mode 100644 api/src/types/story.ts diff --git a/api/schema.graphql b/api/schema.graphql index f806819f..903ccc79 100644 --- a/api/schema.graphql +++ b/api/schema.graphql @@ -19,8 +19,6 @@ 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 @@ -116,32 +114,6 @@ 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 @@ -151,15 +123,6 @@ type Mutation { # Updates a user. updateUser(input: UpdateUserInput!): UpdateUserPayload - - # Deletes a user. - deleteUser(input: DeleteUserInput!): DeleteUserPayload - - # Creates or updates a story. - upsertStory(input: UpsertStoryInput!): UpsertStoryPayload - - # Marks the story as "liked". - likeStory(input: LikeStoryInput!): LikeStoryPayload } type SignInPayload { @@ -183,38 +146,3 @@ input UpdateUserInput { validateOnly: Boolean clientMutationId: String } - -type DeleteUserPayload { - deletedUserId: String - clientMutationId: String -} - -input DeleteUserInput { - id: ID! - 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/src/context.ts b/api/src/context.ts index f17c897e..0f515220 100644 --- a/api/src/context.ts +++ b/api/src/context.ts @@ -7,8 +7,8 @@ import DataLoader from "dataloader"; import { Request } from "express"; -import db, { User, Identity, Story, Comment } from "./db"; -import { mapTo, mapToMany, mapToValues } from "./utils"; +import db, { User, Identity } from "./db"; +import { mapTo, mapToMany } from "./utils"; import { UnauthorizedError, ForbiddenError } from "./error"; export class Context { @@ -24,6 +24,10 @@ export class Context { } } + /* + * Authentication and authorization + * ------------------------------------------------------------------------ */ + get user(): User | null { return this.req.user; } @@ -36,10 +40,6 @@ export class Context { this.req.signOut(); } - /* - * Authorization - * ------------------------------------------------------------------------ */ - ensureAuthorized(check?: (user: User) => boolean): void { if (!this.req.user) { throw new UnauthorizedError(); @@ -89,107 +89,4 @@ 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/src/db.ts b/api/src/db.ts index 2bf556fe..87019422 100644 --- a/api/src/db.ts +++ b/api/src/db.ts @@ -49,21 +49,6 @@ 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; @@ -86,23 +71,6 @@ 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; diff --git a/api/src/mutations/index.ts b/api/src/mutations/index.ts index 74a01d68..c395319b 100644 --- a/api/src/mutations/index.ts +++ b/api/src/mutations/index.ts @@ -6,4 +6,3 @@ export * from "./auth"; export * from "./user"; -export * from "./story"; diff --git a/api/src/mutations/story.ts b/api/src/mutations/story.ts deleted file mode 100644 index 762ec527..00000000 --- a/api/src/mutations/story.ts +++ /dev/null @@ -1,160 +0,0 @@ -/** - * 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/src/mutations/user.ts b/api/src/mutations/user.ts index 9ac9521f..bb5ba74f 100644 --- a/api/src/mutations/user.ts +++ b/api/src/mutations/user.ts @@ -74,7 +74,13 @@ export const updateUser = mutationWithClientMutationId({ .field("locale") .isLength({ max: 10 }) - .field("admin", { as: "admin" }) + .field("admin") + .is( + () => Boolean(ctx.user?.admin), + "Only admins can update this field.", + ) + + .field("archived") .is( () => Boolean(ctx.user?.admin), "Only admins can update this field.", @@ -98,29 +104,3 @@ export const updateUser = mutationWithClientMutationId({ return { user }; }, }); - -export const deleteUser = mutationWithClientMutationId({ - name: "DeleteUser", - description: "Deletes a user.", - - inputFields: { - id: { type: new GraphQLNonNull(GraphQLID) }, - }, - - outputFields: { - deletedUserId: { - type: GraphQLString, - }, - }, - - async mutateAndGetPayload(input, ctx: Context) { - // Check permissions - ctx.ensureAuthorized((user) => user.admin); - - const id = fromGlobalId(input.id, "User"); - - await db.table("users").where({ id }).del(); - - return { deletedUserId: input.id }; - }, -}); diff --git a/api/src/node.ts b/api/src/node.ts index 36810c9e..7cd963bb 100644 --- a/api/src/node.ts +++ b/api/src/node.ts @@ -17,10 +17,6 @@ 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; } @@ -29,10 +25,6 @@ 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/src/queries/index.ts b/api/src/queries/index.ts index 8721dbac..df63e3a3 100644 --- a/api/src/queries/index.ts +++ b/api/src/queries/index.ts @@ -5,4 +5,3 @@ */ export * from "./user"; -export * from "./story"; diff --git a/api/src/queries/story.ts b/api/src/queries/story.ts deleted file mode 100644 index 641a4d8c..00000000 --- a/api/src/queries/story.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * 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/src/types/comment.ts b/api/src/types/comment.ts deleted file mode 100644 index 66ac2e31..00000000 --- a/api/src/types/comment.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * 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/src/types/index.ts b/api/src/types/index.ts index f1a82b48..ea27bb30 100644 --- a/api/src/types/index.ts +++ b/api/src/types/index.ts @@ -5,5 +5,3 @@ */ export * from "./user"; -export * from "./story"; -export * from "./comment"; diff --git a/api/src/types/story.ts b/api/src/types/story.ts deleted file mode 100644 index 20f378f9..00000000 --- a/api/src/types/story.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * 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 585769b8..cec85485 100644 --- a/db/migrations/001_initial.js +++ b/db/migrations/001_initial.js @@ -72,45 +72,10 @@ 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"); await db.raw("DROP DOMAIN IF EXISTS user_id");