From 21f459e695e8fe2dba1afda0ad4297e824b0d58e Mon Sep 17 00:00:00 2001 From: christian Date: Sun, 21 Sep 2025 21:06:02 +0100 Subject: [PATCH] feat: server --- package.json | 23 +- ...micromax.sql => 0000_lively_ben_grimm.sql} | 19 +- src/database/migrations/0001_odd_stingray.sql | 2 + .../0002_sloppy_infant_terrible.sql | 2 + .../migrations/0003_shocking_barracuda.sql | 1 + .../migrations/0004_faithful_daredevil.sql | 2 + .../migrations/meta/0000_snapshot.json | 116 +++---- .../migrations/meta/0001_snapshot.json | 298 ++++++++++++++++ .../migrations/meta/0002_snapshot.json | 297 ++++++++++++++++ .../migrations/meta/0003_snapshot.json | 303 ++++++++++++++++ .../migrations/meta/0004_snapshot.json | 322 ++++++++++++++++++ src/database/migrations/meta/_journal.json | 32 +- src/database/schema/index.ts | 1 + src/database/schema/schema/products.ts | 5 +- src/database/schema/schema/subscriptions.ts | 9 +- src/index.ts | 50 --- src/index.tsx | 131 +++++++ src/lib/constants/index.ts | 14 + src/lib/constants/packages.ts | 58 ++++ src/lib/constants/products.ts | 9 + src/lib/constants/ultracollecte.ts | 53 --- src/lib/types/index.ts | 51 ++- src/lib/types/packages.ts | 66 ++++ src/lib/types/products.ts | 3 + src/lib/types/ultracollectes.ts | 11 - src/lib/utils/index.ts | 10 +- src/server/openapi.ts | 30 ++ src/server/routes/packages.ts | 162 ++++++--- src/server/routes/products.ts | 45 ++- src/server/routes/subscription-client.ts | 78 +++++ src/server/routes/subscriptions.ts | 252 ++++++++++---- src/server/seeds/index.ts | 3 + src/server/seeds/packages.ts | 77 ++--- src/server/seeds/products.ts | 21 ++ .../services/{prducts.ts => products.ts} | 0 src/server/services/subscriptions.ts | 103 ++++-- src/server/utils/getSeedingdata.ts | 36 ++ src/server/utils/pagination.util.ts | 193 +++++++++++ src/web/App.tsx | 14 - src/web/components/layout/Footer.tsx | 67 ++++ src/web/components/layout/Header.tsx | 79 +++++ src/web/components/packages/Packagecard.tsx | 109 ++++++ src/web/pages/DashboardPage.tsx | 302 ++++++++++++++++ src/web/pages/HomePage.tsx | 140 ++++++++ src/web/pages/PackagesPage.tsx | 134 ++++++++ tsconfig.json | 2 +- yarn.lock | 49 ++- 47 files changed, 3295 insertions(+), 489 deletions(-) rename src/database/migrations/{0000_ordinary_micromax.sql => 0000_lively_ben_grimm.sql} (98%) create mode 100644 src/database/migrations/0001_odd_stingray.sql create mode 100644 src/database/migrations/0002_sloppy_infant_terrible.sql create mode 100644 src/database/migrations/0003_shocking_barracuda.sql create mode 100644 src/database/migrations/0004_faithful_daredevil.sql create mode 100644 src/database/migrations/meta/0001_snapshot.json create mode 100644 src/database/migrations/meta/0002_snapshot.json create mode 100644 src/database/migrations/meta/0003_snapshot.json create mode 100644 src/database/migrations/meta/0004_snapshot.json delete mode 100644 src/index.ts create mode 100644 src/index.tsx create mode 100644 src/lib/constants/index.ts create mode 100644 src/lib/constants/packages.ts create mode 100644 src/lib/constants/products.ts delete mode 100644 src/lib/constants/ultracollecte.ts create mode 100644 src/lib/types/packages.ts create mode 100644 src/lib/types/products.ts delete mode 100644 src/lib/types/ultracollectes.ts create mode 100644 src/server/openapi.ts create mode 100644 src/server/routes/subscription-client.ts create mode 100644 src/server/seeds/products.ts rename src/server/services/{prducts.ts => products.ts} (100%) create mode 100644 src/server/utils/getSeedingdata.ts create mode 100644 src/server/utils/pagination.util.ts delete mode 100644 src/web/App.tsx create mode 100644 src/web/components/layout/Footer.tsx create mode 100644 src/web/components/layout/Header.tsx create mode 100644 src/web/components/packages/Packagecard.tsx create mode 100644 src/web/pages/DashboardPage.tsx create mode 100644 src/web/pages/HomePage.tsx create mode 100644 src/web/pages/PackagesPage.tsx diff --git a/package.json b/package.json index ba4346d..5bc83ff 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "subscriptions-service", "scripts": { - "dev": "bun run --hot src/index.ts", + "dev": "bun run --hot src/index.tsx", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:seed": "bun run src/server/seeds/index.ts" @@ -10,31 +10,36 @@ "@auth/core": "^0.40.0", "@hono/auth-js": "^1.1.0", "@hono/node-server": "^1.19.3", + "@hono/react-renderer": "^1.0.1", + "@hono/standard-validator": "^0.1.5", + "@hono/swagger-ui": "^0.5.2", + "bcryptjs": "^2.4.3", "bullmq": "^5.58.6", "clsx": "^2.1.1", "drizzle-kit": "^0.31.4", "drizzle-orm": "^0.44.5", "hono": "^4.9.8", + "hono-openapi": "^1.0.8", "ioredis": "^5.7.0", + "lucide-react": "^0.544.0", "node-cron": "^4.2.1", "pg": "^8.16.3", "postgres": "^3.4.4", - "@hono/react-renderer": "^1.0.0", - "react": "^18.3.1", + "react": "^18.3.1", "react-dom": "^18.3.1", - "bcryptjs": "^2.4.3" + "zod": "^4.1.11" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", "@types/bun": "latest", "@types/node": "^24.5.2", + "@types/node-cron": "^3.0.11", + "@types/react": "^19.1.13", + "@types/react-dom": "^19.1.9", + "drizzle-kit": "^0.24.0", "eslint": "^9.35.0", "prettier": "^3.6.2", "tsx": "^4.20.5", - "@types/react": "^18.3.5", - "@types/react-dom": "^18.3.0", - "@types/bcryptjs": "^2.4.6", - "@types/node-cron": "^3.0.11", - "drizzle-kit": "^0.24.0", "typescript": "^5.5.3" } } diff --git a/src/database/migrations/0000_ordinary_micromax.sql b/src/database/migrations/0000_lively_ben_grimm.sql similarity index 98% rename from src/database/migrations/0000_ordinary_micromax.sql rename to src/database/migrations/0000_lively_ben_grimm.sql index 9144d3a..41f94a6 100644 --- a/src/database/migrations/0000_ordinary_micromax.sql +++ b/src/database/migrations/0000_lively_ben_grimm.sql @@ -13,6 +13,15 @@ CREATE TABLE "packages" ( CONSTRAINT "packages_name_unique" UNIQUE("name") ); --> statement-breakpoint +CREATE TABLE "products" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" varchar(255) NOT NULL, + "description" varchar(255) NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "products_name_unique" UNIQUE("name") +); +--> statement-breakpoint CREATE TABLE "subscriptions" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "protect_key" varchar(255) NOT NULL, @@ -28,15 +37,5 @@ CREATE TABLE "subscriptions" ( CONSTRAINT "subscriptions_protect_key_unique" UNIQUE("protect_key") ); --> statement-breakpoint -CREATE TABLE "products" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "name" varchar(255) NOT NULL, - "description" varchar(255) NOT NULL, - "features" jsonb NOT NULL, - "created_at" timestamp DEFAULT now() NOT NULL, - "updated_at" timestamp DEFAULT now() NOT NULL, - CONSTRAINT "products_name_unique" UNIQUE("name") -); ---> statement-breakpoint ALTER TABLE "packages" ADD CONSTRAINT "packages_product_id_products_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."products"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_package_id_packages_id_fk" FOREIGN KEY ("package_id") REFERENCES "public"."packages"("id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/src/database/migrations/0001_odd_stingray.sql b/src/database/migrations/0001_odd_stingray.sql new file mode 100644 index 0000000..51d7558 --- /dev/null +++ b/src/database/migrations/0001_odd_stingray.sql @@ -0,0 +1,2 @@ +ALTER TABLE "products" ALTER COLUMN "description" SET DEFAULT '';--> statement-breakpoint +ALTER TABLE "products" ALTER COLUMN "description" DROP NOT NULL; \ No newline at end of file diff --git a/src/database/migrations/0002_sloppy_infant_terrible.sql b/src/database/migrations/0002_sloppy_infant_terrible.sql new file mode 100644 index 0000000..3e8d4c3 --- /dev/null +++ b/src/database/migrations/0002_sloppy_infant_terrible.sql @@ -0,0 +1,2 @@ +DROP TYPE "public"."subscriber_type";--> statement-breakpoint +CREATE TYPE "public"."subscriber_type" AS ENUM('student', 'company', 'personal'); \ No newline at end of file diff --git a/src/database/migrations/0003_shocking_barracuda.sql b/src/database/migrations/0003_shocking_barracuda.sql new file mode 100644 index 0000000..06df3af --- /dev/null +++ b/src/database/migrations/0003_shocking_barracuda.sql @@ -0,0 +1 @@ +ALTER TABLE "subscriptions" ADD COLUMN "used_features" jsonb NOT NULL; \ No newline at end of file diff --git a/src/database/migrations/0004_faithful_daredevil.sql b/src/database/migrations/0004_faithful_daredevil.sql new file mode 100644 index 0000000..a9969ad --- /dev/null +++ b/src/database/migrations/0004_faithful_daredevil.sql @@ -0,0 +1,2 @@ +ALTER TABLE "subscriptions" ADD COLUMN "product_id" uuid NOT NULL;--> statement-breakpoint +ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_product_id_products_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."products"("id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/src/database/migrations/meta/0000_snapshot.json b/src/database/migrations/meta/0000_snapshot.json index 2937b27..4edbdfa 100644 --- a/src/database/migrations/meta/0000_snapshot.json +++ b/src/database/migrations/meta/0000_snapshot.json @@ -1,5 +1,5 @@ { - "id": "edfbe82b-5107-421b-9e3c-8170ec30d17c", + "id": "4a2b23d8-2835-4ba3-a254-ff9d24c1bf35", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", @@ -91,6 +91,60 @@ "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": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(255)", + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "products_name_unique": { + "name": "products_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.subscriptions": { "name": "subscriptions", "schema": "", @@ -198,66 +252,6 @@ "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": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "features": { - "name": "features", - "type": "jsonb", - "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": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "products_name_unique": { - "name": "products_name_unique", - "nullsNotDistinct": false, - "columns": [ - "name" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false } }, "enums": { diff --git a/src/database/migrations/meta/0001_snapshot.json b/src/database/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..8849572 --- /dev/null +++ b/src/database/migrations/meta/0001_snapshot.json @@ -0,0 +1,298 @@ +{ + "id": "acf4bf99-bec3-4cfd-a8b1-b53a3df22200", + "prevId": "4a2b23d8-2835-4ba3-a254-ff9d24c1bf35", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.packages": { + "name": "packages", + "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 + }, + "price": { + "name": "price", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "features": { + "name": "features", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "support_type": { + "name": "support_type", + "type": "support_type", + "typeSchema": "public", + "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": { + "packages_product_id_products_id_fk": { + "name": "packages_product_id_products_id_fk", + "tableFrom": "packages", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "packages_name_unique": { + "name": "packages_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "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": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "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": { + "products_name_unique": { + "name": "products_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "protect_key": { + "name": "protect_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subscriber": { + "name": "subscriber", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "duration_in_months": { + "name": "duration_in_months", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "sent_sms_count": { + "name": "sent_sms_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "subscription_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "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": { + "subscriptions_package_id_packages_id_fk": { + "name": "subscriptions_package_id_packages_id_fk", + "tableFrom": "subscriptions", + "tableTo": "packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscriptions_protect_key_unique": { + "name": "subscriptions_protect_key_unique", + "nullsNotDistinct": false, + "columns": [ + "protect_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.support_type": { + "name": "support_type", + "schema": "public", + "values": [ + "standard", + "24/7", + "premium" + ] + }, + "public.subscriber_type": { + "name": "subscriber_type", + "schema": "public", + "values": [ + "microfinance", + "student", + "company", + "school" + ] + }, + "public.subscription_status": { + "name": "subscription_status", + "schema": "public", + "values": [ + "active", + "expired", + "canceled" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/database/migrations/meta/0002_snapshot.json b/src/database/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..7043c12 --- /dev/null +++ b/src/database/migrations/meta/0002_snapshot.json @@ -0,0 +1,297 @@ +{ + "id": "d4a13631-0f62-40d8-b907-ecd4693eb607", + "prevId": "acf4bf99-bec3-4cfd-a8b1-b53a3df22200", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.packages": { + "name": "packages", + "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 + }, + "price": { + "name": "price", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "features": { + "name": "features", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "support_type": { + "name": "support_type", + "type": "support_type", + "typeSchema": "public", + "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": { + "packages_product_id_products_id_fk": { + "name": "packages_product_id_products_id_fk", + "tableFrom": "packages", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "packages_name_unique": { + "name": "packages_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "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": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "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": { + "products_name_unique": { + "name": "products_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "protect_key": { + "name": "protect_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subscriber": { + "name": "subscriber", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "duration_in_months": { + "name": "duration_in_months", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "sent_sms_count": { + "name": "sent_sms_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "subscription_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "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": { + "subscriptions_package_id_packages_id_fk": { + "name": "subscriptions_package_id_packages_id_fk", + "tableFrom": "subscriptions", + "tableTo": "packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscriptions_protect_key_unique": { + "name": "subscriptions_protect_key_unique", + "nullsNotDistinct": false, + "columns": [ + "protect_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.support_type": { + "name": "support_type", + "schema": "public", + "values": [ + "standard", + "24/7", + "premium" + ] + }, + "public.subscriber_type": { + "name": "subscriber_type", + "schema": "public", + "values": [ + "student", + "company", + "personal" + ] + }, + "public.subscription_status": { + "name": "subscription_status", + "schema": "public", + "values": [ + "active", + "expired", + "canceled" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/database/migrations/meta/0003_snapshot.json b/src/database/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..f1d820b --- /dev/null +++ b/src/database/migrations/meta/0003_snapshot.json @@ -0,0 +1,303 @@ +{ + "id": "b42800ef-0563-4446-aa07-e71dc4becb44", + "prevId": "d4a13631-0f62-40d8-b907-ecd4693eb607", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.packages": { + "name": "packages", + "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 + }, + "price": { + "name": "price", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "features": { + "name": "features", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "support_type": { + "name": "support_type", + "type": "support_type", + "typeSchema": "public", + "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": { + "packages_product_id_products_id_fk": { + "name": "packages_product_id_products_id_fk", + "tableFrom": "packages", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "packages_name_unique": { + "name": "packages_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "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": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "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": { + "products_name_unique": { + "name": "products_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "protect_key": { + "name": "protect_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subscriber": { + "name": "subscriber", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "duration_in_months": { + "name": "duration_in_months", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "sent_sms_count": { + "name": "sent_sms_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "subscription_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "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()" + }, + "used_features": { + "name": "used_features", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "subscriptions_package_id_packages_id_fk": { + "name": "subscriptions_package_id_packages_id_fk", + "tableFrom": "subscriptions", + "tableTo": "packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscriptions_protect_key_unique": { + "name": "subscriptions_protect_key_unique", + "nullsNotDistinct": false, + "columns": [ + "protect_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.support_type": { + "name": "support_type", + "schema": "public", + "values": [ + "standard", + "24/7", + "premium" + ] + }, + "public.subscriber_type": { + "name": "subscriber_type", + "schema": "public", + "values": [ + "student", + "company", + "personal" + ] + }, + "public.subscription_status": { + "name": "subscription_status", + "schema": "public", + "values": [ + "active", + "expired", + "canceled" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/database/migrations/meta/0004_snapshot.json b/src/database/migrations/meta/0004_snapshot.json new file mode 100644 index 0000000..d3f2074 --- /dev/null +++ b/src/database/migrations/meta/0004_snapshot.json @@ -0,0 +1,322 @@ +{ + "id": "f50b9714-0193-4e22-9a51-8754c3d60fe0", + "prevId": "b42800ef-0563-4446-aa07-e71dc4becb44", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.packages": { + "name": "packages", + "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 + }, + "price": { + "name": "price", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "features": { + "name": "features", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "support_type": { + "name": "support_type", + "type": "support_type", + "typeSchema": "public", + "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": { + "packages_product_id_products_id_fk": { + "name": "packages_product_id_products_id_fk", + "tableFrom": "packages", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "packages_name_unique": { + "name": "packages_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "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": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "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": { + "products_name_unique": { + "name": "products_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "protect_key": { + "name": "protect_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subscriber": { + "name": "subscriber", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "duration_in_months": { + "name": "duration_in_months", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "sent_sms_count": { + "name": "sent_sms_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "subscription_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "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()" + }, + "used_features": { + "name": "used_features", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "subscriptions_package_id_packages_id_fk": { + "name": "subscriptions_package_id_packages_id_fk", + "tableFrom": "subscriptions", + "tableTo": "packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "subscriptions_product_id_products_id_fk": { + "name": "subscriptions_product_id_products_id_fk", + "tableFrom": "subscriptions", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscriptions_protect_key_unique": { + "name": "subscriptions_protect_key_unique", + "nullsNotDistinct": false, + "columns": [ + "protect_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.support_type": { + "name": "support_type", + "schema": "public", + "values": [ + "standard", + "24/7", + "premium" + ] + }, + "public.subscriber_type": { + "name": "subscriber_type", + "schema": "public", + "values": [ + "student", + "company", + "personal" + ] + }, + "public.subscription_status": { + "name": "subscription_status", + "schema": "public", + "values": [ + "active", + "expired", + "canceled" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/database/migrations/meta/_journal.json b/src/database/migrations/meta/_journal.json index 33703ca..06014fc 100644 --- a/src/database/migrations/meta/_journal.json +++ b/src/database/migrations/meta/_journal.json @@ -5,8 +5,36 @@ { "idx": 0, "version": "7", - "when": 1758304299810, - "tag": "0000_ordinary_micromax", + "when": 1758426395659, + "tag": "0000_lively_ben_grimm", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1758426941130, + "tag": "0001_odd_stingray", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1758437134222, + "tag": "0002_sloppy_infant_terrible", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1758455474529, + "tag": "0003_shocking_barracuda", + "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1758458041709, + "tag": "0004_faithful_daredevil", "breakpoints": true } ] diff --git a/src/database/schema/index.ts b/src/database/schema/index.ts index 0ca2541..d01de52 100644 --- a/src/database/schema/index.ts +++ b/src/database/schema/index.ts @@ -1,3 +1,4 @@ export * from './schema/packages'; +export * from './schema/products'; export * from './schema/subscriptions'; diff --git a/src/database/schema/schema/products.ts b/src/database/schema/schema/products.ts index a3a7b41..ff13daf 100644 --- a/src/database/schema/schema/products.ts +++ b/src/database/schema/schema/products.ts @@ -1,10 +1,9 @@ -import { jsonb, pgTable, timestamp, uuid, varchar } from 'drizzle-orm/pg-core'; +import { pgTable, timestamp, uuid, varchar } from 'drizzle-orm/pg-core'; export const products = pgTable('products', { id: uuid('id').primaryKey().defaultRandom(), name: varchar('name', { length: 255 }).notNull().unique(), - description: varchar('description', { length: 255 }).notNull(), - features: jsonb('features').notNull(), + description: varchar('description', { length: 255 }).default(''), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), }); diff --git a/src/database/schema/schema/subscriptions.ts b/src/database/schema/schema/subscriptions.ts index 180e698..7237d61 100644 --- a/src/database/schema/schema/subscriptions.ts +++ b/src/database/schema/schema/subscriptions.ts @@ -1,15 +1,17 @@ import { integer, jsonb, pgEnum, pgTable, timestamp, uuid, varchar } from 'drizzle-orm/pg-core'; -import type { Subscriber } from '../../../lib/types'; +import type { ISubscriber } from '../../../lib/types'; import { packages } from './packages'; +import { products } from './products'; -export const subscriberTypeEnum = pgEnum('subscriber_type', ['microfinance', 'student', 'company', 'school']); +export const subscriberTypeEnum = pgEnum('subscriber_type', ['student', 'company', 'personal']); export const subscriptionStatusEnum = pgEnum('subscription_status', ['active', 'expired', 'canceled']); export const subscriptions = pgTable('subscriptions', { id: uuid('id').primaryKey().defaultRandom(), protectKey: varchar('protect_key', { length: 255 }).notNull().unique(), - subscriber: jsonb('subscriber').$type().notNull(), + subscriber: jsonb('subscriber').$type().notNull(), package: uuid('package_id').notNull().references(() => packages.id), + product: uuid('product_id').notNull().references(() => products.id), startDate: timestamp('start_date').notNull().defaultNow(), durationInMonths: integer('duration_in_months').notNull(), endDate: timestamp('end_date').notNull(), @@ -17,6 +19,7 @@ export const subscriptions = pgTable('subscriptions', { status: subscriptionStatusEnum('status').notNull().default('active'), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), + usedFeatures: jsonb('used_features').notNull(), }); export type Subscription = typeof subscriptions.$inferSelect; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 2aa147e..0000000 --- a/src/index.ts +++ /dev/null @@ -1,50 +0,0 @@ -import GitHub from '@auth/core/providers/github'; -import { authHandler, initAuthConfig, verifyAuth } from '@hono/auth-js'; -import { serve } from '@hono/node-server'; -import { serveStatic } from '@hono/node-server/serve-static'; -import { Hono } from 'hono'; -import packageRoutes from './server/routes/packages'; -import productRoutes from './server/routes/products'; -import subscriptionRoutes from './server/routes/subscriptions'; -const app = new Hono() - -// Auth middleware -app.use( - '*', - initAuthConfig((c) => ({ - secret: process.env.AUTH_SECRET, - providers: [ - GitHub({ - clientId: process.env.GITHUB_ID, - clientSecret: process.env.GITHUB_SECRET, - }), - ], - })) -) - -// Static files -app.use('/static/*', serveStatic({root: 'src/public'})); - -// Auth routes -app.use('/api/auth/*', authHandler()) - -// Protected routes -app.use('/api/*', verifyAuth()) - -// Routes -app.route('/api/packages', packageRoutes) -app.route('/api/subscriptions', subscriptionRoutes) -app.route('/api/products', productRoutes) - -app.get('/', (c) => { - return c.text('Hello Hono!') -}) - - -app.get('*', (c) => { - return c.notFound() -}) - -const port = process.env.PORT || 3000; - -serve({ fetch: app.fetch, port: Number(port) }) diff --git a/src/index.tsx b/src/index.tsx new file mode 100644 index 0000000..5a67c5d --- /dev/null +++ b/src/index.tsx @@ -0,0 +1,131 @@ +import GitHub from '@auth/core/providers/github'; +import { authHandler, initAuthConfig, verifyAuth } from '@hono/auth-js'; +import { serve } from '@hono/node-server'; +import { serveStatic } from '@hono/node-server/serve-static'; +import { reactRenderer } from '@hono/react-renderer'; +import { Hono } from 'hono'; +import packageRoutes from './server/routes/packages'; +import productRoutes from './server/routes/products'; +import subscriptionClientRoutes from './server/routes/subscription-client'; +import subscriptionRoutes from './server/routes/subscriptions'; +import DashboardPage from './web/pages/DashboardPage'; +import HomePage from './web/pages/HomePage'; +import PackagesPage from './web/pages/PackagesPage'; + +// jobs cron +require('./server/jobs/cron'); + +const app = new Hono() + +// Auth middleware +app.use( + '*', + initAuthConfig((c) => ({ + secret: process.env.AUTH_SECRET, + providers: [ + GitHub({ + clientId: process.env.GITHUB_ID, + clientSecret: process.env.GITHUB_SECRET, + }), + ], + })), +) + + +// React renderer setup +app.use( + '*', + reactRenderer(({ children, title }: { children: React.ReactNode; title: string }) => { + return ( + + + + + {title || 'Subscription System'} + + + +
{children}
+ + + ); + }) +); + + +// Static files +app.use('/static/*', serveStatic({root: 'src/public'})); + +// Auth routes +app.use('/api/auth/*', authHandler()) + + +// Protected routes +app.use('/api/packages', verifyAuth()) +app.use('/api/subscriptions', verifyAuth()) +app.use('/api/products', verifyAuth()) + +// Routes +app.route('/api/packages', packageRoutes) +app.route('/api/subscriptions', subscriptionRoutes) +app.route('/api/products', productRoutes) +app.route('/api/:productkey', subscriptionClientRoutes) + +app.get('/', verifyAuth(), (c) => { + const auth = c.get('authUser') + + const user = auth?.token ? { + email: auth?.token?.email || '', + name: auth?.token?.name || '', + token: auth?.token?.jti || '', + } : undefined; + + if (!user) { + return c.redirect('/api/auth/signin'); + } + + return c.render(, { title: 'Home - Subscription System' }); +}); + +app.get('/packages', verifyAuth(), (c) => { + const auth = c.get('authUser'); + const user = auth?.token ? { + email: auth?.token?.email || '', + name: auth?.token?.name || '', + token: auth?.token?.jti || '', + } : undefined + + if (!user) { + return c.redirect('/api/auth/signin'); + } + + return c.render(, { title: 'Packages - Subscription System' }); +}); + +app.get('/dashboard', verifyAuth(), (c) => { + const auth = c.get('authUser'); + const user = auth?.token ? { + email: auth?.token?.email || '', + name: auth?.token?.name || '', + token: auth?.token?.jti || '', + } : undefined; + + if (!user) { + return c.redirect('/api/auth/signin'); + } + + return c.render(, { title: 'Dashboard - Subscription System' }); +}); + +// Health check +app.get('/health', (c) => { + return c.json({ status: 'ok', timestamp: new Date().toISOString() }); +}); + +app.get('*', (c) => { + return c.notFound() +}) + +const port = process.env.PORT || 3000; + +serve({ fetch: app.fetch, port: Number(port) }) diff --git a/src/lib/constants/index.ts b/src/lib/constants/index.ts new file mode 100644 index 0000000..96470ba --- /dev/null +++ b/src/lib/constants/index.ts @@ -0,0 +1,14 @@ +import { ultracollecteSeedPackage } from "./packages"; +import { ultracolletProductSeeding } from "./products"; + + +export const seedingData = { + products: { + ultracollecte: [ultracolletProductSeeding], + }, + packages: { + ultracollecte: ultracollecteSeedPackage, + } +} + +export type ISeedingData = typeof seedingData diff --git a/src/lib/constants/packages.ts b/src/lib/constants/packages.ts new file mode 100644 index 0000000..b8f3b9f --- /dev/null +++ b/src/lib/constants/packages.ts @@ -0,0 +1,58 @@ +import { IUltracollecteSeedPackage } from "../types"; + +export const ultracollecteSeedPackage: IUltracollecteSeedPackage[] =[ + { + name: 'starter', + price: 50000, + supportType: 'standard', + createdAt: new Date(), + updatedAt: new Date(), + features: { + clientsCount: 1000, + agentsCount: 5, + adminsCount: 2, + agenciesCount: 1, + agentLocalization: true, + enableCommision: true, + smsCount: 250, + supervisorsCount: 2, + cashiersCount: 2, + }, + }, + { + name: 'corporate', + price: 200000, + supportType: '24/7', + createdAt: new Date(), + updatedAt: new Date(), + features: { + clientsCount: 10000, + agentsCount: 100, + adminsCount: 12, + agenciesCount: 10, + agentLocalization: true, + enableCommision: true, + smsCount: 2500, + supervisorsCount: 22, + cashiersCount: 25, + }, + }, + { + name: 'pro', + price: 100000, + supportType: '24/7', + createdAt: new Date(), + updatedAt: new Date(), + features: { + clientsCount: 2500, + agentsCount: 20, + adminsCount: 5, + agenciesCount: 4, + agentLocalization: true, + enableCommision: true, + smsCount: 1000, + supervisorsCount: 10, + cashiersCount: 10, + }, + }, + ]; \ No newline at end of file diff --git a/src/lib/constants/products.ts b/src/lib/constants/products.ts new file mode 100644 index 0000000..ed26af7 --- /dev/null +++ b/src/lib/constants/products.ts @@ -0,0 +1,9 @@ +import { IProductSeed } from "../types"; + +export const ultracolletProductSeeding: IProductSeed = { + name: "ultracollecte", + description: "", + createdAt: new Date(), + updatedAt: new Date(), +}; + diff --git a/src/lib/constants/ultracollecte.ts b/src/lib/constants/ultracollecte.ts deleted file mode 100644 index 09d628f..0000000 --- a/src/lib/constants/ultracollecte.ts +++ /dev/null @@ -1,53 +0,0 @@ -export const ultracollectePackage = [ - { - name: 'starter', - price: 50000, - supportType: 'standard', - features: { - clientsCount: 1000, - agentsCount: 5, - adminsCount: 2, - agenciesCount: 1, - agentLocalization: true, - enableCommision: true, - smsCount: 250, - supervisorsCount: 2, - cashiersCount: 2, - }, - }, - { - name: 'corporate', - price: 200000, - supportType: '24/7', - features: { - clientsCount: 10000, - agentsCount: 100, - adminsCount: 12, - agenciesCount: 10, - agentLocalization: true, - enableCommision: true, - smsCount: 2500, - supervisorsCount: 22, - cashiersCount: 25, - }, - }, - { - name: 'pro', - price: 100000, - supportType: '24/7', - features: { - clientsCount: 2500, - agentsCount: 20, - adminsCount: 5, - agenciesCount: 4, - agentLocalization: true, - enableCommision: true, - smsCount: 1000, - supervisorsCount: 10, - cashiersCount: 10, - }, - }, -]; - - - diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts index 8799788..7a6e9a2 100644 --- a/src/lib/types/index.ts +++ b/src/lib/types/index.ts @@ -1,10 +1,22 @@ -import { Package, } from "../../database/schema"; +import { SQL } from "drizzle-orm"; -export type SubscriberType = 'microfinance' | 'student' | 'company' | 'school'; -export type SupportType = 'standard' | '24/7' | 'premium'; -export type SubscriptionStatus = 'active' | 'expired' | 'canceled'; +export * from "./packages"; +export * from "./products"; +export type SubscriberType = 'student' | 'company' | 'personal'; +export type ISupportType = "standard" | "24/7" | "premium"; +export type SubscriptionStatus = "active" | "expired" | "canceled"; -export interface Subscriber { +/** + * Represents a single filter condition or a group of conditions combined by an operator. + * + * Examples: + * - A single condition: `eq(users.isActive, true)` + * - An 'AND' group: `{ and: [eq(users.age, 30), like(users.email, '%test%')] }` + * - An 'OR' group: `{ or: [eq(users.status, 'pending'), eq(users.status, 'rejected')] }` + */ +export type FilterCondition = SQL | { and: FilterCondition[] } | { or: FilterCondition[] }; + +export interface ISubscriber { type: SubscriberType; name: string; email: string; @@ -13,34 +25,19 @@ export interface Subscriber { country: string; } - -export interface PackageSnapshot { - name: string; - price: number; -} - -export interface Subscription { - id: string; - subscriber: Subscriber; - package: Package; - startDate: Date; - durationInMonths: number; - endDate: Date; - sentSmsCount: number; - status: SubscriptionStatus; - createdAt: Date; - updatedAt: Date; -} - -export interface CreateSubscriptionRequest { +export interface ICreateSubscriptionRequest { packageId: string; durationInMonths: number; - subscriber: Subscriber; + subscriber: ISubscriber; } -export interface ApiResponse { +export interface IApiResponse { success: boolean; data?: T; error?: string; + totalRecords?: number; + totalPages?: number; + currentPage?: number; + pageSize?: number; } diff --git a/src/lib/types/packages.ts b/src/lib/types/packages.ts new file mode 100644 index 0000000..5e3ab1b --- /dev/null +++ b/src/lib/types/packages.ts @@ -0,0 +1,66 @@ +import { z, ZodTypeAny } from "zod"; +import { Package } from "../../database/schema"; + +export interface IPackageSeed extends Omit {} + +export interface IUltracollecteFeatures { + clientsCount: number, + agentsCount: number, + adminsCount: number, + agenciesCount: number, + agentLocalization: boolean, + enableCommision: boolean, + smsCount: number, + supervisorsCount: number, + cashiersCount: number, +} + +export interface IFeatures { + ultracollecte: IUltracollecteFeatures, +} + +type ZodField = + T extends number ? z.ZodNumber : + T extends boolean ? z.ZodBoolean : + ZodTypeAny; + +type ZodSchemaFromInterface = { + [K in keyof T]: ZodField; +}; + + +function buildSchema(shape: ZodSchemaFromInterface) { + return z.object(shape); +} + +const productFeaturesShapes = { + ultracollecte: { + clientsCount: z.number(), + agentsCount: z.number(), + adminsCount: z.number(), + agenciesCount: z.number(), + agentLocalization: z.boolean(), + enableCommision: z.boolean(), + smsCount: z.number(), + supervisorsCount: z.number(), + cashiersCount: z.number(), + }, +} satisfies { + [K in keyof IFeatures]: ZodSchemaFromInterface; +}; + + +export const ProductFeaturesSchemas = { + ultracollecte: buildSchema(productFeaturesShapes.ultracollecte), +} satisfies { + [K in keyof IFeatures]: ReturnType>; +}; + +export const FeaturesSchema = z.object(ProductFeaturesSchemas); + +export type Features = z.infer; + + +export interface IUltracollecteSeedPackage extends Omit { + features: IUltracollecteFeatures +} diff --git a/src/lib/types/products.ts b/src/lib/types/products.ts new file mode 100644 index 0000000..d8e6e15 --- /dev/null +++ b/src/lib/types/products.ts @@ -0,0 +1,3 @@ +import { Package, Product } from '../../database/schema'; + +export interface IProductSeed extends Omit {} diff --git a/src/lib/types/ultracollectes.ts b/src/lib/types/ultracollectes.ts deleted file mode 100644 index 297624e..0000000 --- a/src/lib/types/ultracollectes.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const UltracollecteFeatures = [ - 'clientsCount', - 'agentsCount', - 'adminsCount', - 'agenciesCount', - 'agentLocalization', - 'enableCommision', - 'smsCount', - 'supervisorsCount', - 'cashiersCount', -]; \ No newline at end of file diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 12ce041..ea1a61a 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -1,4 +1,5 @@ import { type ClassValue, clsx } from 'clsx'; +import { ProductFeaturesSchemas } from '../types'; export function cn(...inputs: ClassValue[]) { return clsx(inputs); @@ -37,4 +38,11 @@ export function getDaysUntilExpiry(endDate: Date): number { const now = new Date(); const diffTime = endDate.getTime() - now.getTime(); return Math.ceil(diffTime / (1000 * 60 * 60 * 24)); -} \ No newline at end of file +} + +export function getProductFeaturesSchema< + T extends keyof typeof ProductFeaturesSchemas +>(productName: T) { + return ProductFeaturesSchemas[productName]; +} + diff --git a/src/server/openapi.ts b/src/server/openapi.ts new file mode 100644 index 0000000..09afaab --- /dev/null +++ b/src/server/openapi.ts @@ -0,0 +1,30 @@ +export const openApiSpec = { + openapi: '3.0.0', + info: { + title: 'My Hono API', + version: '1.0.0', + }, + paths: { + '/hello': { + get: { + summary: 'Hello world', + responses: { + 200: { + description: 'Success', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { type: 'string' } + } + } + } + } + } + } + } + } + } + } + \ No newline at end of file diff --git a/src/server/routes/packages.ts b/src/server/routes/packages.ts index fe47e52..93be845 100644 --- a/src/server/routes/packages.ts +++ b/src/server/routes/packages.ts @@ -1,145 +1,197 @@ -import { Hono } from 'hono'; -import { Package } from '../../database/schema'; -import type { ApiResponse } from '../../lib/types'; -import { PackageService } from '../services/packages'; +import { eq, gte, ilike, lte } from "drizzle-orm"; +import { Hono } from "hono"; +import { db } from "../../database/connexion"; +import { Package, packages } from "../../database/schema"; +import type { + FilterCondition, + IApiResponse, + ISupportType, +} from "../../lib/types"; +import { PackageService } from "../services/packages"; +import { getSeedingData } from "../utils/getSeedingdata"; +import { paginate } from "../utils/pagination.util"; const packageRoutes = new Hono(); // get packages -packageRoutes.get('/', async (c) => { +packageRoutes.get("/", async (c) => { + getSeedingData("packages"); try { - const packages = await PackageService.getPackages(); - const response: ApiResponse = { + const page = Number(c.req.query("page")) || 1; + const limit = Number(c.req.query("limit")) || 10; + const product = c.req.query("product"); + const supportType = c.req.query("supportType"); + const price = Number(c.req.query("price")); + const name = c.req.query("name"); + const startDate = c.req.query("startDate"); + const endDate = c.req.query("endDate"); + + let complexFilter: FilterCondition[] = [ + { + and: [...(product ? [eq(packages.product, product)] : [])], + }, + { + or: [ + ...(supportType + ? [eq(packages.supportType, supportType as ISupportType)] + : []), + ], + }, + { + or: [...(price ? [lte(packages.price, price)] : [])], + }, + { + or: [...(name ? [ilike(packages.name, `%${name}%`)] : [])], + }, + { + or: [...(startDate ? [gte(packages.createdAt, new Date(startDate))] : [])], + }, + { + or: [...(endDate ? [lte(packages.createdAt, new Date(endDate))] : [])], + }, + ]; + + const pkgs = await paginate({ + db, + table: packages, + page, + limit, + filters: complexFilter, + }); + const response: IApiResponse = { success: true, - data: packages + ...pkgs, + data: pkgs.data as any, }; return c.json(response); } catch (error) { - const response: ApiResponse = { + console.warn(error); + const response: IApiResponse = { success: false, - error: 'Failed to fetch packages' + data: error, + error: "Failed to fetch packages", }; return c.json(response, 500); } }); // get package by id -packageRoutes.get('/:id', async (c) => { +packageRoutes.get("/:id", async (c) => { try { - const id = c.req.param('id'); - + const id = c.req.param("id"); + const pkg = await PackageService.getPackageById(id); - + if (!pkg) { - const response: ApiResponse = { + const response: IApiResponse = { success: false, - error: 'Package not found' + error: "Package not found", }; return c.json(response, 404); } - - const response: ApiResponse = { + + const response: IApiResponse = { success: true, - data: pkg + data: pkg, }; return c.json(response); } catch (error) { - const response: ApiResponse = { + const response: IApiResponse = { success: false, - error: 'Failed to fetch package' + error: "Failed to fetch package", }; return c.json(response, 500); } }); // get package by name -packageRoutes.get('/name/:name', async (c) => { +packageRoutes.get("/name/:name", async (c) => { try { - const name = c.req.param('name'); - + const name = c.req.param("name"); + const pkg = await PackageService.getPackageByName(name); - + if (!pkg) { - const response: ApiResponse = { + const response: IApiResponse = { success: false, - error: 'Package not found' + error: "Package not found", }; return c.json(response, 404); } - - const response: ApiResponse = { + + const response: IApiResponse = { success: true, - data: pkg + data: pkg, }; return c.json(response); } catch (error) { - const response: ApiResponse = { + const response: IApiResponse = { success: false, - error: 'Failed to fetch package' + error: "Failed to fetch package", }; return c.json(response, 500); } }); // create package -packageRoutes.post('/', async (c) => { +packageRoutes.post("/", async (c) => { try { const packageData: Package = await c.req.json(); - + const newPackage = await PackageService.createPackage(packageData); - - const response: ApiResponse = { + + const response: IApiResponse = { success: true, - data: newPackage + data: newPackage, }; return c.json(response, 201); } catch (error) { - const response: ApiResponse = { + const response: IApiResponse = { success: false, - error: 'Failed to create package' + error: "Failed to create package", }; return c.json(response, 500); } }); // update package -packageRoutes.put('/:id', async (c) => { +packageRoutes.put("/:id", async (c) => { try { - const id = c.req.param('id'); + const id = c.req.param("id"); const packageData: Package = await c.req.json(); - + const updatedPackage = await PackageService.updatePackage(id, packageData); - - const response: ApiResponse = { + + const response: IApiResponse = { success: true, - data: updatedPackage + data: updatedPackage, }; return c.json(response); } catch (error) { - const response: ApiResponse = { + const response: IApiResponse = { success: false, - error: 'Failed to update package' + error: "Failed to update package", }; return c.json(response, 500); } }); // delete package -packageRoutes.delete('/:id', async (c) => { +packageRoutes.delete("/:id", async (c) => { try { - const id = c.req.param('id'); - + const id = c.req.param("id"); + const deletedPackage = await PackageService.deletePackage(id); - - const response: ApiResponse = { + + const response: IApiResponse = { success: true, - data: deletedPackage + data: deletedPackage, }; return c.json(response); } catch (error) { - const response: ApiResponse = { + const response: IApiResponse = { success: false, - error: 'Failed to delete package' + error: "Failed to delete package", }; return c.json(response, 500); } diff --git a/src/server/routes/products.ts b/src/server/routes/products.ts index 6dd6735..2da750b 100644 --- a/src/server/routes/products.ts +++ b/src/server/routes/products.ts @@ -1,21 +1,32 @@ import { Hono } from 'hono'; -import { Product } from '../../database/schema/schema/products'; -import type { ApiResponse } from '../../lib/types'; -import { ProductService } from '../services/prducts'; +import { db } from '../../database/connexion'; +import { Product, products } from '../../database/schema/schema/products'; +import type { IApiResponse } from '../../lib/types'; +import { ProductService } from '../services/products'; +import { paginate } from '../utils/pagination.util'; const productRoutes = new Hono(); // get products productRoutes.get('/', async (c) => { try { - const pts = await ProductService.getProducts(); - const response: ApiResponse = { + const page = Number(c.req.query('page')) || 1 + const limit = Number(c.req.query('limit')) || 10 + + const pts = await paginate({ + db, + table: products, + limit, + page + }); + const response: IApiResponse = { success: true, - data: pts + ...pts, + data: pts.data as any }; return c.json(response); } catch (error) { - const response: ApiResponse = { + const response: IApiResponse = { success: false, error: 'Failed to fetch products' }; @@ -28,13 +39,13 @@ productRoutes.get('/:id', async (c) => { try { const id = c.req.param('id'); const pt = await ProductService.getProductById(id); - const response: ApiResponse = { + const response: IApiResponse = { success: true, data: pt }; return c.json(response); } catch (error) { - const response: ApiResponse = { + const response: IApiResponse = { success: false, error: 'Failed to fetch product' }; @@ -47,13 +58,13 @@ productRoutes.get('/name/:name', async (c) => { try { const name = c.req.param('name'); const pt = await ProductService.getProductByName(name); - const response: ApiResponse = { + const response: IApiResponse = { success: true, data: pt }; return c.json(response); } catch (error) { - const response: ApiResponse = { + const response: IApiResponse = { success: false, error: 'Failed to fetch product' }; @@ -66,13 +77,13 @@ productRoutes.post('/', async (c) => { try { const ptData: Product = await c.req.json(); const newPt = await ProductService.createProduct(ptData); - const response: ApiResponse = { + const response: IApiResponse = { success: true, data: newPt }; return c.json(response, 201); } catch (error) { - const response: ApiResponse = { + const response: IApiResponse = { success: false, error: 'Failed to create product' }; @@ -86,13 +97,13 @@ productRoutes.put('/:id', async (c) => { const id = c.req.param('id'); const ptData: Product = await c.req.json(); const updatedPt = await ProductService.updateProduct(id, ptData); - const response: ApiResponse = { + const response: IApiResponse = { success: true, data: updatedPt }; return c.json(response); } catch (error) { - const response: ApiResponse = { + const response: IApiResponse = { success: false, error: 'Failed to update product' }; @@ -105,13 +116,13 @@ productRoutes.delete('/:id', async (c) => { try { const id = c.req.param('id'); const deletedPt = await ProductService.deleteProduct(id); - const response: ApiResponse = { + const response: IApiResponse = { success: true, data: deletedPt }; return c.json(response); } catch (error) { - const response: ApiResponse = { + const response: IApiResponse = { success: false, error: 'Failed to delete product' }; diff --git a/src/server/routes/subscription-client.ts b/src/server/routes/subscription-client.ts new file mode 100644 index 0000000..bd76c83 --- /dev/null +++ b/src/server/routes/subscription-client.ts @@ -0,0 +1,78 @@ +import { Hono } from "hono"; +import { Subscription } from "../../database/schema"; +import { IApiResponse, ProductFeaturesSchemas } from "../../lib/types"; +import { getProductFeaturesSchema } from "../../lib/utils"; +import { SubscriptionService } from "../services/subscriptions"; + +const subscriptionClientRoutes = new Hono(); + +// Get subscription client by protect key +subscriptionClientRoutes.get("/:protectKey", async (c) => { + try { + const protectKey = c.req.param("protectKey"); + const subscription = await SubscriptionService.getSubscriptionByProtectKey( + protectKey as string + ); + const response: IApiResponse = { + success: true, + data: subscription, + }; + return c.json(response); + } catch (error) { + const response: IApiResponse = { + success: false, + error: "Failed to fetch subscription", + }; + return c.json(response, 500); + } +}); + +subscriptionClientRoutes.put("/:protectKey", async (c) => { + try { + const protectKey = c.req.param("protectKey"); + const subscriptionData = await c.req.json(); + const subscriptionObj = + await SubscriptionService.getSubscriptionByProtectKey( + protectKey as string + ); + + if (!subscriptionObj) { + const response: IApiResponse = { + success: false, + error: "Subscription not found", + }; + return c.json(response, 404); + } + + // Validate subscription features type + const requiredFeatureType = getProductFeaturesSchema(subscriptionObj.product as keyof typeof ProductFeaturesSchemas); + if (!requiredFeatureType.safeParse(subscriptionData).success) { + const response: IApiResponse = { + success: false, + error: "Invalid subscription features type", + }; + return c.json(response, 422); + } + + const subscription = await SubscriptionService.updateSubscription( + { + ...subscriptionObj, + usedFeatures: subscriptionData, + updatedAt: new Date(), + } + ); + const response: IApiResponse = { + success: true, + data: subscription, + }; + return c.json(response); + } catch (error) { + const response: IApiResponse = { + success: false, + error: "Failed to fetch subscriptions", + }; + return c.json(response, 500); + } +}); + +export default subscriptionClientRoutes; diff --git a/src/server/routes/subscriptions.ts b/src/server/routes/subscriptions.ts index 7395d39..068579a 100644 --- a/src/server/routes/subscriptions.ts +++ b/src/server/routes/subscriptions.ts @@ -1,184 +1,294 @@ -import { eq } from 'drizzle-orm'; -import { Hono } from 'hono'; -import { db } from '../../database/connexion'; -import { packages } from '../../database/schema'; -import type { ApiResponse, CreateSubscriptionRequest, Subscription } from '../../lib/types'; -import { SubscriptionService } from '../../server/services/subscriptions'; +import { eq, gte, ilike, lte, sql } from "drizzle-orm"; +import { Hono } from "hono"; +import { db } from "../../database/connexion"; +import { packages, Subscription, subscriptions } from "../../database/schema"; +import type { + FilterCondition, + IApiResponse, + ICreateSubscriptionRequest, + ISupportType, +} from "../../lib/types"; +import { SubscriptionService } from "../../server/services/subscriptions"; +import { paginate } from "../utils/pagination.util"; const subscriptionRoutes = new Hono(); // Create subscription -subscriptionRoutes.post('/', async (c) => { +subscriptionRoutes.post("/", async (c) => { try { - const subscriptionData: CreateSubscriptionRequest = await c.req.json(); - + const subscriptionData: ICreateSubscriptionRequest = await c.req.json(); + // Get package details - const [pkg] = await db.select().from(packages).where(eq(packages.id, subscriptionData.packageId)); - + const [pkg] = await db + .select() + .from(packages) + .where(eq(packages.id, subscriptionData.packageId)); + if (!pkg) { - const response: ApiResponse = { + const response: IApiResponse = { success: false, - error: 'Package not found' + error: "Package not found", }; return c.json(response, 404); } - const subscription = await SubscriptionService.createSubscription(subscriptionData); + const subscription = + await SubscriptionService.createSubscription(subscriptionData); - const response: ApiResponse = { + const response: IApiResponse = { success: true, - data: subscription + data: subscription, }; return c.json(response, 201); } catch (error) { - console.error('Create subscription error:', error); - const response: ApiResponse = { + console.error("Create subscription error:", error); + const response: IApiResponse = { success: false, - error: 'Failed to create subscription' + error: "Failed to create subscription", }; return c.json(response, 500); } }); // Get subscription by protect key -subscriptionRoutes.get('/status/:protectKey', async (c) => { +subscriptionRoutes.get("/status/:protectKey", async (c) => { try { - const protectKey = c.req.param('protectKey'); - - const subscription = await SubscriptionService.getSubscriptionByProtectKey(protectKey); + const protectKey = c.req.param("protectKey"); + const subscription = + await SubscriptionService.getSubscriptionByProtectKey(protectKey); if (!subscription) { - const response: ApiResponse = { + const response: IApiResponse = { success: false, - error: 'Subscription not found' + error: "Subscription not found", }; return c.json(response, 404); } - const response: ApiResponse = { + const response: IApiResponse = { success: true, - data: subscription + data: subscription, }; return c.json(response); } catch (error) { - const response: ApiResponse = { + const response: IApiResponse = { success: false, - error: 'Failed to fetch subscription' + error: "Failed to fetch subscription", }; return c.json(response, 500); } }); // get subscriptions -subscriptionRoutes.get('/', async (c) => { +subscriptionRoutes.get("/", async (c) => { try { - const subscriptions = await SubscriptionService.getSubscriptions(); - const response: ApiResponse = { + const page = Number(c.req.query("page")) || 1; + const limit = Number(c.req.query("limit")) || 10; + + // Parameters that likely filter on the 'packages' table + const product = c.req.query("product"); + const supportType = c.req.query("supportType"); + const price = Number(c.req.query("price")); + const packageName = c.req.query("name"); + + // Parameters that filter on the 'subscriptions' table + const status = c.req.query("status"); + const protectKey = c.req.query("protectKey"); + const subscriptionStartDate = c.req.query("subscriptionStartDate"); + const subscriptionEndDate = c.req.query("subscriptionEndDate"); + const createdAtStart = c.req.query("createdAtStart"); + const createdAtEnd = c.req.query("createdAtEnd"); + + // New parameters for filtering on ISubscriber fields within the 'subscriber' JSONB column + const subscriberType = c.req.query("subscriberType"); + const subscriberName = c.req.query("subscriberName"); + const subscriberEmail = c.req.query("subscriberEmail"); + const subscriberPhone = c.req.query("subscriberPhone"); + const subscriberAddress = c.req.query("subscriberAddress"); + const subscriberCountry = c.req.query("subscriberCountry"); + + const allConditions: FilterCondition[] = []; + + // 1. Conditions related to the 'packages' table (assuming a join or relationship) + if (product) { + allConditions.push(eq(packages.product, product)); + } + if (supportType) { + allConditions.push(eq(packages.supportType, supportType as ISupportType)); + } + if (price !== undefined && !isNaN(price)) { + allConditions.push(lte(packages.price, price)); + } + if (packageName) { + allConditions.push(ilike(packages.name, `%${packageName}%`)); + } + + // 2. Conditions directly on the 'subscriptions' table + if (status) { + allConditions.push( + eq(subscriptions.status, status as Subscription["status"]) + ); + } + if (protectKey) { + allConditions.push(eq(subscriptions.protectKey, protectKey)); + } + if (subscriptionStartDate) { + allConditions.push( + gte(subscriptions.startDate, new Date(subscriptionStartDate)) + ); + } + if (subscriptionEndDate) { + allConditions.push( + lte(subscriptions.endDate, new Date(subscriptionEndDate)) + ); + } + if (createdAtStart) { + allConditions.push( + gte(subscriptions.createdAt, new Date(createdAtStart)) + ); + } + if (createdAtEnd) { + allConditions.push(lte(subscriptions.createdAt, new Date(createdAtEnd))); + } + + // 3. Conditions related to 'subscriber' (this part requires a join or specific schema setup) + if (subscriberType) { + allConditions.push(sql`${subscriptions.subscriber} ->> 'type' = ${subscriberType}`); + } + if (subscriberName) { + allConditions.push(sql`${subscriptions.subscriber} ->> 'name' ILIKE ${`%${subscriberName}%`}`); + } + if (subscriberEmail) { + allConditions.push(sql`${subscriptions.subscriber} ->> 'email' ILIKE ${`%${subscriberEmail}%`}`); + } + if (subscriberPhone) { + allConditions.push(sql`${subscriptions.subscriber} ->> 'phone' ILIKE ${`%${subscriberPhone}%`}`); + } + if (subscriberAddress) { + allConditions.push(sql`${subscriptions.subscriber} ->> 'address' ILIKE ${`%${subscriberAddress}%`}`); + } + if (subscriberCountry) { + allConditions.push(sql`${subscriptions.subscriber} ->> 'country' ILIKE ${`%${subscriberCountry}%`}`); + } + + // Combine all collected conditions. + // By default, we'll AND all top-level conditions. + const finalFilter: FilterCondition[] = + allConditions.length > 0 ? [{ and: allConditions }] : []; + + const subs = await paginate({ + db, + table: subscriptions, + limit, + page, + filters: finalFilter, + }); + const response: IApiResponse = { success: true, - data: subscriptions + ...subs, + data: subs.data as any, }; return c.json(response); } catch (error) { - const response: ApiResponse = { + const response: IApiResponse = { success: false, - error: 'Failed to fetch subscriptions' + error: "Failed to fetch subscriptions", }; return c.json(response, 500); } }); // get expired subscriptions -subscriptionRoutes.get('/expired', async (c) => { +subscriptionRoutes.get("/expired", async (c) => { try { const subscriptions = await SubscriptionService.getExpiredSubscriptions({ limit: 10, page: 1, }); - const response: ApiResponse[]> = { + const response: IApiResponse[]> = { success: true, - data: subscriptions + data: subscriptions, }; return c.json(response); } catch (error) { - const response: ApiResponse = { + const response: IApiResponse = { success: false, - error: 'Failed to fetch expired subscriptions' + error: "Failed to fetch expired subscriptions", }; return c.json(response, 500); } }); // increment sms count -subscriptionRoutes.post('/increment-sms-count/:protectKey', async (c) => { +subscriptionRoutes.post("/increment-sms-count/:protectKey", async (c) => { try { - const protectKey = c.req.param('protectKey'); - + const protectKey = c.req.param("protectKey"); + await SubscriptionService.incrementSmsCount(protectKey); - - const response: ApiResponse = { + + const response: IApiResponse = { success: true, }; return c.json(response); } catch (error) { - const response: ApiResponse = { + const response: IApiResponse = { success: false, - error: 'Failed to increment SMS count' + error: "Failed to increment SMS count", }; return c.json(response, 500); } }); - // update subscription status -subscriptionRoutes.patch('/status/:id', async (c) => { +subscriptionRoutes.patch("/status/:id", async (c) => { try { - const id = c.req.param('id'); - const status = (await c.req.json()).status; - - const updatedSubscription = await SubscriptionService.updateSubscriptionStatus(id, status); - - const response: ApiResponse = { + const id = c.req.param("id"); + const status = (await c.req.json()).status; + + const updatedSubscription = + await SubscriptionService.updateSubscriptionStatus(id, status); + + const response: IApiResponse = { success: true, - data: updatedSubscription + data: updatedSubscription, }; return c.json(response); } catch (error) { - const response: ApiResponse = { + const response: IApiResponse = { success: false, - error: 'Failed to update subscription status' + error: "Failed to update subscription status", }; return c.json(response, 500); } }); - // get by id -subscriptionRoutes.get('/:id', async (c) => { +subscriptionRoutes.get("/:id", async (c) => { try { - const id = c.req.param('id'); - + const id = c.req.param("id"); + const subscription = await SubscriptionService.getSubscriptionById(id); - + if (!subscription) { - const response: ApiResponse = { + const response: IApiResponse = { success: false, - error: 'Subscription not found' + error: "Subscription not found", }; return c.json(response, 404); } - - const response: ApiResponse = { + + const response: IApiResponse = { success: true, - data: subscription + data: subscription, }; return c.json(response); } catch (error) { - const response: ApiResponse = { + const response: IApiResponse = { success: false, - error: 'Failed to fetch subscription' + error: "Failed to fetch subscription", }; return c.json(response, 500); } }); -export default subscriptionRoutes; \ No newline at end of file +export default subscriptionRoutes; diff --git a/src/server/seeds/index.ts b/src/server/seeds/index.ts index 1bff058..bfe40e2 100644 --- a/src/server/seeds/index.ts +++ b/src/server/seeds/index.ts @@ -1,8 +1,11 @@ import { packagesSeed } from "./packages"; +import { productSeed } from "./products"; async function seed() { console.log('🌱 Seeding database...'); + await productSeed() await packagesSeed(); + console.log('✅ Database seeded successfully!'); } seed(); \ No newline at end of file diff --git a/src/server/seeds/packages.ts b/src/server/seeds/packages.ts index 8639578..257d315 100644 --- a/src/server/seeds/packages.ts +++ b/src/server/seeds/packages.ts @@ -1,68 +1,37 @@ +import { count } from 'drizzle-orm'; import { db } from '../../database/connexion'; -import { packages } from '../../database/schema'; +import { packages, products } from '../../database/schema'; +import { IPackageSeed } from '../../lib/types'; +import { getSeedingData } from '../utils/getSeedingdata'; export async function packagesSeed() { - console.log('🌱 Seeding database...'); - try { - // const allPackages = await db.select().from(packages); - // if (allPackages.length > 0) { - // console.log('Database already seeded!'); - // return; - // } + const productNames = await db.select({ name: products.name, id: products.id }).from(products); + if (productNames.length === 0) { + console.error('❌ Error: Products not found'); + process.exit(1); + } + const totalPackageCount = (await db.select({ count: count() }).from(packages))[0].count; + if (totalPackageCount > 0) { + console.log('Packages already seeded!'); + return; + } // Clear existing data await db.delete(packages); + // get sample // Insert sample packages - const samplePackages = [ - { - "name": "Starter", - "price": 50000, - "clientsCount": 1000, - "agentsCount": 5, - "adminsCount": 2, - "agenciesCount": 1, - "agentLocalization": true, - "enableCommision": true, - "smsCount": 250, - "supervisorsCount": 2, - "cashiersCount": 2, - "supportType": "standard", - }, - { - "name": "Corporate", - "price": 200000, - "clientsCount": 10000, - "agentsCount": 100, - "adminsCount": 12, - "agenciesCount": 10, - "agentLocalization": true, - "enableCommision": true, - "smsCount": 2500, - "supervisorsCount": 22, - "cashiersCount": 25, - "supportType": "24/7", - }, - { - "name": "Pro", - "price": 100000, - "clientsCount": 2500, - "agentsCount": 20, - "adminsCount": 5, - "agenciesCount": 4, - "agentLocalization": true, - "enableCommision": true, - "smsCount": 1000, - "supervisorsCount": 10, - "cashiersCount": 10, - "supportType": "24/7", - } - ]; + const addPropertyPerProduct = productNames.map((item) => ({ + productName: item.name, + property: 'product', + value: item.id + })) + const samplePackages = getSeedingData('packages', addPropertyPerProduct) - // await db.insert(packages).values(samplePackages); + await db.insert(packages).values(samplePackages as IPackageSeed[]); - console.log('✅ Database seeded successfully!'); + console.log('Packages seeded successfully! ✅'); } catch (error) { console.error('❌ Error seeding database:', error); process.exit(1); diff --git a/src/server/seeds/products.ts b/src/server/seeds/products.ts new file mode 100644 index 0000000..965aedf --- /dev/null +++ b/src/server/seeds/products.ts @@ -0,0 +1,21 @@ +import { db } from '../../database/connexion'; +import { packages, products } from '../../database/schema'; +import { IProductSeed } from '../../lib/types'; +import { getSeedingData } from '../utils/getSeedingdata'; + +export async function productSeed() { + try { + // Clear existing data + await db.delete(packages); + await db.delete(products); + + const sampleData = getSeedingData('products') + + await db.insert(products).values(sampleData as IProductSeed[]); + + console.log('Products seeded successfully! ✅'); + } catch (error) { + console.error('❌ Error seeding database:', error); + process.exit(1); + } +} diff --git a/src/server/services/prducts.ts b/src/server/services/products.ts similarity index 100% rename from src/server/services/prducts.ts rename to src/server/services/products.ts diff --git a/src/server/services/subscriptions.ts b/src/server/services/subscriptions.ts index f27b734..2c0bcb8 100644 --- a/src/server/services/subscriptions.ts +++ b/src/server/services/subscriptions.ts @@ -1,29 +1,41 @@ -import { eq, lt, sql } from 'drizzle-orm'; -import { db } from '../../database/connexion'; -import { subscriptions } from '../../database/schema'; -import type { CreateSubscriptionRequest, Subscription } from '../../lib/types'; -import { addMonths, generateId } from '../../lib/utils'; +import { eq, lt, sql } from "drizzle-orm"; +import { db } from "../../database/connexion"; +import { products, Subscription, subscriptions } from "../../database/schema"; +import { addMonths, generateId } from "../../lib/utils"; export class SubscriptionService { - static async createSubscription(data: CreateSubscriptionRequest) { - const now = new Date(); - const newSub = await db.insert(subscriptions).values({ - protectKey: 'USSK_' + generateId(6), - subscriber: data.subscriber, - package: data.packageId, - startDate: now, - durationInMonths: data.durationInMonths, - endDate: addMonths(now, data.durationInMonths), - status: 'active', - createdAt: now, - updatedAt: now, - }).returning(); + static async createSubscription(data: Omit) { + const now = new Date(); + const newSub = await db + .insert(subscriptions) + .values({ + ...data, + protectKey: "USSK_" + generateId(6), + startDate: data.startDate || now, + endDate: addMonths(data.startDate || now, data.durationInMonths), + createdAt: now, + updatedAt: now, + status: data.status || "active", + }) + .returning(); + + return newSub[0]; + } + + static async updateSubscription(data: Subscription) { + const now = new Date(); + const newSub = await db + .update(subscriptions) + .set({ ...data, updatedAt: now }) + .where(eq(subscriptions.protectKey, data.protectKey)) + .returning(); return newSub[0]; } static async getSubscriptions(limit = 10, page = 1): Promise { - const data= await db.select() + const data = await db + .select() .from(subscriptions) .limit(limit) .offset((page - 1) * limit); @@ -33,12 +45,13 @@ export class SubscriptionService { static async updateSubscriptionStatus( subscriptionId: string, - status: 'active' | 'expired' | 'canceled' + status: "active" | "expired" | "canceled" ): Promise { - const [updatedSubscription] = await db.update(subscriptions) + const [updatedSubscription] = await db + .update(subscriptions) .set({ status, - updatedAt: new Date() + updatedAt: new Date(), }) .where(eq(subscriptions.id, subscriptionId)) .returning(); @@ -46,18 +59,42 @@ export class SubscriptionService { return updatedSubscription || null; } - static async getSubscriptionByProtectKey(protectKey: string): Promise { - const [subscription] = await db.select() + static async getSubscriptionByProtectKey( + protectKey: string + ): Promise { + try { + const [subscription] = await db + .select() .from(subscriptions) .where(eq(subscriptions.protectKey, protectKey)) .limit(1); - return subscription || null; + if (!subscription) { + throw new Error("Subscription not found"); + } + + // get the product + const product = await db.select({ + name: products.name, + }).from(products).where(eq(products.id, subscription.product)).limit(1); + + if (!product) { + throw new Error("Product not found"); + } + + subscription.product = product[0].name; + return subscription; + } catch (error) { + console.error("Get subscription by protect key error:", error); + throw error; + } } static async getSubscriptionById(id: string): Promise { - const [subscription] = await db.select() + const [subscription] = await db + .select() .from(subscriptions) + .$dynamic() .where(eq(subscriptions.id, id)) .limit(1); @@ -69,8 +106,9 @@ export class SubscriptionService { page = 1, }): Promise[]> { const now = new Date(); - - const expiredSubscriptions = await db.select({ + + const expiredSubscriptions = await db + .select({ id: subscriptions.id, protectKey: subscriptions.protectKey, subscriber: subscriptions.subscriber, @@ -79,7 +117,7 @@ export class SubscriptionService { endDate: subscriptions.endDate, sentSmsCount: subscriptions.sentSmsCount, status: subscriptions.status, - }) + }) .from(subscriptions) .where(lt(subscriptions.endDate, now)) .limit(limit) @@ -89,11 +127,12 @@ export class SubscriptionService { } static async incrementSmsCount(protectKey: string): Promise { - await db.update(subscriptions) + await db + .update(subscriptions) .set({ sentSmsCount: sql`${subscriptions.sentSmsCount} + 1`, - updatedAt: new Date() + updatedAt: new Date(), }) .where(eq(subscriptions.protectKey, protectKey)); } -} \ No newline at end of file +} diff --git a/src/server/utils/getSeedingdata.ts b/src/server/utils/getSeedingdata.ts new file mode 100644 index 0000000..dbcff41 --- /dev/null +++ b/src/server/utils/getSeedingdata.ts @@ -0,0 +1,36 @@ +import { ISeedingData, seedingData } from "../../lib/constants"; +import { IPackageSeed, IProductSeed } from "../../lib/types"; + +type ISeedDataType = IProductSeed | IPackageSeed; + +export function getSeedingData(slug: keyof ISeedingData, addPropertyPerProduct?: { + productName: string, + property: string, + value: any +}[]) { + const seedingJson = seedingData[slug] + type ISeedingJson = typeof seedingJson + let x: ISeedDataType[] = [] + for (const key in seedingJson){ + if (!addPropertyPerProduct) { + const value = seedingJson[key as keyof ISeedingJson] + x = [...x, ...value as ISeedDataType[]] + } else { + const selected = addPropertyPerProduct.find((item) => item.productName === key) + if (selected) { + const value = seedingJson[key as keyof ISeedingJson] + const newValue = value.map((item) => { + return { + ...item, + [selected.property]: selected.value + } + }) + x = [...x, ...newValue as ISeedDataType[]] + } else { + const value = seedingJson[key as keyof ISeedingJson] + x = [...x, ...value as ISeedDataType[]] + } + } + } + return x as ISeedDataType[] +} \ No newline at end of file diff --git a/src/server/utils/pagination.util.ts b/src/server/utils/pagination.util.ts new file mode 100644 index 0000000..664dd5f --- /dev/null +++ b/src/server/utils/pagination.util.ts @@ -0,0 +1,193 @@ +// import { count, SQL } from "drizzle-orm"; +// import { PgDatabase, PgTable, PgTableWithColumns } from "drizzle-orm/pg-core"; +// // import { AnyTable } from 'drizzle-orm'; + +// // Adjust if you use SQLite or another database +// type DrizzleDB = PgDatabase; + +// /** +// * Generic pagination utility for Drizzle ORM. +// * +// * @template T - The Drizzle ORM schema table type. +// * @param {DrizzleDB} db - The Drizzle ORM database instance. +// * @param {T} table - The Drizzle ORM schema table to paginate. +// * @param {number} page - The current page number (1-indexed). +// * @param {number} limit - The number of items per page. +// * @param {SQL | undefined} whereClause - Optional Drizzle SQL condition for filtering. +// * @returns {Promise<{ data: T[], totalRecords: number, totalPages: number, currentPage: number, pageSize: number }>} +// */ +// export async function paginate({ // Adjusted for PgTable +// db, +// table, +// page = 1, +// limit = 10, +// whereClause, +// }: { +// db: DrizzleDB; +// table: PgTableWithColumns; +// page: number; +// limit: number; +// whereClause?: SQL | undefined; // Add optional where clause for filtering +// }): Promise<{ +// data: (typeof table.$inferSelect)[]; // Use $inferSelect for proper type inference +// totalRecords: number; +// totalPages: number; +// currentPage: number; +// pageSize: number; +// }> { +// if (page < 1) page = 1; +// if (limit < 1) limit = 10; + +// const offset = (page - 1) * limit; + +// // Build the query +// let query = db.select().from(table).$dynamic(); // Use $dynamic to conditionally add clauses + +// if (whereClause) { +// query = query.where(whereClause); +// } + +// const dataPromise = query.limit(limit).offset(offset).execute(); + +// // Get the total count, potentially with the same where clause +// let countQuery = db +// .select({ +// count: count(), +// }) +// .from(table) +// .$dynamic(); + +// if (whereClause) { +// countQuery = countQuery.where(whereClause); +// } + +// const countPromise = countQuery.execute(); + +// const [data, [countResult]] = await Promise.all([dataPromise, countPromise]); + +// const totalRecords = countResult?.count || 0; +// const totalPages = Math.ceil(totalRecords / limit); + +// return { +// data: data, +// totalRecords, +// totalPages, +// currentPage: page, +// pageSize: limit, +// }; +// } + + + +import { and, count, SQL } from "drizzle-orm"; +import { PgDatabase, PgTable, PgTableWithColumns } from "drizzle-orm/pg-core"; +import { FilterCondition } from "../../lib/types"; + +type DrizzleDB = PgDatabase; + +/** + * Generic pagination utility for Drizzle ORM. + * + * @template T - The Drizzle ORM schema table type. + * @param {DrizzleDB} db - The Drizzle ORM database instance. + * @param {T} table - The Drizzle ORM schema table to paginate. + * @param {number} page - The current page number (1-indexed). + * @param {number} limit - The number of items per page. + * @param {FilterCondition[] | undefined} filters - An array of filter conditions, which can be single SQL expressions or nested 'and'/'or' groups. + * @returns {Promise<{ data: T[], totalRecords: number, totalPages: number, currentPage: number, pageSize: number }>} + */ +export async function paginate({ + db, + table, + page = 1, + limit = 10, + filters, // Now using FilterCondition[] +}: { + db: DrizzleDB; + table: PgTableWithColumns; + page?: number; + limit?: number; + filters?: FilterCondition[]; // Accepts the new structured filters +}): Promise<{ + data: (typeof table.$inferSelect)[]; + totalRecords: number; + totalPages: number; + currentPage: number; + pageSize: number; +}> { + if (page < 1) page = 1; + if (limit < 1) limit = 10; + + const offset = (page - 1) * limit; + + // Helper function to process the nested filter conditions + const buildWhereClause = (conditions?: FilterCondition[]): SQL | undefined => { + if (!conditions || conditions.length === 0) { + return undefined; + } + + const builtConditions: SQL[] = []; + for (const condition of conditions) { + if (condition instanceof SQL) { + builtConditions.push(condition); + } else if ('and' in condition && Array.isArray(condition.and)) { + const nestedAnd = buildWhereClause(condition.and); + if (nestedAnd) { + builtConditions.push(nestedAnd); + } + } else if ('or' in condition && Array.isArray(condition.or)) { + const nestedOr = buildWhereClause(condition.or); + if (nestedOr) { + builtConditions.push(nestedOr); + } + } + } + + // By default, if multiple top-level conditions are provided without an explicit operator, + // we'll combine them with 'AND'. If you want a different default, adjust here. + if (builtConditions.length === 1) { + return builtConditions[0]; + } else if (builtConditions.length > 1) { + return and(...builtConditions); + } + return undefined; + }; + + const finalWhereClause = buildWhereClause(filters); + + // Build the query + let query = db.select().from(table).$dynamic(); + + if (finalWhereClause) { + query = query.where(finalWhereClause); + } + + const dataPromise = query.limit(limit).offset(offset).execute(); + + // Get the total count, potentially with the same where clause + let countQuery = db + .select({ + count: count(), + }) + .from(table) + .$dynamic(); + + if (finalWhereClause) { + countQuery = countQuery.where(finalWhereClause); + } + + const countPromise = countQuery.execute(); + + const [data, [countResult]] = await Promise.all([dataPromise, countPromise]); + + const totalRecords = countResult?.count || 0; + const totalPages = Math.ceil(totalRecords / limit); + + return { + data: data.reverse(), + totalRecords, + totalPages, + currentPage: page, + pageSize: limit, + }; +} \ No newline at end of file diff --git a/src/web/App.tsx b/src/web/App.tsx deleted file mode 100644 index 5258a03..0000000 --- a/src/web/App.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { SessionProvider, useSession } from '@hono/auth-js/dist/react' - -export default function App() { - return ( - - - - ) -} - -function Children() { - const { data: session, status } = useSession() - return
I am {session?.user}
-} \ No newline at end of file diff --git a/src/web/components/layout/Footer.tsx b/src/web/components/layout/Footer.tsx new file mode 100644 index 0000000..88e2daa --- /dev/null +++ b/src/web/components/layout/Footer.tsx @@ -0,0 +1,67 @@ + +export function Footer() { + return ( + + ); +} \ No newline at end of file diff --git a/src/web/components/layout/Header.tsx b/src/web/components/layout/Header.tsx new file mode 100644 index 0000000..bb2550e --- /dev/null +++ b/src/web/components/layout/Header.tsx @@ -0,0 +1,79 @@ +import { LogOut, User } from "lucide-react"; +interface HeaderProps { + user?: { email: string; name: string } | null; + onLogout?: () => void; +} + +export function Header({ user, onLogout }: HeaderProps) { + return ( +
+
+
+
+ {/* */} + + SubscriptionHub + +
+ + + +
+ {user ? ( +
+
+ + {user.email} + {user.name && ( + + {user.name} + + )} +
+ + + Logout + + {/* */} +
+ ) : ( + + )} +
+
+
+
+ ); +} diff --git a/src/web/components/packages/Packagecard.tsx b/src/web/components/packages/Packagecard.tsx new file mode 100644 index 0000000..08810e0 --- /dev/null +++ b/src/web/components/packages/Packagecard.tsx @@ -0,0 +1,109 @@ +import { Package } from '../../../database/schema'; +import { formatCurrency } from '../../../lib/utils'; + +interface PackageCardProps { + package: Package; + onSelect?: (pkg: Package) => void; + isPopular?: boolean; +} + +export function PackageCard({ package: pkg, onSelect, isPopular }: PackageCardProps) { + const getSupportIcon = (type: string) => { + switch (type) { + case 'basic': return '📧'; + case 'premium': return '💬'; + case 'enterprise': return '📞'; + default: return '📧'; + } + }; + + return ( +
+ {isPopular && ( +
+ + Most Popular + +
+ )} + +
+
+

{pkg.name}

+
+ + {formatCurrency(pkg.price / 100)} + + /month +
+
+ + {/*
+
+ + + {pkg.clientsCount.toLocaleString()} Clients + +
+ +
+ + + {pkg.agentsCount} Agents • {pkg.adminsCount} Admins + +
+ +
+ + + {pkg.smsCount.toLocaleString()} SMS/month + +
+ +
+ + + {getSupportIcon(pkg.supportType)} {pkg.supportType.charAt(0).toUpperCase() + pkg.supportType.slice(1)} Support + +
+
+ +
+ {pkg.agentLocalization && ( +
+ + Agent Localization +
+ )} + + {pkg.enableCommission && ( +
+ + Commission System +
+ )} + +
+ + + {pkg.supervisorsCount} Supervisors • {pkg.cashiersCount} Cashiers + +
+
*/} + + +
+
+ ); +} \ No newline at end of file diff --git a/src/web/pages/DashboardPage.tsx b/src/web/pages/DashboardPage.tsx new file mode 100644 index 0000000..0a943a5 --- /dev/null +++ b/src/web/pages/DashboardPage.tsx @@ -0,0 +1,302 @@ +import { AlertCircle } from "lucide-react"; +import { useEffect, useState } from "react"; +import { Subscription } from "../../database/schema"; +import { formatDate } from "../../lib/utils"; +import { SubscriptionService } from "../../server/services/subscriptions"; +import { Header } from "../components/layout/Header"; + +export default function DashboardPage({ + user, +}: { + user?: { email: string; name: string } | null; +}) { + const [subscriptions, setSubscriptions] = useState([]); + const [loading, setLoading] = useState(true); + + const getSubscriptions = async () => { + try { + console.log("Fetching subscriptions..."); + const response = await SubscriptionService.getSubscriptions(0, 1); + console.log({ response }); + if (response) { + setSubscriptions(response); + } + } catch (error) { + console.error("Failed to fetch subscriptions:", error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + getSubscriptions(); + }, []); + + if (loading) { + return ( +
+
+
+
+

Dashboard

+

+ Monitor your subscription status and usage metrics +

+
+
+
+
+
+
+ ); + } + + const activeSubscription = subscriptions.find( + (sub) => sub.status === "active" + ); + + return ( +
+
+ +
+
+

Dashboard

+

+ Monitor your subscription status and usage metrics +

+
+ + {!activeSubscription ? ( +
+ +

+ No active subscription +

+

+ Get started by choosing a subscription package. +

+ +
+ ) : ( + <> + {/* Subscription Overview */} + {/*
+
+
+
+

+ Current Subscription +

+

+ {activeSubscription.packageSnapshot.name} Plan +

+
+
+ + {activeSubscription.status.charAt(0).toUpperCase() + activeSubscription.status.slice(1)} + +
+
+ +
+
+
+ +
+

Expires

+

+ {formatDate(activeSubscription.endDate)} +

+

+ {getDaysUntilExpiry(activeSubscription.endDate)} days left +

+
+
+
+ +
+
+ +
+

Clients

+

+ {activeSubscription.packageSnapshot.clientsCount.toLocaleString()} +

+

Available

+
+
+
+ +
+
+ +
+

SMS Usage

+

+ {activeSubscription.sentSmsCount.toLocaleString()} / {activeSubscription.packageSnapshot.smsCount.toLocaleString()} +

+

+ {Math.round((activeSubscription.sentSmsCount / activeSubscription.packageSnapshot.smsCount) * 100)}% used +

+
+
+
+ +
+
+ +
+

Monthly Cost

+

+ {formatCurrency(activeSubscription.packageSnapshot.price / 100)} +

+

Per month

+
+
+
+
+
+
*/} + + {/* Usage Details */} + {/*
+
+
+

+ Resource Limits +

+
+
+
+ Agents + {activeSubscription.packageSnapshot.agentsCount} +
+
+
+
+ Admins + {activeSubscription.packageSnapshot.adminsCount} +
+
+
+
+ Agencies + {activeSubscription.packageSnapshot.agenciesCount} +
+
+
+
+ Supervisors + {activeSubscription.packageSnapshot.supervisorsCount} +
+
+
+
+ Cashiers + {activeSubscription.packageSnapshot.cashiersCount} +
+
+
+
+
+ +
+
+

+ Features +

+
+
+
+ Agent Localization +
+
+
+ Commission System +
+
+
+ + {activeSubscription.packageSnapshot.supportType.charAt(0).toUpperCase() + + activeSubscription.packageSnapshot.supportType.slice(1)} Support + +
+
+
+
+
*/} + + {/* Subscription Details */} +
+
+

+ Subscription Details +

+
+
+
+ Organization +
+
+ {activeSubscription.subscriber.name} +
+
+
+
Type
+
+ {activeSubscription.subscriber.type} +
+
+
+
Email
+
+ {activeSubscription.subscriber.email} +
+
+
+
Phone
+
+ {activeSubscription.subscriber.phone} +
+
+
+
+ Protect Key +
+
+ {activeSubscription.protectKey} +
+
+
+
+ Started +
+
+ {formatDate(activeSubscription.startDate)} +
+
+
+
+
+ + )} +
+
+ ); +} diff --git a/src/web/pages/HomePage.tsx b/src/web/pages/HomePage.tsx new file mode 100644 index 0000000..b00c676 --- /dev/null +++ b/src/web/pages/HomePage.tsx @@ -0,0 +1,140 @@ + +import { BarChart3, Globe, Package, Shield, Users, Zap } from 'lucide-react'; +import { Footer } from '../components/layout/Footer'; +import { Header } from '../components/layout/Header'; + +export default function HomePage({ user }: { user?: { email: string; name: string } | null }) { + const features = [ + { + icon: Users, + title: 'Client Management', + description: 'Manage thousands of clients with advanced filtering and organization tools.' + }, + { + icon: BarChart3, + title: 'Analytics Dashboard', + description: 'Real-time insights into your operations with comprehensive reporting.' + }, + { + icon: Shield, + title: 'Secure & Reliable', + description: 'Enterprise-grade security with 99.9% uptime guarantee.' + }, + { + icon: Zap, + title: 'Fast Performance', + description: 'Lightning-fast operations designed for high-volume transactions.' + }, + { + icon: Globe, + title: 'Multi-Location', + description: 'Support for multiple branches and geographical locations.' + }, + { + icon: Package, + title: 'Flexible Plans', + description: 'Choose from various packages tailored to your business needs.' + } + ]; + + return ( +
+
+ + {/* Hero Section */} +
+
+
+
+
+

+ Powerful{' '} + subscription management +

+

+ Streamline your microfinance, educational, or business operations with our comprehensive + subscription platform. Manage clients, agents, and operations with ease. +

+ +
+
+
+
+
+ Business team working +
+
+ + {/* Features Section */} +
+
+
+

Features

+

+ Everything you need to succeed +

+

+ Our platform provides all the tools you need to manage your subscription-based business effectively. +

+
+ +
+
+ {features.map((feature) => ( +
+
+
+

{feature.title}

+
{feature.description}
+
+ ))} +
+
+
+
+ + {/* CTA Section */} +
+
+

+ Ready to get started? + Choose your perfect plan today. +

+

+ Join thousands of businesses already using our platform to manage their operations. +

+ + View Packages + +
+
+ +
+
+ ); +} \ No newline at end of file diff --git a/src/web/pages/PackagesPage.tsx b/src/web/pages/PackagesPage.tsx new file mode 100644 index 0000000..fd96b18 --- /dev/null +++ b/src/web/pages/PackagesPage.tsx @@ -0,0 +1,134 @@ +import { useEffect, useState } from "react"; +import { Package } from "../../database/schema"; +import { Header } from "../components/layout/Header"; +import { PackageCard } from "../components/packages/Packagecard"; + +export default function PackagesPage({ + user, +}: { + user?: { email: string; name: string; token: string } | null; +}) { + const [packages, setPackages] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchPackages(); + }, []); + + const fetchPackages = async (limit = 10, page = 1) => { + console.log("Fetching packages..."); + try { + const response = await fetch( + `/api/packages?limit=${limit}&page=${page}`, + ); + const data = await response.json(); + + if (data) { + setPackages(data); + } + } catch (error) { + console.error("Failed to fetch packages:", error); + } finally { + setLoading(false); + } + }; + + const handleSelectPackage = (pkg: Package) => { + // In a real app, this would open a subscription modal or redirect to checkout + alert(`Selected package: ${pkg.name}`); + }; + + if (loading) { + return ( +
+
+
+
+
+
+ ); + } + + return ( +
+
+ +
+
+

+ Choose Your Perfect Plan +

+

+ Select the subscription package that best fits your business needs. + All plans include our core features with varying limits and support + levels. +

+
+ + {packages.length === 0 ? ( +
+

+ No packages available at the moment. +

+
+ ) : ( +
+ {packages.map((pkg, index) => ( + + ))} +
+ )} + + {/* FAQ Section */} +
+

+ Frequently Asked Questions +

+
+
+

+ Can I change my plan later? +

+

+ Yes, you can upgrade or downgrade your plan at any time. Changes + will be reflected in your next billing cycle. +

+
+
+

+ What happens if I exceed my limits? +

+

+ We'll notify you when you're approaching your limits. You can + upgrade your plan or purchase additional resources. +

+
+
+

+ Is there a free trial? +

+

+ Yes, all plans come with a 14-day free trial. No credit card + required to get started. +

+
+
+

+ What support is included? +

+

+ All plans include email support. Premium and Enterprise plans + include priority support and phone assistance. +

+
+
+
+
+
+ ); +} diff --git a/tsconfig.json b/tsconfig.json index c442b33..f2c6ed7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,6 @@ "compilerOptions": { "strict": true, "jsx": "react-jsx", - "jsxImportSource": "hono/jsx" + "jsxImportSource": "react" } } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index c7c3d30..bc87abc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -350,11 +350,21 @@ resolved "https://registry.yarnpkg.com/@hono/node-server/-/node-server-1.19.3.tgz#f0483de5471b415dce0799a34171c05bfe30a6fd" integrity sha512-Fjyxfux0rMPXMSob79OmddfpK5ArJa2xLkLCV+zamHkbeXQtSNKOi0keiBKyHZ/hCRKjigjmKGp4AJnDFq8PUw== -"@hono/react-renderer@^1.0.0": +"@hono/react-renderer@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@hono/react-renderer/-/react-renderer-1.0.1.tgz#c7fa54dbfd0e910800f7f82bc8201dce72a8d4d0" integrity sha512-vjQ/70hVrbgsi2O44N7w5sO0v51lRcuXau/4caVzw0A1hje+U2zAnuhiBC3JhX56gGfhGT4kO5B0di4SROx0Lg== +"@hono/standard-validator@^0.1.5": + version "0.1.5" + resolved "https://registry.yarnpkg.com/@hono/standard-validator/-/standard-validator-0.1.5.tgz#f9a8dbaef9d858e2b75014ad2290630b1aeb4edf" + integrity sha512-EIyZPPwkyLn6XKwFj5NBEWHXhXbgmnVh2ceIFo5GO7gKI9WmzTjPDKnppQB0KrqKeAkq3kpoW4SIbu5X1dgx3w== + +"@hono/swagger-ui@^0.5.2": + version "0.5.2" + resolved "https://registry.yarnpkg.com/@hono/swagger-ui/-/swagger-ui-0.5.2.tgz#37de7765d3e729a241def1a2f75c69f236d677fb" + integrity sha512-7wxLKdb8h7JTdZ+K8DJNE3KXQMIpJejkBTQjrYlUWF28Z1PGOKw6kUykARe5NTfueIN37jbyG/sBYsbzXzG53A== + "@humanfs/core@^0.19.1": version "0.19.1" resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.1.tgz#17c55ca7d426733fe3c561906b8173c336b40a77" @@ -452,22 +462,16 @@ dependencies: undici-types "~7.12.0" -"@types/prop-types@*": - version "15.7.15" - resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.15.tgz#e6e5a86d602beaca71ce5163fadf5f95d70931c7" - integrity sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw== +"@types/react-dom@^19.1.9": + version "19.1.9" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.1.9.tgz#5ab695fce1e804184767932365ae6569c11b4b4b" + integrity sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ== -"@types/react-dom@^18.3.0": - version "18.3.7" - resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.3.7.tgz#b89ddf2cd83b4feafcc4e2ea41afdfb95a0d194f" - integrity sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ== - -"@types/react@^18.3.5": - version "18.3.24" - resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.24.tgz#f6a5a4c613242dfe3af0dcee2b4ec47b92d9b6bd" - integrity sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A== +"@types/react@^19.1.13": + version "19.1.13" + resolved "https://registry.yarnpkg.com/@types/react/-/react-19.1.13.tgz#fc650ffa680d739a25a530f5d7ebe00cdd771883" + integrity sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ== dependencies: - "@types/prop-types" "*" csstype "^3.0.2" acorn-jsx@^5.3.2: @@ -879,6 +883,11 @@ has-flag@^4.0.0: resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== +hono-openapi@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/hono-openapi/-/hono-openapi-1.0.8.tgz#1d0b03cf31891eb5fd040b2f635cc6e22ca98162" + integrity sha512-JjSdT4sNUgxQGgwO90boRLfnrVYp3ge+Y/vHqPMJrAZuaIhKekAVipoeJ8AgpTyK+ZaxPzqdcmDBA9L+Ce3X9Q== + hono@^4.9.8: version "4.9.8" resolved "https://registry.yarnpkg.com/hono/-/hono-4.9.8.tgz#1710981135ec775fe26fab5ea6535b403e92bcc3" @@ -1010,6 +1019,11 @@ loose-envify@^1.1.0: dependencies: js-tokens "^3.0.0 || ^4.0.0" +lucide-react@^0.544.0: + version "0.544.0" + resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.544.0.tgz#4719953c10fd53a64dd8343bb0ed16ec79f3eeef" + integrity sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw== + luxon@^3.2.1: version "3.7.2" resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.7.2.tgz#d697e48f478553cca187a0f8436aff468e3ba0ba" @@ -1387,3 +1401,8 @@ yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +zod@^4.1.11: + version "4.1.11" + resolved "https://registry.yarnpkg.com/zod/-/zod-4.1.11.tgz#4aab62f76cfd45e6c6166519ba31b2ea019f75f5" + integrity sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==