servcraft 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +29 -0
- package/.github/CODEOWNERS +18 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +46 -0
- package/.github/dependabot.yml +59 -0
- package/.github/workflows/ci.yml +188 -0
- package/.github/workflows/release.yml +195 -0
- package/AUDIT.md +602 -0
- package/README.md +1070 -1
- package/dist/cli/index.cjs +2026 -2168
- package/dist/cli/index.cjs.map +1 -1
- package/dist/cli/index.js +2026 -2168
- package/dist/cli/index.js.map +1 -1
- package/dist/index.cjs +595 -616
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +114 -52
- package/dist/index.d.ts +114 -52
- package/dist/index.js +595 -616
- package/dist/index.js.map +1 -1
- package/docs/CLI-001_MULTI_DB_PLAN.md +546 -0
- package/docs/DATABASE_MULTI_ORM.md +399 -0
- package/docs/PHASE1_BREAKDOWN.md +346 -0
- package/docs/PROGRESS.md +550 -0
- package/docs/modules/ANALYTICS.md +226 -0
- package/docs/modules/API-VERSIONING.md +252 -0
- package/docs/modules/AUDIT.md +192 -0
- package/docs/modules/AUTH.md +431 -0
- package/docs/modules/CACHE.md +346 -0
- package/docs/modules/EMAIL.md +254 -0
- package/docs/modules/FEATURE-FLAG.md +291 -0
- package/docs/modules/I18N.md +294 -0
- package/docs/modules/MEDIA-PROCESSING.md +281 -0
- package/docs/modules/MFA.md +266 -0
- package/docs/modules/NOTIFICATION.md +311 -0
- package/docs/modules/OAUTH.md +237 -0
- package/docs/modules/PAYMENT.md +804 -0
- package/docs/modules/QUEUE.md +540 -0
- package/docs/modules/RATE-LIMIT.md +339 -0
- package/docs/modules/SEARCH.md +288 -0
- package/docs/modules/SECURITY.md +327 -0
- package/docs/modules/SESSION.md +382 -0
- package/docs/modules/SWAGGER.md +305 -0
- package/docs/modules/UPLOAD.md +296 -0
- package/docs/modules/USER.md +505 -0
- package/docs/modules/VALIDATION.md +294 -0
- package/docs/modules/WEBHOOK.md +270 -0
- package/docs/modules/WEBSOCKET.md +691 -0
- package/package.json +53 -38
- package/prisma/schema.prisma +395 -1
- package/src/cli/commands/add-module.ts +520 -87
- package/src/cli/commands/db.ts +3 -4
- package/src/cli/commands/docs.ts +256 -6
- package/src/cli/commands/generate.ts +12 -19
- package/src/cli/commands/init.ts +384 -214
- package/src/cli/index.ts +0 -4
- package/src/cli/templates/repository.ts +6 -1
- package/src/cli/templates/routes.ts +6 -21
- package/src/cli/utils/docs-generator.ts +6 -7
- package/src/cli/utils/env-manager.ts +717 -0
- package/src/cli/utils/field-parser.ts +16 -7
- package/src/cli/utils/interactive-prompt.ts +223 -0
- package/src/cli/utils/template-manager.ts +346 -0
- package/src/config/database.config.ts +183 -0
- package/src/config/env.ts +0 -10
- package/src/config/index.ts +0 -14
- package/src/core/server.ts +1 -1
- package/src/database/adapters/mongoose.adapter.ts +132 -0
- package/src/database/adapters/prisma.adapter.ts +118 -0
- package/src/database/connection.ts +190 -0
- package/src/database/interfaces/database.interface.ts +85 -0
- package/src/database/interfaces/index.ts +7 -0
- package/src/database/interfaces/repository.interface.ts +129 -0
- package/src/database/models/mongoose/index.ts +7 -0
- package/src/database/models/mongoose/payment.schema.ts +347 -0
- package/src/database/models/mongoose/user.schema.ts +154 -0
- package/src/database/prisma.ts +1 -4
- package/src/database/redis.ts +101 -0
- package/src/database/repositories/mongoose/index.ts +7 -0
- package/src/database/repositories/mongoose/payment.repository.ts +380 -0
- package/src/database/repositories/mongoose/user.repository.ts +255 -0
- package/src/database/seed.ts +6 -1
- package/src/index.ts +9 -20
- package/src/middleware/security.ts +2 -6
- package/src/modules/analytics/analytics.routes.ts +80 -0
- package/src/modules/analytics/analytics.service.ts +364 -0
- package/src/modules/analytics/index.ts +18 -0
- package/src/modules/analytics/types.ts +180 -0
- package/src/modules/api-versioning/index.ts +15 -0
- package/src/modules/api-versioning/types.ts +86 -0
- package/src/modules/api-versioning/versioning.middleware.ts +120 -0
- package/src/modules/api-versioning/versioning.routes.ts +54 -0
- package/src/modules/api-versioning/versioning.service.ts +189 -0
- package/src/modules/audit/audit.repository.ts +206 -0
- package/src/modules/audit/audit.service.ts +27 -59
- package/src/modules/auth/auth.controller.ts +2 -2
- package/src/modules/auth/auth.middleware.ts +3 -9
- package/src/modules/auth/auth.routes.ts +10 -107
- package/src/modules/auth/auth.service.ts +126 -23
- package/src/modules/auth/index.ts +3 -4
- package/src/modules/cache/cache.service.ts +367 -0
- package/src/modules/cache/index.ts +10 -0
- package/src/modules/cache/types.ts +44 -0
- package/src/modules/email/email.service.ts +3 -10
- package/src/modules/email/templates.ts +2 -8
- package/src/modules/feature-flag/feature-flag.repository.ts +303 -0
- package/src/modules/feature-flag/feature-flag.routes.ts +247 -0
- package/src/modules/feature-flag/feature-flag.service.ts +566 -0
- package/src/modules/feature-flag/index.ts +20 -0
- package/src/modules/feature-flag/types.ts +192 -0
- package/src/modules/i18n/i18n.middleware.ts +186 -0
- package/src/modules/i18n/i18n.routes.ts +191 -0
- package/src/modules/i18n/i18n.service.ts +456 -0
- package/src/modules/i18n/index.ts +18 -0
- package/src/modules/i18n/types.ts +118 -0
- package/src/modules/media-processing/index.ts +17 -0
- package/src/modules/media-processing/media-processing.routes.ts +111 -0
- package/src/modules/media-processing/media-processing.service.ts +245 -0
- package/src/modules/media-processing/types.ts +156 -0
- package/src/modules/mfa/index.ts +20 -0
- package/src/modules/mfa/mfa.repository.ts +206 -0
- package/src/modules/mfa/mfa.routes.ts +595 -0
- package/src/modules/mfa/mfa.service.ts +572 -0
- package/src/modules/mfa/totp.ts +150 -0
- package/src/modules/mfa/types.ts +57 -0
- package/src/modules/notification/index.ts +20 -0
- package/src/modules/notification/notification.repository.ts +356 -0
- package/src/modules/notification/notification.service.ts +483 -0
- package/src/modules/notification/types.ts +119 -0
- package/src/modules/oauth/index.ts +20 -0
- package/src/modules/oauth/oauth.repository.ts +219 -0
- package/src/modules/oauth/oauth.routes.ts +446 -0
- package/src/modules/oauth/oauth.service.ts +293 -0
- package/src/modules/oauth/providers/apple.provider.ts +250 -0
- package/src/modules/oauth/providers/facebook.provider.ts +181 -0
- package/src/modules/oauth/providers/github.provider.ts +248 -0
- package/src/modules/oauth/providers/google.provider.ts +189 -0
- package/src/modules/oauth/providers/twitter.provider.ts +214 -0
- package/src/modules/oauth/types.ts +94 -0
- package/src/modules/payment/index.ts +19 -0
- package/src/modules/payment/payment.repository.ts +733 -0
- package/src/modules/payment/payment.routes.ts +390 -0
- package/src/modules/payment/payment.service.ts +354 -0
- package/src/modules/payment/providers/mobile-money.provider.ts +274 -0
- package/src/modules/payment/providers/paypal.provider.ts +190 -0
- package/src/modules/payment/providers/stripe.provider.ts +215 -0
- package/src/modules/payment/types.ts +140 -0
- package/src/modules/queue/cron.ts +438 -0
- package/src/modules/queue/index.ts +87 -0
- package/src/modules/queue/queue.routes.ts +600 -0
- package/src/modules/queue/queue.service.ts +842 -0
- package/src/modules/queue/types.ts +222 -0
- package/src/modules/queue/workers.ts +366 -0
- package/src/modules/rate-limit/index.ts +59 -0
- package/src/modules/rate-limit/rate-limit.middleware.ts +134 -0
- package/src/modules/rate-limit/rate-limit.routes.ts +269 -0
- package/src/modules/rate-limit/rate-limit.service.ts +348 -0
- package/src/modules/rate-limit/stores/memory.store.ts +165 -0
- package/src/modules/rate-limit/stores/redis.store.ts +322 -0
- package/src/modules/rate-limit/types.ts +153 -0
- package/src/modules/search/adapters/elasticsearch.adapter.ts +326 -0
- package/src/modules/search/adapters/meilisearch.adapter.ts +261 -0
- package/src/modules/search/adapters/memory.adapter.ts +278 -0
- package/src/modules/search/index.ts +21 -0
- package/src/modules/search/search.service.ts +234 -0
- package/src/modules/search/types.ts +214 -0
- package/src/modules/security/index.ts +40 -0
- package/src/modules/security/sanitize.ts +223 -0
- package/src/modules/security/security-audit.service.ts +388 -0
- package/src/modules/security/security.middleware.ts +398 -0
- package/src/modules/session/index.ts +3 -0
- package/src/modules/session/session.repository.ts +159 -0
- package/src/modules/session/session.service.ts +340 -0
- package/src/modules/session/types.ts +38 -0
- package/src/modules/swagger/index.ts +7 -1
- package/src/modules/swagger/schema-builder.ts +16 -4
- package/src/modules/swagger/swagger.service.ts +9 -10
- package/src/modules/swagger/types.ts +0 -2
- package/src/modules/upload/index.ts +14 -0
- package/src/modules/upload/types.ts +83 -0
- package/src/modules/upload/upload.repository.ts +199 -0
- package/src/modules/upload/upload.routes.ts +311 -0
- package/src/modules/upload/upload.service.ts +448 -0
- package/src/modules/user/index.ts +3 -3
- package/src/modules/user/user.controller.ts +15 -9
- package/src/modules/user/user.repository.ts +237 -113
- package/src/modules/user/user.routes.ts +39 -164
- package/src/modules/user/user.service.ts +4 -3
- package/src/modules/validation/validator.ts +12 -17
- package/src/modules/webhook/index.ts +91 -0
- package/src/modules/webhook/retry.ts +196 -0
- package/src/modules/webhook/signature.ts +135 -0
- package/src/modules/webhook/types.ts +181 -0
- package/src/modules/webhook/webhook.repository.ts +358 -0
- package/src/modules/webhook/webhook.routes.ts +442 -0
- package/src/modules/webhook/webhook.service.ts +457 -0
- package/src/modules/websocket/features.ts +504 -0
- package/src/modules/websocket/index.ts +106 -0
- package/src/modules/websocket/middlewares.ts +298 -0
- package/src/modules/websocket/types.ts +181 -0
- package/src/modules/websocket/websocket.service.ts +692 -0
- package/src/utils/errors.ts +7 -0
- package/src/utils/pagination.ts +4 -1
- package/tests/helpers/db-check.ts +79 -0
- package/tests/integration/auth-redis.test.ts +94 -0
- package/tests/integration/cache-redis.test.ts +387 -0
- package/tests/integration/mongoose-repositories.test.ts +410 -0
- package/tests/integration/payment-prisma.test.ts +637 -0
- package/tests/integration/queue-bullmq.test.ts +417 -0
- package/tests/integration/user-prisma.test.ts +441 -0
- package/tests/integration/websocket-socketio.test.ts +552 -0
- package/tests/setup.ts +11 -9
- package/vitest.config.ts +3 -8
- package/npm-cache/_cacache/content-v2/sha512/1c/d0/03440d500a0487621aad1d6402978340698976602046db8e24fa03c01ee6c022c69b0582f969042d9442ee876ac35c038e960dd427d1e622fa24b8eb7dba +0 -0
- package/npm-cache/_cacache/content-v2/sha512/42/55/28b493ca491833e5aab0e9c3108d29ab3f36c248ca88f45d4630674fce9130959e56ae308797ac2b6328fa7f09a610b9550ed09cb971d039876d293fc69d +0 -0
- package/npm-cache/_cacache/content-v2/sha512/e0/12/f360dc9315ee5f17844a0c8c233ee6bf7c30837c4a02ea0d56c61c7f7ab21c0e958e50ed2c57c59f983c762b93056778c9009b2398ffc26def0183999b13 +0 -0
- package/npm-cache/_cacache/content-v2/sha512/ed/b0/fae1161902898f4c913c67d7f6cdf6be0665aec3b389b9c4f4f0a101ca1da59badf1b59c4e0030f5223023b8d63cfe501c46a32c20c895d4fb3f11ca2232 +0 -0
- package/npm-cache/_cacache/index-v5/58/94/c2cba79e0f16b4c10e95a87e32255741149e8222cc314a476aab67c39cc0 +0 -5
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mongoose User Schema
|
|
3
|
+
* MongoDB schema for User entity
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Schema, model, type Document } from 'mongoose';
|
|
7
|
+
import bcrypt from 'bcryptjs';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* User interface for Mongoose
|
|
11
|
+
*/
|
|
12
|
+
export interface IUserDocument extends Document {
|
|
13
|
+
email: string;
|
|
14
|
+
password: string;
|
|
15
|
+
name: string;
|
|
16
|
+
role: 'user' | 'moderator' | 'admin' | 'super_admin';
|
|
17
|
+
status: 'active' | 'inactive' | 'suspended' | 'pending';
|
|
18
|
+
emailVerified: boolean;
|
|
19
|
+
emailVerifiedAt?: Date;
|
|
20
|
+
lastLoginAt?: Date;
|
|
21
|
+
metadata?: Record<string, unknown>;
|
|
22
|
+
createdAt: Date;
|
|
23
|
+
updatedAt: Date;
|
|
24
|
+
|
|
25
|
+
// Methods
|
|
26
|
+
comparePassword(candidatePassword: string): Promise<boolean>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* User Schema
|
|
31
|
+
*/
|
|
32
|
+
const userSchema = new Schema<IUserDocument>(
|
|
33
|
+
{
|
|
34
|
+
email: {
|
|
35
|
+
type: String,
|
|
36
|
+
required: [true, 'Email is required'],
|
|
37
|
+
unique: true,
|
|
38
|
+
lowercase: true,
|
|
39
|
+
trim: true,
|
|
40
|
+
match: [/^\S+@\S+\.\S+$/, 'Please provide a valid email'],
|
|
41
|
+
index: true,
|
|
42
|
+
},
|
|
43
|
+
password: {
|
|
44
|
+
type: String,
|
|
45
|
+
required: [true, 'Password is required'],
|
|
46
|
+
minlength: [6, 'Password must be at least 6 characters'],
|
|
47
|
+
select: false, // Don't include password by default
|
|
48
|
+
},
|
|
49
|
+
name: {
|
|
50
|
+
type: String,
|
|
51
|
+
required: [true, 'Name is required'],
|
|
52
|
+
trim: true,
|
|
53
|
+
maxlength: [100, 'Name cannot exceed 100 characters'],
|
|
54
|
+
},
|
|
55
|
+
role: {
|
|
56
|
+
type: String,
|
|
57
|
+
enum: {
|
|
58
|
+
values: ['user', 'moderator', 'admin', 'super_admin'],
|
|
59
|
+
message: '{VALUE} is not a valid role',
|
|
60
|
+
},
|
|
61
|
+
default: 'user',
|
|
62
|
+
index: true,
|
|
63
|
+
},
|
|
64
|
+
status: {
|
|
65
|
+
type: String,
|
|
66
|
+
enum: {
|
|
67
|
+
values: ['active', 'inactive', 'suspended', 'pending'],
|
|
68
|
+
message: '{VALUE} is not a valid status',
|
|
69
|
+
},
|
|
70
|
+
default: 'active',
|
|
71
|
+
index: true,
|
|
72
|
+
},
|
|
73
|
+
emailVerified: {
|
|
74
|
+
type: Boolean,
|
|
75
|
+
default: false,
|
|
76
|
+
},
|
|
77
|
+
emailVerifiedAt: {
|
|
78
|
+
type: Date,
|
|
79
|
+
default: null,
|
|
80
|
+
},
|
|
81
|
+
lastLoginAt: {
|
|
82
|
+
type: Date,
|
|
83
|
+
default: null,
|
|
84
|
+
},
|
|
85
|
+
metadata: {
|
|
86
|
+
type: Schema.Types.Mixed,
|
|
87
|
+
default: null,
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
timestamps: true, // Automatically add createdAt and updatedAt
|
|
92
|
+
collection: 'users',
|
|
93
|
+
toJSON: {
|
|
94
|
+
virtuals: true,
|
|
95
|
+
transform: (_doc, ret): Record<string, unknown> => {
|
|
96
|
+
const id = ret._id.toString();
|
|
97
|
+
const { password, _id: mongoId, __v, ...rest } = ret;
|
|
98
|
+
void password; // Intentionally excluded from output
|
|
99
|
+
void mongoId;
|
|
100
|
+
void __v;
|
|
101
|
+
return { id, ...rest };
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
toObject: {
|
|
105
|
+
virtuals: true,
|
|
106
|
+
transform: (_doc, ret): Record<string, unknown> => {
|
|
107
|
+
const { _id, ...rest } = ret;
|
|
108
|
+
return { id: _id.toString(), ...rest };
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Pre-save middleware to hash password
|
|
116
|
+
*/
|
|
117
|
+
userSchema.pre('save', async function (next) {
|
|
118
|
+
// Only hash the password if it has been modified (or is new)
|
|
119
|
+
if (!this.isModified('password')) {
|
|
120
|
+
return next();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
const salt = await bcrypt.genSalt(10);
|
|
125
|
+
this.password = await bcrypt.hash(this.password, salt);
|
|
126
|
+
next();
|
|
127
|
+
} catch (error) {
|
|
128
|
+
next(error as Error);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Method to compare password
|
|
134
|
+
*/
|
|
135
|
+
userSchema.methods.comparePassword = async function (candidatePassword: string): Promise<boolean> {
|
|
136
|
+
try {
|
|
137
|
+
return await bcrypt.compare(candidatePassword, this.password);
|
|
138
|
+
} catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Indexes for better query performance
|
|
145
|
+
* Note: email index is already defined via unique: true in schema
|
|
146
|
+
* role and status indexes are defined via index: true in schema
|
|
147
|
+
*/
|
|
148
|
+
userSchema.index({ role: 1, status: 1 }); // Compound index for filtering
|
|
149
|
+
userSchema.index({ createdAt: -1 }); // For sorting by creation date
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* User Model
|
|
153
|
+
*/
|
|
154
|
+
export const UserModel = model<IUserDocument>('User', userSchema);
|
package/src/database/prisma.ts
CHANGED
|
@@ -3,15 +3,12 @@ import { logger } from '../core/logger.js';
|
|
|
3
3
|
import { isProduction } from '../config/index.js';
|
|
4
4
|
|
|
5
5
|
declare global {
|
|
6
|
-
// eslint-disable-next-line no-var
|
|
7
6
|
var __prisma: PrismaClient | undefined;
|
|
8
7
|
}
|
|
9
8
|
|
|
10
9
|
const prismaClientSingleton = (): PrismaClient => {
|
|
11
10
|
return new PrismaClient({
|
|
12
|
-
log: isProduction()
|
|
13
|
-
? ['error']
|
|
14
|
-
: ['query', 'info', 'warn', 'error'],
|
|
11
|
+
log: isProduction() ? ['error'] : ['query', 'info', 'warn', 'error'],
|
|
15
12
|
errorFormat: isProduction() ? 'minimal' : 'pretty',
|
|
16
13
|
});
|
|
17
14
|
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redis Client Module
|
|
3
|
+
* Shared Redis connection for all services
|
|
4
|
+
*/
|
|
5
|
+
import { Redis } from 'ioredis';
|
|
6
|
+
import { logger } from '../core/logger.js';
|
|
7
|
+
|
|
8
|
+
export interface RedisConfig {
|
|
9
|
+
host: string;
|
|
10
|
+
port: number;
|
|
11
|
+
password?: string;
|
|
12
|
+
db?: number;
|
|
13
|
+
tls?: boolean;
|
|
14
|
+
connectTimeout?: number;
|
|
15
|
+
maxRetries?: number;
|
|
16
|
+
keyPrefix?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let redisClient: Redis | null = null;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Initialize the Redis connection
|
|
23
|
+
*/
|
|
24
|
+
export function initRedis(config: RedisConfig): Redis {
|
|
25
|
+
if (redisClient) {
|
|
26
|
+
return redisClient;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
redisClient = new Redis({
|
|
30
|
+
host: config.host,
|
|
31
|
+
port: config.port,
|
|
32
|
+
password: config.password,
|
|
33
|
+
db: config.db || 0,
|
|
34
|
+
keyPrefix: config.keyPrefix,
|
|
35
|
+
connectTimeout: config.connectTimeout || 10000,
|
|
36
|
+
retryStrategy: (times: number) => {
|
|
37
|
+
const maxRetries = config.maxRetries || 10;
|
|
38
|
+
if (times > maxRetries) {
|
|
39
|
+
logger.error('Redis max retries reached, giving up');
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
const delay = Math.min(times * 50, 2000);
|
|
43
|
+
return delay;
|
|
44
|
+
},
|
|
45
|
+
...(config.tls && { tls: {} }),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
redisClient.on('connect', () => {
|
|
49
|
+
logger.info({ host: config.host, port: config.port }, 'Redis connected');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
redisClient.on('error', (error: Error) => {
|
|
53
|
+
logger.error({ err: error }, 'Redis error');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
redisClient.on('close', () => {
|
|
57
|
+
logger.warn('Redis connection closed');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
redisClient.on('reconnecting', () => {
|
|
61
|
+
logger.info('Redis reconnecting');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return redisClient;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get the Redis client instance
|
|
69
|
+
* Creates a default connection if not initialized
|
|
70
|
+
*/
|
|
71
|
+
export function getRedis(): Redis {
|
|
72
|
+
if (!redisClient) {
|
|
73
|
+
// Initialize with default/env config
|
|
74
|
+
const config: RedisConfig = {
|
|
75
|
+
host: process.env.REDIS_HOST || 'localhost',
|
|
76
|
+
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
|
77
|
+
password: process.env.REDIS_PASSWORD,
|
|
78
|
+
db: parseInt(process.env.REDIS_DB || '0', 10),
|
|
79
|
+
};
|
|
80
|
+
return initRedis(config);
|
|
81
|
+
}
|
|
82
|
+
return redisClient;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Close the Redis connection
|
|
87
|
+
*/
|
|
88
|
+
export async function closeRedis(): Promise<void> {
|
|
89
|
+
if (redisClient) {
|
|
90
|
+
await redisClient.quit();
|
|
91
|
+
redisClient = null;
|
|
92
|
+
logger.info('Redis connection closed');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Check if Redis is connected
|
|
98
|
+
*/
|
|
99
|
+
export function isRedisConnected(): boolean {
|
|
100
|
+
return redisClient?.status === 'ready';
|
|
101
|
+
}
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mongoose Payment Repository
|
|
3
|
+
* Implements payment operations using Mongoose
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
PaymentModel,
|
|
8
|
+
SubscriptionModel,
|
|
9
|
+
PlanModel,
|
|
10
|
+
WebhookModel,
|
|
11
|
+
type IPaymentDocument,
|
|
12
|
+
type ISubscriptionDocument,
|
|
13
|
+
type IPlanDocument,
|
|
14
|
+
} from '../../models/mongoose/payment.schema.js';
|
|
15
|
+
import type { IRepository, PaginationOptions, PaginatedResult } from '../../interfaces/index.js';
|
|
16
|
+
|
|
17
|
+
// Payment types matching application format
|
|
18
|
+
export interface Payment {
|
|
19
|
+
id: string;
|
|
20
|
+
userId: string;
|
|
21
|
+
provider: 'stripe' | 'paypal' | 'mobile_money' | 'manual';
|
|
22
|
+
method: 'card' | 'bank_transfer' | 'mobile_money' | 'paypal' | 'other';
|
|
23
|
+
status: 'pending' | 'processing' | 'completed' | 'failed' | 'refunded' | 'cancelled';
|
|
24
|
+
amount: number;
|
|
25
|
+
currency: string;
|
|
26
|
+
description?: string | null;
|
|
27
|
+
providerPaymentId?: string | null;
|
|
28
|
+
providerCustomerId?: string | null;
|
|
29
|
+
metadata?: Record<string, unknown> | null;
|
|
30
|
+
failureReason?: string | null;
|
|
31
|
+
refundedAt?: Date | null;
|
|
32
|
+
completedAt?: Date | null;
|
|
33
|
+
createdAt: Date;
|
|
34
|
+
updatedAt: Date;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface Subscription {
|
|
38
|
+
id: string;
|
|
39
|
+
userId: string;
|
|
40
|
+
planId: string;
|
|
41
|
+
provider: 'stripe' | 'paypal' | 'mobile_money' | 'manual';
|
|
42
|
+
status: 'active' | 'cancelled' | 'expired' | 'suspended' | 'trialing';
|
|
43
|
+
currentPeriodStart: Date;
|
|
44
|
+
currentPeriodEnd: Date;
|
|
45
|
+
cancelledAt?: Date | null;
|
|
46
|
+
providerSubscriptionId?: string | null;
|
|
47
|
+
metadata?: Record<string, unknown> | null;
|
|
48
|
+
createdAt: Date;
|
|
49
|
+
updatedAt: Date;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface Plan {
|
|
53
|
+
id: string;
|
|
54
|
+
name: string;
|
|
55
|
+
amount: number;
|
|
56
|
+
currency: string;
|
|
57
|
+
interval: 'day' | 'week' | 'month' | 'year';
|
|
58
|
+
intervalCount: number;
|
|
59
|
+
trialPeriodDays?: number | null;
|
|
60
|
+
active: boolean;
|
|
61
|
+
metadata?: Record<string, unknown> | null;
|
|
62
|
+
createdAt: Date;
|
|
63
|
+
updatedAt: Date;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Mongoose Payment Repository
|
|
68
|
+
*/
|
|
69
|
+
export class MongoosePaymentRepository implements IRepository<Payment> {
|
|
70
|
+
// ==========================================
|
|
71
|
+
// PAYMENT METHODS
|
|
72
|
+
// ==========================================
|
|
73
|
+
|
|
74
|
+
private toPayment(doc: IPaymentDocument): Payment {
|
|
75
|
+
return {
|
|
76
|
+
id: doc._id.toString(),
|
|
77
|
+
userId: doc.userId,
|
|
78
|
+
provider: doc.provider,
|
|
79
|
+
method: doc.method,
|
|
80
|
+
status: doc.status,
|
|
81
|
+
amount: doc.amount,
|
|
82
|
+
currency: doc.currency,
|
|
83
|
+
description: doc.description || null,
|
|
84
|
+
providerPaymentId: doc.providerPaymentId || null,
|
|
85
|
+
providerCustomerId: doc.providerCustomerId || null,
|
|
86
|
+
metadata: doc.metadata || null,
|
|
87
|
+
failureReason: doc.failureReason || null,
|
|
88
|
+
refundedAt: doc.refundedAt || null,
|
|
89
|
+
completedAt: doc.completedAt || null,
|
|
90
|
+
createdAt: doc.createdAt,
|
|
91
|
+
updatedAt: doc.updatedAt,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async findById(id: string): Promise<Payment | null> {
|
|
96
|
+
try {
|
|
97
|
+
const payment = await PaymentModel.findById(id);
|
|
98
|
+
return payment ? this.toPayment(payment) : null;
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async findOne(filter: Record<string, unknown>): Promise<Payment | null> {
|
|
105
|
+
try {
|
|
106
|
+
const payment = await PaymentModel.findOne(filter);
|
|
107
|
+
return payment ? this.toPayment(payment) : null;
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async findMany(
|
|
114
|
+
filter: Record<string, unknown> = {},
|
|
115
|
+
options: PaginationOptions = {}
|
|
116
|
+
): Promise<PaginatedResult<Payment>> {
|
|
117
|
+
const page = options.page || 1;
|
|
118
|
+
const limit = options.limit || 10;
|
|
119
|
+
const skip = (page - 1) * limit;
|
|
120
|
+
|
|
121
|
+
const sortBy = options.sortBy || 'createdAt';
|
|
122
|
+
const sortOrder = options.sortOrder === 'asc' ? 1 : -1;
|
|
123
|
+
const sort: Record<string, 1 | -1> = { [sortBy]: sortOrder };
|
|
124
|
+
|
|
125
|
+
const [payments, total] = await Promise.all([
|
|
126
|
+
PaymentModel.find(filter).sort(sort).skip(skip).limit(limit).exec(),
|
|
127
|
+
PaymentModel.countDocuments(filter),
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
const totalPages = Math.ceil(total / limit);
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
data: payments.map((p) => this.toPayment(p)),
|
|
134
|
+
pagination: {
|
|
135
|
+
page,
|
|
136
|
+
limit,
|
|
137
|
+
total,
|
|
138
|
+
totalPages,
|
|
139
|
+
hasNext: page < totalPages,
|
|
140
|
+
hasPrev: page > 1,
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async create(data: Partial<Payment>): Promise<Payment> {
|
|
146
|
+
const payment = await PaymentModel.create(data);
|
|
147
|
+
return this.toPayment(payment);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async update(id: string, data: Partial<Payment>): Promise<Payment | null> {
|
|
151
|
+
try {
|
|
152
|
+
const payment = await PaymentModel.findByIdAndUpdate(
|
|
153
|
+
id,
|
|
154
|
+
{ $set: data },
|
|
155
|
+
{ new: true, runValidators: true }
|
|
156
|
+
);
|
|
157
|
+
return payment ? this.toPayment(payment) : null;
|
|
158
|
+
} catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async delete(id: string): Promise<boolean> {
|
|
164
|
+
try {
|
|
165
|
+
const result = await PaymentModel.findByIdAndDelete(id);
|
|
166
|
+
return result !== null;
|
|
167
|
+
} catch {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async count(filter: Record<string, unknown> = {}): Promise<number> {
|
|
173
|
+
return PaymentModel.countDocuments(filter);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async exists(id: string): Promise<boolean> {
|
|
177
|
+
const count = await PaymentModel.countDocuments({ _id: id });
|
|
178
|
+
return count > 0;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Find payment by provider payment ID
|
|
183
|
+
*/
|
|
184
|
+
async findByProviderPaymentId(providerPaymentId: string): Promise<Payment | null> {
|
|
185
|
+
return this.findOne({ providerPaymentId });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Find payments by user ID
|
|
190
|
+
*/
|
|
191
|
+
async findByUserId(
|
|
192
|
+
userId: string,
|
|
193
|
+
options: PaginationOptions = {}
|
|
194
|
+
): Promise<PaginatedResult<Payment>> {
|
|
195
|
+
return this.findMany({ userId }, options);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Update payment status
|
|
200
|
+
*/
|
|
201
|
+
async updateStatus(
|
|
202
|
+
id: string,
|
|
203
|
+
status: Payment['status'],
|
|
204
|
+
data?: Partial<Payment>
|
|
205
|
+
): Promise<Payment | null> {
|
|
206
|
+
const updateData: Partial<Payment> = { status, ...data };
|
|
207
|
+
|
|
208
|
+
if (status === 'completed' && !data?.completedAt) {
|
|
209
|
+
updateData.completedAt = new Date();
|
|
210
|
+
}
|
|
211
|
+
if (status === 'refunded' && !data?.refundedAt) {
|
|
212
|
+
updateData.refundedAt = new Date();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return this.update(id, updateData);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ==========================================
|
|
219
|
+
// SUBSCRIPTION METHODS
|
|
220
|
+
// ==========================================
|
|
221
|
+
|
|
222
|
+
private toSubscription(doc: ISubscriptionDocument): Subscription {
|
|
223
|
+
return {
|
|
224
|
+
id: doc._id.toString(),
|
|
225
|
+
userId: doc.userId,
|
|
226
|
+
planId: doc.planId,
|
|
227
|
+
provider: doc.provider,
|
|
228
|
+
status: doc.status,
|
|
229
|
+
currentPeriodStart: doc.currentPeriodStart,
|
|
230
|
+
currentPeriodEnd: doc.currentPeriodEnd,
|
|
231
|
+
cancelledAt: doc.cancelledAt || null,
|
|
232
|
+
providerSubscriptionId: doc.providerSubscriptionId || null,
|
|
233
|
+
metadata: doc.metadata || null,
|
|
234
|
+
createdAt: doc.createdAt,
|
|
235
|
+
updatedAt: doc.updatedAt,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async createSubscription(data: Partial<Subscription>): Promise<Subscription> {
|
|
240
|
+
const subscription = await SubscriptionModel.create(data);
|
|
241
|
+
return this.toSubscription(subscription);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async findSubscriptionById(id: string): Promise<Subscription | null> {
|
|
245
|
+
try {
|
|
246
|
+
const subscription = await SubscriptionModel.findById(id);
|
|
247
|
+
return subscription ? this.toSubscription(subscription) : null;
|
|
248
|
+
} catch {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async findSubscriptionsByUserId(
|
|
254
|
+
userId: string,
|
|
255
|
+
options: PaginationOptions = {}
|
|
256
|
+
): Promise<PaginatedResult<Subscription>> {
|
|
257
|
+
const page = options.page || 1;
|
|
258
|
+
const limit = options.limit || 10;
|
|
259
|
+
const skip = (page - 1) * limit;
|
|
260
|
+
|
|
261
|
+
const [subscriptions, total] = await Promise.all([
|
|
262
|
+
SubscriptionModel.find({ userId }).sort({ createdAt: -1 }).skip(skip).limit(limit).exec(),
|
|
263
|
+
SubscriptionModel.countDocuments({ userId }),
|
|
264
|
+
]);
|
|
265
|
+
|
|
266
|
+
const totalPages = Math.ceil(total / limit);
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
data: subscriptions.map((s) => this.toSubscription(s)),
|
|
270
|
+
pagination: {
|
|
271
|
+
page,
|
|
272
|
+
limit,
|
|
273
|
+
total,
|
|
274
|
+
totalPages,
|
|
275
|
+
hasNext: page < totalPages,
|
|
276
|
+
hasPrev: page > 1,
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async updateSubscription(id: string, data: Partial<Subscription>): Promise<Subscription | null> {
|
|
282
|
+
try {
|
|
283
|
+
const subscription = await SubscriptionModel.findByIdAndUpdate(
|
|
284
|
+
id,
|
|
285
|
+
{ $set: data },
|
|
286
|
+
{ new: true, runValidators: true }
|
|
287
|
+
);
|
|
288
|
+
return subscription ? this.toSubscription(subscription) : null;
|
|
289
|
+
} catch {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async cancelSubscription(id: string): Promise<Subscription | null> {
|
|
295
|
+
return this.updateSubscription(id, {
|
|
296
|
+
status: 'cancelled',
|
|
297
|
+
cancelledAt: new Date(),
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ==========================================
|
|
302
|
+
// PLAN METHODS
|
|
303
|
+
// ==========================================
|
|
304
|
+
|
|
305
|
+
private toPlan(doc: IPlanDocument): Plan {
|
|
306
|
+
return {
|
|
307
|
+
id: doc._id.toString(),
|
|
308
|
+
name: doc.name,
|
|
309
|
+
amount: doc.amount,
|
|
310
|
+
currency: doc.currency,
|
|
311
|
+
interval: doc.interval,
|
|
312
|
+
intervalCount: doc.intervalCount,
|
|
313
|
+
trialPeriodDays: doc.trialPeriodDays || null,
|
|
314
|
+
active: doc.active,
|
|
315
|
+
metadata: doc.metadata || null,
|
|
316
|
+
createdAt: doc.createdAt,
|
|
317
|
+
updatedAt: doc.updatedAt,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async createPlan(data: Partial<Plan>): Promise<Plan> {
|
|
322
|
+
const plan = await PlanModel.create(data);
|
|
323
|
+
return this.toPlan(plan);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async findPlanById(id: string): Promise<Plan | null> {
|
|
327
|
+
try {
|
|
328
|
+
const plan = await PlanModel.findById(id);
|
|
329
|
+
return plan ? this.toPlan(plan) : null;
|
|
330
|
+
} catch {
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async findActivePlans(): Promise<Plan[]> {
|
|
336
|
+
const plans = await PlanModel.find({ active: true }).sort({ createdAt: -1 });
|
|
337
|
+
return plans.map((p) => this.toPlan(p));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async updatePlan(id: string, data: Partial<Plan>): Promise<Plan | null> {
|
|
341
|
+
try {
|
|
342
|
+
const plan = await PlanModel.findByIdAndUpdate(
|
|
343
|
+
id,
|
|
344
|
+
{ $set: data },
|
|
345
|
+
{ new: true, runValidators: true }
|
|
346
|
+
);
|
|
347
|
+
return plan ? this.toPlan(plan) : null;
|
|
348
|
+
} catch {
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ==========================================
|
|
354
|
+
// WEBHOOK METHODS
|
|
355
|
+
// ==========================================
|
|
356
|
+
|
|
357
|
+
async storeWebhookEvent(data: {
|
|
358
|
+
provider: 'stripe' | 'paypal' | 'mobile_money' | 'manual';
|
|
359
|
+
type: string;
|
|
360
|
+
data: Record<string, unknown>;
|
|
361
|
+
}): Promise<{ id: string }> {
|
|
362
|
+
const webhook = await WebhookModel.create(data);
|
|
363
|
+
return { id: webhook._id.toString() };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async markWebhookProcessed(id: string, error?: string): Promise<boolean> {
|
|
367
|
+
try {
|
|
368
|
+
const result = await WebhookModel.findByIdAndUpdate(id, {
|
|
369
|
+
$set: {
|
|
370
|
+
processed: true,
|
|
371
|
+
processedAt: new Date(),
|
|
372
|
+
...(error && { error }),
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
return result !== null;
|
|
376
|
+
} catch {
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|