CognxSafeTrack commited on
Commit ·
a37afe6
1
Parent(s): 295ae46
fix: resolve api build errors and multi-tenant unique key constraints
Browse files- apps/api/src/index.ts +13 -7
- apps/api/src/routes/auth.ts +12 -7
- apps/api/src/routes/organizations.ts +13 -33
- apps/api/src/routes/payments.ts +1 -1
- apps/api/src/routes/student.ts +8 -3
- apps/api/src/services/auth.ts +2 -3
- apps/api/src/services/organization.ts +19 -0
apps/api/src/index.ts
CHANGED
|
@@ -21,19 +21,24 @@ import { runWithTenant } from '@repo/database';
|
|
| 21 |
|
| 22 |
declare module 'fastify' {
|
| 23 |
interface FastifyInstance {
|
| 24 |
-
prisma: any;
|
| 25 |
}
|
| 26 |
interface FastifyRequest {
|
| 27 |
rawBody?: Buffer;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
user: {
|
| 29 |
id: string;
|
| 30 |
organizationId: string;
|
| 31 |
role: 'STUDENT' | 'ORG_MEMBER' | 'ORG_ADMIN' | 'SUPER_ADMIN' | 'ADMIN';
|
| 32 |
};
|
| 33 |
}
|
| 34 |
-
interface FastifyContextConfig {
|
| 35 |
-
requireAuth?: boolean;
|
| 36 |
-
}
|
| 37 |
}
|
| 38 |
|
| 39 |
|
|
@@ -144,15 +149,16 @@ server.register(async function guardedRoutes(scope) {
|
|
| 144 |
|
| 145 |
// 🏢 Multi-Tenant Enforcement
|
| 146 |
const requestedOrgId = request.headers['x-organization-id'] as string;
|
|
|
|
| 147 |
|
| 148 |
// Ensure requested Org matches User's Org (unless Super Admin)
|
| 149 |
-
if (
|
| 150 |
return reply.code(403).send({ error: 'Forbidden', message: 'You do not have access to this organization' });
|
| 151 |
}
|
| 152 |
|
| 153 |
// Auto-inject OrgId into request if missing and available in token
|
| 154 |
-
if (!requestedOrgId &&
|
| 155 |
-
request.headers['x-organization-id'] =
|
| 156 |
}
|
| 157 |
|
| 158 |
// Final check for routes requiring an organization
|
|
|
|
| 21 |
|
| 22 |
declare module 'fastify' {
|
| 23 |
interface FastifyInstance {
|
| 24 |
+
prisma: any;
|
| 25 |
}
|
| 26 |
interface FastifyRequest {
|
| 27 |
rawBody?: Buffer;
|
| 28 |
+
}
|
| 29 |
+
interface FastifyContextConfig {
|
| 30 |
+
requireAuth?: boolean;
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
declare module '@fastify/jwt' {
|
| 35 |
+
interface FastifyJWT {
|
| 36 |
user: {
|
| 37 |
id: string;
|
| 38 |
organizationId: string;
|
| 39 |
role: 'STUDENT' | 'ORG_MEMBER' | 'ORG_ADMIN' | 'SUPER_ADMIN' | 'ADMIN';
|
| 40 |
};
|
| 41 |
}
|
|
|
|
|
|
|
|
|
|
| 42 |
}
|
| 43 |
|
| 44 |
|
|
|
|
| 149 |
|
| 150 |
// 🏢 Multi-Tenant Enforcement
|
| 151 |
const requestedOrgId = request.headers['x-organization-id'] as string;
|
| 152 |
+
const user = request.user as any;
|
| 153 |
|
| 154 |
// Ensure requested Org matches User's Org (unless Super Admin)
|
| 155 |
+
if (user.role !== 'SUPER_ADMIN' && requestedOrgId && requestedOrgId !== user.organizationId) {
|
| 156 |
return reply.code(403).send({ error: 'Forbidden', message: 'You do not have access to this organization' });
|
| 157 |
}
|
| 158 |
|
| 159 |
// Auto-inject OrgId into request if missing and available in token
|
| 160 |
+
if (!requestedOrgId && user.organizationId) {
|
| 161 |
+
request.headers['x-organization-id'] = user.organizationId;
|
| 162 |
}
|
| 163 |
|
| 164 |
// Final check for routes requiring an organization
|
apps/api/src/routes/auth.ts
CHANGED
|
@@ -10,13 +10,18 @@ export async function authRoutes(fastify: FastifyInstance) {
|
|
| 10 |
schema: {
|
| 11 |
body: z.object({
|
| 12 |
email: z.string().email(),
|
| 13 |
-
password: z.string()
|
|
|
|
| 14 |
})
|
| 15 |
}
|
| 16 |
}, async (request, reply) => {
|
| 17 |
-
const { email, password } = request.body as any;
|
| 18 |
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
if (!user || !user.passwordHash) {
|
| 22 |
return reply.code(401).send({ error: 'Unauthorized', message: 'Invalid email or password' });
|
|
@@ -34,7 +39,7 @@ export async function authRoutes(fastify: FastifyInstance) {
|
|
| 34 |
role: user.role
|
| 35 |
});
|
| 36 |
|
| 37 |
-
logger.info(`[AUTH] User ${email} logged in successfully for org ${user.
|
| 38 |
|
| 39 |
return {
|
| 40 |
token,
|
|
@@ -44,14 +49,14 @@ export async function authRoutes(fastify: FastifyInstance) {
|
|
| 44 |
email: user.email,
|
| 45 |
role: user.role,
|
| 46 |
organizationId: user.organizationId,
|
| 47 |
-
organization: user.organization
|
| 48 |
}
|
| 49 |
};
|
| 50 |
});
|
| 51 |
|
| 52 |
// Get current user profile (guarded by JWT)
|
| 53 |
fastify.get('/me', async (request, reply) => {
|
| 54 |
-
const { id } = request.user;
|
| 55 |
|
| 56 |
const user = await fastify.prisma.user.findUnique({
|
| 57 |
where: { id },
|
|
@@ -69,7 +74,7 @@ export async function authRoutes(fastify: FastifyInstance) {
|
|
| 69 |
email: user.email,
|
| 70 |
role: user.role,
|
| 71 |
organizationId: user.organizationId,
|
| 72 |
-
organization: user.organization
|
| 73 |
}
|
| 74 |
};
|
| 75 |
});
|
|
|
|
| 10 |
schema: {
|
| 11 |
body: z.object({
|
| 12 |
email: z.string().email(),
|
| 13 |
+
password: z.string(),
|
| 14 |
+
organizationId: z.string().optional() // Optional for super admins, required for others
|
| 15 |
})
|
| 16 |
}
|
| 17 |
}, async (request, reply) => {
|
| 18 |
+
const { email, password, organizationId } = request.body as any;
|
| 19 |
|
| 20 |
+
// If no organizationId provided, we check for Super Admin in 'default-org-id'
|
| 21 |
+
// or we need to ask for it.
|
| 22 |
+
const orgId = organizationId || 'default-org-id';
|
| 23 |
+
|
| 24 |
+
const user = await AuthService.findUserByEmail(email, orgId);
|
| 25 |
|
| 26 |
if (!user || !user.passwordHash) {
|
| 27 |
return reply.code(401).send({ error: 'Unauthorized', message: 'Invalid email or password' });
|
|
|
|
| 39 |
role: user.role
|
| 40 |
});
|
| 41 |
|
| 42 |
+
logger.info(`[AUTH] User ${email} logged in successfully for org ${user.organizationId}`);
|
| 43 |
|
| 44 |
return {
|
| 45 |
token,
|
|
|
|
| 49 |
email: user.email,
|
| 50 |
role: user.role,
|
| 51 |
organizationId: user.organizationId,
|
| 52 |
+
organization: (user as any).organization
|
| 53 |
}
|
| 54 |
};
|
| 55 |
});
|
| 56 |
|
| 57 |
// Get current user profile (guarded by JWT)
|
| 58 |
fastify.get('/me', async (request, reply) => {
|
| 59 |
+
const { id } = request.user as any;
|
| 60 |
|
| 61 |
const user = await fastify.prisma.user.findUnique({
|
| 62 |
where: { id },
|
|
|
|
| 74 |
email: user.email,
|
| 75 |
role: user.role,
|
| 76 |
organizationId: user.organizationId,
|
| 77 |
+
organization: (user as any).organization
|
| 78 |
}
|
| 79 |
};
|
| 80 |
});
|
apps/api/src/routes/organizations.ts
CHANGED
|
@@ -4,42 +4,22 @@ import { z } from 'zod';
|
|
| 4 |
import { logger } from '../logger';
|
| 5 |
import { scheduleEmail } from '../services/queue';
|
| 6 |
import { decryptSecrets, encryptSecrets, invalidateOrganizationCache } from '../services/organization';
|
| 7 |
-
import {
|
| 8 |
import { AuthService } from '../services/auth';
|
| 9 |
-
import { EmailService } from '../services/email';
|
| 10 |
-
|
| 11 |
-
const ENCRYPTION_SECRET = process.env.ENCRYPTION_SECRET || 'default-secret-at-least-32-chars-long-!!!';
|
| 12 |
-
|
| 13 |
-
function encryptSecrets(data: any) {
|
| 14 |
-
if (data.systemUserToken && !data.systemUserToken.startsWith('enc:')) {
|
| 15 |
-
data.systemUserToken = encrypt(data.systemUserToken, ENCRYPTION_SECRET);
|
| 16 |
-
}
|
| 17 |
-
if (data.webhookSecret && !data.webhookSecret.startsWith('enc:')) {
|
| 18 |
-
data.webhookSecret = encrypt(data.webhookSecret, ENCRYPTION_SECRET);
|
| 19 |
-
}
|
| 20 |
-
return data;
|
| 21 |
-
}
|
| 22 |
-
|
| 23 |
-
function decryptSecrets(org: any) {
|
| 24 |
-
if (org.systemUserToken) org.systemUserToken = decrypt(org.systemUserToken, ENCRYPTION_SECRET);
|
| 25 |
-
if (org.webhookSecret) org.webhookSecret = decrypt(org.webhookSecret, ENCRYPTION_SECRET);
|
| 26 |
-
return org;
|
| 27 |
-
}
|
| 28 |
-
|
| 29 |
-
const OrganizationCreationSchema = OrganizationSchema.extend({
|
| 30 |
-
slug: z.string().min(3).regex(/^[a-z0-9-]+$/),
|
| 31 |
-
adminEmail: z.string().email(),
|
| 32 |
-
adminName: z.string().min(2)
|
| 33 |
-
});
|
| 34 |
-
|
| 35 |
-
const PersonalityConfigSchema = z.object({
|
| 36 |
-
botName: z.string().optional(),
|
| 37 |
-
coreMission: z.string().optional(),
|
| 38 |
-
toneDescription: z.string().optional(),
|
| 39 |
-
languageConstraints: z.string().optional()
|
| 40 |
-
});
|
| 41 |
|
| 42 |
export async function organizationRoutes(fastify: FastifyInstance) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
// 1. List all organizations
|
| 44 |
fastify.get('/', async () => {
|
| 45 |
const orgs = await prisma.organization.findMany({
|
|
|
|
| 4 |
import { logger } from '../logger';
|
| 5 |
import { scheduleEmail } from '../services/queue';
|
| 6 |
import { decryptSecrets, encryptSecrets, invalidateOrganizationCache } from '../services/organization';
|
| 7 |
+
import { OrganizationSchema } from '@repo/shared-types';
|
| 8 |
import { AuthService } from '../services/auth';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
export async function organizationRoutes(fastify: FastifyInstance) {
|
| 11 |
+
const OrganizationCreationSchema = OrganizationSchema.extend({
|
| 12 |
+
slug: z.string().min(3).regex(/^[a-z0-9-]+$/),
|
| 13 |
+
adminEmail: z.string().email(),
|
| 14 |
+
adminName: z.string().min(2)
|
| 15 |
+
});
|
| 16 |
+
|
| 17 |
+
const PersonalityConfigSchema = z.object({
|
| 18 |
+
botName: z.string().optional(),
|
| 19 |
+
coreMission: z.string().optional(),
|
| 20 |
+
toneDescription: z.string().optional(),
|
| 21 |
+
languageConstraints: z.string().optional()
|
| 22 |
+
});
|
| 23 |
// 1. List all organizations
|
| 24 |
fastify.get('/', async () => {
|
| 25 |
const orgs = await prisma.organization.findMany({
|
apps/api/src/routes/payments.ts
CHANGED
|
@@ -38,7 +38,7 @@ export async function paymentRoutes(fastify: FastifyInstance) {
|
|
| 38 |
user.id,
|
| 39 |
track.id,
|
| 40 |
track.stripePriceId,
|
| 41 |
-
user.phone
|
| 42 |
);
|
| 43 |
|
| 44 |
return { success: true, url: checkoutUrl };
|
|
|
|
| 38 |
user.id,
|
| 39 |
track.id,
|
| 40 |
track.stripePriceId,
|
| 41 |
+
user.phone || ''
|
| 42 |
);
|
| 43 |
|
| 44 |
return { success: true, url: checkoutUrl };
|
apps/api/src/routes/student.ts
CHANGED
|
@@ -12,6 +12,11 @@ export async function studentRoutes(fastify: FastifyInstance) {
|
|
| 12 |
// Returns user profile + enrollments + generated documents
|
| 13 |
fastify.get('/me', async (req, reply) => {
|
| 14 |
const query = req.query as { phone?: string };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
const phoneSchema = z.string().min(7);
|
| 17 |
const phoneResult = phoneSchema.safeParse(query.phone);
|
|
@@ -22,7 +27,7 @@ export async function studentRoutes(fastify: FastifyInstance) {
|
|
| 22 |
const phone = phoneResult.data.replace(/\s+/g, '').replace(/^\+/, '');
|
| 23 |
|
| 24 |
const user = await prisma.user.findUnique({
|
| 25 |
-
where: { phone },
|
| 26 |
include: {
|
| 27 |
enrollments: {
|
| 28 |
include: {
|
|
@@ -53,7 +58,7 @@ export async function studentRoutes(fastify: FastifyInstance) {
|
|
| 53 |
language: user.language,
|
| 54 |
activity: user.activity,
|
| 55 |
createdAt: user.createdAt,
|
| 56 |
-
enrollments: user.enrollments.map(e => ({
|
| 57 |
id: e.id,
|
| 58 |
trackId: e.trackId,
|
| 59 |
trackTitle: e.track.title,
|
|
@@ -64,7 +69,7 @@ export async function studentRoutes(fastify: FastifyInstance) {
|
|
| 64 |
startedAt: e.startedAt,
|
| 65 |
completedAt: e.completedAt,
|
| 66 |
})),
|
| 67 |
-
payments: user.payments,
|
| 68 |
// R2 document URLs are stored as Payment metadata (future enhancement)
|
| 69 |
};
|
| 70 |
});
|
|
|
|
| 12 |
// Returns user profile + enrollments + generated documents
|
| 13 |
fastify.get('/me', async (req, reply) => {
|
| 14 |
const query = req.query as { phone?: string };
|
| 15 |
+
const organizationId = req.headers['x-organization-id'] as string;
|
| 16 |
+
|
| 17 |
+
if (!organizationId) {
|
| 18 |
+
return reply.code(400).send({ error: 'x-organization-id header is required' });
|
| 19 |
+
}
|
| 20 |
|
| 21 |
const phoneSchema = z.string().min(7);
|
| 22 |
const phoneResult = phoneSchema.safeParse(query.phone);
|
|
|
|
| 27 |
const phone = phoneResult.data.replace(/\s+/g, '').replace(/^\+/, '');
|
| 28 |
|
| 29 |
const user = await prisma.user.findUnique({
|
| 30 |
+
where: { phone_organizationId: { phone, organizationId } },
|
| 31 |
include: {
|
| 32 |
enrollments: {
|
| 33 |
include: {
|
|
|
|
| 58 |
language: user.language,
|
| 59 |
activity: user.activity,
|
| 60 |
createdAt: user.createdAt,
|
| 61 |
+
enrollments: (user as any).enrollments.map((e: any) => ({
|
| 62 |
id: e.id,
|
| 63 |
trackId: e.trackId,
|
| 64 |
trackTitle: e.track.title,
|
|
|
|
| 69 |
startedAt: e.startedAt,
|
| 70 |
completedAt: e.completedAt,
|
| 71 |
})),
|
| 72 |
+
payments: (user as any).payments,
|
| 73 |
// R2 document URLs are stored as Payment metadata (future enhancement)
|
| 74 |
};
|
| 75 |
});
|
apps/api/src/services/auth.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
| 1 |
import bcrypt from 'bcrypt';
|
| 2 |
import { prisma } from './prisma';
|
| 3 |
-
import { logger } from '../logger';
|
| 4 |
|
| 5 |
const SALT_ROUNDS = 10;
|
| 6 |
|
|
@@ -22,9 +21,9 @@ export class AuthService {
|
|
| 22 |
/**
|
| 23 |
* Finds a user by email and includes organization context.
|
| 24 |
*/
|
| 25 |
-
static async findUserByEmail(email: string) {
|
| 26 |
return prisma.user.findUnique({
|
| 27 |
-
where: { email },
|
| 28 |
include: { organization: true }
|
| 29 |
});
|
| 30 |
}
|
|
|
|
| 1 |
import bcrypt from 'bcrypt';
|
| 2 |
import { prisma } from './prisma';
|
|
|
|
| 3 |
|
| 4 |
const SALT_ROUNDS = 10;
|
| 5 |
|
|
|
|
| 21 |
/**
|
| 22 |
* Finds a user by email and includes organization context.
|
| 23 |
*/
|
| 24 |
+
static async findUserByEmail(email: string, organizationId: string) {
|
| 25 |
return prisma.user.findUnique({
|
| 26 |
+
where: { email_organizationId: { email, organizationId } },
|
| 27 |
include: { organization: true }
|
| 28 |
});
|
| 29 |
}
|
apps/api/src/services/organization.ts
CHANGED
|
@@ -56,3 +56,22 @@ export async function invalidateOrganizationCache(organizationId: string, phoneN
|
|
| 56 |
logger.error('[ORG-SERVICE] Cache invalidation failed:', err);
|
| 57 |
}
|
| 58 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
logger.error('[ORG-SERVICE] Cache invalidation failed:', err);
|
| 57 |
}
|
| 58 |
}
|
| 59 |
+
|
| 60 |
+
const ENCRYPTION_SECRET = process.env.ENCRYPTION_SECRET || 'default-secret-at-least-32-chars-long-!!!';
|
| 61 |
+
import { encrypt, decrypt } from '@repo/shared-types';
|
| 62 |
+
|
| 63 |
+
export function encryptSecrets(data: any) {
|
| 64 |
+
if (data.systemUserToken && !data.systemUserToken.startsWith('enc:')) {
|
| 65 |
+
data.systemUserToken = encrypt(data.systemUserToken, ENCRYPTION_SECRET);
|
| 66 |
+
}
|
| 67 |
+
if (data.webhookSecret && !data.webhookSecret.startsWith('enc:')) {
|
| 68 |
+
data.webhookSecret = encrypt(data.webhookSecret, ENCRYPTION_SECRET);
|
| 69 |
+
}
|
| 70 |
+
return data;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
export function decryptSecrets(org: any) {
|
| 74 |
+
if (org.systemUserToken) org.systemUserToken = decrypt(org.systemUserToken, ENCRYPTION_SECRET);
|
| 75 |
+
if (org.webhookSecret) org.webhookSecret = decrypt(org.webhookSecret, ENCRYPTION_SECRET);
|
| 76 |
+
return org;
|
| 77 |
+
}
|