diff --git a/package-lock.json b/package-lock.json index 5bbcfe1..02cf583 100644 --- a/package-lock.json +++ b/package-lock.json @@ -336,6 +336,25 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-12.7.5.tgz", "integrity": "sha512-9fq4jZVhPNW8r+UYKnxF1e2HkDWOWKM5bC2/7c9wPV835I0aOrVbS/Hw/pWPk2uKrNXQqg9Z959Kz+IYDd5p3w==" }, + "@types/pg": { + "version": "7.11.2", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-7.11.2.tgz", + "integrity": "sha512-4+rj7fnidA77jFURNanuPPc1HrQv+RkhI6s+K18G9zOKbOUUpChA/rbNMqFukNuZ89LoIt/I9dAlxf329TjCNw==", + "dev": true, + "requires": { + "@types/node": "*", + "@types/pg-types": "*" + } + }, + "@types/pg-types": { + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/@types/pg-types/-/pg-types-1.11.4.tgz", + "integrity": "sha512-WdIiQmE347LGc1Vq3Ki8sk3iyCuLgnccqVzgxek6gEHp2H0p3MQ3jniIHt+bRODXKju4kNQ+mp53lmP5+/9moQ==", + "dev": true, + "requires": { + "moment": ">=2.14.0" + } + }, "@types/pino": { "version": "5.8.10", "resolved": "https://registry.npmjs.org/@types/pino/-/pino-5.8.10.tgz", @@ -4778,6 +4797,12 @@ } } }, + "moment": { + "version": "2.24.0", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz", + "integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==", + "dev": true + }, "monet": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/monet/-/monet-0.9.0.tgz", diff --git a/package.json b/package.json index 47bfa70..e93cb13 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "http-errors": "^1.7.3", "luxon": "^1.19.2", "monet": "^0.9.0", + "pg": "^7.12.1", "pg-promise": "^9.2.1", "pino": "^5.13.3", "pug": "^2.0.4", @@ -48,6 +49,7 @@ "@types/http-errors": "^1.6.2", "@types/luxon": "^1.15.2", "@types/node": "^12.7.5", + "@types/pg": "^7.11.2", "@types/pino": "^5.8.10", "@types/webpack-dev-middleware": "^2.0.3", "css-loader": "^3.2.0", diff --git a/sql/migrations/patches/20191007000600-task-schedule-type/down.sql b/sql/migrations/patches/20191007000600-task-schedule-type/down.sql new file mode 100644 index 0000000..9b11bdf --- /dev/null +++ b/sql/migrations/patches/20191007000600-task-schedule-type/down.sql @@ -0,0 +1 @@ +DROP TYPE task_schedule; \ No newline at end of file diff --git a/sql/migrations/patches/20191007000600-task-schedule-type/up.sql b/sql/migrations/patches/20191007000600-task-schedule-type/up.sql new file mode 100644 index 0000000..0e6155b --- /dev/null +++ b/sql/migrations/patches/20191007000600-task-schedule-type/up.sql @@ -0,0 +1 @@ +CREATE TYPE task_schedule AS ENUM('once', 'daily', 'weekly', 'monthly', 'yearly'); diff --git a/sql/migrations/patches/20191007000601-tasks/down.sql b/sql/migrations/patches/20191007000601-tasks/down.sql new file mode 100644 index 0000000..87f1ed5 --- /dev/null +++ b/sql/migrations/patches/20191007000601-tasks/down.sql @@ -0,0 +1 @@ +DROP TABLE tasks; diff --git a/sql/migrations/patches/20191007000601-tasks/up.sql b/sql/migrations/patches/20191007000601-tasks/up.sql new file mode 100644 index 0000000..8106ae3 --- /dev/null +++ b/sql/migrations/patches/20191007000601-tasks/up.sql @@ -0,0 +1,14 @@ +CREATE TABLE tasks ( + id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + owner integer NOT NULL REFERENCES users(id), + name text NOT NULL, + notes text, + schedule task_schedule NOT NULL DEFAULT 'daily', + min_frequency integer, + max_frequency integer, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK(min_frequency IS NULL OR max_frequency IS NULL OR max_frequency > min_frequency) +); + +CREATE TRIGGER set_tasks_updated BEFORE UPDATE ON tasks FOR EACH ROW EXECUTE PROCEDURE set_updated_timestamp(); diff --git a/sql/tasks/list.sql b/sql/tasks/list.sql new file mode 100644 index 0000000..197b70d --- /dev/null +++ b/sql/tasks/list.sql @@ -0,0 +1,14 @@ +SELECT + id, + name, + notes, + schedule, + min_frequency, + max_frequency, + created_at +FROM + tasks +WHERE + owner = $1 +ORDER BY created_at ASC +LIMIT $2 OFFSET $3; diff --git a/src/api.ts b/src/api.ts index 7ba9809..b3c4063 100644 --- a/src/api.ts +++ b/src/api.ts @@ -14,6 +14,7 @@ // along with this program. If not, see . import { db } from "@kredens/db"; +import { User } from "@kredens/db/models"; import { ApolloServer, AuthenticationError, gql } from "apollo-server-express"; import { Kind } from "graphql/language"; import { GraphQLScalarType, GraphQLScalarTypeConfig } from "graphql/type"; @@ -24,15 +25,40 @@ const typeDefs = gql` "A simple type for getting started" hello: String migrations: [Migration]! + user(id: ID): User } type Migration { - id: ID + id: ID! name: String! applied_at: DateTime! } + type User { + id: ID! + email: String! + tasks: [Task]! + } + + type Task { + id: ID + name: String! + notes: String + schedule: ScheduleType! + min_frequency: Int + max_frequency: Int + created_at: DateTime! + } + scalar DateTime + + enum ScheduleType { + ONCE + DAILY + WEEKLY + MONTHLY + YEARLY + } `; const dateTimeConfig: GraphQLScalarTypeConfig = { @@ -56,12 +82,29 @@ const dateTimeConfig: GraphQLScalarTypeConfig = { const resolvers = { DateTime: new GraphQLScalarType(dateTimeConfig), Query: { - hello: async () => { - return `Hello, world!`; - }, - migrations: async () => { - return db.migrations.applied(); + hello: () => `Hello, world!`, + migrations: () => db.migrations.applied(), + user: async ( + parent: any, + { id }: { id: string }, + context: { user?: User } + ) => { + if (!context.user || context.user.id !== +id) { + throw new AuthenticationError( + "You cannot query users other than yourself" + ); + } + return db.users.details(+id).then(user => user.orNull()); } + }, + User: { + tasks: (user: User) => + db.tasks.list(user.id).then(tasks => + tasks.map(t => ({ + ...t, + schedule: t.schedule.toUpperCase() + })) + ) } }; diff --git a/src/db/index.ts b/src/db/index.ts index 96d0098..a444a5d 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -16,16 +16,23 @@ import { Extensions, MigrationRepository, + TaskRepository, UserRepository } from "@kredens/db/repos"; - +import { DateTime } from "luxon"; +import pg from "pg"; import pgPromise, { IDatabase, IInitOptions } from "pg-promise"; +const types = pg.types; +types.setTypeParser(types.builtins.TIMESTAMPTZ, DateTime.fromSQL); +types.setTypeParser(types.builtins.TIMESTAMP, DateTime.fromSQL); + type ExtendedProtocol = IDatabase & Extensions; const initOptions: IInitOptions = { extend(obj: ExtendedProtocol, dc: any) { obj.migrations = new MigrationRepository(obj, pgp); + obj.tasks = new TaskRepository(obj, pgp); obj.users = new UserRepository(obj, pgp); } }; diff --git a/src/db/models.ts b/src/db/models.ts index 4305c16..6405062 100644 --- a/src/db/models.ts +++ b/src/db/models.ts @@ -25,3 +25,16 @@ export interface User { id: number; email: string; } + +export type ScheduleType = "once" | "daily" | "weekly" | "monthly" | "yearly"; + +export interface Task { + id: number; + owner: number; + name: string; + notes?: string; + schedule: ScheduleType; + min_frequency?: number; + max_frequency?: number; + created_at: DateTime; +} diff --git a/src/db/repos/index.ts b/src/db/repos/index.ts index ae7f553..365a072 100644 --- a/src/db/repos/index.ts +++ b/src/db/repos/index.ts @@ -14,11 +14,13 @@ // along with this program. If not, see . import { MigrationRepository } from "@kredens/db/repos/migrations"; +import { TaskRepository } from "@kredens/db/repos/tasks"; import { UserRepository } from "@kredens/db/repos/users"; export interface Extensions { migrations: MigrationRepository; users: UserRepository; + tasks: TaskRepository; } -export { MigrationRepository, UserRepository }; +export { MigrationRepository, UserRepository, TaskRepository }; diff --git a/src/db/repos/migrations.ts b/src/db/repos/migrations.ts index 001e520..aaa77c8 100644 --- a/src/db/repos/migrations.ts +++ b/src/db/repos/migrations.ts @@ -50,7 +50,7 @@ export class MigrationRepository { public async applied(): Promise { return this.db.map(sql.applied, [], row => { return { - applied_at: DateTime.fromSQL(row.applied_at), + applied_at: row.applied_at as DateTime, id: +row.id, name: row.name }; diff --git a/src/db/repos/tasks.ts b/src/db/repos/tasks.ts new file mode 100644 index 0000000..2c51895 --- /dev/null +++ b/src/db/repos/tasks.ts @@ -0,0 +1,32 @@ +// Copyright (C) 2019 ModZero +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { ScheduleType, Task } from "@kredens/db/models"; +import { tasks as sql } from "@kredens/db/sql"; +import { IDatabase, IMain } from "pg-promise"; + +export class TaskRepository { + private db: IDatabase; + + constructor(db: IDatabase, pgp: IMain) { + this.db = db; + } + + public async list(owner: number): Promise { + return this.db + .manyOrNone(sql.list, [owner, 10, 0]) + .then(rows => (rows ? rows : [])); + } +} diff --git a/src/db/repos/users.ts b/src/db/repos/users.ts index 0daee17..829e9b7 100644 --- a/src/db/repos/users.ts +++ b/src/db/repos/users.ts @@ -13,11 +13,11 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +import { User } from "@kredens/db/models"; import { users as sql } from "@kredens/db/sql"; import argon2 from "argon2"; import { Maybe, None, Some } from "monet"; import { IDatabase, IMain } from "pg-promise"; -import { User } from "@kredens/db/models"; export class UserRepository { private db: IDatabase; diff --git a/src/db/sql/index.ts b/src/db/sql/index.ts index edbb650..a740829 100644 --- a/src/db/sql/index.ts +++ b/src/db/sql/index.ts @@ -41,7 +41,11 @@ const users = { login: sql("users/login.sql") }; -export { migrations, users }; +const tasks = { + list: sql("tasks/list.sql") +}; + +export { migrations, users, tasks }; /** Helper for linking to external query files */ function sql(file: string): QueryFile {