feat: server

This commit is contained in:
DJIOTSA CHRISTIAN 2025-09-21 21:06:02 +01:00
parent 01c42bf9fc
commit 21f459e695
47 changed files with 3295 additions and 489 deletions

View File

@ -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"
}
}

View File

@ -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;

View File

@ -0,0 +1,2 @@
ALTER TABLE "products" ALTER COLUMN "description" SET DEFAULT '';--> statement-breakpoint
ALTER TABLE "products" ALTER COLUMN "description" DROP NOT NULL;

View File

@ -0,0 +1,2 @@
DROP TYPE "public"."subscriber_type";--> statement-breakpoint
CREATE TYPE "public"."subscriber_type" AS ENUM('student', 'company', 'personal');

View File

@ -0,0 +1 @@
ALTER TABLE "subscriptions" ADD COLUMN "used_features" jsonb NOT NULL;

View File

@ -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;

View File

@ -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": {

View File

@ -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": {}
}
}

View File

@ -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": {}
}
}

View File

@ -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": {}
}
}

View File

@ -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": {}
}
}

View File

@ -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
}
]

View File

@ -1,3 +1,4 @@
export * from './schema/packages';
export * from './schema/products';
export * from './schema/subscriptions';

View File

@ -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(),
});

View File

@ -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<Subscriber>().notNull(),
subscriber: jsonb('subscriber').$type<ISubscriber>().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;

View File

@ -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) })

131
src/index.tsx Normal file
View File

@ -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 (
<html>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title || 'Subscription System'}</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body>
<div id="root">{children}</div>
</body>
</html>
);
})
);
// 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(<HomePage user={user} />, { 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(<PackagesPage user={user} />, { 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(<DashboardPage user={user} />, { 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) })

View File

@ -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

View File

@ -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,
},
},
];

View File

@ -0,0 +1,9 @@
import { IProductSeed } from "../types";
export const ultracolletProductSeeding: IProductSeed = {
name: "ultracollecte",
description: "",
createdAt: new Date(),
updatedAt: new Date(),
};

View File

@ -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,
},
},
];

View File

@ -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<T = any> {
export interface IApiResponse<T = any> {
success: boolean;
data?: T;
error?: string;
totalRecords?: number;
totalPages?: number;
currentPage?: number;
pageSize?: number;
}

66
src/lib/types/packages.ts Normal file
View File

@ -0,0 +1,66 @@
import { z, ZodTypeAny } from "zod";
import { Package } from "../../database/schema";
export interface IPackageSeed extends Omit<Package, 'id'> {}
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> =
T extends number ? z.ZodNumber :
T extends boolean ? z.ZodBoolean :
ZodTypeAny;
type ZodSchemaFromInterface<T> = {
[K in keyof T]: ZodField<T[K]>;
};
function buildSchema<T>(shape: ZodSchemaFromInterface<T>) {
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<IFeatures[K]>;
};
export const ProductFeaturesSchemas = {
ultracollecte: buildSchema<IUltracollecteFeatures>(productFeaturesShapes.ultracollecte),
} satisfies {
[K in keyof IFeatures]: ReturnType<typeof buildSchema<IFeatures[K]>>;
};
export const FeaturesSchema = z.object(ProductFeaturesSchemas);
export type Features = z.infer<typeof FeaturesSchema>;
export interface IUltracollecteSeedPackage extends Omit<Package, 'id' | 'product' | 'feature'> {
features: IUltracollecteFeatures
}

View File

@ -0,0 +1,3 @@
import { Package, Product } from '../../database/schema';
export interface IProductSeed extends Omit<Product, 'id'> {}

View File

@ -1,11 +0,0 @@
export const UltracollecteFeatures = [
'clientsCount',
'agentsCount',
'adminsCount',
'agenciesCount',
'agentLocalization',
'enableCommision',
'smsCount',
'supervisorsCount',
'cashiersCount',
];

View File

@ -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));
}
}
export function getProductFeaturesSchema<
T extends keyof typeof ProductFeaturesSchemas
>(productName: T) {
return ProductFeaturesSchemas[productName];
}

