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