anhedral 0.1.5 → 0.1.7

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.
@@ -659,11 +659,6 @@ export interface RevenueCatEntitlement {
659
659
  cancelAtPeriodEnd?: boolean;
660
660
  }
661
661
 
662
- export interface GrantPromoResult {
663
- success: boolean;
664
- expiresAt: Date;
665
- }
666
-
667
662
  const RC_CACHE_TTL_MS = process.env.NODE_ENV === 'production' ? 60_000 : 10_000;
668
663
  const rcEntitlementCache = new LRUCache<RevenueCatEntitlement>({ maxSize: 100_000, ttlMs: RC_CACHE_TTL_MS });
669
664
  const inflightCached = new Map<string, Promise<RevenueCatEntitlement>>();
@@ -673,17 +668,6 @@ export function invalidateRcEntitlementCache(appUserId: string, entitlementId: s
673
668
  rcEntitlementCache.invalidate(\`\${entitlementId}:\${appUserId}\`);
674
669
  }
675
670
 
676
- function addMonthsClamped(date: Date, months: number): Date {
677
- const m = Number.isFinite(months) ? months : 0;
678
- if (m === 0) return new Date(date.getTime());
679
- const yr = date.getFullYear(), mo = date.getMonth(), day = date.getDate();
680
- const targetMo = mo + m;
681
- const targetYr = yr + Math.floor(targetMo / 12);
682
- const finalMo = ((targetMo % 12) + 12) % 12;
683
- const lastDay = new Date(targetYr, finalMo + 1, 0).getDate();
684
- return new Date(targetYr, finalMo, Math.min(day, lastDay), date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds());
685
- }
686
-
687
671
  interface RcSubscriberResponse {
688
672
  subscriber?: {
689
673
  entitlements?: Record<string, { expires_date?: string; purchase_date?: string; product_identifier?: string; will_renew?: boolean | null; unsubscribe_detected_at?: string | null }>;
@@ -778,23 +762,6 @@ export function verifyRevenueCatWebhookAuthorization(authHeader: string | undefi
778
762
  const normalize = (v: string) => v.trim().replace(/^bearer\\s+/i, '');
779
763
  return normalize(authHeader) === normalize(secret);
780
764
  }
781
-
782
- export async function grantPromotionalEntitlement(
783
- appUserId: string, entitlementId: string, months: number, apiKey: string
784
- ): Promise<GrantPromoResult> {
785
- const safeMonths = Number.isFinite(months) && months > 0 ? months : 1;
786
- const startsAt = new Date();
787
- const expiresAt = addMonthsClamped(startsAt, safeMonths);
788
- const url = \`https://api.revenuecat.com/v1/subscribers/\${encodeURIComponent(appUserId)}/entitlements/\${encodeURIComponent(entitlementId)}/promotional\`;
789
- const res = await fetchWithTimeout(url, {
790
- method: 'POST',
791
- headers: { Accept: 'application/json', 'Content-Type': 'application/json', Authorization: \`Bearer \${apiKey}\` },
792
- body: JSON.stringify({ duration: \`\${safeMonths}m\`, end_time_ms: expiresAt.getTime() }),
793
- timeout: 60_000,
794
- });
795
- if (!res.ok) throw new Error(\`RevenueCat promo grant failed: \${res.status} \${await res.text()}\`);
796
- return { success: true, expiresAt };
797
- }
798
765
  `);
799
766
  writeFile(path.join(dir, 'src/lib/r2.ts'), `import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
800
767
  import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
@@ -1446,27 +1413,13 @@ export class UserRepository {
1446
1413
  return { created: true };
1447
1414
  }
1448
1415
 
1449
- async updateAvatar(userId: string, input: {
1450
- avatarObjectKey: string;
1451
- avatarMimeType: string;
1452
- }): Promise<void> {
1453
- await this.db.update(users).set({
1454
- avatarObjectKey: input.avatarObjectKey,
1455
- avatarMimeType: input.avatarMimeType,
1456
- }).where(eq(users.id, userId));
1457
- authPluginCache.invalidate(\`auth:\${userId}\`);
1458
- }
1459
-
1460
- async getDashboardProfile(userId: string) {
1416
+ async getProfile(userId: string) {
1461
1417
  const rows = await this.db
1462
1418
  .select({
1463
1419
  id: users.id,
1464
1420
  email: users.email,
1465
1421
  displayName: users.displayName,
1466
1422
  profileImageUrl: users.profileImageUrl,
1467
- avatarObjectKey: users.avatarObjectKey,
1468
- avatarMimeType: users.avatarMimeType,
1469
- creditsBalance: users.creditsBalance,
1470
1423
  subscriptionTier: subscriptions.tier,
1471
1424
  subscriptionStatus: subscriptions.status,
1472
1425
  })
@@ -1646,77 +1599,27 @@ export class SubscriptionEventRepository {
1646
1599
  return results.reduce((acc, r) => { acc[r.eventType] = r.count; return acc; }, {} as Record<string, number>);
1647
1600
  }
1648
1601
  }
1649
- `);
1650
- writeFile(path.join(dir, 'src/repositories/PromoCodeRepository.ts'), `import { eq, and, lt, sql } from 'drizzle-orm';
1651
- import crypto from 'node:crypto';
1652
- import type { Database } from '../db/index.js';
1653
- import { promoCodes, promoRedemptions } from '../db/schema.js';
1654
- import type { PromoCodes } from '../db/schema.js';
1655
-
1656
- export interface ValidateCodeResult {
1657
- valid: boolean;
1658
- error?: string;
1659
- promoCode?: PromoCodes;
1660
- }
1661
-
1662
- export class PromoCodeRepository {
1663
- constructor(private db: Database) {}
1664
-
1665
- async validateCode(code: string, userId: string): Promise<ValidateCodeResult> {
1666
- const [promoCode] = await this.db.select().from(promoCodes)
1667
- .where(eq(promoCodes.code, code.toUpperCase())).limit(1);
1668
- if (!promoCode) return { valid: false, error: 'invalid_code' };
1669
-
1670
- const now = new Date();
1671
- if (promoCode.expiresAt && promoCode.expiresAt < now) return { valid: false, error: 'code_expired' };
1672
- if (promoCode.redeemedCount >= promoCode.maxRedemptions) return { valid: false, error: 'code_fully_used' };
1673
-
1674
- const [existing] = await this.db.select().from(promoRedemptions)
1675
- .where(and(eq(promoRedemptions.promoCodeId, promoCode.id), eq(promoRedemptions.userId, userId))).limit(1);
1676
- if (existing) return { valid: false, error: 'already_redeemed' };
1677
-
1678
- return { valid: true, promoCode };
1679
- }
1680
-
1681
- async recordRedemption(promoCodeId: string, userId: string, entitlementExpiresAt: Date): Promise<void> {
1682
- await this.db.insert(promoRedemptions).values({
1683
- id: crypto.randomUUID(), promoCodeId, userId, entitlementExpiresAt,
1684
- });
1685
- await this.db.update(promoCodes)
1686
- .set({ redeemedCount: sql\`\${promoCodes.redeemedCount} + 1\` })
1687
- .where(eq(promoCodes.id, promoCodeId));
1688
- }
1689
-
1690
- async findExpiredRedemptions(userId: string): Promise<typeof promoRedemptions.$inferSelect[]> {
1691
- return this.db.select().from(promoRedemptions)
1692
- .where(and(eq(promoRedemptions.userId, userId), lt(promoRedemptions.entitlementExpiresAt, new Date())));
1693
- }
1694
- }
1695
1602
  `);
1696
1603
  writeFile(path.join(dir, 'src/repositories/index.ts'), `import type { Database } from '../db/index.js';
1697
1604
  import { UserRepository } from './UserRepository.js';
1698
1605
  import { SubscriptionRepository } from './SubscriptionRepository.js';
1699
1606
  import { SubscriptionEventRepository } from './SubscriptionEventRepository.js';
1700
- import { PromoCodeRepository } from './PromoCodeRepository.js';
1701
1607
 
1702
1608
  export class Repositories {
1703
1609
  public readonly users: UserRepository;
1704
1610
  public readonly subscriptions: SubscriptionRepository;
1705
1611
  public readonly subscriptionEvents: SubscriptionEventRepository;
1706
- public readonly promoCodes: PromoCodeRepository;
1707
1612
 
1708
1613
  constructor(db: Database) {
1709
1614
  this.users = new UserRepository(db);
1710
1615
  this.subscriptions = new SubscriptionRepository(db);
1711
1616
  this.subscriptionEvents = new SubscriptionEventRepository(db);
1712
- this.promoCodes = new PromoCodeRepository(db);
1713
1617
  }
1714
1618
  }
1715
1619
 
1716
1620
  export { UserRepository } from './UserRepository.js';
1717
1621
  export { SubscriptionRepository } from './SubscriptionRepository.js';
1718
1622
  export { SubscriptionEventRepository } from './SubscriptionEventRepository.js';
1719
- export { PromoCodeRepository } from './PromoCodeRepository.js';
1720
1623
  export type { UserAuthData } from './UserRepository.js';
1721
1624
  export type { RecordEventParams } from './SubscriptionEventRepository.js';
1722
1625
  `);
@@ -1752,7 +1655,6 @@ export default healthRoutes;
1752
1655
  import { clerkClient } from '@clerk/fastify';
1753
1656
  import { AuthError } from '../errors/index.js';
1754
1657
  import { createAuthHook } from '../lib/routeHelpers.js';
1755
- import { createSignedAvatarUrl, isR2Configured, uploadAvatarToR2 } from '../lib/r2.js';
1756
1658
 
1757
1659
  function getDisplayName(input: {
1758
1660
  firstName?: string | null;
@@ -1765,28 +1667,6 @@ function getDisplayName(input: {
1765
1667
  || (input.email ? input.email.split('@')[0] : 'Builder');
1766
1668
  }
1767
1669
 
1768
- async function resolveAvatarUrl(input: {
1769
- avatarObjectKey?: string | null;
1770
- fallbackUrl?: string | null;
1771
- }) {
1772
- if (input.avatarObjectKey && isR2Configured()) {
1773
- try {
1774
- return await createSignedAvatarUrl(input.avatarObjectKey);
1775
- } catch {
1776
- return input.fallbackUrl ?? null;
1777
- }
1778
- }
1779
-
1780
- return input.fallbackUrl ?? null;
1781
- }
1782
-
1783
- function sanitizeFileName(fileName: string) {
1784
- return fileName
1785
- .toLowerCase()
1786
- .replace(/[^a-z0-9.-]+/g, '-')
1787
- .replace(/^-+|-+$/g, '');
1788
- }
1789
-
1790
1670
  const authRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
1791
1671
  fastify.get('/me', {
1792
1672
  preHandler: createAuthHook(fastify),
@@ -1802,16 +1682,12 @@ const authRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
1802
1682
  lastName: 'Builder',
1803
1683
  displayName: '${displayName} Demo',
1804
1684
  imageUrl: null,
1805
- avatarUrl: null,
1806
- creditsBalance: 250,
1807
- subscriptionTier: 'pro',
1808
- subscriptionStatus: 'active',
1809
1685
  },
1810
1686
  });
1811
1687
  }
1812
1688
 
1813
1689
  const clerkUser = await clerkClient.users.getUser(req.user.id);
1814
- const userData = await fastify.repos.users.getDashboardProfile(req.user.id);
1690
+ const userData = await fastify.repos.users.getProfile(req.user.id);
1815
1691
  const primaryEmail = clerkUser.emailAddresses.find(
1816
1692
  (e: { id: string }) => e.id === clerkUser.primaryEmailAddressId
1817
1693
  );
@@ -1822,10 +1698,6 @@ const authRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
1822
1698
  email,
1823
1699
  fallback: userData?.displayName ?? null,
1824
1700
  });
