diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..34f6aa0 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,32 @@ +name: Check lint + +on: + push: + branches: + - main + - master + - develop + pull_request: + branches: + - main + - master + - develop + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + name: Check lint + steps: + - uses: actions/checkout@v3 + + - name: Set up Node + uses: actions/setup-node@v3 + with: + node-version: 20 + + - name: Install dependencies + run: npm ci + + - name: Check lint + run: npm run lint diff --git a/database/migrations/0000_light_tinkerer.sql b/database/migrations/0000_light_tinkerer.sql deleted file mode 100644 index e121419..0000000 --- a/database/migrations/0000_light_tinkerer.sql +++ /dev/null @@ -1,4 +0,0 @@ -CREATE TABLE IF NOT EXISTS "users" ( - "id" serial PRIMARY KEY NOT NULL, - "first_name" text NOT NULL -); diff --git a/database/migrations/0000_smart_jackal.sql b/database/migrations/0000_smart_jackal.sql new file mode 100644 index 0000000..ac66955 --- /dev/null +++ b/database/migrations/0000_smart_jackal.sql @@ -0,0 +1,51 @@ +CREATE TABLE IF NOT EXISTS "acquired" ( + "user_uuid" uuid, + "challenge_id" serial NOT NULL, + "created_by" serial NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "acquired_user_uuid_challenge_id_pk" PRIMARY KEY("user_uuid","challenge_id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "challenges" ( + "id" serial PRIMARY KEY NOT NULL, + "club_id" serial NOT NULL, + "score" integer NOT NULL, + "name" text NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "clubs" ( + "id" serial PRIMARY KEY NOT NULL, + "avatar_url" text NOT NULL, + "name" text NOT NULL, + "description" text NOT NULL, + "daily_date" date NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "granters" ( + "id" serial PRIMARY KEY NOT NULL, + "club_id" serial NOT NULL, + "email" text NOT NULL, + "password" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "users" ( + "uuid" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "club_id" serial NOT NULL, + "email" text NOT NULL, + "hashpass" text NOT NULL, + "username" text NOT NULL, + "quote" text, + "updated_at" timestamp DEFAULT now() NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_uuid_idx" ON "acquired" USING btree ("user_uuid");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "challenge_id_idx" ON "acquired" USING btree ("challenge_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "name_unique_idx" ON "clubs" USING btree ("name");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "email_unique_idx" ON "users" USING btree ("email");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "username_unique_idx" ON "users" USING btree ("username"); \ No newline at end of file diff --git a/database/migrations/meta/0000_snapshot.json b/database/migrations/meta/0000_snapshot.json index ecf6519..fab077b 100644 --- a/database/migrations/meta/0000_snapshot.json +++ b/database/migrations/meta/0000_snapshot.json @@ -1,11 +1,93 @@ { - "id": "85674a8b-3f8c-476f-9ce2-8635c80b2d04", + "id": "b0e99e73-e2d8-4775-a232-6adac633f175", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", "tables": { - "public.users": { - "name": "users", + "public.acquired": { + "name": "acquired", + "schema": "", + "columns": { + "user_uuid": { + "name": "user_uuid", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "challenge_id": { + "name": "challenge_id", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_uuid_idx": { + "name": "user_uuid_idx", + "columns": [ + { + "expression": "user_uuid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "challenge_id_idx": { + "name": "challenge_id_idx", + "columns": [ + { + "expression": "challenge_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "acquired_user_uuid_challenge_id_pk": { + "name": "acquired_user_uuid_challenge_id_pk", + "columns": [ + "user_uuid", + "challenge_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "public.challenges": { + "name": "challenges", "schema": "", "columns": { "id": { @@ -14,11 +96,37 @@ "primaryKey": true, "notNull": true }, - "first_name": { - "name": "first_name", + "club_id": { + "name": "club_id", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", "type": "text", "primaryKey": false, "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" } }, "indexes": {}, @@ -26,6 +134,205 @@ "compositePrimaryKeys": {}, "uniqueConstraints": {}, "checkConstraints": {} + }, + "public.clubs": { + "name": "clubs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "daily_date": { + "name": "daily_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "name_unique_idx": { + "name": "name_unique_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "public.granters": { + "name": "granters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "club_id": { + "name": "club_id", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "uuid": { + "name": "uuid", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "club_id": { + "name": "club_id", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hashpass": { + "name": "hashpass", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quote": { + "name": "quote", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_unique_idx": { + "name": "email_unique_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "username_unique_idx": { + "name": "username_unique_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} } }, "enums": {}, diff --git a/database/migrations/meta/_journal.json b/database/migrations/meta/_journal.json index fccfd13..1f2ee93 100644 --- a/database/migrations/meta/_journal.json +++ b/database/migrations/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "7", - "when": 1729680286443, - "tag": "0000_light_tinkerer", + "when": 1729847426779, + "tag": "0000_smart_jackal", "breakpoints": true } ] diff --git a/database/schema/acquired.ts b/database/schema/acquired.ts new file mode 100644 index 0000000..a94b12f --- /dev/null +++ b/database/schema/acquired.ts @@ -0,0 +1,42 @@ +import { relations } from "drizzle-orm"; +import { index, pgTable, primaryKey, serial, timestamp, uuid } from "drizzle-orm/pg-core"; +import { users } from "./users"; +import { challenges } from "./challeges"; + +export const acquired = pgTable( + "acquired", + { + userUuid: uuid("user_uuid"), + challengeId: serial("challenge_id"), + createdBy: serial("created_by"), + updatedAt: timestamp("updated_at") + .defaultNow() + .notNull() + .$onUpdate(() => new Date()), + createdAt: timestamp("created_at").defaultNow().notNull() + }, + (acquired) => ({ + pk: primaryKey({ columns: [acquired.userUuid, acquired.challengeId] }), + userUuidIdx: index("user_uuid_idx").on(acquired.userUuid), + challengeIdIdx: index("challenge_id_idx").on(acquired.challengeId) + }) +); + +export const acquiredRelations = relations(acquired, ({ many }) => ({ + user: many(users), + challenge: many(challenges) +})); + +export const acquiredUser = relations(acquired, ({ one }) => ({ + user: one(users, { + fields: [acquired.userUuid], + references: [users.uuid] + }) +})); + +export const acquiredChallenge = relations(acquired, ({ one }) => ({ + challenge: one(challenges, { + fields: [acquired.challengeId], + references: [challenges.id] + }) +})); diff --git a/database/schema/challeges.ts b/database/schema/challeges.ts new file mode 100644 index 0000000..abd1698 --- /dev/null +++ b/database/schema/challeges.ts @@ -0,0 +1,29 @@ +import { relations } from "drizzle-orm"; +import { integer, pgTable, serial, text, timestamp } from "drizzle-orm/pg-core"; +import { acquired } from "./acquired"; +import { clubs } from "./clubs"; +import { granters } from "./granters"; + +export const challenges = pgTable("challenges", { + id: serial("id").primaryKey(), + clubId: serial("club_id"), + score: integer("score").notNull(), + name: text("name").notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .notNull() + .$onUpdate(() => new Date()), + createdAt: timestamp("created_at").defaultNow().notNull() +}); + +export const challengesRelations = relations(challenges, ({ many }) => ({ + acquired: many(acquired), + granters: many(granters) +})); + +export const challengesClub = relations(challenges, ({ one }) => ({ + club: one(clubs, { + fields: [challenges.clubId], + references: [clubs.id] + }) +})); diff --git a/database/schema/clubs.ts b/database/schema/clubs.ts new file mode 100644 index 0000000..56c1aa9 --- /dev/null +++ b/database/schema/clubs.ts @@ -0,0 +1,20 @@ +import { date, pgTable, serial, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core"; + +export const clubs = pgTable( + "clubs", + { + id: serial("id").primaryKey(), + avatarUrl: text("avatar_url").notNull(), + name: text("name").notNull(), + description: text("description").notNull(), + dailyDate: date("daily_date").notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .notNull() + .$onUpdate(() => new Date()), + createdAt: timestamp("created_at").defaultNow().notNull() + }, + (clubs) => ({ + nameUniqueIdx: uniqueIndex("name_unique_idx").on(clubs.name) + }) +); diff --git a/database/schema/granters.ts b/database/schema/granters.ts new file mode 100644 index 0000000..000fae4 --- /dev/null +++ b/database/schema/granters.ts @@ -0,0 +1,17 @@ +import { relations } from "drizzle-orm"; +import { pgTable, serial, text } from "drizzle-orm/pg-core"; +import { clubs } from "./clubs"; + +export const granters = pgTable("granters", { + id: serial("id").primaryKey(), + clubId: serial("club_id").notNull(), + email: text("email").notNull(), + password: text("password").notNull() +}); + +export const grantersClub = relations(granters, ({ one }) => ({ + club: one(clubs, { + fields: [granters.clubId], + references: [clubs.id] + }) +})); diff --git a/database/schema/users.ts b/database/schema/users.ts index 6ec5a55..2aa93d8 100644 --- a/database/schema/users.ts +++ b/database/schema/users.ts @@ -1,6 +1,36 @@ -import { pgTable, serial, text } from "drizzle-orm/pg-core"; +import { relations } from "drizzle-orm"; +import { pgTable, serial, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; +import { acquired } from "./acquired"; +import { clubs } from "./clubs"; -export const users = pgTable("users", { - id: serial("id").primaryKey(), - firstName: text("first_name").notNull() -}); +export const users = pgTable( + "users", + { + uuid: uuid("uuid").primaryKey().defaultRandom(), + clubId: serial("club_id"), + email: text("email").notNull(), + hashpass: text("hashpass").notNull(), + username: text("username").notNull(), + quote: text("quote"), + updatedAt: timestamp("updated_at") + .defaultNow() + .notNull() + .$onUpdate(() => new Date()), + createdAt: timestamp("created_at").defaultNow().notNull() + }, + (users) => ({ + emailUniqueIdx: uniqueIndex("email_unique_idx").on(users.email), + usernameUniqueIdx: uniqueIndex("username_unique_idx").on(users.username) + }) +); + +export const usersRelations = relations(users, ({ many }) => ({ + acquired: many(acquired) +})); + +export const usersClub = relations(users, ({ one }) => ({ + club: one(clubs, { + fields: [users.clubId], + references: [clubs.id] + }) +})); diff --git a/routes.ts b/routes.ts index b2af139..98d4f13 100644 --- a/routes.ts +++ b/routes.ts @@ -1,15 +1,8 @@ import { Router } from "express"; -import Route_Error from "./routes/error"; import Route_Index from "./routes/index"; -import Route_UnhandledError from "./routes/unhandled"; -import usersRouter from "./routes/users/routes"; const router = Router(); router.get("/", Route_Index); -router.get("/error", Route_Error); -router.get("/unhandled", Route_UnhandledError); - -router.use("/users", usersRouter); export default router; diff --git a/routes/error.ts b/routes/error.ts deleted file mode 100644 index 6e1eb5d..0000000 --- a/routes/error.ts +++ /dev/null @@ -1,9 +0,0 @@ -import Status from "@/models/status"; -import { NextFunction, Request, Response } from "express"; - -export default function Route_Error(req: Request, res: Response, next: NextFunction) { - return Status.send(req, next, { - status: 400, - error: "errors.template" - }); -} diff --git a/routes/unhandled.ts b/routes/unhandled.ts deleted file mode 100644 index 3cf0729..0000000 --- a/routes/unhandled.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default function Route_UnhandledError() { - throw new Error("This is an unhandled error"); -} diff --git a/routes/users/getUsers.ts b/routes/users/getUsers.ts deleted file mode 100644 index 3587478..0000000 --- a/routes/users/getUsers.ts +++ /dev/null @@ -1,13 +0,0 @@ -import DB from "@/database/config"; -import { users } from "@/database/schema/users"; -import Status from "@/models/status"; -import { NextFunction, Request, Response } from "express"; - -export default async function Route_GetUsers(req: Request, res: Response, next: NextFunction) { - const allUsers = await DB.instance.select().from(users); - - return Status.send(req, next, { - status: 200, - data: allUsers - }); -} diff --git a/routes/users/postUsers.ts b/routes/users/postUsers.ts deleted file mode 100644 index 7f0296e..0000000 --- a/routes/users/postUsers.ts +++ /dev/null @@ -1,36 +0,0 @@ -import DB from "@/database/config"; -import { users } from "@/database/schema/users"; -import Status from "@/models/status"; -import { NextFunction, Request, Response } from "express"; -import { z } from "zod"; - -const body = z.object({ - firstName: z.string() -}); - -export default async function Route_PostUsers(req: Request, res: Response, next: NextFunction) { - const payload = body.safeParse(req.body); - if (!payload.success) { - return Status.send(req, next, { - status: 400, - error: "errors.validation" - }); - } - - const user = await DB.instance - .insert(users) - .values(payload.data) - .returning({ id: users.id, firstName: users.firstName }); - - if (user.length !== 1) { - return Status.send(req, next, { - status: 500, - error: "errors.database" - }); - } - - return Status.send(req, next, { - status: 200, - data: user[0] - }); -} diff --git a/routes/users/routes.ts b/routes/users/routes.ts deleted file mode 100644 index ee38ed2..0000000 --- a/routes/users/routes.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Router } from "express"; -import Route_GetUsers from "./getUsers"; -import Route_PostUsers from "./postUsers"; - -const usersRouter = Router(); - -usersRouter.get("/", Route_GetUsers); -usersRouter.post("/", Route_PostUsers); - -export default usersRouter; diff --git a/tests/e2e/users.test.ts b/tests/e2e/example.test.disabled similarity index 100% rename from tests/e2e/users.test.ts rename to tests/e2e/example.test.disabled diff --git a/tests/e2e/routes.test.ts b/tests/e2e/routes.test.ts deleted file mode 100644 index 395f131..0000000 --- a/tests/e2e/routes.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import request from "supertest"; -import createApp from "@/app"; - -const app = createApp("e2e-routes"); - -describe("Test multiple routes", () => { - test("should send an empty response on GET /", async () => { - const res = await request(app).get("/").expect("Content-Type", /json/).expect(200); - - expect(res.body).toStrictEqual({ - masterStatus: 204, - sentAt: expect.any(Number), - response: [ - { - status: 204, - success: true - } - ] - }); - }); - - test("should send an empty response GET /error", async () => { - const res = await request(app) - .get("/error") - .set("Accept-Language", "en") - .expect("Content-Type", /json/) - .expect(400); - - expect(res.body).toStrictEqual({ - masterStatus: 400, - sentAt: expect.any(Number), - response: [ - { - status: 400, - success: false, - error: "errors.template", - translatedError: "This is a template error message" - } - ] - }); - }); -}); diff --git a/tests/unit/error.test.ts b/tests/unit/error.test.ts deleted file mode 100644 index 2455d52..0000000 --- a/tests/unit/error.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import request from "supertest"; -import createApp from "@/app"; - -const app = createApp("unit-error"); - -describe("GET /error", () => { - it("should send an empty response", async () => { - const res = await request(app) - .get("/error") - .set("Accept-Language", "en") - .expect("Content-Type", /json/) - .expect(400); - - expect(res.body).toStrictEqual({ - masterStatus: 400, - sentAt: expect.any(Number), - response: [ - { - status: 400, - success: false, - error: "errors.template", - translatedError: "This is a template error message" - } - ] - }); - }); -}); diff --git a/tests/unit/index.test.ts b/tests/unit/status.test.ts similarity index 59% rename from tests/unit/index.test.ts rename to tests/unit/status.test.ts index 1960968..75aa5f1 100644 --- a/tests/unit/index.test.ts +++ b/tests/unit/status.test.ts @@ -1,11 +1,11 @@ import request from "supertest"; import createApp from "@/app"; -const app = createApp("unit-index"); +const app = createApp("e2e-users"); -describe("GET /", () => { - it("should send an empty response", async () => { - const res = await request(app).get("/").expect("Content-Type", /json/).expect(200); +describe("Test status page", () => { + test("should send a 200", async () => { + const res = await request(app).get("/").set("Accept-Language", "en").expect("Content-Type", /json/).expect(200); expect(res.body).toStrictEqual({ masterStatus: 204,