File size: 4,880 Bytes
cbaf159
 
 
 
a966957
cbaf159
 
 
 
 
 
 
 
 
8280d7d
cbaf159
 
 
6dd9bad
6282d86
cbaf159
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6282d86
 
 
 
cbaf159
 
a966957
 
 
 
 
 
cbaf159
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8280d7d
cbaf159
6dd9bad
ea8815c
 
 
 
 
 
 
 
6282d86
cbaf159
 
 
 
 
 
6dd9bad
 
 
 
 
 
 
 
 
 
 
cbaf159
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
import fastify, { FastifyInstance } from 'fastify';
import cors from '@fastify/cors';
import multipart from '@fastify/multipart';
import jwt from '@fastify/jwt';
import rateLimit from '@fastify/rate-limit';
import { prisma } from './services/prisma';
import { runWithTenant } from '@repo/database';
import { whatsappRoutes } from './routes/whatsapp';
import { studentRoutes } from './routes/student';
import { adminRoutes } from './routes/admin';
import { organizationRoutes } from './routes/organizations';
import { aiRoutes } from './routes/ai';
import { paymentRoutes } from './routes/payments';
import { analyticsRoutes } from './routes/analytics';
import { billingRoutes } from './routes/billing';
import { notificationRoutes } from './routes/notifications';
import { authRoutes } from './routes/auth';
import campaignRoutes from './routes/campaigns';
import { internalRoutes } from './routes/internal';
import { superAdminRoutes } from './routes/super-admin';
import { setupErrorHandler } from './utils/errors';
import { injectTenantConfig } from './middleware/tenant';
import { validateApiKey } from './middleware/validateApiKey';
import { verifyJwt } from './middleware/verifyJwt';
import { enforceOrgIsolation } from './middleware/enforceOrgIsolation';

export async function buildApp() {
    const server: FastifyInstance = fastify({
        logger: process.env.NODE_ENV === 'test' ? false : true,
        disableRequestLogging: process.env.NODE_ENV === 'production'
    });

    server.decorate('prisma', prisma);

    const corsOrigins = process.env.CORS_ORIGINS
        ? process.env.CORS_ORIGINS.split(',').map(o => o.trim())
        : ['https://admin.xamle.studio', 'https://xamle.studio'];

    await server.register(cors, {
        origin: corsOrigins,
        methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
        allowedHeaders: ['Content-Type', 'Authorization', 'x-api-key', 'x-organization-id'],
        credentials: true
    });

    await server.register(multipart, {
        limits: { fileSize: 10 * 1024 * 1024 }
    });

    await server.register(jwt, {
        secret: (() => {
            if (!process.env.JWT_SECRET) throw new Error('JWT_SECRET env var is required');
            return process.env.JWT_SECRET;
        })()
    });

    await server.register(rateLimit, {
        max: 200,
        timeWindow: '1 minute',
        keyGenerator: (req) => req.ip
    });

    setupErrorHandler(server);

    // Routes & Hooks
    server.register(async (scope) => {
        scope.addHook('preHandler', async (request, reply) => {
            const isApiKey = await validateApiKey(request);

            if (isApiKey) {
                request.organizationId = request.headers['x-organization-id'] as string;
            } else {
                await verifyJwt(request, reply);
                if (reply.sent) return;

                await enforceOrgIsolation(request, reply);
                if (reply.sent) return;
            }

            await injectTenantConfig(request, reply);
            if (reply.sent) return;

            if (request.organizationId) {
                return new Promise((resolve) => {
                    runWithTenant(request.organizationId as string, resolve);
                });
            }
        });

        scope.register(adminRoutes, { prefix: '/v1/admin' });
        scope.register(organizationRoutes, { prefix: '/v1/organizations' });
        scope.register(aiRoutes, { prefix: '/v1/ai' });
        scope.register(paymentRoutes, { prefix: '/v1/payments' });
        scope.register(analyticsRoutes, { prefix: '/v1/analytics' });
        scope.register(billingRoutes, { prefix: '/v1/billing' });
        scope.register(notificationRoutes, { prefix: '/v1/notifications' });
        scope.register(campaignRoutes, { prefix: '/v1/organizations' });
    });

    // Super-admin routes — JWT-only scope, deliberately NO runWithTenant so Prisma
    // queries are cross-org (tenant extension only filters when AsyncLocalStorage is set).
    server.register(async (scope) => {
        scope.addHook('preHandler', async (request, reply) => {
            await verifyJwt(request, reply);
        });
        scope.register(superAdminRoutes, { prefix: '/v1/super-admin' });
    });

    server.register(whatsappRoutes, { prefix: '/v1/whatsapp' });
    server.register(studentRoutes, { prefix: '/v1/student' });
    server.register(authRoutes, { prefix: '/v1/auth' });

    // Internal routes (worker→API calls, API-key only — no JWT, no tenant injection)
    server.register(async (scope) => {
        scope.addHook('preHandler', async (request, reply) => {
            const isApiKey = await validateApiKey(request);
            if (!isApiKey) {
                reply.code(401).send({ error: 'Unauthorized' });
            }
        });
        scope.register(internalRoutes);
    });

    return server;
}