init
This commit is contained in:
parent
53abf15df2
commit
01c42bf9fc
9
.env
Normal file
9
.env
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
PORT=3000
|
||||||
|
DATABASE_URL=postgresql://postgres:secret@192.168.1.118:5435/subscription
|
||||||
|
REDIS_URL=redis://192.168.1.118:6379
|
||||||
|
|
||||||
|
# auth
|
||||||
|
AUTH_SECRET=sdkhdaioiosdsodjafsfnuwyewqewq
|
||||||
|
GITHUB_ID=Ov23liZacJJiSqbJvRdm
|
||||||
|
GITHUB_SECRET=8224930d9b23c94e7e9684bf91934b643ceb1c2b
|
||||||
|
AUTH_URL=http://localhost:3000/api/auth
|
||||||
9
.env.example
Normal file
9
.env.example
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
PORT=3000
|
||||||
|
DATABASE_URL=postgresql://postgres:secret@192.168.1.118:5435/subscription
|
||||||
|
REDIS_URL=redis://192.168.1.118:6379
|
||||||
|
|
||||||
|
# auth
|
||||||
|
AUTH_SECRET=sdkhdaioiosdsodjafsfnuwyewqewq
|
||||||
|
GITHUB_ID=Ov23liZacJJiSqbJvRdm
|
||||||
|
GITHUB_SECRET=8224930d9b23c94e7e9684bf91934b643ceb1c2b
|
||||||
|
AUTH_URL=http://localhost:3000/api/auth
|
||||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# deps
|
||||||
|
node_modules/
|
||||||
8
drizzle.config.json
Normal file
8
drizzle.config.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"schema": "src/database/schema/*",
|
||||||
|
"out": "src/database/migrations",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"dbCredentials": {
|
||||||
|
"url": process.env.DATABASE_URL || ""
|
||||||
|
}
|
||||||
|
}
|
||||||
10
drizzle.config.ts
Normal file
10
drizzle.config.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'drizzle-kit';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "src/database/schema/*",
|
||||||
|
out: "src/database/migrations",
|
||||||
|
dialect: "postgresql",
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_URL!
|
||||||
|
}
|
||||||
|
});
|
||||||
40
package.json
Normal file
40
package.json
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "subscriptions-service",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "bun run --hot src/index.ts",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:migrate": "drizzle-kit migrate",
|
||||||
|
"db:seed": "bun run src/server/seeds/index.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@auth/core": "^0.40.0",
|
||||||
|
"@hono/auth-js": "^1.1.0",
|
||||||
|
"@hono/node-server": "^1.19.3",
|
||||||
|
"bullmq": "^5.58.6",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"drizzle-kit": "^0.31.4",
|
||||||
|
"drizzle-orm": "^0.44.5",
|
||||||
|
"hono": "^4.9.8",
|
||||||
|
"ioredis": "^5.7.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-dom": "^18.3.1",
|
||||||
|
"bcryptjs": "^2.4.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest",
|
||||||
|
"@types/node": "^24.5.2",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/database/connexion.ts
Normal file
9
src/database/connexion.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||||
|
import postgres from 'postgres';
|
||||||
|
import * as schema from './schema';
|
||||||
|
|
||||||
|
const connectionString = process.env.DATABASE_URL!;
|
||||||
|
|
||||||
|
// Disable prefetch as it is not supported for "Transaction" pool mode
|
||||||
|
const client = postgres(connectionString, { prepare: false });
|
||||||
|
export const db = drizzle(client, { schema });
|
||||||
42
src/database/migrations/0000_ordinary_micromax.sql
Normal file
42
src/database/migrations/0000_ordinary_micromax.sql
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
CREATE TYPE "public"."support_type" AS ENUM('standard', '24/7', 'premium');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."subscriber_type" AS ENUM('microfinance', 'student', 'company', 'school');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."subscription_status" AS ENUM('active', 'expired', 'canceled');--> statement-breakpoint
|
||||||
|
CREATE TABLE "packages" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"name" varchar(255) NOT NULL,
|
||||||
|
"price" integer NOT NULL,
|
||||||
|
"product_id" uuid NOT NULL,
|
||||||
|
"features" jsonb NOT NULL,
|
||||||
|
"support_type" "support_type" NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "packages_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,
|
||||||
|
"subscriber" jsonb NOT NULL,
|
||||||
|
"package_id" uuid NOT NULL,
|
||||||
|
"start_date" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"duration_in_months" integer NOT NULL,
|
||||||
|
"end_date" timestamp NOT NULL,
|
||||||
|
"sent_sms_count" integer DEFAULT 0 NOT NULL,
|
||||||
|
"status" "subscription_status" DEFAULT 'active' NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
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;
|
||||||
303
src/database/migrations/meta/0000_snapshot.json
Normal file
303
src/database/migrations/meta/0000_snapshot.json
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
{
|
||||||
|
"id": "edfbe82b-5107-421b-9e3c-8170ec30d17c",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"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.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
|
||||||
|
},
|
||||||
|
"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": {
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/database/migrations/meta/_journal.json
Normal file
13
src/database/migrations/meta/_journal.json
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1758304299810,
|
||||||
|
"tag": "0000_ordinary_micromax",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
3
src/database/schema/index.ts
Normal file
3
src/database/schema/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from './schema/packages';
|
||||||
|
export * from './schema/subscriptions';
|
||||||
|
|
||||||
17
src/database/schema/schema/packages.ts
Normal file
17
src/database/schema/schema/packages.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { integer, jsonb, pgEnum, pgTable, timestamp, uuid, varchar } from 'drizzle-orm/pg-core';
|
||||||
|
import { products } from './products';
|
||||||
|
export const supportTypes = pgEnum('support_type', ['standard', '24/7', 'premium']);
|
||||||
|
|
||||||
|
export const packages = pgTable('packages', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
name: varchar('name', { length: 255 }).notNull().unique(),
|
||||||
|
price: integer('price').notNull(),
|
||||||
|
product: uuid('product_id').notNull().references(() => products.id),
|
||||||
|
features: jsonb('features').notNull(),
|
||||||
|
supportType: supportTypes('support_type').notNull(),
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
export type Package = typeof packages.$inferSelect;
|
||||||
12
src/database/schema/schema/products.ts
Normal file
12
src/database/schema/schema/products.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { jsonb, 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(),
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Product = typeof products.$inferSelect;
|
||||||
22
src/database/schema/schema/subscriptions.ts
Normal file
22
src/database/schema/schema/subscriptions.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { integer, jsonb, pgEnum, pgTable, timestamp, uuid, varchar } from 'drizzle-orm/pg-core';
|
||||||
|
import type { Subscriber } from '../../../lib/types';
|
||||||
|
import { packages } from './packages';
|
||||||
|
|
||||||
|
export const subscriberTypeEnum = pgEnum('subscriber_type', ['microfinance', 'student', 'company', 'school']);
|
||||||
|
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(),
|
||||||
|
package: uuid('package_id').notNull().references(() => packages.id),
|
||||||
|
startDate: timestamp('start_date').notNull().defaultNow(),
|
||||||
|
durationInMonths: integer('duration_in_months').notNull(),
|
||||||
|
endDate: timestamp('end_date').notNull(),
|
||||||
|
sentSmsCount: integer('sent_sms_count').notNull().default(0),
|
||||||
|
status: subscriptionStatusEnum('status').notNull().default('active'),
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Subscription = typeof subscriptions.$inferSelect;
|
||||||
50
src/index.ts
Normal file
50
src/index.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
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) })
|
||||||
53
src/lib/constants/ultracollecte.ts
Normal file
53
src/lib/constants/ultracollecte.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
46
src/lib/types/index.ts
Normal file
46
src/lib/types/index.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { Package, } from "../../database/schema";
|
||||||
|
|
||||||
|
export type SubscriberType = 'microfinance' | 'student' | 'company' | 'school';
|
||||||
|
export type SupportType = 'standard' | '24/7' | 'premium';
|
||||||
|
export type SubscriptionStatus = 'active' | 'expired' | 'canceled';
|
||||||
|
|
||||||
|
export interface Subscriber {
|
||||||
|
type: SubscriberType;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
address: string;
|
||||||
|
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 {
|
||||||
|
packageId: string;
|
||||||
|
durationInMonths: number;
|
||||||
|
subscriber: Subscriber;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiResponse<T = any> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
11
src/lib/types/ultracollectes.ts
Normal file
11
src/lib/types/ultracollectes.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export const UltracollecteFeatures = [
|
||||||
|
'clientsCount',
|
||||||
|
'agentsCount',
|
||||||
|
'adminsCount',
|
||||||
|
'agenciesCount',
|
||||||
|
'agentLocalization',
|
||||||
|
'enableCommision',
|
||||||
|
'smsCount',
|
||||||
|
'supervisorsCount',
|
||||||
|
'cashiersCount',
|
||||||
|
];
|
||||||
40
src/lib/utils/index.ts
Normal file
40
src/lib/utils/index.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { type ClassValue, clsx } from 'clsx';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return clsx(inputs);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateId(lenght?: number): string {
|
||||||
|
return Math.random().toString(lenght || 36).substring(2) + Date.now().toString(lenght || 36);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addMonths(date: Date, months: number): Date {
|
||||||
|
const result = new Date(date);
|
||||||
|
result.setMonth(result.getMonth() + months);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCurrency(amount: number, currency = 'USD'): string {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(date: Date): string {
|
||||||
|
return new Intl.DateTimeFormat('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSubscriptionExpired(endDate: Date): boolean {
|
||||||
|
return new Date() > endDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDaysUntilExpiry(endDate: Date): number {
|
||||||
|
const now = new Date();
|
||||||
|
const diffTime = endDate.getTime() - now.getTime();
|
||||||
|
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||||
|
}
|
||||||
27
src/server/jobs/cron.ts
Normal file
27
src/server/jobs/cron.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import cron from 'node-cron';
|
||||||
|
import { SubscriptionService } from '../services/subscriptions';
|
||||||
|
|
||||||
|
// Check for expired subscriptions every 5 minutes
|
||||||
|
cron.schedule('*/5 * * * *', async () => {
|
||||||
|
console.log('🔍 Checking for expired subscriptions...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const expiredSubscriptions = await SubscriptionService.getExpiredSubscriptions({
|
||||||
|
limit: 0,
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const subscription of expiredSubscriptions) {
|
||||||
|
await SubscriptionService.updateSubscriptionStatus(subscription.id!, 'expired');
|
||||||
|
console.log(`📅 Subscription of ${subscription.subscriber?.name} marked as expired! `);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expiredSubscriptions.length > 0) {
|
||||||
|
console.log(`✅ Updated ${expiredSubscriptions.length} expired subscriptions`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error checking expired subscriptions:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('⏰ Cron jobs initialized');
|
||||||
57
src/server/jobs/queue.ts
Normal file
57
src/server/jobs/queue.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { Queue, Worker } from 'bullmq';
|
||||||
|
import IORedis from 'ioredis';
|
||||||
|
import { SubscriptionService } from '../services/subscriptions';
|
||||||
|
|
||||||
|
// Redis connection
|
||||||
|
const connection = new IORedis(process.env.REDIS_URL || 'redis://localhost:6379');
|
||||||
|
|
||||||
|
// Create queues
|
||||||
|
export const subscriptionQueue = new Queue('subscription-jobs', { connection });
|
||||||
|
|
||||||
|
// Subscription expiry worker
|
||||||
|
const subscriptionWorker = new Worker(
|
||||||
|
'subscription-jobs',
|
||||||
|
async (job) => {
|
||||||
|
const { subscriptionId, action } = job.data;
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'expire':
|
||||||
|
await SubscriptionService.updateSubscriptionStatus(subscriptionId, 'expired');
|
||||||
|
console.log(`📅 Subscription ${subscriptionId} expired via queue`);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'reminder':
|
||||||
|
// Send expiry reminder
|
||||||
|
console.log(`📧 Sending expiry reminder for subscription ${subscriptionId}`);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.log(`❓ Unknown action: ${action}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ connection }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schedule subscription expiry job
|
||||||
|
export async function scheduleSubscriptionExpiry(subscriptionId: string, expiryDate: Date) {
|
||||||
|
await subscriptionQueue.add(
|
||||||
|
'expire-subscription',
|
||||||
|
{ subscriptionId, action: 'expire' },
|
||||||
|
{ delay: expiryDate.getTime() - Date.now() }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule reminder job (7 days before expiry)
|
||||||
|
export async function scheduleExpiryReminder(subscriptionId: string, expiryDate: Date) {
|
||||||
|
const reminderDate = new Date(expiryDate.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
if (reminderDate > new Date()) {
|
||||||
|
await subscriptionQueue.add(
|
||||||
|
'expiry-reminder',
|
||||||
|
{ subscriptionId, action: 'reminder' },
|
||||||
|
{ delay: reminderDate.getTime() - Date.now() }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🚀 BullMQ workers initialized');
|
||||||
148
src/server/routes/packages.ts
Normal file
148
src/server/routes/packages.ts
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { Package } from '../../database/schema';
|
||||||
|
import type { ApiResponse } from '../../lib/types';
|
||||||
|
import { PackageService } from '../services/packages';
|
||||||
|
|
||||||
|
const packageRoutes = new Hono();
|
||||||
|
|
||||||
|
// get packages
|
||||||
|
packageRoutes.get('/', async (c) => {
|
||||||
|
try {
|
||||||
|
const packages = await PackageService.getPackages();
|
||||||
|
const response: ApiResponse<Package[] | null> = {
|
||||||
|
success: true,
|
||||||
|
data: packages
|
||||||
|
};
|
||||||
|
return c.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to fetch packages'
|
||||||
|
};
|
||||||
|
return c.json(response, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// get package by id
|
||||||
|
packageRoutes.get('/:id', async (c) => {
|
||||||
|
try {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
|
||||||
|
const pkg = await PackageService.getPackageById(id);
|
||||||
|
|
||||||
|
if (!pkg) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'Package not found'
|
||||||
|
};
|
||||||
|
return c.json(response, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: ApiResponse<Package> = {
|
||||||
|
success: true,
|
||||||
|
data: pkg
|
||||||
|
};
|
||||||
|
return c.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to fetch package'
|
||||||
|
};
|
||||||
|
return c.json(response, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// get package by name
|
||||||
|
packageRoutes.get('/name/:name', async (c) => {
|
||||||
|
try {
|
||||||
|
const name = c.req.param('name');
|
||||||
|
|
||||||
|
const pkg = await PackageService.getPackageByName(name);
|
||||||
|
|
||||||
|
if (!pkg) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'Package not found'
|
||||||
|
};
|
||||||
|
return c.json(response, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: ApiResponse<Package> = {
|
||||||
|
success: true,
|
||||||
|
data: pkg
|
||||||
|
};
|
||||||
|
return c.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to fetch package'
|
||||||
|
};
|
||||||
|
return c.json(response, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// create package
|
||||||
|
packageRoutes.post('/', async (c) => {
|
||||||
|
try {
|
||||||
|
const packageData: Package = await c.req.json();
|
||||||
|
|
||||||
|
const newPackage = await PackageService.createPackage(packageData);
|
||||||
|
|
||||||
|
const response: ApiResponse<Package> = {
|
||||||
|
success: true,
|
||||||
|
data: newPackage
|
||||||
|
};
|
||||||
|
return c.json(response, 201);
|
||||||
|
} catch (error) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to create package'
|
||||||
|
};
|
||||||
|
return c.json(response, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// update package
|
||||||
|
packageRoutes.put('/:id', async (c) => {
|
||||||
|
try {
|
||||||
|
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> = {
|
||||||
|
success: true,
|
||||||
|
data: updatedPackage
|
||||||
|
};
|
||||||
|
return c.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to update package'
|
||||||
|
};
|
||||||
|
return c.json(response, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// delete package
|
||||||
|
packageRoutes.delete('/:id', async (c) => {
|
||||||
|
try {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
|
||||||
|
const deletedPackage = await PackageService.deletePackage(id);
|
||||||
|
|
||||||
|
const response: ApiResponse<Package | null> = {
|
||||||
|
success: true,
|
||||||
|
data: deletedPackage
|
||||||
|
};
|
||||||
|
return c.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to delete package'
|
||||||
|
};
|
||||||
|
return c.json(response, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default packageRoutes;
|
||||||
122
src/server/routes/products.ts
Normal file
122
src/server/routes/products.ts
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { Product } from '../../database/schema/schema/products';
|
||||||
|
import type { ApiResponse } from '../../lib/types';
|
||||||
|
import { ProductService } from '../services/prducts';
|
||||||
|
|
||||||
|
const productRoutes = new Hono();
|
||||||
|
|
||||||
|
// get products
|
||||||
|
productRoutes.get('/', async (c) => {
|
||||||
|
try {
|
||||||
|
const pts = await ProductService.getProducts();
|
||||||
|
const response: ApiResponse<Product[] | null> = {
|
||||||
|
success: true,
|
||||||
|
data: pts
|
||||||
|
};
|
||||||
|
return c.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to fetch products'
|
||||||
|
};
|
||||||
|
return c.json(response, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// get product by id
|
||||||
|
productRoutes.get('/:id', async (c) => {
|
||||||
|
try {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const pt = await ProductService.getProductById(id);
|
||||||
|
const response: ApiResponse<Product | null> = {
|
||||||
|
success: true,
|
||||||
|
data: pt
|
||||||
|
};
|
||||||
|
return c.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to fetch product'
|
||||||
|
};
|
||||||
|
return c.json(response, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// get product by name
|
||||||
|
productRoutes.get('/name/:name', async (c) => {
|
||||||
|
try {
|
||||||
|
const name = c.req.param('name');
|
||||||
|
const pt = await ProductService.getProductByName(name);
|
||||||
|
const response: ApiResponse<Product | null> = {
|
||||||
|
success: true,
|
||||||
|
data: pt
|
||||||
|
};
|
||||||
|
return c.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to fetch product'
|
||||||
|
};
|
||||||
|
return c.json(response, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// create product
|
||||||
|
productRoutes.post('/', async (c) => {
|
||||||
|
try {
|
||||||
|
const ptData: Product = await c.req.json();
|
||||||
|
const newPt = await ProductService.createProduct(ptData);
|
||||||
|
const response: ApiResponse<Product> = {
|
||||||
|
success: true,
|
||||||
|
data: newPt
|
||||||
|
};
|
||||||
|
return c.json(response, 201);
|
||||||
|
} catch (error) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to create product'
|
||||||
|
};
|
||||||
|
return c.json(response, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// update product
|
||||||
|
productRoutes.put('/:id', async (c) => {
|
||||||
|
try {
|
||||||
|
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> = {
|
||||||
|
success: true,
|
||||||
|
data: updatedPt
|
||||||
|
};
|
||||||
|
return c.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to update product'
|
||||||
|
};
|
||||||
|
return c.json(response, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// delete product
|
||||||
|
productRoutes.delete('/:id', async (c) => {
|
||||||
|
try {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const deletedPt = await ProductService.deleteProduct(id);
|
||||||
|
const response: ApiResponse<Product | null> = {
|
||||||
|
success: true,
|
||||||
|
data: deletedPt
|
||||||
|
};
|
||||||
|
return c.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to delete product'
|
||||||
|
};
|
||||||
|
return c.json(response, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default productRoutes;
|
||||||
184
src/server/routes/subscriptions.ts
Normal file
184
src/server/routes/subscriptions.ts
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
const subscriptionRoutes = new Hono();
|
||||||
|
|
||||||
|
// Create subscription
|
||||||
|
subscriptionRoutes.post('/', async (c) => {
|
||||||
|
try {
|
||||||
|
const subscriptionData: CreateSubscriptionRequest = await c.req.json();
|
||||||
|
|
||||||
|
// Get package details
|
||||||
|
const [pkg] = await db.select().from(packages).where(eq(packages.id, subscriptionData.packageId));
|
||||||
|
|
||||||
|
if (!pkg) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'Package not found'
|
||||||
|
};
|
||||||
|
return c.json(response, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = await SubscriptionService.createSubscription(subscriptionData);
|
||||||
|
|
||||||
|
const response: ApiResponse<Subscription> = {
|
||||||
|
success: true,
|
||||||
|
data: subscription
|
||||||
|
};
|
||||||
|
return c.json(response, 201);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Create subscription error:', error);
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to create subscription'
|
||||||
|
};
|
||||||
|
return c.json(response, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get subscription by protect key
|
||||||
|
subscriptionRoutes.get('/status/:protectKey', async (c) => {
|
||||||
|
try {
|
||||||
|
const protectKey = c.req.param('protectKey');
|
||||||
|
|
||||||
|
const subscription = await SubscriptionService.getSubscriptionByProtectKey(protectKey);
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'Subscription not found'
|
||||||
|
};
|
||||||
|
return c.json(response, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: ApiResponse<Subscription> = {
|
||||||
|
success: true,
|
||||||
|
data: subscription
|
||||||
|
};
|
||||||
|
return c.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to fetch subscription'
|
||||||
|
};
|
||||||
|
return c.json(response, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// get subscriptions
|
||||||
|
subscriptionRoutes.get('/', async (c) => {
|
||||||
|
try {
|
||||||
|
const subscriptions = await SubscriptionService.getSubscriptions();
|
||||||
|
const response: ApiResponse<Subscription[]> = {
|
||||||
|
success: true,
|
||||||
|
data: subscriptions
|
||||||
|
};
|
||||||
|
return c.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to fetch subscriptions'
|
||||||
|
};
|
||||||
|
return c.json(response, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// get expired subscriptions
|
||||||
|
subscriptionRoutes.get('/expired', async (c) => {
|
||||||
|
try {
|
||||||
|
const subscriptions = await SubscriptionService.getExpiredSubscriptions({
|
||||||
|
limit: 10,
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
const response: ApiResponse<Partial<Subscription>[]> = {
|
||||||
|
success: true,
|
||||||
|
data: subscriptions
|
||||||
|
};
|
||||||
|
return c.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to fetch expired subscriptions'
|
||||||
|
};
|
||||||
|
return c.json(response, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// increment sms count
|
||||||
|
subscriptionRoutes.post('/increment-sms-count/:protectKey', async (c) => {
|
||||||
|
try {
|
||||||
|
const protectKey = c.req.param('protectKey');
|
||||||
|
|
||||||
|
await SubscriptionService.incrementSmsCount(protectKey);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
return c.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to increment SMS count'
|
||||||
|
};
|
||||||
|
return c.json(response, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// update subscription status
|
||||||
|
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> = {
|
||||||
|
success: true,
|
||||||
|
data: updatedSubscription
|
||||||
|
};
|
||||||
|
return c.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to update subscription status'
|
||||||
|
};
|
||||||
|
return c.json(response, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// get by id
|
||||||
|
subscriptionRoutes.get('/:id', async (c) => {
|
||||||
|
try {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
|
||||||
|
const subscription = await SubscriptionService.getSubscriptionById(id);
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'Subscription not found'
|
||||||
|
};
|
||||||
|
return c.json(response, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: ApiResponse<Subscription> = {
|
||||||
|
success: true,
|
||||||
|
data: subscription
|
||||||
|
};
|
||||||
|
return c.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to fetch subscription'
|
||||||
|
};
|
||||||
|
return c.json(response, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default subscriptionRoutes;
|
||||||
8
src/server/seeds/index.ts
Normal file
8
src/server/seeds/index.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { packagesSeed } from "./packages";
|
||||||
|
|
||||||
|
async function seed() {
|
||||||
|
console.log('🌱 Seeding database...');
|
||||||
|
await packagesSeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
seed();
|
||||||
70
src/server/seeds/packages.ts
Normal file
70
src/server/seeds/packages.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { db } from '../../database/connexion';
|
||||||
|
import { packages } from '../../database/schema';
|
||||||
|
|
||||||
|
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;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Clear existing data
|
||||||
|
await db.delete(packages);
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// await db.insert(packages).values(samplePackages);
|
||||||
|
|
||||||
|
console.log('✅ Database seeded successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error seeding database:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/server/services/packages.ts
Normal file
56
src/server/services/packages.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db } from '../../database/connexion';
|
||||||
|
import { Package, packages } from '../../database/schema';
|
||||||
|
|
||||||
|
export class PackageService {
|
||||||
|
static async createPackage(pkg: Package) {
|
||||||
|
const newPkg = await db.insert(packages).values(pkg).returning();
|
||||||
|
return newPkg[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getPackageById(id: string): Promise<Package | null> {
|
||||||
|
const [pkg] = await db.select()
|
||||||
|
.from(packages)
|
||||||
|
.where(eq(packages.id, id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return pkg || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static async getPackageByName(name: string): Promise<Package | null> {
|
||||||
|
const [pkg] = await db.select()
|
||||||
|
.from(packages)
|
||||||
|
.where(eq(packages.name, name))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return pkg || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getPackages(limit = 10, page = 1): Promise<Package[] | null> {
|
||||||
|
const data = await db.select()
|
||||||
|
.from(packages)
|
||||||
|
.limit(limit)
|
||||||
|
.offset((page - 1) * limit);
|
||||||
|
|
||||||
|
return data || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async updatePackage(id: string, pkg: Package): Promise<Package | null> {
|
||||||
|
const [updatedPkg] = await db.update(packages)
|
||||||
|
.set(pkg)
|
||||||
|
.where(eq(packages.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return updatedPkg || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deletePackage(id: string): Promise<Package | null> {
|
||||||
|
const [deletedPkg] = await db.delete(packages)
|
||||||
|
.where(eq(packages.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return deletedPkg || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
54
src/server/services/prducts.ts
Normal file
54
src/server/services/prducts.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db } from '../../database/connexion';
|
||||||
|
import { Product, products } from '../../database/schema/schema/products';
|
||||||
|
|
||||||
|
export class ProductService {
|
||||||
|
static async createProduct(product: Product) {
|
||||||
|
const newProduct = await db.insert(products).values(product).returning();
|
||||||
|
return newProduct[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getProductById(id: string): Promise<Product | null> {
|
||||||
|
const [product] = await db.select()
|
||||||
|
.from(products)
|
||||||
|
.where(eq(products.id, id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return product || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getProductByName(name: string): Promise<Product | null> {
|
||||||
|
const [product] = await db.select()
|
||||||
|
.from(products)
|
||||||
|
.where(eq(products.name, name))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return product || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getProducts(limit = 10, page = 1): Promise<Product[] | null> {
|
||||||
|
const data = await db.select()
|
||||||
|
.from(products)
|
||||||
|
.limit(limit)
|
||||||
|
.offset((page - 1) * limit);
|
||||||
|
|
||||||
|
return data || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async updateProduct(id: string, product: Product): Promise<Product | null> {
|
||||||
|
const [updatedProduct] = await db.update(products)
|
||||||
|
.set(product)
|
||||||
|
.where(eq(products.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return updatedProduct || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deleteProduct(id: string): Promise<Product | null> {
|
||||||
|
const [deletedProduct] = await db.delete(products)
|
||||||
|
.where(eq(products.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return deletedProduct || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
99
src/server/services/subscriptions.ts
Normal file
99
src/server/services/subscriptions.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
return newSub[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getSubscriptions(limit = 10, page = 1): Promise<Subscription[]> {
|
||||||
|
const data= await db.select()
|
||||||
|
.from(subscriptions)
|
||||||
|
.limit(limit)
|
||||||
|
.offset((page - 1) * limit);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async updateSubscriptionStatus(
|
||||||
|
subscriptionId: string,
|
||||||
|
status: 'active' | 'expired' | 'canceled'
|
||||||
|
): Promise<Subscription | null> {
|
||||||
|
const [updatedSubscription] = await db.update(subscriptions)
|
||||||
|
.set({
|
||||||
|
status,
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(subscriptions.id, subscriptionId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return updatedSubscription || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getSubscriptionByProtectKey(protectKey: string): Promise<Subscription | null> {
|
||||||
|
const [subscription] = await db.select()
|
||||||
|
.from(subscriptions)
|
||||||
|
.where(eq(subscriptions.protectKey, protectKey))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return subscription || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getSubscriptionById(id: string): Promise<Subscription | null> {
|
||||||
|
const [subscription] = await db.select()
|
||||||
|
.from(subscriptions)
|
||||||
|
.where(eq(subscriptions.id, id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return subscription || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getExpiredSubscriptions({
|
||||||
|
limit = 10,
|
||||||
|
page = 1,
|
||||||
|
}): Promise<Partial<Subscription>[]> {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const expiredSubscriptions = await db.select({
|
||||||
|
id: subscriptions.id,
|
||||||
|
protectKey: subscriptions.protectKey,
|
||||||
|
subscriber: subscriptions.subscriber,
|
||||||
|
startDate: subscriptions.startDate,
|
||||||
|
durationInMonths: subscriptions.durationInMonths,
|
||||||
|
endDate: subscriptions.endDate,
|
||||||
|
sentSmsCount: subscriptions.sentSmsCount,
|
||||||
|
status: subscriptions.status,
|
||||||
|
})
|
||||||
|
.from(subscriptions)
|
||||||
|
.where(lt(subscriptions.endDate, now))
|
||||||
|
.limit(limit)
|
||||||
|
.offset((page - 1) * limit);
|
||||||
|
|
||||||
|
return expiredSubscriptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async incrementSmsCount(protectKey: string): Promise<void> {
|
||||||
|
await db.update(subscriptions)
|
||||||
|
.set({
|
||||||
|
sentSmsCount: sql`${subscriptions.sentSmsCount} + 1`,
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(subscriptions.protectKey, protectKey));
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/web/App.tsx
Normal file
14
src/web/App.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
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>
|
||||||
|
}
|
||||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "hono/jsx"
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user