diff --git a/.env.example b/.env.example index d03b723c37bfd4aa65acfa2b3c7331443b24dfac..354969e0baee7da768e096a501bb1f406020178d 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,7 @@ NODE_ENV=development DB_HOST=localhost DB_USER=postgres DB_PASSWORD=postgres -DB_NAME=templatedb +DB_NAME=apex_db DB_PORT=5432 DB_URL=postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME} diff --git a/README.md b/README.md index d311cb99f7697676e4c2aa68107908932267517b..9ade0d32628fb4bd06dbc241d6103788eb349150 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,12 @@ 3. To install dependencies: ```sh bun install + cp .env.example .env ``` 4. Get the container up and running: ```sh docker compose up - cp .env.example .env ``` 5. set dev db diff --git a/bunfig.toml b/bunfig.toml index 4ae81d8a621167f7deaf6166f039ca188855ee0f..c9ba21b92e52b1f93ae68e957ed7aa0e689a1d5c 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,2 +1,4 @@ preload = ["./src/preload.ts"] -loglevel = "debug" \ No newline at end of file + +[test] +preload = ["./src/tests/preload-tests.ts"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 1183cb1d1002c0e241c0d345595323c0d9e0f2d9..98113da7176558f9aade7396bbefdcdb921bc9a7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,13 +3,13 @@ services: image: postgres restart: always environment: - POSTGRES_DB: templatedb + POSTGRES_DB: apex_db POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres ports: - "5432:5432" networks: - - tag_network + - apex_network volumes: - postgres_data:/var/lib/postgresql/data @@ -17,4 +17,4 @@ volumes: postgres_data: networks: - tag_network: + apex_network: diff --git a/package.json b/package.json index b6947c8c135e0e3868233334e060991dfff08a94..0c2a4742b4681bc81468b2ff295ed1e1e82c0bcd 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "db:generate": "drizzle-kit generate", "db:migrate": "cross-env DB_MIGRATING=true bun run src/db/migrate.ts", "db:seed": "cross-env DB_SEEDING=true bun run src/db/seed.ts", - "db:studio": "drizzle-kit studio" + "db:studio": "drizzle-kit studio", + "route:test": "bun db:seed && bun test" }, "dependencies": { "@hono/zod-validator": "^0.2.2", diff --git a/src/db/migrations/0001_normal_marvex.sql b/src/db/migrations/0001_normal_marvex.sql new file mode 100644 index 0000000000000000000000000000000000000000..155a5f42399dea7f8b8bc85d1a4e33d8cc0a6515 --- /dev/null +++ b/src/db/migrations/0001_normal_marvex.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS "client" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" varchar(255) NOT NULL, + "cnpj" varchar(20) NOT NULL, + "industry" varchar(50), + "hq_address" varchar(255), + "phone" varchar(20), + "email" varchar(255), + "contact_person" varchar(255), + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "client_cnpj_unique" UNIQUE("cnpj"), + CONSTRAINT "client_email_unique" UNIQUE("email") +); diff --git a/src/db/migrations/0002_lying_beyonder.sql b/src/db/migrations/0002_lying_beyonder.sql new file mode 100644 index 0000000000000000000000000000000000000000..4f04c1d4fdb04066c61edd026b9337dab1c9cd47 --- /dev/null +++ b/src/db/migrations/0002_lying_beyonder.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS "credit_card" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "client_id" uuid NOT NULL, + "card_number" varchar(20) NOT NULL, + "cardholder_name" varchar(255) NOT NULL, + "expiration_date" date NOT NULL, + "cvv" varchar(5) NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "credit_card_card_number_unique" UNIQUE("card_number") +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "credit_card" ADD CONSTRAINT "credit_card_client_id_client_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."client"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/src/db/migrations/meta/0001_snapshot.json b/src/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..e70b869a11b06f962410afc24b724738af8820e2 --- /dev/null +++ b/src/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,146 @@ +{ + "id": "b1587c07-98a9-4039-ae74-c1f6fc5f0823", + "prevId": "4cf89ad7-1672-440b-b964-dad35de18ec8", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.client": { + "name": "client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "cnpj": { + "name": "cnpj", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "industry": { + "name": "industry", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "hq_address": { + "name": "hq_address", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "contact_person": { + "name": "contact_person", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "client_cnpj_unique": { + "name": "client_cnpj_unique", + "nullsNotDistinct": false, + "columns": [ + "cnpj" + ] + }, + "client_email_unique": { + "name": "client_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_username_unique": { + "name": "user_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + } + } + }, + "enums": {}, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/0002_snapshot.json b/src/db/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..834cafdf4784afad23f49ab652d7e8a708d61f9c --- /dev/null +++ b/src/db/migrations/meta/0002_snapshot.json @@ -0,0 +1,229 @@ +{ + "id": "3e1ec9b7-7574-46ca-aec8-0f0fa2c41943", + "prevId": "b1587c07-98a9-4039-ae74-c1f6fc5f0823", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.client": { + "name": "client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "cnpj": { + "name": "cnpj", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "industry": { + "name": "industry", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "hq_address": { + "name": "hq_address", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "contact_person": { + "name": "contact_person", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "client_cnpj_unique": { + "name": "client_cnpj_unique", + "nullsNotDistinct": false, + "columns": [ + "cnpj" + ] + }, + "client_email_unique": { + "name": "client_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + }, + "public.credit_card": { + "name": "credit_card", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "card_number": { + "name": "card_number", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "cardholder_name": { + "name": "cardholder_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "expiration_date": { + "name": "expiration_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "cvv": { + "name": "cvv", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "credit_card_client_id_client_id_fk": { + "name": "credit_card_client_id_client_id_fk", + "tableFrom": "credit_card", + "tableTo": "client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "credit_card_card_number_unique": { + "name": "credit_card_card_number_unique", + "nullsNotDistinct": false, + "columns": [ + "card_number" + ] + } + } + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_username_unique": { + "name": "user_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + } + } + }, + "enums": {}, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 600010d731beb84a0e2a0996864e2923a20e4a8e..f594e3b045559fd74218bea77832329246013875 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -8,6 +8,20 @@ "when": 1719362169477, "tag": "0000_aspiring_frightful_four", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1719970953846, + "tag": "0001_normal_marvex", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1720096335732, + "tag": "0002_lying_beyonder", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/repo/client.repo.ts b/src/db/repo/client.repo.ts new file mode 100644 index 0000000000000000000000000000000000000000..c2eecfbe026428e79bc4bf89ef4e138bce2d888e --- /dev/null +++ b/src/db/repo/client.repo.ts @@ -0,0 +1,56 @@ +import { Service } from 'typedi' +import type { + ClientInput, + ClientModel, + ClientUpdate, +} from '../schema/client.model' +import db from '..' +import clientTable, { clientSchemas } from '../schema/client.model' +import { eq } from 'drizzle-orm' + +@Service() +export class ClientRepo { + async create(client: ClientInput): Promise<ClientModel> { + const [ret] = await db + .insert(clientTable) + .values(client) + .returning() + + return clientSchemas.clientModelSchema.parse(ret) + } + + async update(client: ClientUpdate): Promise<ClientModel> { + const [ret] = await db + .update(clientTable) + .set(client) + .where(eq(clientTable.id, client.id)) + .returning() + + return clientSchemas.clientModelSchema.parse(ret) + } + + async delete(id: ClientModel['id']): Promise<ClientModel> { + const [ret] = await db + .delete(clientTable) + .where(eq(clientTable.id, id)) + .returning() + + return clientSchemas.clientModelSchema.parse(ret) + } + + async find( + id: ClientModel['id'] + ): Promise<ClientModel | undefined> { + const client = await db.query.clientTable.findFirst({ + where: eq(clientTable.id, id), + }) + + return clientSchemas.clientModelSchema.parse(client) + } + + async findMany(): Promise<ClientModel[]> { + return clientSchemas.clientModelSchema + .array() + .parse(await db.query.clientTable.findMany()) + } +} diff --git a/src/db/repo/credit-card.repo.ts b/src/db/repo/credit-card.repo.ts new file mode 100644 index 0000000000000000000000000000000000000000..460eec6709e19b38142add7ce9de412e6c1c289a --- /dev/null +++ b/src/db/repo/credit-card.repo.ts @@ -0,0 +1,62 @@ +import { Service } from 'typedi' +import type { + CreditCardInput, + CreditCardModel, + CreditCardUpdate, +} from '../schema/credit-card.model' +import creditCardTable, { + creditCardSchemas, +} from '../schema/credit-card.model' +import db from '..' +import { eq } from 'drizzle-orm' + +@Service() +export class CreditCardRepo { + async create( + creditCard: CreditCardInput + ): Promise<CreditCardModel> { + const [ret] = await db + .insert(creditCardTable) + .values(creditCard) + .returning() + + return creditCardSchemas.creditCardModelSchema.parse(ret) + } + + async update( + creditCard: CreditCardUpdate + ): Promise<CreditCardModel> { + const [ret] = await db + .update(creditCardTable) + .set(creditCard) + .where(eq(creditCardTable.id, creditCard.id)) + .returning() + + return creditCardSchemas.creditCardModelSchema.parse(ret) + } + + async delete(id: CreditCardModel['id']): Promise<CreditCardModel> { + const [ret] = await db + .delete(creditCardTable) + .where(eq(creditCardTable.id, id)) + .returning() + + return creditCardSchemas.creditCardModelSchema.parse(ret) + } + + async find( + id: CreditCardModel['id'] + ): Promise<CreditCardModel | undefined> { + const creditCard = await db.query.creditCardTable.findFirst({ + where: eq(creditCardTable.id, id), + }) + + return creditCardSchemas.creditCardModelSchema.parse(creditCard) + } + + async findMany(): Promise<CreditCardModel[]> { + return creditCardSchemas.creditCardModelSchema + .array() + .parse(await db.query.creditCardTable.findMany()) + } +} diff --git a/src/db/repo/user.repo.ts b/src/db/repo/user.repo.ts index 8fd0c6d18c5c4863b9758cefa0013013cbc38eb6..c23d853fb3a28c3fcaff9507f9f37cdfa264cfe2 100644 --- a/src/db/repo/user.repo.ts +++ b/src/db/repo/user.repo.ts @@ -11,11 +11,9 @@ import { eq } from 'drizzle-orm' @Service() export class UserRepo { async findMany(): Promise<UserModel[]> { - return z.array(userSchemas.userModelSchema).parse( - await db.query.userTable.findMany({ - limit: 100, - }) - ) + return z + .array(userSchemas.userModelSchema) + .parse(await db.query.userTable.findMany()) } async findByUsername( diff --git a/src/db/schema/client.model.ts b/src/db/schema/client.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..a5a25f917af9e3b135085c7cc1986a8367fc814c --- /dev/null +++ b/src/db/schema/client.model.ts @@ -0,0 +1,64 @@ +import { relations, sql } from 'drizzle-orm' +import { + pgTable, + timestamp, + uuid, + varchar, +} from 'drizzle-orm/pg-core' +import { createInsertSchema, createSelectSchema } from 'drizzle-zod' +import { z } from 'zod' +import creditCardTable, { + creditCardSchemas, +} from './credit-card.model' + +const clientTable = pgTable('client', { + id: uuid('id').primaryKey().defaultRandom(), + name: varchar('name', { length: 255 }).notNull(), + cnpj: varchar('cnpj', { length: 20 }).unique().notNull(), + industry: varchar('industry', { length: 50 }), + hqAddress: varchar('hq_address', { length: 255 }), + phone: varchar('phone', { length: 20 }), + email: varchar('email', { length: 255 }).unique(), + contactPerson: varchar('contact_person', { length: 255 }), + createdAt: timestamp('created_at', { mode: 'string' }) + .notNull() + .defaultNow(), + updatedAt: timestamp('updated_at', { mode: 'string' }) + .notNull() + .defaultNow() + .$onUpdate(() => sql`current_timestamp`), +}) + +export const clientTableRelations = relations( + clientTable, + ({ many }) => ({ + creditCards: many(creditCardTable), + }) +) + +const clientModelSchema = createSelectSchema(clientTable).extend({ + creditCards: creditCardSchemas.creditCardModelSchema + .array() + .optional(), +}) +const clientDtoSchema = clientModelSchema +const clientInputSchema = createInsertSchema(clientTable, { + email: (schema) => schema.email.email(), +}) +const clientUpdateSchema = clientInputSchema + .partial() + .required({ id: true }) + +export type ClientModel = z.infer<typeof clientModelSchema> +export type ClientDto = z.infer<typeof clientDtoSchema> +export type ClientInput = z.infer<typeof clientInputSchema> +export type ClientUpdate = z.infer<typeof clientUpdateSchema> + +export const clientSchemas = { + clientModelSchema, + clientDtoSchema, + clientInputSchema, + clientUpdateSchema, +} + +export default clientTable diff --git a/src/db/schema/credit-card.model.ts b/src/db/schema/credit-card.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..3182b8ae7e9e2f760226b1e41b5ccdc2d6727d5a --- /dev/null +++ b/src/db/schema/credit-card.model.ts @@ -0,0 +1,73 @@ +import { + date, + pgTable, + timestamp, + uuid, + varchar, +} from 'drizzle-orm/pg-core' +import { createInsertSchema, createSelectSchema } from 'drizzle-zod' +import type { z } from 'zod' +import clientTable from './client.model' +import { relations, sql } from 'drizzle-orm' + +const creditCardTable = pgTable('credit_card', { + id: uuid('id').primaryKey().defaultRandom(), + clientId: uuid('client_id') + .references(() => clientTable.id, { onDelete: 'cascade' }) + .notNull(), + cardNumber: varchar('card_number', { length: 20 }) + .unique() + .notNull(), + cardholderName: varchar('cardholder_name', { + length: 255, + }).notNull(), + expirationDate: date('expiration_date', { + mode: 'string', + }).notNull(), + cvv: varchar('cvv', { length: 5 }).notNull(), + createdAt: timestamp('created_at', { + mode: 'string', + }) + .notNull() + .defaultNow(), + updatedAt: timestamp('updated_at', { + mode: 'string', + }) + .notNull() + .defaultNow() + .$onUpdate(() => sql`current_timestamp`), +}) + +export const creditCardTableRelations = relations( + creditCardTable, + ({ one }) => ({ + client: one(clientTable, { + fields: [creditCardTable.clientId], + references: [clientTable.id], + }), + }) +) + +const creditCardModelSchema = createSelectSchema(creditCardTable) +const creditCardDtoSchema = creditCardModelSchema.omit({ + cvv: true, + cardNumber: true, +}) +const creditCardInputSchema = createInsertSchema(creditCardTable) +const creditCardUpdateSchema = creditCardInputSchema + .partial() + .required({ id: true }) + +export type CreditCardModel = z.infer<typeof creditCardModelSchema> +export type CreditCardDto = z.infer<typeof creditCardDtoSchema> +export type CreditCardInput = z.infer<typeof creditCardInputSchema> +export type CreditCardUpdate = z.infer<typeof creditCardUpdateSchema> + +export const creditCardSchemas = { + creditCardModelSchema, + creditCardDtoSchema, + creditCardInputSchema, + creditCardUpdateSchema, +} + +export default creditCardTable diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index 6a9894ef8f94573ccdda030a6a4c12cea2cf41ae..b357bcb9c0e9c9dbb96f519e0c2a36478a15f81b 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -1,5 +1,15 @@ import userTable from './user.model' +import clientTable, { clientTableRelations } from './client.model' +import creditCardTable, { + creditCardTableRelations, +} from './credit-card.model' -export { userTable } +export { + userTable, + clientTable, + clientTableRelations, + creditCardTable, + creditCardTableRelations, +} -export const tables = [userTable] +export const tables = [userTable, clientTable, creditCardTable] diff --git a/src/db/seed.ts b/src/db/seed.ts index de1307e96f2b126ab6ccb0beee2ad2263a08603f..322a2bd8d415b7296946a83433a0821e4483ed75 100644 --- a/src/db/seed.ts +++ b/src/db/seed.ts @@ -5,7 +5,7 @@ import * as schema from '@/db/schema' import * as seeds from '@/db/seeds' if (!env.DB_SEEDING) { - throw new Error('You must sed DB_SEEDING to "true" when seeding') + throw new Error('You must set DB_SEEDING to "true" when seeding') } async function resetTable(db: db, table: Table) { @@ -21,5 +21,7 @@ for (const table of schema.tables) { } await seeds.userSeed(db) +await seeds.clientSeed(db) +await seeds.creditCardSeed(db) await connection.end() diff --git a/src/db/seeds/client.seed.ts b/src/db/seeds/client.seed.ts new file mode 100644 index 0000000000000000000000000000000000000000..94b48dae9551a9cece4ff7cf221ce99ca220c8b7 --- /dev/null +++ b/src/db/seeds/client.seed.ts @@ -0,0 +1,110 @@ +import type db from '..' +import type { ClientInput } from '../schema/client.model' +import clientTable from '../schema/client.model' + +export default async function seed(db: db) { + await db.insert(clientTable).values(clientData) +} + +const clientData: ClientInput[] = [ + { + id: 'f1b9b3b4-0b3b-4b3b-8b3b-0b3b4b3b4b3b', + name: 'Acme Corp', + cnpj: '12.345.678/0001-91', + industry: 'Manufacturing', + hqAddress: '1234 Industry Rd, City', + phone: '123-456-7890', + email: 'contact@acmecorp.com', + contactPerson: 'John Doe', + }, + { + id: '12345678-1234-1234-1234-123456789012', + name: 'Example Corp', + cnpj: '98.765.432/0001-21', + industry: 'Technology', + hqAddress: '5678 Tech St, City', + phone: '987-654-3210', + email: 'contact@examplecorp.com', + contactPerson: 'Jane Smith', + }, + { + id: '87654321-4321-4321-4321-210987654321', + name: 'XYZ Corp', + cnpj: '11.223.344/0001-55', + industry: 'Finance', + hqAddress: '9876 Finance St, City', + phone: '555-555-5555', + email: 'contact@xyzcorp.com', + contactPerson: 'Bob Johnson', + }, + { + id: '98765432-2345-2345-2345-543210987654', + name: 'ABC Corp', + cnpj: '99.888.777/0001-33', + industry: 'Retail', + hqAddress: '5432 Retail St, City', + phone: '111-222-3333', + email: 'contact@abccorp.com', + contactPerson: 'Alice Brown', + }, + { + id: '23456789-3456-3456-3456-654321098765', + name: 'DEF Corp', + cnpj: '44.555.666/0001-44', + industry: 'Healthcare', + hqAddress: '8765 Healthcare St, City', + phone: '999-888-7777', + email: 'contact@defcorp.com', + contactPerson: 'David Wilson', + }, + { + id: '34567890-4567-4567-4567-765432109876', + name: 'GHI Corp', + cnpj: '77.888.999/0001-66', + industry: 'Education', + hqAddress: '2345 Education St, City', + phone: '333-444-5555', + email: 'contact@ghicorp.com', + contactPerson: 'Grace Thompson', + }, + { + id: '45678901-5678-5678-5678-876543210987', + name: 'JKL Corp', + cnpj: '22.333.444/0001-77', + industry: 'Hospitality', + hqAddress: '7654 Hospitality St, City', + phone: '777-666-5555', + email: 'contact@jklcorp.com', + contactPerson: 'James Davis', + }, + { + id: '56789012-6789-6789-6789-987654321098', + name: 'MNO Corp', + cnpj: '66.777.888/0001-88', + industry: 'Transportation', + hqAddress: '1234 Transportation St, City', + phone: '222-111-9999', + email: 'contact@mnocorp.com', + contactPerson: 'Mary Johnson', + }, + { + id: '67890123-7890-7890-7890-098765432109', + name: 'PQR Corp', + cnpj: '33.444.555/0001-99', + industry: 'Energy', + hqAddress: '9876 Energy St, City', + phone: '444-333-2222', + email: 'contact@pqrcorp.com', + contactPerson: 'Peter Wilson', + }, + { + id: '78901234-8901-8901-8901-987654321098', + name: 'STU Corp', + cnpj: '55.666.777/0001-00', + industry: 'Telecommunications', + hqAddress: '5432 Telecom St, City', + phone: '888-999-0000', + email: 'contact@stucorp.com', + contactPerson: 'Sarah Brown', + }, +] diff --git a/src/db/seeds/credit-card.seed.ts b/src/db/seeds/credit-card.seed.ts new file mode 100644 index 0000000000000000000000000000000000000000..6230f3f0278c983ac38c750caba319fa276a18b6 --- /dev/null +++ b/src/db/seeds/credit-card.seed.ts @@ -0,0 +1,52 @@ +import type db from '..' +import { creditCardTable } from '../schema' +import type { CreditCardInput } from '../schema/credit-card.model' + +export default async function seed(db: db) { + await db.insert(creditCardTable).values(creditCardData) +} + +const creditCardData: CreditCardInput[] = [ + { + clientId: 'f1b9b3b4-0b3b-4b3b-8b3b-0b3b4b3b4b3b', + cardNumber: '1234 5678 9012 3456', + cardholderName: 'John Doe', + expirationDate: '2025-12-31', + cvv: '123', + }, + { + clientId: 'f1b9b3b4-0b3b-4b3b-8b3b-0b3b4b3b4b3b', + cardNumber: '9876 5432 1098 7654', + cardholderName: 'Jane Smith', + expirationDate: '2024-06-30', + cvv: '456', + }, + { + clientId: '12345678-1234-1234-1234-123456789012', + cardNumber: '5555 4444 3333 2222', + cardholderName: 'Alice Johnson', + expirationDate: '2023-09-15', + cvv: '789', + }, + { + clientId: '12345678-1234-1234-1234-123456789012', + cardNumber: '1111 2222 3333 4444', + cardholderName: 'Bob Williams', + expirationDate: '2026-03-01', + cvv: '012', + }, + { + clientId: '12345678-1234-1234-1234-123456789012', + cardNumber: '9999 8888 7777 6666', + cardholderName: 'Sarah Davis', + expirationDate: '2025-11-30', + cvv: '345', + }, + { + clientId: '87654321-4321-4321-4321-210987654321', + cardNumber: '4444 5555 6666 7777', + cardholderName: 'Michael Brown', + expirationDate: '2022-07-10', + cvv: '678', + }, +] diff --git a/src/db/seeds/index.ts b/src/db/seeds/index.ts index 4f9fb7bc3e6114822c37691514e30d4610f13237..47dd12191d399d235cb53688dd0d23c51045b239 100644 --- a/src/db/seeds/index.ts +++ b/src/db/seeds/index.ts @@ -1 +1,3 @@ export { default as userSeed } from './user.seed' +export { default as clientSeed } from './client.seed' +export { default as creditCardSeed } from './credit-card.seed' diff --git a/src/index.ts b/src/index.ts index 0225f616dffaf88af0b060da935c1d2bbf881177..fd1950d5f581c1e341d3cc7d9908f0e4aab82b0e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,12 +10,15 @@ import { uploaderRouter } from './routes/uploader.route' import { prettyJSON } from 'hono/pretty-json' import { cors } from 'hono/cors' import { bodyLimit } from 'hono/body-limit' +import { clientRouter } from './routes/client.route' +import { HttpStatus, createApexError } from './services/error.service' +import { creditCardRouter } from './routes/credit-card.route' const app = new Hono() app.use('*', logger()) app.use('*', prettyJSON()) -app.use('/api/*', cors()) +app.use('*', cors()) app.use( '/api/*', jwt({ @@ -27,7 +30,16 @@ app.use( bodyLimit({ maxSize: 50 * 1024 * 1024, // 50mb onError(c) { - return c.json('overflow', 413) + return c.json( + createApexError({ + status: 'error', + message: 'File is too big. Current limit is 50mb', + code: HttpStatus.PAYLOAD_TOO_LARGE, + path: c.req.routePath, + suggestion: 'Reduce the size of your file and try again', + }), + 413 + ) }, }) ) @@ -36,9 +48,12 @@ app.get('/', (c) => c.json({ message: 'sv running on /api' })) app.route('auth', authRouter) -const api = app.basePath('/api') -api.route('/user', userRouter) -api.route('/upload', uploaderRouter) +app + .basePath('/api') + .route('/user', userRouter) + .route('/client', clientRouter) + .route('/credit-card', creditCardRouter) + .route('/upload', uploaderRouter) export default app export type AppType = typeof app diff --git a/src/routes/auth.route.ts b/src/routes/auth.route.ts index a3cf6db945668cb2c9bcfd169c4f3b58936776d4..f3d0c2f8c45198dba32680542651ef62f9d53a79 100644 --- a/src/routes/auth.route.ts +++ b/src/routes/auth.route.ts @@ -1,5 +1,6 @@ import { authSchema } from '@/db/repo/auth.repo' import { AuhtService } from '@/services/auth.service' +import { HttpStatus, createApexError } from '@/services/error.service' import { zValidator } from '@hono/zod-validator' import { Hono } from 'hono' import Container from 'typedi' @@ -17,8 +18,16 @@ export const authRouter = new Hono().post( return c.json({ token }) } catch (e) { - console.log(e) - return c.notFound() + return c.json( + createApexError({ + status: 'error', + code: HttpStatus.NOT_FOUND, + message: 'Invalid or inexistent user', + path: c.req.routePath, + suggestion: 'Check your credentials', + }), + HttpStatus.NOT_FOUND + ) } } ) diff --git a/src/routes/client.route.ts b/src/routes/client.route.ts new file mode 100644 index 0000000000000000000000000000000000000000..7682c33687e0e9d2e0dbc69240119b61dd382112 --- /dev/null +++ b/src/routes/client.route.ts @@ -0,0 +1,108 @@ +import { ClientService } from '@/services/client.service' +import Container from 'typedi' +import { honoWithJwt } from '..' +import { zValidator } from '@hono/zod-validator' +import { clientSchemas } from '@/db/schema/client.model' +import { HttpStatus, createApexError } from '@/services/error.service' + +const service = Container.get(ClientService) + +export const clientRouter = honoWithJwt() + .post( + '/create', + zValidator('json', clientSchemas.clientInputSchema), + async (c) => { + try { + const input = await c.req.valid('json') + + const client = clientSchemas.clientDtoSchema.parse( + await service.create(input) + ) + + return c.json({ client }) + } catch (e) { + return c.json( + createApexError({ + status: 'error', + message: 'could not create client', + code: HttpStatus.BAD_REQUEST, + path: c.req.routePath, + suggestion: 'check the input and try again', + }), + HttpStatus.BAD_REQUEST + ) + } + } + ) + .post( + '/update', + zValidator('json', clientSchemas.clientUpdateSchema), + async (c) => { + try { + const input = await c.req.valid('json') + + const client = clientSchemas.clientDtoSchema.parse( + await service.update(input) + ) + + return c.json({ client }) + } catch (e) { + console.log(e) + + return c.json( + createApexError({ + status: 'error', + message: 'could not update client', + code: HttpStatus.BAD_REQUEST, + path: c.req.routePath, + suggestion: 'check the input and try again', + }), + HttpStatus.BAD_REQUEST + ) + } + } + ) + .post('/delete/:id', async (c) => { + try { + const id = c.req.param('id') + + const client = clientSchemas.clientDtoSchema.parse( + await service.delete(id) + ) + + return c.json({ client }) + } catch (e) { + return c.json( + createApexError({ + status: 'error', + message: 'could not delete client', + code: HttpStatus.BAD_REQUEST, + path: c.req.routePath, + suggestion: 'check the input and try again', + }), + HttpStatus.BAD_REQUEST + ) + } + }) + .get('/:id', async (c) => { + try { + const id = c.req.param('id') + + const client = clientSchemas.clientDtoSchema.parse( + await service.find(id) + ) + + return c.json({ client }) + } catch (e) { + return c.json( + createApexError({ + status: 'error', + message: 'could not find client', + code: HttpStatus.NOT_FOUND, + path: c.req.routePath, + suggestion: 'are you sure this client exists?', + }), + HttpStatus.NOT_FOUND + ) + } + }) diff --git a/src/routes/credit-card.route.ts b/src/routes/credit-card.route.ts new file mode 100644 index 0000000000000000000000000000000000000000..71fdf123d5984acab64b0d57448ba5751bfb72f1 --- /dev/null +++ b/src/routes/credit-card.route.ts @@ -0,0 +1,110 @@ +import { CreditCardService } from '@/services/credit-card.service' +import Container from 'typedi' +import { honoWithJwt } from '..' +import { zValidator } from '@hono/zod-validator' +import { creditCardSchemas } from '@/db/schema/credit-card.model' +import { HttpStatus, createApexError } from '@/services/error.service' + +const service = Container.get(CreditCardService) + +export const creditCardRouter = honoWithJwt() + .post( + '/create', + zValidator('json', creditCardSchemas.creditCardInputSchema), + async (c) => { + try { + const input = await c.req.valid('json') + + const creditCard = + creditCardSchemas.creditCardDtoSchema.parse( + await service.create(input) + ) + + return c.json({ creditCard }) + } catch (e) { + return c.json( + createApexError({ + status: 'error', + message: 'could not create credit card', + code: HttpStatus.BAD_REQUEST, + path: c.req.routePath, + suggestion: 'check the input and try again', + }), + HttpStatus.BAD_REQUEST + ) + } + } + ) + .post( + '/update', + zValidator('json', creditCardSchemas.creditCardUpdateSchema), + async (c) => { + try { + const input = await c.req.valid('json') + + const creditCard = + creditCardSchemas.creditCardDtoSchema.parse( + await service.update(input) + ) + + return c.json({ creditCard }) + } catch (e) { + console.log(e) + + return c.json( + createApexError({ + status: 'error', + message: 'could not update credit card', + code: HttpStatus.BAD_REQUEST, + path: c.req.routePath, + suggestion: 'check the input and try again', + }), + HttpStatus.BAD_REQUEST + ) + } + } + ) + .post('/delete/:id', async (c) => { + try { + const id = c.req.param('id') + + const creditCard = creditCardSchemas.creditCardDtoSchema.parse( + await service.delete(id) + ) + + return c.json({ creditCard }) + } catch (e) { + return c.json( + createApexError({ + status: 'error', + message: 'could not delete credit card', + code: HttpStatus.BAD_REQUEST, + path: c.req.routePath, + suggestion: 'check the input and try again', + }), + HttpStatus.BAD_REQUEST + ) + } + }) + .get('/:id', async (c) => { + try { + const id = c.req.param('id') + + const creditCard = creditCardSchemas.creditCardDtoSchema.parse( + await service.find(id) + ) + + return c.json({ creditCard }) + } catch (e) { + return c.json( + createApexError({ + status: 'error', + message: 'could not find credit card', + code: HttpStatus.NOT_FOUND, + path: c.req.routePath, + suggestion: 'are you sure the credit card exists?', + }), + HttpStatus.NOT_FOUND + ) + } + }) diff --git a/src/routes/user.routes.ts b/src/routes/user.routes.ts index bcf8edc355e0e09d9f7e5ef1959396d25b06ceae..fe9bdc8df8b46822ea48aa828b300cfb9ed78de5 100644 --- a/src/routes/user.routes.ts +++ b/src/routes/user.routes.ts @@ -35,9 +35,7 @@ export const userRouter = honoWithJwt() zValidator('json', userSchemas.userInputSchema), async (c) => { try { - const input = userSchemas.userInputSchema.parse( - await c.req.valid('json') - ) + const input = await c.req.valid('json') const user = userSchemas.userDtoSchema.parse( await service.create(input) diff --git a/src/services/client.service.ts b/src/services/client.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..3622e7030ce4dd951b61ef66477e9c89b42e848c --- /dev/null +++ b/src/services/client.service.ts @@ -0,0 +1,31 @@ +import { ClientRepo } from '@/db/repo/client.repo' +import type { + ClientInput, + ClientModel, + ClientUpdate, +} from '@/db/schema/client.model' +import { Inject, Service } from 'typedi' + +@Service() +export class ClientService { + @Inject() + private readonly repo: ClientRepo + + async create(client: ClientInput): Promise<ClientModel> { + return this.repo.create(client) + } + + async update(client: ClientUpdate): Promise<ClientModel> { + return this.repo.update(client) + } + + async delete(id: ClientModel['id']): Promise<ClientModel> { + return this.repo.delete(id) + } + + async find( + id: ClientModel['id'] + ): Promise<ClientModel | undefined> { + return this.repo.find(id) + } +} diff --git a/src/services/credit-card.service.ts b/src/services/credit-card.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..62528755105a8197fc857c4ddfa40637b53b36f0 --- /dev/null +++ b/src/services/credit-card.service.ts @@ -0,0 +1,35 @@ +import { CreditCardRepo } from '@/db/repo/credit-card.repo' +import type { + CreditCardInput, + CreditCardModel, + CreditCardUpdate, +} from '@/db/schema/credit-card.model' +import { Inject, Service } from 'typedi' + +@Service() +export class CreditCardService { + @Inject() + private readonly repo: CreditCardRepo + + async create( + creditCard: CreditCardInput + ): Promise<CreditCardModel> { + return this.repo.create(creditCard) + } + + async update( + creditCard: CreditCardUpdate + ): Promise<CreditCardModel> { + return this.repo.update(creditCard) + } + + async delete(id: CreditCardModel['id']): Promise<CreditCardModel> { + return this.repo.delete(id) + } + + async find( + id: CreditCardModel['id'] + ): Promise<CreditCardModel | undefined> { + return this.repo.find(id) + } +} diff --git a/src/services/error.service.ts b/src/services/error.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..70488a6cb9840e9e5eb9bd6a38a70cf3eab8e5a9 --- /dev/null +++ b/src/services/error.service.ts @@ -0,0 +1,50 @@ +export enum HttpStatus { + OK = 200, + CREATED = 201, + NO_CONTENT = 204, + BAD_REQUEST = 400, + UNAUTHORIZED = 401, + FORBIDDEN = 403, + NOT_FOUND = 404, + CONFLICT = 409, + INTERNAL_SERVER_ERROR = 500, + SERVICE_UNAVAILABLE = 503, + PAYLOAD_TOO_LARGE = 413, +} + +export type ApexError = { + status: 'error' | 'success' + statusCode: number + error: { + code: string + message: string + // details: string, + timestamp: string + path: string + suggestion: string + } + // requestId: string, + // documentation_url: string +} + +export type ApexErrorInput = { + status: 'error' | 'success' + code: HttpStatus + message: string + path: string + suggestion: string +} + +export function createApexError(input: ApexErrorInput): ApexError { + return { + status: input.status, + statusCode: input.code, + error: { + code: `${input.code}`, + message: input.message, + timestamp: new Date().toISOString(), + path: input.path, + suggestion: input.suggestion, + }, + } +} diff --git a/src/tests/client.test.ts b/src/tests/client.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..1e1ea78cde9ec5358b52d8618dd5ef4d869f6653 --- /dev/null +++ b/src/tests/client.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'bun:test' +import app from '..' +import { + clientSchemas, + type ClientDto, + type ClientInput, + type ClientUpdate, +} from '@/db/schema/client.model' +import { api, token } from './preload-tests' +import { HttpStatus } from '@/services/error.service' + +describe('Tests for client routes', () => { + const id = '28222da6-2b0a-49df-a328-0703fed118a2' + + it('should create a new client', async () => { + const input: ClientInput = { + id, + cnpj: '17.579.768/0001-64', + name: 'Test Client', + } + + const res = await app.request(`${api}/client/create`, { + method: 'POST', + body: JSON.stringify(input), + headers: { + 'Content-Type': 'application/json', + Authorization: token, + }, + }) + + expect(res.status).toBe(HttpStatus.OK) + const data = (await res.json()) as { client: ClientDto } + clientSchemas.clientDtoSchema.parse(data.client) + }) + + it('should update a client', async () => { + const input: ClientUpdate = { + id, + name: 'Test Client Updated', + } + + const res = await app.request(`${api}/client/update`, { + method: 'POST', + body: JSON.stringify(input), + headers: { + 'Content-Type': 'application/json', + Authorization: token, + }, + }) + + expect(res.status).toBe(HttpStatus.OK) + + const data = (await res.json()) as { client: ClientDto } + clientSchemas.clientDtoSchema.parse(data.client) + expect(data.client.name).toBe(input.name!) + }) + + it('should find a client by id', async () => { + const res = await app.request(`${api}/client/${id}`, { + headers: { + Authorization: token, + }, + }) + + expect(res.status).toBe(HttpStatus.OK) + + const data = (await res.json()) as { client: ClientDto } + clientSchemas.clientDtoSchema.parse(data.client) + expect(data.client.id).toBe(id) + }) + + it('should delete a client', async () => { + const res = await app.request(`${api}/client/delete/${id}`, { + method: 'POST', + headers: { + Authorization: token, + }, + }) + + expect(res.status).toBe(HttpStatus.OK) + + const data = (await res.json()) as { client: ClientDto } + clientSchemas.clientDtoSchema.parse(data.client) + expect(data.client.id).toBe(id) + + const res2 = await app.request(`${api}/client/${id}`, { + headers: { + Authorization: token, + }, + }) + + expect(res2.status).toBe(HttpStatus.NOT_FOUND) + }) +}) diff --git a/src/tests/credit-card.test.ts b/src/tests/credit-card.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9245c64f88b2c88c2126fea8a0001b6a75959897 --- /dev/null +++ b/src/tests/credit-card.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'bun:test' +import app from '..' + +import { api, token } from './preload-tests' +import { HttpStatus } from '@/services/error.service' +import { + creditCardSchemas, + type CreditCardDto, + type CreditCardInput, + type CreditCardUpdate, +} from '@/db/schema/credit-card.model' + +describe('Tests for credit card routes', () => { + const id = '28222da6-2b0a-49df-a328-0703fed118a3' + const clientId = 'f1b9b3b4-0b3b-4b3b-8b3b-0b3b4b3b4b3b' + const baseUrl = `${api}/credit-card` + + it('should create a new creditCard', async () => { + const input: CreditCardInput = { + id, + clientId, + cardholderName: 'Test Cardholder', + cardNumber: '1234567890123456', + cvv: '123', + expirationDate: '2025/12/20', + } + + const res = await app.request(`${baseUrl}/create`, { + method: 'POST', + body: JSON.stringify(input), + headers: { + 'Content-Type': 'application/json', + Authorization: token, + }, + }) + + expect(res.status).toBe(HttpStatus.OK) + const data = (await res.json()) as { creditCard: CreditCardDto } + creditCardSchemas.creditCardDtoSchema.parse(data.creditCard) + }) + + it('should update a credit card', async () => { + const input: CreditCardUpdate = { + id, + cardholderName: 'Test Cardholder Updated', + } + + const res = await app.request(`${baseUrl}/update`, { + method: 'POST', + body: JSON.stringify(input), + headers: { + 'Content-Type': 'application/json', + Authorization: token, + }, + }) + + expect(res.status).toBe(HttpStatus.OK) + + const data = (await res.json()) as { creditCard: CreditCardDto } + creditCardSchemas.creditCardDtoSchema.parse(data.creditCard) + expect(data.creditCard.cardholderName).toBe(input.cardholderName!) + }) + + it('should find a credit card by id', async () => { + const res = await app.request(`${baseUrl}/${id}`, { + headers: { + Authorization: token, + }, + }) + + expect(res.status).toBe(HttpStatus.OK) + + const data = (await res.json()) as { creditCard: CreditCardDto } + creditCardSchemas.creditCardDtoSchema.parse(data.creditCard) + expect(data.creditCard.id).toBe(id) + }) + + it('should delete a credit card', async () => { + const res = await app.request(`${baseUrl}/delete/${id}`, { + method: 'POST', + headers: { + Authorization: token, + }, + }) + + expect(res.status).toBe(HttpStatus.OK) + + const data = (await res.json()) as { creditCard: CreditCardDto } + creditCardSchemas.creditCardDtoSchema.parse(data.creditCard) + expect(data.creditCard.id).toBe(id) + + const res2 = await app.request(`${baseUrl}/${id}`, { + headers: { + Authorization: token, + }, + }) + + expect(res2.status).toBe(HttpStatus.NOT_FOUND) + }) +}) diff --git a/src/tests/preload-tests.ts b/src/tests/preload-tests.ts new file mode 100644 index 0000000000000000000000000000000000000000..f8838f56d966f5671cb426f6e3b7265455c9c1e6 --- /dev/null +++ b/src/tests/preload-tests.ts @@ -0,0 +1,8 @@ +import 'reflect-metadata' + +console.log('preloaded for tests') + +export const api = 'http://localhost:3000/api' + +export const token = + 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImZlNzcwMWJjLWNhZDItNDNkYi05ZjFiLWE3ZDhjM2I1Zjc0YiIsInVzZXJuYW1lIjoiYWRtaW4ifQ.Cd8ixLFseKLRidLTpBfHA1QLolwBFO2pzXHq9UtclWk'