This commit is contained in:
DJIOTSA CHRISTIAN 2025-09-19 19:03:09 +01:00
parent 53abf15df2
commit 01c42bf9fc
32 changed files with 2934 additions and 0 deletions

9
.env Normal file
View 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
View 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
View File

@ -0,0 +1,2 @@
# deps
node_modules/

8
drizzle.config.json Normal file
View 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
View 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
View 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"
}
}

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

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

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

View File

@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1758304299810,
"tag": "0000_ordinary_micromax",
"breakpoints": true
}
]
}

View File

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

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

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

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

View 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
View 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;
}

View 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
View 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
View 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
View 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');

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

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

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

View File

@ -0,0 +1,8 @@
import { packagesSeed } from "./packages";
async function seed() {
console.log('🌱 Seeding database...');
await packagesSeed();
}
seed();

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

View 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;
}
}

View 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;
}
}

View 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
View 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
View File

@ -0,0 +1,7 @@
{
"compilerOptions": {
"strict": true,
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx"
}
}

1389
yarn.lock Normal file

File diff suppressed because it is too large Load Diff