spaps 0.6.0 → 0.7.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/AI_TOOLS.json +1 -1
- package/README.md +18 -18
- package/client.js +1 -1
- package/package.json +3 -8
- package/src/ai-helper.js +1 -1
- package/src/cli-dispatcher.js +32 -18
- package/src/config.js +1 -1
- package/src/docs-html.js +1 -1
- package/src/docs-system.js +8 -8
- package/src/doctor.js +4 -4
- package/src/error-handler.js +6 -6
- package/src/handlers.js +33 -24
- package/src/help-system.js +6 -6
- package/src/local-server.js +267 -1630
- package/src/admin-local.js +0 -408
- package/src/stripe-local.js +0 -263
package/src/local-server.js
CHANGED
|
@@ -1,1717 +1,350 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* SPAPS Local Development Server
|
|
5
|
-
*
|
|
4
|
+
* SPAPS Local Development Server - Docker Compose Orchestrator
|
|
5
|
+
* Manages the real Python/FastAPI SPAPS server via docker-compose.spaps-dev.yml
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
const
|
|
9
|
-
const cors = require('cors');
|
|
8
|
+
const { spawn, execSync } = require('child_process');
|
|
10
9
|
const chalk = require('chalk');
|
|
11
|
-
const
|
|
10
|
+
const axios = require('axios');
|
|
12
11
|
const path = require('path');
|
|
13
|
-
const
|
|
14
|
-
let swaggerUiDist = null;
|
|
15
|
-
try { swaggerUiDist = require('swagger-ui-dist'); } catch {}
|
|
16
|
-
const StripeLocalManager = require('./stripe-local');
|
|
17
|
-
const LocalAdminManager = require('./admin-local');
|
|
18
|
-
|
|
19
|
-
// Stripe configuration for test mode
|
|
20
|
-
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY || 'sk_test_51S1WOy2HT0E1dOewiHvzt7T96PDwjocSDDUuc2ur569AVA5fDj4UpNM66lujrda1tTYrgooG0Z1dNFZfwEZuZdcA00nuVLJW67');
|
|
21
|
-
const STRIPE_PUBLISHABLE_KEY = process.env.STRIPE_PUBLISHABLE_KEY || 'pk_test_51S1WOy2HT0E1dOewb2EkxZIaPkz7v3zMM9VxuBoxgNILYMmS85I4zrAWTkevyUQcaWlWUoC2NYnB8X5ZKd5e7Ifc005IzIW6H2';
|
|
12
|
+
const fs = require('fs');
|
|
22
13
|
|
|
23
14
|
class LocalServer {
|
|
24
15
|
constructor(options = {}) {
|
|
25
|
-
this.port = options.port ||
|
|
16
|
+
this.port = options.port || 3301; // Dev API external port
|
|
26
17
|
this.json = options.json || false;
|
|
27
|
-
this.
|
|
28
|
-
this.
|
|
29
|
-
this.
|
|
30
|
-
this.
|
|
31
|
-
this.
|
|
32
|
-
this.
|
|
33
|
-
this.
|
|
34
|
-
this.
|
|
35
|
-
this.setupAdminRoutes();
|
|
36
|
-
this.setupCatchAll();
|
|
37
|
-
|
|
38
|
-
// Optional demo seeding (idempotent)
|
|
39
|
-
if (this.seedMode === 'demo') {
|
|
40
|
-
try {
|
|
41
|
-
this.seedDemoData();
|
|
42
|
-
} catch (e) {
|
|
43
|
-
if (!this.json) console.warn(chalk.yellow(`⚠️ Seed failed: ${e.message}`));
|
|
44
|
-
}
|
|
45
|
-
}
|
|
18
|
+
this.detach = options.detach || false;
|
|
19
|
+
this.fresh = options.fresh || false;
|
|
20
|
+
this.fromBackup = options.fromBackup || null;
|
|
21
|
+
this.repoRoot = this.findRepoRoot();
|
|
22
|
+
this.composeFile = path.join(this.repoRoot, 'docker-compose.spaps-dev.yml');
|
|
23
|
+
this.apiUrl = `http://localhost:${this.port}`;
|
|
24
|
+
this.healthUrl = `${this.apiUrl}/health`;
|
|
25
|
+
this.logProcess = null;
|
|
46
26
|
}
|
|
47
27
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
// Serve Swagger UI assets locally if available
|
|
62
|
-
if (swaggerUiDist && typeof swaggerUiDist.getAbsoluteFSPath === 'function') {
|
|
63
|
-
const uiPath = swaggerUiDist.getAbsoluteFSPath();
|
|
64
|
-
this.app.use('/swagger-ui', express.static(uiPath));
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// User IDs must match production for testing against prod data
|
|
68
|
-
const LOCAL_PERSONAS = {
|
|
69
|
-
admin: {
|
|
70
|
-
id: '5bdb0db2-5ab1-4e2c-999b-1153cc329477', // Real prod super admin ID
|
|
71
|
-
email: 'buildooor@gmail.com',
|
|
72
|
-
role: 'admin'
|
|
73
|
-
},
|
|
74
|
-
user: {
|
|
75
|
-
id: '00000000-0000-0000-0000-000000000002',
|
|
76
|
-
email: 'dev@localhost',
|
|
77
|
-
role: 'user'
|
|
78
|
-
},
|
|
79
|
-
premium: {
|
|
80
|
-
id: '00000000-0000-0000-0000-000000000003',
|
|
81
|
-
email: 'premium@localhost',
|
|
82
|
-
role: 'user'
|
|
28
|
+
/**
|
|
29
|
+
* Find the sweet-potato repo root by walking up from __dirname
|
|
30
|
+
*/
|
|
31
|
+
findRepoRoot() {
|
|
32
|
+
let current = __dirname;
|
|
33
|
+
const maxDepth = 10;
|
|
34
|
+
let depth = 0;
|
|
35
|
+
|
|
36
|
+
while (depth < maxDepth) {
|
|
37
|
+
const candidate = path.join(current, 'docker-compose.spaps-dev.yml');
|
|
38
|
+
if (fs.existsSync(candidate)) {
|
|
39
|
+
return current;
|
|
83
40
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
this.localPersonas = LOCAL_PERSONAS;
|
|
88
|
-
|
|
89
|
-
// Local mode indicator
|
|
90
|
-
this.app.use((req, res, next) => {
|
|
91
|
-
res.setHeader('X-SPAPS-Mode', 'local-development');
|
|
92
|
-
|
|
93
|
-
// Determine persona from query/header (always, regardless of auth header)
|
|
94
|
-
const persona = req.query._user || req.headers['x-test-user'] || 'user';
|
|
95
|
-
req.localPersona = persona;
|
|
96
|
-
|
|
97
|
-
// Auto-auth in local mode - always set user based on persona
|
|
98
|
-
if (!req.headers['x-api-key']) {
|
|
99
|
-
req.headers['x-api-key'] = 'local-dev-key';
|
|
41
|
+
const parent = path.dirname(current);
|
|
42
|
+
if (parent === current) {
|
|
43
|
+
break; // Reached filesystem root
|
|
100
44
|
}
|
|
45
|
+
current = parent;
|
|
46
|
+
depth++;
|
|
47
|
+
}
|
|
101
48
|
|
|
102
|
-
|
|
103
|
-
req.user = LOCAL_PERSONAS[persona] || LOCAL_PERSONAS.user;
|
|
104
|
-
|
|
105
|
-
// Log requests (unless in JSON mode)
|
|
106
|
-
if (!this.json) {
|
|
107
|
-
console.log(chalk.dim(`${req.method} ${req.path} [${persona}]`));
|
|
108
|
-
}
|
|
109
|
-
next();
|
|
110
|
-
});
|
|
49
|
+
throw new Error('Could not find sweet-potato repo root (docker-compose.spaps-dev.yml not found)');
|
|
111
50
|
}
|
|
112
51
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Check if docker compose is available
|
|
54
|
+
*/
|
|
55
|
+
checkDockerCompose() {
|
|
56
|
+
try {
|
|
57
|
+
execSync('docker compose version', { stdio: 'ignore' });
|
|
58
|
+
return 'docker compose';
|
|
59
|
+
} catch {
|
|
116
60
|
try {
|
|
117
|
-
|
|
118
|
-
return
|
|
119
|
-
} catch
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
// Health check
|
|
125
|
-
this.app.get('/health', (req, res) => {
|
|
126
|
-
res.json({
|
|
127
|
-
status: 'healthy',
|
|
128
|
-
mode: 'local-development',
|
|
129
|
-
version: '0.2.0',
|
|
130
|
-
timestamp: new Date().toISOString()
|
|
131
|
-
});
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
// Local mode status
|
|
135
|
-
this.app.get('/health/local-mode', (req, res) => {
|
|
136
|
-
res.json({
|
|
137
|
-
enabled: true,
|
|
138
|
-
environment: 'local-development',
|
|
139
|
-
features: {
|
|
140
|
-
autoAuth: true,
|
|
141
|
-
corsEnabled: true,
|
|
142
|
-
testUsers: ['user', 'admin', 'premium'],
|
|
143
|
-
apiKeyRequired: false
|
|
144
|
-
}
|
|
145
|
-
});
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
// Mock authentication endpoints
|
|
149
|
-
this.app.post('/api/auth/login', (req, res) => {
|
|
150
|
-
const { email } = req.body;
|
|
151
|
-
// Use consistent user from middleware (set by persona)
|
|
152
|
-
res.json({
|
|
153
|
-
success: true,
|
|
154
|
-
data: {
|
|
155
|
-
access_token: 'local-jwt-token-' + Date.now(),
|
|
156
|
-
refresh_token: 'local-refresh-token-' + Date.now(),
|
|
157
|
-
user: {
|
|
158
|
-
id: req.user.id,
|
|
159
|
-
email: email || req.user.email,
|
|
160
|
-
role: req.user.role
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
});
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
this.app.post('/api/auth/register', (req, res) => {
|
|
167
|
-
const { email } = req.body;
|
|
168
|
-
// Use consistent user from middleware (set by persona)
|
|
169
|
-
res.json({
|
|
170
|
-
success: true,
|
|
171
|
-
data: {
|
|
172
|
-
access_token: 'local-jwt-token-' + Date.now(),
|
|
173
|
-
refresh_token: 'local-refresh-token-' + Date.now(),
|
|
174
|
-
user: {
|
|
175
|
-
id: req.user.id,
|
|
176
|
-
email: email || req.user.email,
|
|
177
|
-
role: req.user.role
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
});
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
this.app.post('/api/auth/wallet-sign-in', (req, res) => {
|
|
184
|
-
const { wallet_address, chain_type } = req.body;
|
|
185
|
-
// Use consistent user from middleware (set by persona)
|
|
186
|
-
res.json({
|
|
187
|
-
success: true,
|
|
188
|
-
data: {
|
|
189
|
-
access_token: 'local-jwt-token-' + Date.now(),
|
|
190
|
-
refresh_token: 'local-refresh-token-' + Date.now(),
|
|
191
|
-
user: {
|
|
192
|
-
id: req.user.id,
|
|
193
|
-
wallet_address,
|
|
194
|
-
chain_type,
|
|
195
|
-
role: req.user.role
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
});
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
this.app.post('/api/auth/refresh', (req, res) => {
|
|
202
|
-
res.json({
|
|
203
|
-
success: true,
|
|
204
|
-
data: {
|
|
205
|
-
access_token: 'local-jwt-token-refreshed-' + Date.now(),
|
|
206
|
-
refresh_token: 'local-refresh-token-refreshed-' + Date.now()
|
|
207
|
-
}
|
|
208
|
-
});
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
this.app.post('/api/auth/logout', (req, res) => {
|
|
212
|
-
res.json({ success: true, message: 'Logged out successfully' });
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
this.app.get('/api/auth/user', (req, res) => {
|
|
216
|
-
// req.user is always set by the middleware based on persona
|
|
217
|
-
res.json({
|
|
218
|
-
id: req.user.id,
|
|
219
|
-
email: req.user.email,
|
|
220
|
-
role: req.user.role,
|
|
221
|
-
created_at: new Date().toISOString()
|
|
222
|
-
});
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
// Stripe checkout sessions endpoint - REAL or MOCK based on config
|
|
226
|
-
this.app.post('/api/stripe/checkout-sessions', async (req, res) => {
|
|
227
|
-
try {
|
|
228
|
-
if (this.stripeMode === 'real') {
|
|
229
|
-
// Real Stripe checkout session
|
|
230
|
-
const { product_name, amount, currency = 'usd', success_url, cancel_url, price_id } = req.body;
|
|
231
|
-
|
|
232
|
-
let lineItems;
|
|
233
|
-
if (price_id) {
|
|
234
|
-
// Use existing price
|
|
235
|
-
lineItems = [{ price: price_id, quantity: 1 }];
|
|
236
|
-
} else {
|
|
237
|
-
// Create price on the fly
|
|
238
|
-
lineItems = [{
|
|
239
|
-
price_data: {
|
|
240
|
-
currency,
|
|
241
|
-
product_data: { name: product_name || 'Product' },
|
|
242
|
-
unit_amount: amount || 999
|
|
243
|
-
},
|
|
244
|
-
quantity: 1
|
|
245
|
-
}];
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
const session = await stripe.checkout.sessions.create({
|
|
249
|
-
mode: 'payment',
|
|
250
|
-
line_items: lineItems,
|
|
251
|
-
success_url,
|
|
252
|
-
cancel_url,
|
|
253
|
-
automatic_tax: { enabled: false },
|
|
254
|
-
customer_creation: 'always'
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
res.json({
|
|
258
|
-
success: true,
|
|
259
|
-
data: {
|
|
260
|
-
sessionId: session.id,
|
|
261
|
-
url: session.url,
|
|
262
|
-
amount_total: session.amount_total,
|
|
263
|
-
currency: session.currency,
|
|
264
|
-
payment_status: session.payment_status,
|
|
265
|
-
status: session.status
|
|
266
|
-
}
|
|
267
|
-
});
|
|
268
|
-
} else {
|
|
269
|
-
// Mock response (fallback)
|
|
270
|
-
const sessionId = 'cs_local_' + Date.now();
|
|
271
|
-
res.json({
|
|
272
|
-
success: true,
|
|
273
|
-
data: {
|
|
274
|
-
sessionId,
|
|
275
|
-
url: `http://localhost:${this.port}/checkout/${sessionId}?success=${encodeURIComponent(req.body.success_url)}&cancel=${encodeURIComponent(req.body.cancel_url)}`,
|
|
276
|
-
amount_total: req.body.amount || 999,
|
|
277
|
-
currency: req.body.currency || 'usd',
|
|
278
|
-
payment_status: 'unpaid',
|
|
279
|
-
status: 'open'
|
|
280
|
-
}
|
|
281
|
-
});
|
|
282
|
-
}
|
|
283
|
-
} catch (error) {
|
|
284
|
-
console.error('Stripe checkout error:', error);
|
|
285
|
-
res.status(500).json({
|
|
286
|
-
success: false,
|
|
287
|
-
error: {
|
|
288
|
-
code: 'CHECKOUT_ERROR',
|
|
289
|
-
message: error.message || 'Failed to create checkout session'
|
|
290
|
-
}
|
|
291
|
-
});
|
|
292
|
-
}
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
// Mock Stripe endpoints (legacy)
|
|
296
|
-
this.app.post('/api/stripe/create-checkout-session', (req, res) => {
|
|
297
|
-
res.json({
|
|
298
|
-
sessionId: 'cs_test_local_' + Date.now(),
|
|
299
|
-
url: 'https://checkout.stripe.com/pay/cs_test_local'
|
|
300
|
-
});
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
this.app.get('/api/stripe/subscription', (req, res) => {
|
|
304
|
-
res.json({
|
|
305
|
-
id: 'sub_local_123',
|
|
306
|
-
status: 'active',
|
|
307
|
-
plan: 'premium',
|
|
308
|
-
current_period_end: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString()
|
|
309
|
-
});
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
// Stripe products endpoint - REAL or MOCK based on config
|
|
313
|
-
this.app.get('/api/stripe/products', async (req, res) => {
|
|
314
|
-
try {
|
|
315
|
-
if (this.stripeMode === 'real') {
|
|
316
|
-
// Fetch real Stripe products
|
|
317
|
-
const products = await stripe.products.list({
|
|
318
|
-
active: req.query.active !== undefined ? req.query.active === 'true' : undefined,
|
|
319
|
-
limit: req.query.limit ? parseInt(req.query.limit) : 10
|
|
320
|
-
});
|
|
321
|
-
|
|
322
|
-
// Get prices for each product and filter out local-only products
|
|
323
|
-
const productsWithPrices = await Promise.all(
|
|
324
|
-
products.data
|
|
325
|
-
.filter(product => {
|
|
326
|
-
// Only show products that don't start with prod_local_ (which are local-only placeholders)
|
|
327
|
-
// These are the real Stripe products created from sync
|
|
328
|
-
return !product.id.startsWith('prod_local_');
|
|
329
|
-
})
|
|
330
|
-
.map(async (product) => {
|
|
331
|
-
const prices = await stripe.prices.list({
|
|
332
|
-
product: product.id,
|
|
333
|
-
active: true,
|
|
334
|
-
limit: 1
|
|
335
|
-
});
|
|
336
|
-
|
|
337
|
-
const defaultPrice = prices.data[0];
|
|
338
|
-
return {
|
|
339
|
-
id: product.id,
|
|
340
|
-
name: product.name,
|
|
341
|
-
description: product.description,
|
|
342
|
-
price: defaultPrice ? defaultPrice.unit_amount : 0,
|
|
343
|
-
currency: defaultPrice ? defaultPrice.currency : 'usd',
|
|
344
|
-
price_id: defaultPrice ? defaultPrice.id : null,
|
|
345
|
-
active: product.active,
|
|
346
|
-
metadata: product.metadata
|
|
347
|
-
};
|
|
348
|
-
})
|
|
349
|
-
);
|
|
350
|
-
|
|
351
|
-
res.json({
|
|
352
|
-
success: true,
|
|
353
|
-
data: productsWithPrices
|
|
354
|
-
});
|
|
355
|
-
} else {
|
|
356
|
-
// Mock response (fallback)
|
|
357
|
-
res.json({
|
|
358
|
-
success: true,
|
|
359
|
-
data: [
|
|
360
|
-
{
|
|
361
|
-
id: 'prod_local_validate',
|
|
362
|
-
name: 'Validate',
|
|
363
|
-
description: 'Proof of concept validation',
|
|
364
|
-
price: 500,
|
|
365
|
-
currency: 'usd',
|
|
366
|
-
active: true
|
|
367
|
-
},
|
|
368
|
-
{
|
|
369
|
-
id: 'prod_local_prototype',
|
|
370
|
-
name: 'Prototype',
|
|
371
|
-
description: 'Build an MVP prototype',
|
|
372
|
-
price: 2500,
|
|
373
|
-
currency: 'usd',
|
|
374
|
-
active: true
|
|
375
|
-
}
|
|
376
|
-
]
|
|
377
|
-
});
|
|
378
|
-
}
|
|
379
|
-
} catch (error) {
|
|
380
|
-
console.error('Stripe products error:', error);
|
|
381
|
-
res.status(500).json({
|
|
382
|
-
success: false,
|
|
383
|
-
error: {
|
|
384
|
-
code: 'PRODUCTS_ERROR',
|
|
385
|
-
message: error.message || 'Failed to fetch products'
|
|
386
|
-
}
|
|
387
|
-
});
|
|
61
|
+
execSync('docker-compose version', { stdio: 'ignore' });
|
|
62
|
+
return 'docker-compose';
|
|
63
|
+
} catch {
|
|
64
|
+
throw new Error('docker compose is required but not installed');
|
|
388
65
|
}
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
// Mock auth nonce endpoint
|
|
392
|
-
this.app.post('/api/auth/nonce', (req, res) => {
|
|
393
|
-
const { wallet_address } = req.body;
|
|
394
|
-
res.json({
|
|
395
|
-
success: true,
|
|
396
|
-
data: {
|
|
397
|
-
nonce: 'local-nonce-' + Date.now(),
|
|
398
|
-
message: `Sign this message to authenticate your wallet ${wallet_address}.\n\nNonce: local-nonce-${Date.now()}`,
|
|
399
|
-
wallet_address,
|
|
400
|
-
expires_at: new Date(Date.now() + 300000).toISOString()
|
|
401
|
-
}
|
|
402
|
-
});
|
|
403
|
-
});
|
|
404
|
-
|
|
405
|
-
// Mock magic link endpoint
|
|
406
|
-
this.app.post('/api/auth/magic-link', (req, res) => {
|
|
407
|
-
const { email } = req.body;
|
|
408
|
-
res.json({
|
|
409
|
-
success: true,
|
|
410
|
-
message: 'Magic link sent successfully (simulated in local mode)',
|
|
411
|
-
data: {
|
|
412
|
-
email,
|
|
413
|
-
sent_at: new Date().toISOString()
|
|
414
|
-
}
|
|
415
|
-
});
|
|
416
|
-
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
417
68
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Run docker compose command
|
|
71
|
+
*/
|
|
72
|
+
runCompose(args, options = {}) {
|
|
73
|
+
const composeCmd = this.checkDockerCompose();
|
|
74
|
+
const fullArgs = ['-f', this.composeFile, ...args];
|
|
75
|
+
|
|
76
|
+
if (options.silent) {
|
|
77
|
+
return execSync(`${composeCmd} ${fullArgs.join(' ')}`, {
|
|
78
|
+
cwd: this.repoRoot,
|
|
79
|
+
stdio: 'ignore'
|
|
425
80
|
});
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
// Admin whitelist check endpoint
|
|
429
|
-
this.app.post('/api/v1/admin/whitelist/check', (req, res) => {
|
|
430
|
-
const { email } = req.body;
|
|
431
|
-
|
|
432
|
-
if (!email) {
|
|
433
|
-
return res.status(400).json({
|
|
434
|
-
success: false,
|
|
435
|
-
error: { message: 'Email is required' }
|
|
436
|
-
});
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
// Mock whitelist check - some emails are "whitelisted"
|
|
440
|
-
const whitelistedEmails = ['vip@example.com', 'admin@example.com', 'premium@example.com'];
|
|
441
|
-
const isWhitelisted = whitelistedEmails.includes(email.toLowerCase());
|
|
81
|
+
}
|
|
442
82
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
email,
|
|
447
|
-
whitelisted: isWhitelisted,
|
|
448
|
-
reason: isWhitelisted ? 'VIP access granted' : 'Standard access only'
|
|
449
|
-
}
|
|
450
|
-
});
|
|
83
|
+
const result = execSync(`${composeCmd} ${fullArgs.join(' ')}`, {
|
|
84
|
+
cwd: this.repoRoot,
|
|
85
|
+
encoding: 'utf-8'
|
|
451
86
|
});
|
|
452
87
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
const { product_id, new_price, currency = 'usd' } = req.body;
|
|
456
|
-
|
|
457
|
-
if (!product_id || !new_price) {
|
|
458
|
-
return res.status(400).json({
|
|
459
|
-
success: false,
|
|
460
|
-
error: { message: 'Product ID and new price are required' }
|
|
461
|
-
});
|
|
462
|
-
}
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
463
90
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
updated_at: new Date().toISOString()
|
|
472
|
-
}
|
|
473
|
-
});
|
|
474
|
-
});
|
|
91
|
+
/**
|
|
92
|
+
* Wait for health check to pass
|
|
93
|
+
*/
|
|
94
|
+
async waitForHealthCheck(maxAttempts = 60, intervalMs = 1000) {
|
|
95
|
+
if (!this.json) {
|
|
96
|
+
console.log(chalk.dim(`⏳ Waiting for SPAPS API at ${this.healthUrl}...`));
|
|
97
|
+
}
|
|
475
98
|
|
|
476
|
-
|
|
477
|
-
this.app.post('/api/v1/admin/products/sync', async (req, res) => {
|
|
99
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
478
100
|
try {
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
const syncResults = [];
|
|
483
|
-
|
|
484
|
-
for (const product of localProducts) {
|
|
485
|
-
try {
|
|
486
|
-
// Check if product already exists in Stripe by searching metadata
|
|
487
|
-
let stripeProduct;
|
|
488
|
-
const existingProducts = await stripe.products.list({
|
|
489
|
-
limit: 100,
|
|
490
|
-
expand: ['data.default_price']
|
|
491
|
-
});
|
|
492
|
-
|
|
493
|
-
stripeProduct = existingProducts.data.find(p =>
|
|
494
|
-
p.metadata && p.metadata.spaps_id === product.id
|
|
495
|
-
);
|
|
496
|
-
|
|
497
|
-
if (!stripeProduct) {
|
|
498
|
-
// Create new product in Stripe
|
|
499
|
-
stripeProduct = await stripe.products.create({
|
|
500
|
-
name: product.name,
|
|
501
|
-
description: product.description,
|
|
502
|
-
metadata: {
|
|
503
|
-
spaps_managed: 'true',
|
|
504
|
-
created_by: 'spaps_admin',
|
|
505
|
-
spaps_id: product.id
|
|
506
|
-
}
|
|
507
|
-
});
|
|
508
|
-
|
|
509
|
-
// Create corresponding price
|
|
510
|
-
await stripe.prices.create({
|
|
511
|
-
product: stripeProduct.id,
|
|
512
|
-
unit_amount: product.price,
|
|
513
|
-
currency: product.currency,
|
|
514
|
-
metadata: {
|
|
515
|
-
spaps_managed: 'true',
|
|
516
|
-
spaps_price_id: product.price_id
|
|
517
|
-
}
|
|
518
|
-
});
|
|
519
|
-
|
|
520
|
-
syncResults.push({
|
|
521
|
-
id: product.id,
|
|
522
|
-
name: product.name,
|
|
523
|
-
action: 'created',
|
|
524
|
-
stripe_id: stripeProduct.id
|
|
525
|
-
});
|
|
526
|
-
} else {
|
|
527
|
-
// Update existing product
|
|
528
|
-
await stripe.products.update(stripeProduct.id, {
|
|
529
|
-
name: product.name,
|
|
530
|
-
description: product.description,
|
|
531
|
-
active: product.active !== false
|
|
532
|
-
});
|
|
533
|
-
|
|
534
|
-
syncResults.push({
|
|
535
|
-
id: product.id,
|
|
536
|
-
name: product.name,
|
|
537
|
-
action: 'updated',
|
|
538
|
-
stripe_id: stripeProduct.id
|
|
539
|
-
});
|
|
540
|
-
}
|
|
541
|
-
} catch (productError) {
|
|
542
|
-
console.error(`Error syncing product ${product.id}:`, productError);
|
|
543
|
-
syncResults.push({
|
|
544
|
-
id: product.id,
|
|
545
|
-
name: product.name,
|
|
546
|
-
action: 'error',
|
|
547
|
-
error: productError.message
|
|
548
|
-
});
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
res.json({
|
|
553
|
-
success: true,
|
|
554
|
-
message: `Successfully synced ${syncResults.filter(r => r.action !== 'error').length} products to Stripe`,
|
|
555
|
-
data: {
|
|
556
|
-
synced_count: syncResults.filter(r => r.action !== 'error').length,
|
|
557
|
-
total_count: localProducts.length,
|
|
558
|
-
results: syncResults
|
|
559
|
-
}
|
|
560
|
-
});
|
|
561
|
-
} else {
|
|
562
|
-
// Mock response (fallback)
|
|
563
|
-
res.json({
|
|
564
|
-
success: true,
|
|
565
|
-
message: 'Products synced successfully (mock mode)',
|
|
566
|
-
data: {
|
|
567
|
-
synced_count: 2,
|
|
568
|
-
products: ['Validate', 'Prototype']
|
|
569
|
-
}
|
|
570
|
-
});
|
|
101
|
+
const response = await axios.get(this.healthUrl, { timeout: 2000 });
|
|
102
|
+
if (response.status === 200) {
|
|
103
|
+
return true;
|
|
571
104
|
}
|
|
572
105
|
} catch (error) {
|
|
573
|
-
|
|
574
|
-
res.status(500).json({
|
|
575
|
-
success: false,
|
|
576
|
-
error: {
|
|
577
|
-
code: 'SYNC_ERROR',
|
|
578
|
-
message: error.message || 'Failed to sync products'
|
|
579
|
-
}
|
|
580
|
-
});
|
|
106
|
+
// Continue waiting
|
|
581
107
|
}
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
// Mock usage endpoints
|
|
585
|
-
this.app.get('/api/usage/balance', (req, res) => {
|
|
586
|
-
res.json({
|
|
587
|
-
balance: 1000,
|
|
588
|
-
currency: 'credits',
|
|
589
|
-
updated_at: new Date().toISOString()
|
|
590
|
-
});
|
|
591
|
-
});
|
|
108
|
+
await new Promise(resolve => setTimeout(resolve, intervalMs));
|
|
109
|
+
}
|
|
592
110
|
|
|
593
|
-
|
|
594
|
-
this.app.get('/docs', (req, res) => {
|
|
595
|
-
if (swaggerUiDist && typeof swaggerUiDist.getAbsoluteFSPath === 'function') {
|
|
596
|
-
res.type('html').send(`
|
|
597
|
-
<!DOCTYPE html>
|
|
598
|
-
<html>
|
|
599
|
-
<head>
|
|
600
|
-
<meta charset="utf-8" />
|
|
601
|
-
<title>SPAPS API Docs</title>
|
|
602
|
-
<link rel="stylesheet" href="/swagger-ui/swagger-ui.css" />
|
|
603
|
-
<style>body { margin: 0; } #swagger-ui { max-width: 100%; }</style>
|
|
604
|
-
</head>
|
|
605
|
-
<body>
|
|
606
|
-
<div id="swagger-ui"></div>
|
|
607
|
-
<script src="/swagger-ui/swagger-ui-bundle.js"></script>
|
|
608
|
-
<script src="/swagger-ui/swagger-ui-standalone-preset.js"></script>
|
|
609
|
-
<script>
|
|
610
|
-
window.onload = () => {
|
|
611
|
-
window.ui = SwaggerUIBundle({
|
|
612
|
-
url: '/openapi.json',
|
|
613
|
-
dom_id: '#swagger-ui',
|
|
614
|
-
deepLinking: true,
|
|
615
|
-
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
|
|
616
|
-
layout: 'BaseLayout'
|
|
617
|
-
});
|
|
618
|
-
};
|
|
619
|
-
</script>
|
|
620
|
-
</body>
|
|
621
|
-
</html>`);
|
|
622
|
-
} else {
|
|
623
|
-
// Fallback to the existing docs page if Swagger UI is not available
|
|
624
|
-
const msg = 'Using fallback docs page. Install Swagger UI assets for the full API explorer: npm install swagger-ui-dist';
|
|
625
|
-
res.send(generateDocsHTML(this.port, msg));
|
|
626
|
-
}
|
|
627
|
-
});
|
|
111
|
+
throw new Error(`Health check failed after ${maxAttempts} attempts`);
|
|
628
112
|
}
|
|
629
113
|
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
for (const p of yamlCandidates) {
|
|
637
|
-
try {
|
|
638
|
-
if (fs.existsSync(p)) {
|
|
639
|
-
let yaml;
|
|
640
|
-
try {
|
|
641
|
-
yaml = require('js-yaml');
|
|
642
|
-
} catch {}
|
|
643
|
-
if (yaml) {
|
|
644
|
-
const content = fs.readFileSync(p, 'utf8');
|
|
645
|
-
const parsed = yaml.load(content);
|
|
646
|
-
// ensure servers list points to local
|
|
647
|
-
parsed.servers = [{ url: `http://localhost:${this.port}` }];
|
|
648
|
-
return parsed;
|
|
649
|
-
}
|
|
650
|
-
}
|
|
651
|
-
} catch {}
|
|
114
|
+
/**
|
|
115
|
+
* Load data from backup if provided
|
|
116
|
+
*/
|
|
117
|
+
async loadFromBackup() {
|
|
118
|
+
if (!this.fromBackup) {
|
|
119
|
+
return;
|
|
652
120
|
}
|
|
653
121
|
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
path.resolve(__dirname, '../../../docs/manifest.json')
|
|
658
|
-
];
|
|
659
|
-
for (const p of manifestCandidates) {
|
|
660
|
-
try {
|
|
661
|
-
if (fs.existsSync(p)) {
|
|
662
|
-
const manifest = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
663
|
-
return this.buildOpenApiFromManifest(manifest);
|
|
664
|
-
}
|
|
665
|
-
} catch {}
|
|
122
|
+
const backupPath = path.resolve(this.fromBackup);
|
|
123
|
+
if (!fs.existsSync(backupPath)) {
|
|
124
|
+
throw new Error(`Backup file not found: ${backupPath}`);
|
|
666
125
|
}
|
|
667
126
|
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
openapi: '3.0.0',
|
|
671
|
-
info: { title: 'SPAPS Local API', version: '0.0.0' },
|
|
672
|
-
servers: [{ url: `http://localhost:${this.port}` }],
|
|
673
|
-
paths: {
|
|
674
|
-
'/health': { get: { summary: 'Health', responses: { '200': { description: 'OK' } } } }
|
|
675
|
-
}
|
|
676
|
-
};
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
buildOpenApiFromManifest(manifest) {
|
|
680
|
-
const spec = {
|
|
681
|
-
openapi: '3.0.0',
|
|
682
|
-
info: { title: 'SPAPS API', version: String(manifest.version || '1.0.0') },
|
|
683
|
-
servers: [{ url: `http://localhost:${this.port}` }],
|
|
684
|
-
paths: {}
|
|
685
|
-
};
|
|
686
|
-
const toPath = (p) => p.replace(/:(\w+)/g, '{$1}');
|
|
687
|
-
for (const ep of manifest.endpoints || []) {
|
|
688
|
-
const pathKey = toPath(ep.path);
|
|
689
|
-
if (!spec.paths[pathKey]) spec.paths[pathKey] = {};
|
|
690
|
-
spec.paths[pathKey][String(ep.method || 'GET').toLowerCase()] = {
|
|
691
|
-
summary: ep.description || `${ep.method} ${ep.path}`,
|
|
692
|
-
tags: ep.tags || [],
|
|
693
|
-
responses: { '200': { description: 'OK' } }
|
|
694
|
-
};
|
|
127
|
+
if (!this.json) {
|
|
128
|
+
console.log(chalk.blue(`📦 Loading from backup: ${backupPath}`));
|
|
695
129
|
}
|
|
696
|
-
return spec;
|
|
697
|
-
}
|
|
698
130
|
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
customer_id: alice.id,
|
|
709
|
-
customer_email: alice.email,
|
|
710
|
-
product_id: p.id,
|
|
711
|
-
price_id: p.price_id,
|
|
712
|
-
amount: p.price,
|
|
713
|
-
currency: p.currency
|
|
714
|
-
});
|
|
715
|
-
this.adminManager.updateOrderStatus(order1.id, 'completed');
|
|
716
|
-
this.adminManager.createOrder({
|
|
717
|
-
customer_id: bob.id,
|
|
718
|
-
customer_email: bob.email,
|
|
719
|
-
product_id: p.id,
|
|
720
|
-
price_id: p.price_id,
|
|
721
|
-
amount: p.price,
|
|
722
|
-
currency: p.currency
|
|
131
|
+
// Run the transform + load pipeline
|
|
132
|
+
try {
|
|
133
|
+
execSync(`make load-dev-db`, {
|
|
134
|
+
cwd: this.repoRoot,
|
|
135
|
+
stdio: this.json ? 'ignore' : 'inherit',
|
|
136
|
+
env: {
|
|
137
|
+
...process.env,
|
|
138
|
+
DATA_SQL: backupPath
|
|
139
|
+
}
|
|
723
140
|
});
|
|
724
|
-
|
|
141
|
+
|
|
142
|
+
if (!this.json) {
|
|
143
|
+
console.log(chalk.green('✅ Backup loaded successfully'));
|
|
144
|
+
}
|
|
145
|
+
} catch (error) {
|
|
146
|
+
throw new Error(`Failed to load backup: ${error.message}`);
|
|
725
147
|
}
|
|
726
148
|
}
|
|
727
149
|
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
const { active, category, limit = '100' } = req.query;
|
|
736
|
-
|
|
737
|
-
const products = await stripe.products.list({
|
|
738
|
-
active: active === 'true' ? true : active === 'false' ? false : undefined,
|
|
739
|
-
limit: parseInt(limit),
|
|
740
|
-
expand: ['data.default_price']
|
|
741
|
-
});
|
|
150
|
+
/**
|
|
151
|
+
* Start the Docker Compose stack
|
|
152
|
+
*/
|
|
153
|
+
async start() {
|
|
154
|
+
try {
|
|
155
|
+
// Check prerequisites
|
|
156
|
+
this.checkDockerCompose();
|
|
742
157
|
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
if (category === 'spaps') {
|
|
746
|
-
filteredProducts = products.data.filter(p =>
|
|
747
|
-
p.metadata && p.metadata.spaps_managed === 'true'
|
|
748
|
-
);
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
res.json({
|
|
752
|
-
success: true,
|
|
753
|
-
data: {
|
|
754
|
-
products: filteredProducts.map(product => ({
|
|
755
|
-
id: product.id,
|
|
756
|
-
name: product.name,
|
|
757
|
-
description: product.description,
|
|
758
|
-
images: product.images,
|
|
759
|
-
active: product.active,
|
|
760
|
-
metadata: product.metadata,
|
|
761
|
-
default_price: product.default_price ? {
|
|
762
|
-
id: product.default_price.id,
|
|
763
|
-
unit_amount: product.default_price.unit_amount,
|
|
764
|
-
currency: product.default_price.currency,
|
|
765
|
-
recurring: product.default_price.recurring
|
|
766
|
-
} : null,
|
|
767
|
-
created: product.created,
|
|
768
|
-
updated: product.updated
|
|
769
|
-
})),
|
|
770
|
-
has_more: products.has_more,
|
|
771
|
-
total_count: filteredProducts.length
|
|
772
|
-
}
|
|
773
|
-
});
|
|
774
|
-
} else {
|
|
775
|
-
// Mock response
|
|
776
|
-
const localProducts = this.adminManager.listProducts();
|
|
777
|
-
res.json({
|
|
778
|
-
success: true,
|
|
779
|
-
data: {
|
|
780
|
-
products: localProducts,
|
|
781
|
-
has_more: false,
|
|
782
|
-
total_count: localProducts.length
|
|
783
|
-
}
|
|
784
|
-
});
|
|
785
|
-
}
|
|
786
|
-
} catch (error) {
|
|
787
|
-
console.error('List products error:', error);
|
|
788
|
-
res.status(500).json({
|
|
789
|
-
success: false,
|
|
790
|
-
error: {
|
|
791
|
-
code: 'LIST_PRODUCTS_ERROR',
|
|
792
|
-
message: error.message || 'Failed to list products'
|
|
793
|
-
}
|
|
794
|
-
});
|
|
158
|
+
if (!fs.existsSync(this.composeFile)) {
|
|
159
|
+
throw new Error(`docker-compose.spaps-dev.yml not found at ${this.composeFile}`);
|
|
795
160
|
}
|
|
796
|
-
});
|
|
797
161
|
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
if (!name) {
|
|
804
|
-
return res.status(400).json({
|
|
805
|
-
success: false,
|
|
806
|
-
error: { message: 'Product name is required' }
|
|
807
|
-
});
|
|
162
|
+
// Handle fresh mode
|
|
163
|
+
if (this.fresh) {
|
|
164
|
+
if (!this.json) {
|
|
165
|
+
console.log(chalk.yellow('🔄 Fresh mode: tearing down existing stack...'));
|
|
808
166
|
}
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
name,
|
|
814
|
-
description,
|
|
815
|
-
images,
|
|
816
|
-
active,
|
|
817
|
-
metadata: {
|
|
818
|
-
...metadata,
|
|
819
|
-
spaps_managed: 'true',
|
|
820
|
-
created_by: 'spaps_cli'
|
|
821
|
-
}
|
|
822
|
-
});
|
|
823
|
-
|
|
824
|
-
let stripePrice = null;
|
|
825
|
-
|
|
826
|
-
// If price is provided, create a price for the product
|
|
827
|
-
if (price !== undefined && price !== null && price !== '') {
|
|
828
|
-
stripePrice = await stripe.prices.create({
|
|
829
|
-
product: stripeProduct.id,
|
|
830
|
-
unit_amount: parseInt(price), // Price in cents
|
|
831
|
-
currency: currency,
|
|
832
|
-
metadata: {
|
|
833
|
-
spaps_managed: 'true',
|
|
834
|
-
created_by: 'spaps_cli'
|
|
835
|
-
}
|
|
836
|
-
});
|
|
837
|
-
|
|
838
|
-
// Update product with default price
|
|
839
|
-
await stripe.products.update(stripeProduct.id, {
|
|
840
|
-
default_price: stripePrice.id
|
|
841
|
-
});
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
// Fetch the updated product with default_price
|
|
845
|
-
const updatedProduct = await stripe.products.retrieve(stripeProduct.id, {
|
|
846
|
-
expand: ['default_price']
|
|
847
|
-
});
|
|
848
|
-
|
|
849
|
-
res.json({
|
|
850
|
-
success: true,
|
|
851
|
-
data: {
|
|
852
|
-
product: {
|
|
853
|
-
id: updatedProduct.id,
|
|
854
|
-
name: updatedProduct.name,
|
|
855
|
-
description: updatedProduct.description,
|
|
856
|
-
images: updatedProduct.images,
|
|
857
|
-
active: updatedProduct.active,
|
|
858
|
-
metadata: updatedProduct.metadata,
|
|
859
|
-
created: updatedProduct.created,
|
|
860
|
-
default_price: updatedProduct.default_price ? {
|
|
861
|
-
id: updatedProduct.default_price.id,
|
|
862
|
-
unit_amount: updatedProduct.default_price.unit_amount,
|
|
863
|
-
currency: updatedProduct.default_price.currency
|
|
864
|
-
} : null
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
});
|
|
868
|
-
} else {
|
|
869
|
-
// Create locally
|
|
870
|
-
const productPrice = price ? parseInt(price) : 0;
|
|
871
|
-
const product = this.adminManager.createProduct({
|
|
872
|
-
name,
|
|
873
|
-
description,
|
|
874
|
-
price: productPrice,
|
|
875
|
-
currency: currency
|
|
876
|
-
});
|
|
877
|
-
|
|
878
|
-
// Add mock default_price structure for consistency
|
|
879
|
-
product.default_price = productPrice > 0 ? {
|
|
880
|
-
id: `price_${product.id}`,
|
|
881
|
-
unit_amount: productPrice,
|
|
882
|
-
currency: currency
|
|
883
|
-
} : null;
|
|
884
|
-
|
|
885
|
-
res.json({
|
|
886
|
-
success: true,
|
|
887
|
-
data: { product }
|
|
888
|
-
});
|
|
167
|
+
try {
|
|
168
|
+
this.runCompose(['down', '-v'], { silent: true });
|
|
169
|
+
} catch {
|
|
170
|
+
// Ignore errors if stack doesn't exist
|
|
889
171
|
}
|
|
890
|
-
} catch (error) {
|
|
891
|
-
console.error('Create product error:', error);
|
|
892
|
-
res.status(500).json({
|
|
893
|
-
success: false,
|
|
894
|
-
error: {
|
|
895
|
-
code: 'CREATE_PRODUCT_ERROR',
|
|
896
|
-
message: error.message || 'Failed to create product'
|
|
897
|
-
}
|
|
898
|
-
});
|
|
899
172
|
}
|
|
900
|
-
});
|
|
901
|
-
|
|
902
|
-
// PUT /api/stripe/products/:productId - Update product
|
|
903
|
-
this.app.put('/api/stripe/products/:productId', async (req, res) => {
|
|
904
|
-
try {
|
|
905
|
-
const { productId } = req.params;
|
|
906
|
-
const { name, description, images, metadata, active } = req.body;
|
|
907
173
|
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
if (metadata !== undefined) updateData.metadata = metadata;
|
|
915
|
-
if (active !== undefined) updateData.active = active;
|
|
174
|
+
// Start the stack
|
|
175
|
+
if (!this.json) {
|
|
176
|
+
console.log();
|
|
177
|
+
console.log(chalk.yellow('🍠 SPAPS Local Development Server'));
|
|
178
|
+
console.log(chalk.blue('🐳 Starting Docker Compose stack...'));
|
|
179
|
+
}
|
|
916
180
|
|
|
917
|
-
|
|
181
|
+
const startArgs = ['up', '-d'];
|
|
182
|
+
this.runCompose(startArgs, { silent: this.json });
|
|
918
183
|
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
data: {
|
|
922
|
-
product: {
|
|
923
|
-
id: stripeProduct.id,
|
|
924
|
-
name: stripeProduct.name,
|
|
925
|
-
description: stripeProduct.description,
|
|
926
|
-
images: stripeProduct.images,
|
|
927
|
-
active: stripeProduct.active,
|
|
928
|
-
metadata: stripeProduct.metadata,
|
|
929
|
-
updated: stripeProduct.updated
|
|
930
|
-
}
|
|
931
|
-
}
|
|
932
|
-
});
|
|
933
|
-
} else {
|
|
934
|
-
// Update locally
|
|
935
|
-
const product = this.adminManager.updateProduct(productId, {
|
|
936
|
-
name,
|
|
937
|
-
description,
|
|
938
|
-
active
|
|
939
|
-
});
|
|
184
|
+
// Wait for health check
|
|
185
|
+
await this.waitForHealthCheck();
|
|
940
186
|
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
});
|
|
945
|
-
}
|
|
946
|
-
} catch (error) {
|
|
947
|
-
console.error('Update product error:', error);
|
|
948
|
-
res.status(500).json({
|
|
949
|
-
success: false,
|
|
950
|
-
error: {
|
|
951
|
-
code: 'UPDATE_PRODUCT_ERROR',
|
|
952
|
-
message: error.message || 'Failed to update product'
|
|
953
|
-
}
|
|
954
|
-
});
|
|
187
|
+
// Load backup if provided
|
|
188
|
+
if (this.fromBackup) {
|
|
189
|
+
await this.loadFromBackup();
|
|
955
190
|
}
|
|
956
|
-
});
|
|
957
|
-
|
|
958
|
-
// POST /api/stripe/prices - Create a new price for a product
|
|
959
|
-
this.app.post('/api/stripe/prices', async (req, res) => {
|
|
960
|
-
try {
|
|
961
|
-
const { product_id, unit_amount, currency = 'usd', recurring, metadata = {} } = req.body;
|
|
962
191
|
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
192
|
+
// Print connection info
|
|
193
|
+
const connectionInfo = {
|
|
194
|
+
SPAPS_API_URL: this.apiUrl,
|
|
195
|
+
SPAPS_API_KEY: 'spaps_local_development_key',
|
|
196
|
+
SPAPS_APPLICATION_ID: '00000000-0000-0000-0000-000000000100',
|
|
197
|
+
test_users: {
|
|
198
|
+
admin: { email: 'admin@spaps.dev', password: 'Admin1234x' },
|
|
199
|
+
user: { email: 'user@spaps.dev', password: 'User1234x' },
|
|
200
|
+
premium: { email: 'premium@spaps.dev', password: 'Premium1234x' }
|
|
968
201
|
}
|
|
202
|
+
};
|
|
969
203
|
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
// Add recurring if specified
|
|
982
|
-
if (recurring) {
|
|
983
|
-
priceData.recurring = recurring;
|
|
204
|
+
if (this.json) {
|
|
205
|
+
console.log(JSON.stringify({
|
|
206
|
+
success: true,
|
|
207
|
+
command: 'local',
|
|
208
|
+
server: {
|
|
209
|
+
url: this.apiUrl,
|
|
210
|
+
docs: `${this.apiUrl}/docs`,
|
|
211
|
+
health: `${this.apiUrl}/health`,
|
|
212
|
+
mode: 'docker-compose',
|
|
213
|
+
port: this.port,
|
|
214
|
+
connection: connectionInfo
|
|
984
215
|
}
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
216
|
+
}));
|
|
217
|
+
} else {
|
|
218
|
+
console.log();
|
|
219
|
+
console.log(chalk.green('✨ SPAPS server is running!'));
|
|
220
|
+
console.log();
|
|
221
|
+
console.log(chalk.cyan('📡 Connection Info:'));
|
|
222
|
+
console.log(` ${chalk.bold('API URL:')} ${this.apiUrl}`);
|
|
223
|
+
console.log(` ${chalk.bold('Documentation:')} ${this.apiUrl}/docs`);
|
|
224
|
+
console.log(` ${chalk.bold('Health Check:')} ${this.apiUrl}/health`);
|
|
225
|
+
console.log();
|
|
226
|
+
console.log(chalk.cyan('🔑 Credentials:'));
|
|
227
|
+
console.log(` ${chalk.bold('API Key:')} ${connectionInfo.SPAPS_API_KEY}`);
|
|
228
|
+
console.log(` ${chalk.bold('Application ID:')} ${connectionInfo.SPAPS_APPLICATION_ID}`);
|
|
229
|
+
console.log();
|
|
230
|
+
console.log(chalk.cyan('👥 Test Users:'));
|
|
231
|
+
console.log(` ${chalk.bold('Admin:')} ${connectionInfo.test_users.admin.email} / ${connectionInfo.test_users.admin.password}`);
|
|
232
|
+
console.log(` ${chalk.bold('User:')} ${connectionInfo.test_users.user.email} / ${connectionInfo.test_users.user.password}`);
|
|
233
|
+
console.log(` ${chalk.bold('Premium:')} ${connectionInfo.test_users.premium.email} / ${connectionInfo.test_users.premium.password}`);
|
|
234
|
+
console.log();
|
|
235
|
+
|
|
236
|
+
if (this.detach) {
|
|
237
|
+
console.log(chalk.dim(' Running in background. Use `npx spaps local stop` to stop.'));
|
|
238
|
+
console.log(chalk.dim(' View logs: docker compose -f docker-compose.spaps-dev.yml logs -f'));
|
|
239
|
+
console.log();
|
|
1001
240
|
} else {
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
unit_amount: parseInt(unit_amount),
|
|
1007
|
-
currency,
|
|
1008
|
-
recurring,
|
|
1009
|
-
metadata
|
|
1010
|
-
};
|
|
241
|
+
console.log(chalk.dim(' Press Ctrl+C to stop'));
|
|
242
|
+
console.log();
|
|
243
|
+
console.log(chalk.gray('─'.repeat(60)));
|
|
244
|
+
console.log();
|
|
1011
245
|
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
data: { price }
|
|
1015
|
-
});
|
|
246
|
+
// Tail logs
|
|
247
|
+
this.tailLogs();
|
|
1016
248
|
}
|
|
1017
|
-
} catch (error) {
|
|
1018
|
-
console.error('Create price error:', error);
|
|
1019
|
-
res.status(500).json({
|
|
1020
|
-
success: false,
|
|
1021
|
-
error: {
|
|
1022
|
-
code: 'CREATE_PRICE_ERROR',
|
|
1023
|
-
message: error.message || 'Failed to create price'
|
|
1024
|
-
}
|
|
1025
|
-
});
|
|
1026
249
|
}
|
|
1027
|
-
});
|
|
1028
|
-
|
|
1029
|
-
// PUT /api/stripe/products/:productId/default-price - Update product's default price
|
|
1030
|
-
this.app.put('/api/stripe/products/:productId/default-price', async (req, res) => {
|
|
1031
|
-
try {
|
|
1032
|
-
const { productId } = req.params;
|
|
1033
|
-
const { price_id } = req.body;
|
|
1034
|
-
|
|
1035
|
-
if (!price_id) {
|
|
1036
|
-
return res.status(400).json({
|
|
1037
|
-
success: false,
|
|
1038
|
-
error: { message: 'Price ID is required' }
|
|
1039
|
-
});
|
|
1040
|
-
}
|
|
1041
|
-
|
|
1042
|
-
if (this.stripeMode === 'real') {
|
|
1043
|
-
const stripeProduct = await stripe.products.update(productId, {
|
|
1044
|
-
default_price: price_id
|
|
1045
|
-
});
|
|
1046
250
|
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
id: stripeProduct.id,
|
|
1052
|
-
name: stripeProduct.name,
|
|
1053
|
-
default_price: stripeProduct.default_price
|
|
1054
|
-
}
|
|
1055
|
-
}
|
|
1056
|
-
});
|
|
1057
|
-
} else {
|
|
1058
|
-
res.json({
|
|
1059
|
-
success: true,
|
|
1060
|
-
data: {
|
|
1061
|
-
product: {
|
|
1062
|
-
id: productId,
|
|
1063
|
-
default_price: price_id
|
|
1064
|
-
}
|
|
1065
|
-
}
|
|
1066
|
-
});
|
|
1067
|
-
}
|
|
1068
|
-
} catch (error) {
|
|
1069
|
-
console.error('Update default price error:', error);
|
|
1070
|
-
res.status(500).json({
|
|
251
|
+
return { success: true };
|
|
252
|
+
} catch (error) {
|
|
253
|
+
if (this.json) {
|
|
254
|
+
console.log(JSON.stringify({
|
|
1071
255
|
success: false,
|
|
1072
|
-
error:
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
});
|
|
256
|
+
error: error.message
|
|
257
|
+
}));
|
|
258
|
+
} else {
|
|
259
|
+
console.error(chalk.red('❌ Failed to start SPAPS server:'), error.message);
|
|
1077
260
|
}
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
this.app.delete('/api/stripe/products/:productId', async (req, res) => {
|
|
1082
|
-
try {
|
|
1083
|
-
const { productId } = req.params;
|
|
1084
|
-
|
|
1085
|
-
if (this.stripeMode === 'real') {
|
|
1086
|
-
// Archive in Stripe (can't truly delete)
|
|
1087
|
-
const stripeProduct = await stripe.products.update(productId, {
|
|
1088
|
-
active: false
|
|
1089
|
-
});
|
|
1090
|
-
|
|
1091
|
-
res.json({
|
|
1092
|
-
success: true,
|
|
1093
|
-
message: 'Product archived successfully',
|
|
1094
|
-
data: {
|
|
1095
|
-
product: {
|
|
1096
|
-
id: stripeProduct.id,
|
|
1097
|
-
active: stripeProduct.active,
|
|
1098
|
-
updated: stripeProduct.updated
|
|
1099
|
-
}
|
|
1100
|
-
}
|
|
1101
|
-
});
|
|
1102
|
-
} else {
|
|
1103
|
-
// Soft delete locally
|
|
1104
|
-
const result = this.adminManager.deleteProduct(productId);
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
1105
264
|
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
console.error('Delete product error:', error);
|
|
1114
|
-
res.status(500).json({
|
|
1115
|
-
success: false,
|
|
1116
|
-
error: {
|
|
1117
|
-
code: 'DELETE_PRODUCT_ERROR',
|
|
1118
|
-
message: error.message || 'Failed to delete product'
|
|
1119
|
-
}
|
|
1120
|
-
});
|
|
265
|
+
/**
|
|
266
|
+
* Stop the Docker Compose stack
|
|
267
|
+
*/
|
|
268
|
+
stop() {
|
|
269
|
+
try {
|
|
270
|
+
if (!this.json) {
|
|
271
|
+
console.log(chalk.yellow('🛑 Stopping SPAPS Docker Compose stack...'));
|
|
1121
272
|
}
|
|
1122
|
-
});
|
|
1123
273
|
|
|
1124
|
-
|
|
1125
|
-
this.app.post('/api/stripe/create-checkout-session', async (req, res) => {
|
|
1126
|
-
const { price_id, success_url, cancel_url } = req.body;
|
|
1127
|
-
const sessionId = 'cs_local_' + Date.now();
|
|
1128
|
-
|
|
1129
|
-
res.json({
|
|
1130
|
-
sessionId,
|
|
1131
|
-
url: `http://localhost:${this.port}/checkout/${sessionId}?success=${encodeURIComponent(success_url)}&cancel=${encodeURIComponent(cancel_url)}`
|
|
1132
|
-
});
|
|
1133
|
-
|
|
1134
|
-
// Simulate webhook after delay
|
|
1135
|
-
setTimeout(async () => {
|
|
1136
|
-
try {
|
|
1137
|
-
await this.simulateCheckoutWebhook(sessionId, price_id);
|
|
1138
|
-
if (!this.json) {
|
|
1139
|
-
console.log(chalk.blue(`⚡ Webhook simulated: checkout.session.completed`));
|
|
1140
|
-
}
|
|
1141
|
-
} catch (error) {
|
|
1142
|
-
console.error(chalk.red('Webhook simulation failed:'), error);
|
|
1143
|
-
}
|
|
1144
|
-
}, 2000);
|
|
1145
|
-
});
|
|
1146
|
-
|
|
1147
|
-
// Mock checkout page
|
|
1148
|
-
this.app.get('/checkout/:sessionId', (req, res) => {
|
|
1149
|
-
const { sessionId } = req.params;
|
|
1150
|
-
const { success, cancel } = req.query;
|
|
1151
|
-
|
|
1152
|
-
res.send(`
|
|
1153
|
-
<!DOCTYPE html>
|
|
1154
|
-
<html>
|
|
1155
|
-
<head>
|
|
1156
|
-
<title>SPAPS Local - Mock Checkout</title>
|
|
1157
|
-
<style>
|
|
1158
|
-
body { font-family: system-ui; max-width: 400px; margin: 100px auto; padding: 2rem; }
|
|
1159
|
-
button { width: 100%; padding: 1rem; margin: 0.5rem 0; border: none; border-radius: 8px; cursor: pointer; font-size: 16px; }
|
|
1160
|
-
.pay { background: #635bff; color: white; }
|
|
1161
|
-
.pay:hover { background: #4b41e0; }
|
|
1162
|
-
.cancel { background: #f5f5f5; }
|
|
1163
|
-
.cancel:hover { background: #e5e5e5; }
|
|
1164
|
-
</style>
|
|
1165
|
-
</head>
|
|
1166
|
-
<body>
|
|
1167
|
-
<h1>🍠 Mock Checkout</h1>
|
|
1168
|
-
<p>Session: ${sessionId}</p>
|
|
1169
|
-
<p>This is a mock checkout page for local development.</p>
|
|
1170
|
-
|
|
1171
|
-
<button class="pay" onclick="window.location='${success}'">
|
|
1172
|
-
💳 Complete Payment
|
|
1173
|
-
</button>
|
|
1174
|
-
|
|
1175
|
-
<button class="cancel" onclick="window.location='${cancel}'">
|
|
1176
|
-
Cancel
|
|
1177
|
-
</button>
|
|
1178
|
-
|
|
1179
|
-
<p style="margin-top: 2rem; color: #666; font-size: 14px;">
|
|
1180
|
-
In production, this would be a real Stripe Checkout page.
|
|
1181
|
-
</p>
|
|
1182
|
-
</body>
|
|
1183
|
-
</html>
|
|
1184
|
-
`);
|
|
1185
|
-
});
|
|
1186
|
-
|
|
1187
|
-
// Mock customer portal page
|
|
1188
|
-
this.app.get('/customer-portal', (req, res) => {
|
|
1189
|
-
const { return: returnUrl } = req.query;
|
|
1190
|
-
|
|
1191
|
-
res.send(`
|
|
1192
|
-
<!DOCTYPE html>
|
|
1193
|
-
<html>
|
|
1194
|
-
<head>
|
|
1195
|
-
<title>SPAPS Local - Customer Portal</title>
|
|
1196
|
-
<style>
|
|
1197
|
-
body { font-family: system-ui; max-width: 600px; margin: 100px auto; padding: 2rem; }
|
|
1198
|
-
button { width: 100%; padding: 1rem; margin: 0.5rem 0; border: none; border-radius: 8px; cursor: pointer; font-size: 16px; }
|
|
1199
|
-
.primary { background: #635bff; color: white; }
|
|
1200
|
-
.primary:hover { background: #4b41e0; }
|
|
1201
|
-
.secondary { background: #f5f5f5; }
|
|
1202
|
-
.secondary:hover { background: #e5e5e5; }
|
|
1203
|
-
.section { background: #f9f9f9; padding: 1.5rem; margin: 1rem 0; border-radius: 8px; }
|
|
1204
|
-
</style>
|
|
1205
|
-
</head>
|
|
1206
|
-
<body>
|
|
1207
|
-
<h1>🍠 Customer Portal (Local)</h1>
|
|
1208
|
-
<p>Manage your subscription and billing information.</p>
|
|
1209
|
-
|
|
1210
|
-
<div class="section">
|
|
1211
|
-
<h3>Current Subscription</h3>
|
|
1212
|
-
<p><strong>Plan:</strong> Premium Plan</p>
|
|
1213
|
-
<p><strong>Status:</strong> Active</p>
|
|
1214
|
-
<p><strong>Next billing:</strong> ${new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toLocaleDateString()}</p>
|
|
1215
|
-
</div>
|
|
1216
|
-
|
|
1217
|
-
<div class="section">
|
|
1218
|
-
<h3>Payment Method</h3>
|
|
1219
|
-
<p><strong>Card:</strong> •••• •••• •••• 4242</p>
|
|
1220
|
-
<p><strong>Expires:</strong> 12/2025</p>
|
|
1221
|
-
<button class="secondary">Update Payment Method</button>
|
|
1222
|
-
</div>
|
|
1223
|
-
|
|
1224
|
-
<div class="section">
|
|
1225
|
-
<h3>Billing History</h3>
|
|
1226
|
-
<p>• $25.00 - Premium Plan (${new Date().toLocaleDateString()})</p>
|
|
1227
|
-
<p>• $25.00 - Premium Plan (${new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toLocaleDateString()})</p>
|
|
1228
|
-
<button class="secondary">Download All Invoices</button>
|
|
1229
|
-
</div>
|
|
1230
|
-
|
|
1231
|
-
<button class="primary" onclick="window.location='${returnUrl || 'http://localhost:3000'}'">
|
|
1232
|
-
Return to Application
|
|
1233
|
-
</button>
|
|
1234
|
-
|
|
1235
|
-
<p style="margin-top: 2rem; color: #666; font-size: 14px;">
|
|
1236
|
-
This is a mock customer portal for local development.
|
|
1237
|
-
</p>
|
|
1238
|
-
</body>
|
|
1239
|
-
</html>
|
|
1240
|
-
`);
|
|
1241
|
-
});
|
|
274
|
+
this.runCompose(['down'], { silent: this.json });
|
|
1242
275
|
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
|
1252
|
-
|
|
1253
|
-
if (webhookSecret && sig) {
|
|
1254
|
-
try {
|
|
1255
|
-
event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
|
|
1256
|
-
} catch (err) {
|
|
1257
|
-
console.error('Webhook signature verification failed:', err.message);
|
|
1258
|
-
return res.status(400).send(`Webhook Error: ${err.message}`);
|
|
1259
|
-
}
|
|
1260
|
-
} else {
|
|
1261
|
-
// For local development without webhook secret
|
|
1262
|
-
event = JSON.parse(req.body.toString());
|
|
1263
|
-
}
|
|
1264
|
-
} else {
|
|
1265
|
-
// Mock mode - accept all webhooks
|
|
1266
|
-
if (Buffer.isBuffer(req.body)) {
|
|
1267
|
-
event = JSON.parse(req.body.toString());
|
|
1268
|
-
} else if (typeof req.body === 'string') {
|
|
1269
|
-
event = JSON.parse(req.body);
|
|
1270
|
-
} else {
|
|
1271
|
-
event = req.body;
|
|
1272
|
-
}
|
|
1273
|
-
}
|
|
1274
|
-
|
|
1275
|
-
if (!this.json) {
|
|
1276
|
-
console.log(chalk.blue(`⚡ Webhook received: ${event.type}`));
|
|
1277
|
-
}
|
|
1278
|
-
|
|
1279
|
-
// Handle the event
|
|
1280
|
-
switch (event.type) {
|
|
1281
|
-
case 'checkout.session.completed':
|
|
1282
|
-
const session = event.data.object;
|
|
1283
|
-
console.log(chalk.green(`✅ Payment successful: ${session.id}`));
|
|
1284
|
-
break;
|
|
1285
|
-
case 'payment_intent.succeeded':
|
|
1286
|
-
const paymentIntent = event.data.object;
|
|
1287
|
-
console.log(chalk.green(`💰 Payment intent succeeded: ${paymentIntent.id}`));
|
|
1288
|
-
break;
|
|
1289
|
-
case 'customer.subscription.created':
|
|
1290
|
-
const subscription = event.data.object;
|
|
1291
|
-
console.log(chalk.green(`📋 Subscription created: ${subscription.id}`));
|
|
1292
|
-
break;
|
|
1293
|
-
default:
|
|
1294
|
-
console.log(chalk.yellow(`🔔 Unhandled event type: ${event.type}`));
|
|
1295
|
-
}
|
|
1296
|
-
|
|
1297
|
-
// Store for testing
|
|
1298
|
-
this.lastWebhookEvent = event;
|
|
1299
|
-
|
|
1300
|
-
res.json({ received: true });
|
|
1301
|
-
} catch (error) {
|
|
1302
|
-
console.error('Webhook processing error:', error);
|
|
1303
|
-
res.status(500).json({ error: error.message });
|
|
276
|
+
if (this.json) {
|
|
277
|
+
console.log(JSON.stringify({
|
|
278
|
+
success: true,
|
|
279
|
+
command: 'local stop',
|
|
280
|
+
message: 'SPAPS server stopped'
|
|
281
|
+
}));
|
|
282
|
+
} else {
|
|
283
|
+
console.log(chalk.green('✅ SPAPS server stopped'));
|
|
1304
284
|
}
|
|
1305
|
-
});
|
|
1306
285
|
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
button { background: #635bff; color: white; border: none; padding: 0.75rem 1.5rem; border-radius: 6px; cursor: pointer; margin: 0.25rem; }
|
|
1317
|
-
button:hover { background: #4b41e0; }
|
|
1318
|
-
.event { background: #f9f9f9; padding: 1rem; margin: 1rem 0; border-radius: 8px; border-left: 4px solid #635bff; }
|
|
1319
|
-
</style>
|
|
1320
|
-
</head>
|
|
1321
|
-
<body>
|
|
1322
|
-
<h1>🍠 Stripe Webhook Tester</h1>
|
|
1323
|
-
|
|
1324
|
-
<h2>Simulate Events</h2>
|
|
1325
|
-
<div>
|
|
1326
|
-
<button onclick="simulate('checkout.session.completed')">Checkout Completed</button>
|
|
1327
|
-
<button onclick="simulate('payment_intent.succeeded')">Payment Success</button>
|
|
1328
|
-
<button onclick="simulate('customer.subscription.created')">Subscription Created</button>
|
|
1329
|
-
</div>
|
|
1330
|
-
|
|
1331
|
-
<h2>Last Event</h2>
|
|
1332
|
-
<div id="lastEvent">No events yet</div>
|
|
1333
|
-
|
|
1334
|
-
<script>
|
|
1335
|
-
async function simulate(type) {
|
|
1336
|
-
const response = await fetch('/api/stripe/webhooks', {
|
|
1337
|
-
method: 'POST',
|
|
1338
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1339
|
-
body: JSON.stringify({
|
|
1340
|
-
id: 'evt_local_' + Date.now(),
|
|
1341
|
-
type: type,
|
|
1342
|
-
data: { object: { id: type.split('.')[0] + '_' + Date.now() } }
|
|
1343
|
-
})
|
|
1344
|
-
});
|
|
1345
|
-
|
|
1346
|
-
if (response.ok) {
|
|
1347
|
-
document.getElementById('lastEvent').innerHTML =
|
|
1348
|
-
'<div class="event">✅ ' + type + ' - ' + new Date().toLocaleTimeString() + '</div>';
|
|
1349
|
-
}
|
|
1350
|
-
}
|
|
1351
|
-
</script>
|
|
1352
|
-
</body>
|
|
1353
|
-
</html>
|
|
1354
|
-
`);
|
|
1355
|
-
});
|
|
1356
|
-
}
|
|
1357
|
-
|
|
1358
|
-
async simulateCheckoutWebhook(sessionId, priceId) {
|
|
1359
|
-
const event = {
|
|
1360
|
-
id: 'evt_local_' + Date.now(),
|
|
1361
|
-
type: 'checkout.session.completed',
|
|
1362
|
-
data: {
|
|
1363
|
-
object: {
|
|
1364
|
-
id: sessionId,
|
|
1365
|
-
amount_total: this.getPriceAmount(priceId),
|
|
1366
|
-
currency: 'usd',
|
|
1367
|
-
customer: 'cus_local_' + Date.now(),
|
|
1368
|
-
payment_status: 'paid',
|
|
1369
|
-
status: 'complete',
|
|
1370
|
-
metadata: { app_id: '00000000-0000-0000-0000-000000000100', price_id: priceId }
|
|
1371
|
-
}
|
|
286
|
+
return { success: true };
|
|
287
|
+
} catch (error) {
|
|
288
|
+
if (this.json) {
|
|
289
|
+
console.log(JSON.stringify({
|
|
290
|
+
success: false,
|
|
291
|
+
error: error.message
|
|
292
|
+
}));
|
|
293
|
+
} else {
|
|
294
|
+
console.error(chalk.red('❌ Failed to stop SPAPS server:'), error.message);
|
|
1372
295
|
}
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
// Send to webhook endpoint
|
|
1376
|
-
const response = await fetch(`http://localhost:${this.port}/api/stripe/webhooks`, {
|
|
1377
|
-
method: 'POST',
|
|
1378
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1379
|
-
body: JSON.stringify(event)
|
|
1380
|
-
});
|
|
1381
|
-
|
|
1382
|
-
return response.ok;
|
|
1383
|
-
}
|
|
1384
|
-
|
|
1385
|
-
getPriceAmount(priceId) {
|
|
1386
|
-
const prices = {
|
|
1387
|
-
'price_local_validate': 50000,
|
|
1388
|
-
'price_local_prototype': 250000,
|
|
1389
|
-
'price_local_strategy': 1000000,
|
|
1390
|
-
'price_local_build': 2500000
|
|
1391
|
-
};
|
|
1392
|
-
return prices[priceId] || 10000;
|
|
296
|
+
throw error;
|
|
297
|
+
}
|
|
1393
298
|
}
|
|
1394
299
|
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
this.
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
// Get single product
|
|
1405
|
-
this.app.get('/api/admin/products/:id', (req, res) => {
|
|
1406
|
-
try {
|
|
1407
|
-
const product = this.adminManager.getProduct(req.params.id);
|
|
1408
|
-
if (!product) {
|
|
1409
|
-
return res.status(404).json({ success: false, error: 'Product not found' });
|
|
1410
|
-
}
|
|
1411
|
-
res.json({ success: true, data: product });
|
|
1412
|
-
} catch (error) {
|
|
1413
|
-
res.status(500).json({ success: false, error: error.message });
|
|
1414
|
-
}
|
|
1415
|
-
});
|
|
300
|
+
/**
|
|
301
|
+
* Tail logs from the API container
|
|
302
|
+
*/
|
|
303
|
+
tailLogs() {
|
|
304
|
+
const composeCmd = this.checkDockerCompose();
|
|
305
|
+
const cmdParts = composeCmd.split(' ');
|
|
306
|
+
const command = cmdParts[0]; // 'docker'
|
|
307
|
+
const subArgs = cmdParts.slice(1); // ['compose'] or []
|
|
308
|
+
const args = [...subArgs, '-f', this.composeFile, 'logs', '-f', '--tail=50', 'spaps-dev-api'];
|
|
1416
309
|
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
const product = this.adminManager.createProduct(req.body);
|
|
1421
|
-
res.json({ success: true, data: product });
|
|
1422
|
-
} catch (error) {
|
|
1423
|
-
res.status(400).json({ success: false, error: error.message });
|
|
1424
|
-
}
|
|
310
|
+
this.logProcess = spawn(command, args, {
|
|
311
|
+
cwd: this.repoRoot,
|
|
312
|
+
stdio: 'inherit'
|
|
1425
313
|
});
|
|
1426
314
|
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
const product = this.adminManager.updateProduct(req.params.id, req.body);
|
|
1431
|
-
res.json({ success: true, data: product });
|
|
1432
|
-
} catch (error) {
|
|
1433
|
-
res.status(400).json({ success: false, error: error.message });
|
|
1434
|
-
}
|
|
1435
|
-
});
|
|
1436
|
-
|
|
1437
|
-
// Delete product
|
|
1438
|
-
this.app.delete('/api/admin/products/:id', (req, res) => {
|
|
1439
|
-
try {
|
|
1440
|
-
const result = this.adminManager.deleteProduct(req.params.id);
|
|
1441
|
-
res.json(result);
|
|
1442
|
-
} catch (error) {
|
|
1443
|
-
res.status(400).json({ success: false, error: error.message });
|
|
315
|
+
this.logProcess.on('error', (error) => {
|
|
316
|
+
if (!this.json) {
|
|
317
|
+
console.error(chalk.red('❌ Failed to tail logs:'), error.message);
|
|
1444
318
|
}
|
|
1445
319
|
});
|
|
320
|
+
}
|
|
1446
321
|
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
this.app.post('/api/admin/orders', (req, res) => {
|
|
1455
|
-
try {
|
|
1456
|
-
const order = this.adminManager.createOrder(req.body);
|
|
1457
|
-
res.json({ success: true, data: order });
|
|
1458
|
-
} catch (error) {
|
|
1459
|
-
res.status(400).json({ success: false, error: error.message });
|
|
1460
|
-
}
|
|
1461
|
-
});
|
|
322
|
+
/**
|
|
323
|
+
* Clean shutdown
|
|
324
|
+
*/
|
|
325
|
+
async shutdown() {
|
|
326
|
+
if (this.logProcess) {
|
|
327
|
+
this.logProcess.kill();
|
|
328
|
+
}
|
|
1462
329
|
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
res.json({ success: true, data: order });
|
|
1468
|
-
} catch (error) {
|
|
1469
|
-
res.status(400).json({ success: false, error: error.message });
|
|
330
|
+
if (!this.detach) {
|
|
331
|
+
if (!this.json) {
|
|
332
|
+
console.log();
|
|
333
|
+
console.log(chalk.yellow('👋 Shutting down SPAPS server...'));
|
|
1470
334
|
}
|
|
1471
|
-
});
|
|
1472
335
|
|
|
1473
|
-
// Analytics dashboard
|
|
1474
|
-
this.app.get('/api/admin/analytics', (req, res) => {
|
|
1475
|
-
const analytics = this.adminManager.getAnalytics();
|
|
1476
|
-
res.json({ success: true, data: analytics });
|
|
1477
|
-
});
|
|
1478
|
-
|
|
1479
|
-
// Export data (for migration to production)
|
|
1480
|
-
this.app.get('/api/admin/export', (req, res) => {
|
|
1481
|
-
const data = this.adminManager.exportData();
|
|
1482
|
-
res.json({ success: true, data });
|
|
1483
|
-
});
|
|
1484
|
-
|
|
1485
|
-
// Import data
|
|
1486
|
-
this.app.post('/api/admin/import', (req, res) => {
|
|
1487
336
|
try {
|
|
1488
|
-
|
|
1489
|
-
res.json({ success: true, data: result });
|
|
1490
|
-
} catch (error) {
|
|
1491
|
-
res.status(400).json({ success: false, error: error.message });
|
|
1492
|
-
}
|
|
1493
|
-
});
|
|
1494
|
-
|
|
1495
|
-
// Admin UI Dashboard
|
|
1496
|
-
this.app.get('/admin', (req, res) => {
|
|
1497
|
-
const analytics = this.adminManager.getAnalytics();
|
|
1498
|
-
const products = this.adminManager.listProducts();
|
|
1499
|
-
|
|
1500
|
-
res.send(`
|
|
1501
|
-
<!DOCTYPE html>
|
|
1502
|
-
<html>
|
|
1503
|
-
<head>
|
|
1504
|
-
<title>SPAPS Admin - Local Mode</title>
|
|
1505
|
-
<style>
|
|
1506
|
-
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
1507
|
-
body { font-family: system-ui; background: #f5f5f5; }
|
|
1508
|
-
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 2rem; }
|
|
1509
|
-
.container { max-width: 1200px; margin: 0 auto; padding: 2rem; }
|
|
1510
|
-
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1.5rem; margin: 2rem 0; }
|
|
1511
|
-
.card { background: white; padding: 1.5rem; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
|
1512
|
-
.card h3 { margin-bottom: 1rem; color: #333; }
|
|
1513
|
-
.stat { font-size: 2rem; font-weight: bold; color: #667eea; }
|
|
1514
|
-
.label { color: #666; font-size: 0.9rem; }
|
|
1515
|
-
table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
|
|
1516
|
-
th, td { padding: 0.75rem; text-align: left; border-bottom: 1px solid #eee; }
|
|
1517
|
-
th { background: #f9f9f9; font-weight: 600; }
|
|
1518
|
-
.btn { background: #667eea; color: white; border: none; padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; }
|
|
1519
|
-
.btn:hover { background: #764ba2; }
|
|
1520
|
-
.badge { padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.85rem; }
|
|
1521
|
-
.badge-success { background: #10b981; color: white; }
|
|
1522
|
-
.badge-pending { background: #f59e0b; color: white; }
|
|
1523
|
-
.nav { display: flex; gap: 2rem; margin-bottom: 2rem; }
|
|
1524
|
-
.nav a { color: #667eea; text-decoration: none; font-weight: 500; }
|
|
1525
|
-
.nav a:hover { text-decoration: underline; }
|
|
1526
|
-
</style>
|
|
1527
|
-
</head>
|
|
1528
|
-
<body>
|
|
1529
|
-
<div class="header">
|
|
1530
|
-
<div class="container">
|
|
1531
|
-
<h1>🍠 SPAPS Admin Dashboard</h1>
|
|
1532
|
-
<p>Local Development Mode - Managing test data</p>
|
|
1533
|
-
</div>
|
|
1534
|
-
</div>
|
|
1535
|
-
|
|
1536
|
-
<div class="container">
|
|
1537
|
-
<div class="nav">
|
|
1538
|
-
<a href="/admin">Dashboard</a>
|
|
1539
|
-
<a href="/api/admin/products">Products API</a>
|
|
1540
|
-
<a href="/api/admin/orders">Orders API</a>
|
|
1541
|
-
<a href="/api/admin/export">Export Data</a>
|
|
1542
|
-
<a href="/docs">Documentation</a>
|
|
1543
|
-
</div>
|
|
1544
|
-
|
|
1545
|
-
<div class="grid">
|
|
1546
|
-
<div class="card">
|
|
1547
|
-
<h3>Total Revenue</h3>
|
|
1548
|
-
<div class="stat">$${(analytics.total_revenue / 100).toLocaleString()}</div>
|
|
1549
|
-
<div class="label">From ${analytics.completed_orders} orders</div>
|
|
1550
|
-
</div>
|
|
1551
|
-
|
|
1552
|
-
<div class="card">
|
|
1553
|
-
<h3>Active Products</h3>
|
|
1554
|
-
<div class="stat">${analytics.total_products}</div>
|
|
1555
|
-
<div class="label">Available for purchase</div>
|
|
1556
|
-
</div>
|
|
1557
|
-
|
|
1558
|
-
<div class="card">
|
|
1559
|
-
<h3>Customers</h3>
|
|
1560
|
-
<div class="stat">${analytics.total_customers}</div>
|
|
1561
|
-
<div class="label">Total registered</div>
|
|
1562
|
-
</div>
|
|
1563
|
-
|
|
1564
|
-
<div class="card">
|
|
1565
|
-
<h3>Recent Orders</h3>
|
|
1566
|
-
<div class="stat">${analytics.recent_orders}</div>
|
|
1567
|
-
<div class="label">Last 30 days</div>
|
|
1568
|
-
</div>
|
|
1569
|
-
</div>
|
|
1570
|
-
|
|
1571
|
-
<div class="card">
|
|
1572
|
-
<h3>Products</h3>
|
|
1573
|
-
<table>
|
|
1574
|
-
<thead>
|
|
1575
|
-
<tr>
|
|
1576
|
-
<th>Name</th>
|
|
1577
|
-
<th>Price</th>
|
|
1578
|
-
<th>Status</th>
|
|
1579
|
-
<th>Actions</th>
|
|
1580
|
-
</tr>
|
|
1581
|
-
</thead>
|
|
1582
|
-
<tbody>
|
|
1583
|
-
${products.map(p => `
|
|
1584
|
-
<tr>
|
|
1585
|
-
<td><strong>${p.name}</strong><br><small>${p.description}</small></td>
|
|
1586
|
-
<td>$${(p.price / 100).toLocaleString()}</td>
|
|
1587
|
-
<td><span class="badge badge-${p.active ? 'success' : 'pending'}">${p.active ? 'Active' : 'Inactive'}</span></td>
|
|
1588
|
-
<td>
|
|
1589
|
-
<button class="btn" onclick="editProduct('${p.id}')">Edit</button>
|
|
1590
|
-
</td>
|
|
1591
|
-
</tr>
|
|
1592
|
-
`).join('')}
|
|
1593
|
-
</tbody>
|
|
1594
|
-
</table>
|
|
1595
|
-
|
|
1596
|
-
<div style="margin-top: 1rem;">
|
|
1597
|
-
<button class="btn" onclick="addProduct()">+ Add Product</button>
|
|
1598
|
-
</div>
|
|
1599
|
-
</div>
|
|
1600
|
-
|
|
1601
|
-
<div class="card" style="margin-top: 2rem;">
|
|
1602
|
-
<h3>Recent Activity</h3>
|
|
1603
|
-
<table>
|
|
1604
|
-
<tbody>
|
|
1605
|
-
${analytics.recent_activity.map(a => `
|
|
1606
|
-
<tr>
|
|
1607
|
-
<td>${a.message}</td>
|
|
1608
|
-
<td style="text-align: right; color: #999;">${new Date(a.timestamp).toLocaleString()}</td>
|
|
1609
|
-
</tr>
|
|
1610
|
-
`).join('')}
|
|
1611
|
-
</tbody>
|
|
1612
|
-
</table>
|
|
1613
|
-
</div>
|
|
1614
|
-
|
|
1615
|
-
<div class="card" style="margin-top: 2rem; background: #fef3c7;">
|
|
1616
|
-
<h3>💡 Local Mode Notice</h3>
|
|
1617
|
-
<p style="margin-top: 1rem;">
|
|
1618
|
-
You're running in local development mode. All data is stored locally in <code>.spaps/</code> directory.
|
|
1619
|
-
</p>
|
|
1620
|
-
<p style="margin-top: 0.5rem;">
|
|
1621
|
-
To migrate to production:
|
|
1622
|
-
</p>
|
|
1623
|
-
<ol style="margin: 1rem 0 0 2rem;">
|
|
1624
|
-
<li>Export your data: <code>GET /api/admin/export</code></li>
|
|
1625
|
-
<li>Set up Stripe products in production</li>
|
|
1626
|
-
<li>Import customer data to production database</li>
|
|
1627
|
-
<li>Update environment variables</li>
|
|
1628
|
-
</ol>
|
|
1629
|
-
</div>
|
|
1630
|
-
</div>
|
|
1631
|
-
|
|
1632
|
-
<script>
|
|
1633
|
-
function editProduct(id) {
|
|
1634
|
-
window.location.href = '/api/admin/products/' + id;
|
|
1635
|
-
}
|
|
1636
|
-
|
|
1637
|
-
function addProduct() {
|
|
1638
|
-
const name = prompt('Product name:');
|
|
1639
|
-
if (!name) return;
|
|
1640
|
-
|
|
1641
|
-
const price = prompt('Price (in dollars):');
|
|
1642
|
-
if (!price) return;
|
|
1643
|
-
|
|
1644
|
-
fetch('/api/admin/products', {
|
|
1645
|
-
method: 'POST',
|
|
1646
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1647
|
-
body: JSON.stringify({
|
|
1648
|
-
name,
|
|
1649
|
-
description: prompt('Description:') || '',
|
|
1650
|
-
price: Math.round(parseFloat(price) * 100)
|
|
1651
|
-
})
|
|
1652
|
-
})
|
|
1653
|
-
.then(r => r.json())
|
|
1654
|
-
.then(data => {
|
|
1655
|
-
if (data.success) {
|
|
1656
|
-
alert('Product created!');
|
|
1657
|
-
location.reload();
|
|
1658
|
-
}
|
|
1659
|
-
});
|
|
1660
|
-
}
|
|
1661
|
-
</script>
|
|
1662
|
-
</body>
|
|
1663
|
-
</html>
|
|
1664
|
-
`);
|
|
1665
|
-
});
|
|
1666
|
-
}
|
|
1667
|
-
|
|
1668
|
-
setupCatchAll() {
|
|
1669
|
-
// Catch-all for unimplemented routes
|
|
1670
|
-
this.app.use((req, res) => {
|
|
1671
|
-
res.status(404).json({
|
|
1672
|
-
error: 'Not found',
|
|
1673
|
-
message: `Endpoint ${req.method} ${req.path} not implemented in local mode`,
|
|
1674
|
-
suggestion: 'Check /docs for available endpoints'
|
|
1675
|
-
});
|
|
1676
|
-
});
|
|
1677
|
-
}
|
|
337
|
+
this.runCompose(['down'], { silent: this.json });
|
|
1678
338
|
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
const server = this.app.listen(this.port, (err) => {
|
|
1682
|
-
if (err) {
|
|
1683
|
-
reject(err);
|
|
1684
|
-
} else {
|
|
1685
|
-
if (!this.json) {
|
|
1686
|
-
console.log();
|
|
1687
|
-
console.log(chalk.yellow('🍠 SPAPS Local Development Server'));
|
|
1688
|
-
console.log(chalk.green(`✨ Running at: http://localhost:${this.port}`));
|
|
1689
|
-
console.log(chalk.blue(`📝 Documentation: http://localhost:${this.port}/docs`));
|
|
1690
|
-
if (this.stripeMode === 'real') {
|
|
1691
|
-
console.log(chalk.magenta('💳 Stripe: Real test mode (live API calls)'));
|
|
1692
|
-
} else {
|
|
1693
|
-
console.log(chalk.gray('💳 Stripe: Mock mode (simulated responses)'));
|
|
1694
|
-
}
|
|
1695
|
-
console.log(chalk.dim(' Press Ctrl+C to stop'));
|
|
1696
|
-
console.log();
|
|
1697
|
-
}
|
|
1698
|
-
resolve(server);
|
|
339
|
+
if (!this.json) {
|
|
340
|
+
console.log(chalk.green('✅ Server stopped'));
|
|
1699
341
|
}
|
|
1700
|
-
})
|
|
1701
|
-
|
|
1702
|
-
// Handle errors
|
|
1703
|
-
server.on('error', (err) => {
|
|
342
|
+
} catch (error) {
|
|
1704
343
|
if (!this.json) {
|
|
1705
|
-
|
|
1706
|
-
console.error(chalk.red(`❌ Port ${this.port} is already in use`));
|
|
1707
|
-
console.log(chalk.yellow('💡 Try: spaps local --port 3301'));
|
|
1708
|
-
} else {
|
|
1709
|
-
console.error(chalk.red('❌ Server error:'), err.message);
|
|
1710
|
-
}
|
|
344
|
+
console.error(chalk.red('❌ Error during shutdown:'), error.message);
|
|
1711
345
|
}
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
});
|
|
346
|
+
}
|
|
347
|
+
}
|
|
1715
348
|
}
|
|
1716
349
|
}
|
|
1717
350
|
|
|
@@ -1721,11 +354,15 @@ module.exports = LocalServer;
|
|
|
1721
354
|
// Run directly if called as script
|
|
1722
355
|
if (require.main === module) {
|
|
1723
356
|
const server = new LocalServer();
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
console.log(chalk.yellow('\n👋 Shutting down...'));
|
|
357
|
+
|
|
358
|
+
// Handle Ctrl+C
|
|
359
|
+
process.on('SIGINT', async () => {
|
|
360
|
+
await server.shutdown();
|
|
1729
361
|
process.exit(0);
|
|
1730
362
|
});
|
|
363
|
+
|
|
364
|
+
server.start().catch((error) => {
|
|
365
|
+
console.error(chalk.red('❌ Fatal error:'), error.message);
|
|
366
|
+
process.exit(1);
|
|
367
|
+
});
|
|
1731
368
|
}
|