anhedral 0.1.6 → 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.
- package/README.md +97 -157
- package/dist/scaffold.js +2 -68
- package/dist/templates/backend.js +4 -222
- package/dist/templates/frontend.js +117 -411
- package/package.json +1 -1
|
@@ -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
|
|
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.
|
|
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
|
|
1912
|
-
import { createAuthHook
|
|
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) => {
|