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 CHANGED
@@ -21,19 +21,24 @@ import { runWithTenant } from '@repo/database';
21
 
22
  declare module 'fastify' {
23
  interface FastifyInstance {
24
- prisma: any; // Using any because of Prisma extensions complex types
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 (request.user.role !== 'SUPER_ADMIN' && requestedOrgId && requestedOrgId !== request.user.organizationId) {
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 && request.user.organizationId) {
155
- request.headers['x-organization-id'] = request.user.organizationId;
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
- const user = await AuthService.findUserByEmail(email);
 
 
 
 
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.organization?.name}`);
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 { FlowConfigSchema, OrganizationSchema, encrypt, decrypt } from '@repo/shared-types';
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
+ }