30
src/server/openapi.ts Normal file
View File

@ -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' }
}
}
}
}
}
}
}
}
}
}

View File

@ -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<Package[] | null> = {
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<typeof packages>({
db,
table: packages,
page,
limit,
filters: complexFilter,
});
const response: IApiResponse<Package[] | null> = {
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<Package> = {
const response: IApiResponse<Package> = {
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<Package> = {
const response: IApiResponse<Package> = {
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<Package> = {
const response: IApiResponse<Package> = {
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<Package | null> = {
const response: IApiResponse<Package | null> = {
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<Package | null> = {
const response: IApiResponse<Package | null> = {
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);
}

View File

@ -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<Product[] | null> = {
const page = Number(c.req.query('page')) || 1
const limit = Number(c.req.query('limit')) || 10
const pts = await paginate<typeof products>({
db,
table: products,
limit,
page
});
const response: IApiResponse<Product[] | null> = {
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<Product | null> = {
const response: IApiResponse<Product | null> = {
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<Product | null> = {
const response: IApiResponse<Product | null> = {
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<Product> = {
const response: IApiResponse<Product> = {
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<Product | null> = {
const response: IApiResponse<Product | null> = {
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<Product | null> = {
const response: IApiResponse<Product | null> = {
success: true,
data: deletedPt
};
return c.json(response);
} catch (error) {
const response: ApiResponse = {
const response: IApiResponse = {
success: false,
error: 'Failed to delete product'
};

View File

@ -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<Subscription | null> = {
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<Subscription | null> = {
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;

View File

@ -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<Subscription> = {
const response: IApiResponse<Subscription> = {
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<Subscription> = {
const response: IApiResponse<Subscription> = {
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<Subscription[]> = {
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<typeof subscriptions>({
db,
table: subscriptions,
limit,
page,
filters: finalFilter,
});
const response: IApiResponse<Subscription[] | null> = {
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<Partial<Subscription>[]> = {
const response: IApiResponse<Partial<Subscription>[]> = {
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<Subscription | null> = {
const id = c.req.param("id");
const status = (await c.req.json()).status;
const updatedSubscription =
await SubscriptionService.updateSubscriptionStatus(id, status);
const response: IApiResponse<Subscription | null> = {
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<Subscription> = {
const response: IApiResponse<Subscription> = {
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;
export default subscriptionRoutes;

View File

@ -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();

View File

@ -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);

View File

@ -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);
}
}

View File

@ -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<Subscription, 'protectKey' | 'id' | 'endDate'>) {
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<Subscription[]> {
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<Subscription | null> {
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<Subscription | null> {
const [subscription] = await db.select()
static async getSubscriptionByProtectKey(
protectKey: string
): Promise<Subscription | null> {
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<Subscription | null> {
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<Partial<Subscription>[]> {
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<void> {
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));
}
}
}

View File

@ -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[]
}

View File

@ -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<any, any>;
// /**
// * 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<T extends PgTable>({ // Adjusted for PgTable
// db,
// table,
// page = 1,
// limit = 10,
// whereClause,
// }: {
// db: DrizzleDB;
// table: PgTableWithColumns<any>;
// 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<any, any>;
/**
* 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<T extends PgTable>({
db,
table,
page = 1,
limit = 10,
filters, // Now using FilterCondition[]
}: {
db: DrizzleDB;
table: PgTableWithColumns<any>;
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,
};
}

View File

@ -1,14 +0,0 @@
import { SessionProvider, useSession } from '@hono/auth-js/dist/react'
export default function App() {
return (
<SessionProvider>
<Children />
</SessionProvider>
)
}
function Children() {
const { data: session, status } = useSession()
return <div>I am {session?.user}</div>
}

View File

@ -0,0 +1,67 @@
export function Footer() {
return (
<footer className="bg-gray-50 border-t">
<div className="max-w-7xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
<div className="col-span-1 md:col-span-2">
<h3 className="text-sm font-semibold text-gray-400 tracking-wider uppercase">
About SubscriptionHub
</h3>
<p className="mt-4 text-base text-gray-500">
Powerful subscription management for microfinance, educational institutions,
and businesses. Manage clients, agents, and operations with ease.
</p>
</div>
<div>
<h3 className="text-sm font-semibold text-gray-400 tracking-wider uppercase">
Support
</h3>
<ul className="mt-4 space-y-4">
<li>
<a href="#" className="text-base text-gray-500 hover:text-gray-900">
Documentation
</a>
</li>
<li>
<a href="#" className="text-base text-gray-500 hover:text-gray-900">
Help Center
</a>
</li>
<li>
<a href="#" className="text-base text-gray-500 hover:text-gray-900">
Contact Us
</a>
</li>
</ul>
</div>
<div>
<h3 className="text-sm font-semibold text-gray-400 tracking-wider uppercase">
Legal
</h3>
<ul className="mt-4 space-y-4">
<li>
<a href="#" className="text-base text-gray-500 hover:text-gray-900">
Privacy Policy
</a>
</li>
<li>
<a href="#" className="text-base text-gray-500 hover:text-gray-900">
Terms of Service
</a>
</li>
</ul>
</div>
</div>
<div className="mt-8 border-t border-gray-200 pt-8">
<p className="text-base text-gray-400 text-center">
&copy; 2024 SubscriptionHub. All rights reserved.
</p>
</div>
</div>
</footer>
);
}

View File

@ -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 (
<header className="bg-white shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<div className="flex items-center">
{/* <Package className="h-8 w-8 text-primary-600" /> */}
<span className="ml-2 text-xl font-bold text-gray-900">
SubscriptionHub
</span>
</div>
<nav className="hidden md:flex space-x-8">
<a
href="/"
className="text-gray-500 hover:text-gray-900 px-3 py-2 text-sm font-medium"
>
Home
</a>
<a
href="/packages"
className="text-gray-500 hover:text-gray-900 px-3 py-2 text-sm font-medium"
>
Packages
</a>
{user && (
<a
href="/dashboard"
className="text-gray-500 hover:text-gray-900 px-3 py-2 text-sm font-medium"
>
Dashboard
</a>
)}
</nav>
<div className="flex items-center space-x-4">
{user ? (
<div className="flex items-center space-x-3">
<div className="flex items-center space-x-2">
<User className="h-5 w-5 text-gray-400" />
<span className="text-sm text-gray-700">{user.email}</span>
{user.name && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 text-primary-800">
{user.name}
</span>
)}
</div>
<a
href="/api/auth/signout"
className="flex items-center space-x-1 text-gray-500 hover:text-gray-700"
>
<LogOut className="h-4 w-4" />
<span className="text-sm">Logout</span>
</a>
{/* <button
onClick={onLogout}
className="flex items-center space-x-1 text-gray-500 hover:text-gray-700"
>
<LogOut className="h-4 w-4" />
<span className="text-sm">Logout</span>
</button> */}
</div>
) : (
<button className="bg-primary-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-primary-700">
Sign In
</button>
)}
</div>
</div>
</div>
</header>
);
}

View File

@ -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 (
<div className={`relative bg-white rounded-lg shadow-lg border-2 transition-all duration-200 hover:shadow-xl ${
isPopular ? 'border-primary-500 ring-2 ring-primary-200' : 'border-gray-200 hover:border-primary-300'
}`}>
{isPopular && (
<div className="absolute -top-3 left-1/2 transform -translate-x-1/2">
<span className="bg-primary-500 text-white px-4 py-1 rounded-full text-sm font-medium">
Most Popular
</span>
</div>
)}
<div className="p-6">
<div className="text-center">
<h3 className="text-2xl font-bold text-gray-900">{pkg.name}</h3>
<div className="mt-4">
<span className="text-4xl font-bold text-gray-900">
{formatCurrency(pkg.price / 100)}
</span>
<span className="text-gray-500">/month</span>
</div>
</div>
{/* <div className="mt-6 space-y-4">
<div className="flex items-center">
<Users />
<span className="ml-3 text-gray-700">
{pkg.clientsCount.toLocaleString()} Clients
</span>
</div>
<div className="flex items-center">
<Building className="h-5 w-5 text-primary-500" />
<span className="ml-3 text-gray-700">
{pkg.agentsCount} Agents {pkg.adminsCount} Admins
</span>
</div>
<div className="flex items-center">
<MessageSquare className="h-5 w-5 text-primary-500" />
<span className="ml-3 text-gray-700">
{pkg.smsCount.toLocaleString()} SMS/month
</span>
</div>
<div className="flex items-center">
<Shield className="h-5 w-5 text-primary-500" />
<span className="ml-3 text-gray-700">
{getSupportIcon(pkg.supportType)} {pkg.supportType.charAt(0).toUpperCase() + pkg.supportType.slice(1)} Support
</span>
</div>
</div>
<div className="mt-6 space-y-2">
{pkg.agentLocalization && (
<div className="flex items-center">
<Check className="h-4 w-4 text-green-500" />
<span className="ml-2 text-sm text-gray-600">Agent Localization</span>
</div>
)}
{pkg.enableCommission && (
<div className="flex items-center">
<Check className="h-4 w-4 text-green-500" />
<span className="ml-2 text-sm text-gray-600">Commission System</span>
</div>
)}
<div className="flex items-center">
<Check className="h-4 w-4 text-green-500" />
<span className="ml-2 text-sm text-gray-600">
{pkg.supervisorsCount} Supervisors {pkg.cashiersCount} Cashiers
</span>
</div>
</div> */}
<button
onClick={() => onSelect?.(pkg)}
className={`mt-8 w-full py-3 px-4 rounded-md font-medium transition-colors ${
isPopular
? 'bg-primary-600 text-white hover:bg-primary-700'
: 'bg-gray-100 text-gray-900 hover:bg-gray-200'
}`}
>
Choose Plan
</button>
</div>
</div>
);
}

View File

@ -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<Subscription[]>([]);
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 (
<div className="min-h-screen bg-gray-50">
<Header user={user} />
<div className="max-w-7xl mx-auto py-8 px-4 sm:px-6 lg:px-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
<p className="mt-2 text-gray-600">
Monitor your subscription status and usage metrics
</p>
</div>
</div>
<div className="flex items-center justify-center min-h-[60vh]">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
</div>
</div>
);
}
const activeSubscription = subscriptions.find(
(sub) => sub.status === "active"
);
return (
<div className="min-h-screen bg-gray-50">
<Header user={user} />
<div className="max-w-7xl mx-auto py-8 px-4 sm:px-6 lg:px-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
<p className="mt-2 text-gray-600">
Monitor your subscription status and usage metrics
</p>
</div>
{!activeSubscription ? (
<div className="text-center py-12">
<AlertCircle className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">
No active subscription
</h3>
<p className="mt-1 text-sm text-gray-500">
Get started by choosing a subscription package.
</p>
<div className="mt-6">
<a
href="/packages"
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700"
>
View Packages
</a>
</div>
</div>
) : (
<>
{/* Subscription Overview */}
{/* <div className="bg-white overflow-hidden shadow rounded-lg mb-8">
<div className="px-4 py-5 sm:p-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg leading-6 font-medium text-gray-900">
Current Subscription
</h3>
<p className="mt-1 text-sm text-gray-500">
{activeSubscription.packageSnapshot.name} Plan
</p>
</div>
<div className="flex items-center">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
activeSubscription.status === 'active'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
{activeSubscription.status.charAt(0).toUpperCase() + activeSubscription.status.slice(1)}
</span>
</div>
</div>
<div className="mt-6 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
<div className="bg-gray-50 overflow-hidden rounded-lg p-4">
<div className="flex items-center">
<Calendar className="h-8 w-8 text-primary-600" />
<div className="ml-4">
<p className="text-sm font-medium text-gray-500">Expires</p>
<p className="text-lg font-semibold text-gray-900">
{formatDate(activeSubscription.endDate)}
</p>
<p className="text-sm text-gray-500">
{getDaysUntilExpiry(activeSubscription.endDate)} days left
</p>
</div>
</div>
</div>
<div className="bg-gray-50 overflow-hidden rounded-lg p-4">
<div className="flex items-center">
<Users className="h-8 w-8 text-primary-600" />
<div className="ml-4">
<p className="text-sm font-medium text-gray-500">Clients</p>
<p className="text-lg font-semibold text-gray-900">
{activeSubscription.packageSnapshot.clientsCount.toLocaleString()}
</p>
<p className="text-sm text-gray-500">Available</p>
</div>
</div>
</div>
<div className="bg-gray-50 overflow-hidden rounded-lg p-4">
<div className="flex items-center">
<MessageSquare className="h-8 w-8 text-primary-600" />
<div className="ml-4">
<p className="text-sm font-medium text-gray-500">SMS Usage</p>
<p className="text-lg font-semibold text-gray-900">
{activeSubscription.sentSmsCount.toLocaleString()} / {activeSubscription.packageSnapshot.smsCount.toLocaleString()}
</p>
<p className="text-sm text-gray-500">
{Math.round((activeSubscription.sentSmsCount / activeSubscription.packageSnapshot.smsCount) * 100)}% used
</p>
</div>
</div>
</div>
<div className="bg-gray-50 overflow-hidden rounded-lg p-4">
<div className="flex items-center">
<TrendingUp className="h-8 w-8 text-primary-600" />
<div className="ml-4">
<p className="text-sm font-medium text-gray-500">Monthly Cost</p>
<p className="text-lg font-semibold text-gray-900">
{formatCurrency(activeSubscription.packageSnapshot.price / 100)}
</p>
<p className="text-sm text-gray-500">Per month</p>
</div>
</div>
</div>
</div>
</div>
</div> */}
{/* Usage Details */}
{/* <div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">
Resource Limits
</h3>
<div className="space-y-4">
<div>
<div className="flex justify-between text-sm">
<span>Agents</span>
<span>{activeSubscription.packageSnapshot.agentsCount}</span>
</div>
</div>
<div>
<div className="flex justify-between text-sm">
<span>Admins</span>
<span>{activeSubscription.packageSnapshot.adminsCount}</span>
</div>
</div>
<div>
<div className="flex justify-between text-sm">
<span>Agencies</span>
<span>{activeSubscription.packageSnapshot.agenciesCount}</span>
</div>
</div>
<div>
<div className="flex justify-between text-sm">
<span>Supervisors</span>
<span>{activeSubscription.packageSnapshot.supervisorsCount}</span>
</div>
</div>
<div>
<div className="flex justify-between text-sm">
<span>Cashiers</span>
<span>{activeSubscription.packageSnapshot.cashiersCount}</span>
</div>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">
Features
</h3>
<div className="space-y-3">
<div className="flex items-center">
<div className={`h-2 w-2 rounded-full mr-3 ${
activeSubscription.packageSnapshot.agentLocalization ? 'bg-green-400' : 'bg-gray-300'
}`}></div>
<span className="text-sm text-gray-700">Agent Localization</span>
</div>
<div className="flex items-center">
<div className={`h-2 w-2 rounded-full mr-3 ${
activeSubscription.packageSnapshot.enableCommission ? 'bg-green-400' : 'bg-gray-300'
}`}></div>
<span className="text-sm text-gray-700">Commission System</span>
</div>
<div className="flex items-center">
<div className="h-2 w-2 rounded-full mr-3 bg-green-400"></div>
<span className="text-sm text-gray-700">
{activeSubscription.packageSnapshot.supportType.charAt(0).toUpperCase() +
activeSubscription.packageSnapshot.supportType.slice(1)} Support
</span>
</div>
</div>
</div>
</div>
</div> */}
{/* Subscription Details */}
<div className="mt-8 bg-white overflow-hidden shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">
Subscription Details
</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<dt className="text-sm font-medium text-gray-500">
Organization
</dt>
<dd className="mt-1 text-sm text-gray-900">
{activeSubscription.subscriber.name}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Type</dt>
<dd className="mt-1 text-sm text-gray-900 capitalize">
{activeSubscription.subscriber.type}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Email</dt>
<dd className="mt-1 text-sm text-gray-900">
{activeSubscription.subscriber.email}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Phone</dt>
<dd className="mt-1 text-sm text-gray-900">
{activeSubscription.subscriber.phone}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">
Protect Key
</dt>
<dd className="mt-1 text-sm text-gray-900 font-mono">
{activeSubscription.protectKey}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">
Started
</dt>
<dd className="mt-1 text-sm text-gray-900">
{formatDate(activeSubscription.startDate)}
</dd>
</div>
</div>
</div>
</div>
</>
)}
</div>
</div>
);
}

140
src/web/pages/HomePage.tsx Normal file
View File

@ -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 (
<div className="min-h-screen bg-gray-50">
<Header user={user} />
{/* Hero Section */}
<div className="relative bg-white overflow-hidden">
<div className="max-w-7xl mx-auto">
<div className="relative z-10 pb-8 bg-white sm:pb-16 md:pb-20 lg:max-w-2xl lg:w-full lg:pb-28 xl:pb-32">
<main className="mt-10 mx-auto max-w-7xl px-4 sm:mt-12 sm:px-6 md:mt-16 lg:mt-20 lg:px-8 xl:mt-28">
<div className="sm:text-center lg:text-left">
<h1 className="text-4xl tracking-tight font-extrabold text-gray-900 sm:text-5xl md:text-6xl">
<span className="block xl:inline">Powerful</span>{' '}
<span className="block text-primary-600 xl:inline">subscription management</span>
</h1>
<p className="mt-3 text-base text-gray-500 sm:mt-5 sm:text-lg sm:max-w-xl sm:mx-auto md:mt-5 md:text-xl lg:mx-0">
Streamline your microfinance, educational, or business operations with our comprehensive
subscription platform. Manage clients, agents, and operations with ease.
</p>
<div className="mt-5 sm:mt-8 sm:flex sm:justify-center lg:justify-start">
<div className="rounded-md shadow">
<a
href="/packages"
className="w-full flex items-center justify-center px-8 py-3 border border-transparent text-base font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 md:py-4 md:text-lg md:px-10"
>
View Packages
</a>
</div>
<div className="mt-3 sm:mt-0 sm:ml-3">
<a
href="#features"
className="w-full flex items-center justify-center px-8 py-3 border border-transparent text-base font-medium rounded-md text-primary-700 bg-primary-100 hover:bg-primary-200 md:py-4 md:text-lg md:px-10"
>
Learn More
</a>
</div>
</div>
</div>
</main>
</div>
</div>
<div className="lg:absolute lg:inset-y-0 lg:right-0 lg:w-1/2">
<img
className="h-56 w-full object-cover sm:h-72 md:h-96 lg:w-full lg:h-full"
src="https://images.pexels.com/photos/3184360/pexels-photo-3184360.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2"
alt="Business team working"
/>
</div>
</div>
{/* Features Section */}
<div id="features" className="py-12 bg-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="lg:text-center">
<h2 className="text-base text-primary-600 font-semibold tracking-wide uppercase">Features</h2>
<p className="mt-2 text-3xl leading-8 font-extrabold tracking-tight text-gray-900 sm:text-4xl">
Everything you need to succeed
</p>
<p className="mt-4 max-w-2xl text-xl text-gray-500 lg:mx-auto">
Our platform provides all the tools you need to manage your subscription-based business effectively.
</p>
</div>
<div className="mt-10">
<div className="space-y-10 md:space-y-0 md:grid md:grid-cols-2 md:gap-x-8 md:gap-y-10 lg:grid-cols-3">
{features.map((feature) => (
<div key={feature.title} className="relative">
<div className="absolute flex items-center justify-center h-12 w-12 rounded-md bg-primary-500 text-white">
<feature.icon className="h-6 w-6" aria-hidden="true" />
</div>
<p className="ml-16 text-lg leading-6 font-medium text-gray-900">{feature.title}</p>
<dd className="mt-2 ml-16 text-base text-gray-500">{feature.description}</dd>
</div>
))}
</div>
</div>
</div>
</div>
{/* CTA Section */}
<div className="bg-primary-700">
<div className="max-w-2xl mx-auto text-center py-16 px-4 sm:py-20 sm:px-6 lg:px-8">
<h2 className="text-3xl font-extrabold text-white sm:text-4xl">
<span className="block">Ready to get started?</span>
<span className="block">Choose your perfect plan today.</span>
</h2>
<p className="mt-4 text-lg leading-6 text-primary-200">
Join thousands of businesses already using our platform to manage their operations.
</p>
<a
href="/packages"
className="mt-8 w-full inline-flex items-center justify-center px-5 py-3 border border-transparent text-base font-medium rounded-md text-primary-600 bg-white hover:bg-primary-50 sm:w-auto"
>
View Packages
</a>
</div>
</div>
<Footer />
</div>
);
}

View File

@ -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<Package[]>([]);
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 (
<div className="min-h-screen bg-gray-50">
<Header user={user} />
<div className="flex items-center justify-center min-h-[60vh]">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
<Header user={user} />
<div className="max-w-7xl mx-auto py-16 px-4 sm:px-6 lg:px-8">
<div className="text-center">
<h1 className="text-4xl font-extrabold text-gray-900 sm:text-5xl">
Choose Your Perfect Plan
</h1>
<p className="mt-4 text-xl text-gray-600 max-w-2xl mx-auto">
Select the subscription package that best fits your business needs.
All plans include our core features with varying limits and support
levels.
</p>
</div>
{packages.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-500 text-lg">
No packages available at the moment.
</p>
</div>
) : (
<div className="mt-16 grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3">
{packages.map((pkg, index) => (
<PackageCard
key={pkg.id}
package={pkg}
onSelect={handleSelectPackage}
isPopular={index === 1} // Make the second package popular
/>
))}
</div>
)}
{/* FAQ Section */}
<div className="mt-20">
<h2 className="text-3xl font-extrabold text-gray-900 text-center mb-12">
Frequently Asked Questions
</h2>
<div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
<div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
Can I change my plan later?
</h3>
<p className="text-gray-600">
Yes, you can upgrade or downgrade your plan at any time. Changes
will be reflected in your next billing cycle.
</p>
</div>
<div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
What happens if I exceed my limits?
</h3>
<p className="text-gray-600">
We'll notify you when you're approaching your limits. You can
upgrade your plan or purchase additional resources.
</p>
</div>
<div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
Is there a free trial?
</h3>
<p className="text-gray-600">
Yes, all plans come with a 14-day free trial. No credit card
required to get started.
</p>
</div>
<div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
What support is included?
</h3>
<p className="text-gray-600">
All plans include email support. Premium and Enterprise plans
include priority support and phone assistance.
</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -2,6 +2,6 @@
"compilerOptions": {
"strict": true,
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx"
"jsxImportSource": "react"
}
}

View File

@ -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==