diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 578eaa6..274791f 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -1,6 +1,6 @@ POSTGRES_USER=user POSTGRES_PASSWORD=password POSTGRES_DB=pnscore -DATABASE_URL=postgresql://user:password@db:5432/pnscore +DATABASE_URL=postgresql://user:password@localhost:5432/pnscore FIREFLY_TOKEN=token FIREFLY_API_URL=https://firefly.pns.gg/api \ No newline at end of file diff --git a/apps/backend/drizzle/0001_demonic_exiles.sql b/apps/backend/drizzle/0001_demonic_exiles.sql new file mode 100644 index 0000000..b1b800b --- /dev/null +++ b/apps/backend/drizzle/0001_demonic_exiles.sql @@ -0,0 +1,50 @@ +CREATE TYPE "public"."payment_methods" AS ENUM('VISA', 'MASTERCARD', 'CB', 'CASH', 'PAYPAL');--> statement-breakpoint +CREATE TYPE "public"."product_categories" AS ENUM('MERCHANDIZING', 'DRINKS', 'FOOD');--> statement-breakpoint +CREATE TYPE "public"."stock_movement_types" AS ENUM('BUY', 'SALE', 'LOSS', 'RETURN');--> statement-breakpoint +CREATE TYPE "public"."units_of_measurement" AS ENUM('UNIT', 'KILOGRAM', 'LITER');--> statement-breakpoint +CREATE TABLE "product_sales" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "price" numeric(12, 2) NOT NULL, + "quantity" numeric NOT NULL, + "product_id" uuid NOT NULL, + "sale_id" uuid NOT NULL +); +--> statement-breakpoint +CREATE TABLE "products" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "allergens" text, + "price" numeric(12, 2) NOT NULL, + "category" "product_categories" NOT NULL, + "quantity" numeric NOT NULL, + "unit_of_measurement" "units_of_measurement" DEFAULT 'UNIT' NOT NULL, + "is_on_sale" boolean DEFAULT true NOT NULL, + "location" text +); +--> statement-breakpoint +CREATE TABLE "sales" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "payment_method" "payment_methods" NOT NULL, + "stancer_id" text, + "event_id" uuid, + "stock_movement_id" uuid +); +--> statement-breakpoint +CREATE TABLE "stock_movements" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "product_id" uuid NOT NULL, + "quantity" numeric NOT NULL, + "price" numeric(12, 2) NOT NULL, + "firefly_id" text, + "event_id" uuid +); +--> statement-breakpoint +ALTER TABLE "tournaments" ALTER COLUMN "bracket_type" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "product_sales" ADD CONSTRAINT "product_sales_product_id_products_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."products"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "product_sales" ADD CONSTRAINT "product_sales_sale_id_sales_id_fk" FOREIGN KEY ("sale_id") REFERENCES "public"."sales"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sales" ADD CONSTRAINT "sales_event_id_events_id_fk" FOREIGN KEY ("event_id") REFERENCES "public"."events"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sales" ADD CONSTRAINT "sales_stock_movement_id_stock_movements_id_fk" FOREIGN KEY ("stock_movement_id") REFERENCES "public"."stock_movements"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "stock_movements" ADD CONSTRAINT "stock_movements_product_id_products_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."products"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "stock_movements" ADD CONSTRAINT "stock_movements_event_id_events_id_fk" FOREIGN KEY ("event_id") REFERENCES "public"."events"("id") ON DELETE set null ON UPDATE no action; \ No newline at end of file diff --git a/apps/backend/drizzle/0002_damp_spyke.sql b/apps/backend/drizzle/0002_damp_spyke.sql new file mode 100644 index 0000000..71820f5 --- /dev/null +++ b/apps/backend/drizzle/0002_damp_spyke.sql @@ -0,0 +1,9 @@ +CREATE TYPE "public"."tournament_bracket_types" AS ENUM('ROUND-ROBIN', 'SIMPLE', 'DOUBLE', 'MATCHMAKING', 'OTHER');--> statement-breakpoint +ALTER TABLE "sales" DROP CONSTRAINT "sales_stock_movement_id_stock_movements_id_fk"; +--> statement-breakpoint +ALTER TABLE "tournaments" ALTER COLUMN "bracket_type" SET DEFAULT 'DOUBLE'::"public"."tournament_bracket_types";--> statement-breakpoint +ALTER TABLE "tournaments" ALTER COLUMN "bracket_type" SET DATA TYPE "public"."tournament_bracket_types" USING "bracket_type"::"public"."tournament_bracket_types";--> statement-breakpoint +ALTER TABLE "tournaments" ALTER COLUMN "bracket_type" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "product_sales" ADD COLUMN "index" integer NOT NULL;--> statement-breakpoint +ALTER TABLE "stock_movements" ADD COLUMN "type" "stock_movement_types" NOT NULL;--> statement-breakpoint +ALTER TABLE "sales" ADD CONSTRAINT "sales_stock_movement_id_stock_movements_id_fk" FOREIGN KEY ("stock_movement_id") REFERENCES "public"."stock_movements"("id") ON DELETE set null ON UPDATE no action; \ No newline at end of file diff --git a/apps/backend/drizzle/meta/0001_snapshot.json b/apps/backend/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..8724d80 --- /dev/null +++ b/apps/backend/drizzle/meta/0001_snapshot.json @@ -0,0 +1,559 @@ +{ + "id": "dc24d602-d179-4b8f-b9a6-01dfa86d8930", + "prevId": "7fe30661-e51e-4f07-aa4a-2a8b8597c890", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "startgg_id": { + "name": "startgg_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "series_id": { + "name": "series_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "short_description": { + "name": "short_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "long_description": { + "name": "long_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location_text": { + "name": "location_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_slots": { + "name": "total_slots", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "maximum_participation_fee": { + "name": "maximum_participation_fee", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "events_series_id_series_id_fk": { + "name": "events_series_id_series_id_fk", + "tableFrom": "events", + "tableTo": "series", + "columnsFrom": ["series_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "events_startggId_unique": { + "name": "events_startggId_unique", + "nullsNotDistinct": false, + "columns": ["startgg_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fee_discounts": { + "name": "fee_discounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "fee_discounts_event_id_events_id_fk": { + "name": "fee_discounts_event_id_events_id_fk", + "tableFrom": "fee_discounts", + "tableTo": "events", + "columnsFrom": ["event_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.product_sales": { + "name": "product_sales", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "price": { + "name": "price", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sale_id": { + "name": "sale_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "product_sales_product_id_products_id_fk": { + "name": "product_sales_product_id_products_id_fk", + "tableFrom": "product_sales", + "tableTo": "products", + "columnsFrom": ["product_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "product_sales_sale_id_sales_id_fk": { + "name": "product_sales_sale_id_sales_id_fk", + "tableFrom": "product_sales", + "tableTo": "sales", + "columnsFrom": ["sale_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.products": { + "name": "products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allergens": { + "name": "allergens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "product_categories", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "unit_of_measurement": { + "name": "unit_of_measurement", + "type": "units_of_measurement", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'UNIT'" + }, + "is_on_sale": { + "name": "is_on_sale", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sales": { + "name": "sales", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "payment_method": { + "name": "payment_method", + "type": "payment_methods", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "stancer_id": { + "name": "stancer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "stock_movement_id": { + "name": "stock_movement_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "sales_event_id_events_id_fk": { + "name": "sales_event_id_events_id_fk", + "tableFrom": "sales", + "tableTo": "events", + "columnsFrom": ["event_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "sales_stock_movement_id_stock_movements_id_fk": { + "name": "sales_stock_movement_id_stock_movements_id_fk", + "tableFrom": "sales", + "tableTo": "stock_movements", + "columnsFrom": ["stock_movement_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.series": { + "name": "series", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stock_movements": { + "name": "stock_movements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": true + }, + "firefly_id": { + "name": "firefly_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "stock_movements_product_id_products_id_fk": { + "name": "stock_movements_product_id_products_id_fk", + "tableFrom": "stock_movements", + "tableTo": "products", + "columnsFrom": ["product_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "stock_movements_event_id_events_id_fk": { + "name": "stock_movements_event_id_events_id_fk", + "tableFrom": "stock_movements", + "tableTo": "events", + "columnsFrom": ["event_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tournaments": { + "name": "tournaments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "startgg_id": { + "name": "startgg_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slots": { + "name": "slots", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "bracket_type": { + "name": "bracket_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'DOUBLE'" + } + }, + "indexes": {}, + "foreignKeys": { + "tournaments_event_id_events_id_fk": { + "name": "tournaments_event_id_events_id_fk", + "tableFrom": "tournaments", + "tableTo": "events", + "columnsFrom": ["event_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tournaments_startggId_unique": { + "name": "tournaments_startggId_unique", + "nullsNotDistinct": false, + "columns": ["startgg_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.payment_methods": { + "name": "payment_methods", + "schema": "public", + "values": ["VISA", "MASTERCARD", "CB", "CASH", "PAYPAL"] + }, + "public.product_categories": { + "name": "product_categories", + "schema": "public", + "values": ["MERCHANDIZING", "DRINKS", "FOOD"] + }, + "public.stock_movement_types": { + "name": "stock_movement_types", + "schema": "public", + "values": ["BUY", "SALE", "LOSS", "RETURN"] + }, + "public.units_of_measurement": { + "name": "units_of_measurement", + "schema": "public", + "values": ["UNIT", "KILOGRAM", "LITER"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/backend/drizzle/meta/0002_snapshot.json b/apps/backend/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..acfc062 --- /dev/null +++ b/apps/backend/drizzle/meta/0002_snapshot.json @@ -0,0 +1,578 @@ +{ + "id": "8afd29a8-a348-43e1-b6cd-44645391ad05", + "prevId": "dc24d602-d179-4b8f-b9a6-01dfa86d8930", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "startgg_id": { + "name": "startgg_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "series_id": { + "name": "series_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "short_description": { + "name": "short_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "long_description": { + "name": "long_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location_text": { + "name": "location_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_slots": { + "name": "total_slots", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "maximum_participation_fee": { + "name": "maximum_participation_fee", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "events_series_id_series_id_fk": { + "name": "events_series_id_series_id_fk", + "tableFrom": "events", + "tableTo": "series", + "columnsFrom": ["series_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "events_startggId_unique": { + "name": "events_startggId_unique", + "nullsNotDistinct": false, + "columns": ["startgg_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fee_discounts": { + "name": "fee_discounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "fee_discounts_event_id_events_id_fk": { + "name": "fee_discounts_event_id_events_id_fk", + "tableFrom": "fee_discounts", + "tableTo": "events", + "columnsFrom": ["event_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.product_sales": { + "name": "product_sales", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "price": { + "name": "price", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "index": { + "name": "index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sale_id": { + "name": "sale_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "product_sales_product_id_products_id_fk": { + "name": "product_sales_product_id_products_id_fk", + "tableFrom": "product_sales", + "tableTo": "products", + "columnsFrom": ["product_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "product_sales_sale_id_sales_id_fk": { + "name": "product_sales_sale_id_sales_id_fk", + "tableFrom": "product_sales", + "tableTo": "sales", + "columnsFrom": ["sale_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.products": { + "name": "products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allergens": { + "name": "allergens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "product_categories", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "unit_of_measurement": { + "name": "unit_of_measurement", + "type": "units_of_measurement", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'UNIT'" + }, + "is_on_sale": { + "name": "is_on_sale", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sales": { + "name": "sales", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "payment_method": { + "name": "payment_method", + "type": "payment_methods", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "stancer_id": { + "name": "stancer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "stock_movement_id": { + "name": "stock_movement_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "sales_event_id_events_id_fk": { + "name": "sales_event_id_events_id_fk", + "tableFrom": "sales", + "tableTo": "events", + "columnsFrom": ["event_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "sales_stock_movement_id_stock_movements_id_fk": { + "name": "sales_stock_movement_id_stock_movements_id_fk", + "tableFrom": "sales", + "tableTo": "stock_movements", + "columnsFrom": ["stock_movement_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.series": { + "name": "series", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stock_movements": { + "name": "stock_movements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "stock_movement_types", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "firefly_id": { + "name": "firefly_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "stock_movements_product_id_products_id_fk": { + "name": "stock_movements_product_id_products_id_fk", + "tableFrom": "stock_movements", + "tableTo": "products", + "columnsFrom": ["product_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "stock_movements_event_id_events_id_fk": { + "name": "stock_movements_event_id_events_id_fk", + "tableFrom": "stock_movements", + "tableTo": "events", + "columnsFrom": ["event_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tournaments": { + "name": "tournaments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "startgg_id": { + "name": "startgg_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slots": { + "name": "slots", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "bracket_type": { + "name": "bracket_type", + "type": "tournament_bracket_types", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'DOUBLE'" + } + }, + "indexes": {}, + "foreignKeys": { + "tournaments_event_id_events_id_fk": { + "name": "tournaments_event_id_events_id_fk", + "tableFrom": "tournaments", + "tableTo": "events", + "columnsFrom": ["event_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tournaments_startggId_unique": { + "name": "tournaments_startggId_unique", + "nullsNotDistinct": false, + "columns": ["startgg_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.payment_methods": { + "name": "payment_methods", + "schema": "public", + "values": ["VISA", "MASTERCARD", "CB", "CASH", "PAYPAL"] + }, + "public.product_categories": { + "name": "product_categories", + "schema": "public", + "values": ["MERCHANDIZING", "DRINKS", "FOOD"] + }, + "public.stock_movement_types": { + "name": "stock_movement_types", + "schema": "public", + "values": ["BUY", "SALE", "LOSS", "RETURN"] + }, + "public.tournament_bracket_types": { + "name": "tournament_bracket_types", + "schema": "public", + "values": ["ROUND-ROBIN", "SIMPLE", "DOUBLE", "MATCHMAKING", "OTHER"] + }, + "public.units_of_measurement": { + "name": "units_of_measurement", + "schema": "public", + "values": ["UNIT", "KILOGRAM", "LITER"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/backend/drizzle/meta/_journal.json b/apps/backend/drizzle/meta/_journal.json index d5bbe03..7223ad7 100644 --- a/apps/backend/drizzle/meta/_journal.json +++ b/apps/backend/drizzle/meta/_journal.json @@ -8,6 +8,27 @@ "when": 1762730217760, "tag": "0000_youthful_multiple_man", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1763603257414, + "tag": "0001_demonic_exiles", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1766396510304, + "tag": "0002_damp_spyke", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1767886731864, + "tag": "0003_lyrical_scarecrow", + "breakpoints": true } ] } diff --git a/apps/backend/package.json b/apps/backend/package.json index a477f15..909af68 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -18,6 +18,7 @@ "envalid": "^8.1.1" }, "devDependencies": { + "@sinclair/typebox": "^0.34.41", "bun-types": "latest", "drizzle-kit": "^0.31.6", "pg": "^8.16.3", diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index efb1760..f855b3a 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -1,10 +1,16 @@ +import { productsModule } from '@/modules/products'; +import { salesModule } from '@/modules/sales'; +import { seriesModule } from '@/modules/series'; +import { stockMovementsModule } from '@/modules/stock-movements'; import { Elysia } from 'elysia'; import { eventsModule } from './modules/events'; -import { seriesModule } from './modules/series'; const app = new Elysia() - .use(seriesModule) .use(eventsModule) + .use(productsModule) + .use(salesModule) + .use(seriesModule) + .use(stockMovementsModule) .listen({ port: 3000, hostname: '0.0.0.0' }); console.log(`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`); diff --git a/apps/backend/src/modules/events/index.ts b/apps/backend/src/modules/events/index.ts index ccfeb05..aa8f083 100644 --- a/apps/backend/src/modules/events/index.ts +++ b/apps/backend/src/modules/events/index.ts @@ -27,5 +27,5 @@ export const eventsModule = new Elysia({ prefix: '/events' }) { // params: UuidParamsObject, body: EventsModel.modifyEventsBody, - }, + } ); diff --git a/apps/backend/src/modules/events/model.ts b/apps/backend/src/modules/events/model.ts index e99804f..c7dc2c6 100644 --- a/apps/backend/src/modules/events/model.ts +++ b/apps/backend/src/modules/events/model.ts @@ -7,7 +7,7 @@ export namespace EventsModel { .Transform( t.Numeric({ minimum: 1, - }), + }) ) .Decode((e) => e.toString()) .Encode((e) => parseInt(e)), @@ -25,10 +25,11 @@ export namespace EventsModel { .Transform( t.Numeric({ minimum: 1, - }), + }) ) .Decode((e) => e.toString()) .Encode((e) => parseInt(e)), + slug: t.String({ pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$' }), name: t.String(), slug: t.String(), startsAt: t.Date(), @@ -75,12 +76,12 @@ export namespace EventsModel { slots: t.Optional( t.Number({ minimum: 2, - }), + }) ), bracketType: t.Optional( t.UnionEnum(TournamentBracketTypes, { default: undefined, - }), + }) ), }); diff --git a/apps/backend/src/modules/products/index.ts b/apps/backend/src/modules/products/index.ts new file mode 100644 index 0000000..f0a0561 --- /dev/null +++ b/apps/backend/src/modules/products/index.ts @@ -0,0 +1,35 @@ +import { UuidParamsObject } from '@/utils/type-schema'; +import Elysia from 'elysia'; +import { ProductsModel } from './model'; +import { ProductsService } from './service'; + +export const productsModule = new Elysia({ prefix: '/products' }) + // GET /products + // Récupère tous les produits + .get('/', async () => ProductsService.getProducts()) + // POST /products + // Crée un produit + .post('/', async ({ body }) => ProductsService.createProduct(body), { + body: ProductsModel.createProductsBody, + }) + // GET /products/:id + // Récupère un produit + .get('/:id', async ({ params }) => ProductsService.getProduct(params.id), { + params: UuidParamsObject, + }) + // PATCH /products/:id + // Modifie un produit + .patch('/:id', async ({ params, body }) => ProductsService.modifyProduct(params.id, body), { + params: UuidParamsObject, + body: ProductsModel.modifyProductsBody, + }) + // DELETE /products/:id + // Supprime un produit + .delete('/:id', async ({ params }) => ProductsService.deleteProduct(params.id), { + params: UuidParamsObject, + }) + // GET /products/:id/sales + // Récupère les ventes d'un produit + .get('/:id/sales', async ({ params }) => ProductsService.getProductSales(params.id), { + params: UuidParamsObject, + }); diff --git a/apps/backend/src/modules/products/model.ts b/apps/backend/src/modules/products/model.ts new file mode 100644 index 0000000..2cf0ef7 --- /dev/null +++ b/apps/backend/src/modules/products/model.ts @@ -0,0 +1,22 @@ +import { ProductCategories, UnitsOfMeasurement } from '@/utils/db/schema'; +import { createModifySchema, FloatToString, NoDefaultEnum } from '@/utils/type-schema'; +import { t } from 'elysia'; + +export namespace ProductsModel { + export const createProductsBody = t.Object({ + name: t.String(), + allergens: t.Optional(t.String()), + price: FloatToString({ exclusiveMinimum: 0.0 }), + category: NoDefaultEnum(ProductCategories), + quantity: FloatToString({ exclusiveMinimum: 0.0 }), + unitOfMeasurement: NoDefaultEnum(UnitsOfMeasurement), + isOnSale: t.Optional(t.Boolean({ default: true })), + location: t.Optional(t.String()), + }); + + export type CreateProductBody = typeof createProductsBody.static; + + export const modifyProductsBody = createModifySchema(createProductsBody); + + export type ModifyProductBody = typeof modifyProductsBody.static; +} diff --git a/apps/backend/src/modules/products/service.ts b/apps/backend/src/modules/products/service.ts new file mode 100644 index 0000000..4e8792f --- /dev/null +++ b/apps/backend/src/modules/products/service.ts @@ -0,0 +1,83 @@ +import { db } from '@/utils/db'; +import { productSalesTable, productsTable, salesTable } from '@/utils/db/schema'; +import { eq } from 'drizzle-orm'; +import { status } from 'elysia'; +import { ProductsModel } from './model'; + +export abstract class ProductsService { + static async getProducts() { + return await db.query.productsTable.findMany(); + } + + static async getProduct(productId: string) { + const product = await db.query.productsTable.findFirst({ + where: (product, { eq }) => eq(product.id, productId), + }); + + if (!product) { + throw status(404); + } + + return product; + } + + static async createProduct(data: ProductsModel.CreateProductBody) { + const [newProduct] = await db.insert(productsTable).values(data).returning(); + + return status(201, newProduct); + } + + static async modifyProduct(productId: string, data: ProductsModel.ModifyProductBody) { + const [updatedProduct] = await db + .update(productsTable) + .set(data) + .where(eq(productsTable.id, productId)) + .returning(); + + if (!updatedProduct) { + throw status(404); + } + + return updatedProduct; + } + + static async deleteProduct(productId: string) { + const [deletedProduct] = await db + .delete(productsTable) + .where(eq(productsTable.id, productId)) + .returning({ id: productsTable.id }); + + if (!deletedProduct) { + throw status(404); + } + + return status(204); + } + + static async getProductSales(productId: string) { + const productSales = await db + .select({ + id: salesTable.id, + createdAt: salesTable.createdAt, + paymentMethod: salesTable.paymentMethod, + stancerId: salesTable.stancerId, + eventId: salesTable.eventId, + stockMovementId: salesTable.stockMovementId, + product: { + price: productSalesTable.price, + quantity: productSalesTable.quantity, + index: productSalesTable.index, + productId: productSalesTable.productId, + }, + }) + .from(salesTable) + .leftJoin(productSalesTable, eq(salesTable.id, productSalesTable.saleId)) + .where(eq(productSalesTable.productId, productId)); + + if (!productSales) { + throw status(404); + } + + return productSales; + } +} diff --git a/apps/backend/src/modules/sales/index.ts b/apps/backend/src/modules/sales/index.ts new file mode 100644 index 0000000..be6d322 --- /dev/null +++ b/apps/backend/src/modules/sales/index.ts @@ -0,0 +1,43 @@ +import { Uuid, UuidParamsObject } from '@/utils/type-schema'; +import Elysia, { t } from 'elysia'; +import { SalesModel } from './model'; +import { SalesService } from './service'; + +export const salesModule = new Elysia({ prefix: '/sales' }) + // GET /sales + // Récupère toutes les ventes (objet partiel) + .get('/', async () => await SalesService.getSales()) + // POST /sales + // Crée une vente + .post('/', async ({ body }) => await SalesService.createSale(body), { + body: SalesModel.createSalesBody, + }) + // GET /sales/:id + // Récupère une vente + .get('/:id', async ({ params }) => await SalesService.getSale(params.id), { + params: UuidParamsObject, + }) + // PATCH /sales/:id + // Modifie une vente + .patch('/:id', async ({ params, body }) => await SalesService.modifySale(params.id, body), { + params: UuidParamsObject, + body: SalesModel.modifySalesBody, + }) + // DELETE /sales/:id + // Supprime une vente et les produits vendus + // Ne supprime pas le mouvement d'inventaire ou la transaction Firefly III + .delete('/:id', async ({ params }) => await SalesService.deleteSale(params.id), { + params: UuidParamsObject, + }) + // DELETE /sales/:id/products/:index + // Supprime un produit d'une vente + .delete( + '/:id/products/:index', + async ({ params }) => await SalesService.deleteProductSale(params.id, params.index), + { + params: t.Object({ + id: Uuid(), + index: t.Numeric(), + }), + } + ); diff --git a/apps/backend/src/modules/sales/model.ts b/apps/backend/src/modules/sales/model.ts new file mode 100644 index 0000000..bb6c04c --- /dev/null +++ b/apps/backend/src/modules/sales/model.ts @@ -0,0 +1,33 @@ +import { PaymentMethods } from '@/utils/db/schema'; +import { createModifySchema, FloatToString, NoDefaultEnum, Uuid } from '@/utils/type-schema'; +import { t } from 'elysia'; + +export namespace SalesModel { + const createProductSalesBody = t.Object({ + productId: Uuid(), + price: FloatToString({ minimum: 0 }), + quantity: FloatToString({ minimum: 1 }), + }); + + export type CreateProductSaleBody = typeof createProductSalesBody.static; + + export const modifyProductSalesBody = createProductSalesBody; + + export type ModifyProductSaleBody = typeof modifyProductSalesBody.static; + + export const createSalesBody = t.Object({ + paymentMethod: NoDefaultEnum(PaymentMethods), + stancerId: t.Optional(t.String()), + eventId: Uuid(), + // Une liste d'identifiant des produits vendus + products: t.Array(createProductSalesBody, { minItems: 1, uniqueItems: true }), + }); + + export type CreateSaleBody = typeof createSalesBody.static; + + export const modifySalesBody = createModifySchema(createSalesBody, { + omit: ['products', 'stancerId', 'eventId'], + }); + + export type ModifySaleBody = typeof modifySalesBody.static; +} diff --git a/apps/backend/src/modules/sales/service.ts b/apps/backend/src/modules/sales/service.ts new file mode 100644 index 0000000..878d987 --- /dev/null +++ b/apps/backend/src/modules/sales/service.ts @@ -0,0 +1,191 @@ +import { db } from '@/utils/db'; +import { + CardPaymentMethods, + productSalesTable, + productsTable, + salesTable, +} from '@/utils/db/schema'; +import { fireflyFetch } from '@/utils/firefly'; +import { FireflyIII } from '@/utils/firefly/types'; +import { and, eq, inArray } from 'drizzle-orm'; +import { status } from 'elysia'; +import { SalesModel } from './model'; + +export abstract class SalesService { + static getSaleTotal({ + products, + }: { + products: { + price: string; + quantity: string; + }[]; + }) { + return products.reduce((acc, p) => acc + parseFloat(p.price) * parseFloat(p.quantity), 0); + } + + static async getSales() { + const sales = await db.query.salesTable.findMany({ + columns: { + stockMovementId: false, + }, + with: { + products: { + columns: { + id: false, + saleId: false, + }, + }, + stockMovement: true, + }, + }); + + const salesWithTotal = sales.map((sale) => ({ + ...sale, + total: this.getSaleTotal(sale).toString(), + })); + + return salesWithTotal; + } + + static async createSale(data: SalesModel.CreateSaleBody) { + if (data.stancerId && !CardPaymentMethods.includes(data.paymentMethod)) { + throw status( + 400, + `Une création de sale avec un stancerId doit avoir un paymentMethod de type : ${CardPaymentMethods.join(', ')}` + ); + } + + const uniqueDataProductIds = new Set(data.products.map((p) => p.productId)); + + if (data.products.length !== uniqueDataProductIds.size) { + throw status(400, `Chaque objet produit doit contenir un productId unique.`); + } + + const products = await db + .select() + .from(productsTable) + .where( + inArray( + productsTable.id, + data.products.map((p) => p.productId) + ) + ); + + if (products.length !== data.products.length) { + const productsId = products.map((p) => p.id); + const dataProductIdsNotFound = data.products + .filter((p) => !productsId.includes(p.productId)) + .map((p) => p.productId); + + throw status( + 400, + `Un ou plusieurs produits n'ont pas été trouvés : ${dataProductIdsNotFound.join(', ')}` + ); + } + + const [newSale] = await db.insert(salesTable).values(data).returning(); + + const newSaleProducts = await db + .insert(productSalesTable) + .values( + data.products.map((product, index) => ({ + productId: product.productId, + saleId: newSale.id, + price: product.price, + quantity: product.quantity, + index, + })) + ) + .returning({ + price: productSalesTable.price, + quantity: productSalesTable.quantity, + index: productSalesTable.index, + productId: productSalesTable.productId, + }); + + return { + ...(({ stockMovementId, ...newSale }) => newSale)(newSale), + products: newSaleProducts, + total: this.getSaleTotal(data), + }; + } + + static async getSale(saleId: string) { + const sale = await db.query.salesTable.findFirst({ + where: ({ id }, { eq }) => eq(id, saleId), + columns: { + stockMovementId: false, + }, + with: { + products: { + columns: { + id: false, + saleId: false, + }, + }, + stockMovement: true, + }, + }); + + if (!sale) { + throw status(404); + } + + const stockMovement = sale.stockMovement + ? { + ...sale.stockMovement, + fireflyData: await fireflyFetch( + `/attachments/${sale.stockMovement.fireflyId}` + ), + } + : null; + + return { + ...sale, + stockMovement, + total: this.getSaleTotal(sale), + }; + } + + static async modifySale(saleId: string, data: SalesModel.ModifySaleBody) { + const [updatedSale] = await db + .update(salesTable) + .set(data) + .where(eq(salesTable.id, saleId)) + .returning(); + + if (!updatedSale) { + throw status(404); + } + + return updatedSale; + } + + static async deleteSale(saleId: string) { + const [deletedSale] = await db + .delete(salesTable) + .where(eq(salesTable.id, saleId)) + .returning({ id: salesTable.id }); + + if (!deletedSale) { + throw status(404); + } + + return status(204); + } + + static async deleteProductSale(saleId: string, productSaleIndex: number) { + const [deletedProductSale] = await db + .delete(productSalesTable) + .where( + and(eq(productSalesTable.saleId, saleId), eq(productSalesTable.index, productSaleIndex)) + ) + .returning({ id: productSalesTable.id }); + + if (!deletedProductSale) { + throw status(404); + } + + return status(204); + } +} diff --git a/apps/backend/src/modules/stock-movements/index.ts b/apps/backend/src/modules/stock-movements/index.ts new file mode 100644 index 0000000..8a6b770 --- /dev/null +++ b/apps/backend/src/modules/stock-movements/index.ts @@ -0,0 +1,28 @@ +import { Elysia } from 'elysia'; +import { StockMovementsModel } from './model'; +import { StockMovementsService } from './service'; + +export const stockMovementsModule = new Elysia({ prefix: '/stock-movements' }) + // GET /stock-movements + // Récupère tous les mouvements de stock + .get('/', () => StockMovementsService.getStockMovements()) + // POST /stock-movements + // Crée un nouveau mouvement de stock + .post('/', ({ body }) => StockMovementsService.createStockMovement(body), { + body: StockMovementsModel.createStockMovementBody, + }) + // GET /stock-movements/:id + // Récupère un mouvement de stock + .get('/:id', ({ params: { id } }) => StockMovementsService.getStockMovement(id)) + // PATCH /stock-movements/:id + // Modifie un mouvement de stock + .patch( + '/:id', + ({ params: { id }, body }) => StockMovementsService.modifyStockMovement(id, body), + { + body: StockMovementsModel.modifyStockMovementBody, + } + ) + // DELETE /stock-movements/:id + // Supprime un mouvement de stock + .delete('/:id', ({ params: { id } }) => StockMovementsService.deleteStockMovement(id)); diff --git a/apps/backend/src/modules/stock-movements/model.ts b/apps/backend/src/modules/stock-movements/model.ts new file mode 100644 index 0000000..9ca68ca --- /dev/null +++ b/apps/backend/src/modules/stock-movements/model.ts @@ -0,0 +1,23 @@ +import { StockMovementTypes } from '@/utils/db/schema'; +import { createModifySchema, FloatToString, NoDefaultEnum, Uuid } from '@/utils/type-schema'; +import { t } from 'elysia'; + +export namespace StockMovementsModel { + export const createStockMovementBody = t.Object({ + productId: Uuid(), + quantity: FloatToString(), + price: FloatToString({ minimum: 0 }), + type: NoDefaultEnum(StockMovementTypes), + fireflyId: t.Optional(t.String()), + eventId: t.Optional(Uuid()), + salesIds: t.Optional(t.Array(Uuid())), + }); + + export type CreateStockMovementBody = typeof createStockMovementBody.static; + + export const modifyStockMovementBody = createModifySchema(createStockMovementBody, { + omit: ['salesIds'], + }); + + export type ModifyStockMovementBody = typeof modifyStockMovementBody.static; +} diff --git a/apps/backend/src/modules/stock-movements/service.ts b/apps/backend/src/modules/stock-movements/service.ts new file mode 100644 index 0000000..a3405be --- /dev/null +++ b/apps/backend/src/modules/stock-movements/service.ts @@ -0,0 +1,93 @@ +import { db } from '@/utils/db'; +import { stockMovementsTable } from '@/utils/db/schema'; +import { fireflyFetch } from '@/utils/firefly'; +import { FireflyIII } from '@/utils/firefly/types'; +import { eq } from 'drizzle-orm'; +import { status } from 'elysia'; +import { StockMovementsModel } from './model'; + +export abstract class StockMovementsService { + static async getFireflyTransaction(id: string) { + try { + return await fireflyFetch(`/transactions/${id}`); + } catch (error) { + console.error(`Error fetching Firefly data for transaction ${id}:`, error); + return null; + } + } + + static async getStockMovements() { + const movements = await db.query.stockMovementsTable.findMany(); + + const movementsWithFireflyData = await Promise.all( + movements.map(async (movement) => { + let fireflyData = null; + + if (movement.fireflyId) { + fireflyData = await StockMovementsService.getFireflyTransaction(movement.fireflyId); + } + + return { + ...movement, + fireflyData, + }; + }) + ); + + return movementsWithFireflyData; + } + + static async createStockMovement(data: StockMovementsModel.CreateStockMovementBody) {} + + static async getStockMovement(id: string) { + const movement = await db.query.stockMovementsTable.findFirst({ + where: eq(stockMovementsTable.id, id), + with: { + product: true, + event: true, + sales: true, + }, + }); + + if (!movement) { + throw status(404); + } + + let fireflyData = null; + if (movement.fireflyId) { + fireflyData = await StockMovementsService.getFireflyTransaction(movement.fireflyId); + } + + return { + ...movement, + fireflyData, + }; + } + + static async modifyStockMovement(id: string, data: StockMovementsModel.ModifyStockMovementBody) { + const [updatedMovement] = await db + .update(stockMovementsTable) + .set(data) + .where(eq(stockMovementsTable.id, id)) + .returning(); + + if (!updatedMovement) { + throw status(404); + } + + return updatedMovement; + } + + static async deleteStockMovement(id: string) { + const [deletedMovement] = await db + .delete(stockMovementsTable) + .where(eq(stockMovementsTable.id, id)) + .returning({ id: stockMovementsTable.id }); + + if (!deletedMovement) { + throw status(404); + } + + return status(204); + } +} diff --git a/apps/backend/src/utils/db/schema.ts b/apps/backend/src/utils/db/schema.ts index e162fa7..c0251bf 100644 --- a/apps/backend/src/utils/db/schema.ts +++ b/apps/backend/src/utils/db/schema.ts @@ -1,5 +1,14 @@ import { relations } from 'drizzle-orm'; -import { integer, pgEnum, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'; +import { + boolean, + integer, + numeric, + pgEnum, + pgTable, + text, + timestamp, + uuid, +} from 'drizzle-orm/pg-core'; /** * Table des séries d'événements @@ -94,3 +103,138 @@ export const feeDiscountsRelations = relations(feeDiscountsTable, ({ one }) => ( references: [eventsTable.id], }), })); + +/** + * Énumération des catégories de produits (merch, boissons ou nourriture) + */ +export const ProductCategories = ['MERCHANDIZING', 'DRINKS', 'FOOD'] as [string, ...string[]]; +export const productCategories = pgEnum('product_categories', ProductCategories); + +/** + * Énumération des unités de mesure possibles pour un produit + */ +export const UnitsOfMeasurement = ['UNIT', 'KILOGRAM', 'LITER'] as [string, ...string[]]; +export const unitsOfMeasurement = pgEnum('units_of_measurement', UnitsOfMeasurement); + +/** + * Table des produits (une ligne = un ensemble du même produit, pas une unité de produit) + */ +export const productsTable = pgTable('products', { + id: uuid().defaultRandom().primaryKey(), + name: text().notNull(), + allergens: text(), + price: numeric({ precision: 12, scale: 2 }).notNull(), + category: productCategories().notNull(), + quantity: numeric().notNull(), + unitOfMeasurement: unitsOfMeasurement().default('UNIT').notNull(), + isOnSale: boolean().default(true).notNull(), + location: text(), +}); + +export const productsRelations = relations(productsTable, ({ many }) => ({ + sales: many(productSalesTable), + stockMovements: many(stockMovementsTable), +})); + +export const CardPaymentMethods = ['VISA', 'MASTERCARD', 'CB']; +export const OtherPaymentMethods = ['CASH', 'PAYPAL']; + +export const PaymentMethods = [...CardPaymentMethods, ...OtherPaymentMethods] as [ + string, + ...string[], +]; + +export const paymentMethods = pgEnum('payment_methods', PaymentMethods); + +/** + * Table des ventes. + * Est considérée une vente l'achat d'un client d'un ou plusieurs produits. + * Une vente est comme un ticket de caisse. + * @see productSalesTable + */ +export const salesTable = pgTable('sales', { + id: uuid().defaultRandom().primaryKey(), + createdAt: timestamp().defaultNow().notNull(), + paymentMethod: paymentMethods().notNull(), + stancerId: text(), // ID de référence au paiement sur Stancer, si applicable + eventId: uuid().references(() => eventsTable.id, { onDelete: 'set null' }), + stockMovementId: uuid().references(() => stockMovementsTable.id, { onDelete: 'set null' }), +}); + +export const salesRelations = relations(salesTable, ({ one, many }) => ({ + products: many(productSalesTable), + event: one(eventsTable, { + fields: [salesTable.eventId], + references: [eventsTable.id], + }), + stockMovement: one(stockMovementsTable, { + fields: [salesTable.stockMovementId], + references: [stockMovementsTable.id], + }), +})); + +/** + * Table des ventes d'un produit pour une vente. + * Une ligne de cette table serait équivalente à une ligne sur un ticket de caisse. + * @see salesTable + */ +export const productSalesTable = pgTable('product_sales', { + id: uuid().defaultRandom().primaryKey(), + price: numeric({ precision: 12, scale: 2 }).notNull(), + quantity: numeric().notNull(), + index: integer().notNull(), + productId: uuid() + .references(() => productsTable.id, { onDelete: 'cascade' }) + .notNull(), + saleId: uuid() + .references(() => salesTable.id, { onDelete: 'cascade' }) + .notNull(), +}); + +export const productSalesRelations = relations(productSalesTable, ({ one }) => ({ + sale: one(salesTable, { + fields: [productSalesTable.saleId], + references: [salesTable.id], + }), + product: one(productsTable, { + fields: [productSalesTable.productId], + references: [productsTable.id], + }), +})); + +export const StockMovementTypes = [ + 'BUY', // Achat + 'SALE', // Vente + 'LOSS', // Perte + 'RETURN', // Remboursement +] as [string, ...string[]]; + +export const stockMovementTypes = pgEnum('stock_movement_types', StockMovementTypes); + +/** + * Table des mouvements d'inventaire (achat, remboursement, perte...). + * Pour une vente, considérer une ligne sur cette table comme le bilan pour un événement donné + * Pour un remboursement ou un achat, le produit est forcément défini + */ +export const stockMovementsTable = pgTable('stock_movements', { + id: uuid().defaultRandom().primaryKey(), + createdAt: timestamp().defaultNow().notNull(), + productId: uuid().references(() => productsTable.id, { onDelete: 'cascade' }), + quantity: numeric().notNull(), + price: numeric({ precision: 12, scale: 2 }).notNull(), + type: stockMovementTypes().notNull(), + fireflyId: text(), // ID du journal des opérations Firefly III, non nul dans le cadre d'une vente, d'un achat ou d'un remboursement + eventId: uuid().references(() => eventsTable.id, { onDelete: 'set null' }), +}); + +export const stockMovementsRelations = relations(stockMovementsTable, ({ one, many }) => ({ + product: one(productsTable, { + fields: [stockMovementsTable.productId], + references: [productsTable.id], + }), + sales: many(salesTable), + event: one(eventsTable, { + fields: [stockMovementsTable.eventId], + references: [eventsTable.id], + }), +})); diff --git a/apps/backend/src/utils/env.ts b/apps/backend/src/utils/env.ts index 03d0a1f..77d7014 100644 --- a/apps/backend/src/utils/env.ts +++ b/apps/backend/src/utils/env.ts @@ -1,8 +1,16 @@ -import { cleanEnv, url } from 'envalid'; +import { cleanEnv, str, url } from 'envalid'; export const env = cleanEnv(Bun.env, { DATABASE_URL: url({ desc: 'Une URL vers une base de données PostgreSQL', example: 'postgres://postgres@db:5432/pns', }), + FIREFLY_TOKEN: str({ + desc: "Un jeton d'accès personnel à ton instance Firefly III", + docs: 'https://docs.firefly-iii.org/how-to/firefly-iii/features/api/#personal-access-tokens', + }), + FIREFLY_API_URL: url({ + desc: "L'URL racine de ton instance Firefly III", + example: 'https://firefly.pns.gg/api', + }), }); diff --git a/apps/backend/src/utils/firefly/index.ts b/apps/backend/src/utils/firefly/index.ts new file mode 100644 index 0000000..29f3dc6 --- /dev/null +++ b/apps/backend/src/utils/firefly/index.ts @@ -0,0 +1,14 @@ +import { env } from '.env'; +import { fetch } from 'bun'; + +export async function fireflyFetch(url: `/${string}`, init?: RequestInit) { + return await fetch(`https://${env.FIREFLY_API_URL}/v1${url}`, { + ...init, + headers: { + ...init?.headers, + Accept: 'application/vnd.api+json', + Authorization: `Bearer ${env.FIREFLY_TOKEN}`, + 'Content-type': 'application/json', + }, + }).then((r) => r.json() as T); +} diff --git a/apps/backend/src/utils/firefly/types.ts b/apps/backend/src/utils/firefly/types.ts new file mode 100644 index 0000000..0562f13 --- /dev/null +++ b/apps/backend/src/utils/firefly/types.ts @@ -0,0 +1,48 @@ +export namespace FireflyIII { + export type DateString = string; + + export interface PartialTransaction { + transaction_journal_id: string; + type: 'withdrawal' | 'deposit'; + date: DateString; + order: number; + currency_code: 'EUR'; + currency_name: 'Euro'; + currency_symbol: '€'; + currency_decimal_places: 2; + amount: string; + description: string; + source_id: string; + source_name: string; + source_type: string; + destination_id: string; + destination_name: string; + destination_type: string; + category_name: string | null; + notes: string | null; + tags: string[]; + } + + export interface PartialAPIGetTransactionResponse { + data: { + type: 'transactions'; + id: string; + attributes: { + created_at: DateString; + updated_at: DateString; + transactions: PartialTransaction[]; + }; + links: { + self: string; + }; + }[]; + } + + export interface APIPostTransaction { + error_if_duplicate_hash: boolean; + apply_rules: boolean; + fire_webhooks: boolean; + group_title: string | null; + transactions: PartialTransaction[]; + } +} diff --git a/apps/backend/src/utils/general.ts b/apps/backend/src/utils/general.ts new file mode 100644 index 0000000..e053fc4 --- /dev/null +++ b/apps/backend/src/utils/general.ts @@ -0,0 +1,6 @@ +export type DeepPartial = + T extends Array + ? Array> + : T extends object + ? { [K in keyof T]?: DeepPartial } + : T; diff --git a/apps/backend/src/utils/type-schema.ts b/apps/backend/src/utils/type-schema.ts new file mode 100644 index 0000000..11ab946 --- /dev/null +++ b/apps/backend/src/utils/type-schema.ts @@ -0,0 +1,79 @@ +import { + NumberOptions, + SchemaOptions, + Static, + StringOptions, + TEnumValue, + TObject, + TOmit, + TSchema, + TypeGuard, +} from '@sinclair/typebox'; +import { t } from 'elysia'; +import { DeepPartial } from './general'; + +export const Uuid = (options?: StringOptions) => t.String({ ...options, format: 'uuid' }); + +export const FloatToString = (options?: NumberOptions) => + t + .Transform(t.Numeric(options)) + .Decode((e) => e.toString()) + .Encode((e) => parseFloat(e)); + +export const UuidParamsObject = t.Object({ + id: Uuid(), +}); + +export interface TRecursiveOptional extends TSchema { + static: DeepPartial>; +} + +export function RecursiveOptional(schema: T): TRecursiveOptional { + if (TypeGuard.IsObject(schema)) { + const newProps: Record = {}; + for (const [key, propSchema] of Object.entries(schema.properties)) { + newProps[key] = RecursiveOptional(propSchema); + } + + const newObj = t.Object(newProps, { ...schema }); + + return t.Partial(newObj) as unknown as TRecursiveOptional; + } + + if (TypeGuard.IsArray(schema)) { + const inner = RecursiveOptional(schema.items); + return t.Array(inner, { ...schema }) as unknown as TRecursiveOptional; + } + + if (TypeGuard.IsUnion(schema)) { + const anyOf = schema.anyOf.map((s: TSchema) => RecursiveOptional(s)); + return t.Union(anyOf, { ...schema }) as unknown as TRecursiveOptional; + } + + if (TypeGuard.IsIntersect(schema)) { + const allOf = schema.allOf.map((s: TSchema) => RecursiveOptional(s)); + return t.Intersect(allOf, { ...schema }) as unknown as TRecursiveOptional; + } + + return schema as unknown as TRecursiveOptional; +} + +export const NoDefaultEnum = ( + values: T, + options?: SchemaOptions +) => t.UnionEnum(values, { default: undefined, ...options }); + +export function createModifySchema(schema: T): TRecursiveOptional; +export function createModifySchema)[]>( + schema: T, + opts: { omit: Keys } +): TRecursiveOptional>; +export function createModifySchema)[]>( + schema: T, + opts?: { omit?: Keys } +): TRecursiveOptional | TRecursiveOptional> { + return RecursiveOptional({ + ...(opts?.omit ? t.Omit(schema, opts.omit) : schema), + minProperties: 1, + }) as unknown as TRecursiveOptional; +} diff --git a/bun.lock b/bun.lock index a602b9f..abc15b3 100644 --- a/bun.lock +++ b/bun.lock @@ -19,6 +19,7 @@ "envalid": "^8.1.1", }, "devDependencies": { + "@sinclair/typebox": "^0.34.41", "bun-types": "latest", "drizzle-kit": "^0.31.6", "pg": "^8.16.3", diff --git a/package.json b/package.json index 80f34e0..f9ffce9 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "pns-core", "private": true, "scripts": { + "format": "prettier --write .", "dev": "bun --filter '*' dev", "build": "bun --filter '*' build", "test": "bun --filter '*' test"