anhedral 0.1.1 → 0.1.3
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/README.md +30 -26
- package/dist/cli.d.ts +12 -0
- package/dist/cli.js +86 -0
- package/dist/commands.d.ts +2 -3
- package/dist/commands.js +8 -32
- package/dist/index.js +8 -134
- package/dist/print.d.ts +8 -0
- package/dist/print.js +33 -0
- package/dist/scaffold.d.ts +3 -3
- package/dist/scaffold.js +770 -381
- package/dist/templates/backend.js +76 -190
- package/dist/templates/default-web.js +41 -27
- package/dist/templates/extension.js +200 -18
- package/dist/templates/frontend.js +11 -10
- package/dist/toolchain.d.ts +0 -3
- package/dist/toolchain.js +0 -9
- package/dist/util.js +40 -7
- package/package.json +9 -11
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { writeFile, exec } from '../util.js';
|
|
3
|
+
import { anhedralPrint } from '../print.js';
|
|
3
4
|
import { getBackendInstallCommands } from '../commands.js';
|
|
4
5
|
export async function scaffoldBackend(root, { projectName, displayName, frontendUrl }) {
|
|
5
|
-
const dir = path.join(root, '
|
|
6
|
-
|
|
6
|
+
const dir = path.join(root, 'apps/api');
|
|
7
|
+
anhedralPrint.section('Backend (Fastify)');
|
|
8
|
+
anhedralPrint.step('Writing backend source files');
|
|
7
9
|
writePackageJson(dir, projectName);
|
|
8
10
|
writeTsConfig(dir);
|
|
9
11
|
writeEslintConfig(dir);
|
|
@@ -12,7 +14,6 @@ export async function scaffoldBackend(root, { projectName, displayName, frontend
|
|
|
12
14
|
writeEnvFile(dir, frontendUrl);
|
|
13
15
|
writeDrizzleConfig(dir);
|
|
14
16
|
writeVitestConfig(dir);
|
|
15
|
-
writeVercelJson(dir);
|
|
16
17
|
writeTypes(dir);
|
|
17
18
|
writeDbFiles(dir);
|
|
18
19
|
writeErrorFiles(dir);
|
|
@@ -24,9 +25,16 @@ export async function scaffoldBackend(root, { projectName, displayName, frontend
|
|
|
24
25
|
writeAppAndIndex(dir, displayName);
|
|
25
26
|
writeVercelEntry(dir);
|
|
26
27
|
writeTestFiles(dir);
|
|
28
|
+
anhedralPrint.done('Backend source files written');
|
|
29
|
+
if (process.env.ANHEDRAL_SKIP_INSTALL === '1') {
|
|
30
|
+
anhedralPrint.info('Skipping backend dependency install (ANHEDRAL_SKIP_INSTALL=1)');
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
anhedralPrint.step('Installing backend dependencies');
|
|
27
34
|
for (const command of getBackendInstallCommands()) {
|
|
28
35
|
exec(command.cmd, dir);
|
|
29
36
|
}
|
|
37
|
+
anhedralPrint.done('Backend dependencies installed');
|
|
30
38
|
}
|
|
31
39
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
32
40
|
// Config files
|
|
@@ -42,7 +50,7 @@ function writePackageJson(dir, projectName) {
|
|
|
42
50
|
build: 'pnpm typecheck',
|
|
43
51
|
typecheck: 'tsc --noEmit',
|
|
44
52
|
'db:generate': 'drizzle-kit generate',
|
|
45
|
-
'db:migrate': 'pnpm
|
|
53
|
+
'db:migrate': 'pnpm --filter @anhedral/db db:migrate',
|
|
46
54
|
'db:studio': 'drizzle-kit studio',
|
|
47
55
|
'db:push': 'drizzle-kit push',
|
|
48
56
|
lint: 'eslint . --ext .js,.ts',
|
|
@@ -51,14 +59,17 @@ function writePackageJson(dir, projectName) {
|
|
|
51
59
|
},
|
|
52
60
|
keywords: [],
|
|
53
61
|
license: 'MIT',
|
|
62
|
+
dependencies: {
|
|
63
|
+
'@anhedral/db': 'workspace:*',
|
|
64
|
+
},
|
|
54
65
|
}, null, 2) + '\n');
|
|
55
66
|
}
|
|
56
67
|
function writeTsConfig(dir) {
|
|
57
68
|
writeFile(path.join(dir, 'tsconfig.json'), JSON.stringify({
|
|
58
69
|
compilerOptions: {
|
|
59
70
|
target: 'ESNext',
|
|
60
|
-
module: '
|
|
61
|
-
moduleResolution: '
|
|
71
|
+
module: 'ESNext',
|
|
72
|
+
moduleResolution: 'Bundler',
|
|
62
73
|
skipLibCheck: true,
|
|
63
74
|
rootDir: '.',
|
|
64
75
|
outDir: 'dist',
|
|
@@ -104,6 +115,7 @@ function writeEnvExample(dir, frontendUrl = 'http://localhost:8081') {
|
|
|
104
115
|
PORT=8787
|
|
105
116
|
NODE_ENV=development
|
|
106
117
|
LOG_LEVEL=info
|
|
118
|
+
ANHEDRAL_DEMO=false
|
|
107
119
|
|
|
108
120
|
# Database (NeonDB)
|
|
109
121
|
DATABASE_URL="postgresql://neondb_owner:***@***.neon.tech/neondb?sslmode=require"
|
|
@@ -134,6 +146,7 @@ function writeEnvFile(dir, frontendUrl = 'http://localhost:8081') {
|
|
|
134
146
|
PORT=8787
|
|
135
147
|
NODE_ENV=development
|
|
136
148
|
LOG_LEVEL=info
|
|
149
|
+
ANHEDRAL_DEMO=true
|
|
137
150
|
|
|
138
151
|
# Database (NeonDB)
|
|
139
152
|
DATABASE_URL="postgresql://user:pass@localhost:5432/app?sslmode=disable"
|
|
@@ -162,8 +175,8 @@ RC_OFFERING_ID=default
|
|
|
162
175
|
function writeDrizzleConfig(dir) {
|
|
163
176
|
writeFile(path.join(dir, 'drizzle.config.ts'), `import type { Config } from 'drizzle-kit';
|
|
164
177
|
export default {
|
|
165
|
-
schema: '
|
|
166
|
-
out: '
|
|
178
|
+
schema: '../../packages/db/src/schema.ts',
|
|
179
|
+
out: '../../packages/db/migrations',
|
|
167
180
|
dialect: 'postgresql',
|
|
168
181
|
dbCredentials: { url: process.env.DATABASE_URL! }
|
|
169
182
|
} satisfies Config;
|
|
@@ -184,13 +197,6 @@ export default defineConfig({
|
|
|
184
197
|
});
|
|
185
198
|
`);
|
|
186
199
|
}
|
|
187
|
-
function writeVercelJson(dir) {
|
|
188
|
-
writeFile(path.join(dir, 'vercel.json'), JSON.stringify({
|
|
189
|
-
version: 2,
|
|
190
|
-
builds: [{ src: 'api/index.ts', use: '@vercel/node' }],
|
|
191
|
-
routes: [{ src: '/(.*)', dest: 'api/index.ts' }],
|
|
192
|
-
}, null, 2) + '\n');
|
|
193
|
-
}
|
|
194
200
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
195
201
|
// Types
|
|
196
202
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -207,6 +213,7 @@ export interface AppEnv {
|
|
|
207
213
|
PORT: number;
|
|
208
214
|
NODE_ENV: string;
|
|
209
215
|
LOG_LEVEL: string;
|
|
216
|
+
ANHEDRAL_DEMO?: string | null;
|
|
210
217
|
CLERK_PUBLISHABLE_KEY?: string | null;
|
|
211
218
|
CLERK_SECRET_KEY?: string | null;
|
|
212
219
|
FRONTEND_URL?: string | null;
|
|
@@ -265,183 +272,12 @@ export {};
|
|
|
265
272
|
// Database
|
|
266
273
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
267
274
|
function writeDbFiles(dir) {
|
|
268
|
-
writeFile(path.join(dir, 'src/db/index.ts'), `
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
if (!process.env.DATABASE_URL) {
|
|
272
|
-
throw new Error('DATABASE_URL is not defined');
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
const sql = neon(process.env.DATABASE_URL);
|
|
276
|
-
export const db = drizzle(sql);
|
|
277
|
-
export type Database = typeof db;
|
|
275
|
+
writeFile(path.join(dir, 'src/db/index.ts'), `export { db } from '@anhedral/db';
|
|
276
|
+
export type { Database } from '@anhedral/db';
|
|
278
277
|
`);
|
|
279
|
-
writeFile(path.join(dir, 'src/db/schema.ts'), `
|
|
280
|
-
import type { InferSelectModel, InferInsertModel } from 'drizzle-orm';
|
|
281
|
-
|
|
282
|
-
export const SUBSCRIPTION_TIERS = ['free', 'pro'] as const;
|
|
283
|
-
export type SubscriptionTier = (typeof SUBSCRIPTION_TIERS)[number];
|
|
284
|
-
|
|
285
|
-
export const SUBSCRIPTION_STATUSES = ['active', 'expired', 'canceled'] as const;
|
|
286
|
-
export type SubscriptionStatus = (typeof SUBSCRIPTION_STATUSES)[number];
|
|
287
|
-
|
|
288
|
-
export const SUBSCRIPTION_METHODS = ['trialing', 'redeemed', 'paid'] as const;
|
|
289
|
-
export type SubscriptionMethod = (typeof SUBSCRIPTION_METHODS)[number];
|
|
290
|
-
|
|
291
|
-
export const SUBSCRIPTION_ORIGINS = ['web', 'apple', 'google'] as const;
|
|
292
|
-
export type SubscriptionOrigin = (typeof SUBSCRIPTION_ORIGINS)[number];
|
|
293
|
-
|
|
294
|
-
export const SUBSCRIPTION_EVENT_TYPES = [
|
|
295
|
-
'trial_started', 'trial_converted', 'trial_expired',
|
|
296
|
-
'initial_purchase', 'renewal', 'product_change',
|
|
297
|
-
'cancellation_scheduled', 'cancellation_unscheduled', 'subscription_expired', 'subscription_canceled',
|
|
298
|
-
'promo_redeemed', 'billing_issue', 'billing_recovered',
|
|
299
|
-
] as const;
|
|
300
|
-
export type SubscriptionEventType = (typeof SUBSCRIPTION_EVENT_TYPES)[number];
|
|
301
|
-
|
|
302
|
-
export type SubscriptionMetadata = {
|
|
303
|
-
revenueCatProductId?: string; lastWebhookUpdate?: string; cancelReason?: string;
|
|
304
|
-
redeemCode?: string; redeemCodeRedeemedAt?: string;
|
|
305
|
-
};
|
|
306
|
-
|
|
307
|
-
export type SubscriptionEventMetadata = {
|
|
308
|
-
revenueCatEventType?: string; revenueCatProductId?: string; promoCode?: string;
|
|
309
|
-
billingPeriod?: string; price?: { amount: number; currency: string };
|
|
310
|
-
store?: string; transactionId?: string; reason?: string;
|
|
311
|
-
};
|
|
312
|
-
|
|
313
|
-
export const PROMO_CODE_DURATIONS = [1, 6, 12] as const;
|
|
314
|
-
export type PromoCodeDuration = (typeof PROMO_CODE_DURATIONS)[number];
|
|
315
|
-
|
|
316
|
-
export const users = pgTable('users', {
|
|
317
|
-
id: text('id').primaryKey(),
|
|
318
|
-
email: text('email').notNull().unique(),
|
|
319
|
-
displayName: text('display_name'),
|
|
320
|
-
lastLoginAt: timestamp('last_login_at'),
|
|
321
|
-
profileImageUrl: text('profile_image_url'),
|
|
322
|
-
avatarObjectKey: text('avatar_object_key'),
|
|
323
|
-
avatarMimeType: text('avatar_mime_type'),
|
|
324
|
-
creditsBalance: integer('credits_balance').notNull().default(250),
|
|
325
|
-
createdAt: timestamp('created_at').$defaultFn(() => new Date()).notNull(),
|
|
326
|
-
});
|
|
327
|
-
|
|
328
|
-
export const subscriptions = pgTable('subscriptions', {
|
|
329
|
-
id: text('id').primaryKey(),
|
|
330
|
-
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }).unique(),
|
|
331
|
-
tier: text('tier').$type<SubscriptionTier>().notNull().default('free'),
|
|
332
|
-
status: text('status').$type<SubscriptionStatus>().notNull().default('active'),
|
|
333
|
-
method: text('method').$type<SubscriptionMethod>(),
|
|
334
|
-
origin: text('origin').$type<SubscriptionOrigin>(),
|
|
335
|
-
billingPeriod: text('billing_period'),
|
|
336
|
-
currentPeriodStart: timestamp('current_period_start'),
|
|
337
|
-
currentPeriodEnd: timestamp('current_period_end'),
|
|
338
|
-
canceledAt: timestamp('canceled_at'),
|
|
339
|
-
cancelAtPeriodEnd: boolean('cancel_at_period_end').notNull().default(false),
|
|
340
|
-
trialStart: timestamp('trial_start'),
|
|
341
|
-
trialEnd: timestamp('trial_end'),
|
|
342
|
-
dailyLimit: integer('daily_limit'),
|
|
343
|
-
metadata: jsonb('metadata').$type<SubscriptionMetadata>(),
|
|
344
|
-
createdAt: timestamp('created_at').$defaultFn(() => new Date()).notNull(),
|
|
345
|
-
updatedAt: timestamp('updated_at').$defaultFn(() => new Date()).notNull(),
|
|
346
|
-
}, (t) => [
|
|
347
|
-
index('subscriptions_user_idx').on(t.userId),
|
|
348
|
-
index('subscriptions_status_idx').on(t.status),
|
|
349
|
-
index('subscriptions_tier_idx').on(t.tier),
|
|
350
|
-
index('subscriptions_period_end_idx').on(t.currentPeriodEnd),
|
|
351
|
-
]);
|
|
352
|
-
|
|
353
|
-
export const subscriptionEvents = pgTable('subscription_events', {
|
|
354
|
-
id: text('id').primaryKey(),
|
|
355
|
-
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
|
356
|
-
subscriptionId: text('subscription_id').references(() => subscriptions.id, { onDelete: 'set null' }),
|
|
357
|
-
eventType: text('event_type').$type<SubscriptionEventType>().notNull(),
|
|
358
|
-
previousTier: text('previous_tier').$type<SubscriptionTier>(),
|
|
359
|
-
previousStatus: text('previous_status').$type<SubscriptionStatus>(),
|
|
360
|
-
previousMethod: text('previous_method').$type<SubscriptionMethod>(),
|
|
361
|
-
newTier: text('new_tier').$type<SubscriptionTier>(),
|
|
362
|
-
newStatus: text('new_status').$type<SubscriptionStatus>(),
|
|
363
|
-
newMethod: text('new_method').$type<SubscriptionMethod>(),
|
|
364
|
-
revenueCatEventType: text('revenuecat_event_type'),
|
|
365
|
-
revenueCatProductId: text('revenuecat_product_id'),
|
|
366
|
-
origin: text('origin').$type<SubscriptionOrigin>(),
|
|
367
|
-
periodStart: timestamp('period_start'),
|
|
368
|
-
periodEnd: timestamp('period_end'),
|
|
369
|
-
metadata: jsonb('metadata').$type<SubscriptionEventMetadata>(),
|
|
370
|
-
createdAt: timestamp('created_at').$defaultFn(() => new Date()).notNull(),
|
|
371
|
-
}, (t) => [
|
|
372
|
-
index('sub_events_user_idx').on(t.userId),
|
|
373
|
-
index('sub_events_user_created_idx').on(t.userId, t.createdAt),
|
|
374
|
-
index('sub_events_type_idx').on(t.eventType),
|
|
375
|
-
]);
|
|
376
|
-
|
|
377
|
-
export const trialClaims = pgTable('trial_claims', {
|
|
378
|
-
id: text('id').primaryKey(),
|
|
379
|
-
userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
|
|
380
|
-
email: text('email').notNull().unique(),
|
|
381
|
-
claimedAt: timestamp('claimed_at').$defaultFn(() => new Date()).notNull(),
|
|
382
|
-
}, (t) => [index('trial_claims_email_idx').on(t.email)]);
|
|
383
|
-
|
|
384
|
-
export const promoCodes = pgTable('promo_codes', {
|
|
385
|
-
id: text('id').primaryKey(),
|
|
386
|
-
code: text('code').notNull().unique(),
|
|
387
|
-
months: integer('months').$type<PromoCodeDuration>().notNull(),
|
|
388
|
-
maxRedemptions: integer('max_redemptions').notNull().default(1),
|
|
389
|
-
redeemedCount: integer('redeemed_count').notNull().default(0),
|
|
390
|
-
expiresAt: timestamp('expires_at'),
|
|
391
|
-
createdAt: timestamp('created_at').$defaultFn(() => new Date()).notNull(),
|
|
392
|
-
}, (t) => [index('promo_codes_code_idx').on(t.code)]);
|
|
393
|
-
|
|
394
|
-
export const promoRedemptions = pgTable('promo_redemptions', {
|
|
395
|
-
id: text('id').primaryKey(),
|
|
396
|
-
promoCodeId: text('promo_code_id').notNull().references(() => promoCodes.id, { onDelete: 'cascade' }),
|
|
397
|
-
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
|
398
|
-
redeemedAt: timestamp('redeemed_at').$defaultFn(() => new Date()).notNull(),
|
|
399
|
-
entitlementExpiresAt: timestamp('entitlement_expires_at').notNull(),
|
|
400
|
-
}, (t) => [
|
|
401
|
-
index('promo_redemptions_user_idx').on(t.userId),
|
|
402
|
-
index('promo_redemptions_code_idx').on(t.promoCodeId),
|
|
403
|
-
]);
|
|
404
|
-
|
|
405
|
-
export type Users = InferSelectModel<typeof users>;
|
|
406
|
-
export type NewUsers = InferInsertModel<typeof users>;
|
|
407
|
-
export type Subscriptions = InferSelectModel<typeof subscriptions>;
|
|
408
|
-
export type NewSubscriptions = InferInsertModel<typeof subscriptions>;
|
|
409
|
-
export type SubscriptionEvents = InferSelectModel<typeof subscriptionEvents>;
|
|
410
|
-
export type NewSubscriptionEvents = InferInsertModel<typeof subscriptionEvents>;
|
|
411
|
-
export type TrialClaims = InferSelectModel<typeof trialClaims>;
|
|
412
|
-
export type NewTrialClaims = InferInsertModel<typeof trialClaims>;
|
|
413
|
-
export type PromoCodes = InferSelectModel<typeof promoCodes>;
|
|
414
|
-
export type NewPromoCodes = InferInsertModel<typeof promoCodes>;
|
|
415
|
-
export type PromoRedemptions = InferSelectModel<typeof promoRedemptions>;
|
|
416
|
-
export type NewPromoRedemptions = InferInsertModel<typeof promoRedemptions>;
|
|
278
|
+
writeFile(path.join(dir, 'src/db/schema.ts'), `export * from '@anhedral/db/schema';
|
|
417
279
|
`);
|
|
418
|
-
writeFile(path.join(dir, 'src/db/migrate.ts'), `import
|
|
419
|
-
import { migrate } from 'drizzle-orm/neon-http/migrator';
|
|
420
|
-
import { neon } from '@neondatabase/serverless';
|
|
421
|
-
import dotenv from 'dotenv';
|
|
422
|
-
|
|
423
|
-
dotenv.config({ quiet: true });
|
|
424
|
-
|
|
425
|
-
const runMigrate = async () => {
|
|
426
|
-
if (!process.env.DATABASE_URL) {
|
|
427
|
-
throw new Error('DATABASE_URL is not defined');
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
const client = neon(process.env.DATABASE_URL);
|
|
431
|
-
const db = drizzle(client);
|
|
432
|
-
|
|
433
|
-
console.log('⏳ Running migrations...');
|
|
434
|
-
const start = Date.now();
|
|
435
|
-
await migrate(db, { migrationsFolder: './src/db/migrations' });
|
|
436
|
-
console.log('✅ Migrations completed in', Date.now() - start, 'ms');
|
|
437
|
-
process.exit(0);
|
|
438
|
-
};
|
|
439
|
-
|
|
440
|
-
runMigrate().catch((err) => {
|
|
441
|
-
console.error('❌ Migration failed');
|
|
442
|
-
console.error(err);
|
|
443
|
-
process.exit(1);
|
|
444
|
-
});
|
|
280
|
+
writeFile(path.join(dir, 'src/db/migrate.ts'), `import '@anhedral/db/migrate';
|
|
445
281
|
`);
|
|
446
282
|
}
|
|
447
283
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -1297,6 +1133,7 @@ const configPlugin: FastifyPluginAsync = async (fastify: FastifyInstance) => {
|
|
|
1297
1133
|
PORT: { type: 'number' },
|
|
1298
1134
|
NODE_ENV: { type: 'string', default: 'development' },
|
|
1299
1135
|
LOG_LEVEL: { type: 'string', default: 'info' },
|
|
1136
|
+
ANHEDRAL_DEMO: { type: 'string', default: 'false' },
|
|
1300
1137
|
CLERK_PUBLISHABLE_KEY: { type: 'string', nullable: true },
|
|
1301
1138
|
CLERK_SECRET_KEY: { type: 'string', nullable: true },
|
|
1302
1139
|
FRONTEND_URL: { type: 'string', nullable: true },
|
|
@@ -1366,6 +1203,15 @@ export const clerkAuthPlugin: FastifyPluginAsync = async (fastify: FastifyInstan
|
|
|
1366
1203
|
const authenticate = async (req: FastifyRequest, _reply: FastifyReply) => {
|
|
1367
1204
|
if (req.method === 'OPTIONS' || req.url.startsWith('/health')) return;
|
|
1368
1205
|
|
|
1206
|
+
if (fastify.env?.ANHEDRAL_DEMO === 'true') {
|
|
1207
|
+
req.user = {
|
|
1208
|
+
id: 'user_demo',
|
|
1209
|
+
subscriptionTier: 'pro',
|
|
1210
|
+
subscriptionStatus: 'active',
|
|
1211
|
+
};
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1369
1215
|
const authHeader = req.headers.authorization;
|
|
1370
1216
|
if (!authHeader?.startsWith('Bearer ')) throw AuthError.unauthorized();
|
|
1371
1217
|
|
|
@@ -1929,6 +1775,23 @@ const authRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
|
|
|
1929
1775
|
}, async (req: FastifyRequest, reply: FastifyReply) => {
|
|
1930
1776
|
if (!req.user?.id) throw AuthError.unauthorized();
|
|
1931
1777
|
|
|
1778
|
+
if (fastify.env.ANHEDRAL_DEMO === 'true') {
|
|
1779
|
+
return reply.send({
|
|
1780
|
+
user: {
|
|
1781
|
+
id: 'user_demo',
|
|
1782
|
+
email: 'demo@anhedral.dev',
|
|
1783
|
+
firstName: 'Demo',
|
|
1784
|
+
lastName: 'Builder',
|
|
1785
|
+
displayName: '${displayName} Demo',
|
|
1786
|
+
imageUrl: null,
|
|
1787
|
+
avatarUrl: null,
|
|
1788
|
+
creditsBalance: 250,
|
|
1789
|
+
subscriptionTier: 'pro',
|
|
1790
|
+
subscriptionStatus: 'active',
|
|
1791
|
+
},
|
|
1792
|
+
});
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1932
1795
|
const clerkUser = await clerkClient.users.getUser(req.user.id);
|
|
1933
1796
|
const userData = await fastify.repos.users.getDashboardProfile(req.user.id);
|
|
1934
1797
|
const primaryEmail = clerkUser.emailAddresses.find(
|
|
@@ -2048,6 +1911,19 @@ const subscriptionRoutes: FastifyPluginAsync = async (fastify: FastifyInstance)
|
|
|
2048
1911
|
}, async (req, reply) => {
|
|
2049
1912
|
const userId = req.user?.id;
|
|
2050
1913
|
if (!userId) throw AuthError.unauthorized();
|
|
1914
|
+
if (app.env.ANHEDRAL_DEMO === 'true') {
|
|
1915
|
+
reply.header('Cache-Control', 'private, no-store');
|
|
1916
|
+
return reply.send({
|
|
1917
|
+
pro: true,
|
|
1918
|
+
inTrial: false,
|
|
1919
|
+
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
|
1920
|
+
periodStart: new Date().toISOString(),
|
|
1921
|
+
periodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
|
1922
|
+
method: 'paid',
|
|
1923
|
+
managementUrl: 'https://app.revenuecat.com/',
|
|
1924
|
+
cancelAtPeriodEnd: false,
|
|
1925
|
+
});
|
|
1926
|
+
}
|
|
2051
1927
|
const refreshRaw = (req.query as unknown as { refresh?: unknown }).refresh;
|
|
2052
1928
|
const requestedRefresh = refreshRaw === true || refreshRaw === 'true' || refreshRaw === 1 || refreshRaw === '1';
|
|
2053
1929
|
const forceRefresh = Boolean(app.env.RC_SECRET_API_KEY) && requestedRefresh;
|
|
@@ -2061,6 +1937,9 @@ const subscriptionRoutes: FastifyPluginAsync = async (fastify: FastifyInstance)
|
|
|
2061
1937
|
}, async (req, reply) => {
|
|
2062
1938
|
const userId = req.user?.id;
|
|
2063
1939
|
if (!userId) throw AuthError.unauthorized();
|
|
1940
|
+
if (app.env.ANHEDRAL_DEMO === 'true') {
|
|
1941
|
+
return { ok: true, expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString() };
|
|
1942
|
+
}
|
|
2064
1943
|
const { code } = req.body;
|
|
2065
1944
|
const validation = await app.repos.promoCodes.validateCode(code, userId);
|
|
2066
1945
|
if (!validation.valid || !validation.promoCode) {
|
|
@@ -2299,6 +2178,13 @@ async function getApp(): Promise<FastifyInstance> {
|
|
|
2299
2178
|
|
|
2300
2179
|
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
|
2301
2180
|
const app = await getApp();
|
|
2181
|
+
if (req.url === '/api' || req.url === '/backend') {
|
|
2182
|
+
req.url = '/';
|
|
2183
|
+
} else if (req.url?.startsWith('/api/')) {
|
|
2184
|
+
req.url = req.url.slice('/api'.length);
|
|
2185
|
+
} else if (req.url?.startsWith('/backend/')) {
|
|
2186
|
+
req.url = req.url.slice('/backend'.length);
|
|
2187
|
+
}
|
|
2302
2188
|
app.server.emit('request', req, res);
|
|
2303
2189
|
}
|
|
2304
2190
|
`);
|
|
@@ -785,7 +785,7 @@ export async function POST(request: Request) {
|
|
|
785
785
|
return Response.json({ error: 'No email address found for the signed-in user.' }, { status: 400 });
|
|
786
786
|
}
|
|
787
787
|
|
|
788
|
-
await syncUserProfile({
|
|
788
|
+
const user = await syncUserProfile({
|
|
789
789
|
clerkUserId: userId,
|
|
790
790
|
email,
|
|
791
791
|
displayName,
|
|
@@ -793,6 +793,21 @@ export async function POST(request: Request) {
|
|
|
793
793
|
|
|
794
794
|
const origin = new URL(request.url).origin;
|
|
795
795
|
const stripe = getStripe();
|
|
796
|
+
let stripeCustomerId = user?.stripeCustomerId ?? null;
|
|
797
|
+
|
|
798
|
+
if (!stripeCustomerId) {
|
|
799
|
+
const customer = await stripe.customers.create({
|
|
800
|
+
email,
|
|
801
|
+
name: displayName,
|
|
802
|
+
metadata: {
|
|
803
|
+
clerkUserId: userId,
|
|
804
|
+
},
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
stripeCustomerId = customer.id;
|
|
808
|
+
await updateStripeCustomerForUser(userId, stripeCustomerId);
|
|
809
|
+
}
|
|
810
|
+
|
|
796
811
|
const session = await stripe.checkout.sessions.create({
|
|
797
812
|
mode: 'subscription',
|
|
798
813
|
line_items: [
|
|
@@ -802,7 +817,7 @@ export async function POST(request: Request) {
|
|
|
802
817
|
},
|
|
803
818
|
],
|
|
804
819
|
allow_promotion_codes: true,
|
|
805
|
-
|
|
820
|
+
customer: stripeCustomerId,
|
|
806
821
|
client_reference_id: userId,
|
|
807
822
|
success_url: \`\${origin}/dashboard?checkout=success\`,
|
|
808
823
|
cancel_url: \`\${origin}/dashboard?checkout=cancelled\`,
|
|
@@ -811,10 +826,6 @@ export async function POST(request: Request) {
|
|
|
811
826
|
},
|
|
812
827
|
});
|
|
813
828
|
|
|
814
|
-
if (typeof session.customer === 'string' && session.customer.length > 0) {
|
|
815
|
-
await updateStripeCustomerForUser(userId, session.customer);
|
|
816
|
-
}
|
|
817
|
-
|
|
818
829
|
return Response.json({ url: session.url });
|
|
819
830
|
}
|
|
820
831
|
`);
|
|
@@ -857,10 +868,15 @@ export async function POST(request: Request) {
|
|
|
857
868
|
import { HeaderUserMenu } from '@/components/dashboard/header-user-menu';
|
|
858
869
|
import { getClerkProfile } from '@/lib/auth/clerk';
|
|
859
870
|
import { getDashboardState } from '@/lib/app/dashboard';
|
|
871
|
+
import { clerkEnvReady } from '@/lib/demo-env';
|
|
860
872
|
import { buttonVariants } from '@/lib/ui/button';
|
|
861
873
|
import { cn } from '@/lib/utils';
|
|
862
874
|
|
|
863
875
|
export default async function DashboardPage() {
|
|
876
|
+
if (!clerkEnvReady) {
|
|
877
|
+
redirect('/');
|
|
878
|
+
}
|
|
879
|
+
|
|
864
880
|
const { userId } = await auth();
|
|
865
881
|
|
|
866
882
|
if (!userId) {
|
|
@@ -1543,25 +1559,9 @@ function hasRealValue(value: string | undefined) {
|
|
|
1543
1559
|
return !value.includes('***') && !value.includes('demo_placeholder');
|
|
1544
1560
|
}
|
|
1545
1561
|
|
|
1546
|
-
function isValidPublishableKey(value: string | undefined) {
|
|
1547
|
-
if (!hasRealValue(value)) {
|
|
1548
|
-
return false;
|
|
1549
|
-
}
|
|
1550
|
-
|
|
1551
|
-
return /^pk_(test|live)_[A-Za-z0-9]+$/.test(value ?? '');
|
|
1552
|
-
}
|
|
1553
|
-
|
|
1554
|
-
function isValidSecretKey(value: string | undefined) {
|
|
1555
|
-
if (!hasRealValue(value)) {
|
|
1556
|
-
return false;
|
|
1557
|
-
}
|
|
1558
|
-
|
|
1559
|
-
return /^sk_(test|live)_[A-Za-z0-9]+$/.test(value ?? '');
|
|
1560
|
-
}
|
|
1561
|
-
|
|
1562
1562
|
export const clerkEnvReady =
|
|
1563
|
-
|
|
1564
|
-
|
|
1563
|
+
hasRealValue(process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY) &&
|
|
1564
|
+
hasRealValue(process.env.CLERK_SECRET_KEY);
|
|
1565
1565
|
|
|
1566
1566
|
export const requiredEnvGroups = REQUIRED_ENV_GROUPS;
|
|
1567
1567
|
`);
|
|
@@ -1746,9 +1746,15 @@ export default async function HomePage() {
|
|
|
1746
1746
|
);
|
|
1747
1747
|
}
|
|
1748
1748
|
`);
|
|
1749
|
-
writeFile(path.join(root, 'app/sign-in/[[...sign-in]]/page.tsx'), `import {
|
|
1749
|
+
writeFile(path.join(root, 'app/sign-in/[[...sign-in]]/page.tsx'), `import { redirect } from 'next/navigation';
|
|
1750
|
+
import { SignInPanel } from '@/components/auth/sign-in-panel';
|
|
1751
|
+
import { clerkEnvReady } from '@/lib/demo-env';
|
|
1750
1752
|
|
|
1751
1753
|
export default function SignInPage() {
|
|
1754
|
+
if (!clerkEnvReady) {
|
|
1755
|
+
redirect('/');
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1752
1758
|
return (
|
|
1753
1759
|
<main className="min-h-screen bg-background">
|
|
1754
1760
|
<section className="container mx-auto flex min-h-screen max-w-5xl items-center justify-center px-4 py-8 sm:px-6">
|
|
@@ -1758,9 +1764,15 @@ export default function SignInPage() {
|
|
|
1758
1764
|
);
|
|
1759
1765
|
}
|
|
1760
1766
|
`);
|
|
1761
|
-
writeFile(path.join(root, 'app/sign-up/[[...sign-up]]/page.tsx'), `import {
|
|
1767
|
+
writeFile(path.join(root, 'app/sign-up/[[...sign-up]]/page.tsx'), `import { redirect } from 'next/navigation';
|
|
1768
|
+
import { SignUpPanel } from '@/components/auth/sign-up-panel';
|
|
1769
|
+
import { clerkEnvReady } from '@/lib/demo-env';
|
|
1762
1770
|
|
|
1763
1771
|
export default function SignUpPage() {
|
|
1772
|
+
if (!clerkEnvReady) {
|
|
1773
|
+
redirect('/');
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1764
1776
|
return (
|
|
1765
1777
|
<main className="min-h-screen bg-background">
|
|
1766
1778
|
<section className="container mx-auto flex min-h-screen max-w-5xl items-center justify-center px-4 py-8 sm:px-6">
|
|
@@ -1906,12 +1918,14 @@ export default function SignUpPage() {
|
|
|
1906
1918
|
import { fileURLToPath } from 'node:url';
|
|
1907
1919
|
|
|
1908
1920
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
1921
|
+
const monorepoRoot = path.resolve(__dirname, '../..');
|
|
1909
1922
|
|
|
1910
1923
|
/** @type {import('next').NextConfig} */
|
|
1911
1924
|
const nextConfig = {
|
|
1912
1925
|
reactCompiler: true,
|
|
1926
|
+
transpilePackages: ['@anhedral/db'],
|
|
1913
1927
|
turbopack: {
|
|
1914
|
-
root:
|
|
1928
|
+
root: monorepoRoot,
|
|
1915
1929
|
},
|
|
1916
1930
|
};
|
|
1917
1931
|
|