1825
- const avatarUrl = await resolveAvatarUrl({
1826
- avatarObjectKey: userData?.avatarObjectKey ?? null,
1827
- fallbackUrl: userData?.profileImageUrl ?? clerkUser.imageUrl ?? null,
1828
- });
1829
1701
 
1830
1702
  return reply.send({
1831
1703
  user: {
@@ -1835,10 +1707,6 @@ const authRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
1835
1707
  lastName: clerkUser.lastName,
1836
1708
  displayName,
1837
1709
  imageUrl: userData?.profileImageUrl ?? clerkUser.imageUrl,
1838
- avatarUrl,
1839
- creditsBalance: userData?.creditsBalance ?? 250,
1840
- subscriptionTier: userData?.subscriptionTier || 'free',
1841
- subscriptionStatus: userData?.subscriptionStatus || 'active',
1842
1710
  },
1843
1711
  });
1844
1712
  });
@@ -1850,48 +1718,6 @@ const authRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
1850
1718
  return reply.send({ success: true });
1851
1719
  });
1852
1720
 
1853
- fastify.post<{ Body: { base64: string; mimeType: string; fileName?: string } }>('/avatar', {
1854
- preHandler: createAuthHook(fastify),
1855
- }, async (req, reply) => {
1856
- if (!req.user?.id) throw AuthError.unauthorized();
1857
- if (!isR2Configured()) {
1858
- return reply.code(500).send({ error: 'r2_not_configured', message: 'Configure Cloudflare R2 before uploading avatars.' });
1859
- }
1860
-
1861
- const { base64, mimeType, fileName } = req.body;
1862
- if (!base64 || !mimeType) {
1863
- return reply.code(400).send({ error: 'invalid_payload', message: 'Avatar payload is incomplete.' });
1864
- }
1865
- if (!mimeType.startsWith('image/')) {
1866
- return reply.code(400).send({ error: 'invalid_mime_type', message: 'Only image uploads are supported.' });
1867
- }
1868
-
1869
- const normalizedBase64 = base64.includes(',') ? (base64.split(',')[1] ?? '') : base64;
1870
- const buffer = Buffer.from(normalizedBase64, 'base64');
1871
- if (buffer.length === 0) {
1872
- return reply.code(400).send({ error: 'invalid_payload', message: 'Avatar payload is empty.' });
1873
- }
1874
- if (buffer.length > 5 * 1024 * 1024) {
1875
- return reply.code(400).send({ error: 'file_too_large', message: 'Avatar uploads are capped at 5MB.' });
1876
- }
1877
-
1878
- const safeName = sanitizeFileName(fileName || 'avatar');
1879
- const objectKey = \`avatars/\${req.user.id}/\${Date.now()}-\${safeName || 'avatar'}\`;
1880
- await uploadAvatarToR2({
1881
- objectKey,
1882
- body: buffer,
1883
- contentType: mimeType,
1884
- });
1885
-
1886
- await fastify.repos.users.updateAvatar(req.user.id, {
1887
- avatarObjectKey: objectKey,
1888
- avatarMimeType: mimeType,
1889
- });
1890
-
1891
- const avatarUrl = await createSignedAvatarUrl(objectKey);
1892
- return reply.send({ ok: true, avatarUrl });
1893
- });
1894
-
1895
1721
  fastify.delete('/account', {
1896
1722
  preHandler: createAuthHook(fastify),
1897
1723
  }, async (req: FastifyRequest, reply: FastifyReply) => {
@@ -1908,8 +1734,8 @@ export default authRoutes;
1908
1734
  import clerkAuthPlugin from '../plugins/clerkAuth.js';
1909
1735
  import { SubscriptionService } from '../services/SubscriptionService.js';
1910
1736
  import { AuthError } from '../errors/index.js';
1911
- import { verifyRevenueCatWebhook, verifyRevenueCatWebhookAuthorization, grantPromotionalEntitlement } from '../lib/revenuecat.js';
1912
- import { createAuthHook, runBackgroundTask } from '../lib/routeHelpers.js';
1737
+ import { verifyRevenueCatWebhook, verifyRevenueCatWebhookAuthorization } from '../lib/revenuecat.js';
1738
+ import { createAuthHook } from '../lib/routeHelpers.js';
1913
1739
  import { CACHE_SECONDS } from '../lib/constants.js';
1914
1740
 
1915
1741
  const subscriptionRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
@@ -1949,50 +1775,6 @@ const subscriptionRoutes: FastifyPluginAsync = async (fastify: FastifyInstance)
1949
1775
  reply.header('Cache-Control', 'private, no-store');
1950
1776
  return reply.send(data);
1951
1777
  });
1952
-
1953
- app.post<{ Body: { code: string } }>('/redeem', {
1954
- preHandler: createAuthHook(app),
1955
- }, async (req, reply) => {
1956
- const userId = req.user?.id;
1957
- if (!userId) throw AuthError.unauthorized();
1958
- if (app.env.ANHEDRAL_DEMO === 'true') {
1959
- return { ok: true, expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString() };
1960
- }
1961
- const { code } = req.body;
1962
- const validation = await app.repos.promoCodes.validateCode(code, userId);
1963
- if (!validation.valid || !validation.promoCode) {
1964
- const msgs: Record<string, string> = { invalid_code: 'This code is not valid.', code_expired: 'This code has expired.', code_fully_used: 'This code has already been used.', already_redeemed: 'You have already redeemed this code.' };
1965
- return reply.code(400).send({ error: validation.error, message: msgs[validation.error!] || 'Invalid code.' });
1966
- }
1967
- const rcKey = app.env.RC_SECRET_API_KEY;
1968
- if (!rcKey) return reply.code(500).send({ error: 'configuration_error', message: 'Subscription service not configured.' });
1969
-
1970
- const { promoCode } = validation;
1971
- const entitlementId = app.env.RC_ENTITLEMENT_ID || 'pro';
1972
- try {
1973
- const currentSub = await app.repos.subscriptions.findByUserId(userId);
1974
- const result = await grantPromotionalEntitlement(userId, entitlementId, promoCode.months, rcKey);
1975
- await app.repos.promoCodes.recordRedemption(promoCode.id, userId, result.expiresAt);
1976
- const now = new Date();
1977
- await app.repos.subscriptions.upsert(userId, {
1978
- tier: 'pro', status: 'active', method: 'redeemed',
1979
- currentPeriodStart: now, currentPeriodEnd: result.expiresAt,
1980
- trialStart: null, trialEnd: null,
1981
- metadata: { redeemCode: promoCode.code, redeemCodeRedeemedAt: now.toISOString() },
1982
- });
1983
- runBackgroundTask(req, app.repos.subscriptionEvents.recordEvent({
1984
- userId, subscriptionId: currentSub?.id, eventType: 'promo_redeemed',
1985
- previousState: { tier: currentSub?.tier, status: currentSub?.status, method: currentSub?.method },
1986
- newState: { tier: 'pro', status: 'active', method: 'redeemed' },
1987
- periodStart: now, periodEnd: result.expiresAt,
1988
- metadata: { promoCode: promoCode.code },
1989
- }), 'promo_redeemed_event');
1990
- return { ok: true, expiresAt: result.expiresAt.toISOString() };
1991
- } catch (err) {
1992
- app.log.error({ msg: '[redeem:failed]', userId, error: (err as Error).message });
1993
- return reply.code(500).send({ error: 'redemption_failed', message: 'Failed to redeem code. Please try again.' });
1994
- }
1995
- });
1996
1778
  });
1997
1779
 
1998
1780
  await fastify.register(async (app) => {